Skip to content

Refuse an entry that would be written over the archive being read - #105

Merged
otsobide merged 1 commit into
devfrom
feature/refuse-overwriting-the-archive
Aug 27, 2026
Merged

Refuse an entry that would be written over the archive being read#105
otsobide merged 1 commit into
devfrom
feature/refuse-overwriting-the-archive

Conversation

@otsobide

Copy link
Copy Markdown
Owner

Fixes #96, the half of #62 that destroys data without the user getting anything wrong.

The bug

An archive holding an entry named after the archive, extracted into the archive's own directory, overwrote itself. Real binary, on dev:

[zip] exit=0  "Extracted 1 file(s)"  132  -> 12 bytes
[tar] exit=0  "Extracted 1 file(s)"  2048 -> 12 bytes
[7z]  exit=0  "Extracted 1 file(s)"  115  -> 12 bytes

The entry is written onto the file still being read, so the archive is truncated mid-read and what replaces it is whatever fraction the extractor had reached. The contents are lost from the output as much as from disk, and two of the three formats report success.

Why it is not a judgement call

Compression has always refused the mirror image, and refuses it even with --force: OutputIsSource, plus a separate refusal for an output inside the folder being archived, on the grounds that "agreeing to replace a file is not agreeing to destroy the source". Extraction had no equivalent, so the same product held two opposite positions on the same question.

So this is refused outright rather than made overridable. Nobody asks for this outcome.

Two things easy to get wrong

File identity, not paths. A hardlink is a second name for one file and never resolves to the same string. That is not hypothetical: it is exactly how --force used to be able to overwrite its own source on the compression side before paths::same_file was introduced. Verified against a real hardlink:

error: the entry "alias.zip" would be written over the archive itself (real.zip),
       so nothing was extracted. Extract into a different directory.

The planned name, not the archive's spelling. A rename can land an entry on the archive that the archive's own name does not match: archive v_.zip, entry v?.zip, answered with _.

Cost

None worth measuring. The planning pass already reads the whole listing before a byte is written, so this is one identity comparison per entry and no extra pass over the file.

Tests

Four, and every mutation is caught:

mutation tests that fail
guard removed entirely 3
compares paths instead of identity 1
ignores the plan, checks the archive's spelling 1
refuses too much (any name matching the archive's) 2

The last row is the one that matters. "Refuse anything that resembles the archive" would pass the first three and quietly break ordinary extractions, so the_same_archive_extracts_normally_somewhere_else exists to stop exactly that fix.

Notes

WouldOverwriteArchive is a variant of its own rather than a Failed string. Both places that match on CompressionError have catch-all arms, so nothing breaks, and a front end that wants to offer "extract somewhere else" now has something to match on.

608 Rust tests and 116 Vitest, 493 offline. threat_model.md gains section 4a.

#62 keeps what remains: the general policy for overwriting files that already exist, which is a design decision rather than a bug and probably wants relabelling.

An archive holding an entry named after the archive, extracted into the
archive's own directory, overwrote itself. All three formats; two of the three
said "Extracted 1 file(s)" and exited 0 while doing it.

  [zip] 132 -> 12 bytes    [tar] 2048 -> 12 bytes    [7z] 115 -> 12 bytes

The entry is written onto the file still being read, so the archive is
truncated mid-read and what replaces it is whatever fraction the extractor had
reached: the contents are lost from the output as much as from disk.

What made this indefensible rather than merely unfortunate is that compression
has always refused the mirror image, and refuses it even with `--force`
(`OutputIsSource`, and an output inside the folder being archived). The same
product held two opposite positions on the same question.

The planning pass already reads the whole listing before a byte is written, so
the check costs one comparison per entry and no extra pass over the file. Two
details it would be easy to get wrong, both pinned by tests:

- it compares **file identity**, not paths. A hardlink is a second name for one
  file and never resolves to the same string, which is exactly how `--force`
  was once able to overwrite its own source on the compression side. Verified
  against a real hardlink.
- it follows the **planned** name, not the archive's spelling, since a rename
  can land an entry on the archive that the archive's own name does not match.

Refused outright rather than made overridable. Nobody agrees to this by asking
to extract something, so no flag should unlock it.

`WouldOverwriteArchive` is a variant of its own rather than a `Failed` string:
the two callers that match on `CompressionError` both have catch-all arms, so
nothing breaks, and a caller that wants to offer "extract somewhere else" now
has something to match on.

Four tests, and all four mutations are caught: removing the guard, comparing
paths instead of identity, ignoring the plan, and refusing too much. The last
one matters most, since "refuse anything that resembles the archive" would
pass the other three and break ordinary extractions.

Closes #96
@otsobide otsobide added the full-matrix Run the macOS and Windows suites on this PR label Aug 27, 2026
@otsobide
otsobide merged commit 8ddf9b6 into dev Aug 27, 2026
36 checks passed
@otsobide
otsobide deleted the feature/refuse-overwriting-the-archive branch August 27, 2026 09:54
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