use cadmpeg_core::decode::{DecodeContext, View};
use cadmpeg_core::CodecError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefWidth {
Four,
Eight,
}
impl RefWidth {
#[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)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct BinaryHeader {
pub width: RefWidth,
pub metadata: KernelHeader,
}
#[derive(Debug, Clone, PartialEq)]
pub struct KernelHeader {
pub save_format_version: Option<u32>,
pub entity_count: Option<u64>,
pub flags: Option<u64>,
pub product_family: Option<String>,
pub product_version: Option<String>,
pub save_date: Option<String>,
pub scale: Option<f64>,
pub linear: Option<f64>,
pub angular: Option<f64>,
}
pub const HISTORY_PARTITION_FLAG: u64 = 1;
pub const FORMAT_REVISION_FLAGS: u64 = 0xfe;
impl KernelHeader {
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))
}
pub fn save_format_major(&self) -> Option<u32> {
self.save_format_version.map(|version| version / 100)
}
pub fn save_format_minor(&self) -> Option<u32> {
self.save_format_version.map(|version| version % 100)
}
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 })
}
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));
}
}
}