cadmpeg-asm 0.6.1

Parse and write Autodesk Shape Manager record streams.
Documentation
// SPDX-License-Identifier: Apache-2.0
//! Parse ASM `BinaryFile4` and `BinaryFile8` headers and locate history data.
//!
//! [`parse`] reads format words, product strings, scale, and tolerances.
//! [`record_stream_start`] locates the first SAB record, and
//! [`solved_record_limit`] returns the exact boundary between solved BREP
//! records and the optional construction-history partition.
//!
//! `BinaryFile8` layout: `0..15` magic `ASM BinaryFile8`, `15..19`
//! little-endian u32 ACIS save-format version, `19..31` zero, `31..39`
//! little-endian u64 entity count, `39..47` little-endian u64 flags. Bit 0 marks a history
//! partition and bits 1 to 7 hold the save format's revision number. The three
//! `0x07`-tagged UTF-8 strings (`product_family`,
//! `product_version_string`, `save_date`) begin at byte 47.
//!
//! `BinaryFile4` layout: `0..15` magic `ASM BinaryFile4`, then four
//! little-endian u32 words: `15..19` ACIS save-format version, `19..23`
//! record count, `23..27` entity count, `27..31` flags, with the same bit
//! assignment as `BinaryFile8`. The string region begins at byte 31.
//!
//! In both widths, three `0x06`-tagged little-endian f64s (`scale`, `resabs`,
//! `resnor`) follow the strings, then the SAB record stream.

use crate::kernel_header::{
    read_string_region, scan_string_region, BinaryHeader, HeaderRegion, KernelHeader, RefWidth,
};
use crate::layout::asmheader_binaryfile4 as bf4;
use crate::layout::asmheader_binaryfile8 as bf8;
use cadmpeg_core::decode::{DecodeContext, View};
use cadmpeg_core::CodecError;

/// The ASM magic prefix common to both widths.
const MAGIC_PREFIX: &[u8] = b"ASM BinaryFile";

/// Returns `true` if `bytes` begins with an ASM `BinaryFile` magic: the
/// 15-byte prefix `ASM BinaryFile4` or `ASM BinaryFile8`. Byte 15 is the
/// save-format version's low byte in both widths, not part of the magic.
pub fn has_asm_magic(bytes: &[u8]) -> bool {
    declared_width(bytes).is_some()
}

fn declared_width(bytes: &[u8]) -> Option<RefWidth> {
    if bytes.len() < 16 || !bytes.starts_with(MAGIC_PREFIX) {
        return None;
    }
    match bytes[14] {
        b'4' => Some(RefWidth::Four),
        b'8' => Some(RefWidth::Eight),
        _ => None,
    }
}

/// Stored record-count word of an ASM `BinaryFile4` header.
pub fn record_count(bytes: &[u8]) -> Option<u32> {
    if declared_width(bytes) != Some(RefWidth::Four) {
        return None;
    }
    View::u32_le_at(bytes, bf4::RECORD_COUNT)
}

/// Parse the header of a decompressed ASM stream. Returns `None` if the magic
/// is absent. Fields that cannot be read (short stream or unexpected tags) are
/// left `None` rather than guessed.
pub fn parse(ctx: &DecodeContext<'_>, bytes: &[u8]) -> Result<Option<BinaryHeader>, CodecError> {
    let Some(width) = declared_width(bytes) else {
        return Ok(None);
    };
    let mut header = KernelHeader {
        save_format_version: None,
        entity_count: None,
        flags: None,
        product_family: None,
        product_version: None,
        save_date: None,
        scale: None,
        linear: None,
        angular: None,
    };

    match width {
        RefWidth::Eight => {
            header.save_format_version = View::u32_le_at(bytes, bf8::SAVE_FORMAT_VERSION);
            header.entity_count = View::u64_le_at(bytes, bf8::ENTITY_COUNT);
            header.flags = View::u64_le_at(bytes, bf8::FLAGS);
        }
        RefWidth::Four => {
            header.save_format_version = View::u32_le_at(bytes, bf4::SAVE_FORMAT_VERSION);
            header.entity_count = View::u32_le_at(bytes, bf4::ENTITY_COUNT).map(u64::from);
            header.flags = View::u32_le_at(bytes, bf4::FLAGS).map(u64::from);
        }
    }

    // The three product strings and three tolerance doubles follow the fixed
    // word block. Parse them by tag rather than fixed offset so differing
    // string lengths do not desync the doubles.
    let start = match width {
        RefWidth::Four => bf4::LEN,
        RefWidth::Eight => bf8::LEN,
    };
    let HeaderRegion {
        strings: [family, version, date],
        doubles: [scale, linear, angular],
    } = read_string_region(ctx, bytes, start)?;
    header.product_family = family;
    header.product_version = version;
    header.save_date = date;
    header.scale = scale;
    header.linear = linear;
    header.angular = angular;

    Ok(Some(BinaryHeader {
        width,
        metadata: header,
    }))
}

/// Byte offset at which the SAB record stream begins, i.e. the first byte after
/// the fixed header words, the three `0x07`-tagged product strings, and the
/// three `0x06`-tagged tolerance doubles. The record stream's first record is
/// the `asmheader`, which is `RecordTable` index 0. Returns `None` for streams
/// without a recognized header layout.
pub fn record_stream_start(bytes: &[u8]) -> Option<usize> {
    let width = declared_width(bytes)?;
    record_stream_start_with_width(bytes, width)
}

/// Byte offset at which the SAB record stream begins, using an already-parsed
/// ASM header.
pub fn record_stream_start_with_header(bytes: &[u8], header: &BinaryHeader) -> Option<usize> {
    record_stream_start_with_width(bytes, header.width)
}

fn record_stream_start_with_width(bytes: &[u8], width: RefWidth) -> Option<usize> {
    let start = match width {
        RefWidth::Eight => bf8::LEN,
        RefWidth::Four => bf4::LEN,
    };
    let (strings, doubles, cur) = scan_string_region(bytes, start);
    (strings == 3 && doubles == 3).then_some(cur)
}

/// Exact byte boundary between solved BREP records and construction history.
///
/// The SAB framer establishes record boundaries from token widths and subtype
/// depth. A current history stream contains a `Begin-of-ASM-History-Data`
/// preamble record; its record start is the partition boundary. Earlier
/// streams can begin directly with `delta_state`; for those, the first
/// unframed record after the solved sequence is accepted only when its exact
/// identifier token is `delta_state`. Raw substring search is deliberately not
/// used because string payloads can contain the same bytes.
pub fn solved_record_limit(
    ctx: &DecodeContext<'_>,
    bytes: &[u8],
) -> Result<Option<usize>, CodecError> {
    let Some((start, width)) = (|| {
        let width = declared_width(bytes)?;
        let flags = match width {
            RefWidth::Four => View::u32_le_at(bytes, bf4::FLAGS).map(u64::from),
            RefWidth::Eight => View::u64_le_at(bytes, bf8::FLAGS),
        }?;
        if flags & crate::kernel_header::HISTORY_PARTITION_FLAG == 0 {
            return None;
        }
        let start = record_stream_start_with_width(bytes, width)?;
        Some((start, width))
    })() else {
        return Ok(None);
    };
    crate::sab::scan_history_boundary(
        ctx,
        bytes,
        start,
        width,
        Some(&["Begin", "of", "ASM", "History", "Data"]),
    )
}

/// Exact solved-record boundary, using an already-parsed ASM header.
pub fn solved_record_limit_with_header(
    ctx: &DecodeContext<'_>,
    bytes: &[u8],
    header: &BinaryHeader,
) -> Result<Option<usize>, CodecError> {
    if !header.metadata.has_history_partition() {
        return Ok(None);
    }
    let Some(start) = record_stream_start_with_header(bytes, header) else {
        return Ok(None);
    };
    crate::sab::scan_history_boundary(
        ctx,
        bytes,
        start,
        header.width,
        Some(&["Begin", "of", "ASM", "History", "Data"]),
    )
}

#[cfg(test)]
mod tests {
    use cadmpeg_core::decode::{DecodeArena, DecodeContext, DecodePolicy, ResourceDimension};
    use cadmpeg_core::CodecError;

    #[test]
    fn asm_product_string_refuses_retained_limit_before_copy() {
        let mut bytes = b"ASM BinaryFile4".to_vec();
        bytes.resize(crate::layout::asmheader_binaryfile4::LEN, 0);
        bytes.extend_from_slice(&[7, 3]);
        bytes.extend_from_slice(b"ASM");
        let arena = DecodeArena::new();
        let mut policy = DecodePolicy::service();
        policy.limits.max_retained_bytes = 2;
        let (limited, _) = DecodeContext::from_root_bytes(&bytes, &arena, &policy).unwrap();
        assert!(matches!(
            super::parse(&limited, &bytes),
            Err(CodecError::ResourceLimit(limit))
                if limit.dimension == ResourceDimension::RetainedBytes
                    && limit.operation == "retain kernel header product string"
        ));
        let (service, _) =
            DecodeContext::from_root_bytes(&bytes, &arena, &DecodePolicy::service()).unwrap();
        assert_eq!(
            super::parse(&service, &bytes)
                .unwrap()
                .unwrap()
                .metadata
                .product_family
                .as_deref(),
            Some("ASM")
        );
    }
}