Skip to content

fix(storage): bound Local recursive directory walks - #102

Open
loks0n wants to merge 1 commit into
mainfrom
fix/storage-bounded-walks
Open

fix(storage): bound Local recursive directory walks#102
loks0n wants to merge 1 commit into
mainfrom
fix/storage-bounded-walks

Conversation

@loks0n

@loks0n loks0n commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Closes CLO-4269 (remaining scope — the path-concat half shipped in storage/3.1.0).

Problem

Local::getDirectorySize(), Local::delete($path, recursive: true) and Local::deletePath() each recursed through a directory tree with no iteration or time budget. On a multi-TB tenant directory a single call pins a PHP worker for minutes, which makes usage-accounting and admin endpoints a DoS vector.

Change

All three route through one lazy walk() helper over RecursiveIteratorIterator, capped at maxEntries — default 100,000, settable on the constructor so background jobs can raise it (new Local($root, maxEntries: PHP_INT_MAX)). Past the cap the walk throws a StorageException naming the directory, instead of running to completion.

Collapsing three hand-rolled recursions into the shared helper removes more code than it adds: deletePath() becomes a resolve-then-delete() one-liner, and getDirectorySize() loses its opendir/readdir loop.

listFiles() keeps its own traversal on purpose — it already paginates, so bounding it would change documented API behaviour rather than fix an unbounded walk.

Notes

  • getDirectorySize() returns -1 for a non-directory as before; the empty-path guard is unchanged.
  • Recursive delete now removes a symlink to a directory with unlink rather than rmdir. Descent behaviour is otherwise unchanged from the scandir version.

Testing

  • bin/monorepo check storage — pint, PHPStan, Rector clean
  • unit tier: 81 tests, 276 assertions green (new test: a walk with maxEntries: 1 over a two-file directory throws)
  • e2e tier against the package's MinIO compose service: 27 tests green
  • vale packages/storage/README.md clean

🤖 Generated with Claude Code

`getDirectorySize()`, `delete($path, recursive: true)` and `deletePath()`
each walked a tree with no iteration or time budget, so a multi-TB tenant
directory held a request worker for minutes — a DoS vector on usage
accounting and admin endpoints.

All three now route through one lazy `walk()` helper over
`RecursiveIteratorIterator`, capped at `maxEntries` (default 100k,
settable on the constructor so background jobs can raise it). Past the
cap the walk throws a `StorageException` naming the directory instead of
running to completion. Collapsing the three hand-rolled recursions into
the shared helper removes more code than it adds.

`listFiles()` keeps its own traversal: it already paginates, so bounding
it would change documented API behaviour rather than fix a walk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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