Skip to content

Format/archives pad and lock - #32

Merged
donislawdev merged 4 commits into
mainfrom
format/archives-pad-and-lock
Sep 1, 2026
Merged

Format/archives pad and lock#32
donislawdev merged 4 commits into
mainfrom
format/archives-pad-and-lock

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

No description provided.

donislawdev and others added 4 commits September 1, 2026 12:11
…ded before

O163, and a breaking change the owner asked for. Sizes do not move and every
size that was reachable still is. What moves is where the padding sits inside
the gzip header, and with it the bytes of every .tar.gz this tool writes.

The fault was invisible for a month and each reason it stayed that way is worth
naming, because none of them was carelessness.

Go's compress/gzip reads a header comment into a fixed buffer: 511 bytes it
takes, 512 it refuses. This format padded through that comment up to four
kilobytes, so 4134 of the 11 260 reachable sizes produced an archive no Go
program could open. The message it gives is "gzip: invalid header", which reads
like a corrupt file rather than like a field the reader will not take, so
anybody meeting it would suspect the fixture rather than the tool that made it.
Testers write tools in Go.

Every reference tool took those files. 7-Zip, GNU tar, bsdtar, Python and node
accept a comment of ten megabytes without a word, so the oracle was green on all
of them. The channel had been measured carefully in August and written up, and
the measurement asked five readers - the language this is written in was not
among them. That is the same shape as trusting a document instead of measuring:
the question was right and the sample was one entry short.

Both fixes named in the observation were measured and rejected. Capping the
comment at what Go takes gives zero unreadable archives and takes 831 sizes out
of reach, because a tar filler entry cannot be smaller than one 512 byte block
and nothing bridges the gap under it. Trading 4134 unreadable files for 831
refusals is not a fix, and it would have looked like one in a summary.

So the padding moved to the gzip extra field, which was measured the same day
rather than assumed: Go takes 65 531 bytes of it, to the last one, beside a
comment, at a cost of exactly two bytes more than it carries. Byte granular,
where the filler entry is aligned to 512. The comment keeps the label and
stretches by at most one byte, which is the one amount the extra field cannot
do, so the pair together reach every size.

After: 11 260 reachable, 0 unreachable, 0 unreadable. Reachability identical to
before, which is the number that decided it. 7-Zip, GNU tar and Python were run
on real output afterwards and all three open it.

Three golden hashes moved, recorded through the remeasured list with the
measurement, because editing them quietly is the single move that turns that
guard into decoration. There is no way back to the old bytes and the changelog
says so: the old bytes are the ones Go cannot read, so a switch for them would
be a switch for the fault.

TestEveryArchiveThisToolWritesCanBeReadByTheStandardLibrary now asks both
containers, across the band where the padding channel changes shape, whether the
standard library opens what we wrote. 1762 archives. It is the guard that would
have caught this on the day it went in, and the reason it did not exist is that
every check we had asked somebody else's reader.

The sweep that priced all of this is tools/probes/targzpadding, and it asks two
questions rather than one: which sizes are REACHABLE and which of those are
READABLE. Run it before and after any change to a padding limit. Reachability is
the one that gets worse quietly.

validCost, commentOfCost and commentCapacity had no readers left afterwards and
are gone. The measurement commentCapacity carried moved into the comment on the
limit that still binds, because a fact does not stop being true when the
constant holding it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aving for what it breaks

    tfg generate --format zip --size 30kb --set entries=3 \
      --set password=Secret123 --set encryption=zipcrypto

It is here for what it does to a reader rather than for what it protects.
Measured on our own output: .NET's ZipFile opens one of these, reports the entry
at its true length of 8192, hands back a stream and fills it with ff c7 04 3e
where the file holds "tfg - txt". It never says the entry was encrypted at all,
so an application built on that library processes noise and calls it data. AES
in the same library throws, which is the safer defect and the less interesting
one. This is the fixture PRESETS.md section 4.12 has been describing since
before there was anything to build it with.

The cipher is not ours and it was not taken on trust. It went into
tools/probes/zipcrypto BEFORE it went into the generator, pointed at an archive
7-Zip had written, and asked to decrypt it - check byte, plaintext CRC and the
bytes themselves all came back right. That order was chosen because of what the
AES work had shown a few hours earlier: a mutation proved the archiver guard
could not see a keystream running backwards, since an AE-2 entry signs its
ciphertext rather than its contents, so a file of noise passes every check a
reader makes.

A defect got through anyway, and the probe is what diagnosed it. Every locked
entry declared method 99 - WinZip AES - while a ZipCrypto entry has to stay
stored, because it changes nothing about how the bytes sit and only puts twelve
in front of them. 7-Zip reported "Data Error in encrypted file. Wrong password?"
which points at the password, the one thing that was right. The probe decrypted
the same file perfectly, and that is what moved the suspicion off the
cryptography and onto the header.

So there is a guard for the header shape now, and it asks both schemes in both
directions. ZipCrypto stores, carries the real plaintext CRC and no extra field.
AES declares 99, carries a CRC of nought and a 0x9901 field. Getting either
backwards produces a file that opens and then fails on something that sounds
like the user's fault.

The cost is named rather than hidden: ZipCrypto puts the high byte of the
plaintext CRC in its header, so the contents have to be known before the first
byte of the entry goes out - and the contents arrive as a stream. Each entry is
therefore generated twice, once to be checksummed and once to be encrypted.
Twice the processor and not a byte more memory, because buffering the entry
would break the guard that says a generator does not hold a whole file.

Three guards stopped carrying a hand written list of methods and read the
registry instead. A list in a test is one somebody has to remember on the day a
fourth scheme arrives, and this was that day.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ne mistake

691 entries, 74.0 minutes, five guards that stayed green while the thing they
name was broken. Every one is closed and proven.

Three of the five are the same mistake wearing different clothes: a guard asking
"is this text in the file" stops meaning anything the day the text appears a
second time. This tree has that trap written down and still collected three of
them in one run.

  The window binary. The guard asked whether ci.yml mentions ./cmd/tfg-gui
  anywhere. It appears three times now - and the third arrived later, in the
  sbom job, which runs on Linux alone. Removing both builds from the platform
  matrix left the guard green, so a window that stops compiling on Windows or
  macOS would have passed in silence, which is the whole thing it watches for.
  It asks the matrix job now.

  The release notes. The notes carry two attestation commands and they answer
  different questions - what is inside a file, which needs the predicate type
  spelled out, and where a Linux archive came from, which must not carry it.
  Breaking one left the phrase present in the other. Both are required now, by
  count and by name.

  The menu. The mutation turned off the branch that widens a menu box to what a
  row of its open list needs, and measuring says that branch is unreachable for
  every menu this window has: the guard prints the same tightest slot with it on
  and off. It was reachable once - the comment beside it records the day twenty
  values came out cut off - so the data changed, not the rule. Deleting a defence
  in the window is not something to do in passing, so it is written up as O164
  with three options and the price of each. The mutation moved to what the guard
  actually names: the room a row gives its words. Four real values come out cut
  off and it names each.

The fourth is this branch's own doing. The comment terminator mutation used to
move the floor, and the minimum guard watched the floor. Padding rode in the
comment then, so an off by one in commentCost was absorbed - the solve built a
comment one byte longer and the archive still came out exact. Padding rides in
the extra field now, so the same byte lands on the size instead, and three other
guards go red on it. The entry follows it to the arithmetic guard that owns the
fact.

The fifth is a guard whose sample was short. JXL checks that a named quality
still fits the file, across five qualities and three sizes - and the break shows
at quality 100 and 5000 B, which was not among them. Two sizes added. That is
the same shape as O163 one file over: a measurement proves what its sample
covers and says nothing about the gap beside it.

CLAUDE.md gets the measured run time, because the sentence there was written at
512 entries. 74.0 min at 691, median 4.64 s an entry, 99% of it inside go test -
and a note not to estimate it from the first dozen, which I did twice that day
and was wrong by a factor of four both times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit e1a63a5 into main Sep 1, 2026
18 checks passed
@donislawdev
donislawdev deleted the format/archives-pad-and-lock branch September 1, 2026 13:03
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.

1 participant