fix(archives): wrap the bare EOFError a truncated tar.gz raises - #3938
Conversation
`tarfile` wraps most decompression failures in `TarError`, but a gzip stream that ends before its end-of-stream marker escapes as a bare `EOFError` from the gzip layer. `EOFError` derives from neither `TarError` nor `OSError`, so it bypassed all three of the tar handlers added with tar archive support (github#3874): - the format probe in `detect_archive_format`, which caught only `tarfile.TarError`; - `tarfile.open` in `safe_extract_tar`; - member iteration in `safe_extract_tar`. A truncated `.tar.gz` — an interrupted download, a partially written file — therefore raised a raw `EOFError` straight through the caller's `error_type`, so callers catching `ValueError`/`ExtensionError`/ `PresetError` never saw it. In `specify workflow add` the effect is worse than a traceback: Typer treats a bare `EOFError` as a Ctrl-D abort, so the command printed only "Aborted." with no diagnostic at all. The ZIP twin reports "Invalid workflow archive: Invalid ZIP archive: <path>". Route all three sites through a shared `_TAR_DECOMPRESSION_ERRORS` tuple so they stay in sync. `zlib.error` is included alongside `EOFError`: it is likewise neither a `TarError` nor an `OSError` and can surface from a corrupt deflate block. `OSError` is kept only on the two `safe_extract_tar` sites, which report genuine I/O failures; adding it to the probe would silently swallow them instead. Truncated tar.gz now reports the same clean, domain-typed error as the ZIP path. Tests cover both the short prefix that fails in `tarfile.open` and the longer ones that fail during member iteration — `tarfile` decompresses lazily, so the leak surfaced at different sites depending on how much of the stream survived. Assisted-by: Claude Opus 5 (1M context) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Wraps tar/gzip decompression failures in caller-defined domain errors.
Changes:
- Adds shared handling for
TarError,EOFError, andzlib.error. - Adds truncated tar.gz regression coverage.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/_download_security.py |
Handles decompression failures across tar probing and extraction. |
tests/test_download_security.py |
Tests truncated tar.gz behavior and error wrapping. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
|
Please address Copilot feedback |
Review feedback: the `zlib.error` arm of `_TAR_DECOMPRESSION_ERRORS` was not exercised. Every regression added with the fix truncates a valid deflate stream, which raises `EOFError`, so `zlib.error` could regress independently of the EOF handling. It is genuinely reachable, but only under a narrower condition than the truncation cases. `tarfile` converts `zlib.error` to `ReadError` while reading a member *header*, but the forward seek it performs to skip member *data* (`tarfile.next`) sits outside that conversion, so a corrupt region past the first header escapes raw. Reaching that seek needs members larger than the gzip read buffer: with small members the whole stream is decompressed during the first header read and the error is wrapped. The new fixture therefore uses two 256 KiB members at `compresslevel=1` — a ~7 KiB archive — corrupted past the midpoint so the first header still reads clean. Adds four tests: the two `safe_extract_tar` sites (plain and with a caller-supplied `error_type`), the `safe_extract_archive` entry point with a caller-supplied `error_type`, and a guard asserting the fixture still reaches the module as a bare `zlib.error` — so if a future Python wraps it, that fails loudly instead of the coverage silently decaying into a duplicate of the `EOFError` cases. Verified test-the-test: the three wrapping tests fail against the unmodified `_download_security.py` with a raw `zlib.error: Error -3 while decompressing data: invalid distance code`, and pass with the fix. Also corrects the scope claimed for the probe site. Fuzzing 2800 corrupt archives never produced a bare `zlib.error` from `tarfile.open` alone, because the only read it performs is the header read that `tarfile` already converts. The probe's `zlib.error` arm is defensive, not load-bearing; the tuple comment and a detection test now say so rather than implying coverage that cannot exist. Assisted-by: Claude Opus 5 (1M context) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@mnriem Copilot feedback addressed in ef49acc. It flagged that the It also surfaced that the PR description overstated the scope: the Details in the review thread. |
mnriem
left a comment
There was a problem hiding this comment.
Please fix test & lint errors
…dent CI failure on macos-latest/3.13: `test_corrupt_deflate_fixture_raises_bare_zlib_error` failed with `gzip.BadGzipFile: CRC check failed`. The other five pytest jobs were fail-fast cancellations, not real failures, and ruff was already green. The fixture built its corruption by XOR-ing 64 arbitrary bytes mid-stream. Whether that produces a *structural* deflate error is zlib-version dependent: on the macOS runner the mangled bytes still decoded, so the stream instead failed the trailing gzip CRC check and raised `BadGzipFile` -- an `OSError`, which the pre-fix `(TarError, OSError)` handler already caught. The guard test exists precisely to catch that degradation, and it did its job. Replaces the XOR with a deflate block header whose `BTYPE` is the reserved value `0b11`. Every zlib rejects that identically as "invalid block type", and it fails during decompression rather than at the CRC check, so no version can turn it into a `TarError` or `OSError`. The stream is assembled by hand (`compressobj(-15)` + explicit gzip header/trailer) so the invalid block lands a controlled 256 KiB into the first member's data -- past the gzip read buffer, so the first header still reads clean and the failure surfaces from the forward seek in `tarfile.next`, which is the site the raw `zlib.error` escapes from. A sweep over clean-prefix sizes confirms a wide margin: with 512 KiB members every prefix from 160 KiB up yields a bare `zlib.error`, versus the transition below ~131 KiB where `tarfile` still wraps it as `ReadError`. The hand-built gzip header also zeroes the mtime field, so the fixture is now byte-identical across builds instead of embedding a timestamp. Strengthens the guard to assert what the fix actually depends on -- that the exception is neither a `TarError` nor an `OSError` -- so the fixture cannot silently decay into an already-caught type again. Production code is unchanged from ef49acc; this is test-only. Verified test-the-test by dropping the `zlib.error` arm from `_TAR_DECOMPRESSION_ERRORS`: the three wrapping tests fail with the raw `zlib.error: Error -3 while decompressing data: invalid block type`, and pass with it restored. `tests/test_download_security.py`: 193 passed. `ruff check src tests` (the exact CI command): all checks passed. Assisted-by: Claude Opus 4.8 (1M context) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks — fixed in a0beea1. Lint was already green ( What broke. Exactly one test failed, on The other five pytest jobs were fail-fast cancellations, not real failures — and the preceding commit ( Why. That fixture built its corruption by XOR-ing 64 arbitrary bytes mid-stream. Whether that yields a structural deflate error turns out to be zlib-version dependent: on the macOS runner the mangled bytes still decoded successfully, so the stream instead failed the trailing gzip CRC check and raised The fix. Replace the XOR with a deflate block header whose A sweep over clean-prefix sizes confirms the margin is wide rather than balanced on a knife edge — with 512 KiB members, every prefix from 160 KiB up gives a bare I also strengthened the guard to assert what the fix actually depends on, rather than just the exception's name: assert not isinstance(excinfo.value, tarfile.TarError)
assert not isinstance(excinfo.value, OSError)That is the property that makes the Production code is unchanged from Re-verified test-the-test by dropping the |
|
Thank you! |
Problem
tarfilewraps most decompression failures inTarError, but a gzip stream that ends before its end-of-stream marker escapes as a bareEOFErrorfrom the gzip layer.EOFErrorderives from neitherTarErrornorOSError, so it bypassed all three tar handlers added with tar archive support (#3874):detect_archive_formatexcept tarfile.TarErrortarfile.openinsafe_extract_tarexcept (tarfile.TarError, OSError)safe_extract_tarexcept (tarfile.TarError, OSError)A truncated
.tar.gz— an interrupted download, a partially written file — raised a rawEOFErrorstraight through the caller'serror_type, so callers catchingValueError/ExtensionError/PresetErrornever saw it.In
specify workflow addthe effect is worse than a traceback. Typer treats a bareEOFErroras a Ctrl-D abort, so the whole diagnostic vanishes:The ZIP twin, given the same treatment, reports properly:
Fix
Route all three sites through a shared
_TAR_DECOMPRESSION_ERRORStuple so they stay in sync:zlib.erroris included alongsideEOFError: it is likewise neither aTarErrornor anOSError, and can surface from a corrupt deflate block.OSErroris deliberately kept only on the twosafe_extract_tarsites, which use it to report genuine I/O failures. Adding it to the probe would silently swallow those into "format mismatch" instead of the existing cleanInvalid archiveerror, so the probe catches the decompression tuple alone.After the fix, the tar path matches its ZIP twin:
and domain error types wrap correctly again:
Tests
Six regression tests in
tests/test_download_security.py.tarfiledecompresses lazily, so the leak surfaced at different sites depending on how much of the stream survived — the tests pin explicit byte counts to cover both:tarfile.openitself (covers the probe and the open site)Plus one test asserting the caller's
error_typeis honored, and one covering thesafe_extract_archiveentry point.Verified test-the-test: all 6 fail against unmodified
_download_security.py(DID NOT RAISE/ rawEOFError), all pass with the fix.The 27 failures are the pre-existing Windows symlink-elevation class (
OSError: [WinError 1314] A required privilege is not held by the client) — identical count and identity before and after this change on the same machine.🤖 Generated with Claude Code