From 833cd63c8a0aec826595640b356b3213355957bd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 16:14:06 -0400 Subject: [PATCH] proposal(redact): prevent retention of confidential vector paths behind clipping (F-008) (#4577) Partial vector redaction previously hid covered areas with clipping (`clip_out`, `W/W*`) while retaining the complete original drawing instructions and painting operators in the content stream. This allowed recipients to recover confidential outlined text or other vector drawings from supposedly redacted documents. This change removes vector paths that intersect redaction marks completely and refuses partial clip retention, ensuring no confidential geometry is preserved behind clipping. Report counts are updated to record removed paths. --- crates/redact/src/interp.rs | 33 +++++---------------------------- crates/redact/src/tests.rs | 30 ++++++++++++++++++++++++++++-- parity/acrobat-features.toml | 2 +- 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index ffd59a26f6..bd9da5215f 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -884,37 +884,14 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], } let b = bbox.unwrap_or_default(); changed = true; - if scope.covered(b) { - scope.report.paths_removed += 1; - if clips { - // Keep the clipping, drop the painting. - ops.extend(body); - ops.push(Op::new("n", vec![])); - } - } else if clips { - // A clip-and-paint path can't be wrapped in q/Q without losing its clip: - // paint it clipped, then set its clip with a no-op path. - scope.report.paths_clipped += 1; - if let Some(c) = clip_out(scope.rects, &gs.ctm, b) { - let plain: Vec = body.iter().filter(|p| !p.is("W") && !p.is("W*")).cloned().collect(); - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.extend(plain); - ops.push(op); - ops.push(Op::new("Q", vec![])); - } + scope.report.paths_removed += 1; + if scope.covered(b) && clips { + // Keep the clipping, drop the painting. ops.extend(body); ops.push(Op::new("n", vec![])); - } else { - scope.report.paths_clipped += 1; - if let Some(c) = clip_out(scope.rects, &gs.ctm, b) { - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.extend(body); - ops.push(op); - ops.push(Op::new("Q", vec![])); - } } + // Partial vector redaction must not retain underlying drawing commands + // behind clipping (F-008). Refuse partial clip retention and remove the path. continue; } if !path.is_empty() { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 8b94612145..c072d19db7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -281,14 +281,40 @@ fn vectors_are_removed_or_clipped_and_inline_images_go() { let mut doc = one_page(src, "", vec![]); mark(&mut doc, 0, &[[5.0, 5.0, 35.0, 35.0], [45.0, 45.0, 65.0, 65.0]], ""); let r = apply(&mut doc, None).unwrap(); - assert_eq!((r.paths_removed, r.paths_clipped, r.images_removed), (1, 1, 1), "{r:?}"); + assert_eq!((r.paths_removed, r.paths_clipped, r.images_removed), (2, 0, 1), "{r:?}"); let c = content(&doc, 0); assert!(!c.contains("10 10 20 20 re"), "{c}"); - assert!(c.contains("W*\nn\n0 0 300 300 re\nf\nQ"), "the page-size rect is clipped: {c}"); + assert!(!c.contains("300 300 re"), "the intersecting rect is removed: {c}"); assert!(!c.contains("BI"), "{c}"); assert!(c.contains("150 150 m"), "the line elsewhere stays"); } +#[test] +fn partially_covered_vector_paths_do_not_leak_drawing_commands() { + // A vector path with detailed drawing commands (e.g. outlined text or confidential geometry) + // that is only partially covered by a redaction mark must be completely removed, not retained + // behind a clipping path (F-008). + let src = b"0 g 100 100 m 150 150 l 200 100 l h f 10 10 m 20 20 l W b 300 300 m 350 350 l S"; + let mut doc = one_page(src, "", vec![]); + // Redaction mark covers only part of the triangle (100..200, 100..150): + // mark is [120, 120, 160, 160], partially intersecting the triangle. + // Also partially intersect the clip-and-stroke path [10..20, 10..20] with [15, 15, 25, 25]. + mark(&mut doc, 0, &[[120.0, 120.0, 160.0, 160.0], [15.0, 15.0, 25.0, 25.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.paths_removed, 2, "both partially intersected paths removed"); + assert_eq!(r.paths_clipped, 0, "no paths retained via clipping"); + + let c = content(&doc, 0); + // Neither the drawing operators nor the coordinates should leak in the content stream. + assert!(!c.contains("100 100 m"), "underlying geometry must not leak: {c}"); + assert!(!c.contains("150 150 l"), "underlying geometry must not leak: {c}"); + assert!(!c.contains("200 100 l"), "underlying geometry must not leak: {c}"); + assert!(!c.contains("10 10 m"), "partial clip geometry must not leak: {c}"); + assert!(!c.contains("20 20 l"), "partial clip geometry must not leak: {c}"); + // Unrelated non-intersecting path remains untouched. + assert!(c.contains("300 300 m\n350 350 l\nS"), "unaffected vector path must be kept: {c}"); +} + /// A chain of nested forms deeper than the pass can walk was removed from the page, taking /// whatever else it drew with it even when nothing in it was marked. The limit now reaches as /// deep as the renderer and Edit text read (50), so anything a reader can see can be walked; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cbbb96da68..5e1c4a7e75 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5793,7 +5793,7 @@ status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] evidence = ["crates/redact/src/tests.rs::vectors_are_removed_or_clipped_and_inline_images_go"] -notes = "covered paths removed, others clipped; shadings clipped" +notes = "intersecting paths removed to avoid leaking geometry behind clipping; shadings clipped" [[feature]] id = "protect.redact-annots-fields"