diff --git a/crates/eps/src/embedded.rs b/crates/eps/src/embedded.rs index 47f73fb43..8e2225993 100644 --- a/crates/eps/src/embedded.rs +++ b/crates/eps/src/embedded.rs @@ -25,7 +25,7 @@ pub fn embedded_font_programs(ps: &[u8]) -> Vec { let names = positions(ps, b"/FontName"); let mut from = 0; let mut used = 0; - while let Some(ee) = find_from(ps, b"eexec", from) { + while let Some(ee) = find_eexec(ps, from) { from = ee + 5; let last_before = |v: &[usize]| v[..v.partition_point(|&p| p < ee)].last().copied().filter(|&p| p >= used); // The fallback look-back stops at the previous font's end, so it cannot pick up its header. @@ -56,6 +56,110 @@ fn find_from(hay: &[u8], needle: &[u8], from: usize) -> Option { hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|p| p + from) } +/// Find an executable `eexec` operator, ignoring comments, literal strings and names. +fn find_eexec(ps: &[u8], from: usize) -> Option { + let mut pos = from.min(ps.len()); + while pos < ps.len() { + let c = ps[pos]; + if is_white(c) { + pos += 1; + continue; + } + if c == b'%' { + let start = pos; + while pos < ps.len() && !matches!(ps[pos], b'\n' | b'\r') { + pos += 1; + } + let line = &ps[start..pos]; + let marker: Option<&[u8]> = if line.starts_with(b"%%BeginData:") { + Some(b"%%EndData") + } else if line.starts_with(b"%%BeginBinary:") { + Some(b"%%EndBinary") + } else if line.starts_with(b"%%BeginPreview") { + Some(b"%%EndPreview") + } else { + None + }; + if let Some(marker) = marker { + let rest = ps.get(pos..)?; + pos = rest.windows(marker.len()).position(|window| window == marker).map_or(ps.len(), |offset| pos + offset); + } + continue; + } + if c == b'(' { + pos += 1; + let mut depth = 1usize; + while pos < ps.len() && depth > 0 { + match ps[pos] { + b'\\' => pos = pos.saturating_add(2).min(ps.len()), + b'(' => { + depth = depth.saturating_add(1); + pos += 1; + } + b')' => { + depth -= 1; + pos += 1; + } + _ => pos += 1, + } + } + continue; + } + if c == b'<' { + match ps.get(pos + 1) { + Some(b'<') => pos += 2, + Some(b'~') => { + pos += 2; + pos = ps + .get(pos..) + .and_then(|rest| { + rest.windows(2).position(|window| window == b"~>").map(|offset| pos.saturating_add(offset).saturating_add(2)) + }) + .unwrap_or(ps.len()); + } + _ => { + pos += 1; + while pos < ps.len() && ps[pos] != b'>' { + pos += 1; + } + pos = pos.saturating_add(1).min(ps.len()); + } + } + continue; + } + if c == b'/' { + pos += 1; + if ps.get(pos) == Some(&b'/') { + pos += 1; + } + while pos < ps.len() && !is_white(ps[pos]) && !is_delimiter(ps[pos]) { + pos += 1; + } + continue; + } + if is_delimiter(c) { + pos += 1; + continue; + } + let start = pos; + while pos < ps.len() && !is_white(ps[pos]) && !is_delimiter(ps[pos]) { + pos += 1; + } + if &ps[start..pos] == b"eexec" { + return Some(start); + } + } + None +} + +fn is_white(c: u8) -> bool { + matches!(c, 0 | 9 | 10 | 12 | 13 | 32) +} + +fn is_delimiter(c: u8) -> bool { + matches!(c, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') +} + fn positions(hay: &[u8], needle: &[u8]) -> Vec { let mut out = vec![]; let mut from = 0; @@ -70,7 +174,7 @@ fn name_after(b: &[u8], key: &[u8]) -> Option { let p = find_from(b, key, 0)? + key.len(); let rest = &b[p..]; let s = rest.iter().position(|c| *c == b'/')? + 1; - let e = rest[s..].iter().position(|c| c.is_ascii_whitespace() || matches!(c, b'/' | b'[' | b'(' | b'{')).map_or(rest.len(), |q| s + q); + let e = rest[s..].iter().position(|c| is_white(*c) || is_delimiter(*c)).map_or(rest.len(), |q| s + q); (e > s).then(|| String::from_utf8_lossy(&rest[s..e]).into_owned()) } @@ -128,4 +232,38 @@ mod tests { let b = fonts.iter().find(|font| font.name.as_deref() == Some("B")).unwrap(); assert!(!String::from_utf8_lossy(&b.program).contains("AdobeFont")); } + + #[test] + fn eexec_in_comments_strings_names_and_font_names_is_not_an_operator() { + let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%!PS-AdobeFont-1.0: Test\n%% eexec in a comment\n%%BeginBinary: 10\neexec junk\n%%EndBinary\n/FontName /eexecTest def\n(nested (ignored eexec)) pop\n<6565786563> pop\n<~eexec~> pop\n/eexec def\ncurrentfile eexec\n0000\ncleartomark\n"; + let fonts = embedded_font_programs(ps); + assert_eq!(fonts.len(), 1); + assert_eq!(fonts[0].name.as_deref(), Some("eexecTest")); + } + + #[test] + fn font_name_stops_at_postscript_delimiters_and_comments() { + let ps = b"%!PS-AdobeFont-1.0: Test\n/FontName /Test%comment\n def\ncurrentfile eexec\n0000\ncleartomark\n"; + let fonts = embedded_font_programs(ps); + assert_eq!(fonts.len(), 1); + assert_eq!(fonts[0].name.as_deref(), Some("Test")); + } + + #[test] + fn font_names_stop_at_every_postscript_delimiter() { + for delimiter in b"()<>[]{}/%" { + let mut source = b"/FontName /Test".to_vec(); + source.push(*delimiter); + source.extend_from_slice(b"ignored"); + assert_eq!(super::name_after(&source, b"/FontName").as_deref(), Some("Test")); + } + } + + #[test] + fn literal_name_backslash_does_not_escape_a_comment_delimiter() { + let ps = b"%!PS-AdobeFont-1.0: Test\n/FontName /Test def\n/unused\\%(\npop\ncurrentfile eexec\n0000\ncleartomark\n"; + let fonts = embedded_font_programs(ps); + assert_eq!(fonts.len(), 1); + assert_eq!(fonts[0].name.as_deref(), Some("Test")); + } } diff --git a/crates/gif/src/frames.rs b/crates/gif/src/frames.rs index da65b483e..12ae37dda 100644 --- a/crates/gif/src/frames.rs +++ b/crates/gif/src/frames.rs @@ -22,9 +22,10 @@ pub struct FrameLimits { pub max_bytes: u64, /// Largest canvas (logical screen) in bytes at 4 bytes a pixel. Default 512 MiB. pub max_canvas_bytes: u64, - /// Combined decoder working-allocation budget. The decoder reserves its persistent canvas - /// before the frame buffer, so both count against this limit. `None` uses twice - /// `max_canvas_bytes`, matching the decoder's working-buffer allowance. Default `None`. + /// Combined decoder working-allocation budget. The persistent canvas reserves budget before + /// the frame buffer. `None` uses twice `max_canvas_bytes`, matching the decoder's working + /// buffer allowance. Set this when an application has a tighter allocator budget. Default + /// `None`. pub max_decode_bytes: Option, /// Merge a frame identical to the one before into it (its delay added). Default off. pub merge_identical: bool, @@ -91,7 +92,18 @@ impl std::error::Error for FrameError {} /// Decodes the frames of a GIF. pub fn read_frames(bytes: &[u8], limits: &FrameLimits) -> Result { - let info = crate::scan(bytes, 1).ok_or_else(|| FrameError::Malformed("not a GIF".into()))?; + if limits.max_frames == 0 { + return Err(FrameError::LimitExceeded("the frame limit is zero".into())); + } + let inspect_limit = limits.max_frames.saturating_add(1); + let info = crate::scan(bytes, inspect_limit).ok_or_else(|| FrameError::Malformed("not a GIF".into()))?; + let has_more = if info.frames.len() > limits.max_frames { + Some(true) + } else if info.complete { + Some(false) + } else { + None + }; let (width, height) = (u32::from(info.width), u32::from(info.height)); let canvas = u64::from(width) * u64::from(height) * 4; if canvas > limits.max_canvas_bytes { @@ -111,15 +123,30 @@ pub fn read_frames(bytes: &[u8], limits: &FrameLimits) -> Result Option> { + let mut decoder = GifDecoder::new(Cursor::new(bytes)).ok()?; + decoder.icc_profile().ok().flatten() } /// Keeps the frames `frames` yields under `limits`. -fn collect(width: u32, height: u32, frames: image::Frames<'_>, limits: &FrameLimits) -> Result { +fn collect( + width: u32, + height: u32, + frames: impl Iterator>, + limits: &FrameLimits, + has_more: Option, +) -> Result { let frame_bytes = u64::from(width) * u64::from(height) * 4; let mut out: Vec = Vec::new(); + let mut decoded_frames = 0; let mut stop = Stop::End; for frame in frames { + decoded_frames += 1; let frame = match frame { Ok(f) => f, Err(e) if out.is_empty() => { @@ -155,6 +182,13 @@ fn collect(width: u32, height: u32, frames: image::Frames<'_>, limits: &FrameLim } out.push(AnimFrame { rgba, delay_ms }); } + if stop == Stop::End && decoded_frames == limits.max_frames { + stop = match has_more { + Some(true) => Stop::FrameLimit, + Some(false) => Stop::End, + None => Stop::Damaged("could not inspect the blocks after the kept frames".into()), + }; + } if out.is_empty() { return Err(FrameError::Malformed("no frame".into())); } @@ -192,6 +226,30 @@ mod tests { assert!(a.frames.iter().all(|f| f.rgba.len() == 24)); } + #[test] + fn first_frame_pixel_count_tracks_the_lzw_output() { + let mut gif = Vec::new(); + let pixels = image::RgbaImage::from_fn(640, 480, |x, y| image::Rgba([(x / 4) as u8, (y / 3) as u8, 17, 255])); + image::codecs::gif::GifEncoder::new_with_speed(&mut gif, 30).encode_frame(image::Frame::new(pixels)).unwrap(); + assert_eq!(crate::first_frame_decoded_pixels(&gif), Some(640 * 480)); + assert_eq!(crate::first_frame_decoded_pixels(b"not a gif"), None); + } + + #[test] + fn reads_the_optional_icc_application_extension() { + let mut bytes = gif(&[(10, 5)]); + let frame = crate::scan(&bytes, 1).unwrap().frames[0]; + let profile = [1, 2, 3, 4, 5]; + let mut extension = vec![0x21, 0xff, 0x0b]; + extension.extend_from_slice(b"ICCRGBG1012"); + extension.push(profile.len() as u8); + extension.extend_from_slice(&profile); + extension.push(0); + bytes.splice(frame.offset..frame.offset, extension); + + assert_eq!(icc_profile(&bytes).as_deref(), Some(profile.as_slice())); + } + #[test] fn partial_frames_composite_and_background_disposal_is_applied() { let palette = [0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 255, 0]; @@ -250,6 +308,30 @@ mod tests { Err(FrameError::LimitExceeded(_)) )); assert!(read_frames(&bytes, &FrameLimits { max_decode_bytes: Some(1), ..FrameLimits::default() }).is_err()); + assert!( + matches!(read_frames(&bytes, &FrameLimits { max_frames: 0, ..FrameLimits::default() }), Err(FrameError::LimitExceeded(message)) if message.contains("frame limit is zero")) + ); + } + + #[test] + fn merged_frames_still_report_the_decode_frame_limit() { + let bytes = gif(&[(10, 5), (10, 7), (200, 1)]); + let animation = read_frames(&bytes, &FrameLimits { max_frames: 2, merge_identical: true, ..FrameLimits::default() }).unwrap(); + assert_eq!(reds(&animation), [(10, 120)]); + assert_eq!(animation.stop, Stop::FrameLimit); + } + + #[test] + fn a_frame_beyond_the_limit_is_not_decoded_or_allocated() { + let mut bytes = gif(&[(1, 1), (2, 1)]); + let info = crate::scan(&bytes, 2).unwrap(); + let second = info.frames[1]; + // The first frame and canvas fit in 24 bytes; the second frame must not be decoded. + bytes[second.offset + 5..second.offset + 9].copy_from_slice(&[0xff, 0xff, 0xff, 0xff]); + let limits = FrameLimits { max_frames: 1, max_bytes: 24, max_canvas_bytes: 24, max_decode_bytes: Some(1024), ..FrameLimits::default() }; + let animation = read_frames(&bytes, &limits).unwrap(); + assert_eq!(animation.frames.len(), 1); + assert_eq!(animation.stop, Stop::FrameLimit); } #[test] diff --git a/crates/gif/src/lib.rs b/crates/gif/src/lib.rs index 33cdad53c..1cec284ca 100644 --- a/crates/gif/src/lib.rs +++ b/crates/gif/src/lib.rs @@ -188,6 +188,94 @@ pub fn palette(bytes: &[u8]) -> Option<(Vec<[u8; 3]>, Option)> { Some((colors, transparent)) } +/// Counts pixels emitted by the first GIF frame's LZW stream, up to its declared area. +/// Returns `None` when the first frame or its LZW minimum code size is unreadable. This reads +/// code lengths only; it does not allocate or materialize pixel values. +pub fn first_frame_decoded_pixels(bytes: &[u8]) -> Option { + let parsed = scan(bytes, 1)?; + let frame = parsed.frames.first()?; + let packed = *bytes.get(frame.offset.checked_add(9)?)?; + let table_bytes = table_len(packed); + let code_size_at = frame.offset.checked_add(10)?.checked_add(table_bytes)?; + let min_size = *bytes.get(code_size_at)?; + if !(1..=11).contains(&min_size) { + return None; + } + let clear = 1usize << min_size; + let end = clear + 1; + let max = u64::from(frame.width) * u64::from(frame.height); + let mut lengths = [1u16; 4096]; + let (mut width, mut next, mut previous) = (u32::from(min_size) + 1, end + 1, None::); + let (mut bits, mut held, mut pixels) = (0u32, 0u32, 0u64); + let mut pos = code_size_at.checked_add(1)?; + while let Some(&length) = bytes.get(pos) { + let start = pos.checked_add(1)?; + pos = start.checked_add(usize::from(length))?; + if length == 0 { + break; + } + for &byte in bytes.get(start..pos.min(bytes.len()))? { + bits |= u32::from(byte) << held; + held += 8; + while held >= width { + let code = (bits & ((1 << width) - 1)) as usize; + bits >>= width; + held -= width; + if code == clear { + (width, next, previous) = (u32::from(min_size) + 1, end + 1, None); + continue; + } + let string_len = match previous { + _ if code == end => return Some(pixels), + None if code < clear => 1, + Some(_) if code < next => lengths.get(code).copied().unwrap_or(1), + Some(prev) if code == next => lengths.get(prev).copied().unwrap_or(1).saturating_add(1), + _ => return Some(pixels), + }; + let defined_len = previous.map(|prev| lengths.get(prev).copied().unwrap_or(1).saturating_add(1)); + if let Some(defined_len) = defined_len + && let Some(slot) = lengths.get_mut(next) + { + *slot = defined_len; + next += 1; + if next == 1 << width && width < 12 { + width += 1; + } + } + previous = Some(code); + pixels = pixels.saturating_add(u64::from(string_len)); + if pixels >= max { + return Some(max); + } + } + } + } + Some(pixels) +} + +/// Counts compressed image-data bytes in the first frame's sub-blocks, excluding its LZW code-size +/// byte and all later blocks. A truncated final sub-block contributes only the bytes present. +pub fn first_frame_compressed_bytes(bytes: &[u8]) -> Option { + let parsed = scan(bytes, 1)?; + let frame = parsed.frames.first()?; + let packed = *bytes.get(frame.offset.checked_add(9)?)?; + let mut pos = frame.offset.checked_add(10)?.checked_add(table_len(packed))?.checked_add(1)?; + let mut total = 0usize; + loop { + let length = usize::from(*bytes.get(pos)?); + pos = pos.checked_add(1)?; + if length == 0 { + return Some(total); + } + let end = pos.checked_add(length)?; + total = total.checked_add(end.min(bytes.len()).saturating_sub(pos))?; + if end > bytes.len() { + return Some(total); + } + pos = end; + } +} + /// Whether a file of `file_len` bytes can plausibly hold `declared_bytes` of decoded picture: at /// most `max_ratio` decoded bytes for each file byte, plus 1 MiB. A decoder that sets up the whole /// logical screen before it reads a frame should be given only files that pass this. diff --git a/crates/gif/src/tests.rs b/crates/gif/src/tests.rs index a75743b19..6a776eb50 100644 --- a/crates/gif/src/tests.rs +++ b/crates/gif/src/tests.rs @@ -54,6 +54,15 @@ fn animated(delays_cs: &[u32], repeat: Option) -> Ve out } +#[test] +fn first_frame_compressed_bytes_exclude_extensions_after_the_image_data() { + let mut bytes = gif(&[[1, 2, 3], [4, 5, 6]], None, None); + assert_eq!(first_frame_compressed_bytes(&bytes), Some(2)); + bytes.splice(bytes.len() - 1..bytes.len() - 1, [0x21, 0xFE, 1, b'x', 0]); + assert_eq!(first_frame_compressed_bytes(&bytes), Some(2)); + assert_eq!(first_frame_compressed_bytes(b"not a gif"), None); +} + #[test] fn the_first_frames_table_and_transparent_entry() { let g = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255]];