Files
pdfcraft/crates/prc/src/container.rs
T
KenM76andClaude Opus 5.5 b4ba4edc7f M12: PRC reader: charge copied blocks, refuse overflowing schema expressions
Review fixes from #6087:

- Picture and uncompressed blocks are charged to the parse ceiling before
  they are copied. File-structure headers may overlap, so each structure's
  copy counts. The ceiling test now includes the 3-byte picture.
- Schema expressions use checked multiply, add and subtract; an overflow is
  Malformed rather than a wrapped loop count.
- Each face's triangles are counted from its entity sizes and charged before
  it is triangulated.
- Matrix::mirrors divides each row by its largest entry before taking the
  determinants, so a mirror whose determinant underflows still reverses the
  winding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-03 23:58:08 -05:00

341 lines
13 KiB
Rust

//! 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, together with the
/// picture and uncompressed blocks it copies.
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<Vec<u8>>,
sections: [Vec<u8>; 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, PrcError> {
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<FileStructure>,
/// The inflated model-file section: its schema, then the model file entity.
pub model_file: Vec<u8>,
/// The `UncompressedFiles` blocks after the file header [WD 6.1.1], which picture
/// entities may also reference.
pub uncompressed_files: Vec<Vec<u8>>,
}
/// 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<u32, PrcError> {
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<UniqueId, PrcError> {
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], each charged to
/// `budget` before it is copied.
fn blocks(&mut self, what: &'static str, budget: &mut Budget) -> Result<Vec<Vec<u8>>, 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)?);
let block = self.take(len, what)?;
budget.charge(block.len())?;
Ok(block.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).
pub(crate) 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<usize>), 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<FileStructure, PrcError> {
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")?;
// Headers may overlap, so each structure's copy of its pictures is charged.
let pictures = h.blocks("file structure pictures", budget)?;
let mut sections: [Vec<u8>; 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<Vec<u8>, 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.charge(out.len())?;
Ok(out)
}
/// Spend `n` bytes of the ceiling.
fn charge(&mut self, n: usize) -> Result<(), PrcError> {
self.left = self.left.checked_sub(n).ok_or(PrcError::TooLarge { limit: self.limit })?;
Ok(())
}
}
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, inflated sections and copied blocks together.
///
/// # Examples
/// ```
/// use pdfcraft_prc::{PrcError, PrcFile};
/// assert_eq!(PrcFile::parse(b"%PDF"), Err(PrcError::NotPrc));
/// ```
pub fn parse(data: &[u8]) -> Result<Self, PrcError> {
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<Self, PrcError> {
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::<Result<Vec<_>, _>>()?;
let mf_start = offset(r.u32("model file offsets")?);
let mf_end = offset(r.u32("model file offsets")?);
let mut budget = Budget { left: limit, limit };
let uncompressed_files = r.blocks("uncompressed files", &mut budget)?;
if mf_end < mf_start {
return Err(PrcError::Malformed("model file ends before it starts".into()));
}
if mf_end > data.len() {
return Err(PrcError::Malformed(format!("model file ends at {mf_end}, past the {}-byte stream", data.len())));
}
// prc-rs: the last section runs to the model file's start.
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::<Result<Vec<_>, _>>()?;
let model_file = budget.inflate(data, (mf_start, mf_end), "model file")?;
Ok(PrcFile { header, file_structures, model_file, uncompressed_files })
}
}