mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
* Add craft-aep: After Effects project container and typed view from EffectCraft The .aep (RIFX 'Egg!') and .aepx container as a lossless chunk tree that writes back byte for byte, and the typed, read-only view over it: compositions, layers, properties, keyframes, effects and their settings data, masks, shapes, text documents, markers, cameras, track mattes, footage, project settings and the saving version. Moved from effectcraft crates/aep/src/container at f6b4da0a with its unit tests, the container integration tests, the aepdump and stubicc tools and docs/aep-format.md. The match-name tables (effect_map, style_map), the importer and the exporter are written against EffectCraft's model and stay there. ExtractorChannels::sources takes the caller's name for a copied channel instead of an EffectCraft constant. * craft-aep: read the Photoshop layer a footage item shows A footage item made from one layer of a .psd stores the layer in its `opti` (tag `8BPS`): the layer record's index (big-endian i32 at 14, -1 for the merged image), the document's two sides (little-endian u32 at 32 and 36), the layer's bounds (little-endian i32 at 78, 82, 86, 90) and its name (UTF-8 from 344). `Footage::psd_layer` reads them as `PsdLayer`. The format part of storytold/effectcraft#3449; the importer's use of it stays in EffectCraft. --------- Co-authored-by: arnav-hiwarkar <asharnav2008@gmail.com>
267 lines
11 KiB
Rust
267 lines
11 KiB
Rust
//! Hand-built containers for odd but well-formed shapes the reader must keep, and the limits against hostile
|
|
//! input. Every successfully parsed input must write back byte for byte.
|
|
|
|
use craft_aep::{AepError, Body, Chunk, Limits, parse, parse_with_limits, write};
|
|
|
|
/// One chunk with its pad byte (`pad` is used when the body is odd).
|
|
fn chunk(id: &[u8; 4], body: &[u8], pad: u8) -> Vec<u8> {
|
|
let mut v = id.to_vec();
|
|
v.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
|
v.extend_from_slice(body);
|
|
if body.len() % 2 == 1 {
|
|
v.push(pad);
|
|
}
|
|
v
|
|
}
|
|
|
|
fn list(list_type: &[u8; 4], children: &[Vec<u8>]) -> Vec<u8> {
|
|
let mut body = list_type.to_vec();
|
|
for c in children {
|
|
body.extend_from_slice(c);
|
|
}
|
|
chunk(b"LIST", &body, 0)
|
|
}
|
|
|
|
/// A whole file: `RIFX`, size, `Egg!`, chunks, trailer.
|
|
fn file(chunks: &[Vec<u8>], trailer: &[u8]) -> Vec<u8> {
|
|
let mut body = b"Egg!".to_vec();
|
|
for c in chunks {
|
|
body.extend_from_slice(c);
|
|
}
|
|
let mut v = b"RIFX".to_vec();
|
|
v.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
|
v.extend_from_slice(&body);
|
|
v.extend_from_slice(trailer);
|
|
v
|
|
}
|
|
|
|
fn assert_round_trip(bytes: &[u8]) -> craft_aep::AepFile<'_> {
|
|
let f = parse(bytes).unwrap();
|
|
assert!(write(&f).unwrap() == bytes, "round trip differs");
|
|
f
|
|
}
|
|
|
|
#[test]
|
|
fn minimal_file_and_empty_container() {
|
|
let bytes = file(&[], b"");
|
|
let f = assert_round_trip(&bytes);
|
|
assert!(f.chunks.is_empty() && f.trailer.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn empty_input_and_bad_headers_are_errors() {
|
|
assert_eq!(parse(b""), Err(AepError::NotRifx));
|
|
assert_eq!(parse(b"RIFX"), Err(AepError::NotRifx));
|
|
assert_eq!(parse(b"RIFF\0\0\0\x04Egg!"), Err(AepError::NotRifx));
|
|
assert_eq!(parse(b"RIFX\0\0\0\x04Evil"), Err(AepError::BadFormType(*b"Evil")));
|
|
assert!(matches!(parse(b"RIFX\0\0\0\x03Egg!"), Err(AepError::Truncated { .. })));
|
|
// Declared size past the end of the input.
|
|
assert!(matches!(parse(b"RIFX\0\0\0\x20Egg!"), Err(AepError::Truncated { declared: 32, available: 4 })));
|
|
assert!(matches!(parse(b"RIFX\xff\xff\xff\xffEgg!"), Err(AepError::Truncated { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn ids_ending_in_a_space_are_kept() {
|
|
let bytes = file(&[chunk(b"seq ", &[1, 2], 0), chunk(b"Pin ", &[], 0), chunk(b"FEE ", &[9, 9, 9, 9], 0), chunk(b"LOm ", &[1], 0)], b"");
|
|
let f = assert_round_trip(&bytes);
|
|
let ids: Vec<&[u8; 4]> = f.chunks.iter().map(|c| &c.id).collect();
|
|
assert_eq!(ids, [b"seq ", b"Pin ", b"FEE ", b"LOm "]);
|
|
assert!(f.chunks[0].is(b"seq "));
|
|
}
|
|
|
|
#[test]
|
|
fn odd_sized_chunks_keep_their_pad_byte_even_when_not_zero() {
|
|
let bytes = file(&[chunk(b"abcd", &[1, 2, 3], 0xAA), chunk(b"efgh", &[4], 0)], b"");
|
|
let f = assert_round_trip(&bytes);
|
|
assert_eq!(f.chunks[0].pad, Some(0xAA));
|
|
assert_eq!(f.chunks[1].pad, Some(0));
|
|
assert_eq!(f.chunks[0].bytes(), Some(&[1, 2, 3][..]));
|
|
}
|
|
|
|
#[test]
|
|
fn odd_last_chunk_without_pad_byte() {
|
|
// The container ends right after an odd body: no pad byte exists in the file.
|
|
let mut body = b"Egg!".to_vec();
|
|
body.extend_from_slice(b"abcd\0\0\0\x03xyz");
|
|
let mut bytes = b"RIFX".to_vec();
|
|
bytes.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
|
bytes.extend_from_slice(&body);
|
|
let f = assert_round_trip(&bytes);
|
|
assert_eq!(f.chunks.len(), 1);
|
|
assert_eq!(f.chunks[0].pad, None);
|
|
// The same with a trailer following.
|
|
bytes.extend_from_slice(b"<?xpacket begin?>");
|
|
let f = assert_round_trip(&bytes);
|
|
assert_eq!(f.chunks[0].pad, None);
|
|
assert_eq!(&*f.trailer, b"<?xpacket begin?>");
|
|
}
|
|
|
|
#[test]
|
|
fn list_with_raw_text_body_is_opaque() {
|
|
// Like LIST btdk: the body after the list type is text, not chunks.
|
|
let text = b"{\"a\": 1, \"b\": [1, 2, 3]} not a chunk sequence at all";
|
|
let bytes = file(&[chunk(b"LIST", &[b"btdk".as_slice(), text].concat(), 0), chunk(b"next", &[], 0)], b"");
|
|
let f = assert_round_trip(&bytes);
|
|
assert!(matches!(f.chunks[0].body, Body::OpaqueList { .. }));
|
|
assert_eq!(f.chunks[0].list_type(), Some(*b"btdk"));
|
|
assert_eq!(f.chunks[0].bytes(), Some(&text[..]));
|
|
assert!(f.chunks[1].is(b"next"));
|
|
}
|
|
|
|
#[test]
|
|
fn list_with_a_child_larger_than_the_list_is_opaque_not_an_error() {
|
|
let mut bad_child = b"kid1".to_vec();
|
|
bad_child.extend_from_slice(&1000u32.to_be_bytes());
|
|
bad_child.extend_from_slice(&[0; 4]);
|
|
let bytes = file(&[chunk(b"LIST", &[b"lsta".as_slice(), &bad_child].concat(), 0), chunk(b"tail", &[], 0)], b"");
|
|
let f = assert_round_trip(&bytes);
|
|
assert!(matches!(f.chunks[0].body, Body::OpaqueList { .. }));
|
|
assert!(f.chunks[1].is(b"tail"));
|
|
}
|
|
|
|
#[test]
|
|
fn list_with_trailing_bytes_shorter_than_a_header_is_opaque() {
|
|
let bytes = file(&[chunk(b"LIST", &[b"lsta".as_slice(), &chunk(b"kid1", &[], 0), &[1, 2, 3]].concat(), 0)], b"");
|
|
let f = assert_round_trip(&bytes);
|
|
assert!(matches!(f.chunks[0].body, Body::OpaqueList { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_list_and_list_shorter_than_its_type() {
|
|
let bytes = file(&[list(b"Pefl", &[]), chunk(b"LIST", &[1, 2], 0), chunk(b"LIST", &[], 0)], b"");
|
|
let f = assert_round_trip(&bytes);
|
|
assert!(matches!(&f.chunks[0].body, Body::List { children, .. } if children.is_empty()));
|
|
// A LIST chunk too short to hold a type stays plain data.
|
|
assert!(matches!(f.chunks[1].body, Body::Data(_)));
|
|
assert!(matches!(f.chunks[2].body, Body::Data(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn nested_ids_hold_a_utf8_chunk() {
|
|
let name = chunk(b"Utf8", b"Hello", 0x7f);
|
|
let empty = chunk(b"Utf8", b"", 0);
|
|
let bytes = file(&[chunk(b"tdsn", &name, 0), chunk(b"pdnm", &empty, 0), chunk(b"fnam", &name, 0), chunk(b"othr", &name, 0)], b"");
|
|
let f = assert_round_trip(&bytes);
|
|
for c in &f.chunks[..3] {
|
|
assert!(matches!(c.body, Body::Chunks(_)), "{:?}", c.id);
|
|
assert_eq!(c.children().len(), 1);
|
|
let expect: &[u8] = if c.id == *b"pdnm" { b"" } else { b"Hello" };
|
|
assert_eq!(c.children()[0].bytes(), Some(expect));
|
|
}
|
|
assert_eq!(f.chunks[0].children()[0].pad, Some(0x7f));
|
|
// An id outside the nested set is plain data even though its body looks like a chunk.
|
|
assert!(matches!(f.chunks[3].body, Body::Data(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn nested_id_with_a_body_that_is_not_a_chunk_stays_data() {
|
|
let bytes = file(&[chunk(b"tdsn", b"not a chunk sequence!", 0), chunk(b"tdsn", &[0; 3], 0), chunk(b"tdsn", &[], 0)], b"");
|
|
let f = assert_round_trip(&bytes);
|
|
assert!(f.chunks.iter().all(|c| matches!(c.body, Body::Data(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn xmp_packet_follows_the_container() {
|
|
let xmp = b"<?xpacket begin=\"x\" id=\"W5M0\"?>\n<x:xmpmeta>data</x:xmpmeta>\n \n<?xpacket end=\"w\"?>";
|
|
let mut trailer = b"junk before ".to_vec();
|
|
trailer.extend_from_slice(xmp);
|
|
trailer.extend_from_slice(b"\n\n");
|
|
let bytes = file(&[chunk(b"abcd", &[1, 2], 0)], &trailer);
|
|
let f = assert_round_trip(&bytes);
|
|
assert_eq!(&*f.trailer, &trailer[..]);
|
|
assert_eq!(f.xmp(), Some(&xmp[..]));
|
|
// No packet, no XMP.
|
|
let bytes = file(&[], b"nothing here");
|
|
assert_eq!(assert_round_trip(&bytes).xmp(), None);
|
|
// An unterminated packet is not returned.
|
|
let bytes = file(&[], b"<?xpacket begin?> and no end");
|
|
assert_eq!(assert_round_trip(&bytes).xmp(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn chunk_larger_than_its_parent_is_an_error_at_the_top_level() {
|
|
let mut bad = b"big!".to_vec();
|
|
bad.extend_from_slice(&0xffff_fff0u32.to_be_bytes());
|
|
bad.extend_from_slice(&[0; 4]);
|
|
let bytes = file(&[bad], b"");
|
|
assert!(matches!(parse(&bytes), Err(AepError::ChunkTooLarge { size: 0xffff_fff0, .. })));
|
|
// A chunk header cut short by the container end.
|
|
let bytes = file(&[vec![b'a', b'b', b'c', b'd', 0, 0]], b"");
|
|
assert!(matches!(parse(&bytes), Err(AepError::Malformed { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn deep_nesting_is_kept_opaque_below_the_depth_limit() {
|
|
// 50 000 nested lists, emitted outermost first so building stays linear.
|
|
let levels: usize = 50_000;
|
|
let leaf = chunk(b"leaf", &[1, 2], 0);
|
|
let mut chunk_bytes = Vec::new();
|
|
for _ in 0..levels {
|
|
chunk_bytes.extend_from_slice(b"LIST");
|
|
// placeholder for the size, filled below
|
|
chunk_bytes.extend_from_slice(&[0; 4]);
|
|
chunk_bytes.extend_from_slice(b"deep");
|
|
}
|
|
chunk_bytes.extend_from_slice(&leaf);
|
|
let mut inner_total = leaf.len();
|
|
for level in (0..levels).rev() {
|
|
let size = 4 + inner_total;
|
|
let at = level * 12 + 4;
|
|
chunk_bytes[at..at + 4].copy_from_slice(&(size as u32).to_be_bytes());
|
|
inner_total = 8 + size;
|
|
}
|
|
let bytes = file(&[chunk_bytes], b"");
|
|
let f = assert_round_trip(&bytes);
|
|
let depth = f.walk().map(|(d, _)| d).max().unwrap();
|
|
assert!(depth <= Limits::default().max_depth, "depth {depth}");
|
|
// A smaller limit parses less and still round-trips.
|
|
let f = parse_with_limits(&bytes, Limits { max_depth: 3, max_chunks: 1000 }).unwrap();
|
|
assert!(f.walk().map(|(d, _)| d).max().unwrap() <= 3);
|
|
assert!(write(&f).unwrap() == bytes);
|
|
}
|
|
|
|
#[test]
|
|
fn chunk_count_limit() {
|
|
let many: Vec<Vec<u8>> = (0..100).map(|_| chunk(b"abcd", &[], 0)).collect();
|
|
let bytes = file(&many, b"");
|
|
assert_eq!(parse_with_limits(&bytes, Limits { max_depth: 8, max_chunks: 99 }), Err(AepError::TooManyChunks));
|
|
assert!(parse_with_limits(&bytes, Limits { max_depth: 8, max_chunks: 100 }).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn huge_declared_sizes_do_not_allocate() {
|
|
// A list that claims a gigantic child inside a small file: no allocation sized by the claim.
|
|
let mut c = b"LIST".to_vec();
|
|
c.extend_from_slice(&u32::MAX.to_be_bytes());
|
|
c.extend_from_slice(b"lsta");
|
|
let bytes = file(&[c], b"");
|
|
assert!(matches!(parse(&bytes), Err(AepError::ChunkTooLarge { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn modified_trees_are_re_sized_on_write() {
|
|
let bytes = file(&[list(b"Fold", &[chunk(b"name", b"abc", 0), chunk(b"keep", &[7, 7], 0)])], b"tail");
|
|
let mut f = parse(&bytes).unwrap();
|
|
if let Some(Chunk { body: Body::List { children, .. }, .. }) = f.chunks.first_mut() {
|
|
children[0] = Chunk::data(*b"name", b"a longer name!".to_vec());
|
|
}
|
|
let out = write(&f).unwrap();
|
|
let g = parse(&out).unwrap();
|
|
let kids = g.chunks[0].children();
|
|
assert_eq!(kids[0].bytes(), Some(&b"a longer name!"[..]));
|
|
assert_eq!(kids[1].bytes(), Some(&[7u8, 7][..]));
|
|
assert_eq!(&*g.trailer, b"tail");
|
|
assert!(write(&g).unwrap() == out);
|
|
}
|
|
|
|
#[test]
|
|
fn writer_rejects_absurd_depth_instead_of_overflowing_the_stack() {
|
|
let mut c = Chunk::data(*b"leaf", vec![1u8, 2]);
|
|
for _ in 0..2000 {
|
|
c = Chunk::list(*b"deep", vec![c]);
|
|
}
|
|
let f = craft_aep::AepFile { chunks: vec![c], trailer: std::borrow::Cow::Borrowed(&[]) };
|
|
assert_eq!(write(&f), Err(AepError::TooDeep));
|
|
// Dropping the deep tree must not overflow either (2000 levels).
|
|
}
|