Skip to content

Correct the README for 4.0, and give each bridge its own page - #38

Merged
jakejackson1 merged 1 commit into
mainfrom
docs/readme-4.0-accuracy
Aug 24, 2026
Merged

Correct the README for 4.0, and give each bridge its own page#38
jakejackson1 merged 1 commit into
mainfrom
docs/readme-4.0-accuracy

Conversation

@jakejackson1

Copy link
Copy Markdown
Member

Docs only — no src/ change, so nothing here alters behaviour.

Accuracy

Each of these was checked against the source rather than reasoned about:

  • "Wording is API" contradicted the four other places saying codes are stable and wording is not, and its second half described one outcome twice ("untranslated rather than reverting to English" — those are the same thing). Rewritten to say what actually happens: rewording retires the msgid, the CHANGELOG lists it, branch on ErrorCode.
  • sanitizeForDisplay()'s steps were listed with the trim before the UTF-8 repair. It runs after — sanitizeForDisplay("abc \xC3") returns "abc", not "abc ".
  • Multi-file offsets. isset($file[0]) was the wrong warning: a failed transfer does not leave a hole, it renumbers everything after it. With a 3-entry $_FILES whose entries 0 and 2 failed, count() is 1 and $file[0] is the file the client selected second. Now points at FileList::getSourceKeys().
  • ErrorCode listed UNKNOWN_TRANSFER_ERROR as one of "the eight UPLOAD_ERR_* outcomes". There are seven; it is forUploadError()'s fallback, which UPLOAD_ERR_OK also gets.
  • FileInfoInterface sent implementers at FileInfo's : FileInfo setter signatures. The interface declares no return type on the three setters — that is what made it implementable in 4.0, and copying the table reintroduced the TypeError.
  • The custom-validation example called __() with no use function GravityPdf\Upload\__;, which is exactly the mistake the paragraph below it warns about; under WordPress it silently reaches the global __().
  • sanitizeNameWithExtension()'s real ?array $reserved signature; $displayName no longer assigned and never used.

Added

Filename's public predicates (hasControlCharacters(), hasBidiControls(), deviceComponent(), isReservedDeviceComponent(), extensionComponents(), acceptExtension()), pointed to from "Custom storage backends" — they exist so a custom backend can ask what the shipped one asks, and nothing said so. Plus __call()'s BadMethodCallException, the ArrayAccess read/write semantics, FileType::allow()'s empty-side guard and accumulate-not-replace behaviour, Size's parse failure, and that ext-mbstring now backs the getErrors() strings too.

Fork list

A 3.x list on a 4.0 README: it named getDirectory() and the upstream import while omitting FileList, uploadValid() and translation, and three bullets described the codebase rather than what a consumer gets. Twelve bullets to seven.

One page per bridge

A bridge off the $_FILES path now gets a page under docs/, and the README holds only what every bridge shares — the constructor table, getSourceKeys(), the two provenance decisions, TmpUploadFile, and the tmp-file cleanup (now its own section rather than buried in the PSR-7 walkthrough).

  • docs/psr7.md takes the PSR-7 bridge, flattening, cap and moveTo() warning.
  • docs/base64-uploads.md is included here and is not my work — it was in the tree uncommitted. It could not be split out: the README's new Bridges table links it, and my relocation rewrites the section it added. Flagging it so it gets reviewed on its own terms.

Both checkers read TmpUploadFile and the cleanup out of README.md, so those two snippets exist once however many bridges ship. tools/psr7-readme/verify.php reads its own two from the new page; docs/base64-uploads.md's cleanup anchor was repointed.

While reviewing the base64 page I found the cap is on strlen($encoded), which counts line breaks, so a wrapped payload is allowed ~1.3% fewer bytes than an unwrapped one — the page said wrapped payloads decode but not that the wrapping eats the cap, and its own fixture is far enough from the boundary that nothing caught it. Documented. CHANGELOG.md also said Unknown Error where the source says Unknown error.

README is 1023 lines to 961.

Checks

check-syntax, lint, phpstan, phpunit (628 tests, 1 expected skip), psr7-readme, base64-docs all pass against the committed tree. The dist package's top level is unchanged, and no anchor or relative link across README.md and docs/ is broken.

🤖 Generated with Claude Code

@jakejackson1
jakejackson1 force-pushed the docs/readme-4.0-accuracy branch 4 times, most recently from 178ef8d to 41b4295 Compare August 24, 2026 02:38
Documentation only. No behaviour changes, no code changes.

Eight claims in the README no longer matched src/, each checked rather than
taken on faith:

- "Wording is API" contradicted the four other places saying codes are
  stable and wording is not, and its second half described one outcome
  twice: turning up untranslated and reverting to English are the same
  thing. Rewording retires the msgid, which msgmerge reports as obsolete.
- sanitizeForDisplay()'s steps were listed with the trim before the UTF-8
  repair. It runs after: "abc \xC3" returns "abc", not "abc ".
- isset($file[0]) was the wrong warning for a multi-file field. A failed
  transfer leaves no hole, it renumbers: with entries 0 and 2 failing,
  count() is 1 and $file[0] is the file the client selected second.
- ErrorCode listed UNKNOWN_TRANSFER_ERROR among "the eight UPLOAD_ERR_*
  outcomes". There are seven; it is forUploadError()'s fallback, which
  UPLOAD_ERR_OK also gets.
- The Interfaces table sent a custom FileInfoInterface at FileInfo's
  `: FileInfo` setter signatures. The interface declares no return type on
  the three setters, which is what made it implementable in 4.0.
- The custom-validation example called __() with no
  `use function GravityPdf\Upload\__;`, the mistake the paragraph below it
  warns about; under WordPress it reaches the global __().
- sanitizeNameWithExtension()'s signature omitted its $reserved parameter.
- $displayName was assigned and never used in the single-file example.

Added what a caller could not find: Filename's public predicates, which exist
so a custom storage backend can ask what the shipped one asks; __call()'s
BadMethodCallException; the ArrayAccess read and write semantics;
FileType::allow()'s empty-side guard; Size's parse failure; and that
ext-mbstring backs the getErrors() strings as well as the filenames.

The fork list was a 3.x list on a 4.0 README, naming getDirectory() and the
upstream import while omitting FileList, uploadValid() and translation.
Twelve bullets to seven.

A bridge off the $_FILES path now gets a page under docs/, and the README
holds only what every bridge shares. docs/psr7.md takes the PSR-7 bridge;
docs/base64-uploads.md does the same for a file posted as base64 in a JSON
body. Both checkers read TmpUploadFile and the tmp-file cleanup out of
README.md, so those two snippets exist once however many bridges ship.

Three more blocks moved, for readers the usage path does not serve:

  docs/api-reference.md       every public method, what it takes and throws
  docs/extending.md           ValidationInterface and StorageInterface
  docs/translation/README.md  the hook, the catalogue, the __() marker

Each leaves a stub. "Reacting to a failure rather than showing it" stays,
being about ErrorCode rather than translation. Three things could not move:
FileSystemTest reads the deny-list table out of README.md, and both bridge
checkers read TmpUploadFile and the cleanup from there. docs/ ships, so links
between the two halves stay relative and resolve from an installed vendor/.

Both bridge pages were then deslopped. Three facts in the base64 page were
stated twice, once in a snippet comment and again in the prose below it; its
post_max_size claim was contradicted by PHP's own 8M default, and the
whitespace margin it put a figure on assumed LF at 76 columns. The PSR-7
page's moveTo() section claimed the file is stored twice and then that the
second call finds nothing there; PSR-7 requires moveTo() to remove the
original and to raise on a second call, so only the second half was true.

CLAUDE.md and the CHANGELOG described the dist package as src/, composer.json
and four Markdown files, which predates docs/ and i18n/ shipping.

README 1023 -> 560 lines. No link across README.md, CHANGELOG.md, UPGRADE.md
or docs/ is broken.

Reviewing the base64 bridge by running it turned up four defects, each now
fixed and each with a case in tools/base64-docs/verify.php that fails against
the old code:

- Casting a client-supplied field to string raised "Array to string
  conversion" on {"filename": ["a"]}, valid JSON that any client can send. A
  default handler logs it on every malformed request and stores the literal
  name "Array"; a handler that promotes warnings to exceptions returns 500.
  This is what File::__construct() already guards for $_FILES, where the rule
  is that remote input must not warn. Both fields are tested with is_string()
  rather than cast.
- The data URI header was matched as `data:[^,]*`, which accepts a URI that
  is not base64 at all: `data:text/plain,hello world` had its header stripped
  and the remainder decoded to seven bytes of rubbish, then written. The
  pattern now requires `;base64`, so such a payload fails the strict decode.
- A payload that arrived and could not be decoded was reported as
  UPLOAD_ERR_NO_FILE, telling the submitter nothing was uploaded and reading
  in getErrorDetails() as the same failure as an absent `data` key. It is now
  UPLOAD_ERR_PARTIAL, and the empty case keeps NO_FILE.
- The cap rounded up to four base64 characters, so a 64 byte bound stored a
  66 byte file. The encoded check stays, since it is what keeps an oversized
  payload from being decoded into a second copy in memory, and an exact check
  on the decoded length now follows it.

Checked and left alone: a traversal-shaped client filename (`../../etc/passwd`
stores as `passwd`, inside the tmp directory), the partial-write branch, and
a string, int, null or bool entry, which `??` already handles without warning.

"Turning the defaults off" opened straight into the five calls that disable
the protections the rest of the README argues for, with the consequence of
each compressed into a trailing code comment. It now leads with a warning
callout and a table saying what each call stops applying: that overwriting
drops the reservation but keeps the staged write and the symlink refusal;
that an empty deny-list admits .php, .htaccess and .svg; that setMode(null)
stores world-readable under the usual 022 umask; that
acceptFilesNotUploadedByPhp() gives up move_uploaded_file()'s refusal; and
that allowUnvalidatedUploads() leaves only the storage rules. The code
comments went, since the table carries them.

The isUploadedFile() note now says an override has to assert provenance:
`return true;` gives the check up rather than replacing it, and pairing that
with acceptFilesNotUploadedByPhp() leaves nothing at either end.

"Turning the defaults off" moved to docs/turning-the-defaults-off.md, and is
linked from docs/api-reference.md alone. It is the instructions for disabling
the protections the rest of the README argues for, so it belongs with the
methods rather than in the path of someone reading how to accept an upload. A
reader who wants one of the five arrives through the method they are looking
up; the README's Storage\FileSystem row now says what the class does instead
of pointing at the switches.

The deny-list section keeps array_diff() on getDefaultBlockedExtensions() as
the way to accept one format, which is the answer to "how do I allow SVG".
allowAnyExtension() is no longer named anywhere a reader lands on by
accident.

Reviewing both bridges by running them turned up six more defects, four of
them in the PSR-7 one, which had not had that treatment:

- A field the client nests one level deeper than expected left an array where
  an UploadedFileInterface belonged, and the bridge called a method on it:
  a fatal Error, where FileList would have raised InvalidArgumentException.
  getUploadedFiles() mirrors the multipart field names, so photos[0][0] is
  always reachable. The element is tested before it is dereferenced.
- writeTmpFile() returned a bool, collapsing "fopen failed", "over the cap"
  and "write failed" into one signal that the caller reported as
  UPLOAD_ERR_INI_SIZE. With the tmp directory missing, a 17 KB file under a
  1 MB cap was reported to the submitter as too large. It now returns the
  UPLOAD_ERR_* that describes what stopped it.
- fwrite() was checked against false, which a short write is not: a full disk
  writes fewer bytes than asked and truncates the file rather than failing,
  and the truncated file was then validated and stored. Checked against
  strlen($chunk), as the base64 bridge already did.
- The copy loop could spin forever. PSR-7 requires neither that read() make
  progress nor that eof() ever become true; a stream doing neither read 42
  million times in two seconds. Two empty reads in a row now end it.

And two in both bridges:

- fopen() was unsilenced, so a missing tmp directory put the absolute path
  into the log on every request. The library silences the same call.
- random_bytes() throws where there is no CSPRNG, and neither bridge caught
  it. The library converts its own to STAGING_NAME_FAILED.

Three design gaps are documented rather than coded. $maxBytes bounds one file
and not the batch, so both pages now say to bound the count too; there is no
UPLOAD_ERR_* meaning "too many files", so reporting it would have to misname
it. The cleanup loop belongs in a finally, since nothing else removes a tmp
file. And a base64 payload's key is client input that survives as
getSourceKeys().

Every one has a case in the checkers, each verified to fail against the code
it replaced. The stalled-stream fixture throws after 100 reads rather than
letting the job hang, and the PSR-7 cases run one scenario per try so the
first throw does not mask the rest. Both error handlers honour the @ operator,
which a handler is otherwise called straight through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jakejackson1
jakejackson1 force-pushed the docs/readme-4.0-accuracy branch from 41b4295 to 485e77d Compare August 24, 2026 02:57
@jakejackson1
jakejackson1 merged commit 06f6cce into main Aug 24, 2026
36 checks passed
@jakejackson1
jakejackson1 deleted the docs/readme-4.0-accuracy branch August 24, 2026 03:02
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