From 7c3030ff52389939636e9c856ffbe743b094ea26 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 02:46:40 +0700 Subject: [PATCH] EPS import: type layout counts against the op budget, hostile executable-file tests (review of #331) Laying out type (show, stringwidth, cshow) costs about 300 operations plus 64 a character, measured, and now counts that much against MAX_OPS: a 300-byte file running cshow over a 4 MB string took 60 s, and { (a) stringwidth pop pop } loop ran for over 5 minutes (already the case on main); both now stop with "the program runs too long" within seconds. Tests: executable filters run inside themselves stop at the nesting limit (directly, mutually and under stopped), currentfile cvx exec runs nothing twice, cshow over a long string is refused; the hostile PostScript fuzz tokens gain self-recursive filter execution, currentfile filters, cvlit and cshow. Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/import_fuzz.rs | 1 + crates/eps/src/import/interp.rs | 8 +++++++- crates/eps/src/import/tests.rs | 17 +++++++++++++++++ crates/eps/src/import/text.rs | 4 ++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index d8a97431..3829fce8 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -915,6 +915,7 @@ fn arb_ps_token() -> impl Strategy { "(*) {pop} 8 string /Category resourceforall", "/Generic /Category findresource dup length dict copy /Category defineresource", "dictstack", "8 array", "1183615869 internaldict", "cshow", "currentsystemparams", "currentuserparams", "flushfile", "xcheck", "systemdict", "/userdict", "SharedFontDirectory", "(1 2 add) 0 () /SubFileDecode filter cvx exec", "currentfile cvx exec", + "/p { (p) 0 () /SubFileDecode filter cvx exec } def p", "currentfile 0 () /SubFileDecode filter cvx exec", "cvlit", "{ (a) cshow }", "/ASCII85Decode", "/ASCIIHexDecode", "/FlateDecode", "/RunLengthDecode", "/LZWDecode", "/DCTDecode", "/SubFileDecode", "/DeviceRGB", "/DeviceCMYK", "/DeviceGray", "/Pattern", "/x", "x", "/Helvetica", "(abc)", "", "<~87cURD]i,\"Ebo80~>", "true", "false", "null", "[/Separation (S) /DeviceCMYK {dup dup dup}]", "[/Indexed /DeviceRGB 1 ]", diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 9f0f862d..94d6b8a8 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -267,7 +267,13 @@ impl<'a> Interp<'a> { } fn tick(&mut self) -> Res { - self.ops += 1; + self.spend(1) + } + + /// Count `ops` operations' worth of work against [`MAX_OPS`]: what an operator that costs + /// far more than one operation (laying out type) does in a loop has to end too. + pub fn spend(&mut self, ops: u64) -> Res { + self.ops = self.ops.saturating_add(ops); if self.ops > MAX_OPS { return Err(PsError::Limit("the program runs too long")); } diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 1d72ba04..dfbc1b84 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -609,6 +609,23 @@ fn flushfile_skips_the_data_of_a_filter() { blue("currentfile 0 (%%EndPacket) /SubFileDecode filter flushfile\n\n%%EndPacket\n0 0 1 setrgbcolor"); } +/// Executable files are untrusted programs too: running one inside itself stops at the nesting +/// limit, and the program's own file runs once. Type laid out in a loop (`cshow` on a long +/// string, `stringwidth` in `loop`) counts against the operation budget like the loop itself. +#[test] +fn hostile_executable_files_and_type_loops_are_refused() { + let refused = |body: &str, why: &str| { + let e = import(&eps(body)).err().unwrap_or_default(); + assert!(e.contains(why), "{body}: {e}"); + }; + refused("/p { (p) 0 () /SubFileDecode filter cvx exec } def p", "too deeply"); + refused("/p { (p) 0 () /SubFileDecode filter cvx exec } def { p } stopped pop p", "too deeply"); + refused("/a { (b) 0 () /SubFileDecode filter cvx exec } def /b { (a) 0 () /SubFileDecode filter cvx exec } def a", "too deeply"); + refused("/Helvetica findfont 10 scalefont setfont { pop pop pop } 1000000 string cshow", "too long"); + // The program's own file, made executable, is already running. + blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor"); +} + #[test] fn cshow_runs_its_procedure_for_each_character() { check("/n 0 def { pop pop pop /n n 1 add def } (abc) cshow n 3 eq"); diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 5450021d..1eb2a8e8 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -238,6 +238,10 @@ impl Interp<'_> { /// `s` (read as Latin-1) set in the current font at user point `at`: point type placed there, /// its layout in text space, and how far it moves the current point (user space). fn set_type(&mut self, s: &[u8], at: Point) -> Res<(TextObject, TextLayout, Vec2)> { + // Laying out type costs about as much as 300 operations, and 64 more a character + // (measured), so `{ (a) stringwidth pop pop } loop` or `cshow` on a long string ends + // within the budget like any other loop instead of running for minutes. + self.spend((s.len() as u64).saturating_mul(64).saturating_add(300))?; let (name, f1) = self.font()?; // Text space (y down, `size` points an em) onto the document. let m = self.xf() * Affine::translate(at.to_vec2()) * f1;