Skip to content

fix(archives): wrap the bare EOFError a truncated tar.gz raises - #3938

Open
Noor-ul-ain001 wants to merge 1 commit into
github:mainfrom
Noor-ul-ain001:fix/tar-truncation-eoferror
Open

fix(archives): wrap the bare EOFError a truncated tar.gz raises#3938
Noor-ul-ain001 wants to merge 1 commit into
github:mainfrom
Noor-ul-ain001:fix/tar-truncation-eoferror

Conversation

@Noor-ul-ain001

Copy link
Copy Markdown
Contributor

Problem

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 tar handlers added with tar archive support (#3874):

Site Handler before
format probe in detect_archive_format except tarfile.TarError
tarfile.open in safe_extract_tar except (tarfile.TarError, OSError)
member iteration in safe_extract_tar except (tarfile.TarError, OSError)

A truncated .tar.gz — an interrupted download, a partially written file — 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 whole diagnostic vanishes:

$ specify workflow add ./pkg.tar.gz      # truncated

Aborted.

The ZIP twin, given the same treatment, reports properly:

$ specify workflow add ./pkg.zip         # truncated
Error: Invalid workflow archive: Invalid ZIP archive: /tmp/pkg.zip

Fix

Route all three sites through a shared _TAR_DECOMPRESSION_ERRORS tuple so they stay in sync:

_TAR_DECOMPRESSION_ERRORS = (tarfile.TarError, EOFError, zlib.error)

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 deliberately kept only on the two safe_extract_tar sites, 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 clean Invalid archive error, so the probe catches the decompression tuple alone.

After the fix, the tar path matches its ZIP twin:

$ specify workflow add ./pkg.tar.gz      # truncated
Error: Invalid workflow archive: Archive format mismatch: expected tar.gz, got
invalid/unsupported data

and domain error types wrap correctly again:

ExtensionError -> Invalid tar.gz archive: /tmp/...
PresetError    -> Invalid tar.gz archive: /tmp/...

Tests

Six regression tests in tests/test_download_security.py. tarfile decompresses 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:

  • 64 bytes — fails inside tarfile.open itself (covers the probe and the open site)
  • 512 / 2048 bytes — opens fine, fails during member iteration

Plus one test asserting the caller's error_type is honored, and one covering the safe_extract_archive entry point.

Verified test-the-test: all 6 fail against unmodified _download_security.py (DID NOT RAISE / raw EOFError), all pass with the fix.

tests/test_download_security.py    188 passed
tests/test_extensions.py           431 passed, 8 skipped
tests/test_workflows.py + test_presets.py   1403 passed, 27 failed

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

`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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Wraps tar/gzip decompression failures in caller-defined domain errors.

Changes:

  • Adds shared handling for TarError, EOFError, and zlib.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

#: mid-member escapes as a bare ``EOFError`` from the gzip layer, and a corrupt
#: deflate block can surface as ``zlib.error``. Neither derives from
#: ``TarError`` or ``OSError``, so both bypass a ``(TarError, OSError)`` handler.
_TAR_DECOMPRESSION_ERRORS = (tarfile.TarError, EOFError, zlib.error)
@mnriem

mnriem commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

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.

3 participants