security(io): charge sparse layers, shape caches and shared decodes (#8955)

* security(io): charge sparse layers, shape caches and shared decodes

ORA and PSD export refuse dense boxes past a cap; PSD import and ABR mapping decode shared masks/tips once; Affinity and SVG shape caches are charged to the import budget; SVG embedded JPEG size is probed; Krita tiles are charged for the backing tiles they land in.

* fix(io): clippy range pattern; align the sparse Krita test tiles to the backing grid

* io: address review - offset-aware KRA tile budget, saturating stroke reach, OpenRaster entry limit, PSD retained-surface budget, halved PSD gather cap
This commit is contained in:
Dylan M. Taylor
2026-10-23 21:52:25 -04:00
committed by GitHub
parent a02f2ee410
commit 8931277070
8 changed files with 207 additions and 16 deletions
+10 -2
View File
@@ -57,7 +57,7 @@ pub fn map_file(f: &AbrFile, group: &str) -> AbrImport {
/// [`map_file`] that checks `ctl` before each brush.
pub fn map_file_with(f: &AbrFile, group: &str, ctl: &photocraft_raster::Interrupt) -> Result<AbrImport, photocraft_raster::Cancelled> {
let mut out = AbrImport { version: f.version, warnings: f.warnings.clone(), ..Default::default() };
let mut m = Mapper { file: f, warnings: BTreeSet::new(), unknown: BTreeSet::new() };
let mut m = Mapper { file: f, warnings: BTreeSet::new(), unknown: BTreeSet::new(), tips: Default::default() };
let total = f.legacy.len().max(f.samples.len()).max(f.presets.len()).max(1) as f32;
let step = |i: usize| -> Result<(), photocraft_raster::Cancelled> {
ctl.check()?;
@@ -255,18 +255,26 @@ struct Mapper<'a> {
file: &'a AbrFile,
warnings: BTreeSet<String>,
unknown: BTreeSet<String>,
/// Tips already converted, by sample: presets can name one sample thousands of times, and a
/// tile clones by sharing its data.
tips: std::collections::HashMap<*const AbrSample, (photocraft_paint::tile::GrayTile, f32)>,
}
impl Mapper<'_> {
/// A sampled tip (downsampled to [`MAX_TIP_EDGE`]) and its size (larger side).
fn tip(&mut self, s: &AbrSample) -> (TipShape, f32) {
if let Some((tile, size)) = self.tips.get(&std::ptr::from_ref(s)) {
return (TipShape::Sampled(tile.clone()), *size);
}
let v = s.to_unit();
let (w, h, v) = fit(s.width, s.height, v, MAX_TIP_EDGE);
if (w, h) != (s.width, s.height) {
self.warnings.insert(format!("tips larger than {MAX_TIP_EDGE} px were downsampled"));
}
let data = v.iter().map(|x| (x.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect();
(TipShape::Sampled(GrayTile { width: w, height: h, data }), s.width.max(s.height) as f32)
let (tile, size) = (GrayTile { width: w, height: h, data }, s.width.max(s.height) as f32);
self.tips.insert(std::ptr::from_ref(s), (tile.clone(), size));
(TipShape::Sampled(tile), size)
}
fn dynamic(&mut self, d: Option<&Descriptor>) -> Dynamic {
+11 -1
View File
@@ -443,7 +443,17 @@ impl Builder {
})
});
let mut sh = ShapeLayer { path, fill, stroke, ..ShapeLayer::default() };
sh.cache = Some(photocraft_vector::render_shape(&sh, self.format, self.canvas));
// The pixel cache is optional (the shape renders from its path without it), so charge the
// area it would cover to the import's pixel budget and go without it when that is spent:
// geometry across a 300000 px canvas would otherwise be rasterized for every shape.
let reach = sh.stroke.as_ref().map_or(0, |s| (s.width.ceil() as i32).saturating_add(1));
let area = frame.inflate(reach).intersect(&self.canvas);
let cost = u64::from(area.width()).saturating_mul(u64::from(area.height())).saturating_mul(self.format.bytes_per_pixel() as u64);
if charge(&mut self.pixel_budget, cost) {
sh.cache = Some(photocraft_vector::render_shape(&sh, self.format, self.canvas));
} else {
self.warn("shapes covering more pixels than the import's memory allows were left without a pixel cache");
}
sh
}
+1 -1
View File
@@ -112,7 +112,7 @@ impl Reader<'_, '_> {
if file.pixel_size != space.pixel_size() {
return Err(invalid(format!("{name}: pixel size {} does not match its colour space", file.pixel_size)));
}
let cost = file.decoded_bytes(fmt.bytes_per_pixel())?;
let cost = file.decoded_bytes(fmt.bytes_per_pixel(), dx, dy)?;
self.budget = self.budget.checked_sub(cost).ok_or_else(|| invalid("decoded layers exceed the 1 GiB limit"))?;
let mut surface = match self.default_pixel(name, space)? {
Some(px) if px.iter().any(|v| *v != 0.0) => Surface::with_default(fmt, &space.to_format(&px, fmt)),
+49 -3
View File
@@ -216,9 +216,27 @@ impl<'a> TileFile<'a> {
Ok(TileFile { width, height, pixel_size, tiles })
}
/// Bytes the decoded tiles take at `bpp` bytes per pixel.
pub(crate) fn decoded_bytes(&self, bpp: usize) -> Result<usize> {
self.tiles.len().checked_mul(self.width * self.height).and_then(|n| n.checked_mul(bpp)).ok_or_else(|| invalid("tile size overflow"))
/// Bytes the decoded tiles take at `bpp` bytes per pixel: the larger of their own pixels and
/// the whole 256×256 backing tiles they land in (a surface stores no less than a tile, so
/// sparse tiles at distinct positions retain far more than they hold).
pub(crate) fn decoded_bytes(&self, bpp: usize, dx: i32, dy: i32) -> Result<usize> {
let overflow = || invalid("tile size overflow");
let logical = self.tiles.len().checked_mul(self.width * self.height).and_then(|n| n.checked_mul(bpp)).ok_or_else(overflow)?;
let side = photocraft_geom::TILE_SIZE;
let (w, h) = (i32::try_from(self.width.max(1)).map_err(|_| overflow())?, i32::try_from(self.height.max(1)).map_err(|_| overflow())?);
let mut backing = std::collections::HashSet::new();
for &(x, y, _) in &self.tiles {
// The tile lands at its offset position and covers every backing tile its box overlaps.
let (x, y) = (x.checked_add(dx).ok_or_else(overflow)?, y.checked_add(dy).ok_or_else(overflow)?);
let (x1, y1) = (x.checked_add(w - 1).ok_or_else(overflow)?, y.checked_add(h - 1).ok_or_else(overflow)?);
for c in x.div_euclid(side)..=x1.div_euclid(side) {
for r in y.div_euclid(side)..=y1.div_euclid(side) {
backing.insert((c, r));
}
}
}
let tile_bytes = (side as usize).checked_mul(side as usize).and_then(|n| n.checked_mul(bpp)).ok_or_else(overflow)?;
Ok(logical.max(backing.len().checked_mul(tile_bytes).ok_or_else(overflow)?))
}
/// Writes every tile into `surface` (format `surface.format()`), offset by (`dx`, `dy`).
@@ -260,3 +278,31 @@ impl<'a> TileFile<'a> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn file(positions: &[(i32, i32)]) -> TileFile<'static> {
TileFile { width: 64, height: 64, pixel_size: 4, tiles: positions.iter().map(|&(x, y)| (x, y, &[][..])).collect() }
}
#[test]
fn sparse_tiles_are_charged_for_the_backing_tiles_they_land_in() {
// Sixteen 64×64 tiles that fill one 256×256 backing tile cost what they hold.
let dense: Vec<_> = (0..4).flat_map(|y| (0..4).map(move |x| (x * 64, y * 64))).collect();
assert_eq!(file(&dense).decoded_bytes(4, 0, 0).unwrap(), 16 * 64 * 64 * 4);
// The same number far apart each take a whole backing tile: 16 × 256 × 256 × 4.
let sparse: Vec<_> = (0..16).map(|i| (i * 10_240, 0)).collect();
assert_eq!(file(&sparse).decoded_bytes(4, 0, 0).unwrap(), 16 * 256 * 256 * 4);
}
#[test]
fn the_placement_offset_and_tile_extent_decide_the_backing_tiles() {
// One 64×64 tile straddling a backing-tile corner once offset touches four.
assert_eq!(file(&[(0, 0)]).decoded_bytes(4, 224, 224).unwrap(), 4 * 256 * 256 * 4);
// A tile wider than a backing tile reaches the ones between its corners.
let wide = TileFile { width: 1024, height: 1, pixel_size: 4, tiles: vec![(0, 0, &[][..])] };
assert_eq!(wide.decoded_bytes(4, 0, 0).unwrap(), 4 * 256 * 256 * 4);
}
}
+19 -3
View File
@@ -520,6 +520,20 @@ fn png(w: u32, h: u32, px: &[[f32; 4]], sample: CSample) -> Result<Vec<u8>> {
/// `px` (row-major over `rect`) cut down to the pixels that aren't fully transparent, with
/// transparent pixels zeroed, so masked-out or empty margins aren't written.
/// Most bytes of straight RGBA floats one layer may take while it is written. A layer is one PNG
/// of its content's bounding box, so a few pixels far apart make a box of mostly nothing; refuse
/// that rather than allocate it. 1.5 × [`MAX_ENTRY`] of floats is at most 8 B/px × a third of that
/// raw, so the PNG stays within what [`Reader::png`] will read back.
const MAX_EXPORT_LAYER_BYTES: u64 = 3 * MAX_ENTRY as u64 / 2;
fn admit_layer(rect: Rect) -> Result<()> {
let bytes = u64::from(rect.width()).saturating_mul(u64::from(rect.height())).saturating_mul(std::mem::size_of::<[f32; 4]>() as u64);
if bytes > MAX_EXPORT_LAYER_BYTES {
return Err(IoError::Unsupported(format!("a {}×{} layer is too large to save to OpenRaster", rect.width(), rect.height())));
}
Ok(())
}
fn crop(rect: Rect, mut px: Vec<[f32; 4]>) -> (Rect, Vec<[f32; 4]>) {
let w = rect.width() as usize;
if w == 0 {
@@ -609,11 +623,12 @@ impl Writer<'_> {
}
/// The layer's own pixels (straight RGBA floats) and their bounds; its pixel mask applied.
fn pixels(&mut self, layer: &Layer) -> (Rect, Vec<[f32; 4]>) {
fn pixels(&mut self, layer: &Layer) -> Result<(Rect, Vec<[f32; 4]>)> {
let canvas = self.doc.bounds();
let (rect, mut buf) = match &layer.content {
LayerContent::Raster(s) => {
let r = s.content_bounds();
admit_layer(r)?;
(r, photocraft_compose::surface_to_buffer(s, r).px)
}
_ => {
@@ -627,6 +642,7 @@ impl Writer<'_> {
solo.mask = None;
solo.effects = Default::default();
let r = photocraft_compose::composite_bounds(&solo, canvas).unwrap_or(canvas).intersect(&canvas);
admit_layer(r)?;
(r, photocraft_compose::render_layer(&solo, r).px)
}
};
@@ -642,7 +658,7 @@ impl Writer<'_> {
p[3] *= layer.fill_opacity.clamp(0.0, 1.0);
}
}
crop(rect, buf)
Ok(crop(rect, buf))
}
fn layers(&mut self, layers: &[Layer], depth: usize) -> Result<()> {
@@ -671,7 +687,7 @@ impl Writer<'_> {
if self.dry {
continue;
}
let (rect, px) = self.pixels(layer);
let (rect, px) = self.pixels(layer)?;
let (rect, px) = if rect.is_empty() { (Rect::from_xywh(0, 0, 1, 1), vec![[0.0; 4]]) } else { (rect, px) };
let src = format!("data/layer{}.png", self.next);
self.next += 1;
+11
View File
@@ -21,6 +21,10 @@ use crate::psd_import::REGENERATED;
const RASTER_STALE_KEYS: [&[u8; 4]; 8] = [b"SoCo", b"GdFl", b"PtFl", b"SoLd", b"PlLd", b"SoLE", b"TySh", b"vscg"];
const PSD_MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024;
/// Most bytes of one layer's interleaved samples the exporter gathers. The interleaved bytes and
/// the channel planes made from them (or the band parts and the joined buffer) are alive together,
/// so the peak is about twice this; it is also what fits in a file of at most [`PSD_MAX_FILE_SIZE`].
const MAX_LAYER_GATHER_BYTES: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 256 << 20 };
const PSD_ESTIMATE_FIXED_OVERHEAD: u64 = 1024 * 1024;
const PSD_ESTIMATE_LAYER_OVERHEAD: u64 = 64 * 1024;
@@ -382,6 +386,13 @@ impl Ex {
if r.is_empty() {
return (PsdRect::default(), self.empty_channels(), warnings);
}
// A layer record is one dense rectangle, so a few pixels far apart make a box of mostly
// nothing, gathered and then copied again. Past the cap the layer is saved empty, said so.
let dense = u64::from(r.width()).saturating_mul(u64::from(r.height())).saturating_mul(s.format().bytes_per_pixel() as u64);
if dense > MAX_LAYER_GATHER_BYTES {
warnings.push(format!("layer \"{name}\" spans {}×{} px, too large to write: saved without its pixels", r.width(), r.height()));
return (PsdRect::default(), self.empty_channels(), warnings);
}
// The interleaved gather is the sequential pass for full-canvas layers, so it runs
// in row bands on all cores and the band results concatenate in order.
let row_len = r.width() as usize;
+38 -3
View File
@@ -59,6 +59,13 @@ pub(crate) struct Ctx<'a> {
pub txt2: Option<photocraft_text::engine_data::Value>,
/// Smart-filter caches from the global `FEid`/`FXid` blocks (filter masks, by placed id).
pub filter_effects: Vec<photocraft_psd::filter_effects::FilterEffectsItem>,
/// Filter masks already decoded, by placed id: every layer of a source reads the same item, and
/// a surface clones by sharing its tiles.
pub filter_masks: std::collections::HashMap<String, Option<LayerMask>>,
/// The same for the unfiltered pixels the mask mixes back in.
pub unfiltered: std::collections::HashMap<String, Option<Surface>>,
/// Pixel bytes retained in [`Self::unfiltered`], held under [`MAX_UNFILTERED_BYTES`].
pub unfiltered_bytes: u64,
/// Placed layers naming the same source share editable contents, not their placement.
pub smart_contents: std::collections::HashMap<String, SmartContentsId>,
/// Source sizes of `PlLd`-only placed layers, by source id (read once per embedded file).
@@ -278,14 +285,19 @@ impl Ctx<'_> {
/// document coordinates, white beyond its bounds (`filterMaskExtendWithWhite`). An all-white
/// mask is no mask.
fn filter_mask(&mut self, placed: &str, stack: &crate::smart_map::FilterStack, name: &str) -> Option<LayerMask> {
if let Some(done) = self.filter_masks.get(placed) {
return done.clone();
}
let item = self.filter_effects.iter().find(|i| i.id == placed)?;
match crate::smart_map::mask_from_item(item, self.mask_fmt.sample, stack) {
let mask = match crate::smart_map::mask_from_item(item, self.mask_fmt.sample, stack) {
Ok(m) => m,
Err(e) => {
self.warn(format!("layer \"{name}\": {e}; the filter mask was ignored"));
None
}
}
};
self.filter_masks.insert(placed.to_string(), mask.clone());
mask
}
fn apply_common(&mut self, l: &mut Layer, rec: &LayerRecord, blend_override: Option<photocraft_psd::BlendMode>) {
@@ -459,7 +471,24 @@ impl Ctx<'_> {
let shown = stack.enabled && stack.filters.iter().any(|f| f.visible);
let cache = match (&placed, filter_mask.as_ref().filter(|m| m.enabled && shown)) {
(Some(p), Some(m)) => {
let unfiltered = self.filter_effects.iter().find(|i| i.id == p.placed).and_then(|i| crate::smart_map::unfiltered_from_item(i, self.fmt));
let unfiltered = match self.unfiltered.get(&p.placed) {
Some(done) => done.clone(),
None => {
// Every distinct source is kept until the import ends, so what they add up to is
// bounded: past the limit the layer keeps its stored pixels, unmixed.
let item = self.filter_effects.iter().find(|i| i.id == p.placed);
let cost = item
.and_then(|i| i.rect.size().ok())
.map_or(0, |(w, h)| (w as u64).saturating_mul(h as u64).saturating_mul(self.fmt.bytes_per_pixel() as u64));
let fits = self.unfiltered_bytes.saturating_add(cost) <= MAX_UNFILTERED_BYTES;
let made = item.filter(|_| fits).and_then(|i| crate::smart_map::unfiltered_from_item(i, self.fmt));
if made.is_some() {
self.unfiltered_bytes += cost;
}
self.unfiltered.insert(p.placed.clone(), made.clone());
made
}
};
match unfiltered {
Some(u) => photocraft_compose::filter_mask::mix(&u, &stored, m),
None => stored,
@@ -660,6 +689,9 @@ pub(crate) fn group_depth(file: &PsdFile) -> usize {
/// Additional-layer-information keys (Adobe PSD spec, plus `cust`, `vowv` and `SoLE` seen in
/// Photoshop's files) that are not an adjustment of their own: names, ids, locks, effects,
/// masks, metadata, and the fill, type, placed and shape content read before.
/// Most pixel bytes the smart-filter `FEid` sources may keep decoded across one import.
const MAX_UNFILTERED_BYTES: u64 = 1 << 30;
const LAYER_INFO_KEYS: [&[u8; 4]; 50] = [
b"luni", b"lyid", b"lnsr", b"lsct", b"lsdk", b"lspf", b"lclr", b"iOpa", b"clbl", b"infx", b"knko", b"tsly", b"lmgm", b"vmgm", b"brst", b"shmd", b"fxrp",
b"lfx2", b"lfxs", b"lmfx", b"lrFX", b"lyvr", b"sn2P", b"cust", b"CgEd", b"vmsk", b"vsms", b"vogk", b"vowv", b"vstk", b"vscg", b"artb", b"artd", b"abdd",
@@ -975,6 +1007,9 @@ pub(crate) fn psd_to_document_decoded_integrity(
dpi: doc.resolution_dpi,
txt2: file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)),
filter_effects: Vec::new(),
filter_masks: Default::default(),
unfiltered: Default::default(),
unfiltered_bytes: 0,
smart_contents: Default::default(),
plld_sizes: Default::default(),
ctl: *ctl,
+68 -3
View File
@@ -54,15 +54,42 @@ fn png_dimensions(data: &[u8]) -> Option<(u32, u32)> {
/// Returns `Some(Some((w, h)))` when the format is PNG/WebP and its header parses cleanly,
/// `Some(None)` when the format is PNG/WebP (by MIME or magic bytes under `"text/plain"`)
/// and its header is malformed, or `None` for other formats.
/// A JPEG's size from its frame header (the first SOF marker), without decoding anything.
fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> {
let mut i = 2;
loop {
if *data.get(i)? != 0xFF {
return None;
}
let marker = *data.get(i + 1)?;
match marker {
0xFF => i += 1,
// SOF0..SOF15, but for DHT (C4), JPG (C8) and DAC (CC).
0xC0..=0xCF if !matches!(marker, 0xC4 | 0xC8 | 0xCC) => {
let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]);
let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]);
return Some((u32::from(w), u32::from(h)));
}
// Start of scan or end of image before any frame header.
0xD8..=0xDA => return None,
0x01 | 0xD0..=0xD7 => i += 2,
_ => i += 2 + usize::from(u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?])),
}
}
}
fn embedded_image_dimensions(mime: &str, data: &[u8]) -> Option<Option<(u32, u32)>> {
match mime {
"image/png" => Some(png_dimensions(data)),
"image/webp" => Some(photocraft_codecs::probe_size(data).ok()),
"image/webp" | "image/gif" => Some(photocraft_codecs::probe_size(data).ok()),
"image/jpeg" | "image/jpg" => Some(jpeg_dimensions(data)),
"text/plain" => {
if data.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
Some(png_dimensions(data))
} else if data.get(0..4) == Some(b"RIFF") && data.get(8..12) == Some(b"WEBP") {
} else if (data.get(0..4) == Some(b"RIFF") && data.get(8..12) == Some(b"WEBP")) || data.starts_with(b"GIF8") {
Some(photocraft_codecs::probe_size(data).ok())
} else if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
Some(jpeg_dimensions(data))
} else {
None
}
@@ -672,7 +699,25 @@ impl Conv {
_ => FillRule::NonZero,
};
let mut sh = ShapeLayer { path: Path { subpaths, fill_rule, inverted: false }, fill, stroke, ..Default::default() };
sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, self.canvas));
// The pixel cache is optional, so it is charged like any other retained raster and left
// out (the shape renders from its path) once the budget is spent: compact geometry can
// cover the whole canvas in every one of thousands of shapes.
let b = data.bounds();
let reach = sh.stroke.as_ref().map_or(0.0, |s| s.width);
let area = Rect::new(
(b.left() - reach).floor() as i32,
(b.top() - reach).floor() as i32,
(b.right() + reach).ceil() as i32,
(b.bottom() + reach).ceil() as i32,
)
.intersect(&self.canvas);
let pixels = u64::from(area.width()) * u64::from(area.height());
if self.can_retain_pixels(pixels) {
sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, self.canvas));
let bytes = pixels * self.fmt.bytes_per_pixel() as u64;
self.pixel_budget -= bytes;
self.retained_pixel_bytes += bytes;
}
Some(Layer::new(self.name(p.id(), 0), LayerContent::Shape(sh)))
}
@@ -1153,6 +1198,26 @@ mod tests {
assert!(resolve("text/plain", Arc::new(bad_webp), &opt).is_none());
}
#[test]
fn jpeg_dimensions_come_from_the_frame_header_and_oversized_jpegs_are_refused() {
// SOI, an APP0 segment of length 4, then SOF0 (length 11): 8 bits, height 40000, width 30000.
let jpeg = |h: u16, w: u16| {
let mut v = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0, 0, 0xFF, 0xC0, 0x00, 0x0B, 8];
v.extend(h.to_be_bytes());
v.extend(w.to_be_bytes());
v.extend([1, 1, 0x11, 0]);
v
};
assert_eq!(jpeg_dimensions(&jpeg(40_000, 30_000)), Some((30_000, 40_000)));
assert_eq!(jpeg_dimensions(&[0xFF, 0xD8, 0xFF, 0xDA, 0, 2]), None);
assert_eq!(jpeg_dimensions(&[0xFF, 0xD8]), None);
let opt = options(Arc::new(usvg::fontdb::Database::new()));
let resolve = &opt.image_href_resolver.resolve_data;
// 1.2 billion pixels would be 4.8 GB of RGBA before resvg scaled anything down.
assert!(resolve("image/jpeg", Arc::new(jpeg(40_000, 30_000)), &opt).is_none());
assert!(resolve("text/plain", Arc::new(jpeg(40_000, 30_000)), &opt).is_none());
}
#[test]
fn resolve_data_rejects_nested_gzip_and_handles_text_plain_svgz() {
use std::io::Write;