fix(bundle): wrap local .zip errors in BundlerError instead of raw crash - #3713
fix(bundle): wrap local .zip errors in BundlerError instead of raw crash#3713jawwad-ali wants to merge 4 commits into
Conversation
_local_manifest_source's .zip branch called zipfile.ZipFile() and yaml.safe_load() with no error handling. A .zip-suffixed file that is not a valid archive raised a raw zipfile.BadZipFile, and a valid zip with a malformed embedded bundle.yml raised a raw yaml.YAMLError. bundle_install only does `except BundlerError`, so both escaped to an unhandled traceback instead of the intended clean `Error: ...` + exit 1. Wrap both failure modes in BundlerError, mirroring the remote-download sibling (_download_remote_manifest already wraps its _local_manifest_source call for exactly this). The directory and bundle.yml branches already report cleanly via BundleManifest.from_file -> load_yaml; this aligns the .zip branch. Valid archives are unaffected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Hardens local bundle ZIP parsing by translating archive and YAML failures into BundlerError.
Changes:
- Wraps invalid ZIP and malformed YAML errors.
- Adds regression tests for both cases.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/commands/bundle/__init__.py |
Adds error translation for local ZIP manifests. |
tests/integration/test_bundler_local_install.py |
Tests corrupt ZIP and malformed YAML handling. |
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: Medium
Address review feedback: ZipFile() validates only the central directory, so a
corrupt MEMBER still failed after the open and escaped bundle_install's
`except BundlerError` as a traceback.
Verified both failure modes on a directory-intact archive: a flipped byte in a
stored member raises zipfile.BadZipFile("Bad CRC-32 for file 'bundle.yml'"), and
a mangled deflate stream raises zlib.error. Neither is an OSError subclass, so
the read boundary lists BadZipFile/OSError/zlib.error/EOFError explicitly (the
review named BadZipFile; zlib.error is the same class of corruption and would
have escaped an OSError-only clause).
Valid archives are unaffected.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Confirmed and fixed in 6d5f8d2 — you're right that I reproduced both corruption modes on a directory-intact archive:
Since neither is an |
…ied text
Self-review follow-up: the exception sets were still incomplete, so a raw
exception could escape bundle_install's `except BundlerError` after all.
Verified against CPython's zipfile:
read() RuntimeError -- password-protected artifact (`zip -e`)
NotImplementedError -- unsupported compression method
open() NotImplementedError -- unsupported zip version
ValueError
None are OSError subclasses, so each is now listed explicitly.
Also escape the interpolated exception text: zipfile embeds bytes taken FROM
THE ARCHIVE in some messages -- BadZipFile("File name in directory 'bundle.yml'
and header b'[/red]abcd' differ.") -- and that text reaches the user through
_fail(), which renders Rich markup. Unescaped, a crafted member name raised
MarkupError instead of reporting the corruption. The user's own typed path is
left as-is, matching the other messages in this module.
Three new tests (encrypted, unsupported compression, crafted member name) all
fail before this commit.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (2)
src/specify_cli/commands/bundle/init.py:764
- This path is rendered as Rich markup by
_fail(), so a valid archive with no manifest and a path containing markup (such as[/red].zip) can still crash withMarkupErrorinstead of returning the intendedBundlerError. Escape the interpolated path.
raise BundlerError(
f"Artifact '{candidate}' does not contain a bundle.yml."
src/specify_cli/commands/bundle/init.py:777
- The malformed-YAML error still inserts the archive path into Rich markup without escaping it. A path containing an unmatched tag can therefore turn this handled
YAMLErrorback into an unhandledMarkupError; escapecandidatehere as well.
raise BundlerError(
f"Artifact '{candidate}' contains an invalid bundle.yml: "
f"{_escape_markup(str(exc))}"
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Medium
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback
Address review feedback: the path is interpolated into the same _fail()-rendered messages as the exception text, so it needs the same escaping. A file reachable as `.../[/red].zip` -- a directory named `[` -- puts an unmatched closing tag in the message and raises MarkupError instead of reporting the corruption (confirmed: rendering that exact string raises "closing tag '[/red]' ... doesn't match any open tag"; escaping it renders fine). Escape once into `safe_path` and use it at all three sites. Note the vector is POSIX-specific in practice -- Windows Path() normalizes the forward slash to a backslash, which is inert markup -- so the new test builds the POSIX-style path explicitly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Right again — fixed in aedb9e2. Confirmed the vector: rendering The path is now escaped once into |
Problem
The local
.zipbranch of_local_manifest_sourceopens the archive and parses its embeddedbundle.ymlwith no error handling:.zip-suffixed file that isn't a valid archive →zipfile.BadZipFile.bundle.ymlis malformed →yaml.YAMLError.Neither is a
BundlerError, andbundle_installonly doesexcept BundlerError, so both escape to an unhandled traceback instead of the intended cleanError: …+ exit 1. Reproduced both onmain(4d3a428).This is a parity gap: the directory and
bundle.ymlbranches go throughBundleManifest.from_file→load_yaml, which wraps failures inBundlerError; and the remote sibling_download_remote_manifestexplicitly wraps its_local_manifest_sourcecall intry/except → BundlerError. Only the direct local.zipbranch is unguarded.Fix
Wrap both failure modes in
BundlerError(hardening the source function, so every caller benefits):Valid archives are unaffected — no behaviour change for correct input.
Verification
test_local_source_corrupt_zip_raises_clean_errorandtest_local_source_zip_with_malformed_bundle_yml_raises_clean_error: fail before (rawBadZipFile/YAMLError), pass after (cleanBundlerError).test_bundler_local_install.py: 12 passed.ruffclean.AI-assisted: authored with Claude Code. I reproduced both raw crashes on
mainvia a direct_local_manifest_sourcecall and mirrored the remote-download path's existing error wrapping.