Skip to content

Make the seams that closed say so - #43

Merged
jakejackson1 merged 1 commit into
mainfrom
fix/final-entry-points
Aug 25, 2026
Merged

Make the seams that closed say so#43
jakejackson1 merged 1 commit into
mainfrom
fix/final-entry-points

Conversation

@jakejackson1

Copy link
Copy Markdown
Member

File::isValid() stopped being an extension seam in 4.0.0 and said nothing. Two independent silent breaks:

  1. The override stops being called. upload() routes through the private runValidations().
  2. The property it wrote to is gone. protected $errors became private $errorDetails, so $this->errors[] = $message lands on a dynamic property nothing reads — and before PHP 8.2, without so much as a deprecation.

A subclass loosening a check fails safe. One adding a check — a virus scan, a tenant quota, a stricter name rule — has it bypassed on every upload, on a release whose headline is security.

What changed

final on isValid(), upload() and uploadValid(). The three share the reset-then-validate sequence, the re-entrancy lock and the error count that decides which files passed, so a partial override breaks one of them. prepareUpload() and store() are private either way, so an override could only ever have wrapped these — which is what beforeUpload/afterUpload are for. A check of your own belongs in a ValidationInterface, which all three run.

The error list is guarded on all four routes to it, because each is silent on its own:

Route Caught by
$this->errors = [...] __set()
$this->errors[] = $message — an append is a read __get()
empty($this->errors) — answered true for a collection that rejected every file __isset()
class Mine extends File { protected $errors = []; } a construction-time check in init()

The magic methods run in File's scope, so the guard covers the rest of what the class declares private$running above all, which a bare fallback would have let a subclass take from inside a callback. No magic method can see a property the subclass declares, and a static never dispatches to one at all, which is why init() carries the fifth name: protected static $errorCodeMessages, now getUploadErrorMessages().

UPGRADE.md gets a removed-seams table. §10 had the facts as prose; a migration is read in two columns. The rows that did not move are half the value — FileInfo::isUploadedFile() and getReservedWindowsNames() still hold, FileSystem::resolveFilename() still names the file but no longer refuses one, and FileInfo::sanitizeName() is a seam setExtension() re-fits behind. FileTest pins the table by reflection, in the idiom testReadmeDocumentsTheDefaultDenyList() already uses, so it cannot drift the way the 4.0.0 docs did.

Compatibility

Breaking for a File subclass that overrode one of the three entry points or touched the error list — which is the point: each was already broken and now says so, at load or at construction rather than never. CHANGELOG.md and UPGRADE.md cover both.

Checks

phpunit (716 tests, 1,436 assertions), phpstan (level 9), lint, check-syntax, psr7-readme and base64-docs all pass. i18n:pot regenerates with no diff — the new exception messages are developer-facing and unmarked.

🤖 Generated with Claude Code

@jakejackson1
jakejackson1 force-pushed the fix/final-entry-points branch 3 times, most recently from acd4bc2 to 6f99c90 Compare August 25, 2026 01:02
`isValid()` stopped being an extension seam in 4.0.0 without a word: `upload()`
routes through the private `runValidations()`, so an override is never called,
and the `protected $errors` it wrote to became `private $errorDetails`, so the
append lands on a dynamic property nothing reads. A subclass loosening a check
fails safe. One adding a check — a scan, a quota, a stricter name rule — has it
bypassed on every upload, on a release whose headline is security.

`final` on `isValid()`, `upload()` and `uploadValid()`, which is what PHP has to
say "this is not a seam": the three share the reset-then-validate sequence, the
re-entrancy lock and the error count that decides which files passed, so a
partial override breaks one of them. `prepareUpload()` and `store()` are private
either way, so an override could only ever have wrapped these, which is what the
`beforeUpload`/`afterUpload` callbacks are for.

The property is guarded on all four routes to it, since each one is silent on
its own. `__set()` catches an assignment, `__get()` catches `$this->errors[] =
$message` — an append is a read — and `__isset()` catches `empty($this->errors)`,
which PHP would otherwise answer `true` for a collection that rejected every
file. The magic methods run in `File`'s scope, so the guard covers the rest of
what the class declares `private`, `$running` among it: without that arm a
subclass assigning to the re-entrancy lock by name would have taken the real one.

None of that can see a property the subclass declares, and a static never
dispatches to a magic method at all, so `init()` refuses a subclass declaring any
of the five names at construction — `$errorCodeMessages`, which became
`getUploadErrorMessages()`, is reachable no other way.

`UPGRADE.md` gets the two columns a migration is actually read for. The rows that
did not move are half of it: `FileInfo::isUploadedFile()` and
`getReservedWindowsNames()` still hold, `FileSystem::resolveFilename()` still
names the file but no longer refuses one, and `FileInfo::sanitizeName()` is a
seam `setExtension()` re-fits behind. `FileTest` reads those rows out of the
document and reflects over what they name, the way the deny-list test reads the
README: hand-copied, the list and the table disagreed on two rows before this
sentence was written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jakejackson1
jakejackson1 force-pushed the fix/final-entry-points branch from 6f99c90 to 3a7c68a Compare August 25, 2026 01:10
@jakejackson1
jakejackson1 merged commit 8bb7c10 into main Aug 25, 2026
39 checks passed
@jakejackson1
jakejackson1 deleted the fix/final-entry-points branch August 25, 2026 01:11
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