From d62c51645ef78a342731384fba96fc9dd6d31cc2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 21:59:03 +0000 Subject: [PATCH 1/3] Add bounded PostScript VM primitives --- crates/eps/src/postscript.rs | 210 +++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) diff --git a/crates/eps/src/postscript.rs b/crates/eps/src/postscript.rs index 05850580e..1b93c6663 100644 --- a/crates/eps/src/postscript.rs +++ b/crates/eps/src/postscript.rs @@ -7,6 +7,161 @@ /// Maximum nesting depth for executable procedures in a source token. pub const MAX_PROCEDURE_DEPTH: usize = 100; +/// A bounded PostScript operand stack. +/// +/// The stack is deliberately generic: an interpreter owns the concrete PostScript values and +/// graphics state, while this module provides the shared language-level stack operators. A push +/// past `limit` is ignored, matching the bounded EPS interpreter's defensive behavior. +#[derive(Clone, Debug)] +pub struct OperandStack { + values: Vec, + limit: usize, +} + +impl OperandStack { + /// Create an empty stack with a maximum number of values. + pub fn new(limit: usize) -> Self { + Self { values: Vec::new(), limit } + } + + /// Number of values currently on the stack. + pub fn len(&self) -> usize { + self.values.len() + } + + /// Whether the stack is empty. + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } + + /// Push a value if the configured limit allows it; report whether it was pushed. + pub fn push(&mut self, value: T) -> bool { + if self.values.len() >= self.limit { + return false; + } + self.values.push(value); + true + } + + /// Pop a value, or return the caller's language-specific underflow value. + pub fn pop_or(&mut self, underflow: T) -> T { + self.values.pop().unwrap_or(underflow) + } + + /// Borrow the top value. + pub fn top(&self) -> Option<&T> { + self.values.last() + } + + /// PostScript `dup`: duplicate the top value if one exists. + pub fn duplicate(&mut self) { + if let Some(value) = self.values.last().cloned() { + let _ = self.push(value); + } + } + + /// PostScript `exch`: exchange the top two values when both exist. + pub fn exchange(&mut self) { + let len = self.values.len(); + if len >= 2 { + self.values.swap(len - 1, len - 2); + } + } + + /// PostScript `index`: copy the value `depth` below the top onto the stack. + /// + /// Invalid or negative depths are left to the caller to validate; an out-of-range depth is a + /// no-op. This keeps the helper bounded even for hostile numeric operands. + pub fn index(&mut self, depth: usize) { + let len = self.values.len(); + if depth < len && len < self.limit { + let value = self.values[len - depth - 1].clone(); + self.values.push(value); + } + } + + /// PostScript `n j roll`: rotate the top `count` values by signed `shift` places. + /// + /// Positive shifts move values toward the top; negative shifts move them toward the bottom. + /// Counts larger than the stack and empty rolls are ignored. + pub fn roll(&mut self, count: usize, shift: isize) { + let len = self.values.len(); + if count == 0 || count > len { + return; + } + let places = shift.rem_euclid(count as isize) as usize; + self.values[len - count..].rotate_right(places); + } + + /// PostScript `copy` for an integer operand: append a copy of the top `count` values if it + /// fits. Returns false when the range is invalid or the configured limit would be exceeded. + pub fn copy_top(&mut self, count: usize) -> bool { + let len = self.values.len(); + if count > len || len.saturating_add(count) > self.limit { + return false; + } + let copied = self.values[len - count..].to_vec(); + self.values.extend(copied); + true + } + + /// Borrow all stack values from bottom to top. + pub fn values(&self) -> &[T] { + &self.values + } +} + +/// Bounded execution accounting shared by PostScript interpreters. +/// +/// EPS programs can recurse or repeatedly execute procedures. A host can use this counter to +/// stop work before it consumes unbounded CPU, independently of its operator and graphics policy. +#[derive(Clone, Debug)] +pub struct ExecutionBudget { + remaining: usize, + call_depth: usize, + max_call_depth: usize, +} + +impl ExecutionBudget { + /// Create a budget with an operation allowance and maximum nested procedure calls. + pub fn new(operations: usize, max_call_depth: usize) -> Self { + Self { remaining: operations, call_depth: 0, max_call_depth } + } + + /// Units of work still available. + pub fn remaining(&self) -> usize { + self.remaining + } + + /// Current nested procedure depth. + pub fn call_depth(&self) -> usize { + self.call_depth + } + + /// Charge work atomically. On failure the remaining budget stays unchanged. + pub fn spend(&mut self, units: usize) -> bool { + let Some(remaining) = self.remaining.checked_sub(units) else { + return false; + }; + self.remaining = remaining; + true + } + + /// Enter a procedure call if it remains within the configured depth. + pub fn enter_call(&mut self) -> bool { + if self.call_depth >= self.max_call_depth { + return false; + } + self.call_depth += 1; + true + } + + /// Leave a procedure call. Extra leaves are ignored to keep cleanup paths safe. + pub fn leave_call(&mut self) { + self.call_depth = self.call_depth.saturating_sub(1); + } +} + /// A lexical PostScript value. Names are left unevaluated for the caller's interpreter. #[derive(Clone, Debug, PartialEq)] pub enum Token { @@ -283,6 +438,61 @@ fn parse_number(word: &[u8]) -> Option { .or_else(|| text.trim_start_matches(['+', '-']).parse::().ok().map(|value| if text.starts_with('-') { -value } else { value })) } +#[cfg(test)] +mod stack_tests { + use super::{ExecutionBudget, OperandStack}; + + #[test] + fn stack_operators_follow_postscript_order_and_respect_limit() { + let mut stack = OperandStack::new(8); + assert!(stack.push(1)); + assert!(stack.push(2)); + assert!(stack.push(3)); + stack.exchange(); + assert_eq!(stack.values(), &[1, 3, 2]); + stack.duplicate(); + assert_eq!(stack.values(), &[1, 3, 2, 2]); + stack.roll(4, 1); + assert_eq!(stack.values(), &[2, 1, 3, 2]); + stack.roll(4, -1); + assert_eq!(stack.values(), &[1, 3, 2, 2]); + stack.index(2); + assert_eq!(stack.values(), &[1, 3, 2, 2, 3]); + assert!(stack.copy_top(2)); + assert_eq!(stack.values(), &[1, 3, 2, 2, 3, 2, 3]); + stack.duplicate(); + assert_eq!(stack.len(), 8); + assert!(!stack.push(7)); + } + + #[test] + fn stack_underflow_and_invalid_rolls_are_safe() { + let mut stack = OperandStack::new(2); + assert_eq!(stack.pop_or(99), 99); + stack.roll(1, isize::MAX); + stack.index(usize::MAX); + assert_eq!(stack.values(), &[]); + assert!(stack.copy_top(0)); + assert_eq!(stack.values(), &[]); + } + + #[test] + fn execution_budget_charges_atomically_and_bounds_call_depth() { + let mut budget = ExecutionBudget::new(3, 1); + assert!(budget.spend(2)); + assert_eq!(budget.remaining(), 1); + assert!(!budget.spend(2)); + assert_eq!(budget.remaining(), 1); + assert!(budget.enter_call()); + assert_eq!(budget.call_depth(), 1); + assert!(!budget.enter_call()); + budget.leave_call(); + budget.leave_call(); + assert_eq!(budget.call_depth(), 0); + assert!(budget.enter_call()); + } +} + #[cfg(test)] mod tests { use super::{Scanner, Token}; From 26feec69cbd68896ac6180814078ff311da32501 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 00:37:04 +0000 Subject: [PATCH 2/3] Add reusable PostScript VM stack operators --- crates/eps/src/postscript.rs | 178 +++++++++++++++++++++++++++++++++-- 1 file changed, 170 insertions(+), 8 deletions(-) diff --git a/crates/eps/src/postscript.rs b/crates/eps/src/postscript.rs index 1b93c6663..a52fd92f5 100644 --- a/crates/eps/src/postscript.rs +++ b/crates/eps/src/postscript.rs @@ -1,12 +1,40 @@ -//! A bounded-token PostScript scanner for EPS programs. +//! Shared PostScript format primitives for EPS programs. //! -//! This module only turns source bytes into values. It does not execute operators or interpret -//! graphics state. That boundary lets an application keep its own drawing and font policies while -//! sharing the format-level lexical rules. +//! [`Scanner`] handles bounded lexical parsing. [`OperandStack`] and [`ExecutionBudget`] provide +//! reusable language-level VM behavior while leaving values, dictionaries, graphics state, +//! rendering, and font policy to the host interpreter. /// Maximum nesting depth for executable procedures in a source token. pub const MAX_PROCEDURE_DEPTH: usize = 100; +/// Maximum number of operand values an interpreter should retain by default. +pub const MAX_OPERAND_STACK: usize = 100_000; + +/// Maximum nested executable procedure calls supported by the bounded VM. +pub const MAX_CALL_DEPTH: usize = 256; + +/// PostScript operand-stack operators whose semantics do not depend on the value model. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StackOperator { + Pop, + Exchange, + Duplicate, + CopyTop(usize), + Index(usize), + Roll { count: usize, shift: i64 }, + Clear, + Count, +} + +/// Result of applying an operand-stack operator. +#[derive(Debug, PartialEq, Eq)] +pub enum StackOutcome { + None, + Popped(Option), + Count(usize), + Copied(bool), +} + /// A bounded PostScript operand stack. /// /// The stack is deliberately generic: an interpreter owns the concrete PostScript values and @@ -34,6 +62,28 @@ impl OperandStack { self.values.is_empty() } + /// Borrow all stack values mutably from bottom to top. + pub fn values_mut(&mut self) -> &mut [T] { + &mut self.values + } + + /// Remove and return the top value, if present. + pub fn pop(&mut self) -> Option { + self.values.pop() + } + + /// Remove every value. + pub fn clear(&mut self) { + self.values.clear(); + } + + /// Remove values at and above `at`, returning them in bottom-to-top order. An index beyond + /// the stack length removes nothing. + pub fn split_off(&mut self, at: usize) -> Vec { + let at = at.min(self.values.len()); + self.values.split_off(at) + } + /// Push a value if the configured limit allows it; report whether it was pushed. pub fn push(&mut self, value: T) -> bool { if self.values.len() >= self.limit { @@ -60,6 +110,12 @@ impl OperandStack { } } + /// PostScript `dup` with a caller-supplied underflow value. + pub fn duplicate_or(&mut self, underflow: T) { + let value = self.values.last().cloned().unwrap_or(underflow); + let _ = self.push(value); + } + /// PostScript `exch`: exchange the top two values when both exist. pub fn exchange(&mut self) { let len = self.values.len(); @@ -68,6 +124,15 @@ impl OperandStack { } } + /// PostScript `exch` with caller-supplied underflow values. Both operands are popped and + /// restored even if either pop underflows, matching interpreters that use a null sentinel. + pub fn exchange_or(&mut self, underflow: T) { + let b = self.pop_or(underflow.clone()); + let a = self.pop_or(underflow); + let _ = self.push(b); + let _ = self.push(a); + } + /// PostScript `index`: copy the value `depth` below the top onto the stack. /// /// Invalid or negative depths are left to the caller to validate; an out-of-range depth is a @@ -80,16 +145,24 @@ impl OperandStack { } } + /// PostScript `index` with a caller-supplied underflow value, pushed even when `depth` is out + /// of range. The configured stack limit still applies. + pub fn index_or(&mut self, depth: usize, underflow: T) { + let len = self.values.len(); + let value = if depth < len { self.values[len - depth - 1].clone() } else { underflow }; + let _ = self.push(value); + } + /// PostScript `n j roll`: rotate the top `count` values by signed `shift` places. /// /// Positive shifts move values toward the top; negative shifts move them toward the bottom. /// Counts larger than the stack and empty rolls are ignored. - pub fn roll(&mut self, count: usize, shift: isize) { + pub fn roll(&mut self, count: usize, shift: i64) { let len = self.values.len(); if count == 0 || count > len { return; } - let places = shift.rem_euclid(count as isize) as usize; + let places = (i128::from(shift).rem_euclid(count as i128)) as usize; self.values[len - count..].rotate_right(places); } @@ -109,6 +182,36 @@ impl OperandStack { pub fn values(&self) -> &[T] { &self.values } + + /// Apply a PostScript operand-stack operator. `underflow` is the interpreter's fallback value + /// for `exch`, `dup`, and `index`; the stack's configured limit remains in force. + pub fn execute(&mut self, operator: StackOperator, underflow: T) -> StackOutcome { + match operator { + StackOperator::Pop => StackOutcome::Popped(self.pop()), + StackOperator::Exchange => { + self.exchange_or(underflow); + StackOutcome::None + } + StackOperator::Duplicate => { + self.duplicate_or(underflow); + StackOutcome::None + } + StackOperator::CopyTop(count) => StackOutcome::Copied(self.copy_top(count)), + StackOperator::Index(depth) => { + self.index_or(depth, underflow); + StackOutcome::None + } + StackOperator::Roll { count, shift } => { + self.roll(count, shift); + StackOutcome::None + } + StackOperator::Clear => { + self.clear(); + StackOutcome::None + } + StackOperator::Count => StackOutcome::Count(self.len()), + } + } } /// Bounded execution accounting shared by PostScript interpreters. @@ -147,6 +250,13 @@ impl ExecutionBudget { true } + /// Charge work that is accounted lazily: saturate at zero so the caller's next strict + /// [`Self::spend`] stops execution. This supports work whose current operator preserves the + /// app's historical behavior after exhausting its budget. + pub fn charge(&mut self, units: usize) { + self.remaining = self.remaining.saturating_sub(units); + } + /// Enter a procedure call if it remains within the configured depth. pub fn enter_call(&mut self) -> bool { if self.call_depth >= self.max_call_depth { @@ -440,7 +550,7 @@ fn parse_number(word: &[u8]) -> Option { #[cfg(test)] mod stack_tests { - use super::{ExecutionBudget, OperandStack}; + use super::{ExecutionBudget, OperandStack, StackOperator, StackOutcome}; #[test] fn stack_operators_follow_postscript_order_and_respect_limit() { @@ -469,13 +579,62 @@ mod stack_tests { fn stack_underflow_and_invalid_rolls_are_safe() { let mut stack = OperandStack::new(2); assert_eq!(stack.pop_or(99), 99); - stack.roll(1, isize::MAX); + stack.duplicate_or(99); + assert_eq!(stack.values(), &[99]); + stack.clear(); + stack.exchange_or(99); + assert_eq!(stack.values(), &[99, 99]); + stack.clear(); + stack.roll(1, i64::MAX); stack.index(usize::MAX); assert_eq!(stack.values(), &[]); assert!(stack.copy_top(0)); assert_eq!(stack.values(), &[]); } + #[test] + fn stack_mutation_and_underflow_index_are_bounded() { + let mut stack = OperandStack::new(4); + assert!(stack.push(10)); + assert!(stack.push(20)); + stack.index_or(1, -1); + stack.index_or(9, -1); + assert_eq!(stack.values(), &[10, 20, 10, -1]); + stack.index_or(0, -1); + assert_eq!(stack.values(), &[10, 20, 10, -1]); // full stack silently drops the push + + assert_eq!(stack.top(), Some(&-1)); + stack.values_mut()[3] = 40; + stack.roll(3, i64::MIN); + assert_eq!(stack.values(), &[10, 40, 20, 10]); + assert!(stack.split_off(usize::MAX).is_empty()); + assert_eq!(stack.split_off(1), [40, 20, 10]); + assert_eq!(stack.values(), &[10]); + assert_eq!(stack.pop(), Some(10)); + assert_eq!(stack.pop(), None); + stack.clear(); + assert!(stack.is_empty()); + } + + #[test] + fn shared_stack_operator_dispatch_reports_only_value_results() { + let mut stack = OperandStack::new(4); + assert!(stack.push(1)); + assert!(stack.push(2)); + assert_eq!(stack.execute(StackOperator::Count, 0), StackOutcome::Count(2)); + assert_eq!(stack.execute(StackOperator::Exchange, 0), StackOutcome::None); + assert_eq!(stack.values(), &[2, 1]); + assert_eq!(stack.execute(StackOperator::Duplicate, 0), StackOutcome::None); + assert_eq!(stack.execute(StackOperator::Index(2), 0), StackOutcome::None); + assert_eq!(stack.values(), &[2, 1, 1, 2]); + assert_eq!(stack.execute(StackOperator::Roll { count: 3, shift: 1 }, 0), StackOutcome::None); + assert_eq!(stack.values(), &[2, 2, 1, 1]); + assert_eq!(stack.execute(StackOperator::CopyTop(2), 0), StackOutcome::Copied(false)); + assert_eq!(stack.execute(StackOperator::Pop, 0), StackOutcome::Popped(Some(1))); + assert_eq!(stack.execute(StackOperator::Clear, 0), StackOutcome::None); + assert!(stack.is_empty()); + } + #[test] fn execution_budget_charges_atomically_and_bounds_call_depth() { let mut budget = ExecutionBudget::new(3, 1); @@ -483,6 +642,9 @@ mod stack_tests { assert_eq!(budget.remaining(), 1); assert!(!budget.spend(2)); assert_eq!(budget.remaining(), 1); + budget.charge(2); + assert_eq!(budget.remaining(), 0); + assert!(!budget.spend(1)); assert!(budget.enter_call()); assert_eq!(budget.call_depth(), 1); assert!(!budget.enter_call()); From 39f45eda5542813e64468a3b2c129da8454f3830 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 00:48:11 +0000 Subject: [PATCH 3/3] Avoid ambiguous empty stack assertions --- crates/eps/src/postscript.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/postscript.rs b/crates/eps/src/postscript.rs index a52fd92f5..92ed2c06d 100644 --- a/crates/eps/src/postscript.rs +++ b/crates/eps/src/postscript.rs @@ -587,9 +587,9 @@ mod stack_tests { stack.clear(); stack.roll(1, i64::MAX); stack.index(usize::MAX); - assert_eq!(stack.values(), &[]); + assert!(stack.is_empty()); assert!(stack.copy_top(0)); - assert_eq!(stack.values(), &[]); + assert!(stack.is_empty()); } #[test]