Skip to content

redact: destroy the pixels of a JPEG, as plain samples - #87

Merged
tannevaled merged 1 commit into
mainfrom
feat/the-jpeg-half
Nov 10, 2026
Merged

tannevaled merged 1 commit into
mainfrom
feat/the-jpeg-half

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

ISO 32000-1 §12.5.6.23 says a redaction over a picture must destroy that
portion of the image data, and that a clip or a mask will not do. Until now a
JPEG in the region was a refusal — so a scanned page, which is one picture and
almost always DCTDecode, could not be redacted at all.

It is written back as plain samples, not as another JPEG

⛔ Re-encoding re-quantises block by block. The blanked picture would come
back with every block changed: the whole photograph degraded in order to
destroy a corner of it. What this writes instead is exactly what a reader of
the original would have seen, with the region destroyed.

The test says that in the only way the claim can be said: it decodes the
original JPEG itself and compares the kept samples byte for byte against
that decode. Asserting a colour would be asserting what an encoder does with
it, which is a different subject and a moving one; and asserting "it still
looks about the same"
would pass for a picture re-encoded into mud.

The trade is size — a photograph as Flate is several times the same photograph
as a JPEG — and it is in the doc comment and the README rather than left to be
discovered. It is paid only by a picture a region actually reaches: one nobody
redacted over keeps its DCTDecode stream byte for byte, and there is a test
for that too.

Two new refusals

refused why
a CMYK JPEG no faithful CMYK → RGB without the output profile the file does not carry, so converting here would change every colour on the page to destroy a corner of it
more than 100 megapixels a JPEG says how big it is in a handful of bytes and the decoder then allocates width × height × 3, so thirty-nine bytes can ask for 1.2 GB

image/jpeg writes Gray, YCbCr and RGBA and has no way to write a CMYK
JPEG
, so decodeJPEG is a seam — without it that refusal would be a claim no
test could reach.

The ceiling is a real bomb, against the real bound

⛔ jpegMegapixels is a const, not a knob a test lowers. The fixture is
thirty-nine bytes declaring 20000 by 20000 with not one sample in it, run
against the bound callers actually get — and it asserts that the decoder was
never reached
, because the refusal message reads the same whether the check
runs before the allocation or after it.

⛔ The APP0 segment in that fixture is not decoration: image/jpeg returns from
a header-only read at the frame header only once it has seen a JFIF marker, so
without it the fixture is an unexpected EOF and the test proves the "cannot
read"
arm instead of the ceiling. The test checks its own fixture first.

Mutated both ways, because a guard that passes its own mutation is vacuous

mutation result
+1 on one channel of the kept samples red — row 0, column 0 came back [21 200 90] where a reader of the JPEG would have seen [20 200 90]
the ceiling moved after the decode red — it was decoded anyway, which is the allocation the ceiling exists to refuse; the message assertion stayed green, which is the point

Fuzzed

FuzzRedactOverAJPEG feeds arbitrary bytes to the new decoder arm, with the
four seeds committed in source including the bomb header: 4.7 million
executions in 90 s
under GOMEMLIMIT=1GiB, no crash and no runaway.

Gates

gofmt -l . silent, go vet ./... silent, 100.0% of statements and 0
uncovered blocks of 3873
(covblocks).

The old refusal table row for DCTDecode moved rather than disappeared: what
stays refused is a stream that says DCTDecode and holds no JPEG, plus
JPXDecode and any other filter this does not undo.

Closes #82.

🤖 Generated with Claude Code

ISO 32000-1 12.5.6.23 says a redaction over a picture must DESTROY that portion
of the image data, and that a clip or a mask will not do. Until now a JPEG in
the region was a refusal, which meant a scanned page — one picture, almost
always DCTDecode — could not be redacted at all.

⛔ IT IS WRITTEN BACK AS PLAIN SAMPLES, NOT AS ANOTHER JPEG. Re-encoding
re-quantises block by block, so the blanked picture would come back with EVERY
block changed: the whole photograph degraded in order to destroy a corner of
it. What this writes instead is exactly what a reader of the original would
have seen, with the region destroyed. The test says so in the only way that
claim can be said: it decodes the original itself and compares the kept samples
BYTE FOR BYTE against that decode. Asserting a colour would be asserting what
an encoder does with it, which is a different subject and a moving one; and
asserting "it still looks about the same" would pass for a picture re-encoded
into mud.

The trade is size — a photograph as Flate is several times the same photograph
as a JPEG — and it is said out loud in the doc comment and the README rather
than discovered. It is paid only by a picture a region actually reaches: one
nobody redacted over keeps its DCTDecode stream byte for byte, and there is a
test for that too.

Two new refusals:

  - a CMYK JPEG, rather than a conversion. There is no faithful CMYK to RGB
    without the output profile the file does not carry, so converting here
    would change every colour on the page to destroy a corner of it. image/jpeg
    writes Gray, YCbCr and RGBA and has no way to write a CMYK JPEG, so
    decodeJPEG is a seam — without it the refusal would be a claim no test
    could reach.
  - more than 100 megapixels. A JPEG says how big it is in a handful of bytes
    and the decoder then allocates width × height × 3, so a file of thirty-nine
    bytes can ask for 1.2 GB.

⛔ THE CEILING IS READ FROM THE HEADER, BEFORE ANYTHING IS DECODED, and the
test proves the ORDER and not only the message. jpegMegapixels is a const, not
a knob a test lowers: the fixture is a real bomb, thirty-nine bytes declaring
20000 by 20000 with not one sample in it, run against the bound callers get —
and it asserts the decoder was never reached, because the refusal reads the
same whether the check runs before the allocation or after it. Moving the check
after the decode makes that test red; it leaves the message untouched.

Mutation-checked both ways: +1 on one channel of the kept samples fails the
byte-for-byte test (row 0, column 0 came back [21 200 90] for [20 200 90]), and
the reordered ceiling fails the bomb test.

FuzzRedactOverAJPEG feeds arbitrary bytes to the new decoder arm with the four
seeds committed in source, including the bomb header: 4.7 million executions in
90 s under GOMEMLIMIT=1GiB, no crash and no runaway.

gofmt clean, go vet clean, 100.0% of statements and 0 uncovered blocks of 3873
(covblocks).

Closes #82.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit e5bf458 into main Nov 10, 2026
1 check passed
@tannevaled
tannevaled deleted the feat/the-jpeg-half branch November 10, 2026 20:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Redact across a JPEG: destroying its pixels needs a re-encoding decision

1 participant