From 83b230ae730b9d8fe8e8d94972ea75d593a682ea Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 10:17:23 -0400 Subject: [PATCH] M12: PRC reader crate: container, bit primitives, schema (#1297) New standalone L0 crate pdfcraft-prc: the PRC container walk (file header, file-structure descriptions, per-section zlib inflation under a 512 MiB ceiling), the bit-level primitives of the compressed sections, and the schema interpreter a newer producer uses to describe fields it added (step and nesting ceilings). The Double prefix-code table is not in this commit: Double reads return UnknownDoubleCode, and the tests that need the table are ignored with that reason. Adapted from pdfcer (MIT, same author), crates/pdfcer-3d at 0c9742b5936232af89cd707138ef149dc1f4a660. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- Cargo.lock | 9 + crates/prc/Cargo.toml | 18 ++ crates/prc/README.md | 23 ++ crates/prc/src/acof.rs | 98 +++++++ crates/prc/src/bits.rs | 421 +++++++++++++++++++++++++++ crates/prc/src/container.rs | 325 +++++++++++++++++++++ crates/prc/src/error.rs | 38 +++ crates/prc/src/lib.rs | 26 ++ crates/prc/src/schema.rs | 525 ++++++++++++++++++++++++++++++++++ crates/prc/src/testw.rs | 101 +++++++ crates/prc/tests/container.rs | 114 ++++++++ crates/prc/tests/proptests.rs | 41 +++ xtask/src/layers.rs | 1 + 13 files changed, 1740 insertions(+) create mode 100644 crates/prc/Cargo.toml create mode 100644 crates/prc/README.md create mode 100644 crates/prc/src/acof.rs create mode 100644 crates/prc/src/bits.rs create mode 100644 crates/prc/src/container.rs create mode 100644 crates/prc/src/error.rs create mode 100644 crates/prc/src/lib.rs create mode 100644 crates/prc/src/schema.rs create mode 100644 crates/prc/src/testw.rs create mode 100644 crates/prc/tests/container.rs create mode 100644 crates/prc/tests/proptests.rs diff --git a/Cargo.lock b/Cargo.lock index d2f4dadad0..e5abfe499e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4551,6 +4551,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "pdfcraft-prc" +version = "0.15.0" +dependencies = [ + "flate2", + "proptest", + "thiserror 2.0.21", +] + [[package]] name = "pdfcraft-preflight" version = "0.17.0" diff --git a/crates/prc/Cargo.toml b/crates/prc/Cargo.toml new file mode 100644 index 0000000000..741c0ac5bd --- /dev/null +++ b/crates/prc/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "pdfcraft-prc" +description = "PRC 3D model reader (ISO 14739-1): container, bit-level primitives, schema" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +flate2.workspace = true +thiserror.workspace = true + +[dev-dependencies] +proptest.workspace = true diff --git a/crates/prc/README.md b/crates/prc/README.md new file mode 100644 index 0000000000..0f8a01872f --- /dev/null +++ b/crates/prc/README.md @@ -0,0 +1,23 @@ +# pdfcraft-prc + +A reader for PRC (ISO 14739-1), the 3D model format a PDF `/3D` stream of `/Subtype /PRC` carries (ISO 32000-2 §13.6.3). + +- **Layer:** L0, standalone. No dependency on any other PdfCraft crate. +- **Licence:** MIT OR Apache-2.0. Inflation through `flate2` with the pure-Rust `miniz_oxide` backend. +- `#![forbid(unsafe_code)]`. Never panics on input: every read is bounded and returns `PrcError`. + +## API + +```rust +PrcFile::parse(data) -> Result // header, file structures, model file +PrcFile::parse_with_limit(data, max_inflated) -> Result +FileStructure::section(SectionKind) -> &[u8] // inflated globals / tree / tessellation / geometry / extra geometry +FileStructure::schema() -> Result // the globals section's leading schema +Schema::read(&mut BitReader) / Schema::skip_added_fields(entity_type, &mut BitReader) +BitReader: bit, bits, character, unsigned_integer, integer, integer_vbn, double_vbn, nbits_then_unsigned, string, float_as_bytes, double +``` + +- `MAX_INFLATED_BYTES` (512 MiB) bounds every section together; `TooLarge` reports the limit hit. +- Schema programs (how a newer producer describes fields it added) run under a step ceiling (2^22) and a nesting ceiling (64). +- `Unsupported` names constructs the reader cannot step over (schema pointer fields, `Value_CurveIs3D`). +- Not yet in the crate: the `Double` code table, so `BitReader::double` returns `UnknownDoubleCode`; the product tree and tessellation readers. diff --git a/crates/prc/src/acof.rs b/crates/prc/src/acof.rs new file mode 100644 index 0000000000..dff5d4a6f2 --- /dev/null +++ b/crates/prc/src/acof.rs @@ -0,0 +1,98 @@ +//! The prefix-code table behind the PRC `Double` [WD 11.17.2]. +//! +//! The table has 2077 rows: 29 frequent doubles and one row per IEEE binary64 exponent +//! (0..=2047). Each row is a code of 1 to 22 bits and the value it decodes to. +//! +//! The rows are not in this crate yet: until they are, [`lookup`] finds no code, so every +//! `Double` read fails with `UnknownDoubleCode` and nothing else changes. + +use std::collections::HashMap; +use std::sync::OnceLock; + +/// Rows a complete table has. +const ROWS: usize = 2077; + +/// The longest code, in bits. +pub(crate) const MAX_CODE_BITS: u32 = 22; + +/// What a code decodes to. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(not(test), expect(dead_code, reason = "built only by the table's rows, which are not in the crate yet"))] +pub(crate) enum Entry { + /// A frequent value, returned with the sign bit applied. + Double(f64), + /// The upper 32 bits of a binary64 (exponent set, mantissa zero); the mantissa follows + /// in the stream. + Exponent(u32), +} + +/// One row: code length, code, and what it decodes to. +type Row = (u32, u32, Entry); + +/// The table's rows. +const TABLE: &[Row] = &[]; + +fn key(nbits: u32, code: u32) -> u32 { + (nbits << 24) | code +} + +/// The rows keyed by `(nbits, code)`, or `None` when the table is incomplete or not prefix +/// free (then no `Double` decodes, rather than some decoding wrongly). +fn build(rows: &[Row]) -> Option> { + let mut map = HashMap::with_capacity(rows.len()); + for &(nbits, code, entry) in rows { + if nbits == 0 || nbits > MAX_CODE_BITS || code >> nbits != 0 || map.insert(key(nbits, code), entry).is_some() { + return None; + } + } + let prefix_free = map.keys().all(|&k| { + let (n, c) = (k >> 24, k & 0xff_ffff); + (1..n).all(|m| !map.contains_key(&key(m, c >> (n - m)))) + }); + (map.len() == ROWS && prefix_free).then_some(map) +} + +fn table() -> Option<&'static HashMap> { + static MAP: OnceLock>> = OnceLock::new(); + MAP.get_or_init(|| build(TABLE)).as_ref() +} + +/// The entry for an `nbits`-long code, if one exists. +pub(crate) fn lookup(nbits: u32, code: u32) -> Option { + table()?.get(&key(nbits, code)).copied() +} + +/// The `(nbits, code)` of IEEE exponent `e`'s row, the test writer's half of [`lookup`]. +#[cfg(test)] +pub(crate) fn exponent_code(e: u32) -> Option<(u32, u32)> { + table()?.iter().find(|(_, v)| **v == Entry::Exponent(e << 20)).map(|(&k, _)| (k >> 24, k & 0xff_ffff)) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn an_incomplete_or_ambiguous_table_decodes_nothing() { + assert!(build(&[(2, 0b01, Entry::Double(0.0))]).is_none(), "one row is not the table"); + let rows: Vec = (0..ROWS as u32).map(|i| (22, i, Entry::Exponent(i))).collect(); + assert!(build(&rows).is_some(), "2077 distinct 22-bit codes are prefix free"); + let mut ambiguous = rows.clone(); + ambiguous[0] = (1, 0, Entry::Double(1.0)); // a prefix of every code starting 0 + assert!(build(&ambiguous).is_none()); + let mut too_long = rows; + too_long[0] = (2, 0b111, Entry::Double(1.0)); // a code wider than its length + assert!(build(&too_long).is_none()); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn the_table_is_complete() { + let t = table().unwrap(); + assert_eq!(t.len(), ROWS); + assert_eq!(t.values().filter(|e| matches!(e, Entry::Double(_))).count(), 29); + assert!((0..2048).all(|e| exponent_code(e).is_some())); + assert_eq!(lookup(2, 0b01), Some(Entry::Double(0.0))); + } +} diff --git a/crates/prc/src/bits.rs b/crates/prc/src/bits.rs new file mode 100644 index 0000000000..a94a16dc9a --- /dev/null +++ b/crates/prc/src/bits.rs @@ -0,0 +1,421 @@ +//! The bit-level reader for PRC compressed sections [WD 10.3, 11.1-11.17]. +//! +//! Values are consumed MSB-first from each inflated byte and straddle byte boundaries freely +//! [WD 10.3.1, 11.3]. Multi-byte integers are groups of eight bits, low group first. Two sign +//! conventions coexist: `Integer` is two's complement, the `*WithVariableBitNumber` types are +//! sign-magnitude. + +use crate::PrcError; +use crate::acof::{self, Entry}; + +/// A cursor over one inflated section. +#[derive(Debug, Clone)] +pub struct BitReader<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> BitReader<'a> { + /// A reader positioned at the first bit of `data`. + pub fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + /// Bits consumed so far. + pub fn position(&self) -> usize { + self.pos + } + + /// Bits left before the end of the data. + pub fn remaining(&self) -> usize { + self.data.len().saturating_mul(8).saturating_sub(self.pos) + } + + /// Step over `n` bits of opaque data (`UserData` [WD 8.6]). + /// + /// # Errors + /// [`PrcError::Truncated`] when fewer than `n` bits remain; the cursor does not move. + pub fn skip_bits(&mut self, n: usize) -> Result<(), PrcError> { + if n > self.remaining() { + return Err(PrcError::Truncated("user data")); + } + self.pos = self.pos.saturating_add(n); + Ok(()) + } + + /// One bit; a PRC `Boolean` [WD 10.3.3]. + /// + /// # Errors + /// [`PrcError::Truncated`] at the end of the data. + pub fn bit(&mut self) -> Result { + let byte = self.data.get(self.pos / 8).ok_or(PrcError::Truncated("a bit"))?; + let b = (byte >> (7 - (self.pos % 8))) & 1 == 1; + // `pos / 8` indexed the data, so `pos + 1` is at most `8 * len`. + self.pos = self.pos.saturating_add(1); + Ok(b) + } + + /// `n` bits (at most 32), MSB-first; `UnsignedIntegerWithVariableBitNumber` [WD 11.13]. + /// + /// # Errors + /// [`PrcError::Truncated`] at the end of the data; [`PrcError::Malformed`] for `n > 32`. + pub fn bits(&mut self, n: u32) -> Result { + if n > 32 { + return Err(PrcError::Malformed(format!("{n}-bit field"))); + } + if self.remaining() < n as usize { + return Err(PrcError::Truncated("a bit field")); + } + let mut v: u64 = 0; + for _ in 0..n { + v = (v << 1) | u64::from(self.bit()?); + } + // n <= 32, so v fits. + Ok(u32::try_from(v).unwrap_or(u32::MAX)) + } + + fn byte(&mut self) -> Result { + Ok(u8::try_from(self.bits(8)?).unwrap_or(u8::MAX)) + } + + /// A `Character`: one byte [WD 10.3.4]. + /// + /// # Errors + /// [`PrcError::Truncated`]. + pub fn character(&mut self) -> Result { + self.byte() + } + + /// An `UnsignedInteger` [WD 11.10]: while a 1 bit precedes it, one more eight-bit group, + /// low group first. + /// + /// # Errors + /// [`PrcError::Truncated`]; [`PrcError::Malformed`] past four groups. + pub fn unsigned_integer(&mut self) -> Result { + let mut v: u32 = 0; + let mut shift = 0; + while self.bit()? { + if shift >= 32 { + return Err(PrcError::Malformed("UnsignedInteger over 32 bits".into())); + } + v |= u32::from(self.byte()?) << shift; + shift += 8; + } + Ok(v) + } + + /// An `Integer` [WD 11.11]: a 0 bit is zero; otherwise eight-bit groups, low group first, + /// each followed by a continue bit, sign-extended from the last group's top bit. + /// + /// # Errors + /// [`PrcError::Truncated`]; [`PrcError::Malformed`] past four groups. + pub fn integer(&mut self) -> Result { + if !self.bit()? { + return Ok(0); + } + let mut v: u32 = 0; + let mut shift = 0; + loop { + if shift >= 32 { + return Err(PrcError::Malformed("Integer over 32 bits".into())); + } + let group = self.byte()?; + v |= u32::from(group) << shift; + shift += 8; + if !self.bit()? { + if group & 0x80 != 0 && shift < 32 { + v |= u32::MAX << shift; + } + return Ok(v.cast_signed()); + } + } + } + + /// An `IntegerWithVariableBitNumber(n)` [WD 11.12]: a negative flag (1 = negative; the WD + /// prose says the opposite, its sample code and real files agree on this), then the + /// magnitude in `n - 1` bits. + /// + /// # Errors + /// [`PrcError::Truncated`]; [`PrcError::Malformed`] for `n > 33`. + pub fn integer_vbn(&mut self, n: u32) -> Result { + let Some(magnitude_bits) = n.checked_sub(1) else { + return Ok(0); + }; + let negative = self.bit()?; + let m = i64::from(self.bits(magnitude_bits)?); + Ok(if negative { -m } else { m }) + } + + /// A `DoubleWithVariableBitNumber(tolerance, n)` [WD 11.14]: a sign-magnitude quantum + /// count times `tolerance`. + /// + /// # Errors + /// As [`Self::integer_vbn`]. + pub fn double_vbn(&mut self, tolerance: f64, n: u32) -> Result { + Ok(self.integer_vbn(n)? as f64 * tolerance) + } + + /// `NumberOfBitsThenUnsignedInteger` [WD 11.15]: a 5-bit width, then the value in that + /// many bits. + /// + /// # Errors + /// [`PrcError::Truncated`]. + pub fn nbits_then_unsigned(&mut self) -> Result { + let n = self.bits(5)?; + self.bits(n) + } + + /// A `String` [WD 10.3.7, 11.4]: `None` for the null string, else UTF-8 (invalid + /// sequences replaced). + /// + /// # Errors + /// [`PrcError::Truncated`] when the byte count runs past the data. + pub fn string(&mut self) -> Result, PrcError> { + if !self.bit()? { + return Ok(None); + } + let len = self.unsigned_integer()? as usize; + if len.saturating_mul(8) > self.remaining() { + return Err(PrcError::Truncated("a String")); + } + let bytes = (0..len).map(|_| self.byte()).collect::, _>>()?; + Ok(Some(String::from_utf8_lossy(&bytes).into_owned())) + } + + /// `FloatAsBytes` [WD 11.5]: an IEEE binary32, least-significant byte first. + /// + /// # Errors + /// [`PrcError::Truncated`]. + pub fn float_as_bytes(&mut self) -> Result { + let b = [self.byte()?, self.byte()?, self.byte()?, self.byte()?]; + Ok(f32::from_le_bytes(b)) + } + + /// A `Double` [WD 11.17]: a prefix code from the Double table, then a sign bit and, for + /// an exponent code, a byte-wise mantissa with back-references. + /// + /// The two-bit zero code has no sign bit. Back-reference offsets 1 to 5 are all accepted, + /// distance 1 included (PDF Association pdf-issues #769). + /// + /// # Errors + /// [`PrcError::UnknownDoubleCode`], [`PrcError::Truncated`], or [`PrcError::Malformed`] + /// for a back-reference outside the value. + pub fn double(&mut self) -> Result { + let mut code = 0u32; + let mut entry = None; + for n in 1..=acof::MAX_CODE_BITS { + code = (code << 1) | u32::from(self.bit()?); + if let Some(e) = acof::lookup(n, code) { + if n == 2 && code == 0b01 { + return Ok(0.0); + } + entry = Some(e); + break; + } + } + let entry = entry.ok_or(PrcError::UnknownDoubleCode)?; + let negative = self.bit()?; + let upper = match entry { + Entry::Double(v) => return Ok(if negative { -v } else { v }), + Entry::Exponent(upper) => upper, + }; + let mut b = (u64::from(upper) << 32).to_le_bytes(); + if self.bit()? { + self.mantissa(&mut b)?; + } + let sign = if negative { 1u64 << 63 } else { 0 }; + Ok(f64::from_bits(u64::from_le_bytes(b) | sign)) + } + + /// Mantissa bits 51..0 into `b` (little-endian byte order): a nibble, then bytes 5 down + /// to 0, each a literal or a back-reference to a byte already set. + fn mantissa(&mut self, b: &mut [u8; 8]) -> Result<(), PrcError> { + let nibble = u8::try_from(self.bits(4)?).unwrap_or(0); + let [.., b6, _] = b; + *b6 |= nibble; + let at = |b: &[u8; 8], i: usize| b.get(i).copied().ok_or_else(|| PrcError::Malformed("Double back-reference past the value".into())); + for cbi in (0..=5usize).rev() { + let prev = at(b, cbi + 1)?; + let value = if self.bit()? { + self.byte()? + } else { + match self.bits(3)? { + // Fill this byte and every lower one with the previous byte. + 0 => { + b.iter_mut().take(cbi + 1).for_each(|x| *x = prev); + return Ok(()); + } + // As 0, but the lowest byte is a literal. + 6 => { + b.iter_mut().take(cbi + 1).skip(1).for_each(|x| *x = prev); + let low = self.byte()?; + let [b0, ..] = b; + *b0 = low; + return Ok(()); + } + off @ 1..=5 => at(b, cbi + off as usize)?, + _ => return Err(PrcError::Malformed("Double mantissa offset 7".into())), + } + }; + if let Some(x) = b.get_mut(cbi) { + *x = value; + } + } + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::testw::W; + + /// The code for IEEE exponent `e`. + fn exponent_code(e: u32) -> (u32, u32) { + acof::exponent_code(e).expect("the table has a row per exponent") + } + + fn read_double(w: &W) -> f64 { + BitReader::new(&w.bytes()).double().unwrap() + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn zero_is_the_two_bit_code_with_no_sign() { + let mut w = W::default(); + w.put(0b01, 2).put(0b1111_1111, 8); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + assert_eq!(r.double().unwrap(), 0.0); + assert_eq!(r.position(), 2); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn exponent_with_literal_mantissa() { + let v = -1234.5678f64; + let le = v.to_bits().to_le_bytes(); + let e = u32::try_from((v.to_bits() >> 52) & 0x7ff).unwrap(); + let (n, c) = exponent_code(e); + let mut w = W::default(); + w.put(u64::from(c), n).put(1, 1).put(1, 1).put(u64::from(le[6] & 0x0f), 4); + for byte in le[..6].iter().rev() { + w.put(1, 1).put(u64::from(*byte), 8); + } + assert_eq!(read_double(&w), v); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn exponent_without_mantissa_is_a_power_of_two() { + let (n, c) = exponent_code(1023 + 3); + let mut w = W::default(); + w.put(u64::from(c), n).put(0, 1).put(0, 1); + assert_eq!(read_double(&w), 8.0); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn fill_back_reference_and_literal_low_byte() { + // Mantissa bytes 6..0 = 0x05, 0xAA, 0xAA (offset 1), 0xAA (offset 2), fill 0xAA to byte 1, byte 0 = 0x11. + let (n, c) = exponent_code(1023); + let mut w = W::default(); + w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0x5, 4); + w.put(1, 1).put(0xAA, 8); + w.put(0, 1).put(1, 3); + w.put(0, 1).put(2, 3); + w.put(0, 1).put(6, 3).put(0x11, 8); + assert_eq!(read_double(&w), f64::from_le_bytes([0x11, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xF5, 0x3F])); + + // Byte 5 literal, then fill 4..0. + let mut w = W::default(); + w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0x5, 4); + w.put(1, 1).put(0x77, 8).put(0, 1).put(0, 3); + assert_eq!(read_double(&w), f64::from_le_bytes([0x77, 0x77, 0x77, 0x77, 0x77, 0x77, 0xF5, 0x3F])); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn a_back_reference_past_the_value_is_refused() { + let (n, c) = exponent_code(1023); + let mut w = W::default(); + w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0, 4); + w.put(0, 1).put(5, 3).put(0, 16); // byte 5 + offset 5 = byte 10 + assert!(matches!(BitReader::new(&w.bytes()).double(), Err(PrcError::Malformed(_)))); + } + + #[test] + fn without_the_table_a_double_is_an_error_not_a_panic() { + let mut r = BitReader::new(&[0x40, 0, 0]); + assert_eq!(r.double(), Err(PrcError::UnknownDoubleCode)); + assert!(matches!(BitReader::new(&[0x40]).double(), Err(PrcError::Truncated(_)))); + } + + #[test] + fn integers_and_strings() { + let mut w = W::default(); + // UnsignedInteger 300 = groups 0x2C, 0x01. + w.put(1, 1).put(0x2C, 8).put(1, 1).put(0x01, 8).put(0, 1); + // Integer -2 = group 0xFE, stop. + w.put(1, 1).put(0xFE, 8).put(0, 1); + // Integer 200 = 0xC8 then 0x00 (top bit clear), stop. + w.put(1, 1).put(0xC8, 8).put(1, 1).put(0x00, 8).put(0, 1); + // IntegerWithVariableBitNumber(4) = -5. + w.put(1, 1).put(5, 3); + // String "hé". + w.put(1, 1).put(1, 1).put(3, 8).put(0, 1).put(0x68, 8).put(0xC3, 8).put(0xA9, 8); + // Null string. + w.put(0, 1); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + assert_eq!(r.unsigned_integer().unwrap(), 300); + assert_eq!(r.integer().unwrap(), -2); + assert_eq!(r.integer().unwrap(), 200); + assert_eq!(r.integer_vbn(4).unwrap(), -5); + assert_eq!(r.string().unwrap().as_deref(), Some("hé")); + assert_eq!(r.string().unwrap(), None); + } + + #[test] + fn the_writer_round_trips_through_the_reader() { + let mut w = W::default(); + w.uint(0).uint(u32::MAX).int(-129).int(i32::MIN).int(i32::MAX).string(Some("glass")).string(None); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + assert_eq!(r.unsigned_integer().unwrap(), 0); + assert_eq!(r.unsigned_integer().unwrap(), u32::MAX); + assert_eq!(r.integer().unwrap(), -129); + assert_eq!(r.integer().unwrap(), i32::MIN); + assert_eq!(r.integer().unwrap(), i32::MAX); + assert_eq!(r.string().unwrap().as_deref(), Some("glass")); + assert_eq!(r.string().unwrap(), None); + } + + #[test] + fn oversized_groups_and_widths_are_malformed() { + // Five UnsignedInteger groups. + let mut w = W::default(); + (0..5).for_each(|_| { + w.put(1, 1).put(0xFF, 8); + }); + w.put(0, 1); + assert!(matches!(BitReader::new(&w.bytes()).unsigned_integer(), Err(PrcError::Malformed(_)))); + assert!(matches!(BitReader::new(&[0; 8]).bits(33), Err(PrcError::Malformed(_)))); + assert!(matches!(BitReader::new(&[0; 8]).integer_vbn(34), Err(PrcError::Malformed(_)))); + assert_eq!(BitReader::new(&[]).integer_vbn(0), Ok(0)); + } + + #[test] + fn truncation_is_an_error_not_a_panic() { + let mut r = BitReader::new(&[0xFF]); + assert!(r.unsigned_integer().is_err()); + let mut r = BitReader::new(&[0xC0, 0x40]); + assert!(matches!(r.string(), Err(PrcError::Truncated(_)))); + let mut r = BitReader::new(&[0xAB]); + assert!(matches!(r.skip_bits(9), Err(PrcError::Truncated(_)))); + assert_eq!(r.position(), 0, "a refused skip does not move the cursor"); + r.skip_bits(8).unwrap(); + assert_eq!(r.remaining(), 0); + } +} diff --git a/crates/prc/src/container.rs b/crates/prc/src/container.rs new file mode 100644 index 0000000000..8a44921334 --- /dev/null +++ b/crates/prc/src/container.rs @@ -0,0 +1,325 @@ +//! The PRC container: file header, file-structure descriptions, section extents and +//! inflation [WD 6.1-6.2, 10.2]. +//! +//! Section extents are not stored: each section runs to the next one's start offset, and the +//! last one to the model file's start. Inflation stops at the zlib stream's end, so padding +//! between sections is tolerated. + +use std::io::Read; + +use crate::PrcError; +use crate::bits::BitReader; +use crate::schema::Schema; + +/// The PRC version this reader implements. +pub const PRC_READER_VERSION: u32 = 8137; + +/// The most file structures [`PrcFile::parse`] accepts. +pub const MAX_FILE_STRUCTURES: usize = 65_536; + +/// Ceiling on the total inflated size of every section in one PRC stream. +pub const MAX_INFLATED_BYTES: usize = 512 * 1024 * 1024; + +/// Sections per file structure [WD 6.1.2]: its header, then the five in [`SectionKind`] +/// order. +const SECTIONS: usize = 6; + +/// A 128-bit PRC identifier [WD 10.2.4]: four little-endian `u32`s. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct UniqueId(pub [u32; 4]); + +/// The compressed sections of one file structure, in file order [WD 6.1.2, 6.2.1]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SectionKind { + /// Starts with the file structure's schema, then its globals entity. + Globals, + /// The product-structure tree. + Tree, + /// Tessellated (mesh) data. + Tessellation, + /// Exact (B-rep) geometry. + Geometry, + /// Supplementary geometry. + ExtraGeometry, +} + +impl SectionKind { + /// Every kind, in file order. + pub const ALL: [SectionKind; 5] = + [SectionKind::Globals, SectionKind::Tree, SectionKind::Tessellation, SectionKind::Geometry, SectionKind::ExtraGeometry]; + + /// The section's name, as used in errors. + pub fn name(self) -> &'static str { + match self { + SectionKind::Globals => "globals", + SectionKind::Tree => "tree", + SectionKind::Tessellation => "tessellation", + SectionKind::Geometry => "geometry", + SectionKind::ExtraGeometry => "extra geometry", + } + } + + fn index(self) -> usize { + match self { + SectionKind::Globals => 0, + SectionKind::Tree => 1, + SectionKind::Tessellation => 2, + SectionKind::Geometry => 3, + SectionKind::ExtraGeometry => 4, + } + } +} + +/// The uncompressed file header [WD 6.1.1]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct PrcHeader { + /// The oldest reader version the producer says can read the file. + pub min_version_for_read: u32, + /// The producer's PRC version. + pub authoring_version: u32, + /// The file's identifier. + pub file_id: UniqueId, + /// The producing application's identifier (all zero = unregistered). + pub application_id: UniqueId, +} + +/// One file structure: its uncompressed header fields and its five inflated sections +/// [WD 6.2]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct FileStructure { + /// The identifier; equals the file header's description of it. + pub id: UniqueId, + /// Per-structure reader version floor; may differ from the file's. + pub min_version_for_read: u32, + /// Per-structure producer version. + pub authoring_version: u32, + /// Picture payloads (JPEG, PNG, ...) carried uncompressed in the header, referenced by + /// index from picture entities [WD 6.2.2]. + pub pictures: Vec>, + sections: [Vec; 5], +} + +impl FileStructure { + /// The inflated bytes of one section. + pub fn section(&self, kind: SectionKind) -> &[u8] { + self.sections.get(kind.index()).map_or(&[], Vec::as_slice) + } + + /// The globals section's leading schema: the fields producers newer than this reader + /// added to each entity type [WD 6.3]. + /// + /// # Errors + /// As [`Schema::read`]. + pub fn schema(&self) -> Result { + Schema::read(&mut BitReader::new(self.section(SectionKind::Globals))) + } +} + +/// A parsed PRC stream: header, file structures and model-file section. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct PrcFile { + /// The file header. + pub header: PrcHeader, + /// File structures in physical order (referenced before referencing). + pub file_structures: Vec, + /// The inflated model-file section: its schema, then the model file entity. + pub model_file: Vec, + /// The `UncompressedFiles` blocks after the file header [WD 6.1.1], which picture + /// entities may also reference. + pub uncompressed_files: Vec>, +} + +/// A little-endian byte cursor over the uncompressed parts of the stream. +struct Bytes<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Bytes<'a> { + fn take(&mut self, n: usize, what: &'static str) -> Result<&'a [u8], PrcError> { + let end = self.pos.checked_add(n).ok_or(PrcError::Truncated(what))?; + let s = self.data.get(self.pos..end).ok_or(PrcError::Truncated(what))?; + self.pos = end; + Ok(s) + } + + fn u32(&mut self, what: &'static str) -> Result { + let s = self.take(4, what)?; + let b: [u8; 4] = s.try_into().map_err(|_| PrcError::Truncated(what))?; + Ok(u32::from_le_bytes(b)) + } + + fn id(&mut self, what: &'static str) -> Result { + Ok(UniqueId([self.u32(what)?, self.u32(what)?, self.u32(what)?, self.u32(what)?])) + } + + fn magic(&mut self) -> Result<(), PrcError> { + match self.take(3, "magic") { + Ok(b"PRC") => Ok(()), + _ => Err(PrcError::NotPrc), + } + } + + /// `UncompressedBlock`s: a count, then size-prefixed blocks [WD 10.2.2-3]. + fn blocks(&mut self, what: &'static str) -> Result>, PrcError> { + let n = offset(self.u32(what)?); + // Each block needs at least its 4-byte size, so `n` is bounded by the data left. + if n > self.data.len().saturating_sub(self.pos) / 4 { + return Err(PrcError::Truncated(what)); + } + (0..n) + .map(|_| { + let len = offset(self.u32(what)?); + Ok(self.take(len, what)?.to_vec()) + }) + .collect() + } +} + +/// A stored `u32` offset or count as a `usize` (saturating on a 16-bit target, where no data +/// that large could exist). +fn offset(v: u32) -> usize { + usize::try_from(v).unwrap_or(usize::MAX) +} + +/// One file structure description of the file header [WD 6.2]: its id and the offsets of +/// its sections. +fn file_structure_description(r: &mut Bytes) -> Result<(UniqueId, Vec), PrcError> { + let id = r.id("file structure description")?; + if r.u32("file structure description")? != 0 { + return Err(PrcError::Malformed("file structure reserved field is not 0".into())); + } + let count = offset(r.u32("file structure description")?); + if count < SECTIONS { + return Err(PrcError::Malformed(format!("{count} sections in a file structure"))); + } + let mut offs = Vec::with_capacity(SECTIONS); + // Offsets past the six this reader uses are read and dropped; each read is bounded by + // the data, so a huge `count` ends in `Truncated`. + for i in 0..count { + let o = r.u32("section offset")?; + if i < SECTIONS { + offs.push(offset(o)); + } + } + Ok((id, offs)) +} + +/// Where one file structure's parts lie: its section offsets and the end of its last section. +struct Extent<'a> { + offs: &'a [usize], + last: usize, +} + +impl Extent<'_> { + fn bound(&self, i: usize) -> usize { + self.offs.get(i).copied().unwrap_or(self.last) + } +} + +/// The file structure `id` lying at `extent`: its header and its inflated sections, charged +/// to `budget`. +fn file_structure(data: &[u8], id: UniqueId, extent: &Extent, budget: &mut Budget) -> Result { + if extent.offs.windows(2).any(|w| w.first() > w.get(1)) || extent.bound(SECTIONS - 1) > extent.last { + return Err(PrcError::Malformed("file structure section offsets are not ascending".into())); + } + let header = data.get(..extent.bound(1)).ok_or(PrcError::Truncated("file structure header"))?; + let mut h = Bytes { data: header, pos: extent.bound(0) }; + h.magic()?; + let min_version_for_read = h.u32("file structure header")?; + let authoring_version = h.u32("file structure header")?; + if h.id("file structure header")? != id { + return Err(PrcError::Malformed("file structure id differs from its description".into())); + } + let _application = h.id("file structure header")?; + let pictures = h.blocks("file structure pictures")?; + let mut sections: [Vec; 5] = Default::default(); + for (i, (slot, kind)) in sections.iter_mut().zip(SectionKind::ALL).enumerate() { + let end = if i + 2 < SECTIONS { extent.bound(i + 2) } else { extent.last }; + *slot = budget.inflate(data, (extent.bound(i + 1), end), kind.name())?; + } + Ok(FileStructure { id, min_version_for_read, authoring_version, pictures, sections }) +} + +/// What is left of the inflation ceiling, and the ceiling itself for the error. +struct Budget { + left: usize, + limit: usize, +} + +impl Budget { + /// The zlib stream in `data[range]`, its size charged to the budget. + fn inflate(&mut self, data: &[u8], range: (usize, usize), section: &'static str) -> Result, PrcError> { + let (start, end) = range; + if start == end { + return Ok(Vec::new()); + } + let src = data.get(start..end).ok_or_else(|| PrcError::Malformed(format!("{section} section {start}..{end} is outside the data")))?; + let mut out = Vec::new(); + // One byte past the budget is enough to know it was exceeded. + let cap = u64::try_from(self.left).unwrap_or(u64::MAX).saturating_add(1); + flate2::read::ZlibDecoder::new(src).take(cap).read_to_end(&mut out).map_err(|e| PrcError::Inflate { section, reason: e.to_string() })?; + self.left = self.left.checked_sub(out.len()).ok_or(PrcError::TooLarge { limit: self.limit })?; + Ok(out) + } +} + +impl PrcFile { + /// Parse a PRC stream (the decoded data of a `/3D` stream with `/Subtype /PRC`). + /// + /// Newer `min_version_for_read` values are not refused: the fields read here are + /// unchanged across published versions, and the caller sees the versions in + /// [`PrcHeader`]. + /// + /// # Errors + /// [`PrcError::NotPrc`] without the magic; [`PrcError::Truncated`] or + /// [`PrcError::Malformed`] for a damaged header or offset table; [`PrcError::Inflate`] + /// for a section that is not zlib; [`PrcError::TooLarge`] past [`MAX_INFLATED_BYTES`] + /// in total. + /// + /// # Examples + /// ``` + /// use pdfcraft_prc::{PrcError, PrcFile}; + /// assert_eq!(PrcFile::parse(b"%PDF"), Err(PrcError::NotPrc)); + /// ``` + pub fn parse(data: &[u8]) -> Result { + Self::parse_with_limit(data, MAX_INFLATED_BYTES) + } + + /// [`Self::parse`] with a caller-chosen ceiling on the total inflated size, for callers + /// with less memory to spend than [`MAX_INFLATED_BYTES`]. + /// + /// # Errors + /// As [`Self::parse`]; [`PrcError::TooLarge`] carries `limit`. + pub fn parse_with_limit(data: &[u8], limit: usize) -> Result { + let mut r = Bytes { data, pos: 0 }; + r.magic()?; + let header = PrcHeader { + min_version_for_read: r.u32("file header")?, + authoring_version: r.u32("file header")?, + file_id: r.id("file header")?, + application_id: r.id("file header")?, + }; + let n_fs = offset(r.u32("file structure count")?); + if n_fs == 0 || n_fs > MAX_FILE_STRUCTURES { + return Err(PrcError::Malformed(format!("{n_fs} file structures"))); + } + let descriptions = (0..n_fs).map(|_| file_structure_description(&mut r)).collect::, _>>()?; + let mf_start = offset(r.u32("model file offsets")?); + let mf_end = offset(r.u32("model file offsets")?); + let uncompressed_files = r.blocks("uncompressed files")?; + if mf_end < mf_start { + return Err(PrcError::Malformed("model file ends before it starts".into())); + } + + let mut budget = Budget { left: limit, limit }; + let last = mf_start.min(data.len()); + let file_structures = + descriptions.iter().map(|(id, offs)| file_structure(data, *id, &Extent { offs, last }, &mut budget)).collect::, _>>()?; + let model_file = budget.inflate(data, (mf_start, mf_end.min(data.len())), "model file")?; + Ok(PrcFile { header, file_structures, model_file, uncompressed_files }) + } +} diff --git a/crates/prc/src/error.rs b/crates/prc/src/error.rs new file mode 100644 index 0000000000..eaad2bb6e1 --- /dev/null +++ b/crates/prc/src/error.rs @@ -0,0 +1,38 @@ +//! The crate's error type. + +/// Why a PRC stream could not be read. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum PrcError { + /// The data does not start with the `PRC` magic [WD 6.1.1]. + #[error("not a PRC stream: missing the \"PRC\" magic")] + NotPrc, + /// A value ran past the end of its byte or bit stream. + #[error("PRC data truncated while reading {0}")] + Truncated(&'static str), + /// A structural field holds a value the format forbids. + #[error("malformed PRC: {0}")] + Malformed(String), + /// No `Double` code of up to 22 bits matched [WD 11.17]. + #[error("PRC Double: no code matches the next 22 bits")] + UnknownDoubleCode, + /// A section is not a valid zlib stream. + #[error("PRC {section} section does not inflate: {reason}")] + Inflate { + /// The section's name. + section: &'static str, + /// The decoder's message. + reason: String, + }, + /// The inflated sections together exceed the ceiling ([`crate::MAX_INFLATED_BYTES`] or + /// the caller's limit). + #[error("PRC sections inflate past the {limit}-byte ceiling")] + TooLarge { + /// The ceiling that was hit. + limit: usize, + }, + /// A construct this reader does not decode; the part of the file holding it cannot be + /// read past. + #[error("PRC {0} is not supported")] + Unsupported(&'static str), +} diff --git a/crates/prc/src/lib.rs b/crates/prc/src/lib.rs new file mode 100644 index 0000000000..1a62f9f272 --- /dev/null +++ b/crates/prc/src/lib.rs @@ -0,0 +1,26 @@ +//! A reader for PRC, the 3D model format a PDF `/3D` stream of `/Subtype /PRC` carries +//! (ISO 32000-2 §13.6.3, ISO 14739-1). +//! +//! Layer L0, standalone: this crate depends on no other PdfCraft crate. It reads the +//! uncompressed container, inflates each section, and decodes the bit-level primitives and +//! the schema a newer producer uses to describe fields this reader does not know. +//! +//! Clause numbers in `[WD x.y]` refer to the PRC working draft, ISO/TC 171/SC 2 N570. +//! +//! Every read is bounded: inflation by [`MAX_INFLATED_BYTES`] (or a caller's limit), schema +//! programs by a step and depth ceiling. Damaged data is an error, never a panic. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod acof; +mod bits; +mod container; +mod error; +mod schema; +#[cfg(test)] +mod testw; + +pub use bits::BitReader; +pub use container::{FileStructure, MAX_FILE_STRUCTURES, MAX_INFLATED_BYTES, PRC_READER_VERSION, PrcFile, PrcHeader, SectionKind, UniqueId}; +pub use error::PrcError; +pub use schema::Schema; diff --git a/crates/prc/src/schema.rs b/crates/prc/src/schema.rs new file mode 100644 index 0000000000..a93554334b --- /dev/null +++ b/crates/prc/src/schema.rs @@ -0,0 +1,525 @@ +//! `FileStructureSchema` and its token interpreter [WD 6.3, 9]. +//! +//! A producer newer than the reader describes, per entity type, the fields it appended; the +//! reader runs that program after the fields it knows, to step over the rest [WD 9.1]. Token +//! values follow the WD 9.2 table as corrected by PDF Association pdf-issues #407 +//! (`Block_Start` = 19; 39 and 40 swallow one token). +//! +//! Choices the sources leave open, made here: +//! - `SimpleFor` reads its count as an `UnsignedInteger` [WD 9.3.17 pseudocode; pdf-issues +//! #575]: that read also decodes an `Integer`-written non-negative count, while a signed +//! read does not decode an unsigned one whose top group is 0x80 or higher. +//! - `Block_Version V` runs its block only when `V` is newer than +//! [`crate::PRC_READER_VERSION`]: an older block holds fields this reader already reads +//! natively [WD 9.3.20]. +//! - `Extent_1D` and `Extent_2D` read 2 and 4 `Double`s: `Interval` and `Domain` +//! [WD 8.2.2, 8.2.4]. +//! - Expression values are `i64`; a `Double` read as an operand truncates (NaN reads as 0). +//! - Variables are not block-scoped: a program that reuses an index after its block ends +//! reads the stale value instead of an error. +//! - Pointer tokens (12-14) and `Value_CurveIs3D` (28) need the exact-geometry reader and are +//! refused with [`PrcError::Unsupported`]. + +use std::collections::HashMap; + +use crate::PrcError; +use crate::bits::BitReader; + +/// Steps (tokens executed, loop iterations included) one +/// [`Schema::skip_added_fields`] call may take. +const MAX_STEPS: u64 = 1 << 22; + +/// Nesting depth of blocks, loops, expressions and `Father_Type` calls. +const MAX_DEPTH: u32 = 64; + +/// The per-entity-type programs at the start of a globals or model-file +/// bitstream [WD 6.3.1]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Schema { + programs: HashMap>, +} + +impl Schema { + /// Read a schema: `UnsignedInteger` count, then per entry the entity + /// type, a token count and the tokens [WD 6.3.2]. + /// + /// # Errors + /// [`PrcError::Truncated`] when a count runs past the data; + /// [`PrcError::Malformed`] for an entity type listed twice. + pub fn read(r: &mut BitReader<'_>) -> Result { + let n = r.unsigned_integer()? as usize; + // Every entry takes at least two bits. + if n > r.remaining() / 2 { + return Err(PrcError::Truncated("the schema")); + } + let mut programs = HashMap::new(); + for _ in 0..n { + let entity_type = r.unsigned_integer()?; + let len = r.unsigned_integer()? as usize; + if len > r.remaining() { + return Err(PrcError::Truncated("a schema program")); + } + let tokens = (0..len).map(|_| r.unsigned_integer()).collect::, _>>()?; + if programs.insert(entity_type, tokens).is_some() { + return Err(PrcError::Malformed(format!("schema lists entity type {entity_type} twice"))); + } + } + Ok(Self { programs }) + } + + /// True when no entity type has added fields (the common case: the file + /// was written at the version this reader implements). + pub fn is_empty(&self) -> bool { + self.programs.is_empty() + } + + /// The entity types the schema describes, unordered. + pub fn entity_types(&self) -> impl Iterator + '_ { + self.programs.keys().copied() + } + + /// Consume the fields a newer producer appended to `entity_type`, if the + /// schema describes it; a no-op otherwise. + /// + /// # Errors + /// [`PrcError::Unsupported`] for a token that needs exact geometry; + /// [`PrcError::Malformed`] for an ill-formed program or one past the step + /// or depth ceiling; any read error from `r`. + pub fn skip_added_fields(&self, entity_type: u32, r: &mut BitReader<'_>) -> Result<(), PrcError> { + let Some(program) = self.programs.get(&entity_type) else { + return Ok(()); + }; + let mut exec = Exec { schema: self, r, vars: HashMap::new(), loop_counter: 0, steps: 0 }; + exec.program(program, 0) + } +} + +struct Exec<'s, 'r, 'd> { + schema: &'s Schema, + r: &'r mut BitReader<'d>, + vars: HashMap, + loop_counter: i64, + steps: u64, +} + +fn malformed(what: &str) -> PrcError { + PrcError::Malformed(format!("schema program: {what}")) +} + +/// The token at `*pos`, advancing past it. +fn next(prog: &[u32], pos: &mut usize) -> Result { + let t = prog.get(*pos).copied().ok_or_else(|| malformed("ends mid-statement"))?; + *pos += 1; + Ok(t) +} + +impl Exec<'_, '_, '_> { + fn tick(&mut self, depth: u32) -> Result<(), PrcError> { + self.steps += 1; + if self.steps > MAX_STEPS { + return Err(malformed("step ceiling reached")); + } + if depth > MAX_DEPTH { + return Err(malformed("nesting ceiling reached")); + } + Ok(()) + } + + fn program(&mut self, prog: &[u32], depth: u32) -> Result<(), PrcError> { + let mut pos = 0; + while pos < prog.len() { + self.stmt(prog, &mut pos, true, depth)?; + } + Ok(()) + } + + /// One data read for tokens 0-5 and 7-11, returning its value as an + /// operand. + fn read(&mut self, token: u32) -> Result { + let doubles = match token { + 0 => return Ok(i64::from(self.r.bit()?)), + 1 => return Ok(self.r.double()? as i64), + 2 => return Ok(i64::from(self.r.character()?)), + 3 => return Ok(i64::from(self.r.unsigned_integer()?)), + 4 => return Ok(i64::from(self.r.integer()?)), + 5 => { + self.r.string()?; + return Ok(0); + } + 7 => 2, + 8 => 3, + 9 => 2, + 10 => 4, + 11 => 6, + _ => return Err(malformed("not a data token")), + }; + for _ in 0..doubles { + self.r.double()?; + } + Ok(0) + } + + /// Statements until `Block_End`, which is consumed. + fn block(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> { + loop { + if prog.get(*pos) == Some(&21) { + *pos += 1; + return Ok(()); + } + self.stmt(prog, pos, run, depth + 1)?; + } + } + + /// Run `body` (one statement at `*pos`) `count` times; `*pos` ends past it. + fn repeat(&mut self, prog: &[u32], pos: &mut usize, count: i64, run: bool, depth: u32) -> Result<(), PrcError> { + let start = *pos; + self.stmt(prog, pos, false, depth + 1)?; + if run { + let saved = self.loop_counter; + for i in 0..count.max(0) { + self.tick(depth)?; + self.loop_counter = i; + let mut p = start; + self.stmt(prog, &mut p, true, depth + 1)?; + } + self.loop_counter = saved; + } + Ok(()) + } + + /// One statement; with `run` false, only step over its tokens. + fn stmt(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> { + self.tick(depth)?; + match next(prog, pos)? { + t @ (0..=5 | 7..=11) => { + if run { + self.read(t)?; + } + } + 6 => self.parent_program(prog, pos, run, depth)?, + 12 => { + next(prog, pos)?; + if run { + return Err(PrcError::Unsupported("a schema pointer field")); + } + } + 13 | 14 => { + if run { + return Err(PrcError::Unsupported("a schema pointer field")); + } + } + 15 => { + let count = self.expr(prog, pos, run, depth + 1)?; + self.repeat(prog, pos, count, run, depth)?; + } + 16 => { + let count = if run { i64::from(self.r.unsigned_integer()?) } else { 0 }; + self.repeat(prog, pos, count, run, depth)?; + } + 17 => self.conditional(prog, pos, run, depth)?, + 19 => self.block(prog, pos, run, depth)?, + 20 => { + let version = next(prog, pos)?; + let newer = version > crate::PRC_READER_VERSION; + self.block(prog, pos, run && newer, depth)?; + } + 22 => { + let v = next(prog, pos)?; + if run { + self.vars.insert(v, 0); + } + } + 23 | 24 => { + let v = next(prog, pos)?; + let value = self.expr(prog, pos, run, depth + 1)?; + if run { + self.vars.insert(v, value); + } + } + 39 | 40 => { + next(prog, pos)?; + } + t => return Err(malformed(&format!("token {t} where a statement belongs"))), + } + Ok(()) + } + + /// Token 6: run the named type's own program, the fields it inherits. + fn parent_program(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> { + let parent = next(prog, pos)?; + if run && let Some(p) = self.schema.programs.get(&parent) { + self.tick(depth + 1)?; + let mut q = 0; + while q < p.len() { + self.stmt(p, &mut q, true, depth + 1)?; + } + } + Ok(()) + } + + /// Token 17: `if` with an optional token-18 `else`. + fn conditional(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> { + let cond = self.expr(prog, pos, run, depth + 1)? != 0; + self.stmt(prog, pos, run && cond, depth + 1)?; + if prog.get(*pos) == Some(&18) { + *pos += 1; + self.stmt(prog, pos, run && !cond, depth + 1)?; + } + Ok(()) + } + + /// One expression; with `run` false its value is 0 and nothing is read. + fn expr(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result { + self.tick(depth)?; + let t = next(prog, pos)?; + match t { + 0..=4 => { + if run { + self.read(t) + } else { + Ok(0) + } + } + 25 => { + let v = next(prog, pos)?; + if !run { + return Ok(0); + } + self.vars.get(&v).copied().ok_or_else(|| malformed(&format!("variable {v} read before it is set"))) + } + 26 => Ok(i64::from(next(prog, pos)?)), + 27 => Ok(self.loop_counter), + 28 => { + if run { + Err(PrcError::Unsupported("Value_CurveIs3D")) + } else { + Ok(0) + } + } + 29..=38 => { + let a = self.expr(prog, pos, run, depth + 1)?; + let b = self.expr(prog, pos, run, depth + 1)?; + if !run { + return Ok(0); + } + Ok(match t { + 29 => a.wrapping_mul(b), + 30 => a.checked_div(b).ok_or_else(|| malformed("division by zero"))?, + 31 => a.wrapping_add(b), + 32 => a.wrapping_sub(b), + 33 => i64::from(a < b), + 34 => i64::from(a <= b), + 35 => i64::from(a > b), + 36 => i64::from(a >= b), + 37 => i64::from(a == b), + _ => i64::from(a != b), + }) + } + t => Err(malformed(&format!("token {t} where a value belongs"))), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::testw::W; + + fn schema(entries: &[(u32, &[u32])]) -> W { + let mut w = W::default(); + w.uint(entries.len() as u32); + for (t, toks) in entries { + w.uint(*t).uint(toks.len() as u32); + for k in *toks { + w.uint(*k); + } + } + w + } + + fn run(entries: &[(u32, &[u32])], data: &W) -> (Result<(), PrcError>, usize) { + let mut w = schema(entries); + w.append(data); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + let s = Schema::read(&mut r).unwrap(); + let start = r.position(); + let res = s.skip_added_fields(172, &mut r); + (res, r.position() - start) + } + + #[test] + fn integer_string_and_boolean_tokens_consume_their_fields() { + let mut d = W::default(); + d.bit(true).uint(300).int(-7).string(Some("ab")).put(0x41, 8); + let want = d.len(); + d.uint(99); // not part of the added fields + let (res, used) = run(&[(172, &[0, 3, 4, 5, 2])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn data_tokens_consume_their_fields() { + let mut d = W::default(); + d.bit(true).double(1.5).uint(300).int(-7).string(Some("ab")).double(2.0).double(3.0); + let want = d.len(); + d.uint(99); // not part of the added fields + let (res, used) = run(&[(172, &[0, 1, 3, 4, 5, 7])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn vector_extent_and_box_tokens_read_their_doubles() { + // Vec3, Extent1D, Extent2D, BBox: 3 + 2 + 4 + 6 Doubles. + let mut d = W::default(); + for i in 0..15 { + d.double(f64::from(i) + 0.5); + } + let want = d.len(); + d.uint(99); + let (res, used) = run(&[(172, &[8, 9, 10, 11])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn arithmetic_and_comparison_operators() { + // For (5 - 3) { UInt }, then If (2 < 2) { Bool } Else { Char }. + let mut d = W::default(); + d.uint(7).uint(8).put(0x41, 8); + let want = d.len(); + d.uint(99); + let prog = [15, 32, 26, 5, 26, 3, 3, 17, 33, 26, 2, 26, 2, 0, 18, 2]; + let (res, used) = run(&[(172, &prog)], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn an_absent_type_is_a_no_op() { + let mut w = schema(&[(300, &[3])]); + w.uint(5); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + let s = Schema::read(&mut r).unwrap(); + let at = r.position(); + s.skip_added_fields(172, &mut r).unwrap(); + assert_eq!(r.position(), at); + assert!(!s.is_empty()); + } + + #[test] + fn simple_for_reads_its_count_then_repeats_the_body() { + let mut d = W::default(); + d.int(3).uint(1).uint(2).uint(3); + let want = d.len(); + d.uint(77); + let (res, used) = run(&[(172, &[16, 3])], &d); + res.unwrap(); + assert_eq!(used, want); + + // A block body. + let mut d = W::default(); + d.int(2).bit(true).uint(9).bit(false).uint(8); + let want = d.len(); + let (res, used) = run(&[(172, &[16, 19, 0, 3, 21])], &d); + res.unwrap(); + assert_eq!(used, want); + + // A count whose top byte is 0x80 or higher, written either way. + for signed in [false, true] { + let mut d = W::default(); + if signed { + d.int(200); + } else { + d.uint(200); + } + (0..200).for_each(|_| { + d.bit(true); + }); + let want = d.len(); + d.uint(77); + let (res, used) = run(&[(172, &[16, 0])], &d); + res.unwrap(); + assert_eq!(used, want, "signed={signed}"); + } + } + + #[test] + fn if_else_and_variables_choose_the_branch_by_file_data() { + // DeclareAndSet v0 = ; if v0 > 1 { UInt } else { Character }. + let prog: &[u32] = &[24, 0, 3, 17, 35, 25, 0, 26, 1, 3, 18, 2]; + let mut d = W::default(); + d.uint(5).uint(4250); + let want = d.len(); + let (res, used) = run(&[(172, prog)], &d); + res.unwrap(); + assert_eq!(used, want); + + let mut d = W::default(); + d.uint(0).put(0xAB, 8); + let want = d.len(); + let (res, used) = run(&[(172, prog)], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn for_counts_by_expression_and_exposes_the_counter() { + // For (2 * 2) { if counter == 2 { UInt } } => one UInt read. + let prog: &[u32] = &[15, 29, 26, 2, 26, 2, 17, 37, 27, 26, 2, 3]; + let mut d = W::default(); + d.uint(1234); + let want = d.len(); + let (res, used) = run(&[(172, prog)], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn version_blocks_run_only_when_newer_than_the_reader() { + let mut d = W::default(); + d.uint(42); + let want = d.len(); + let (res, used) = run(&[(172, &[20, 8137, 3, 21, 20, 9000, 3, 21])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn father_type_runs_the_parent_program_and_obsolete_tokens_swallow_one() { + let mut d = W::default(); + d.uint(1).uint(2); + let want = d.len(); + let (res, used) = run(&[(172, &[39, 3, 6, 50, 40, 8]), (50, &[3, 3])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn hostile_programs_are_refused_not_run_forever() { + let d = W::default(); + // For (1000000 * 1000000) { empty block }. + let prog: &[u32] = &[15, 29, 26, 1_000_000, 26, 1_000_000, 19, 21]; + assert!(matches!(run(&[(172, prog)], &d).0, Err(PrcError::Malformed(_)))); + // Father_Type recursing into itself. + assert!(matches!(run(&[(172, &[6, 172])], &d).0, Err(PrcError::Malformed(_)))); + // A pointer field. + assert!(matches!(run(&[(172, &[13])], &d).0, Err(PrcError::Unsupported(_)))); + // A stray Block_End, an unterminated block, a division by zero. + assert!(matches!(run(&[(172, &[21])], &d).0, Err(PrcError::Malformed(_)))); + assert!(matches!(run(&[(172, &[19, 0])], &d).0, Err(PrcError::Malformed(_)))); + assert!(matches!(run(&[(172, &[23, 0, 30, 26, 1, 26, 0])], &d).0, Err(PrcError::Malformed(_)))); + } + + #[test] + fn a_duplicated_entity_type_is_malformed() { + let bytes = schema(&[(172, &[3]), (172, &[3])]).bytes(); + assert!(matches!(Schema::read(&mut BitReader::new(&bytes)), Err(PrcError::Malformed(_)))); + } +} diff --git a/crates/prc/src/testw.rs b/crates/prc/src/testw.rs new file mode 100644 index 0000000000..4706a20ef8 --- /dev/null +++ b/crates/prc/src/testw.rs @@ -0,0 +1,101 @@ +//! Test-only PRC bit writer: the inverse of [`crate::BitReader`]. + +#![allow(clippy::expect_used)] + +use crate::acof; + +/// An MSB-first bit sink for building test streams. +#[derive(Debug, Default, Clone)] +pub(crate) struct W { + bits: Vec, +} + +impl W { + /// The low `n` bits of `v` (`n` ≤ 64), most significant first. + pub(crate) fn put(&mut self, v: u64, n: u32) -> &mut Self { + for i in (0..n).rev() { + self.bits.push((v >> i) & 1 == 1); + } + self + } + + /// One bit; a PRC `Boolean`. + pub(crate) fn bit(&mut self, b: bool) -> &mut Self { + self.bits.push(b); + self + } + + /// Bits written so far. + pub(crate) fn len(&self) -> usize { + self.bits.len() + } + + /// Every bit of `other`, unaligned. + pub(crate) fn append(&mut self, other: &Self) -> &mut Self { + self.bits.extend_from_slice(&other.bits); + self + } + + /// An `UnsignedInteger` [WD 11.10]. + pub(crate) fn uint(&mut self, mut v: u32) -> &mut Self { + while v != 0 { + self.bit(true).put(u64::from(v & 0xff), 8); + v >>= 8; + } + self.bit(false) + } + + /// An `Integer` [WD 11.11]: two's-complement groups, low first, each followed by a + /// continue bit. + pub(crate) fn int(&mut self, v: i32) -> &mut Self { + if v == 0 { + return self.bit(false); + } + self.bit(true); + let mut v = i64::from(v); + loop { + let group = v & 0xff; + self.put(group.cast_unsigned(), 8); + v >>= 8; + let done = (v == 0 && group & 0x80 == 0) || (v == -1 && group & 0x80 != 0); + self.bit(!done); + if done { + return self; + } + } + } + + /// A `String` [WD 11.4]: presence bit, byte count, raw UTF-8 bytes. + pub(crate) fn string(&mut self, s: Option<&str>) -> &mut Self { + let Some(s) = s else { + return self.bit(false); + }; + self.bit(true).uint(u32::try_from(s.len()).expect("a short test string")); + for b in s.bytes() { + self.put(u64::from(b), 8); + } + self + } + + /// A finite `Double` [WD 11.17]: zero as the two-bit zero code; anything else as its + /// exponent row, the sign, then the mantissa as literal bytes. + pub(crate) fn double(&mut self, v: f64) -> &mut Self { + if v == 0.0 { + return self.put(0b01, 2); + } + let bits = v.to_bits(); + let exponent = u32::try_from((bits >> 52) & 0x7ff).expect("11 bits"); + let (n, c) = acof::exponent_code(exponent).expect("the Double table has a row per exponent"); + let le = bits.to_le_bytes(); + self.put(u64::from(c), n).bit(v < 0.0).bit(true).put(u64::from(le[6] & 0x0f), 4); + for b in le[..6].iter().rev() { + self.bit(true).put(u64::from(*b), 8); + } + self + } + + /// The bits packed MSB-first, the last byte zero-padded. + pub(crate) fn bytes(&self) -> Vec { + self.bits.chunks(8).map(|c| c.iter().enumerate().fold(0u8, |a, (i, &b)| a | (u8::from(b) << (7 - i)))).collect() + } +} diff --git a/crates/prc/tests/container.rs b/crates/prc/tests/container.rs new file mode 100644 index 0000000000..dc9970704b --- /dev/null +++ b/crates/prc/tests/container.rs @@ -0,0 +1,114 @@ +//! The PRC container walk [WD 6.1-6.2] against synthetic streams. + +#![allow(clippy::unwrap_used)] + +use std::io::Write; + +use pdfcraft_prc::{PrcError, PrcFile, SectionKind, UniqueId}; + +fn zlib(data: &[u8]) -> Vec { + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(data).unwrap(); + e.finish().unwrap() +} + +fn u32s(out: &mut Vec, vs: &[u32]) { + for v in vs { + out.extend_from_slice(&v.to_le_bytes()); + } +} + +/// One file structure with the given section payloads and model file. +fn build(sections: [&[u8]; 5], model: &[u8], padding: usize) -> Vec { + const HEADER_LEN: usize = 107; + let mut fs_header = b"PRC".to_vec(); + u32s(&mut fs_header, &[8137, 8137, 5, 6, 7, 8, 0, 0, 0, 0, 1, 3]); + fs_header.extend_from_slice(b"\xff\xd8\xff"); + + let mut body = fs_header.clone(); + let mut offs = vec![HEADER_LEN]; + for s in sections { + offs.push(HEADER_LEN + body.len()); + body.extend(zlib(s)); + body.extend(std::iter::repeat_n(0u8, padding)); + } + let mf_start = HEADER_LEN + body.len(); + body.extend(zlib(model)); + let mf_end = HEADER_LEN + body.len(); + + let mut out = b"PRC".to_vec(); + u32s(&mut out, &[8137, 8137, 1, 2, 3, 4, 0, 0, 0, 0, 1]); + u32s(&mut out, &[5, 6, 7, 8, 0, 6]); + u32s(&mut out, &offs.iter().map(|&o| o as u32).collect::>()); + u32s(&mut out, &[mf_start as u32, mf_end as u32, 0]); + assert_eq!(out.len(), HEADER_LEN); + out.extend(body); + out +} + +const SECTIONS: [&[u8]; 5] = [b"globals", b"tree", b"tess", b"", b"extra"]; + +#[test] +fn the_container_walk_inflates_every_section() { + for padding in [0, 3] { + let f = PrcFile::parse(&build(SECTIONS, b"model file", padding)).unwrap(); + assert_eq!(f.header.min_version_for_read, 8137); + assert_eq!(f.header.file_id, UniqueId([1, 2, 3, 4])); + assert_eq!(f.file_structures.len(), 1); + let fs = &f.file_structures[0]; + assert_eq!(fs.id, UniqueId([5, 6, 7, 8])); + assert_eq!(fs.pictures, vec![b"\xff\xd8\xff".to_vec()]); + for (kind, want) in SectionKind::ALL.into_iter().zip(SECTIONS) { + assert_eq!(fs.section(kind), want, "{}", kind.name()); + } + assert_eq!(f.model_file, b"model file"); + } +} + +#[test] +fn damaged_containers_are_refused_by_name() { + let good = build(SECTIONS, b"m", 0); + assert_eq!(PrcFile::parse(b"U3D\0").unwrap_err(), PrcError::NotPrc); + assert!(matches!(PrcFile::parse(&good[..40]), Err(PrcError::Truncated(_)))); + + // Reserved field (offset 63) not zero. + let mut bad = good.clone(); + bad[63] = 1; + assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Malformed(_)))); + + // File-structure id disagrees with its description (FS header id at 107+11). + let mut bad = good.clone(); + bad[118] ^= 0xff; + assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Malformed(_)))); + + // A section that is not zlib: corrupt the globals stream's header byte. + let globals = u32::from_le_bytes(good[75..79].try_into().unwrap()) as usize; + let mut bad = good.clone(); + bad[globals] = 0; + assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Inflate { section: "globals", .. }))); + + // Offsets out of order. + let mut bad = good; + bad[75..79].copy_from_slice(&1u32.to_le_bytes()); + assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Malformed(_)))); +} + +#[test] +fn the_inflation_ceiling_is_reported_not_exceeded() { + let f = build([&[0u8; 600], &[0u8; 600], b"", b"", b""], b"", 0); + assert!(PrcFile::parse_with_limit(&f, 1200).is_ok()); + assert_eq!(PrcFile::parse_with_limit(&f, 1199).unwrap_err(), PrcError::TooLarge { limit: 1199 }); +} + +#[test] +fn an_offset_or_count_past_the_data_is_an_error_not_a_panic() { + let mut bad = build(SECTIONS, b"m", 0); + // The model file's start (header offset 95) far past the end, its end further still. + bad[95..99].copy_from_slice(&u32::MAX.to_le_bytes()); + bad[99..103].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(PrcFile::parse(&bad).is_err()); + // A section count that claims more offsets than the data holds. + let mut bad = build(SECTIONS, b"m", 0); + bad[67..71].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Truncated(_)))); +} diff --git a/crates/prc/tests/proptests.rs b/crates/prc/tests/proptests.rs new file mode 100644 index 0000000000..e33e095bb2 --- /dev/null +++ b/crates/prc/tests/proptests.rs @@ -0,0 +1,41 @@ +//! Arbitrary bytes through every reader: an error or a value, never a panic. + +use pdfcraft_prc::{BitReader, PrcFile, Schema}; +use proptest::prelude::*; + +proptest! { + #[test] + fn any_bytes_parse_or_fail_cleanly(data in proptest::collection::vec(any::(), 0..512), magic in any::()) { + let mut data = data; + if magic && data.len() >= 3 { + data[..3].copy_from_slice(b"PRC"); + } + let _ = PrcFile::parse_with_limit(&data, 1 << 20); + } + + #[test] + fn any_bits_read_or_fail_cleanly(data in proptest::collection::vec(any::(), 0..64), n in 0u32..40) { + let mut r = BitReader::new(&data); + let _ = r.unsigned_integer(); + let _ = r.integer(); + let _ = r.integer_vbn(n); + let _ = r.double_vbn(0.5, n); + let _ = r.nbits_then_unsigned(); + let _ = r.string(); + let _ = r.float_as_bytes(); + let _ = r.double(); + let _ = r.skip_bits(usize::try_from(n).unwrap_or(0)); + prop_assert!(r.position() <= data.len() * 8); + } + + #[test] + fn any_schema_runs_or_fails_cleanly(data in proptest::collection::vec(any::(), 0..256), entity in 0u32..4) { + let mut r = BitReader::new(&data); + if let Ok(schema) = Schema::read(&mut r) { + let types: Vec = schema.entity_types().chain([entity]).collect(); + for t in types { + let _ = schema.skip_added_fields(t, &mut r.clone()); + } + } + } +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index b961ebef66..4f59c1dc94 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -34,6 +34,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("filters", Class::Standalone(0)), ("crypt", Class::Standalone(0)), ("arlington", Class::Standalone(0)), + ("prc", Class::Standalone(0)), // L1 object layer ("cos", Class::Standalone(1)), // L2 core model