Skip to content

fix(drive): treat unrecognised-mime "package" downloads as single-file bundles instead of failures - #461

Open
epheterson wants to merge 9 commits into
mandarons:mainfrom
epheterson:fix/drive-package-single-file-bundles
Open

fix(drive): treat unrecognised-mime "package" downloads as single-file bundles instead of failures#461
epheterson wants to merge 9 commits into
mandarons:mainfrom
epheterson:fix/drive-package-single-file-bundles

Conversation

@epheterson

Copy link
Copy Markdown
Contributor

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 recognises application/zip and
application/gzip mime types. Anything else falls through to
return None, which the caller at drive_file_download.py:64
interprets 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, and
because 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 unrecognised
    mime instead of None. The bytes ARE the canonical local
    representation for these bundle types — the file opens in
    Keynote/Pages/etc directly.
  • Log level drops from ERROR to INFO: "Package format not recognised
    for unpacking; keeping as single-file bundle: {path}"
  • download_file() only treats explicit None as failure (was
    treating any falsy value as failure).

Validation

  • New test test_download_file_keeps_unrecognised_package_as_single_file
    exercises the new success path.
  • Renamed test test_process_package_unrecognised_mime_preserves_file
    documents the new contract.
  • Fixed test_download_file_returns_none_on_processing_failure to
    patch the correct module-level binding (was patching wrong path
    but happened to pass on the old contract).
  • Real-world: Eric's 111K-photo library + 40 .jmb / .key files —
    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() compares getsize(local_file) to item.size, and
for flat bundles those differ (item.size is the unpacked contents
total). A sidecar-marker or mtime-fallback comparator would close
the loop. Out of scope for this focused fix.

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

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-in flatten mode to skip unpacking.
  • Update drive download flow to only treat explicit None from process_package() as a processing failure, and plumb flatten_packages through 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.

Comment thread src/drive_package_processing.py Outdated
Comment thread src/drive_package_processing.py
Comment thread src/drive_parallel_download.py Outdated
Comment thread src/drive_package_processing.py Outdated
Comment thread src/drive_package_processing.py
Comment thread src/drive_parallel_download.py Outdated
epheterson added a commit to epheterson/icloud-docker that referenced this pull request Jun 4, 2026
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>
epheterson and others added 6 commits July 20, 2026 23:06
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>
@epheterson
epheterson force-pushed the fix/drive-package-single-file-bundles branch from 69c68e9 to a57c868 Compare July 21, 2026 07:50
epheterson added a commit to epheterson/icloud-docker that referenced this pull request Jul 21, 2026
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>
@mandarons

Copy link
Copy Markdown
Owner

/opencode review this code.

@opencode-agent

Copy link
Copy Markdown

Here's my review of the PR:

Overall Assessment

The 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 Issues

1. Zip Slip protection is incomplete — CWE-22 still exploitable

_safe_extractall (drive_package_processing.py:135-180) validates paths but still delegates to extractall. A zip member with a symlink entry can bypass the realpath check at extraction time, then be followed by a subsequent write to overwrite arbitrary files. The safer pattern is to extract per-member with explicit file writes, or use zipfile.Path.

Additionally, the traversal layout handler (line 214) widens safety_boundary to os.path.dirname(parent_dir), which means a crafted zip with ../../etc/passwd entries would pass the check and write to grandparent's grandparent. The test at test_drive_package_bundle_layout.py:351-384 asserts the bypass works (blocked == []), treating it as correct behavior. This is dangerous — the legitimate ../<bundle>/ pattern should be handled by renormalizing paths, not by widening the safety net.

Recommendation: Replace _safe_extractall + extractall with per-member extraction that normalizes each path and writes explicitly. For the traversal layout, strip the ../ prefix rather than widening the boundary.

2. _zip_entries_self_prefixed trusts the zip's stated filenames

The heuristic at drive_package_processing.py:122-132 checks all(n.startswith(prefix) for n in names) where names comes from zf.namelist(). A malicious zip can contain entries with arbitrary prefixes while still having a bundle_basename/ entry. The check should also verify the bundle directory entry itself exists.

3. Recursive process_package call on gzip has no depth limit

_process_gzip_package (line 257) recursively calls process_package(local_file=local_file). If a gzip stream contains another gzip stream (or a zip), this recurses. There's no depth guard — a crafted chain could cause a RecursionError. Add a max_depth parameter or iteration limit.


Medium Issues

4. flatten early-return should come before magic.Magic() instantiation

The Copilot review noted this — when flatten=True, libmagic detection at line 64-65 is wasted work. The current code already handles this correctly (the early return is at line 53, before line 64), but the magic_object instantiation on line 64 is still executed when flatten=False for the non-zip/gzip path. This is fine but worth noting for clarity.

5. getattr pattern in drive_parallel_download.py:68-72 is fragile

flatten_packages = (
    bool(config_parser.get_drive_flatten_packages(config))
    if config
    else False
)

The Copilot review suggests calling get_drive_flatten_packages(config) directly since it handles None. The current code is functionally correct but unnecessarily verbose — get_drive_flatten_packages(None) returns False, so the if config guard is redundant.

6. Unicode normalization inconsistency

_process_zip_package normalizes to NFD (line 226), while collect_file_for_download normalizes to NFC (line 59). This means a file collected as NFC could be renamed to NFD during unpacking. Downstream file_exists checks may then fail to match. The PR description acknowledges a known follow-up for dedup, but this NFD/NFC mismatch should be resolved within the PR — it's a regression vector.

7. _safe_extractall uses os.rename for NFD but os.remove for cleanup

Line 228-229 renames for normalization, line 231 removes the archive. If the rename fails (e.g., target exists), the archive is still removed. The os.rename should have error handling or use os.replace.


Low Issues / Nits

8. Docstring on process_package mentions JMG but the mime comment says JMB

Lines 46-47 mention .jmb but the comment on line 74 says "JMG .jmb" — pick one. Minor inconsistency.

9. TestProcessPackageNeverTouchesRegularUserZips uses source scanning for invariant

The test at line 430-463 scans src/*.py for callers of process_package. This is brittle — a refactor that moves the URL gate to a helper function would break the test without breaking the invariant. Consider documenting this as a known fragility, or switch to a runtime assertion.

10. Tests use assert statements outside test methods

test_drive_package_bundle_layout.py:199-205 uses bare assert inside with blocks rather than self.assertEqual. While functionally equivalent, it breaks the unittest convention and provides worse failure messages.


Test Quality

The test suite is thorough. The following are well-done:

  • _safe_extractall Zip Slip tests cover absolute paths, .. traversal, and boundary widening
  • The sibling iWork collision test (test_two_sibling_iwork_zips_dont_collide) directly validates the real-world bug
  • test_url_gate_is_the_only_caller locks down the process_package call site invariant
  • test_download_file_returns_none_on_processing_failure now patches the correct module binding

One gap: there's no test for the recursive gzip→zip case (gzip wrapping a zip, which recurses). Consider adding one to guard against stack overflow.

New%20session%20-%202026-07-22T00%3A34%3A49.488Z
opencode session  |  github run

@mandarons mandarons added this to the v1.28.0 milestone Jul 22, 2026
…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>
@epheterson

Copy link
Copy Markdown
Contributor Author

Went through the opencode findings:

Zip Slip / CWE-22 (1) — verified false positive, left extraction as-is. Python's ZipFile.extractall sanitises entries: a ../../../tmp/x entry lands inside the extract dir, not /tmp, and a symlink-mode entry extracts as a regular file, not a symlink — so neither the traversal nor symlink-through vector applies. _safe_extractall is redundant defence-in-depth, not a hole. (Can simplify the boundary logic in a follow-up since Python's sanitisation makes it moot, but it's not a vuln.)

Recursion depth (3) — fixed. gzip carries no depth signal, so a gzip-of-gzip chain could hit RecursionError. Added a _MAX_PACKAGE_DEPTH ceiling threaded through the gzip path, with a test that a >ceiling chain returns instead of crashing.

Config guard (5) — fixed. get_drive_flatten_packages(None) already returns False, so dropped the redundant if config.

2 and 6 are backstopped / known follow-ups. Full suite green, 100%.

epheterson added a commit to epheterson/icloud-docker that referenced this pull request Jul 22, 2026
…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
@mandarons mandarons removed this from the v1.29.0 milestone Jul 27, 2026
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