Skip to content

fix(viewer): stream ZIP fallback in playground - #90

Merged
erseco merged 5 commits into
mainfrom
fix/zip-entry-stream-fallback
Jul 28, 2026
Merged

fix(viewer): stream ZIP fallback in playground#90
erseco merged 5 commits into
mainfrom
fix/zip-entry-stream-fallback

Conversation

@erseco

@erseco erseco commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Problem

ZipEntryService::readEntry() falls back to the portable stream reader
(readEntryFromStream()) only when getLocalFile() returns false or
null. In practice, several storage backends return neither of those:

  • S3 / object storage and other non-local primary storages can
    return an empty string instead of false/null.
  • Server-side encryption wrappers can return a path that exists but
    that native ZipArchive::open() still refuses to open.
  • php-wasm (WordPress/Nextcloud Playground) exposes a nominal local
    path backed by a virtual filesystem; ZipArchive cannot open it even
    though the path "looks" local.

In all three cases, readEntry() returned null, and AssetController
answered with a 404 "Entry not found" — the viewer stayed blank with no
indication of what went wrong, even though the file was perfectly
readable through File::fopen().

Fix

  • Broaden the local-path guard from === false || === null to
    !is_string() || === "", so any non-usable "local path" value routes
    through the stream fallback.
  • When a local path is present but ZipArchive::open() still fails to
    open it, retry through readEntryFromStream() instead of returning
    null. readEntryFromStream() already existed and is exercised by
    the plain no-local-path case; it was simply unreachable once a
    (bogus) local path was reported.

Hardening (from review)

  • The stream fallback never enforced MAX_UNCOMPRESSED_SIZE_BYTES, a
    gap this PR would have widened to every ZipArchive::open() failure.
    Entry extraction now lives in a shared extractEntry() that both the
    local-path branch and the stream fallback call, so they enforce
    identical limits (MAX_ENTRIES, MAX_UNCOMPRESSED_SIZE_BYTES) and
    agree on returning null for missing entries.
  • The read itself is bounded instead of checking strlen() after
    loading the whole entry: the declared size from statName() is
    validated against the limit first, then getFromName() reads at most
    declared size + 1 bytes. The bound is the declared size rather than
    the 500MB limit because getFromName() preallocates its entire
    $len buffer up front — bounding by the limit would allocate 500MB
    per request regardless of entry size. The extra byte exposes archives
    whose central directory understates the real entry size (rejected
    with a RuntimeException).
  • The limits are constructor-injectable, defaulting to the class
    constants, so tests can exercise them with small archives. Nextcloud's
    DI autowires scalar parameters with defaults, so production wiring is
    unchanged.

Testing

  • The fake File used by the tests was rebuilt (review finding):
    OCP\Files\File is an interface in all supported Nextcloud
    versions, so the previous class_exists() guard never detected it and
    the eval'd class would collide with the real API outside the
    standalone bootstrap. bootstrap-standalone.php now stubs a minimal
    File interface (guarded by interface_exists(), methods untyped as
    in the real API) and the tests build the double with createMock().
  • testReadEntryFallsBackToStreamWhenLocalPathCannotBeOpened() simulates
    a storage whose getLocalFile() returns a path ZipArchive cannot
    open (the php-wasm Playground case), and asserts readEntry() still
    returns the correct entry contents via the stream path.
  • testReadEntryFallsBackToStreamWhenLocalFileIsEmptyString() covers the
    S3/object-storage case that originally motivated this fix:
    getLocalFile() returning "" rather than false/null.
  • New limit tests: the uncompressed-size limit is asserted through both
    branches (stream fallback and local path — the latter previously had
    no coverage at all), the entry-count limit is asserted through the
    fallback, and a zero-byte entry still reads as '' (the bounded-read
    edge where getFromName() is asked for a single byte).

Full unit suite (15 tests) plus php-cs-fixer are clean locally; CI is
green across stable31–33 / PHP 8.2–8.5.

Follow-up

listEntries() still uses the old === false || === null guard and has
no stream fallback at all. It has no callers today, so this PR leaves it
untouched rather than changing behavior nothing exercises — but the same
class of storage backend could hit the same blank-result failure mode if
a caller shows up. Worth a follow-up issue if/when listEntries() gets a
real caller.

erseco added 2 commits July 28, 2026 15:36
Add a test for the S3/object-storage case that motivated widening the
readEntry() guard: getLocalFile() returning an empty string rather than
false/null. Extract the archive/fake-File setup shared with the existing
playground test into two small helpers, and drop the class doc comment's
now-stale claim that the reading methods are too heavy to fake here.
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Preview this PR in the Nextcloud Playground

Open this PR in the Nextcloud Playground

A fresh Nextcloud boots in your browser with this branch's exelearning app installed and enabled (log in as admin / admin). Two sample .elpx are seeded under exelearning-samples/ in Files — click one to open the viewer.

eXeLearning editor: v4.0.2 (overlaid at boot from the upstream release).

erseco added 3 commits July 28, 2026 19:54
OCP\Files\File is an interface in all supported Nextcloud versions, so
the previous class_exists() guard never detected it and the eval()'d
class would collide with the real API outside the standalone bootstrap.
Stub the interface in bootstrap-standalone.php (guarded by
interface_exists) and build the fake file with createMock() instead.
The File::fopen() fallback returned entries without applying
MAX_UNCOMPRESSED_SIZE_BYTES, and widening the fallback to every
ZipArchive::open() failure widened that gap too. Extract the entry read
into a shared extractEntry() so the local-path and stream branches
enforce identical limits and agree on missing entries.

The read is bounded by the declared entry size (validated against the
limit first) rather than the limit itself: getFromName() preallocates
its whole $len buffer, so passing the 500MB limit would allocate 500MB
per request. The extra byte past the declared size exposes archives
whose central directory understates the real entry size.

The limits are constructor-injectable (defaulting to the class
constants) so tests can exercise them with small archives.
The real OCP\Files\File declares fopen($mode) with the type only in
the docblock, and the stub's own comment already promised untyped
methods.
@erseco
erseco merged commit 2b799df into main Jul 28, 2026
14 checks passed
@erseco
erseco deleted the fix/zip-entry-stream-fallback branch July 28, 2026 23:13
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.

1 participant