Skip to content

Make the suite pass on Windows - #41

Merged
jakejackson1 merged 5 commits into
fix/audit-findingsfrom
fix/windows-suite
Aug 24, 2026
Merged

Make the suite pass on Windows#41
jakejackson1 merged 5 commits into
fix/audit-findingsfrom
fix/windows-suite

Conversation

@jakejackson1

@jakejackson1 jakejackson1 commented Aug 24, 2026

Copy link
Copy Markdown
Member

Stacked on #40, which adds the windows job. Review that one first; this PR's diff is only its own change.

The job ran 658 tests on its first outing and returned 45 failures. One was the library. The other 44 were the suite and the checkout assuming POSIX — which is exactly what a platform nobody had ever tested on was going to produce, and why the job went in continue-on-error.

Library finding 1 — pathinfo() splits on \ under Windows

FileInfo::setNameWithExtension() and Filename::sanitizeNameWithExtension() split a client filename with pathinfo(). That treats \ as a path separator on Windows and as an ordinary character on POSIX, so the same name split two ways:

Client name POSIX Windows
a\b.txt a-b.txt b.txt
..\..\windows\win.ini windows-win.ini win.ini
a----t----t----.php\x00.png a-t-t-php-x00.png x00.png

Filename::rewriteCharacters() rewrites \ to -, so the rule both layers read from Filename is that a backslash stays in the name. Filename's own docblock says the two layers "must not disagree about what the rules are" — and a rule that depends on which platform is applying it is not one rule.

Filename::splitNameAndExtension() owns the split now, with / the only separator everywhere. It answers what pathinfo()'s PATHINFO_FILENAME and PATHINFO_EXTENSION answered in every other respect, and the new provider pins that case by case — the trailing slash basename() drops, the dotfile that is all extension and no name, .. and ..., repeated slashes. Nothing on POSIX changes; the existing 76 sanitizing data sets pass untouched, which is the evidence for that.

On Windows this keeps name content that was previously discarded, so it is noted in UPGRADE.md as well as CHANGELOG.md.

Library finding 2 — the default configuration could not store a file on Windows/7.3

reserveDestination() confirms its exclusive create by comparing fstat() against lstat(); that is what catches an x that followed a symlink. Windows before PHP 7.4 reports ino as 0 for both, so the comparison had nothing to compare — and read that as a mismatch. Every reservation was answered 'Destination is a symbolic link', and a reservation happens whenever $overwrite = false, which is the default. That platform could not store a single file.

The comment above the check said the comparison "degrades to same-drive and detects nothing" there. It detected everything. Corrected along with the code.

The comparison is now skipped where the inode is unavailable rather than absence being read as mismatch. A real file has a real inode, so POSIX gives up nothing, and the symlink protections in this class were already documented as not load-bearing on Windows — where a symlink needs a privilege an uploading process should not hold.

This relaxes a symlink check, and I would rather it were ratified than inherited. My reasoning is that the pre-fix state was not protecting anything on that platform — it was refusing everything. If refusing to run on Windows/7.3 is preferable to skipping the check there, that is a legitimate alternative and your call, not mine.

testAReservationIsNotRefusedWhereTheInodeIsUnavailable() stubs lstatEntry() with what that platform answers — no inode, and a dev of its own, so it is the missing inode that decides rather than a lucky match on the drive. Removing the guard reproduces the exact 'Destination is a symbolic link' failure; I checked both ways.

Library finding 3 — an orphaned placeholder on Windows

releaseReservation() decided whether the destination was a symlink by whether readlink() failed. That is not the same question on every platform: PHP's Windows readlink() answers a regular file with its own canonical path rather than failing, so the 0-byte placeholder took the symlink branch, found nothing matching the inode it had opened, and was never removed — holding the caller's name against every later upload of it.

is_link() asks directly. The stat cache is cleared first, since reserveDestination() has already lstat'd that path.

Both branches already had POSIX tests — testAReservationThatCannotBeConfirmedIsNotReportedAsASymlink for the plain file, testReservationThroughASymlinkLeavesNothingAtItsTarget for the link. They pass on Linux either way; the first is what caught this, on the platform where readlink() behaves that way.

The other 44

Path separators in assertions (~35). upload() returns $this->directory . $filename and the constructor ends the directory with DIRECTORY_SEPARATOR, where these tests build their working directory with /. Windows resolves both to the same file, so the storage was right and the string comparison was wrong. Four sites and the shared assertStoredAs() helper now go through a destinationOf() helper. assertFileExists() was always fine — PHP resolves either separator.

Line endings (5 sites, 2 tests). The callback tests echo PHP_EOL against an expected literal \n. They are about hook order, so the literal wins and the expectation stays readable.

A CRLF checkout (3). CatalogueTest parses i18n/upload.pot line by line with /^msgid "(.*)"$/m. Under a CRLF checkout the trailing \r makes "$ unmatchable, so no msgid was read and every assertion failed vacuously. .gitattributes pins that file to LF — which the i18n workflow needs regardless, since its guarantee is a byte comparison against a generator that writes LF.

A subprocess (1). FilenameTest builds a php -r script containing "a\nb". escapeshellarg() quotes with " on Windows and cannot escape one inside the argument, so the inner quotes were dropped and PHP read a\nb as a constant. The script carries no quote character of its own now.

What genuinely cannot run there (2, errors). NTFS refuses control characters in a filename outright, so touch() cannot create the colliding file that two of the collision-message data sets need. Split into their own @group posix test, since a data set cannot carry a group — the same treatment the fourteen symlink()/chmod()/umask() tests get in #40.

Verified

phpunit (full, --exclude-group posix, --exclude-group mbstring), lint, phpstan, check-syntax, base64-docs, i18n:pot, the package archive diff, and composer validate --strict all pass locally. 647 → 671 tests.

Both Windows jobs now pass — 654 tests and 1,284 assertions on 7.3 and on 8.5, identical to --exclude-group posix locally, so the grouping selects the same work on both platforms and what remains is real difference rather than selection drift. continue-on-error is removed and the job is a gate.

One correction

While measuring for this branch I found the test counts I quoted on #40 were taken against the wrong baseline. main is 628 tests, so #40 is 628 → 647 (the +19 was right; the absolute figures were the CJK-tests branch I had been standing on). #40's description is corrected.

🤖 Generated with Claude Code

jakejackson1 and others added 5 commits August 24, 2026 15:17
The `windows` job added alongside the audit fixes ran 658 tests and
returned 45 failures. One was the library; the rest were the suite and the
checkout assuming POSIX.

**The library.** `FileInfo::setNameWithExtension()` and
`Filename::sanitizeNameWithExtension()` split a client filename with
`pathinfo()`, which treats `\` as a path separator on Windows and as an
ordinary character on POSIX. The same name split two ways: `a\b.txt` was
stored as `a-b.txt` here and `b.txt` there, `..\..\windows\win.ini` as
`windows-win.ini` or `win.ini`. `Filename::rewriteCharacters()` rewrites
`\` to `-`, so the rule both layers read from `Filename` is that a
backslash stays in the name — and a rule cannot depend on which platform
applies it. `Filename::splitNameAndExtension()` owns the split now, with
`/` the only separator on every platform. It answers what `pathinfo()`'s
two fields answered otherwise, trailing slashes and the all-extension
dotfile included, which the provider pins case by case.

**The assertions.** `upload()` returns `$this->directory . $filename` and
the constructor ends the directory with `DIRECTORY_SEPARATOR`, where these
tests build their working directory with `/`. Windows resolves both to the
same file, so the storage was correct and the string comparison was not.
Four sites and the shared `assertStoredAs()` helper now go through
`destinationOf()`.

**The line endings.** The callback tests echoed `PHP_EOL` against an
expected literal `\n`; they are about hook order, so the literal wins.
`CatalogueTest` parses `i18n/upload.pot` line by line and a CRLF checkout
left `"$` unmatchable, so no msgid was read and every assertion failed
vacuously — `.gitattributes` pins that file to LF, which the `i18n`
workflow's byte comparison needs regardless.

**The subprocess.** `FilenameTest` builds a `php -r` script containing
`"a\nb"`. `escapeshellarg()` quotes with `"` on Windows and cannot escape
one inside the argument, so the inner quotes were dropped and PHP read
`a\nb` as a constant. The script carries no quote character of its own now.

**What cannot run there.** NTFS refuses control characters in a filename,
so `touch()` cannot create the colliding file two of the collision-message
data sets need. Those two are split into their own `@group posix` test,
since a data set cannot carry a group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`testAUnicodeSpaceIsKeptInAStoredName` was the one site the first pass
missed, and the only Windows failure left: 9 of them, one per unicode
space, all the same `/` against `DIRECTORY_SEPARATOR` comparison.

The four remaining sites are inside `@group posix` tests, so Windows never
reached them, but they carry the same assumption and would surface the day
one of those tests stops being excluded. `destinationOf()` is now the only
way this suite asserts a locator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Windows under PHP 7.3 reports `ino` as 0 for both `fstat()` and `lstat()`,
so `reserveDestination()`'s identity comparison had nothing to compare —
and read that as a mismatch. Every reservation was answered
`'Destination is a symbolic link'`, which is every upload the default
`$overwrite = false` makes: the platform could not store a single file.
Nine of the ten remaining Windows 7.3 failures were that one line, the
tenth its knock-on.

Skipped where the inode is unavailable rather than failed. Nothing has been
established either way, a real file has a real inode so POSIX gives up
nothing, and the symlink protections in this class were already documented
as not load-bearing on Windows, where a symlink needs a privilege an
uploading process should not hold.

The comment above the check claimed the comparison "degrades to same-drive
and detects nothing" there. It detected everything. Corrected.

`lstatEntry()` is the seam for asserting this on the platform the suite
actually runs on, so the new test stubs the answer that platform gives —
no inode, and a `dev` of its own, so it is the missing inode that decides
rather than a lucky match on the drive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`releaseReservation()` inferred it from whether `readlink()` failed. That
is not the same question on every platform: PHP's Windows `readlink()`
answers a *regular file* with its own canonical path instead of failing, so
the 0-byte placeholder took the symlink branch, found nothing matching the
inode it opened, and was never removed — holding the caller's name against
every later upload of it.

`is_link()` asks directly. The stat cache is cleared first because
`reserveDestination()` has already lstat'd that path.

The last of the Windows failures. Both branches are covered on POSIX
already — `testAReservationThatCannotBeConfirmedIsNotReportedAsASymlink`
for the plain file, `testReservationThroughASymlinkLeavesNothingAtItsTarget`
for the link — and the first of those is what the Windows job caught this
with, on the platform where `readlink()` behaves that way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both versions pass: 654 tests and 1284 assertions on each, identical to
`--exclude-group posix` locally, so the grouping selects the same work on
both platforms and what is left is real difference rather than drift.

It earned the promotion on its first outing, with two library bugs on the
default `$overwrite = false` path that no Linux job could reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jakejackson1
jakejackson1 merged commit 16da9ea into fix/audit-findings Aug 24, 2026
39 checks passed
@jakejackson1
jakejackson1 deleted the fix/windows-suite branch August 24, 2026 06:00
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