mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-30 11:12:24 +01:00
Strict workspace Clippy stopped on collapsible_match in affinity, raw, plugins, engine, ui-egui and xtask. Pure conditions become guarded match arms (each falling through to an existing empty arm), and the ruler drag state and the measurement command result are bound to locals, preserving evaluation order, error messages and fallbacks. The same lint fixes also appear as a side commit in #5157 (David2766), which can drop it on rebase.
125 lines
5.5 KiB
Rust
125 lines
5.5 KiB
Rust
//! The indexed PNG thumbnail Affinity stores next to the archive: Affinity's own render of the
|
|
//! document, at most 512 pixels on the long side in real files, never the document itself.
|
|
|
|
use crate::Error;
|
|
use crate::container::header;
|
|
|
|
pub const MAX_PREVIEW_BYTES: usize = 16 << 20;
|
|
pub const MAX_PREVIEW_DIMENSION: u32 = 4096;
|
|
|
|
/// Borrowed embedded thumbnail, at its own dimensions, never the native document dimensions.
|
|
#[derive(Debug)]
|
|
pub struct Preview<'a> {
|
|
pub png: &'a [u8],
|
|
pub width: u32,
|
|
pub height: u32,
|
|
}
|
|
|
|
fn bytes_at(bytes: &[u8], offset: usize, length: usize) -> Result<&[u8], Error> {
|
|
let end = offset.checked_add(length).ok_or(Error::Malformed("offset overflow"))?;
|
|
bytes.get(offset..end).ok_or(Error::Malformed("truncated record"))
|
|
}
|
|
|
|
fn le32(bytes: &[u8], offset: usize) -> Result<u32, Error> {
|
|
let b = bytes_at(bytes, offset, 4)?;
|
|
Ok(u32::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?))
|
|
}
|
|
|
|
fn be32(bytes: &[u8], offset: usize) -> Result<u32, Error> {
|
|
let b = bytes_at(bytes, offset, 4)?;
|
|
Ok(u32::from_be_bytes(b.try_into().map_err(|_| Error::Malformed("PNG integer"))?))
|
|
}
|
|
|
|
/// Read only the indexed `Thmb` record (identical in container versions 8 to 12), checking every PNG chunk CRC. Compressed
|
|
/// pixels must still be validated by the consuming image decoder, with allocation limits.
|
|
pub fn preview(bytes: &[u8]) -> Result<Preview<'_>, Error> {
|
|
let h = header(bytes)?;
|
|
if h.thumbnail_offset == 0 {
|
|
return Err(Error::Unsupported("no embedded preview"));
|
|
}
|
|
let offset = usize::try_from(h.thumbnail_offset).map_err(|_| Error::Malformed("thumbnail offset overflow"))?;
|
|
if offset < 72 {
|
|
return Err(Error::Malformed("thumbnail overlaps the file header"));
|
|
}
|
|
let record = bytes.get(offset..).ok_or(Error::Malformed("thumbnail offset outside file"))?;
|
|
if bytes_at(record, 0, 8)? != b"\xff\xff\xff\xffThmb" {
|
|
return Err(Error::Malformed("missing indexed thumbnail"));
|
|
}
|
|
if le32(record, 8)? != 1 || bytes_at(record, 28, 1)? != [1] {
|
|
return Err(Error::Unsupported("thumbnail record version or encoding"));
|
|
}
|
|
let size = usize::try_from(le32(record, 24)?).map_err(|_| Error::Limit("encoded size"))?;
|
|
if size > MAX_PREVIEW_BYTES {
|
|
return Err(Error::Limit("encoded preview exceeds 16 MiB"));
|
|
}
|
|
if le32(record, 16)? != 29 || le32(record, 20)? != 0 || u64::from(le32(record, 12)?) != size as u64 + 13 {
|
|
return Err(Error::Malformed("inconsistent thumbnail record lengths"));
|
|
}
|
|
let png = bytes_at(record, 29, size)?;
|
|
if bytes_at(png, 0, 8)? != b"\x89PNG\r\n\x1a\n" || be32(png, 8)? != 13 || bytes_at(png, 12, 4)? != b"IHDR" {
|
|
return Err(Error::Malformed("thumbnail is not a PNG"));
|
|
}
|
|
let width = be32(png, 16)?;
|
|
let height = be32(png, 20)?;
|
|
if width == 0 || height == 0 || width > MAX_PREVIEW_DIMENSION || height > MAX_PREVIEW_DIMENSION {
|
|
return Err(Error::Limit("preview dimensions must be 1..4096"));
|
|
}
|
|
// Bound work by bytes, not a file-supplied chunk count, and require exactly one complete PNG.
|
|
let mut pos = 8usize;
|
|
let (mut palette, mut image_data, mut image_data_ended) = (false, false, false);
|
|
loop {
|
|
let length = usize::try_from(be32(png, pos)?).map_err(|_| Error::Limit("PNG chunk length"))?;
|
|
let chunk_size = length.checked_add(12).ok_or(Error::Malformed("PNG chunk overflow"))?;
|
|
let chunk = bytes_at(png, pos, chunk_size)?;
|
|
let checksum_input = bytes_at(chunk, 4, chunk_size - 8)?;
|
|
if crc32fast::hash(checksum_input) != be32(chunk, chunk_size - 4)? {
|
|
return Err(Error::Malformed("PNG chunk checksum"));
|
|
}
|
|
let kind = bytes_at(chunk, 4, 4)?;
|
|
if !kind.iter().all(u8::is_ascii_alphabetic) {
|
|
return Err(Error::Malformed("PNG chunk type"));
|
|
}
|
|
match kind {
|
|
b"IHDR" if pos == 8 => {}
|
|
b"IHDR" => return Err(Error::Malformed("duplicate PNG header")),
|
|
b"PLTE" => {
|
|
if palette || image_data {
|
|
return Err(Error::Malformed("PNG palette order"));
|
|
}
|
|
palette = true;
|
|
}
|
|
b"IDAT" => {
|
|
if image_data_ended {
|
|
return Err(Error::Malformed("non-contiguous PNG image data"));
|
|
}
|
|
image_data = true;
|
|
}
|
|
b"IEND" if !image_data => return Err(Error::Malformed("PNG has no image data")),
|
|
b"IEND" => {}
|
|
_ if kind.first().is_some_and(u8::is_ascii_uppercase) => {
|
|
return Err(Error::Unsupported("unknown critical PNG chunk"));
|
|
}
|
|
_ => {}
|
|
}
|
|
if image_data && kind != b"IDAT" {
|
|
image_data_ended = true;
|
|
}
|
|
// Native sample previews are plain, single-frame PNGs. Pixel allocation limits do not
|
|
// necessarily bound aggregate compressed metadata; leave these layouts unsupported.
|
|
if matches!(kind, b"zTXt" | b"iTXt" | b"iCCP") {
|
|
return Err(Error::Unsupported("compressed PNG metadata in the preview"));
|
|
}
|
|
if matches!(kind, b"acTL" | b"fcTL" | b"fdAT") {
|
|
return Err(Error::Unsupported("animated PNG preview"));
|
|
}
|
|
pos = pos.checked_add(chunk_size).ok_or(Error::Malformed("PNG offset overflow"))?;
|
|
if kind == b"IEND" {
|
|
if length != 0 || pos != png.len() {
|
|
return Err(Error::Malformed("PNG end or trailing data"));
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
Ok(Preview { png, width, height })
|
|
}
|