cadmpeg-asm 0.6.1

Parse and write Autodesk Shape Manager record streams.
Documentation
// SPDX-License-Identifier: Apache-2.0
//! Kernel header metadata shared by binary ASM, binary ACIS, and text streams.

use cadmpeg_core::decode::{DecodeContext, View};
use cadmpeg_core::CodecError;

/// Integer and reference payload width of a kernel stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefWidth {
    /// Four-byte signed integers and references.
    Four,
    /// Eight-byte signed integers and references.
    Eight,
}

impl RefWidth {
    /// Encoded payload size in bytes.
    #[must_use]
    pub const fn bytes(self) -> usize {
        match self {
            Self::Four => 4,
            Self::Eight => 8,
        }
    }
}

impl std::fmt::Display for RefWidth {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        self.bytes().fmt(f)
    }
}

/// Binary kernel framing with its mandatory integer and reference width.
#[derive(Debug, Clone, PartialEq)]
pub struct BinaryHeader {
    /// Integer and reference width used by the binary record stream.
    pub width: RefWidth,
    /// Encoding-independent kernel metadata.
    pub metadata: KernelHeader,
}

/// The recognized metadata fields of an ASM or ACIS model stream.
#[derive(Debug, Clone, PartialEq)]
pub struct KernelHeader {
    /// ACIS save-format version, encoded as `100 * major + minor`.
    pub save_format_version: Option<u32>,
    /// Entity-count word.
    pub entity_count: Option<u64>,
    /// Kernel flags word.
    pub flags: Option<u64>,
    /// Product family string.
    pub product_family: Option<String>,
    /// Product version string.
    pub product_version: Option<String>,
    /// Save date string.
    pub save_date: Option<String>,
    /// Kernel scale metadata slot. Coordinate decoding does not apply it.
    pub scale: Option<f64>,
    /// Absolute distance tolerance `resabs`, normalized to centimetres.
    pub linear: Option<f64>,
    /// Normal tolerance `resnor`.
    pub angular: Option<f64>,
}

/// Flag bit selecting the optional construction-history partition.
pub const HISTORY_PARTITION_FLAG: u64 = 1;

/// Flag bits 1 to 7, which hold the save format's revision number.
pub const FORMAT_REVISION_FLAGS: u64 = 0xfe;

impl KernelHeader {
    /// Product fields whose independently framed payload could not be decoded.
    pub fn unreadable_product_fields(&self) -> impl Iterator<Item = &'static str> + '_ {
        [
            ("product family", self.product_family.as_deref()),
            ("product version", self.product_version.as_deref()),
            ("save date", self.save_date.as_deref()),
        ]
        .into_iter()
        .filter_map(|(name, value)| value.is_none().then_some(name))
    }

    /// Major component of the encoded ACIS save-format version.
    pub fn save_format_major(&self) -> Option<u32> {
        self.save_format_version.map(|version| version / 100)
    }

    /// Minor component of the encoded ACIS save-format version.
    pub fn save_format_minor(&self) -> Option<u32> {
        self.save_format_version.map(|version| version % 100)
    }

    /// Whether the stream header declares a construction-history partition.
    pub fn has_history_partition(&self) -> bool {
        self.flags
            .is_some_and(|flags| flags & HISTORY_PARTITION_FLAG != 0)
    }
}

pub(crate) struct HeaderRegion {
    pub(crate) strings: [Option<String>; 3],
    pub(crate) doubles: [Option<f64>; 3],
}

pub(crate) fn read_string_region(
    ctx: &DecodeContext<'_>,
    bytes: &[u8],
    start: usize,
) -> Result<HeaderRegion, CodecError> {
    let mut cur = start;
    let mut strings = [None, None, None];
    for slot in &mut strings {
        match read_u8_string_span(bytes, cur) {
            Some((value, next)) => {
                if let Ok(value) = std::str::from_utf8(value) {
                    *slot =
                        Some(ctx.copy_retained_text(value, "retain kernel header product string")?);
                }
                cur = next;
            }
            None => break,
        }
    }
    let mut doubles = [None, None, None];
    for slot in &mut doubles {
        match read_tagged_f64(bytes, cur) {
            Some((value, next)) => {
                *slot = Some(value);
                cur = next;
            }
            None => break,
        }
    }
    Ok(HeaderRegion { strings, doubles })
}

/// Locate tagged header values without materializing a second copy.
pub(crate) fn scan_string_region(bytes: &[u8], start: usize) -> (usize, usize, usize) {
    let mut cur = start;
    let mut strings = 0;
    while strings < 3 {
        let Some((_, next)) = read_u8_string_span(bytes, cur) else {
            break;
        };
        strings += 1;
        cur = next;
    }
    let mut doubles = 0;
    while doubles < 3 {
        let Some((_, next)) = read_tagged_f64(bytes, cur) else {
            break;
        };
        doubles += 1;
        cur = next;
    }
    (strings, doubles, cur)
}

fn read_u8_string_span(bytes: &[u8], at: usize) -> Option<(&[u8], usize)> {
    if *bytes.get(at)? != 0x07 {
        return None;
    }
    let len = usize::from(*bytes.get(at + 1)?);
    let start = at + 2;
    let value = bytes.get(start..start + len)?;
    Some((value, start + len))
}

fn read_tagged_f64(bytes: &[u8], at: usize) -> Option<(f64, usize)> {
    if *bytes.get(at)? != 0x06 {
        return None;
    }
    let value = View::f64_le_at(bytes, at + 1)?;
    Some((value, at + 9))
}

#[cfg(test)]
mod tests {
    #[test]
    fn binary_product_encoding_does_not_control_framing_or_other_fields() {
        for (magic, header_len) in [
            (
                b"ASM BinaryFile4".as_slice(),
                crate::layout::asmheader_binaryfile4::LEN,
            ),
            (
                b"ASM BinaryFile8".as_slice(),
                crate::layout::asmheader_binaryfile8::LEN,
            ),
            (
                b"ACIS BinaryFile".as_slice(),
                crate::layout::acisheader_binaryfile4::LEN,
            ),
        ] {
            let mut original = magic.to_vec();
            original.resize(header_len, 0);
            let mut positions = Vec::new();
            for value in [b"Family".as_slice(), b"Version", b"Date"] {
                positions.push(original.len() + 2);
                original.extend_from_slice(&[7, u8::try_from(value.len()).unwrap()]);
                original.extend_from_slice(value);
            }
            for value in [10.0_f64, 0.125, 0.25] {
                original.push(6);
                original.extend_from_slice(&value.to_le_bytes());
            }
            let record_start = original.len();
            original.extend_from_slice(b"records");
            let start = |bytes: &[u8]| {
                if magic.starts_with(b"ASM") {
                    crate::asm_header::record_stream_start(bytes)
                } else {
                    crate::acis_header::record_stream_start(bytes)
                }
            };
            for (index, position) in positions.iter().enumerate() {
                let mut bytes = original.clone();
                bytes[*position] = 0xff;
                assert_eq!(start(&bytes), Some(record_start));
                let ctx = cadmpeg_test_support::service_decode_context();
                let header = if magic.starts_with(b"ASM") {
                    crate::asm_header::parse(&ctx, &bytes)
                } else {
                    crate::acis_header::parse(&ctx, &bytes)
                }
                .unwrap()
                .unwrap();
                assert_eq!(header.metadata.linear, Some(0.125));
                assert_eq!(header.metadata.angular, Some(0.25));
                assert_eq!(
                    header
                        .metadata
                        .unreadable_product_fields()
                        .collect::<Vec<_>>(),
                    [["product family"], ["product version"], ["save date"]][index]
                );
                assert_eq!(&bytes[record_start..], &original[record_start..]);
            }
            let mut truncated = original[..record_start].to_vec();
            truncated[positions[0] - 1] = u8::MAX;
            assert_eq!(
                start(&truncated),
                None,
                "untrusted byte boundaries stay fatal"
            );
        }
    }

    #[test]
    fn binary_header_product_string_refuses_retained_limit() {
        use cadmpeg_core::decode::{DecodeArena, DecodeContext, DecodePolicy, ResourceDimension};
        use cadmpeg_core::CodecError;

        let bytes = [0x07, 3, b'a', b'b', b'c'];
        let arena = DecodeArena::new();
        let mut policy = DecodePolicy::service();
        policy.limits.max_retained_bytes = 2;
        let (ctx, _) = DecodeContext::from_root_bytes(&bytes, &arena, &policy)
            .expect("source fits input limit");
        let Err(error) = super::read_string_region(&ctx, &bytes, 0) else {
            panic!("product string must exceed retained limit");
        };
        let CodecError::ResourceLimit(refusal) = error else {
            panic!("expected resource refusal, got {error:?}");
        };
        assert_eq!(refusal.dimension, ResourceDimension::RetainedBytes);
        assert_eq!(refusal.operation, "retain kernel header product string");
    }

    #[test]
    fn partial_binary_headers_retain_linear_tolerance_without_angular() {
        for (magic, header_len) in [
            (
                b"ASM BinaryFile4".as_slice(),
                crate::layout::asmheader_binaryfile4::LEN,
            ),
            (
                b"ASM BinaryFile8".as_slice(),
                crate::layout::asmheader_binaryfile8::LEN,
            ),
            (
                b"ACIS BinaryFile".as_slice(),
                crate::layout::acisheader_binaryfile4::LEN,
            ),
        ] {
            let mut bytes = magic.to_vec();
            bytes.resize(header_len, 0);
            bytes.extend_from_slice(&[7, 0, 7, 0, 7, 0]);
            for value in [1.0_f64, 0.125] {
                bytes.push(6);
                bytes.extend_from_slice(&value.to_le_bytes());
            }
            let parse = if magic.starts_with(b"ASM") {
                crate::asm_header::parse
            } else {
                crate::acis_header::parse
            };
            let header = parse(&cadmpeg_test_support::service_decode_context(), &bytes)
                .expect("service policy admits header")
                .expect("recognized partial header");
            assert_eq!(header.metadata.linear, Some(0.125));
            assert_eq!(header.metadata.angular, None);
            bytes.push(6);
            bytes.extend_from_slice(&0.25_f64.to_le_bytes());
            let header = parse(&cadmpeg_test_support::service_decode_context(), &bytes)
                .expect("service policy admits header")
                .expect("recognized complete header");
            assert_eq!(header.metadata.linear, Some(0.125));
            assert_eq!(header.metadata.angular, Some(0.25));
        }
    }
}