diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index 90b7103b..255da696 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -34,6 +34,9 @@ const MAX_PATH: usize = 1 << 20; const MAX_DRAWN: usize = 1 << 24; /// Samples taken of a shading function that isn't a plain interpolation. const SHADING_SAMPLES: usize = 32; +/// Most bounds of a stitched linear function that become a gradient's stops: past it, the +/// function is sampled like any other (each stop evaluates the whole function). +const MAX_STITCH_STOPS: usize = 1024; pub(super) const FAR_AWAY: &str = "objects far outside the page were left out"; pub(super) const TOO_MUCH: &str = "the file draws more than VectorCraft reads: the rest was left out"; @@ -1007,8 +1010,10 @@ impl Interp<'_> { let domain = nums(get("Domain")); let (d0, d1) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0)); let f = get("Function").ok_or(PsError::Ps("undefined", "Function".into()))?; - // Where the colour changes: the ends, and the bounds of stitched linear functions. - let ts: Vec = match linear_points(&f) { + // Where the colour changes: the ends, and the bounds of stitched linear functions (unless + // there are too many). + let mut visits = 0; + let ts: Vec = match linear_points(&f, 0, &mut visits).filter(|p| p.len() <= MAX_STITCH_STOPS) { Some(mut pts) => { pts.retain(|t| (d0.min(d1)..=d0.max(d1)).contains(t)); pts.iter().map(|t| if d1 != d0 { (t - d0) / (d1 - d0) } else { 0.0 }).chain([0.0, 1.0]).collect() @@ -1085,16 +1090,21 @@ impl Interp<'_> { Ok(c0.iter().zip(&c1).map(|(a, b)| a + x * (b - a)).collect()) } Some(3.0) => { - let fs = get("Functions").and_then(|o| o.items().map(|i| i.to_vec())).unwrap_or_default(); + // Read the pieces through the borrow and clone only the one we recurse into: a + // `Functions` array of millions (shared, or reached again through a cycle) must + // not be copied on every call and at every live recursion level. + let functions = get("Functions"); + let fs = functions.as_ref().and_then(Obj::items); + let n = fs.map_or(0, Shared::len); let bounds = nums(get("Bounds")); let encode = nums(get("Encode")); let (lo, hi) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0)); - let k = bounds.iter().take_while(|b| t >= **b).count().min(fs.len().saturating_sub(1)); + let k = bounds.iter().take_while(|b| t >= **b).count().min(n.saturating_sub(1)); let a = if k == 0 { lo } else { bounds.get(k - 1).copied().unwrap_or(lo) }; let b = bounds.get(k).copied().unwrap_or(hi); let (e0, e1) = (encode.get(2 * k).copied().unwrap_or(0.0), encode.get(2 * k + 1).copied().unwrap_or(1.0)); let u = if b != a { e0 + (t - a) / (b - a) * (e1 - e0) } else { e0 }; - let g = fs.get(k).cloned().ok_or(PsError::Ps("rangecheck", "Functions".into()))?; + let g = fs.and_then(|fs| fs.get(k)).ok_or(PsError::Ps("rangecheck", "Functions".into()))?; self.eval(&g, &[u], depth + 1) } _ => { @@ -1621,16 +1631,34 @@ fn is_mesh(sh: &DictRef) -> bool { } /// Where a stitched function of linear interpolations changes slope (its bounds), when it is -/// one; `None` for functions that must be sampled. -fn linear_points(f: &Obj) -> Option> { +/// one; `None` for functions that must be sampled. `depth` bounds the nesting the same way +/// [`Interp::eval`] does (`> 8`) and `visits` (shared by the whole recursion, incremented on +/// every call) bounds the total nodes looked at. Both are needed: the depth limit alone stops a +/// function that refers to itself in a cycle, but not one that fans out through shared children +/// (1025 references to a child a few levels deep is 1025^levels nodes without the visit cap). A +/// well-formed stitch of linear pieces is one level, at most `MAX_STITCH_STOPS + 1` pieces, so it +/// costs at most `MAX_STITCH_STOPS + 2` visits and is never cut off. +fn linear_points(f: &Obj, depth: u32, visits: &mut usize) -> Option> { + *visits += 1; + if depth > 8 || *visits > 2 * (MAX_STITCH_STOPS + 1) { + return None; + } let Obj::Dict(d) = f else { return None }; let d = d.borrow(); let num = |k: &str| d.get(&Key::name(k)).and_then(Obj::as_num); match num("FunctionType")? { 2.0 if num("N").unwrap_or(1.0) == 1.0 => Some(vec![]), 3.0 => { - let fs = d.get(&Key::name("Functions"))?.items()?.to_vec(); - if !fs.iter().all(|g| linear_points(g).is_some_and(|p| p.is_empty())) { + let items = d.get(&Key::name("Functions"))?.items()?; + // More pieces than the stop cap allows: sample the function instead (tested before + // `to_vec`, so a huge `Functions` array is not copied only to be rejected). Each stop + // runs [`Interp::eval`], which reads one piece, so a short `Bounds` with a huge + // `Functions` would still be O(stops * Functions) to find each piece without this. + if items.len() > MAX_STITCH_STOPS + 1 { + return None; + } + let fs = items.to_vec(); + if !fs.iter().all(|g| linear_points(g, depth + 1, visits).is_some_and(|p| p.is_empty())) { return None; } d.get(&Key::name("Bounds"))?.items().map(|i| i.borrow().iter().filter_map(Obj::as_num).collect()) diff --git a/crates/eps/src/import/tests_generators.rs b/crates/eps/src/import/tests_generators.rs index e5ad7c4d..cf5f4e08 100644 --- a/crates/eps/src/import/tests_generators.rs +++ b/crates/eps/src/import/tests_generators.rs @@ -897,6 +897,99 @@ fn hostile_patterns_glyphs_and_shadings_end() { assert_eq!(objects(&r.document).len(), 1); } +/// The stops of the gradient `body` draws, after asserting it came in cleanly as one gradient. +fn stitch_stops(body: &str) -> Vec { + let r = read(body); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let o = objects(&r.document); + let [node] = o.as_slice() else { panic!("{o:?}") }; + let Some(Paint::Gradient(g)) = fill(node) else { panic!("{node:?}") }; + g.gradient.stops.iter().map(|s| s.offset).collect() +} + +/// A gradient stitched from linear pieces keeps their bounds as its stops up to the cap, and is +/// sampled past it. The cap is on the pieces; a well-formed stitch has one more piece than bounds. +#[test] +fn gradients_stitched_from_linear_pieces_keep_or_sample_their_stops() { + // `bounds` interior bounds at i / (bounds + 1), with one linear piece more than that. + let stitched = |bounds: usize| { + let pieces = bounds + 1; + format!( + "/F << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> def \ + << /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function << /FunctionType 3 /Domain [0 1] \ + /Functions [ {pieces} {{ F }} repeat ] /Bounds [ 1 1 {bounds} {{ {pieces} div }} for ] >> >> shfill" + ) + }; + // A few pieces: the bounds are kept exactly as stops (three bounds -> five stops with the ends). + assert_eq!(stitch_stops(&stitched(3)), vec![0.0, 0.25, 0.5, 0.75, 1.0]); + // At the cap (1024 bounds, 1025 pieces) the 1024 bounds and the two ends are all kept. + assert_eq!(stitch_stops(&stitched(1024)).len(), 1026); + // One past it (1025 bounds, 1026 pieces) the function is sampled instead. + assert_eq!(stitch_stops(&stitched(1025)).len(), 33); +} + +/// A malformed stitching function with a single piece but thousands of bounds is sampled, not +/// turned into thousands of stops. This is the one case the stop-count `.filter` catches on its +/// own: the one piece is under the Functions-length guard, so only the bound count is out of range. +#[test] +fn a_stitching_function_with_one_piece_but_many_bounds_is_sampled() { + let stops = stitch_stops( + "/F << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> def \ + << /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function << /FunctionType 3 /Domain [0 1] \ + /Functions [ F ] /Bounds [ 1 1 2000 { 2001 div } for ] >> >> shfill", + ); + // Base keeps all 2000 bounds (2002 stops); the fix samples. + assert_eq!(stops.len(), 33); +} + +/// A short `Bounds` with a `Functions` array one past the cap is sampled: without the length +/// guard each of the ~1000 stops would read through the whole array to find its piece. +#[test] +fn a_gradient_with_a_functions_array_past_the_cap_is_sampled() { + let stops = stitch_stops( + "/F << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> def \ + << /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function << /FunctionType 3 /Domain [0 1] \ + /Functions [ 1026 { F } repeat ] /Bounds [ 1 1 1000 { 1001 div } for ] >> >> shfill", + ); + // Base keeps the 1000 bounds (1002 stops); the fix samples. + assert_eq!(stops.len(), 33); +} + +/// A stitching function (type 3) whose `Functions` array holds the function itself must not +/// recurse without end: `linear_points` (depth- and visit-bounded) and `eval` (depth-bounded) +/// both stop, so the shading raises an error instead of overflowing the stack (an abort +/// `vectorcraft_engine::guard` can't catch, and on wasm there is no net at all). +#[test] +fn a_stitching_function_that_refers_to_itself_is_rejected_not_crashed() { + let r = read( + "0 0 10 10 rectfill \ + /F 5 dict def F /FunctionType 3 put F /Domain [0 1] put F /Bounds [] put F /Encode [0 1] put F /Functions [ F ] put \ + << /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function F >> shfill", + ); + // Not an abort: the reader stops at the error, keeps the rectangle drawn before it, and leaves + // the rest of the program unread (see `import`: what follows the error is left out). + assert_eq!(objects(&r.document).len(), 1); + assert!(r.warnings.iter().any(|w| w.contains("read up to an error") && w.contains("Function")), "{:?}", r.warnings); +} + +/// A stitching function need not point at itself to blow up: a `Functions` array of 1025 +/// references to a child that holds 1025 references to a child ... is a tree of 1025^levels nodes +/// though it is tiny in memory (the children are shared). The shared visit counter stops +/// `linear_points` after about 2 * (MAX_STITCH_STOPS + 1) nodes, so it is sampled at once instead +/// of being walked; on the unmodified reader this explores ~1025^3 nodes. +#[test] +fn a_deeply_shared_stitching_function_is_sampled_not_walked() { + // leaf (linear) <- mid2[1025 leaf] <- mid1[1025 mid2] <- top[1025 mid1] (1000 bounds). + let stops = stitch_stops( + "/leaf << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> def \ + /mid2 << /FunctionType 3 /Domain [0 1] /Bounds [] /Functions [ 1025 { leaf } repeat ] >> def \ + /mid1 << /FunctionType 3 /Domain [0 1] /Bounds [] /Functions [ 1025 { mid2 } repeat ] >> def \ + /top << /FunctionType 3 /Domain [0 1] /Bounds [ 1 1 1000 { 1001 div } for ] /Functions [ 1025 { mid1 } repeat ] >> def \ + << /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function top >> shfill", + ); + assert_eq!(stops.len(), 33); +} + /// Executable strings and keys other than names (#474): `cvx` makes a string executable, and /// running it runs its text (PLRM 3rd ed. §3.3.1, `cvx`, `exec`); `load` takes any key (§8.2). /// A program closes its shading dictionaries with an executable string (`>>` at Level 2 and up,