diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index 5d4f1c974..2c6ede5c6 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -95,6 +95,9 @@ impl IccProfile { } pub fn from_bytes(name: Option, bytes: &[u8]) -> Result { + if !localized_text_fits(bytes) { + return Err(CmsError::BadProfile(format!("its localized names take more than {} KB", MAX_LOCALIZED_TEXT >> 10))); + } let p = ColorProfile::new_from_slice(bytes).map_err(|e| CmsError::BadProfile(format!("{e:?}")))?; Ok(Self { source: bytes.to_vec(), ..Self::from_profile(name, p)? }) } @@ -393,3 +396,34 @@ pub(super) fn gray_profile(name: &str) -> ColorProfile { p.gray_trc = ColorProfile::new_srgb().red_trc; labelled(p, name) } + +/// Most bytes of UTF-16 the localized names (`mluc` tags) of a profile may take together. +const MAX_LOCALIZED_TEXT: usize = 1 << 20; + +/// Whether the localized-name records of the `mluc` tags in ICC `bytes` take at most +/// [`MAX_LOCALIZED_TEXT`] together. moxcms copies each record into a string of its own, and records +/// may all point at the same bytes, so a profile under a megabyte could make it allocate +/// gigabytes. A table it can't read is left to moxcms to refuse. +pub(crate) fn localized_text_fits(bytes: &[u8]) -> bool { + let be32 = |b: &[u8], at: usize| b.get(at..at.checked_add(4)?).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]) as usize); + let Some(tags) = be32(bytes, 128) else { return true }; + let mut total = 0usize; + for i in 0..tags { + let Some(entry) = i.checked_mul(12).and_then(|e| e.checked_add(132)) else { return false }; + let (Some(offset), Some(size)) = (be32(bytes, entry + 4), be32(bytes, entry + 8)) else { break }; + let Some(tag) = offset.checked_add(size).and_then(|end| bytes.get(offset..end)) else { continue }; + if !tag.starts_with(b"mluc") { + continue; + } + let records = be32(tag, 8).unwrap_or(0); + for r in 0..records { + // Each record's header: language, country, length, offset. + let Some(length) = r.checked_mul(12).and_then(|h| be32(tag, h + 16 + 4)) else { break }; + total = total.saturating_add(length); + if total > MAX_LOCALIZED_TEXT { + return false; + } + } + } + true +} diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index ae3697fe3..fa2449bc0 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -152,3 +152,31 @@ fn cmyk_profiles_have_the_tags_of_an_output_profile() { assert!(p.lut_a_to_b_saturation.is_some() && p.lut_b_to_a_saturation.is_some() && p.gamut.is_some(), "{name}"); } } + +/// A profile whose localized-name records all point at one long string is refused before moxcms +/// copies each of them; a few short names pass. +#[test] +fn aliased_localized_names_are_refused() { + let profile = |records: u32, length: u32| { + let mut b = vec![0u8; 128]; + b.extend(1u32.to_be_bytes()); + let tag_at = 128 + 4 + 12; + let size = 16 + 12 * records + length; + b.extend(b"desc"); + b.extend((tag_at as u32).to_be_bytes()); + b.extend(size.to_be_bytes()); + b.extend(b"mluc"); + b.extend([0; 4]); + b.extend(records.to_be_bytes()); + b.extend(12u32.to_be_bytes()); + for _ in 0..records { + b.extend(b"enUS"); + b.extend(length.to_be_bytes()); + b.extend((16 + 12 * records).to_be_bytes()); + } + b.extend(vec![0u8; length as usize]); + b + }; + assert!(super::icc::localized_text_fits(&profile(3, 64))); + assert!(!super::icc::localized_text_fits(&profile(4096, 64 << 10))); +} diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs index 80f740321..b1f51213b 100644 --- a/crates/color/src/palette_io.rs +++ b/crates/color/src/palette_io.rs @@ -515,13 +515,24 @@ fn read_acb(bytes: &[u8], fallback: &str) -> Result { let spot = data.0.windows(8).last() != Some(b"spflproc".as_slice()); let mut lib = SwatchLibrary { name: if title.is_empty() { fallback.into() } else { title }, ..Default::default() }; let mut names = UniqueNames::default(); + // Every name gets the prefix and the suffix, and is kept twice (the swatch's and the set of + // names taken): a megabyte book of long affixes and many colors came to hundreds of MB. + let mut kept = 0usize; for (name, color) in colors.into_iter().filter(|(n, _)| !n.trim().is_empty()) { + kept = kept.saturating_add(2 * (prefix.len() + name.len() + suffix.len())); + if kept > ACB_MAX_NAME_BYTES { + return Err(format!("the color book's names take more than {} MB", ACB_MAX_NAME_BYTES >> 20)); + } let name = names.unique(format!("{prefix}{name}{suffix}").trim().to_string()); lib.swatches.push(Swatch { name, paint: Paint::solid(color), global: spot, spot }); } Ok(lib) } +/// Most bytes a color book's full color names (with its prefix and suffix) may take, counted +/// twice as they are kept twice. +const ACB_MAX_NAME_BYTES: usize = 64 << 20; + /// The swatch of an `.ase` color block, `None` for a model other than RGB, CMYK, Lab and Gray. /// Components are clamped to their ranges. fn ase_swatch(body: &mut AseData) -> Result, String> { diff --git a/crates/effects/src/pixel/loaded.rs b/crates/effects/src/pixel/loaded.rs index f08d2ef1c..bb885b239 100644 --- a/crates/effects/src/pixel/loaded.rs +++ b/crates/effects/src/pixel/loaded.rs @@ -7,6 +7,7 @@ //! directions. use std::io::Cursor; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use vectorcraft_geom::Point; @@ -26,6 +27,15 @@ const RELIEF: f64 = 1.0; /// How many decoded textures [`texture`] keeps. const KEEP: usize = 4; +/// Most bytes of gray levels the loaded textures alive at once may hold, wherever they are kept +/// (the effects of every drawing hold theirs): a texture that would pass it isn't decoded. Each +/// one is decoded within [`vectorcraft_doc::MAX_RASTER_ALLOC`], but a document's many Glass +/// effects each kept their own. +const MAX_LIVE: usize = 512 << 20; + +/// Bytes of gray levels the [`LoadedTexture`]s alive hold ([`MAX_LIVE`]). +static LIVE: AtomicUsize = AtomicUsize::new(0); + /// A loaded texture: `width` × `height` gray levels, row by row from the top. #[derive(Debug, PartialEq, Eq)] pub struct LoadedTexture { @@ -34,7 +44,19 @@ pub struct LoadedTexture { gray: Vec, } +impl Drop for LoadedTexture { + fn drop(&mut self) { + LIVE.fetch_sub(self.gray.len(), Ordering::Relaxed); + } +} + impl LoadedTexture { + /// A texture of `gray`, counted in [`LIVE`]. + fn new(width: u32, height: u32, gray: Vec) -> Self { + LIVE.fetch_add(gray.len(), Ordering::Relaxed); + Self { width, height, gray } + } + /// The texture of `width` × `height` straight (not premultiplied) RGBA pixels: each pixel's /// luma over middle gray, as much as its alpha covers it (a transparent pixel is middle gray). /// `None` when the image is empty or `rgba` doesn't hold exactly that many pixels. @@ -52,7 +74,7 @@ impl LoadedTexture { ((a * l + (1.0 - a) * 0.5) * 255.0).round().clamp(0.0, 255.0) as u8 }) .collect(); - Some(Self { width, height, gray }) + Some(Self::new(width, height, gray)) } /// The size in pixels. @@ -71,14 +93,23 @@ impl LoadedTexture { /// The texture of a PNG's gray levels, decoded within [`vectorcraft_doc::MAX_RASTER_ALLOC`]; /// `None` when `png` isn't a PNG or holds no pixels. + /// `None` too when its gray levels would take the live textures past [`MAX_LIVE`]. fn from_png(png: &[u8]) -> Option { - let mut reader = image::ImageReader::with_format(Cursor::new(png), image::ImageFormat::Png); - let mut limits = image::Limits::default(); - limits.max_alloc = Some(vectorcraft_doc::MAX_RASTER_ALLOC); - reader.limits(limits); - let img = reader.decode().ok()?.to_luma8(); + let reader = || { + let mut reader = image::ImageReader::with_format(Cursor::new(png), image::ImageFormat::Png); + let mut limits = image::Limits::default(); + limits.max_alloc = Some(vectorcraft_doc::MAX_RASTER_ALLOC); + reader.limits(limits); + reader + }; + let (w, h) = reader().into_dimensions().ok()?; + let gray = (w as usize).checked_mul(h as usize)?; + if LIVE.load(Ordering::Relaxed).saturating_add(gray) > MAX_LIVE { + return None; + } + let img = reader().decode().ok()?.to_luma8(); let (width, height) = img.dimensions(); - (width > 0 && height > 0).then(|| Self { width, height, gray: img.into_raw() }) + (width > 0 && height > 0).then(|| Self::new(width, height, img.into_raw())) } /// The surface's height (points) at `p` (points at 100 %, from the object's center): the gray @@ -99,24 +130,26 @@ impl LoadedTexture { } } -/// The textures [`texture`] decoded last, with their stored form, the newest last. -static DECODED: Mutex)>> = Mutex::new(Vec::new()); +/// The textures [`texture`] decoded last, with their PNG, the newest last. +static DECODED: Mutex, Arc)>> = Mutex::new(Vec::new()); /// The texture a [`TEXTURE_IMAGE`] value holds; `None` when it isn't base64 of a PNG. The last -/// [`KEEP`] textures decoded are kept for the next call. +/// [`KEEP`] textures decoded are kept for the next call, found by their PNG: values that differ +/// only in how the base64 is spaced share one. pub fn texture(stored: &str) -> Option> { + let png = base64_decode(stored)?; let mut decoded = DECODED.lock().unwrap_or_else(PoisonError::into_inner); - if let Some(i) = decoded.iter().position(|(s, _)| s == stored) { + if let Some(i) = decoded.iter().position(|(p, _)| *p == png) { let entry = decoded.remove(i); let t = entry.1.clone(); decoded.push(entry); return Some(t); } - let t = Arc::new(LoadedTexture::from_png(&base64_decode(stored)?)?); + let t = Arc::new(LoadedTexture::from_png(&png)?); if decoded.len() >= KEEP { decoded.remove(0); } - decoded.push((stored.to_string(), t.clone())); + decoded.push((png, t.clone())); Some(t) } diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 17507f3ce..b2528818b 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -12,6 +12,24 @@ use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; pub const REVOLVE: &str = "threeD.revolve"; +/// Most bytes the copies of the object's paint in the faces may take together. Each face and the +/// seam stroke around it gets a copy of the paint, so a gradient of many stops times tens of +/// thousands of faces took gigabytes; past this the source path is drawn. +const MAX_PAINT_COPIES: usize = 256 << 20; + +/// About the bytes one copy of `paint` takes. +fn paint_bytes(paint: &Paint) -> usize { + use std::mem::size_of; + use vectorcraft_doc::color::{FreeformPoint, GradientStop}; + let own = size_of::(); + let Paint::Gradient(g) = paint else { return own }; + let freeform = g + .freeform + .as_ref() + .map_or(0, |f| f.points.len() * size_of::() + f.lines.iter().map(|l| l.len() * size_of::()).sum::()); + own + size_of::() + g.gradient.stops.len() * size_of::() + freeform +} + fn visible(fx: &[Effect]) -> bool { fx.iter().any(|e| e.visible && e.id == REVOLVE) } @@ -93,6 +111,10 @@ fn surface( let options = revolve_options(&merged_params(REVOLVE, &fx.params)); let colored = options.light_color != [1.0; 3] || options.lights.is_some(); let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; + // A face's fill, its shadow and their seam strokes: up to four copies of the paint. + if faces.len().saturating_mul(4).saturating_mul(paint_bytes(&paint)) > MAX_PAINT_COPIES { + return None; + } // Visibility is an expansion option, never a change to live geometry or export baking. // Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility // failure also retains the complete surface rather than losing any of the user's art. diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 44d24343b..87eca97d7 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -58,7 +58,9 @@ pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec, }; match seg { PathSeg::Line(l) => push(l.p1), - _ => kurbo::flatten([PathEl::MoveTo(seg.start()), seg.as_path_el()], tol, |el| { + // Bounded per curve: a projection can blow a small curve up enough that kurbo sizes + // an enormous buffer from it. + _ => vectorcraft_geom::flatten([PathEl::MoveTo(seg.start()), seg.as_path_el()], tol, |el| { if let PathEl::LineTo(p) = el { push(p); } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index c69a95a1c..118d3f0b9 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -26,6 +26,53 @@ pub use projective::Homography; /// Tolerance used when comparing handle positions to anchor positions. pub const EPS: f64 = 1e-9; +/// Most line segments [`flatten`] makes of one curve, whatever its size and the tolerance. +pub const MAX_FLATTEN_PER_CURVE: f64 = 4096.0; + +/// [`kurbo::flatten`], each curve's tolerance raised so that it makes at most about +/// [`MAX_FLATTEN_PER_CURVE`] segments. kurbo sizes its work and a buffer from the curve's +/// coordinates before producing anything, so a small curve with far-out handles (from a file, or +/// scaled up by a view or a projection) asked for gigabytes. The segments a curve needs grow with +/// the square root of its control polygon's length over the tolerance; that ratio is held under +/// the square of the cap. A curve whose points aren't finite is drawn as a line to its end. +pub fn flatten(path: impl IntoIterator, tolerance: f64, mut callback: impl FnMut(PathEl)) { + let mut last = None; + let mut start = None; + for el in path { + let (from, to, length) = match el { + PathEl::MoveTo(p) => { + (last, start) = (Some(p), Some(p)); + callback(el); + continue; + } + PathEl::LineTo(p) => { + last = Some(p); + callback(el); + continue; + } + PathEl::ClosePath => { + last = start; + callback(el); + continue; + } + PathEl::QuadTo(a, b) => (last, b, last.map(|p| p.distance(a) + a.distance(b))), + PathEl::CurveTo(a, b, c) => (last, c, last.map(|p| p.distance(a) + a.distance(b) + b.distance(c))), + }; + last = Some(to); + let (Some(from), Some(length)) = (from, length) else { continue }; + if !length.is_finite() { + callback(PathEl::LineTo(to)); + continue; + } + let tol = tolerance.max(length / (MAX_FLATTEN_PER_CURVE * MAX_FLATTEN_PER_CURVE)); + kurbo::flatten([PathEl::MoveTo(from), el], tol, |e| { + if !matches!(e, PathEl::MoveTo(_)) { + callback(e); + } + }); + } +} + /// Union of two optional rects. pub fn union_opt(a: Option, b: Option) -> Option { match (a, b) { @@ -131,6 +178,28 @@ pub fn reference_point(r: Rect, index: usize) -> Point { mod tests { use super::*; + /// A tiny curve with far-out handles flattens into a bounded number of segments; an ordinary + /// one exactly as kurbo flattens it. + #[test] + fn flatten_bounds_the_segments_of_one_curve() { + let count = |path: &[PathEl], tol: f64| { + let mut n = 0usize; + flatten(path.iter().copied(), tol, |_| n += 1); + n + }; + let wild = [PathEl::MoveTo(Point::ZERO), PathEl::CurveTo(Point::new(1e15, -1e15), Point::new(-1e15, 1e15), Point::new(1.0, 0.0))]; + assert!(count(&wild, 0.25) < 3 * MAX_FLATTEN_PER_CURVE as usize); + let plain = + [PathEl::MoveTo(Point::ZERO), PathEl::CurveTo(Point::new(0.0, 50.0), Point::new(100.0, 50.0), Point::new(100.0, 0.0)), PathEl::ClosePath]; + let mut ours = vec![]; + flatten(plain.iter().copied(), 0.25, |e| ours.push(e)); + let mut theirs = vec![]; + kurbo::flatten(plain.iter().copied(), 0.25, |e| theirs.push(e)); + assert_eq!(ours, theirs); + let nan = [PathEl::MoveTo(Point::ZERO), PathEl::QuadTo(Point::new(f64::NAN, 0.0), Point::new(1.0, 1.0))]; + assert_eq!(count(&nan, 0.25), 2); + } + #[test] fn constrain_axes_are_exact_and_whole_turns_are_none() { for d in [0.0, 360.0, -360.0, 720.0, f64::NAN, f64::INFINITY] { diff --git a/crates/plugins/src/effect.rs b/crates/plugins/src/effect.rs index 8796745f6..e55e0e0ba 100644 --- a/crates/plugins/src/effect.rs +++ b/crates/plugins/src/effect.rs @@ -29,6 +29,10 @@ pub const WALL_TIME_MS: u64 = 5_000; /// Cached results kept before the cache starts over. const CACHE_SIZE: usize = 512; +/// Anchors the cached inputs and results may hold together before the cache starts over (about +/// 50 MB). A plug-in can return far more than it was given, so a count of results alone let a few +/// hundred small inputs keep gigabytes. A result past a quarter of this isn't kept. +const CACHE_ANCHORS: usize = 1 << 20; /// The plug-in id of effect id `id` (`plugin.`). pub fn plugin_id(effect_id: &str) -> Option<&str> { @@ -107,9 +111,18 @@ impl Key { } } -type Cache = HashMap)>; +#[derive(Default)] +struct Cache { + entries: HashMap)>, + /// The anchors of the entries' inputs and results ([`CACHE_ANCHORS`]). + anchors: usize, +} static CACHE: Mutex> = Mutex::new(None); + +fn anchors(p: &PathData) -> usize { + p.subpaths.iter().map(|sp| sp.anchors.len()).sum() +} static ERRORS: Mutex>> = Mutex::new(None); /// The last error of live-effect plug-in `id` (cleared when it next succeeds). @@ -142,7 +155,7 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill path: path.clone(), }; let h = key.hash64(); - if let Some((k, out)) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|c| c.get(&h)) + if let Some((k, out)) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|c| c.entries.get(&h)) && *k == key { return out.clone(); @@ -155,12 +168,19 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill } note(id, &r); let out = r.ok(); - let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); - let cache = cache.get_or_insert_with(HashMap::new); - if cache.len() >= CACHE_SIZE { - cache.clear(); + let size = anchors(&key.path) + out.as_ref().map_or(0, anchors); + if size > CACHE_ANCHORS / 4 { + return out; } - cache.insert(h, (key, out.clone())); + let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let cache = cache.get_or_insert_with(Cache::default); + if cache.entries.len() >= CACHE_SIZE || cache.anchors + size > CACHE_ANCHORS { + *cache = Cache::default(); + } + if let Some((old, old_out)) = cache.entries.insert(h, (key, out.clone())) { + cache.anchors = cache.anchors.saturating_sub(anchors(&old.path) + old_out.as_ref().map_or(0, anchors)); + } + cache.anchors += size; out } diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index b70ebe84b..eeb397b62 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -46,6 +46,11 @@ pub struct Limits { /// Wall-clock budget for one run, in milliseconds. Native builds only: the web build has no /// monotonic clock in `std`, so the fuel budget alone bounds it there. pub wall_time_ms: u64, + /// The most a heavy filter ([`crate::Budget::Heavy`]) is given instead of `fuel_base` and + /// `wall_time_ms`: what the host allows, which the module's manifest can ask for but never + /// raise. Set them to `fuel_base` and `wall_time_ms` to give heavy filters nothing more. + pub heavy_fuel: u64, + pub heavy_wall_time_ms: u64, } impl Default for Limits { @@ -58,6 +63,8 @@ impl Default for Limits { fuel_per_byte: 4_000, max_recursion_depth: 1024, wall_time_ms: 60_000, + heavy_fuel: HEAVY_FUEL, + heavy_wall_time_ms: HEAVY_WALL_TIME_MS, } } } @@ -170,11 +177,11 @@ impl Plugin { e => e, })?; p.manifest = Manifest::parse(&text)?; - // A heavy filter's runs get the larger budget, which the host lets the user allow or - // refuse before installing it. + // A heavy filter's runs get the larger budget the host allows for one, which the host + // lets the user allow or refuse before installing it. if p.manifest.budget == crate::Budget::Heavy { - p.limits.fuel_base = p.limits.fuel_base.max(HEAVY_FUEL); - p.limits.wall_time_ms = p.limits.wall_time_ms.max(HEAVY_WALL_TIME_MS); + p.limits.fuel_base = p.limits.fuel_base.max(p.limits.heavy_fuel); + p.limits.wall_time_ms = p.limits.wall_time_ms.max(p.limits.heavy_wall_time_ms); } // The other exports must exist with the right types. live.func::("vc_alloc")?; @@ -217,8 +224,9 @@ impl Plugin { StoreLimitsBuilder::new().memory_size(self.limits.max_memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build(); let mut store = Store::new(&self.engine, limits); store.limiter(|l| l); - // The start function (if any) runs on this fuel. - store.set_fuel(self.limits.fuel_base).map_err(wasm_err)?; + // The start function (if any) runs on one slice of fuel: it runs before `call` can check + // the deadline or a cancel between slices, so a heavy budget would let it run for minutes. + store.set_fuel(self.limits.fuel_base.min(FUEL_SLICE)).map_err(wasm_err)?; let linker = Linker::::new(&self.engine); let instance = linker.instantiate_and_start(&mut store, &self.module).map_err(wasm_err)?; let memory = diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1b89cae74..c39c7ea0e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3442,7 +3442,9 @@ fn recent_row(ui: &mut Ui, path: &str) -> egui::Response { } fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) { - vectorcraft_geom::kurbo::flatten(p.elements().iter().copied(), tol, f); + // Bounded per curve: a document's far-out handles, zoomed onto the screen, made kurbo + // reserve gigabytes for the selection outline. + vectorcraft_geom::flatten(p.elements().iter().copied(), tol, f); } /// The rendered raster `img` as an egui image. Both are premultiplied RGBA8, so its bytes are copied