Make the seams that closed say so - #43
Merged
Merged
Conversation
jakejackson1
force-pushed
the
fix/final-entry-points
branch
3 times, most recently
from
August 25, 2026 01:02
acd4bc2 to
6f99c90
Compare
`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
force-pushed
the
fix/final-entry-points
branch
from
August 25, 2026 01:10
6f99c90 to
3a7c68a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
File::isValid()stopped being an extension seam in 4.0.0 and said nothing. Two independent silent breaks:upload()routes through the privaterunValidations().protected $errorsbecameprivate $errorDetails, so$this->errors[] = $messagelands 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
finalonisValid(),upload()anduploadValid(). 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()andstore()are private either way, so an override could only ever have wrapped these — which is whatbeforeUpload/afterUploadare for. A check of your own belongs in aValidationInterface, which all three run.The error list is guarded on all four routes to it, because each is silent on its own:
$this->errors = [...]__set()$this->errors[] = $message— an append is a read__get()empty($this->errors)— answeredtruefor a collection that rejected every file__isset()class Mine extends File { protected $errors = []; }init()The magic methods run in
File's scope, so the guard covers the rest of what the class declaresprivate—$runningabove 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 whyinit()carries the fifth name:protected static $errorCodeMessages, nowgetUploadErrorMessages().UPGRADE.mdgets 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()andgetReservedWindowsNames()still hold,FileSystem::resolveFilename()still names the file but no longer refuses one, andFileInfo::sanitizeName()is a seamsetExtension()re-fits behind.FileTestpins the table by reflection, in the idiomtestReadmeDocumentsTheDefaultDenyList()already uses, so it cannot drift the way the 4.0.0 docs did.Compatibility
Breaking for a
Filesubclass 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.mdandUPGRADE.mdcover both.Checks
phpunit(716 tests, 1,436 assertions),phpstan(level 9),lint,check-syntax,psr7-readmeandbase64-docsall pass.i18n:potregenerates with no diff — the new exception messages are developer-facing and unmarked.🤖 Generated with Claude Code