Skip to content

Stop extracting an archive whose listing cannot be read - #101

Merged
otsobide merged 1 commit into
devfrom
feature/refuse-unreadable-listing
Aug 26, 2026
Merged

Stop extracting an archive whose listing cannot be read#101
otsobide merged 1 commit into
devfrom
feature/refuse-unreadable-listing

Conversation

@otsobide

Copy link
Copy Markdown
Owner

Fixes #89. The cheap option of the two we discussed: refuse, rather than judge each name lazily as it is written.

The bug

plan_for swallowed a listing failure and carried on with the identity plan. The comment justifying that was right about the message and wrong about the timing: the extractor fails while streaming, so by the time it notices it has already written every entry before the fault, with no plan at all. No rewriting, no refusal, no collision check.

Same two entries, twice. The second archive is identical except that its end-of-archive marker is replaced with garbage:

intact listing   report Ok(1 entry)  extract Err  ON DISK []
unreadable       report Err          extract Err  ON DISK ["notes.txt:hidden", "second.txt"]

On Windows notes.txt:hidden is not a file name, it is the hidden alternate data stream of notes.txt: the write succeeds, the bytes land where no listing shows them, and the user was told there was nothing to answer for. Issue #63's harm without consent, arranged by appending one bad 512 byte header to a tar.

NamePlan::identity() meant two different things and the write sites could not tell them apart: "nobody asked for rules" (correct, that is extract_zip called directly) and "rules were asked for and I could not read the listing".

Why the cheap option is defensible

The message half of the old argument turned out to be the weak half. Both passes go through the same parser, so they say close to the same thing; in the reproduction they differed only in where they were cut off.

What it costs, and what it does not

Partial recovery: a truncated tar used to hand back whatever preceded the damage and now hands back nothing.

Not actually gone, though. The backends take no options and never come through the planning pass, so extract_tar still salvages. recovering_from_a_damaged_archive_is_still_possible_through_the_backend pins that, so the capability does not look deleted.

Checked before committing to this that the listing parser is not stricter than the extractor for archives that are merely unusual, or we would start refusing good ones:

archive listing readable extract()
tar with end-of-archive marker yes Ok(2)
tar without the marker (some writers omit it) yes Ok(2)
zip with junk appended yes Ok(2)

The message

Not scope creep: the refusal is now the whole of what the user gets for a damaged archive, and the tar crate embeds the bytes it choked on.

before: Compression failed: numeric field did not have utf-8 text: <4 unprintable bytes>
        when getting cksum for <100 more>
after:  Compression failed: this archive could not be read, so nothing was extracted:
        numeric field did not have utf-8 text: when getting cksum for

Narrower than #66, which is about callers telling causes apart. This is only about what is fit to show a person. One desktop assertion moves with it.

Tests

Four new, two of which fail against the old behaviour:

test fails before
a_damaged_archive_writes_nothing_rather_than_writing_raw_names yes
the_same_names_are_refused_whether_or_not_the_archive_is_damaged yes
a_damaged_archive_says_so_in_words_a_person_can_read yes
recovering_from_a_damaged_archive_is_still_possible_through_the_backend no, it is a pin for the escape hatch

One existing test was renamed: an_archive_that_cannot_be_listed_still_fails_in_the_extractor_s_words still passed, but its comment said "the listing pass is advisory on purpose", which is no longer true. It would have misled the next reader.

Verified with the real binary too: a correctly damaged tar exits 1 and creates no output directory, while the intact one extracts normally.

Counts: 604 Rust and 113 Vitest, 489 offline. threat_model.md updated.

`plan_for` swallowed a listing failure and carried on with the identity plan.
The comment justifying that was right about the message and wrong about the
timing: the extractor fails **while streaming**, so by the time it notices it
has written every entry before the fault, and written them with no plan at
all. No rewriting, no refusal, no collision check.

Reproduced, same two entries, twice:

  intact listing    report Ok(1)   extract Err   ON DISK []
  unreadable        report Err     extract Err   ON DISK [notes.txt:hidden, second.txt]

On Windows `notes.txt:hidden` is not a file name, it is the `hidden` alternate
data stream of `notes.txt`: the write succeeds, the bytes land where no listing
shows them, and the user was told there was nothing to answer for. Issue #63's
harm, performed without consent, arranged by appending one bad 512 byte header
to a tar.

The message half of the old argument turned out to be the weak half. Both
passes go through the same parser, so they say close to the same thing; in the
reproduction they differed only in where they were cut off.

What this costs is partial recovery: a truncated tar used to hand back
whatever preceded the damage and now hands back nothing. Deliberate, and not
actually gone, since the backends take no options and never come through the
planning pass. `recovering_from_a_damaged_archive_is_still_possible_through_
the_backend` pins that so the capability does not look deleted.

Checked before committing to it that the listing parser is not stricter than
the extractor for archives that are merely unusual: a tar with no
end-of-archive marker and a zip with junk appended both list fine, so this
does not start refusing good archives.

Also makes the refusal readable, which is not scope creep but the consequence
of the change: it is now the whole of what the user gets for a damaged
archive, and the tar crate embeds the bytes it choked on.

  before: Compression failed: numeric field did not have utf-8 text: <4 bad
          bytes> when getting cksum for <100 more>
  after:  Compression failed: this archive could not be read, so nothing was
          extracted: numeric field did not have utf-8 text: when getting cksum for

Narrower than issue #66, which is about callers telling causes apart. This is
only about what is fit to show a person.

One desktop assertion moves with it, since the 7z message is now prefixed with
what happened before why.

Closes #89
@otsobide otsobide added the full-matrix Run the macOS and Windows suites on this PR label Aug 26, 2026
@otsobide
otsobide merged commit 740a15f into dev Aug 26, 2026
38 checks passed
This was referenced Aug 26, 2026
@otsobide
otsobide deleted the feature/refuse-unreadable-listing branch August 28, 2026 18:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

full-matrix Run the macOS and Windows suites on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant