Repository navigation
fix(redact): clear redacted text from /Info and XMP metadata on apply (#2448) - #2598
jonathanwxh-cell wants to merge 6 commits into
Conversation
…ld#2448) An /Info value whose words are too short to be terms ("J. Li") survived when the sentence holding it was redacted. A value that is wholly part of a removed run, with three letters or digits at least, now goes as well (the reverse check storytold#2598 proposed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
|
Hi @jonathanwxh-cell — #2539 (opened about two hours before this one) also fixes #2448, so the two overlap. A short comparison, in case it helps the maintainers choose:
I'm happy to fold anything else useful from here into #2539, or to close it if the maintainers prefer this one. |
34e7919 to
bb57f20
Compare
|
Thanks @mtlcl410 for the concrete example and for crediting the reverse-match case. I checked the current head, bb57f20: show() still flushes at each operator, and clear_marked_metadata drops the resulting one-character fragments. Your TOPSECRET example therefore identifies a remaining gap. I haven’t independently rerun the fixture. #2539’s position-based collection addresses the cross-operator case. |
A value shorter than the redacted sentence ("James Bond" from "JAMES BOND
IS A SPY") in a custom schema's element or attribute takes its packet
with it, as it goes from /Info, while a clean packet and an unrelated
entry stay. The regression storytold#2598 suggested keeping whichever fix lands.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
|
Thanks for checking. Agreed on that regression — I've added it to #2539 as One difference worth noting for the trade-off: #2539 also matches each word of three or more letters on its own, so it errs further towards clearing — with "JAMES BOND IS A SPY" redacted, a |
…old#2448) Many generators draw text one glyph per operator ((T) Tj 5 0 Td (O) Tj ...). The metadata pass collected each operator's removed text separately, so such text became one-character fragments, the two-character needle floor filtered them all, and a /Title repeating the removed text survived (storytold#2598). The interpreter now carries the open run in the Scope and joins by device-space position: a removed glyph continues the run when it starts within 1.5 glyph heights of where the last one's advance - character and word spacing included, so tracking never reads as a word gap - landed; a repositioning gap past 0.15 heights becomes a real space. TJ kern numbers keep their old meaning of spacing inside one text run: the glyph after a number joins with no space and no split, so a kerned intra-word value still clears. Kept glyphs, codes with no Unicode meaning and page ends still split runs. Matching itself is unchanged: whole-run needles and both two-character floors, so unrelated entries like a /Subject (Spy stories) beside a redacted sentence still stay.
|
@mtlcl410 Good catch — the cross-operator case you demonstrated, where Verified: |
A completed raster can legitimately clear the settling stroke before the integration test checks it. A temporary 300 ms receive delay reproduced the FreeBSD count-0-versus-1 assertion on parent 9248b8a. Hold delivery while checking pending strokes, assert five extra frames, then release it and wait for the raster. Preserve the undo assertion and production defaults. Narrowly adapted from Guillem Hermida's pen-test changes in upstream PR storytold#2812 at 47e5398 (MIT OR Apache-2.0). Exclude its separate fullscreen and renderer changes. No redaction change. The temporary delay is not included. Validation: comments 45/45 with and without the diagnostic delay; native Clippy all targets -D warnings; formatting; layers 37; assets; wasm 31. Co-authored-by: Guillem Hermida <guillemhr2@gmail.com>
|
Thanks for taking #2448, and for the fix in 9248b8a. I reported the issue, and I went through this head (
Neither PR covers XMP attached to images, fonts or form XObjects, or Unicode normalization (NFD accents, ligatures like "fi"). Those may be fine as documented limits next to Sanitize's metadata category. Tests that would help: a pdfTeX-style |
A TJ word kern could collect JamesBond while metadata held James Bond. Record optional forward-gap spaces for comparison, preserving unspaced intra-word kerning and literal word boundaries without enumerating variants. Normalize internal whitespace in values and removed fragments. Parse XMP values with the already-locked roxmltree dependency rather than searching markup names; malformed XML and unsupported DTDs take the whole-packet fallback. Preserve complete-fragment/value matching, Spy stories, and existing -200/-300 kerning regressions. Partial/reordered names, Unicode normalization, short-value over-clearing, and resource-limit policy remain documented and unchanged. Review feedback: storytold#2598 (comment) Validation: three expected-correctness groups failed before implementation; 67 redaction tests pass (one existing manual probe ignored); headless metadata redaction automation test passes; strict Clippy, formatting, layers, assets, parity and wasm (31 crates) pass. Synthetic full-save/reopen regressions cover mixed/rotated kerns, whitespace, XML names/namespaces, entities and CDATA.
|
Thanks for the quick update. I went through
One new gap: The new metadata tests check the reopened document, which is the right way. A few older ones in |
…ld#2448) An /Info value whose words are too short to be terms ("J. Li") survived when the sentence holding it was redacted. A value that is wholly part of a removed run, with three letters or digits at least, now goes as well (the reverse check storytold#2598 proposed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
A value shorter than the redacted sentence ("James Bond" from "JAMES BOND
IS A SPY") in a custom schema's element or attribute takes its packet
with it, as it goes from /Info, while a clean packet and an unrelated
entry stay. The regression storytold#2598 suggested keeping whichever fix lands.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
|
Checked Two small things left, neither blocking:
|
Title: fix(redact): clear redacted text from /Info and XMP metadata on apply (#2448)
Applying redactions removed the page content but left the redacted string in the document
metadata: a title or subject holding the secret stayed readable in
/Infoand in the XMP/Metadatastream (#2448).applynow records the Unicode text of every glyph run it removes and, after the residuecheck, clears metadata that repeats it. A
/Infostring entry goes when its value contains aremoved run — or when its value is contained in one, so the author of a wholly redacted
sentence goes too. Clearing goes through
pdfcraft_organize::set_info, which also removes theXMP property mirroring the entry and nothing else in the packet. A catalog or page
/Metadatastream is removed whole when removed text survives in it where per-entry editing cannot reach
(custom schemas, entity-escaped values), or when the stream cannot be decoded and read as
UTF-8 within 16 MiB. A trailer
/Infothat is a direct dictionary is removed whole when itstill matches; the per-entry step cannot edit that shape. All of it runs before
require_full_save, so the next save is the full rewrite redaction already forces and the old/Infoand/Metadataobjects are dropped from the saved file. Fixes #2448.What this changes
show()(crates/redact/src/interp.rs:637) records maximal removed runsas fragments: joined across
TJkerning numbers within oneshow()call, split at keptglyphs, at codes with no Unicode meaning (each side then matches on its own), and at operator
ends. Collection is gated to redaction apply mode — sanitize and verify are untouched.
clear_marked_metadata(crates/redact/src/lib.rs:642) keeps fragments of two charactersor more: one removed glyph is no evidence a secret was removed, and would clear unrelated
entries containing that character. The same two-character floor applies to the value in the
contained-in-a-run direction.
Reportgainsinfo_entries_clearedandxmp_removed(crates/redact/src/lib.rs:134) —library-level only; the engine discards the Report and
redact_apply's JSON is unchanged.("redact", "organize")was added to the xtaskSIDEWAYSallow-list.matching errs towards clearing — a fully redacted common word takes unrelated entries
containing it with it, and the wholesale fallback drops a whole stream for one custom-schema
hit — and it cannot prove surviving metadata clean: text inside images never becomes a
fragment, fragments do not join across operators, and component-level
/Metadata(images,fonts, form XObjects) is not scanned, the same scope as sanitize.
doc_remove_hiddenwiththe metadata category remains the certain path.
How you verified it
Tests written first and run against unmodified main — behaviour-only assertions; the
Report-field asserts came with the fix, since the fields do not exist pre-fix.
(The second test above has since been renamed
an_xmp_stream_holding_redacted_text_is_removed_whole;the failure output predates the rename. The fifth unit test,
applying_with_no_marks_leaves_metadata_alone, passes on main by design: with no marksanywhere,
applyfails withNothingToApplybefore changing anything, and the test asserts/Infoand the packet are still in place — it guards the insertion point. It was in the 50passed above.)
A sixth unit test,
a_value_inside_a_longer_removed_run_is_cleared_too, was also writtenred-first, against the first version of the fix: with the whole sentence "JAMES BOND IS A SPY"
removed (19 glyphs), it failed with
info_entries_cleared: 0, xmp_removed: 0, because theauthor "James Bond" is shorter than the removed run and the matcher only looked one direction.
That failure is what made the matcher bidirectional.
Post-fix, on the final tree:
The green runs assert the safety property directly, not just the clears: Author "Ada" stays
while Title goes (
crates/redact/src/tests.rs:700), the packet'spdf:Producerand a cleanpage-level
/Metadatastream stay (:703,:705), Subject "Spy stories" stays and survivesthe saved file (
:727,:733), and the end-to-end test reads Author back out of the savedfile (
crates/automation/tests/automation.rs:3314). The "nothing else in the packet" propertyis
pdfcraft_organize's own, pinned by its testxmp::tests::element_values_are_replaced_in_place(
crates/organize/src/xmp.rs:194, "Everything else is untouched"); on this treecargo test -p pdfcraft-organize -j 2gives126 passed; 0 failed.cargo fmt --check—cargo fmt --all -- --check, clean.-D warningson the crates I changed —cargo clippy -p pdfcraft-redact -j 2 --all-targets -- -D warningsandcargo clippy -p pdfcraft-automation -j 2 --all-targets -- -D warnings, both finished with no warnings.cargo xtask layers—OK: 37 crates, no layering violations.(the redact row now lists organize).cargo xtask assets— not run; no assets are touched. CI runs it on every PR.Tests. Four red-first unit tests in
crates/redact/src/tests.rs— per-entry clearing withcase-insensitivity; UTF-16BE
/Infothrough a ToUnicode-mapped fixture font; escapedcustom-schema and page-level wholesale removal; direct-dictionary wholesale removal — plus the
passes-on-main guard, the bidirectional-matcher test above, and one end-to-end test in
crates/automation/tests/automation.rsdrivingdoc_create/doc_set_info/redact_mark/redact_apply/doc_info/doc_saveand assertingthrough a re-open of the saved file. A full write may pack
/Infointo a compressed objectstream, so file-level survival is asserted by re-opening the saved bytes, not by scanning them.
Not run: the full automation suite (two known-stale failures on main, #2417), and the CI-only
gates (full workspace test, wasm, no-default-features check).
Never-crash
unwrap/expect/panic!/unreachable!/todo!outside tests — none in the new code;crates/redact/src/lib.rs:18denies the family crate-wide and clippy passed with-D warnings.xml_unescape/xml_chunks(crates/redact/src/lib.rs:543,:595, withpacket_hitat:588) sit at char boundaries by construction (positions fromfindof ASCII separators andlen_utf8advances); metadata streams decode throughdecoded_within(MAX_METADATA)(:538, 16 MiB), and a stream that fails to decode or read as UTF-8 is removed whole rather than failingapply.unsafe.Provenance
/Info/XMP shapes per spec, driven by synthetic fixtures.ATTRIBUTION.tomlentry — no new assets, so there is nothing to attribute;cargo xtask assetswas not run.Security
--rootconfinement still holds — no path handling in the change; redact operates on an already-openDocument.Parity and reach
parity/acrobat-features.tomlupdated in this PR — protect.apply-redactions gains the new evidence tests and notes saying what is still missing (the Limits bullet above); protect.remove-hidden-information is noted as the certain metadata follow-up.automationtool — the end-to-end test above drives it throughredact_apply.crates/redact/README.md(the "Metadata redaction (Redaction leaves redacted strings in the Info dictionary and XMP metadata #2448)" section) and a ROADMAP progress-log entry.Anything a reviewer should look at first
The
SIDEWAYSentry inxtask/src/layers.rs: redact (L4) now depends on organize (L4) — anarchitecture-rule change, and the one judgement call in the PR. After that, the matcher's
direction: a value is also cleared when it is merely part of a removed run — deliberate, and
part of the errs-towards-clearing trade-off recorded in the Limits bullet above.