security(paint): cap spaced steps and procedural primitives (#8957)

* security(paint): cap distance-spaced steps per segment and procedural tip primitives

* test(paint): count steps through a Cell so the walker closure can be shared

* paint: keep the spacing carry-over when a segment ends at the step cap; lower the tip work budget
This commit is contained in:
Dylan M. Taylor
2026-10-23 17:38:07 -04:00
committed by GitHub
parent 7e3594b822
commit cfd894aad3
2 changed files with 72 additions and 3 deletions
+45 -2
View File
@@ -26,6 +26,9 @@ const MAX_GRID_STEPS: f64 = 1e7;
const MIN_SPEED_STEP: f64 = 0.5;
/// Maximum airbrush catch-up dabs for one input segment.
const MAX_AIRBRUSH_DABS_PER_SEGMENT: usize = 512;
/// Most steps one distance-spaced segment emits: two far-apart points at a quarter-pixel spacing
/// would otherwise be billions of steps, all buffered before the first is drawn.
const MAX_DISTANCE_STEPS_PER_SEGMENT: usize = 1 << 20;
/// The dab centre of the pixel-grid cell holding `v` (see [`PathWalker::pixel_grid`]): the
/// pixel's centre for `offset` 0.5, the nearest pixel corner for 0. A value within rounding
@@ -336,12 +339,17 @@ impl PathWalker {
match self.speed.filter(|_| dt > 0.0 && dt.is_finite()) {
None => {
if !self.grid.is_some_and(|g| self.grid_steps(&a, &p, g, out)) {
while self.next_at <= len + 1e-9 {
let mut emitted = 0usize;
while self.next_at <= len + 1e-9 && emitted < MAX_DISTANCE_STEPS_PER_SEGMENT {
let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0));
self.emit(q, out);
self.next_at += step_len(&q).max(0.25);
emitted += 1;
}
self.next_at -= len;
// Cut short: the rest of the segment is skipped, spacing restarts at its end.
// Only when the cap held back a step that was still due; a segment that ends right at the cap keeps its carry-over.
let cut_short = emitted == MAX_DISTANCE_STEPS_PER_SEGMENT && self.next_at <= len + 1e-9;
self.next_at = if cut_short { step_len(&p).max(0.25) } else { self.next_at - len };
}
}
Some(iv) => {
@@ -815,3 +823,38 @@ impl DabGenerator {
}
}
}
#[cfg(test)]
mod walker_budget_tests {
use super::*;
#[test]
fn one_far_segment_emits_a_bounded_number_of_steps() {
let steps = std::cell::Cell::new(0usize);
let mut count = |_: StepInput| steps.set(steps.get() + 1);
let mut walker = PathWalker::new(None);
walker.push(StrokePoint::new(0.0, 0.0, 1.0), &|_: &StrokePoint| 1.0, &mut count);
// At a quarter pixel, a billion-pixel segment is four billion steps.
walker.push(StrokePoint::new(1.0e9, 0.0, 1.0), &|_: &StrokePoint| 0.25, &mut count);
assert!(steps.get() <= MAX_DISTANCE_STEPS_PER_SEGMENT + 1, "{}", steps.get());
// Spacing carries on from the end of the cut segment.
let before = steps.get();
walker.push(StrokePoint::new(1.0e9 + 10.0, 0.0, 1.0), &|_: &StrokePoint| 1.0, &mut count);
assert!(steps.get() > before && steps.get() - before <= 11, "{} more steps", steps.get() - before);
}
#[test]
fn a_segment_ending_at_the_cap_keeps_its_carry_over() {
let steps = std::cell::Cell::new(0usize);
let mut count = |_: StepInput| steps.set(steps.get() + 1);
let mut walker = PathWalker::new(None);
let one = |_: &StrokePoint| 1.0;
walker.push(StrokePoint::new(0.0, 0.0, 1.0), &one, &mut count);
// Exactly the cap in steps, then 0.75 left before the next is due.
let end = MAX_DISTANCE_STEPS_PER_SEGMENT as f64 + 0.25;
walker.push(StrokePoint::new(end, 0.0, 1.0), &one, &mut count);
let before = steps.get();
walker.push(StrokePoint::new(end + 0.8, 0.0, 1.0), &one, &mut count);
assert_eq!(steps.get(), before + 1);
}
}
+27 -1
View File
@@ -87,8 +87,20 @@ pub fn chalk_tip(n: u32, seed: u32) -> GrayTile {
})
}
/// Most primitives one generated tip is drawn from, and most (pixel × primitive) visits it costs:
/// every pixel is tested against every primitive, so both counts are the caller's to multiply.
const MAX_PRIMITIVES: u32 = 4096;
const MAX_TIP_WORK: u64 = 1 << 27;
/// `count` limited so that an `n`×`n` tip tests at most [`MAX_TIP_WORK`] pixel/primitive pairs.
fn clamp_primitives(n: u32, count: u32) -> u32 {
let per_primitive = (u64::from(n) * u64::from(n)).max(1);
count.min(MAX_PRIMITIVES).min(u32::try_from(MAX_TIP_WORK / per_primitive).unwrap_or(u32::MAX))
}
/// A spatter tip: a cluster of round droplets of different sizes.
pub fn spatter_tip(n: u32, seed: u32, drops: u32) -> GrayTile {
let drops = clamp_primitives(n, drops);
let salt = u64::from(seed) + 202;
let s = n as f32;
let blobs: Vec<(f32, f32, f32)> = (0..drops)
@@ -162,6 +174,7 @@ pub fn leaf_tip(n: u32) -> GrayTile {
/// Grass: a tuft of tapered, slightly curved blades rising from the bottom edge.
pub fn grass_tip(n: u32, seed: u32, blades: u32) -> GrayTile {
let blades = clamp_primitives(n, blades);
let salt = u64::from(seed) + 505;
let s = n as f32;
let bl: Vec<(f32, f32, f32, f32)> = (0..blades)
@@ -217,7 +230,7 @@ pub fn star_tip(n: u32) -> GrayTile {
pub fn rake_tip(n: u32, seed: u32, bristles: u32) -> GrayTile {
let salt = u64::from(seed) + 707;
let s = n as f32;
let k = bristles.max(1);
let k = clamp_primitives(n, bristles).max(1);
let dots: Vec<(f32, f32, f32)> = (0..k)
.map(|i| {
let x = s * (i as f32 + 0.5) / k as f32;
@@ -239,6 +252,19 @@ pub fn rake_tip(n: u32, seed: u32, bristles: u32) -> GrayTile {
mod tests {
use super::*;
#[test]
fn tips_with_absurd_primitive_counts_stay_cheap() {
// 4 billion drops over a 1024² tip would be 4·10¹⁵ pixel tests.
let t = std::time::Instant::now();
assert_eq!(spatter_tip(1024, 1, u32::MAX).width, 1024);
assert_eq!(grass_tip(1024, 1, u32::MAX).height, 1024);
assert_eq!(rake_tip(1024, 1, u32::MAX).width, 1024);
assert!(t.elapsed().as_secs() < 60, "{:?}", t.elapsed());
assert!(clamp_primitives(1024, u32::MAX) <= MAX_PRIMITIVES);
assert!(clamp_primitives(60_000, 100) < 100, "a huge tip gets fewer primitives");
assert_eq!(clamp_primitives(0, 7), 7);
}
#[test]
fn patterns_are_seamless_and_in_range() {
for st in [PatternStyle::Noise, PatternStyle::Paper, PatternStyle::Canvas, PatternStyle::Dots] {