Strip UTF-8 BOMs from each file when concatenating - #1
suyogyashukla wants to merge 3 commits into
Conversation
A BOM gives the encoding only at offset 0. The concatenator appended raw bytes, so the BOM of any file after the first became a U+FEFF character in the middle of the stream. CSS accepts that character in a selector, so the browser read it as part of the next selector and ignored that rule. Remove the BOM after the read and before the code that uses the buffer. The @charset test is at offset 0, and a BOM also broke that test. If a BOM was present, give charset=UTF-8 in the Content-Type header. The BOM was the encoding declaration, so the response must keep it. Fixes Automattic#49
|
Reviewed the diff, read the surrounding code, and ran the new functions standalone on PHP 8.3 to check the claims. VerdictThe fix is correct, in the right place, and the diff is about as small as this problem allows. The strip happens after Verified independentlyI ran the 15 provider cases as plain PHP: all pass, idempotency holds, no residual BOM, and Push-back1. The conditional charset couples the header to a byte you have just deleted. This is the part worth recording. The bundle's It also creates a new failure shape: a bundle of file A (UTF-8, BOM) plus file B ( The predictable alternatives are to always declare UTF-8 for CSS and JS (bigger blast radius, out of scope for Automattic#49), or to re-emit one BOM at offset 0 of the output (keeps today's declaration mechanism, no header change at all). I would still keep what you have — the header genuinely outranks a BOM and an 2. The multi-BOM loop's stated rationale does not hold. "A file can be the result of an earlier concatenation" — but after this change this concatenator's output never carries a BOM at offset 0 at all, so the case cannot originate here. The loop is defensible on other grounds (some build tools really do emit doubled BOMs, and a second BOM lands as a stray character in the first selector), it is eight lines, and it is tested, so keep it if you like. Just correct the reasoning in the docblock, or drop to a single 3. The Worth being precise here: this is not a new bug introduced by the PR. Non-BOM files starting with Nits
|
After this change the concatenator never emits a BOM at offset 0, so its own output cannot be the source of a repeated BOM. The real case is a build tool that adds a BOM to a file that already has one.
The previous wording explained why doubled BOMs exist, which neither the code nor the tests demonstrate. The verifiable reason is what a second BOM does: it is not an encoding signal, and in any file but the first it reaches the output as the stray character this function removes.
|
Agreed on all three. The two code-side items are in a3c47b1 and 1605230, both comment-only, and the description carries the parts that were argument rather than code. The multi-BOM loop The reasoning was wrong in the way you describe. After this change the concatenator never emits a BOM at offset 0, so its own output cannot produce a repeated BOM. Two commits because I first took the build-tool half of your parenthetical, then took it back out: neither of us has checked it, and it was the same kind of unverified claim as the line it replaced. The docblock now gives the half the tests demonstrate, that a second BOM is not an encoding signal, and in any file but the first it reaches the output as the same stray character. Checking that turned up one detail. A single substr is not equivalent, but not for the reason I would have guessed: with one strip, a double-BOM file in first position leaves a BOM at offset 0, which is valid and harmless. It only becomes a stray character from second position on. So the loop is what makes "the output carries no BOM" true rather than usually true, which is the invariant your point above rests on. Loop and test kept. The conditional charset In the description now, with both alternatives you name. One correction on the failure shape. On VIP that bundle is already mojibake today, because CSS bundles come back as text/css;charset=utf-8, so the header already outranked file B's @charset before this change. I tested the mechanism rather than assuming it. On the same page, CSS bundles carry the charset and JS bundles come back as bare application/javascript. That split rules out nginx, whose default charset_types includes application/javascript and would have charset both. header('Content-Type: text/css') under default_charset=utf-8 reproduces the production header exactly, lowercase and unspaced. So the coupling is narrower than it looks. For CSS on VIP this only respells a charset that was already there, as text/css; charset=UTF-8, the same value under RFC 7231, and PHP does not append a second. It earns its place on JS, and on any deployment with an empty default_charset. Your "always declare UTF-8" alternative is stronger than my first pass allowed, because on VIP CSS already always carries the charset, so the conditional is what makes two bundles from one site differ. JS still rules it out: always declaring changes every JS bundle on every install, which is the blast radius you name. Your scenario does hold where default_charset is empty. I checked VIP, not WP.com, so that part is worth your eyes. The @charset hoist The description now says the bug predates this change rather than leaving it to inference. Automattic#70 already proposes the assignment fix, open since 2022 on the same six lines. It does not conflict: this branch inserts above the block and changes the header, Automattic#70 rewrites the block. Nits Timings: the description now gives the doubling ratio and drops the absolutes, which is the right fix whatever explains the 10x. I put it down to hardware earlier; an active Xdebug would account for it more neatly. has_utf8_bom: left as it is. The uncovered $had_utf8_bom to Content-Type wiring is recorded in the description as the known gap. phpcs --standard=WordPress-VIP-Go reports the same violations on this branch as on master for both changed files, and none on the new test file. |
Fixes Automattic#49. Reported again in a support ticket this week.
The problem
A BOM gives the encoding only at offset 0 of a file. The concatenator reads each file and appends the raw bytes, so the BOM of any file after the first becomes a U+FEFF character in the middle of the response. CSS accepts that character in a selector. The browser reads it as part of the next selector and ignores that rule, then carries on with the rest of the file.
The same rule renders with
?concat_css=false. The site that reported it ships a BOM onfrontend.cssfrom a Dart Sass build, and lost*{margin:0;padding:0}.A BOM also breaks the
@charsethoist, because that test reads offset 0.The fix
Remove the BOM after the read and before the code that uses the buffer.
relative_path_replace, the@charsethoist, the@importhoist and cssmin all read that buffer, so the position of this code matters. The JS branch gets the same change.If a BOM was present, add
charset=UTF-8to theContent-Typeheader. Issue Automattic#49 does not ask for this, but the BOM was the encoding declaration for that file, and a strip on its own drops it. A variant that strips and declares nothing, served to a windows-1252 page:contentchar codes[34, 226, 8224, 8217, 34]"→", wrong[34, 8594, 34]"→"(U+2192), correctThe Content-Type charset has a higher priority than a BOM and than an
@charsetrule, so the bundle stays UTF-8 for any file position.Two things about that header, both worth recording. The charset is set only after a BOM, so nothing is claimed about the encoding of bundles that had none. But it also means the header now depends on a byte this code deletes: if a build stops emitting a BOM, the bundle loses
charset=UTF-8and the encoding interpretation changes, with no change to this plugin. The alternatives are to always declare UTF-8 for CSS and JS, which is a larger change than Automattic#49 asks for, or to re-emit one BOM at offset 0 and leave the header alone.The practical scope is smaller than it looks. On VIP, CSS bundles already come back as
text/css;charset=utf-8, and JS bundles asapplication/javascriptwith no charset, because PHP appendsdefault_charsetonly to atext/*header. So for CSS on VIP this changes the spelling of a charset that was already there, totext/css; charset=UTF-8, which is the same value under RFC 7231. It carries real weight for JS, and for any deployment with an emptydefault_charset. PHP does not add a second charset when one is already set.Testing
This file is the byte path for every concatenated asset request on VIP and WP.com.
I built a document root, drove
ngx-http-concat.phpover HTTP, and compared this branch againstmasterfor 17 requests. Every request without a BOM returns the same bytes, status and headers. Every request with a BOM loses the BOM, andContent-Lengthmatches the body in all 17. The 404 and 400 paths do not change.tests/test-concat-utils.phpgains 46 cases. The suite is 78 tests and passes on PHPUnit 9.6, the version incomposer.lock.For the reported CSS in a browser,
masterleavesbodyat the 8px user agent margin and this branch gives 0px.phpcs --standard=WordPress-VIP-Goreports the same violations on this branch as onmasterfor both changed files, and none on the new test file.Cases covered: a BOM in the first file, a later file and the only file; more than one BOM; a file of only a BOM; a file shorter than a BOM; an incomplete BOM; a BOM with
@charset; a BOM with@import; a UTF-16 BOM; JS in both positions; the gzip and base64 URL form; HEAD; and a subdirectory install.The cost on the common path is one 3-byte
strncmpfor each file, becausehas_utf8_bomreturns before the loop starts. A file of only BOMs stays linear: at 50k, 100k and 200k BOMs, each doubling of the count doubles the time. Absolute timings are hardware-specific, so the ratio is the part to read.Notes
Bundles already in the edge cache do not change. They hold for a year and clear on a version change, so a site with this problem needs a version change or a purge after this deploys.
The
@charsethoist has a separate bug, and it predates this change. Files that start with@charsetalready get the rule copied to$pre_outputand left in the body, because thepreg_replace_callbackresult is never assigned. This change makes BOM'd files behave the same as those files, and does not alter the bug. Automattic#70 already proposes a fix for it.The
$had_utf8_bomtoContent-Typewiring is the one piece of new logic with no automated coverage, because it lives in the procedural part of the script. The HTTP comparison above covers it, but nothing will catch a later regression.