mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
craft-heif: cap zero-width iloc extents, apply the census on the thumbnail path (#813)
This commit is contained in:
1 parent
cf14284af1
commit
2408292033
2 files changed
+90
-1
No files matched your search
@@ -25,6 +25,12 @@ const MAX_IPMA_BYTES: usize = 64 << 10;
|
||||
/// construction-method-2 reconstruction chain can visit.
|
||||
const MAX_ILOC_BYTES: usize = 64 << 10;
|
||||
|
||||
/// The most extents accepted from an `iloc` whose offset, length and index fields are all
|
||||
/// zero-width (same as the item-visit budget). Such an extent takes no bytes, so the payload cap
|
||||
/// does not bound it, yet heic-rs 0.1.4 skips its "more extents than it holds" check and
|
||||
/// allocates 24 bytes per extent (up to 65535 per item).
|
||||
const MAX_ZERO_WIDTH_EXTENTS: u64 = 4096;
|
||||
|
||||
/// Falls through unless the container declares more `ipma` or `iloc` payload than a
|
||||
/// legitimate phone photo ever does.
|
||||
pub fn cap(bytes: &[u8]) -> Result<(), Error> {
|
||||
@@ -36,6 +42,12 @@ pub fn cap(bytes: &[u8]) -> Result<(), Error> {
|
||||
if c.iloc > MAX_ILOC_BYTES {
|
||||
return Err(Error::Limit(format!("the image carries {} bytes of item locations, over the {MAX_ILOC_BYTES}-byte cap", c.iloc)));
|
||||
}
|
||||
if c.zero_width_extents > MAX_ZERO_WIDTH_EXTENTS {
|
||||
return Err(Error::Limit(format!(
|
||||
"the image declares {} extents that occupy no bytes, over the {MAX_ZERO_WIDTH_EXTENTS}-extent cap",
|
||||
c.zero_width_extents
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -44,6 +56,30 @@ pub fn cap(bytes: &[u8]) -> Result<(), Error> {
|
||||
struct Census {
|
||||
ipma: usize,
|
||||
iloc: usize,
|
||||
zero_width_extents: u64,
|
||||
}
|
||||
|
||||
/// The number of extents an `iloc` payload declares when its extents are zero-width, else 0.
|
||||
/// Stops silently at a truncated or unknown layout: heic-rs reports those itself.
|
||||
fn zero_width_extents(p: &[u8]) -> u64 {
|
||||
let (Some(&version), Some(&sizes), Some(&sizes2)) = (p.first(), p.get(4), p.get(5)) else { return 0 };
|
||||
let base_size = usize::from(sizes2 >> 4);
|
||||
let index_size = if version >= 1 { sizes2 & 0x0f } else { 0 };
|
||||
if version > 2 || (sizes >> 4) + (sizes & 0x0f) + index_size != 0 {
|
||||
return 0;
|
||||
}
|
||||
let id_size = if version < 2 { 2 } else { 4 };
|
||||
let Some(count) = p.get(6..6 + id_size) else { return 0 };
|
||||
let count = count.iter().fold(0u32, |n, &b| n << 8 | u32::from(b));
|
||||
let (mut at, mut total) = (6 + id_size, 0u64);
|
||||
for _ in 0..count {
|
||||
// id, [construction method], data reference index, base offset, then the extent count
|
||||
at += id_size + if version >= 1 { 2 } else { 0 } + 2 + base_size;
|
||||
let Some(n) = p.get(at..at + 2) else { break };
|
||||
total += u64::from(u16::from_be_bytes([n[0], n[1]]));
|
||||
at += 2;
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// Walks boxes, adding the payload sizes of those named in [`Census`].
|
||||
@@ -61,6 +97,7 @@ fn depth_boxes(c: &mut Census, boxes: BoxIter<'_>, depth: u32) {
|
||||
c.ipma = c.ipma.saturating_add(b.payload.len());
|
||||
} else if b.is(b"iloc") {
|
||||
c.iloc = c.iloc.saturating_add(b.payload.len());
|
||||
c.zero_width_extents = c.zero_width_extents.saturating_add(zero_width_extents(b.payload));
|
||||
} else if b.is(b"meta") {
|
||||
// `meta` is a FullBox: version and flags sit between its header and its children.
|
||||
if b.payload.len() >= 4 {
|
||||
@@ -111,6 +148,55 @@ mod tests {
|
||||
assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("associations")), "{r:?}");
|
||||
}
|
||||
|
||||
/// An `iloc` v0 whose offset, length and base-offset sizes are 0, listing `items` items of
|
||||
/// `extents` extents each. Each item is 8 bytes, so 4096+ items stay far under the byte cap.
|
||||
fn zero_width_iloc(items: u16, extents: u16) -> Vec<u8> {
|
||||
let mut iloc = vec![0u8, 0, 0, 0, 0x00, 0x00];
|
||||
iloc.extend_from_slice(&items.to_be_bytes());
|
||||
for id in 0..items {
|
||||
iloc.extend_from_slice(&id.to_be_bytes());
|
||||
iloc.extend_from_slice(&[0, 0]); // data reference index
|
||||
iloc.extend_from_slice(&extents.to_be_bytes());
|
||||
}
|
||||
iloc
|
||||
}
|
||||
|
||||
fn file_with_iloc(iloc: &[u8]) -> Vec<u8> {
|
||||
let mut out = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic".to_vec();
|
||||
let mut meta = vec![0u8, 0, 0, 0];
|
||||
meta.extend(box_of(b"iloc", iloc));
|
||||
out.extend(box_of(b"meta", &meta));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_width_extents_are_counted_not_allocated() {
|
||||
// 8000 items x 65535 extents would be ~12 GB in heic-rs 0.1.4; the census only counts.
|
||||
let iloc = zero_width_iloc(8000, u16::MAX);
|
||||
assert!(iloc.len() < MAX_ILOC_BYTES);
|
||||
let r = cap(&file_with_iloc(&iloc));
|
||||
assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("occupy no bytes")), "{r:?}");
|
||||
// at the cap it passes; one more is refused
|
||||
assert_eq!(zero_width_extents(&zero_width_iloc(4096, 1)), 4096);
|
||||
assert!(cap(&file_with_iloc(&zero_width_iloc(4096, 1))).is_ok());
|
||||
assert!(cap(&file_with_iloc(&zero_width_iloc(4097, 1))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_thumbnail_path_applies_the_caps_too() {
|
||||
let bytes = file_with_iloc(&zero_width_iloc(8000, u16::MAX));
|
||||
let r = crate::decode_thumbnail(&bytes, 1, &crate::Options::default()).map(|_| ());
|
||||
assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("occupy no bytes")), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extents_with_width_are_left_to_the_byte_cap() {
|
||||
// offset_size 4, length_size 4: the payload cap already bounds them.
|
||||
let mut iloc = zero_width_iloc(1, u16::MAX);
|
||||
iloc[4] = 0x44;
|
||||
assert_eq!(zero_width_extents(&iloc), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_undersized_ipma_is_untouched_by_the_caps() {
|
||||
// The same shape with a normal ipma (two entries): the caps pass, and whatever error
|
||||
|
||||
@@ -259,6 +259,7 @@ pub fn decode(bytes: &[u8], options: &Options) -> Result<Decoded, Error> {
|
||||
/// profile) are the primary image's.
|
||||
pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Result<Option<Decoded>, Error> {
|
||||
guarded(|| {
|
||||
census::cap(bytes)?;
|
||||
let ctx = Context::open(bytes).map_err(err)?;
|
||||
let primary = ctx.meta.primary;
|
||||
let mut best: Option<(u32, u32)> = None;
|
||||
@@ -287,7 +288,9 @@ pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Resul
|
||||
}
|
||||
}
|
||||
let mut decoded = decode_item(&ctx, id, options, false)?;
|
||||
if decoded.icc.is_none() {
|
||||
// `item_icc` rebuilds the primary grid's descriptor: only when that is within the visit
|
||||
// budget (the thumbnail path never counted it).
|
||||
if decoded.icc.is_none() && item_visits(&ctx, primary, 0, &mut 0).is_ok() {
|
||||
decoded.icc = item_icc(&ctx, primary);
|
||||
}
|
||||
(decoded.exif, decoded.xmp) = metadata(&ctx);
|
||||
|
||||
Reference in new issue
Block a user