EPS: loops under a long dash array no longer hang the import

setdash took a dash array of any length, and the graphics state kept
a copy of it. gsave, gstate, currentgstate and setgstate copied the
whole pattern with the state, currentdash made an array of it, and
each stroke and strokepath copied and scanned it. Each of these
counted as one operation against the operation budget. Under a
pattern of 1,000,000 entries, a pass of gsave grestore took 0.38 ms
of user CPU time, and a pass of strokepath on a line took 2.05 ms.
Loops of the two would reach the budget after about 1.6 and 2.8
hours.

setdash now raises limitcheck for a pattern of more than 65,535
entries, the most elements an array has in a typical PostScript
implementation (PostScript Language Reference, third edition,
Table B.1). Saved and copied graphics states share the pattern.
setdash and each stroke count each entry they read or copy as one
operation, and currentdash counts the array it makes against the
memory budget. Under a pattern of 65,535 entries, loops of setdash,
currentdash, stroke and strokepath now run out of the operation
budget, or the memory budget for currentdash, after 0.08 to 0.31
seconds of user CPU time.

Tests: a pattern of 65,535 entries is set, and a pattern of 65,536
entries stops the reading with limitcheck. Under a pattern of 65,535
entries, 4,000,000 passes of gsave grestore and 2,000,000 passes of
gstate currentgstate setgstate finish, and loops of setdash,
currentdash and strokepath run out of a budget. The test of these
loops reads each program on a thread of its own and fails when no
result arrives within 20 seconds.
This commit is contained in:
dotCooCoo 2026-10-11 18:28:09 -07:00 committed by bflatastic
parent 9cbab1383b
commit ae27cf6c2b
2 changed files with 62 additions and 16 deletions

View File

@ -26,6 +26,9 @@ const HAIRLINE: f64 = 0.25;
const MAX_INKS: usize = 32;
/// Most dashes `strokepath` makes of a path (more and it outlines the path undashed).
const MAX_DASHES: f64 = 1e5;
/// Most entries a dash pattern has: the most elements an array has in a typical PostScript
/// implementation (PLRM 3rd ed., Table B.1).
const MAX_DASH_ENTRIES: usize = 65_535;
/// Most entries an indexed colour space's table has.
const MAX_HIVAL: usize = 4095;
/// Most elements the current path may have.
@ -121,7 +124,8 @@ pub(crate) struct GState {
pub cap: LineCap,
pub join: LineJoin,
pub miter: f64,
pub dash: Vec<f64>,
/// The dash pattern, shared by the states saved or copied from this one.
pub dash: Rc<[f64]>,
pub dash_offset: f64,
/// The current path, in document space.
pub path: BezPath,
@ -151,7 +155,7 @@ impl Default for GState {
cap: LineCap::Butt,
join: LineJoin::Miter,
miter: 10.0,
dash: vec![],
dash: Rc::from([]),
dash_offset: 0.0,
path: BezPath::new(),
cur: None,
@ -564,16 +568,19 @@ impl Interp<'_> {
Ok(())
}
fn stroke_path(&mut self, bp: BezPath) {
fn stroke_path(&mut self, bp: BezPath) -> Res {
if bp.elements().is_empty() || self.g.paint.is_none() || self.g.null {
return;
return Ok(());
}
let st = self.stroke_layer();
let st = self.stroke_layer()?;
self.out.stroke(bp, st, &self.g.clips);
Ok(())
}
/// The stroke the graphics state draws, in document space.
fn stroke_layer(&self) -> StrokeLayer {
/// The stroke the graphics state draws, in document space. Each entry of the dash pattern it
/// copies counts as an operation.
fn stroke_layer(&mut self) -> Res<StrokeLayer> {
self.spend(self.g.dash.len() as u64)?;
let k = self.scale();
let w = self.g.width * k;
let mut st = StrokeLayer::new(self.g.paint.clone(), if w > 0.0 && w.is_finite() { w } else { HAIRLINE });
@ -584,7 +591,7 @@ impl Interp<'_> {
let pattern: Vec<f64> = self.g.dash.iter().map(|d| d * k).collect();
let dash = Dash { pattern, offset: self.g.dash_offset * k, align_corners: false };
st.dash = dash.is_dashed().then_some(dash);
st
Ok(st)
}
/// The current path in user space, each element as its operator and points (quadratic
@ -707,10 +714,7 @@ impl Interp<'_> {
}
let bp = self.take_path();
match op {
Op::UStroke => {
self.stroke_path(bp);
Ok(())
}
Op::UStroke => self.stroke_path(bp),
_ => self.fill_path(bp, if op == Op::UEoFill { FillRule::EvenOdd } else { FillRule::NonZero }),
}
});
@ -1201,7 +1205,7 @@ impl Interp<'_> {
ExecForm => self.exec_form()?,
StrokePath => {
let bp = self.take_path();
let mut st = self.stroke_layer();
let mut st = self.stroke_layer()?;
// A dash pattern far finer than the path would make countless pieces.
let period: f64 = st.dash.as_ref().map_or(0.0, |d| d.pattern.iter().sum());
let periods = if st.dash.is_some() && period > 0.0 { bp.perimeter(1e-3) / period } else { 0.0 };
@ -1312,11 +1316,18 @@ impl Interp<'_> {
SetDash => {
let offset = self.pop_num()?;
let items = self.pop_array()?;
if items.len() > MAX_DASH_ENTRIES {
return ps_err("limitcheck", "");
}
// Each entry read counts as an operation.
self.spend(items.len() as u64)?;
let v: Option<Vec<f64>> = items.borrow().iter().map(|o| o.as_num().filter(|v| v.is_finite() && *v >= 0.0)).collect();
self.g.dash = v.ok_or(PsError::Ps("typecheck", String::new()))?;
self.g.dash = v.ok_or(PsError::Ps("typecheck", String::new()))?.into();
self.g.dash_offset = offset;
}
CurrentDash => {
// The array made counts against the memory budget.
self.alloc(self.g.dash.len() * std::mem::size_of::<Obj>())?;
let d = self.g.dash.iter().map(|v| Obj::Real(*v)).collect();
self.push(Obj::array(d))?;
self.push_num(self.g.dash_offset)?;
@ -1651,7 +1662,7 @@ impl Interp<'_> {
}
Stroke => {
let bp = self.take_path();
self.stroke_path(bp);
self.stroke_path(bp)?;
}
RectFill => {
let bp = self.rects()?;
@ -1667,7 +1678,7 @@ impl Interp<'_> {
let xf = self.xf();
bp.apply_affine(xf * m * xf.inverse());
}
self.stroke_path(bp);
self.stroke_path(bp)?;
}
ShFill => {
let d = self.pop_dict()?;

View File

@ -171,6 +171,41 @@ fn line_state_reaches_the_stroke() {
assert!(stroke(&objects(&read("0 setlinewidth 0 0 moveto 9 9 lineto stroke").document)[0]).unwrap().width > 0.0);
}
/// `setdash` takes a dash pattern of up to 65,535 entries, the most elements an array has in a
/// typical implementation (PLRM 3rd ed., Table B.1), and raises `limitcheck` for a longer one.
#[test]
fn dash_patterns_hold_at_most_65535_entries() {
let dash = |n: usize| format!("/a {n} array def 0 1 {} {{ a exch 1 put }} for a 0 setdash", n - 1);
check(&format!("{} currentdash pop length 65535 eq", dash(65535)));
let r = read(&format!("0 0 10 10 rectfill {}", dash(65536)));
assert_eq!(objects(&r.document).len(), 1, "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("limitcheck in `setdash`")), "{:?}", r.warnings);
}
/// Saved and copied graphics states share the dash pattern, and `setdash`, `currentdash` and
/// each stroke count the entries they read or copy against the budgets. Under a pattern of
/// 65,535 entries, 4,000,000 passes of `gsave grestore` and 2,000,000 passes of
/// `gstate currentgstate setgstate` finish, and loops of `setdash`, `currentdash` and `strokepath`
/// run out of a budget.
#[test]
fn work_on_the_longest_dash_pattern_ends_soon() {
let dash = "0 0 10 10 rectfill /a 65535 array def 0 1 65534 { a exch 1 put } for a 0 setdash";
for body in ["4000000 { gsave grestore } repeat", "2000000 { gstate currentgstate setgstate } repeat"] {
let r = read_soon(&format!("{dash} {body} 20 20 10 10 rectfill"));
assert_eq!(objects(&r.document).len(), 2, "{body}: {:?}", r.warnings);
assert!(r.warnings.is_empty(), "{body}: {:?}", r.warnings);
}
for (body, why) in [
("{ a 0 setdash } loop", "runs too long"),
("{ currentdash pop pop } loop", "uses too much memory"),
("{ newpath 0 0 moveto 100 0 lineto strokepath } loop", "runs too long"),
] {
let r = read_soon(&format!("{dash} {body}"));
assert_eq!(objects(&r.document).len(), 1, "{body}: {:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains(why)), "{body}: {:?}", r.warnings);
}
}
#[test]
fn procedures_bind_def_loops_and_dictionaries() {
let r = read(