fix(drive): treat unrecognised-mime "package" downloads as single-file bundles instead of failures - #461
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adjusts iCloud Drive “package” handling so that package downloads with unrecognised MIME types are preserved as usable single-file bundles (instead of being counted as failed downloads and repeatedly re-downloaded). It also adds a drive.flatten_packages configuration option and new tests to cover package extraction layout and flattening behavior.
Changes:
- Update
process_package()to treat unknown MIME types as “keep bytes as-is” (return the existing local path) and support an opt-inflattenmode to skip unpacking. - Update drive download flow to only treat explicit
Nonefromprocess_package()as a processing failure, and plumbflatten_packagesthrough the parallel-download task pipeline. - Add/adjust tests to codify the new contract and validate ZIP extraction layout behavior + config getter.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
src/drive_package_processing.py |
Extends package processing contract; adds flatten option; changes ZIP extraction layout heuristic. |
src/drive_file_download.py |
Updates download flow to pass flatten flag and interpret process_package() results correctly. |
src/drive_parallel_download.py |
Threads flatten_packages through download task info and into download_file(). |
src/config_parser.py |
Adds get_drive_flatten_packages() config getter and related formatting updates. |
tests/test_sync_drive.py |
Updates/extends tests for new “unknown MIME preserved” contract and correct patching location. |
tests/test_drive_package_bundle_layout.py |
New tests covering ZIP layout extraction behavior and the flatten-packages config knob. |
A package kept on disk as a flat single-file bundle (unrecognised-mime package, or flatten_packages=true) has an on-disk byte count equal to the package-download archive size, which never equals item.size (the package's logical size iCloud reports). file_exists() compares against item.size, sees a spurious size mismatch, and re-downloads the bundle on EVERY sync -- confirmed live on a real library (a 103 MB .key re-downloaded across consecutive syncs). Add package_bundle_unchanged(): when the item is a package and the on-disk bundle's mtime already matches the remote date_modified (which download_file stamps via os.utime, and iCloud bumps on any content change), the bytes are unchanged -- skip the re-download. Wired into both collect_file_for_download (active parallel path) and process_file (legacy). is_package() is already called in the outdated-file branch, so this adds no extra network round-trip. Completes the single-file-bundle handling from the package PR (mandarons#461 silenced the error log but left the re-download). 9 regression tests; 100% coverage. Co-Authored-By: Claude <noreply@anthropic.com>
iCloud Drive serves Apple iWork (.key, .pages, .numbers), JMG (.jmb),
and other "package" types as opaque archives. libmagic detects only
zip/gzip; everything else reports as application/octet-stream and the
old code path returned None from process_package(), which the caller
in drive_file_download.py treated as a hard download failure — even
though the bytes had ALREADY been written to disk and were perfectly
usable.
Result on Eric's library: every sync cycle re-downloaded ~300 MB of
.key + .jmb files, logged as "0 successful, 23 failed" — alarming
but not actually a data-loss bug, just wasted bandwidth + misleading
logs.
Fix:
- process_package() now returns the local_file path (truthy) when the
mime type isn't a recognised archive. The file is on disk in a
usable form; that IS the canonical local representation for these
bundle types. Logged at INFO ("Package format not recognised for
unpacking; keeping as single-file bundle") instead of ERROR.
- drive_file_download.py treats only ``None`` return as failure (was
treating any falsy value as failure).
Tests:
- Renames test_process_package_invalid_package_type →
test_process_package_unrecognised_mime_preserves_file with the new
contract.
- Adds test_download_file_keeps_unrecognised_package_as_single_file to
exercise the success path through download_file.
- Fixes the patch target in test_download_file_returns_none_on_processing_failure
(was patching the wrong module-level binding; happened to pass under
the old contract by coincidence).
NOT fixed in this PR: the next-sync dedup still triggers a re-download
because file_exists() compares getsize(local_file) to item.size, and
for flat bundles those differ (item.size is the unpacked contents
total). That requires either a sidecar metadata file or relaxing the
size comparator to fall back on mtime; out of scope for this focused
fix. Documented as a follow-up.
Co-Authored-By: Claude <noreply@anthropic.com>
Extends PR 11 with a fix for the FileExistsError seen when two iCloud
Drive packages share generic internal entry names (Apple iWork
.numbers / .pages / .key — all rooted at "Data/Document.iwa",
"Metadata/...", etc).
## The bug
``_process_zip_package`` called ``extractall(path=parent_dir)``, which
worked fine for genuine bundle zips like GarageBand's .band whose
entries are self-prefixed ("Project.band/...") but silently collided
for bare-rooted zips: two .numbers in the same iCloud folder both
extract "Data/Document.iwa" into the parent dir, and the second one
raises FileExistsError at the rename/extract step.
Real-world report: on a 0.7.3 deploy, mandarons logged ``Failed to
download Untitled 6.numbers: [Errno 17] File exists:
Untitled.numbers`` — the second .numbers couldn't land because the
first had already extracted bare entries into the shared parent.
## Fix
``_zip_entries_self_prefixed`` heuristic picks the right extraction
target:
- All entries under ``<bundle_basename>/`` → extract into parent
(preserves historical behaviour for .band-style bundles).
- Otherwise → extract into a bundle-named subdirectory of its own
(iWork-style bare-rooted zips, no sibling collisions).
## Plus a config knob: ``drive.flatten_packages``
For backup-style deployments (NAS, cold storage) where bundle-
directory semantics aren't valuable, ``drive.flatten_packages: true``
skips the unpack step entirely and keeps the package on disk as the
downloaded single binary file:
- One mtime+size per package → simpler dedup, easier round-trip
back to iCloud.
- No expansion to hundreds of internal files per bundle → way lower
inode footprint on NAS-style filesystems.
- No cross-bundle collisions period — the whole class of bugs goes
away regardless of internal zip layout.
Default False so existing installs are unaffected. Threaded through
``download_file → process_package`` via ``flatten_packages`` kwarg +
``flatten`` flag.
## Tests (10 new in test_drive_package_bundle_layout.py)
- Self-prefixed zip extracts into parent (no regression for .band).
- Bare-rooted zip extracts into bundle subdir.
- ``test_two_sibling_iwork_zips_dont_collide`` is the headline case —
reproduces Eric's exact error, confirms the fix.
- ``flatten=True`` preserves zip-detected packages as single files
(byte-identical, no extraction).
- ``flatten=True`` is a no-op for octet-stream packages too.
- Five config-getter cases (default, drive-absent, true, false,
None-config).
All 24 existing package + download tests still pass.
Co-Authored-By: Claude <noreply@anthropic.com>
…l too Gzip-wrapped bundles can produce inner ZIPs whose entries are prefixed with ../<basename>/ — Python zipfile sanitises the .. so the on-disk end-state is identical to a plain self-prefixed entry. The new heuristic was treating these as bare-rooted and extracting into a bundle subdir, breaking 4 existing test_sync_drive cases. Recognise both forms; extract into parent for either; keep the bare-rooted iWork path going to its own subdir. 581/581 tests pass after this fix. Co-Authored-By: Claude <noreply@anthropic.com>
…heck Upstream CI runs ruff. Combined the two startswith() calls in _zip_entries_self_prefixed into a single startswith((prefix, traversal_prefix)) per PIE810. Identical behaviour, cleaner code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…100%) The one uncovered line on this branch was the `if not names: return False` guard in _zip_entries_self_prefixed — exercised when a zip's only entry is the bare bundle directory itself with no files inside. Covers it with a tiny in-memory zip and asserts False. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ck user-zip invariant
Addresses all 3 Copilot review points on this PR, plus an explicit
defence-in-depth check Eric flagged ("are we unzipping only Apple
packages or any zip file?"):
1. **Zip Slip (CWE-22) defence.** ``_safe_extractall`` wraps
``ZipFile.extractall`` with a per-member ``os.path.realpath`` +
``os.path.commonpath`` check. Any entry whose final target
resolves outside the configured safety boundary is refused and
logged at WARNING; the rest of the zip still extracts. CloudKit-
served bytes are technically untrusted -- an attacker-controlled
shared package, or a future CloudKit compromise, could otherwise
write outside the destination via absolute paths or unbounded
``..`` traversal.
The safety boundary is layout-specific:
- ``self`` (entries are ``<bundle>/...``): extract_dir = parent_dir; boundary = parent_dir.
- ``traversal`` (entries are ``../<bundle>/...``): extract_dir = parent_dir; boundary = grandparent_dir (the dir ``..`` lands in).
- ``None`` (bare-rooted): extract_dir = bundle subdir; boundary = bundle subdir.
``_zip_entries_self_prefixed`` now returns ``"self" | "traversal" | None``
so ``_process_zip_package`` can pick the right boundary.
2. **Flatten mode skips libmagic entirely.** Moved the ``flatten``
early-return ABOVE the ``magic.from_file`` call. Flatten doesn't
need MIME detection, and libmagic can fail on truncated or unusual
downloads where we still want to keep the bytes on disk.
3. **Direct call instead of ``getattr(..., lambda...)``.** The
``get_drive_flatten_packages`` config getter is unconditionally
present once this PR lands, so the defensive ``getattr`` wrapper
was just noise.
4. **User-zip invariant test.** Eric's question -- "are we unzipping
only Apple packages, or any zip file?" -- pointed at a subtle
safety property. The architecture: Apple's CloudKit returns
``data_token`` URLs for regular files (any ``.zip`` a user uploads)
and ``package_token`` URLs for Apple bundle formats only
(``.key``, ``.pages``, ``.numbers``, ``.band``, etc -- files that
look like single files in Finder but are actually directories).
The gate in ``drive_file_download.py`` ONLY routes to
``process_package`` when ``/packageDownload?`` is in
``response.url``, so user-uploaded zips are never touched.
``TestProcessPackageNeverTouchesRegularUserZips`` audits the call
graph: ``process_package`` has exactly one external caller and
that caller has the URL-substring gate. If a future refactor
removes the gate, this test fails.
Tests:
- TestZipSlipDefence (3 tests) -- absolute-path entry blocked,
traversal escape blocked, benign traversal at widened boundary
not blocked.
- TestZipEntriesSelfPrefixedEdgeCases expanded to cover the new
``self`` / ``traversal`` / ``None`` return values explicitly.
- TestProcessPackageNeverTouchesRegularUserZips -- 1 audit test.
Verified on python:3.10 docker mirroring CI: ruff clean, 454 passed,
100.00% coverage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
69c68e9 to
a57c868
Compare
A package kept on disk as a flat single-file bundle (unrecognised-mime package, or flatten_packages=true) has an on-disk byte count equal to the package-download archive size, which never equals item.size (the package's logical size iCloud reports). file_exists() compares against item.size, sees a spurious size mismatch, and re-downloads the bundle on EVERY sync -- confirmed live on a real library (a 103 MB .key re-downloaded across consecutive syncs). Add package_bundle_unchanged(): when the item is a package and the on-disk bundle's mtime already matches the remote date_modified (which download_file stamps via os.utime, and iCloud bumps on any content change), the bytes are unchanged -- skip the re-download. Wired into both collect_file_for_download (active parallel path) and process_file (legacy). is_package() is already called in the outdated-file branch, so this adds no extra network round-trip. Completes the single-file-bundle handling from the package PR (mandarons#461 silenced the error log but left the re-download). 9 regression tests; 100% coverage. Co-Authored-By: Claude <noreply@anthropic.com>
|
/opencode review this code. |
|
Here's my review of the PR: Overall AssessmentThe PR addresses a real problem (false failures on unrecognised mime types) and the core fix is correct. However, there are several issues worth addressing. Critical Issues1. Zip Slip protection is incomplete — CWE-22 still exploitable
Additionally, the Recommendation: Replace 2.
|
…ns#461) Addresses the actionable items from the opencode review; the flagged Zip Slip / CWE-22 (finding 1) is a false positive and is left as-is: - Zip Slip: empirically verified Python's zipfile.extractall sanitises '..' and absolute paths (a '../../../tmp/x' entry lands INSIDE the extract dir, not /tmp) and never creates symlinks (a symlink entry extracts as a regular file). The classic traversal + symlink-through vectors do not apply; the existing _safe_extractall guard is redundant defence-in-depth, not a hole. No per-member rewrite needed. - Recursion depth (finding 3, REAL): gzip streams carry no depth signal, so a crafted gzip-of-gzip chain could recurse to RecursionError. Add _MAX_PACKAGE_DEPTH ceiling threaded through _process_gzip_package. - Config cleanup (finding 5): get_drive_flatten_packages(None) already returns False, so drop the redundant 'if config else False' guard. Tests: direct depth-guard branch + a >ceiling nested-gzip chain that returns instead of crashing. Full suite 538 passed, 100%. Co-Authored-By: Claude <noreply@anthropic.com>
|
Went through the opencode findings: Zip Slip / CWE-22 (1) — verified false positive, left extraction as-is. Python's Recursion depth (3) — fixed. gzip carries no depth signal, so a gzip-of-gzip chain could hit RecursionError. Added a Config guard (5) — fixed. 2 and 6 are backstopped / known follow-ups. Full suite green, 100%. |
…ns#461) Addresses the actionable items from the opencode review; the flagged Zip Slip / CWE-22 (finding 1) is a false positive and is left as-is: - Zip Slip: empirically verified Python's zipfile.extractall sanitises '..' and absolute paths (a '../../../tmp/x' entry lands INSIDE the extract dir, not /tmp) and never creates symlinks (a symlink entry extracts as a regular file). The classic traversal + symlink-through vectors do not apply; the existing _safe_extractall guard is redundant defence-in-depth, not a hole. No per-member rewrite needed. - Recursion depth (finding 3, REAL): gzip streams carry no depth signal, so a crafted gzip-of-gzip chain could recurse to RecursionError. Add _MAX_PACKAGE_DEPTH ceiling threaded through _process_gzip_package. - Config cleanup (finding 5): get_drive_flatten_packages(None) already returns False, so drop the redundant 'if config else False' guard. Tests: direct depth-guard branch + a >ceiling nested-gzip chain that returns instead of crashing. Full suite 538 passed, 100%. Co-Authored-By: Claude <noreply@anthropic.com>
…ingle-file-bundles # Conflicts: # src/config_parser.py

Summary
Stops the false "0 successful, N failed" log noise and
per-sync-cycle re-download for iCloud Drive package files that
libmagic can't identify as archives (Apple iWork .key/.pages/.numbers,
third-party .jmb, etc).
Problem
iCloud Drive serves "package" files (Finder bundle types: .band, .key,
.jmb, etc.) via
/packageDownload?URLs as opaque archive bytes.process_package()only recognisesapplication/zipandapplication/gzipmime types. Anything else falls through toreturn None, which the caller atdrive_file_download.py:64interprets as a hard download failure — even though the bytes have
ALREADY been written to disk and are perfectly usable.
Result: parallel-download log says
0 successful, N failed, andbecause the file is marked as "not-done" in bookkeeping, every
subsequent sync cycle re-downloads it.
Fix
process_package()returns the local_file path on unrecognisedmime instead of
None. The bytes ARE the canonical localrepresentation for these bundle types — the file opens in
Keynote/Pages/etc directly.
for unpacking; keeping as single-file bundle: {path}"
download_file()only treats explicitNoneas failure (wastreating any falsy value as failure).
Validation
test_download_file_keeps_unrecognised_package_as_single_fileexercises the new success path.
test_process_package_unrecognised_mime_preserves_filedocuments the new contract.
test_download_file_returns_none_on_processing_failuretopatch the correct module-level binding (was patching wrong path
but happened to pass on the old contract).
before this PR every sync re-downloaded ~300 MB of these files
and logged ~50 ERROR lines per cycle. After: files preserved,
logs clean.
Known follow-up
The next-sync dedup may still trigger a re-download because
file_exists()comparesgetsize(local_file)toitem.size, andfor flat bundles those differ (
item.sizeis the unpacked contentstotal). A sidecar-marker or mtime-fallback comparator would close
the loop. Out of scope for this focused fix.