From 8e4e29844ec480894d22bd0a3be724d404f9db24 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 12:15:02 -0400 Subject: [PATCH 1/2] Never crash: PDF close-path operators and CFF outlines without a current point The s, b and b* operators closed the path even when there was no current point, which kurbo asserts against (a panic in debug builds; found by fuzzing). Close only when a subpath is open. CFF charstrings that draw before their first rmoveto now start the subpath at the current point, as Type 1 charstrings already did. Denies the panic lints in the crate (it had no panic sites). Co-Authored-By: Claude Opus 5.5 --- clippy.toml | 4 ++++ crates/pdf/src/cff.rs | 9 +++++++++ crates/pdf/src/lib.rs | 2 ++ crates/pdf/src/page.rs | 13 ++++++++++--- crates/pdf/src/tests_import.rs | 9 +++++++++ 5 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 clippy.toml diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 000000000..4b6c79f55 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-indexing-slicing-in-tests = true diff --git a/crates/pdf/src/cff.rs b/crates/pdf/src/cff.rs index 03493d81d..ce39a732a 100644 --- a/crates/pdf/src/cff.rs +++ b/crates/pdf/src/cff.rs @@ -646,12 +646,21 @@ impl T2<'_> { self.path.move_to((self.x, self.y)); self.open = true; } + /// A segment before the first moveto starts at the current point. + fn ensure_open(&mut self) { + if !self.open { + self.path.move_to((self.x, self.y)); + self.open = true; + } + } fn line_to(&mut self, dx: f64, dy: f64) { + self.ensure_open(); self.x += dx; self.y += dy; self.path.line_to((self.x, self.y)); } fn curve(&mut self, d: [f64; 6]) { + self.ensure_open(); let (x1, y1) = (self.x + d[0], self.y + d[1]); let (x2, y2) = (x1 + d[2], y1 + d[3]); self.x = x2 + d[4]; diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 3db854fc8..39d150749 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -7,6 +7,8 @@ //! children are the document's **layers**: one group per top-level optional-content group //! (Illustrator layers), with content outside them gathered into groups of its own. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + mod build; mod ccitt; mod cff; diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index c754c1dd8..94f629335 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -332,7 +332,7 @@ impl<'a> Interp<'a> { // Painting. "S" => self.paint(false, None, true, depth), "s" => { - self.path.close_path(); + self.close_subpath(); self.paint(false, None, true, depth) } "f" | "F" => self.paint(true, Some(FillRule::NonZero), false, depth), @@ -340,11 +340,11 @@ impl<'a> Interp<'a> { "B" => self.paint(true, Some(FillRule::NonZero), true, depth), "B*" => self.paint(true, Some(FillRule::EvenOdd), true, depth), "b" => { - self.path.close_path(); + self.close_subpath(); self.paint(true, Some(FillRule::NonZero), true, depth) } "b*" => { - self.path.close_path(); + self.close_subpath(); self.paint(true, Some(FillRule::EvenOdd), true, depth) } "n" => self.paint(false, None, false, depth), @@ -549,6 +549,13 @@ impl<'a> Interp<'a> { } } + /// Close the current subpath; nothing to close without a current point. + fn close_subpath(&mut self) { + if self.cur.is_some() { + self.path.close_path(); + } + } + fn ensure_start(&mut self) { if self.cur.is_none() { self.path.move_to((0.0, 0.0)); diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index b6b96b80e..e5daf94eb 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -295,3 +295,12 @@ fn truncated_and_corrupt_eps_never_panic() { assert!(r.is_ok(), "{}", String::from_utf8_lossy(prog)); } } + +#[test] +fn closing_an_empty_path_is_a_no_op() { + // "s", "b" and "b*" closed the path without a current point (kurbo asserts on that). + for ops in ["s", "b", "b*", "0 0 m 50 0 l 50 50 l b s b*", "f s 10 10 m 20 20 l s"] { + let doc = parse(&page_pdf(ops, "", vec![])).unwrap(); + let _ = render(&doc); + } +} From e9108584452687910b89e60ef6ffa9505d64fc86 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 14:21:14 -0400 Subject: [PATCH 2/2] Never crash: PostScript radix numbers outside 2..=36 In EPS/PostScript, `0#7` asked i64::from_str_radix for radix 0, which panics (found by fuzzing). Radixes outside 2..=36 are not numbers now. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/ps.rs | 2 +- crates/pdf/src/tests_import.rs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs index 22d2e2314..f0adaab2b 100644 --- a/crates/pdf/src/ps.rs +++ b/crates/pdf/src/ps.rs @@ -278,7 +278,7 @@ impl Src<'_> { None => { // Radix numbers (16#FF). let s = String::from_utf8_lossy(w).into_owned(); - match s.split_once('#').and_then(|(r, v)| i64::from_str_radix(v, r.parse().ok()?).ok()) { + match s.split_once('#').and_then(|(r, v)| i64::from_str_radix(v, r.parse::().ok().filter(|r| (2..=36).contains(r))?).ok()) { Some(n) => V::Num(n as f64), None => V::Name(s.into()), } diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index e5daf94eb..8314c7d13 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -304,3 +304,12 @@ fn closing_an_empty_path_is_a_no_op() { let _ = render(&doc); } } + +#[test] +fn postscript_radix_numbers_outside_2_to_36_are_not_numbers() { + // `0#7` asked `i64::from_str_radix` for radix 0, which panics (found by fuzzing). + for n in ["0#7", "1#1", "37#1", "99999999999#1", "16#FF"] { + let src = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n{n} pop 0 0 moveto 10 10 lineto stroke\n"); + let _ = parse(src.as_bytes()); + } +}