aep container: size sibling lists, hex and write output once; borrow plain .aepx attributes (#3275)

- read.rs: count the chunk headers of a sequence first and allocate the
  children Vec once (bounded by the input and by the chunk limit).
- aepx.rs: decode_hex sizes its output once; attribute values without
  an entity are borrowed instead of copied through decode_entities.
- typed.rs: PropGroup::get compares match names without building a
  String (same result as the lossy compare, invalid UTF-8 included).
- write.rs: reserve the exact output size up front (try_reserve, so a
  hand-built tree cannot abort on the allocation).
- read.rs, settings.rs, values.rs: use the shared bytes.rs readers.

Output is identical on the corpus (tree, written bytes, import).
This commit is contained in:
vangel76
2026-11-08 03:17:17 +01:00
parent 84382487bb
commit 38d51dd185
6 changed files with 265 additions and 28 deletions
+78 -10
View File
@@ -122,7 +122,7 @@ struct Reader<'d> {
trailer: Option<Vec<u8>>,
}
impl Reader<'_> {
impl<'d> Reader<'d> {
fn run(mut self) -> Result<AepFile<'static>, AepError> {
if self.data.starts_with(&[0xef, 0xbb, 0xbf]) {
self.pos = 3;
@@ -227,7 +227,8 @@ impl Reader<'_> {
let at = self.pos;
self.pos = self.pos.saturating_add(1);
let name = self.name()?;
let mut attrs: Vec<((usize, usize), Vec<u8>)> = Vec::new();
let data = self.data;
let mut attrs: Vec<((usize, usize), Cow<'d, [u8]>)> = Vec::new();
let self_closing = loop {
self.skip_ws();
let rest = self.rest();
@@ -255,12 +256,19 @@ impl Reader<'_> {
}
let vstart = self.pos.saturating_add(1);
let vend = self.find(vstart, &[quote]).ok_or(xml(vstart, "an attribute value is not closed"))?;
let raw = self.data.get(vstart..vend).unwrap_or(&[]);
let raw = data.get(vstart..vend).unwrap_or(&[]);
if raw.contains(&b'<') {
return Err(xml(vstart, "`<` inside an attribute value"));
}
let mut value = Vec::new();
decode_entities(raw, vstart, &mut value)?;
// A value without `&` is its own decoding (most of them, every `bdata` After Effects
// writes): borrow it instead of copying it.
let value = if raw.contains(&b'&') {
let mut value = Vec::new();
decode_entities(raw, vstart, &mut value)?;
Cow::Owned(value)
} else {
Cow::Borrowed(raw)
};
self.pos = vend.saturating_add(1);
attrs.push((key, value));
};
@@ -476,11 +484,15 @@ fn decode_hex(text: &[u8], offset: usize) -> Result<Vec<u8>, AepError> {
_ => None,
};
let (pairs, _) = text.as_chunks::<2>();
pairs
.iter()
.map(|[hi, lo]| digit(*hi).zip(digit(*lo)).map(|(h, l)| (h << 4) | l))
.collect::<Option<Vec<u8>>>()
.ok_or(xml(offset, "bdata holds a character that is not a hex digit"))
// Sized once (collecting into `Option<Vec>` has no size hint and regrows step by step).
let mut out = Vec::with_capacity(pairs.len());
for [hi, lo] in pairs {
match (digit(*hi), digit(*lo)) {
(Some(h), Some(l)) => out.push((h << 4) | l),
_ => return Err(xml(offset, "bdata holds a character that is not a hex digit")),
}
}
Ok(out)
}
/// The JSON body of an `alas` chunk from the attributes of a `fileReference`, keys in document
@@ -628,6 +640,62 @@ mod tests {
assert!(matches!(read_aepx(b"<AfterEffectsProject>\xff</AfterEffectsProject>"), Err(AepError::Xml { .. })));
}
#[test]
fn attribute_values_decode_entities_only_where_they_have_them() {
// An entity inside `bdata` still decodes before the hex does.
let f = read_aepx(doc("<a bdata=\"&#x30;1&#65;b\"/>").as_bytes()).unwrap();
assert_eq!(f.chunks[0].bytes(), Some(&[0x01, 0xab][..]));
// A bad entity in any attribute, even of an element that is skipped, is still an error.
for bad in ["<a other=\"&nbsp;\"/>", "<Unknown x=\"&bogus;\"/>", "<a bdata=\"00\" note=\"&amp\"/>"] {
assert!(matches!(read_aepx(doc(bad).as_bytes()), Err(AepError::Xml { .. })), "{bad}");
}
// Without `&` the value is taken as written.
let f = read_aepx(doc("<a bdata=\"0aFf\" x=\"y\"/>").as_bytes()).unwrap();
assert_eq!(f.chunks[0].bytes(), Some(&[0x0a, 0xff][..]));
}
#[test]
fn decode_hex_matches_the_collecting_version() {
fn old(text: &[u8], offset: usize) -> Result<Vec<u8>, AepError> {
if text.len() % 2 == 1 {
return Err(xml(offset, "bdata has an odd number of hex digits"));
}
let digit = |b: u8| match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
};
let (pairs, _) = text.as_chunks::<2>();
pairs
.iter()
.map(|[hi, lo]| digit(*hi).zip(digit(*lo)).map(|(h, l)| (h << 4) | l))
.collect::<Option<Vec<u8>>>()
.ok_or(xml(offset, "bdata holds a character that is not a hex digit"))
}
let alphabet = b"0123456789abcdefABCDEFgG \0";
let mut seed = 0x2545_f491_4f6c_dd1du64;
for len in 0..200usize {
for _ in 0..20 {
let text: Vec<u8> = (0..len)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
// Mostly hex digits, now and then a byte that is not one.
let i = (seed % 1000) as usize;
alphabet[if i < 990 { i % 22 } else { 22 + i % 4 }]
})
.collect();
let new = decode_hex(&text, 7);
assert_eq!(new, old(&text, 7));
if let Ok(v) = new {
assert_eq!(v.capacity(), v.len());
}
}
}
}
#[test]
fn limits_hold() {
let deep = format!("<AfterEffectsProject>{}{}</AfterEffectsProject>", "<a>".repeat(100_000), "</a>".repeat(100_000));
+79 -9
View File
@@ -2,6 +2,7 @@
use std::borrow::Cow;
use crate::container::bytes::{array, be_u32};
use crate::container::chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS};
use crate::container::error::AepError;
@@ -39,7 +40,7 @@ pub fn parse_with_limits(data: &[u8], limits: Limits) -> Result<AepFile<'_>, Aep
if id4(data, 0) != Some(*b"RIFX") {
return Err(AepError::NotRifx);
}
let declared = u64::from(be32(data, 4).ok_or(AepError::NotRifx)?);
let declared = u64::from(be_u32(data, 4).ok_or(AepError::NotRifx)?);
let form = id4(data, 8).ok_or(AepError::NotRifx)?;
if &form != b"Egg!" {
return Err(AepError::BadFormType(form));
@@ -56,13 +57,8 @@ pub fn parse_with_limits(data: &[u8], limits: Limits) -> Result<AepFile<'_>, Aep
Ok(AepFile { chunks, trailer: Cow::Borrowed(trailer) })
}
fn be32(data: &[u8], at: usize) -> Option<u32> {
id4(data, at).map(u32::from_be_bytes)
}
fn id4(data: &[u8], at: usize) -> Option<[u8; 4]> {
let b = data.get(at..at.checked_add(4)?)?;
Some([*b.first()?, *b.get(1)?, *b.get(2)?, *b.get(3)?])
array::<4>(data, at)
}
struct Parser<'a> {
@@ -74,13 +70,16 @@ struct Parser<'a> {
impl<'a> Parser<'a> {
/// Parses the sibling chunks that exactly fill `start..end`.
fn sequence(&mut self, start: usize, end: usize, depth: usize) -> Result<Vec<Chunk<'a>>, AepError> {
let mut out = Vec::new();
// Sized once: a well-formed sequence fills exactly this many slots. The count is bounded by
// the input (one per 8 bytes at most) and by the chunks the limit still allows.
let capacity = self.count_siblings(start, end).min(self.limits.max_chunks.saturating_sub(self.count));
let mut out = Vec::with_capacity(capacity);
let mut pos = start;
while pos < end {
let short = AepError::Malformed { offset: pos, what: "bytes left over that are too short for a chunk header" };
let body_start = pos.checked_add(8).filter(|b| *b <= end).ok_or(short)?;
let id = id4(self.data, pos).ok_or(AepError::Malformed { offset: pos, what: "short read" })?;
let size = be32(self.data, pos + 4).ok_or(AepError::Malformed { offset: pos, what: "short read" })? as usize;
let size = be_u32(self.data, pos + 4).ok_or(AepError::Malformed { offset: pos, what: "short read" })? as usize;
let avail = end - body_start;
if size > avail {
return Err(AepError::ChunkTooLarge { offset: pos, size: size as u64, available: avail as u64 });
@@ -103,6 +102,20 @@ impl<'a> Parser<'a> {
Ok(out)
}
/// The number of chunk headers that tile `start..end` the way [`Self::sequence`] walks them,
/// stopping at the first that does not fit. Never more than `(end - start) / 8`.
fn count_siblings(&self, start: usize, end: usize) -> usize {
let mut n = 0usize;
let mut pos = start;
while let Some(body_start) = pos.checked_add(8).filter(|b| *b <= end) {
let Some(size) = be_u32(self.data, pos.saturating_add(4)) else { break };
let Some(next) = usize::try_from(size).ok().and_then(|s| body_start.checked_add(s)).filter(|e| *e <= end) else { break };
n = n.saturating_add(1);
pos = if size % 2 == 1 && next < end { next.saturating_add(1) } else { next };
}
n
}
/// Like [`Self::sequence`] but a structural problem yields `None` (the caller keeps bytes).
fn tentative(&mut self, start: usize, end: usize, depth: usize) -> Result<Option<Vec<Chunk<'a>>>, AepError> {
match self.sequence(start, end, depth) {
@@ -140,3 +153,60 @@ impl<'a> Parser<'a> {
Ok(Body::Data(Cow::Borrowed(self.slice(start, end))))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Every children `Vec` in the tree, depth first.
fn child_vecs<'f, 'a>(chunks: &'f [Chunk<'a>], out: &mut Vec<&'f Vec<Chunk<'a>>>) {
for c in chunks {
match &c.body {
Body::List { children, .. } | Body::Chunks(children) => {
out.push(children);
child_vecs(children, out);
}
Body::Data(_) | Body::OpaqueList { .. } => {}
}
}
}
#[test]
fn every_sibling_list_is_allocated_once_at_its_exact_length() {
let inner: Vec<Chunk<'static>> = (0..37u8).map(|i| Chunk::data(*b"cdat", vec![i; usize::from(i % 5)])).collect();
let middle: Vec<Chunk<'static>> = (0..9).map(|_| Chunk::list(*b"tdbs", inner.clone())).collect();
let file = AepFile { chunks: vec![Chunk::data(*b"head", vec![0; 3]), Chunk::list(*b"Fold", middle)], trailer: Cow::Borrowed(&[]) };
let bytes = crate::container::write(&file).expect("write");
let parsed = parse(&bytes).expect("parse");
assert_eq!(crate::container::write(&parsed).expect("rewrite"), bytes);
let mut vecs = vec![&parsed.chunks];
child_vecs(&parsed.chunks, &mut vecs);
assert_eq!(vecs.len(), 11);
for v in vecs {
assert_eq!(v.capacity(), v.len(), "a sibling Vec grew past its length");
}
}
#[test]
fn the_preallocation_is_bounded_by_the_input_and_the_chunk_limit() {
// 1000 empty headers: the most chunks the bytes can hold.
let mut data = b"RIFX\0\0\0\0Egg!".to_vec();
data.extend(std::iter::repeat_n(*b"abcd\0\0\0\0", 1000).flatten());
let declared = u32::try_from(data.len() - 8).expect("small");
data[4..8].copy_from_slice(&declared.to_be_bytes());
let parser = Parser { data: &data, limits: Limits::default(), count: 0 };
assert_eq!(parser.count_siblings(12, data.len()), 1000);
assert!(parser.count_siblings(12, data.len()) <= (data.len() - 12) / 8);
// A header whose size runs past the end stops the count; a stray tail is not counted.
assert_eq!(parser.count_siblings(12, data.len() - 3), 999);
let mut big = data.clone();
big[16..20].copy_from_slice(&u32::MAX.to_be_bytes());
let parser = Parser { data: &big, limits: Limits::default(), count: 0 };
assert_eq!(parser.count_siblings(12, big.len()), 0);
let f = parse(&data).expect("parse");
assert_eq!((f.chunks.len(), f.chunks.capacity()), (1000, 1000));
// Under a tighter limit the parse fails as before, without sizing for more than allowed.
let tight = Limits { max_depth: 64, max_chunks: 10 };
assert_eq!(parse_with_limits(&data, tight), Err(AepError::TooManyChunks));
}
}
+5 -4
View File
@@ -2,6 +2,7 @@
//! linear-light switches, the time display and the frame count start. Cases 620-634: one change
//! each against the base project 620; every field below is the only byte (or chunk) that changes.
use crate::container::bytes::be_u32;
use crate::container::chunk::AepFile;
use crate::container::footage::json_string;
@@ -130,7 +131,7 @@ fn base64_decode(text: &str) -> Option<Vec<u8>> {
/// The profile description (ICC.1 tag `desc`): a v2 `desc` (ASCII) or a v4 `mluc` (its first
/// record, UTF-16BE). `None` when the profile or the tag is cut short.
fn icc_description(icc: &[u8]) -> Option<String> {
let be32 = |at: usize| icc.get(at..at.checked_add(4)?).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes);
let be32 = |at: usize| be_u32(icc, at);
let count = be32(128)?;
let tag = (0..count.min(1024)).find_map(|i| {
let at = 132usize.checked_add(usize::try_from(i).ok()?.checked_mul(12)?)?;
@@ -141,13 +142,13 @@ fn icc_description(icc: &[u8]) -> Option<String> {
let body = icc.get(off..off.checked_add(len)?)?;
let text = match body.get(..4)? {
b"desc" => {
let n = usize::try_from(u32::from_be_bytes(body.get(8..12)?.try_into().ok()?)).ok()?;
let n = usize::try_from(be_u32(body, 8)?).ok()?;
let ascii = body.get(12..12usize.checked_add(n)?)?;
String::from_utf8_lossy(ascii.split(|b| *b == 0).next().unwrap_or_default()).into_owned()
}
b"mluc" => {
let n = usize::try_from(u32::from_be_bytes(body.get(20..24)?.try_into().ok()?)).ok()?;
let at = usize::try_from(u32::from_be_bytes(body.get(24..28)?.try_into().ok()?)).ok()?;
let n = usize::try_from(be_u32(body, 20)?).ok()?;
let at = usize::try_from(be_u32(body, 24)?).ok()?;
let units: Vec<u16> = body.get(at..at.checked_add(n)?)?.as_chunks::<2>().0.iter().map(|p| u16::from_be_bytes(*p)).collect();
String::from_utf16_lossy(&units)
}
+26 -1
View File
@@ -287,7 +287,7 @@ impl<'f, 'a> PropGroup<'f, 'a> {
pending = c.bytes();
} else if c.id == crate::container::chunk::LIST
&& let Some(body) = pending.take()
&& match_name(body) == name
&& match_name_is(body, name)
{
return Some(PropNode::of(c));
}
@@ -314,6 +314,14 @@ fn match_name(body: &[u8]) -> String {
text(until_nul(body))
}
/// `match_name(body) == name` without building the `String`: equal bytes match; otherwise only
/// a body that is not valid UTF-8 can still match (through its U+FFFD replacements), and only a
/// name that holds U+FFFD.
fn match_name_is(body: &[u8], name: &str) -> bool {
let bytes = until_nul(body);
bytes == name.as_bytes() || (name.contains('\u{FFFD}') && String::from_utf8_lossy(bytes) == name)
}
/// A property (`LIST tdbs`): `tdsb`, `tdsn`, the 124-byte header `tdb4`, then either the static
/// value `cdat` or a keyframe `LIST list`.
#[derive(Debug, Clone, Copy)]
@@ -536,4 +544,21 @@ mod tests {
assert!(g.get("C").is_none());
assert_eq!(g.names(), ["A", "B", "A"]);
}
#[test]
fn match_name_is_agrees_with_comparing_the_decoded_name() {
let bodies: [&[u8]; 9] =
[b"ADBE Position\0\0\0", b"ADBE Position", b"ADBE Pos", b"", b"\0ADBE", b"ADBE \xff Bad\0", b"\xff", b"\xef\xbf\xbd", "Gr\u{fc}n\0".as_bytes()];
let names = ["ADBE Position", "ADBE Pos", "", "ADBE \u{FFFD} Bad", "\u{FFFD}", "Gr\u{fc}n", "ADBE Position\0", "ADBE \u{FFFD}"];
for body in bodies {
for name in names {
assert_eq!(match_name_is(body, name), match_name(body) == name, "{body:?} vs {name:?}");
}
}
// Names read back from a group find their entries, a damaged one included.
let group = Chunk::list(*b"tdgp", vec![Chunk::data(*b"tdmn", b"X\xffY\0".to_vec()), Chunk::list(*b"tdbs", Vec::new())]);
let g = PropGroup::from_chunk(&group).expect("group");
let names = g.names();
assert!(g.get(&names[0]).is_some());
}
}
+3 -4
View File
@@ -8,6 +8,7 @@
//! 100 %. These defaults are verified for AV layers (solids); cameras and lights use other
//! conventions (see the `camera` module).
use crate::container::bytes::be_f64;
use crate::container::typed::{Layer, Project, PropGroup, PropNode};
/// Where a [`Tracked`] value came from.
@@ -192,8 +193,7 @@ impl<'f, 'a> Layer<'f, 'a> {
let otda = otst.child_list(b"otky")?.child(b"otda")?.bytes()?;
let mut degrees = [0.0; 3];
for (i, slot) in degrees.iter_mut().enumerate() {
let b = otda.get(i * 8..i * 8 + 8)?;
*slot = f64::from_be_bytes(b.try_into().ok()?);
*slot = be_f64(otda, i * 8)?;
}
Some(Orientation {
degrees,
@@ -227,8 +227,7 @@ impl<'f, 'a> Layer<'f, 'a> {
.filter(|c| c.is(b"otda"))
.map(|c| {
let b = c.bytes()?;
let at = |i: usize| b.get(i * 8..i * 8 + 8).and_then(|s| s.try_into().ok()).map(f64::from_be_bytes);
Some([at(0)?, at(1)?, at(2)?])
Some([be_f64(b, 0)?, be_f64(b, 8)?, be_f64(b, 16)?])
})
.collect()
})
+74
View File
@@ -17,6 +17,15 @@ const MAX_WRITE_DEPTH: usize = 256;
/// [`AepError::TooDeep`] for an absurdly deep hand-built tree.
pub fn write(file: &AepFile<'_>) -> Result<Vec<u8>, AepError> {
let mut out = Vec::new();
// Sized once to the exact output when the tree fits a container (a tree that does not fails
// below anyway). `try_reserve` so a hand-built tree that repeats one large borrowed body many
// times cannot abort on the allocation; the writer then grows as it goes, as before.
if let Some(len) = sequence_len(&file.chunks, 1)
&& u32::try_from(len.saturating_add(4)).is_ok()
&& let Some(total) = len.checked_add(12).and_then(|n| n.checked_add(file.trailer.len()))
{
let _ = out.try_reserve_exact(total);
}
out.extend_from_slice(b"RIFX");
out.extend_from_slice(&[0; 4]);
out.extend_from_slice(b"Egg!");
@@ -56,6 +65,28 @@ fn write_sequence(out: &mut Vec<u8>, chunks: &[Chunk<'_>], depth: usize) -> Resu
Ok(())
}
/// The number of bytes [`write_sequence`] produces for `chunks`; `None` past the depth limit or
/// on overflow (both make the write fail).
fn sequence_len(chunks: &[Chunk<'_>], depth: usize) -> Option<usize> {
if depth > MAX_WRITE_DEPTH {
return None;
}
let count = chunks.len();
let mut total = 0usize;
for (i, chunk) in chunks.iter().enumerate() {
let body = match &chunk.body {
Body::Data(d) => d.len(),
Body::Chunks(c) => sequence_len(c, depth + 1)?,
Body::List { children, .. } => sequence_len(children, depth + 1)?.checked_add(4)?,
Body::OpaqueList { data, .. } => data.len().checked_add(4)?,
};
// The same pad rule as `write_chunk`.
let pad = usize::from(body % 2 == 1 && (chunk.pad.is_some() || i + 1 != count));
total = total.checked_add(8)?.checked_add(body)?.checked_add(pad)?;
}
Some(total)
}
fn write_chunk(out: &mut Vec<u8>, chunk: &Chunk<'_>, is_last: bool, depth: usize) -> Result<(), AepError> {
out.extend_from_slice(&chunk.id);
let size_at = out.len();
@@ -84,3 +115,46 @@ fn write_chunk(out: &mut Vec<u8>, chunk: &Chunk<'_>, is_last: bool, depth: usize
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use super::*;
#[test]
fn the_output_is_sized_once_to_its_exact_length() {
let odd = |n: u8| Chunk::data(*b"cdat", vec![n; usize::from(n)]);
let mut padded = odd(3);
padded.pad = Some(0x7f);
let trees = [
AepFile { chunks: Vec::new(), trailer: Cow::Borrowed(&[]) },
AepFile {
chunks: vec![
odd(1),
Chunk::list(*b"Fold", vec![odd(2), odd(3), Chunk::list(*b"tdgp", vec![odd(5)])]),
Chunk { id: *b"LIST", body: Body::OpaqueList { list_type: *b"btdk", data: Cow::Owned(vec![1, 2, 3]) }, pad: None },
Chunk { id: *b"tdmn", body: Body::Chunks(vec![odd(7), padded]), pad: None },
odd(9),
],
trailer: Cow::Borrowed(b"<x:xmpmeta/>"),
},
];
for file in &trees {
let out = write(file).expect("write");
assert_eq!(sequence_len(&file.chunks, 1).map(|n| n + 12 + file.trailer.len()), Some(out.len()));
assert_eq!(out.capacity(), out.len(), "the output grew past its size");
}
}
#[test]
fn a_tree_past_the_depth_limit_has_no_size() {
let mut deep = Chunk::data(*b"cdat", vec![0]);
for _ in 0..=MAX_WRITE_DEPTH {
deep = Chunk::list(*b"tdgp", vec![deep]);
}
let file = AepFile { chunks: vec![deep], trailer: Cow::Borrowed(&[]) };
assert_eq!(sequence_len(&file.chunks, 1), None);
assert_eq!(write(&file), Err(AepError::TooDeep));
}
}