M12: PRC reader: a test for each input guard (#1297)

Disabling each input guard in turn left several with no failing test.
Each now has one that pins the guard's own refusal, not a later error:

- a zero Huffman code-length width, and a zero code length;
- a boolean array longer than the data, refused before any bit is read;
- a character array count past the data;
- no file structures, or more than MAX_FILE_STRUCTURES;
- a schema entry count past the data;
- normal angles wider than 16 bits;
- more texture sets than a face may carry (now by message);
- representation sets nested past the ceiling (and at it, accepted);
- a prototype chain past the ceiling;
- the element allowance, at each place it is charged: an entity's
  counted array, uncompressed character and short arrays, a schema
  program, and across one walk's readers (walk_within takes the
  allowance so a test can set it; an allowance that fits one file
  structure does not fit two).

Each fails with its guard disabled (the texture-set one by aborting on
the allocation the guard prevents).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
KenM76andClaude Opus 5.5 committed 2026-11-02 13:52:20 -05:00
1 parent a7cc7586d1
commit 5eb484c235
6 files changed
+165 -4

No files matched your search

+49
View File
@@ -352,4 +352,53 @@ mod tests {
assert_eq!(r.elements_left(), 1);
assert!(matches!(bool_array(&mut BitReader::with_elements(&[0], 1), 2), Err(PrcError::TooManyElements { .. })));
}
/// Uncompressed character and short arrays are charged against the allowance too, and a
/// character count past the data is refused by name before anything is read.
#[test]
fn uncompressed_arrays_are_charged_and_their_counts_checked() {
let mut w = W::default();
w.uint(3).put(0, 8).put(1, 8).put(2, 8);
let chars = w.bytes();
let read = |n| character_array(&mut BitReader::with_elements(&chars, n), 8, Some(false), false);
assert!(matches!(read(2), Err(PrcError::TooManyElements { .. })));
assert_eq!(read(3).unwrap(), [0, 1, 2]);
let mut w = W::default();
w.bit(false).uint(2).put(1, 8).put(0, 8).put(2, 8).put(0, 8);
let shorts = w.bytes();
assert!(matches!(short_array(&mut BitReader::with_elements(&shorts, 1), 8), Err(PrcError::TooManyElements { .. })));
assert_eq!(short_array(&mut BitReader::with_elements(&shorts, 2), 8).unwrap(), [1, 2]);
let mut w = W::default();
w.uint(1000).put(7, 8);
let short_of_data = w.bytes();
let res = character_array(&mut BitReader::new(&short_of_data), 8, Some(false), false);
assert!(matches!(res, Err(PrcError::Truncated("a character array"))), "{res:?}");
}
fn malformed_by(res: Result<Vec<i32>, PrcError>, what: &str) -> bool {
matches!(res, Err(PrcError::Malformed(m)) if m == what)
}
#[test]
fn a_zero_code_length_width_or_length_is_refused_by_name() {
let mut w = W::default();
w.huffman_raw(2, 0, &[(1, 0, 0)], &[]);
let bytes = w.bytes();
assert!(malformed_by(huffman(&mut BitReader::new(&bytes), 2, false), "Huffman code-length width"));
let mut w = W::default();
w.huffman_raw(2, 2, &[(1, 0, 0)], &[]);
let bytes = w.bytes();
assert!(malformed_by(huffman(&mut BitReader::new(&bytes), 2, false), "Huffman code length"));
}
#[test]
fn a_boolean_array_past_the_data_is_refused_before_it_is_read() {
let mut w = W::default();
w.put(0b1011, 4);
let bytes = w.bytes();
let mut r = BitReader::new(&bytes);
let n = r.remaining() + 1;
assert_eq!(bool_array(&mut r, n), Err(PrcError::Truncated("a boolean array")));
assert_eq!(r.position(), 0, "nothing read");
}
}
+16
View File
@@ -341,6 +341,14 @@ mod tests {
w
}
/// A schema program's tokens are charged against the reader's element allowance.
#[test]
fn program_tokens_are_charged_against_the_allowance() {
let bytes = schema(&[(172, &[1, 2, 3])]).bytes();
assert!(matches!(Schema::read(&mut BitReader::with_elements(&bytes, 2)), Err(PrcError::TooManyElements { .. })));
assert!(Schema::read(&mut BitReader::with_elements(&bytes, 3)).is_ok());
}
fn run(entries: &[(u32, &[u32])], data: &W) -> (Result<(), PrcError>, usize) {
let mut w = schema(entries);
w.append(data);
@@ -523,4 +531,12 @@ mod tests {
let bytes = schema(&[(172, &[3]), (172, &[3])]).bytes();
assert!(matches!(Schema::read(&mut BitReader::new(&bytes)), Err(PrcError::Malformed(_))));
}
#[test]
fn an_entry_count_past_the_data_is_refused_before_any_entry_is_read() {
let mut w = W::default();
w.uint(1_000).uint(172).uint(0);
let bytes = w.bytes();
assert_eq!(Schema::read(&mut BitReader::new(&bytes)).unwrap_err(), PrcError::Truncated("the schema"));
}
}
+28 -2
View File
@@ -216,7 +216,9 @@ fn too_many_texture_sets_are_refused_before_anything_is_allocated() {
// An empty fan on a face claiming 2^32 - 1 texture sets: no slot bounds the count.
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices: &[0], faces: &[(0x400, 0, &[1, 0])], textures: u32::MAX, ..Mesh::default() });
assert!(matches!(decode(&section(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(_))));
assert!(
matches!(decode(&section(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(m)) if m == format!("{} texture-coordinate sets on a face", u32::MAX))
);
let sets = u32::try_from(MAX_TEXTURE_SETS).unwrap();
let mut ok = W::default();
tess_3d(&mut ok, 8137, &Mesh { indices: &[0], faces: &[(0x400, 0, &[1, 0])], textures: sets, ..Mesh::default() });
@@ -425,6 +427,11 @@ fn attributes_names_and_user_data_are_read_past() {
/// A one-triangle TESS_3D_Compressed. `full` takes every optional branch: stored normals,
/// colours, line attributes, texture, behaviours. Otherwise `three_t` picks the 3T edge form.
fn compressed(w: &mut W, v: u32, full: bool, three_t: bool) {
compressed_with_angles(w, v, full, three_t, 10);
}
/// [`compressed`] whose `full` form stores `angle_bits`-bit normal angles.
fn compressed_with_angles(w: &mut W, v: u32, full: bool, three_t: bool, angle_bits: u8) {
w.uint(173).bit(false).bit(true).double(0.001);
if v >= 7031 {
w.put(0, 32).put(0, 32).put(0, 32);
@@ -456,7 +463,7 @@ fn compressed(w: &mut W, v: u32, full: bool, three_t: bool) {
w.uint(3).put(0, 3).uint(0);
}
if full {
w.bit(false).put(10, 8).uint(3).put(0b101, 3);
w.bit(false).put(u64::from(angle_bits), 8).uint(3).put(0b101, 3);
w.bit(true).huffman(10, 2, &[(5, 0b10, 2)], &[5, 5]);
w.bit(true); // is_face_planar
w.bit(true).bit(true).bit(false).uint(5);
@@ -586,3 +593,22 @@ fn a_prc_file_yields_its_tessellations() {
let t = f.file_structures[0].tessellations().unwrap();
assert_eq!(mesh(&t[0]).triangles, [[0, 1, 2], [0, 2, 3]]);
}
#[test]
fn normal_angles_wider_than_16_bits_are_refused_by_name() {
let mut b = W::default();
compressed_with_angles(&mut b, 8137, true, true, 17);
assert!(matches!(decode(&section(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(m)) if m == "17-bit normal angles"));
}
/// An entity's counted array is charged against the reader's element allowance.
#[test]
fn counted_entity_arrays_are_charged_against_the_allowance() {
let mut w = W::default();
w.uint(3).uint(1).uint(2).uint(3);
let bytes = w.bytes();
let schema = Schema::default();
let read = |n| Ctx::new(BitReader::with_elements(&bytes, n), &schema, 8137).uints("indices");
assert!(matches!(read(2), Err(PrcError::TooManyElements { .. })));
assert_eq!(read(3).unwrap(), [1, 2, 3]);
}
+11 -1
View File
@@ -269,11 +269,21 @@ impl PrcFile {
}
fn walk(&self, rule: StyleAlpha, scope: EntityOverrides) -> Result<(Vec<Placement>, Vec<ModelNode>), PrcError> {
self.walk_within(rule, scope, MAX_DECODED_ELEMENTS)
}
/// [`Self::walk`] with `elements` decoded array elements allowed across all its readers.
pub(crate) fn walk_within(
&self,
rule: StyleAlpha,
scope: EntityOverrides,
elements: usize,
) -> Result<(Vec<Placement>, Vec<ModelNode>), PrcError> {
if !self.stores_tree() {
return Ok((Vec::new(), Vec::new()));
}
// One element allowance for the whole walk, so many sections cannot each decode a full one.
let mut left = MAX_DECODED_ELEMENTS;
let mut left = elements;
let mut trees = Vec::with_capacity(self.file_structures.len());
for fs in &self.file_structures {
let schema = fs.schema()?;
+48
View File
@@ -398,6 +398,25 @@ fn a_prototype_cycle_is_an_error() {
assert!(malformed(single(&[], &occs).model_tree()).contains("own prototype"));
}
/// One element allowance covers the whole walk: an allowance that fits one file structure does
/// not fit two, and running short of it is reported as such.
#[test]
fn one_element_allowance_covers_the_whole_walk() {
// A part of 64 items, so each file structure's tree section, not its globals or the model
// file, decodes most of the elements.
let wide = || part(None, PLAIN, 64, |w| (0..64).for_each(|_| poly_item(w, 0, 0, 1, PLAIN)));
let fs = || Fs { globals: palette(), tree: tree(&[wide()], &[Occ { part: 1, ..Occ::default() }]), tess: two_face_square() };
let one = PrcFile::parse(&prc(&[fs()], &model(&[(0, 1)]))).unwrap();
let two = PrcFile::parse(&prc(&[fs(), fs()], &model(&[(0, 1), (1, 1)]))).unwrap();
let walk = |f: &PrcFile, n| f.walk_within(StyleAlpha::default(), EntityOverrides::default(), n);
let short = |f: &PrcFile, n| matches!(walk(f, n), Err(PrcError::TooManyElements { .. }));
// One structure decodes about 70 elements, two about 140.
assert!(short(&one, 32));
assert_eq!(walk(&one, 100).map(|w| w.0.len()), Ok(64));
assert!(short(&two, 100), "each structure fits in 100, both do not");
assert_eq!(walk(&two, 200).map(|w| w.0.len()), Ok(128));
}
#[test]
fn an_exponential_walk_is_cut_off() {
// Occurrence k has occurrence k + 1 twice as sons: 2^21 visits.
@@ -590,3 +609,32 @@ fn every_truncation_of_a_tree_or_globals_is_an_error_not_a_panic() {
}
}
}
#[test]
fn a_prototype_chain_past_the_ceiling_is_an_error() {
// Occurrence k (1-based) has occurrence k + 1 as its prototype; none has a part.
let n = u32::try_from(super::MAX_DEPTH).unwrap() + 3;
let occs: Vec<_> = (1..=n).map(|k| Occ { prototype: if k < n { k + 1 } else { 0 }, ..Occ::default() }).collect();
assert!(malformed(single(&[], &occs).model_tree()).contains("prototype chain too long"));
}
/// `depth` representation sets, each holding the next, around one poly-B-rep item.
fn nested_sets(w: &mut W, depth: usize) {
if depth == 0 {
return poly_item(w, 0, 0, 1, PLAIN);
}
w.uint(super::RI_SET);
graphics(w, None, 0, PLAIN);
w.uint(0).uint(0).uint(1);
nested_sets(w, depth - 1);
w.uint(0); // user data
}
#[test]
fn representation_sets_nested_past_the_ceiling_are_an_error() {
let deep = part(None, PLAIN, 1, |w| nested_sets(w, super::MAX_DEPTH + 1));
let occs = [Occ { part: 1, ..Occ::default() }];
assert!(malformed(single(&[deep], &occs).placements()).contains("representation sets nest too deep"));
let ok = part(None, PLAIN, 1, |w| nested_sets(w, super::MAX_DEPTH));
assert_eq!(single(&[ok], &occs).placements().unwrap().len(), 1);
}
+13 -1
View File
@@ -4,7 +4,7 @@
use std::io::Write;
use pdfcraft_prc::{PrcError, PrcFile, SectionKind, UniqueId};
use pdfcraft_prc::{MAX_FILE_STRUCTURES, PrcError, PrcFile, SectionKind, UniqueId};
fn zlib(data: &[u8]) -> Vec<u8> {
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
@@ -112,3 +112,15 @@ fn an_offset_or_count_past_the_data_is_an_error_not_a_panic() {
bad[67..71].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Truncated(_))));
}
#[test]
fn no_file_structures_or_more_than_the_cap_is_refused_by_name() {
let good = build(SECTIONS, b"m", 0);
// The file-structure count follows the magic, two versions and two ids.
assert_eq!(good[43..47], 1u32.to_le_bytes());
for n in [0, u32::try_from(MAX_FILE_STRUCTURES).unwrap() + 1] {
let mut bad = good.clone();
bad[43..47].copy_from_slice(&n.to_le_bytes());
assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Malformed(m)) if m == format!("{n} file structures")), "{n}");
}
}