Skip to content

fix(release): harden make-tarball's manifest writing the way #1311 hardened reading - #1332

Open
justinjoy wants to merge 2 commits into
fix/1315-manifest-unescapefrom
fix/1316-tarball-hardening
Open

fix(release): harden make-tarball's manifest writing the way #1311 hardened reading#1332
justinjoy wants to merge 2 commits into
fix/1315-manifest-unescapefrom
fix/1316-tarball-hardening

Conversation

@justinjoy

Copy link
Copy Markdown
Collaborator

Closes #1316. Stacked on #1330 (fix/1315-manifest-unescape) — review that first.

The change

make-tarball.sh wrote its checksum manifests missing the three hardenings #1311 applied to
the reading side. Each has a distinct failure, and each now has a fixture no other kills:

hardening failure
CDPATH= cd on a relative path lands in whichever CDPATH entry matched first — manifest written in the wrong place, or sha256sum finds no archive and the run aborts. release-tag.yml:274,344 pass a relative out_dir, so this is production shape.
-- an out_dir beginning with - is parsed as options by mkdir, dirname, basename and cd.
-P cd is logical: with link -> y/target, x/dir/link/.. is y — the link target's parent — for the kernel that writes the archive, but x/dir for a logical cd.

Scope note: mkdir -p --

Line 22 is hardened although the issue's criterion 1 names only the two subshells. Without it
the -- hardening is unreachablemkdir fails first with invalid option -- 'o', so no
fixture could kill it, while criterion 3 separately requires each hardening to be individually
killable. Those two criteria conflict as written.

Verified both directions: the mkdir-only mutant and the subshells-only mutant are each caught
by the dash fixture, so neither substitutes for the other.

Mutation matrix

mutant FAILs
this PR 0
no CDPATH= CDPATH case only
no -P symlink case only
no -- (all) dash case only
no -- (subshells only) dash case only
no -- (mkdir only) dash case only
pre-change all three

Byte-identity holds (criterion 4): archive, .sha256 and .blake3 are cmp-identical to
pre-change output for an ordinary out_dir, and verify-release.sh round-trips them. Suite
passes under GNU bash 3.2.57 as well as 5.3.

What I got wrong, since it shaped the result

Two of my three fixtures were wrong before I measured them, and both passed against the
unhardened script while covering nothing: for -P I first used link/sub/.., where logical
and physical cd agree; for -- I used ./-outdir, whose dirname is ..

I described the CDPATH mechanism incorrectly in three places, and wrote a test assertion for
it.
I claimed cd's CDPATH chatter is captured into the manifest. It is not — in
(cd DIR && sha256sum NAME > NAME.sha256) the redirection binds to sha256sum, not to cd.
The reviewer caught it and showed that the assertion I added for that mechanism
([ "$(grep -c . -- "$man")" = 1 ]) can never fire. Both the claim and the dead assertion are
gone, and the comment now states the binding explicitly so the error is not re-derived.

The -P example was off by one path component — I wrote "where the link points" when my
own measurement had shown the link target's parent.

Left out deliberately

make-tarball.sh:8's git rev-parse --verify "$ref^{commit}" also lacks --. Filed as
#1331 rather than folded in: the mkdir widening was forced by the criteria conflict
above, this one is not required by anything, and widening twice on my own judgment in one
change is how scope creep gets rationalised.

verify-release.sh rejected any archive whose name coreutils escapes, before
comparing a single hash:

    checksum manifest does not name back\slash.tar.gz: back\\slash.tar.gz

GNU coreutils escapes exactly three characters in a name -- backslash as `\\`,
newline as `\n`, carriage return as `\r` -- and prefixes the whole line with a
literal `\`. The set was determined by scanning all 254 legal filename bytes
through sha256sum, not from documentation: tab, other control bytes and UTF-8
are not escaped. b3sum escapes the same three with the same marker.

A third failure had nothing to do with escaping. The name was read as awk's
`$2`, and coreutils does not escape spaces, so `has space.tar.gz` was truncated
to `has` and reported as a name mismatch. The name is now taken as everything
after the two-character separator, which is also correct for binary mode.

Compare against the ESCAPED form of the expected name rather than unescaping the
manifest's. Unescaping is ambiguous in the wrong direction: a file genuinely
named `a\nb.tar.gz` is written `a\\nb.tar.gz`, and replacing `\\` then `\n` in
sequence turns it into a newline. Measured -- the naive form corrupts that name.
Escaping is one-way and has no such case, which is why `a\nb.tar.gz` is a
regression case rather than an incidental one.

Every path still fails closed. The name check only selects which manifest line
is trusted; the hash is recomputed independently, so no name-check outcome can
produce a false "verified".

This makes #1311's leading-backslash strip in hash_of reachable for the first
time and pins it, so the comment declaring it deliberately unpinned is replaced.

Refs #1315
@justinjoy
justinjoy force-pushed the fix/1315-manifest-unescape branch from 679bb8c to 329ce3e Compare September 3, 2026 09:25
…rdened reading

make-tarball.sh wrote its checksum manifests with

    (cd "$(dirname "$archive")" && sha256sum "$(basename "$archive")" > ...)

missing the three hardenings #1311 applied to verify-release.sh. Each has a
distinct failure, and each is now covered by a fixture that no other kills:

  CDPATH=  With CDPATH set, `cd` on a relative path lands in whichever CDPATH
           entry matched first, so the manifest is written in the wrong place
           or sha256sum finds no archive there and the run aborts. It also
           echoes the resolved path on its own stdout, which the archive_dir
           capture takes as part of the value. release-tag.yml:274,344 pass a
           relative out_dir, so this is the production shape.

           It does NOT corrupt the manifest -- the redirection binds to
           sha256sum, not to cd. An earlier draft of this change claimed
           otherwise and added a test assertion for that non-existent
           mechanism; both are gone.

  --       An out_dir beginning with `-` is parsed as options.

  -P       `cd` is logical, so with `link -> y/target` an out_dir of
           `x/dir/link/..` is `y` -- the link target's parent -- for the kernel
           that writes the archive, but `x/dir` for a logical cd.

`mkdir -p --` at line 22 is included although the issue's criteria name only
the two subshells. Without it the `--` hardening is unreachable: mkdir fails
first with `invalid option -- 'o'`, so no fixture could kill it, and the issue
separately requires each hardening to be individually killable. Verified both
directions -- the mkdir-only mutant and the subshells-only mutant are each
caught by the dash fixture.

Archive and both manifests are byte-identical to pre-change output for an
ordinary out_dir, and verify-release.sh round-trips them.

Refs #1316
@justinjoy
justinjoy force-pushed the fix/1316-tarball-hardening branch from abaa89b to c0271af Compare September 3, 2026 09:25
@justinjoy
justinjoy force-pushed the fix/1315-manifest-unescape branch from 329ce3e to 72efd1b Compare September 3, 2026 14:29
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