fix: read and write config.php the way PHP does; 34.0.3:0 -> 34.0.3:1 - #134
Conversation
The config.php grammar was adapted from a JSON grammar and kept JSON's string rules, so the two sides disagreed with PHP in opposite directions. Reading, it rejected `NULL` (var_export writes null in uppercase) and any raw control character inside a single-quoted string. A rejected parse throws out of the `configPhp.merge` at the top of `main`, and StartOS retries a failed `main` without reporting why, so one such value left the service restarting every 10s with nothing in the log but "Starting Nextcloud...". `occ config:system:set <key> --type=json --value=null` is enough to reach it. Writing, it escaped control characters as `\n`, `\t` and `\uXXXX` inside a single-quoted string, where PHP decodes neither -- so PHP read back a literal backslash-n. Only `\\` and `\'` are escapes there. Both sides now follow PHP: keywords match case-insensitively, a single-quoted string escapes only a backslash and a quote, and every other byte stands for itself. Verified by interop against PHP 8.4 in both directions, and an existing config.php parses to the identical value. INF and NAN are still refused; nothing writes them to a Nextcloud config, and an unreadable file now logs the parse error rather than failing mute. Refs #133
Review of the previous commit found the same restart loop still reachable,
and a claim in that commit's own message that is wrong.
`occ config:system:set X --type=float --value=1e400` stores `INF`:
`CastHelper` accepts it because `is_numeric('1e400')` is true, and
`(float)'1e400'` is `INF`. `var_export` then writes the bare constant, which
the grammar rejected, so the documented command bricked the service exactly
as `NULL` did. The grammar now reads `INF`, `-INF` and `NAN`, and
`toPhpString` writes them back — both halves are required, because
`String(Infinity)` is `Infinity`, an undefined constant that would make
Nextcloud itself fatal on every request.
`array` was still case-sensitive while `true`/`false`/`null` had become
case-insensitive, which the grammar's own comment claimed to have fixed.
The parse error is now reported through Peggy's `format`, which quotes the
offending line under a caret, instead of a bare "expected" message with no
position. The same reporting covers the shape validation, which is the other
half of a read and threw just as silently.
`AGENTS.md` and `README.md` said unmodelled `config.php` keys are dropped on
the next write. Measured, they are kept: the SDK patches `z.object` to be
loose, so `instanceid`, `passwordsalt` and `secret` survive. The docs told a
future reader to make the validator strict, which would drop exactly those.
`php-parser.js` is generated but nothing recorded how; `npm run regen-parser`
now reproduces it byte-for-byte from the grammar.
Verified against PHP 8.4 in both directions, including `INF`/`-INF`/`NAN`
round-tripping byte-identically to `var_export`'s own output. An existing
config.php still parses to the identical value.
|
Second commit is the result of a code review round on the first. The review found the restart loop still reachable by a documented command: Also in the second commit:
Verified against PHP 8.4 both directions, Known gaps, all pre-existing and now logged rather than silent: Two things I found but deliberately did not change, since they are pre-existing and outside this fix — say the word and I will take either:
|
…out of the log
Review of the previous commit found it red in CI and carrying three defects
it introduced.
The SDK lint gate rejects interpolating an `unknown` into a template literal.
`s9pk.mk` runs `lint.mjs` between `check` and `build`, so `make` aborted and
both BuildMatrix arches failed. `tsc` passes on it, which is how it survived a
round.
`FileHelper.merge` validates the on-disk data inside `try { … } catch (_) {}`,
because a file that does not satisfy the shape yet is what a merge is for.
Wrapping the validator to log meant a healthy start reported that its config
could not be read -- reachable through `occ config:system:delete
trusted_domains <n>`, which leaves a gapped array, and on a merge with no file
at all. The validator is unwrapped again; the parse callback keeps the log,
and it is never swallowed.
Peggy's `format` quotes the offending source line, so a parse failure printed a
line of config.php into the service log -- `dbpassword`, `passwordsalt` or
`secret` if the fault is on one of those lines, and the whole file if it is on
one line. The log now carries the line and column and the parser's message,
which is the part that locates the fault, and none of the file.
Docs: the previous commit replaced a false claim with a promise that is also
false. `README` said keys the shape does not model are left alone, but five
modelled keys appear in neither group it lists, and `overwriteprotocol` is
modelled as unset, so a hand-set value is removed on the next write. The
consequence given in `AGENTS.md` was wrong too -- dropping `secret` or
`passwordsalt` takes Nextcloud offline with a 503, and `instanceid` is
regenerated rather than merely invalidating sessions. Both measured against a
running instance.
The release note claimed a fix for "a number outside the range PHP can
represent", which is not what `INF` and `NAN` are, and would have covered
`PHP_INT_MIN` -- still unreadable, since `var_export` writes it as an
expression. The note now names the values that were fixed, and the expression
and precision limits are recorded in `AGENTS.md`.
|
Third commit is round 2 of code review on this branch. It found the previous commit red in CI and carrying three defects it had introduced — two of them regressions from the round-1 fixes. The build was broken. A healthy start reported that its config could not be read.
The validator is unwrapped again. The parse callback keeps the log, and that one is never swallowed. The log could print a secret. Peggy's Verified zero occurrences of the secret values in the output. Docs. My round-1 doc fix replaced a false claim with a promise that is also false: README said keys the shape does not model are left alone, but five modelled keys appear in neither group it lists, and Release note. "A number outside the range PHP can represent" is not what Two findings I did not implement, with reasons:
Also still open and pre-existing, worth an issue rather than this PR: Gates on this head: |
…s again Review of the previous commit found a doc claim I had over-corrected into a different falsehood, and one real failure left behind by removing the validator log. `occ config:system:delete trusted_domains <n>` leaves a gapped PHP array, which is a map rather than a list, and `trusted_domains` was the only field in the shape with no `.catch()`. `main` supplies its own value so it survives and rewrites the file, but until the next restart `backups.ts` and the Configure action read through the same shape and threw -- so a backup failed. It now falls back to an empty list, which `main` replaces on the next start anyway, since it rebuilds the value from the published addresses. A config that is not an object still throws. `README` said the package re-asserts every key the shape models. Measured, 9 of 11 do not: `.catch()` fires only when validation fails, so a modelled key whose hand-edited value is valid -- `default_locale`, `overwrite.cli.url`, `dbpassword` and six others -- is kept. Only the literal-pinned keys are re-asserted, which is what the wording said before I widened it. Restored, with `overwriteprotocol` named in the enforced list where it belongs. `AGENTS.md` quoted a Nextcloud error string that does not exist verbatim -- the key name is interpolated into the middle of it -- so it could not be grepped or matched against a screenshot. It also enumerated the forms the grammar cannot read without the one a person is most likely to type: a comment. `//`, `#` and `/* */` are all valid PHP and all rejected. The comment above the parse log claimed StartOS reports nothing for a failed `main`, which depends on the OS version, and said the position is reported "but not the line" two lines above code that reports a line number. It now states the constraint that outlives both: never log the failing line's text. The German release note called it a "keine-Zahl-Wert"; the other four locales say non-numeric idiomatically.
|
Fourth commit is round 3 of review. Smaller than the last two — CI was already green and the grammar and serializer are byte-identical to the previous commit, so this is one real fix plus doc corrections. A de-listed trusted domain made backups fail. Three finders reached this independently and all three agreed the round-2 removal of the validator log was right — the fix belonged in the shape, not in re-adding a log that would have to stay quiet on the merge path. I over-corrected the README last round. I had rewritten it to say the package re-asserts every key the shape models. Measured against the real shape: 9 of 11 modelled keys survive a hand edit, because Two more doc corrections. The comment above the parse log claimed StartOS reports nothing for a failed Also: the German release note said "keine-Zahl-Wert"; the other four locales render non-numeric idiomatically. On the secret leak, since it was the reason for the round-2 change: two finders attacked the new message independently. It carries at most one code point of the file — Peggy's Not acted on, and why: non-UTF-8 bytes in a config value are destroyed on read, but that happens in Gates on this head: |
The `.catch([])` in the previous commit is a worse bug than the one it fixed. `trusted_domains` is the only key the Configure action does not carry, so the merge writes back whatever the read produced -- and an empty list makes Nextcloud reject every request as an untrusted domain. Measured against the three revisions, with a gapped list on disk and a Configure-shaped merge: before the .catch merge throws, file untouched, backups fail .catch([]) merge writes `trusted_domains => array ( )` this commit merge writes back both hostnames `occ` mangles this key two ways: deleting one entry leaves a gapped PHP array, which reads back as a map, and setting the key without an index leaves a bare string. Both now recover the hostnames. Nextcloud's own `TrustedDomainHelper::isTrustedDomain` refuses a string-valued `trusted_domains` outright, so recovering that one turns a locked-out instance back into a reachable one; the empty list did not. `README` undercounted twice, both measured by round-tripping every modelled key and reading what lands on disk. `dbport` is a sixth database-connection literal and is re-asserted, but the enforced list named five; and "the two below" pointed at a table of three, which its own heading calls three. Both keys the list omitted are overwritten, while the limitation below it promised that anything unlisted survives. Configure owns five settings, not four -- the trash retention setting this release adds was never added to the count.
|
Fifth commit is round 4. It found that my round-3 fix was a worse bug than the one it fixed, so this one is worth reading even though the diff is small.
Now it recovers the hostnames instead of discarding them. The adversarial pass could not break it: README undercounted twice, both measured by round-tripping every modelled key and reading what lands on disk. Gates on this head: Worth saying plainly about the shape of this review: the grammar and serializer have been byte-identical since the second commit, and rounds 3 and 4 have been about one |
Nextcloud 34 prepends a warning comment between `<?php` and `$CONFIG` on every config write -- `Config::CONF_WARNING`, added in 34 and absent in 33. The grammar accepted only whitespace there, so the file Nextcloud itself produces did not parse: Could not parse config/config.php at line 3, column 1: Expected "$CONFIG" but "/" found. That throw comes out of the merge at the top of `main`, so the service restart-loops and Postgres and Valkey never start. Both delivery paths hit it: a fresh install writes the banner from the stock entrypoint during init, and an upgrade writes it during `occ upgrade`. `origin/master` is affected identically, so the already-published `34.0.3:0` cannot start once Nextcloud writes its config -- this is the first release of the 34 line, and the grammar had refused comments since long before it. The grammar now takes a comment anywhere whitespace is allowed, in all three PHP forms. An unterminated block comment still fails, as it should. Separately, a `$CONFIG` that parses but is not an array used to make `merge` fall through to the shape's defaults and write those, dropping `secret`, `instanceid` and `dbpassword` -- `dbpassword` exists nowhere else. Giving `trusted_domains` a `.catch()` last commit removed the last field that would have refused such a merge, so the read now rejects it while the file is still intact. That restores what the previous commit message claimed. Verified against the real `nextcloud:34.0.3-apache`: the exact bytes `writeData()` emits now parse, and PHP reads back what the package writes.
|
Sixth commit is round 5, and it is the most important one. The published Nextcloud 34 added That throw comes out of the merge at the top of The grammar now accepts a comment anywhere whitespace is allowed, in all three PHP forms. An unterminated block comment still fails. The banner config parses and round-trips through real PHP. How this got missed for four rounds is worth saying plainly. "PHP comments are unparseable" had been established early and I carried it forward as a documented limitation — it was even written into Second fix in the same commit: a Also corrected: I also pushed back on one finding rather than implementing it, and corrected the reasoning behind another: the claim that a non-string entry makes PHP's Gates on this head: Given the severity of what round 5 turned up, I would not treat the earlier rounds' "clean" areas as settled either — the same suppression could be hiding something else. If it were my call I would want this one exercised on a real 34.0.3 install before it ships. |
The two Fixed bullets gave the root cause, the mechanism and the old behaviour in all five locales. A reader of a release note is deciding whether an upgrade affects them, and the observable difference is one sentence: the service could hang restarting, and no longer does. The banner comment, INF/NAN and the parse-position log belong in the PR body. Also point the enforced-key list at the table it means. "The three below" sits directly above three bolded groups, so it read as those. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MattDHill
left a comment
There was a problem hiding this comment.
Approved. I verified the substantive claims independently rather than taking the branch's word for them.
The severity is real. Config::CONF_WARNING is in nextcloud/server v34.0.3 at lib/private/Config.php:19 and writeData() prepends it on every write; zero occurrences in v33.0.8. I generated the exact file NC34 emits (real var_export behind the real banner) and ran origin/master's committed parser on it — Expected "$CONFIG" but "/" found, line 3 column 1. init/bootstrapNextcloud.ts runs occ upgrade and the stock entrypoint installs, so both paths land the banner. 34.0.3:0 is the current Latest and bricks on the first restart after Nextcloud writes its own config.
Checks I ran on this head:
npm run regen-parser vs committed php-parser.js |
byte-identical |
33-key var_export corpus → parse → serialize → PHP 8.4 include → === |
32/33 identical |
| same file through master's parser (control) | fails at the banner |
| write is a fixpoint | yes — no per-start rewrite churn |
| realistic 33-era config, master parser vs this one | identical, so no migration |
tsc, lint.mjs, prettier --check |
clean |
trusted_domains .catch over 12 hostile inputs |
never throws; bare * → []; valid list never invokes it |
| log leak surface | Peggy's found is input.codePointAt(pos) — one code point, not a line |
Corpus covered NULL, TRUE/FALSE, INF/-INF/NAN, raw LF/TAB/CR/ESC/NUL in strings, a PEM blob, a literal \n, unicode, $, quotes, \OC\Memcache\APCu, apps_paths, nested/gapped/empty arrays, numeric-string keys and exponent floats. Documented gaps fail exactly as documented. The only non-identical key was a __proto__ probe of my own, dropped on write — pre-existing, unreachable from Nextcloud, and already disclosed.
I pushed one commit of my own: the two Fixed release-note bullets gave root cause, mechanism and old behaviour in all five locales, so they are now one sentence describing what a user can observe. The enforced-key list also said "the three below" directly above three bolded groups it did not mean.
I rewrote the PR description as well. It still listed "a PHP comment does not parse, in any of its three forms" under Not covered — the bug round 5 exists to fix — and never mentioned the banner, the non-array $CONFIG guard, or that round 3's .catch([]) was itself a lockout that round 4 replaced.
Ship it, and de-index 34.0.3:0 until 34.0.3:1 is tagged.
|
Exercised on a real StartOS box, as the last round asked for. Control:
|
|
Thank you for putting it on a real box — that was the one gap I flagged when I closed round 5, and it turned out to matter more than I expected. Your control is a better reproduction than the one in #133. I had #133 framed around a value written by Your two findings are filed, both noted as pre-existing and not caused by this PR:
On the I have not pushed anything since your approval, and I won't — merging and the de-index are yours. |
|
Correction to my dev-box report above. Finding 1 — "
Everything else in that report stands. The control, the Finding 3 is confirmed at the source and is worse than I framed it. The null quirk is not the sharp edge, though. This is: Measured on the box, on That is one of the two forms listed under Not covered, it is reachable from a documented Worth deciding before this is tagged. |
occ runs inside the service container, so a value main cannot read is a brick with no way to reach the command that would remove it. Recovery needs filesystem access to config.php, which a StartOS user does not have. Two such values are reachable from documented occ invocations, because var_export writes them as expressions: PHP_INT_MIN, which `config:system:set --type=integer` saturates any out-of-range negative onto, and a NUL byte. Rather than teach the grammar those two literals, Value falls back to capturing the source text of anything it cannot model and the serializer re-emits it byte for byte. The fallback needs a terminator lookahead to be reachable at all: Number matches the leading -9223372036854775807 of PHP_INT_MIN, and PEG does not retry an alternative once the enclosing sequence fails. Bounded so malformed input still fails loudly rather than merging the entries around it: Raw stops at a newline, since var_export never spreads a scalar expression over two lines, and a bare positional entry may not be raw. Verified against PHP 8.4 that a missing comma, a missing =>, an unterminated string, an unterminated comment and a truncated file all still throw. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Passthrough commit exercised on the box. Wrote both expression forms through Restart at 20:21:13 → Read back through Nextcloud afterwards: Worth recording about reachability, since it narrows one of the two: the NUL form is not reachable through Gates on this head: CI green both arches, |
Closes #133.
The published
34.0.3:0cannot startNextcloud 34 added
Config::CONF_WARNING, andwriteData()prepends it between<?phpand$CONFIGon every config write. It does not exist in 33.0.8. The grammar allowed only whitespace there, so the file Nextcloud itself produces did not parse:That throw comes out of the
configPhp.mergeat the top ofmain, before any container starts. StartOS retries a failedmainwithout reporting why, so the service restarts every 10 s with nothing in the log butStarting Nextcloud...,adding callback N;statusInfo.errorstaysnulland Postgres and Valkey never come up.Both delivery paths reach it — a fresh install writes the banner from the stock entrypoint, an upgrade writes it during
occ upgrade— so this is live for everyone on34.0.3:0. It is pre-existing in that release, not introduced here:origin/masterrejects the exact byteswriteData()emits, identically.What else was wrong
php.pegjswas adapted from a JSON grammar and kept JSON's string rules, so the read and write sides each disagreed with PHP — in opposite directions.Reading. The grammar matched
true/false/null/arraycase-sensitively and rejected raw control characters inside a single-quoted string. PHP'svar_export— which is how Nextcloud writesconfig.php— emitsNULLin uppercase and puts control characters in raw.INFandNANwere rejected outright. One command reaches each:CastHelper::castValuegates--type=floatonis_numeric, which accepts1e400, and(float)'1e400'isINF.Writing.
toSingleQuotedLiteralescaped control characters as\n,\r,\tand\uXXXXinside single quotes, where PHP decodes none of them. Measured against PHP 8.4:line1\nline2— 12 bytes, a literal backslashline1<LF>line2— 11 bytes, the original valueThe package round-tripped with itself because its parser shared the same wrong rule, so this was invisible from inside.
What changed
Both sides now follow PHP: keywords match case-insensitively,
INF/-INF/NANare read and written, a comment parses anywhere whitespace is allowed in all three forms, and a single-quoted string escapes only\and'with every other byte standing for itself.INF/NANhad to move on both sides together —String(Infinity)isInfinity, an undefined constant that would make Nextcloud itself fatal on every request.php-parser.jsis regenerated from the grammar with Peggy 5.1.0, andnpm run regen-parsernow reproduces it byte-for-byte. Nothing recorded how before, so a grammar edit could passcheckandbuildand ship the old parser.Two failure modes the read now refuses rather than compounds:
$CONFIGthat is not an array would merge down to the shape's defaults, and the write that follows would dropsecret,instanceidanddbpassword— anddbpasswordexists nowhere else, so Postgres access and backups would be unrecoverable. The read rejects it while the file is still intact.trusted_domainsis recovered rather than blanked.occmangles this key two ways:config:system:delete trusted_domains 1leaves a gapped PHP array, which reads back as a map, andconfig:system:set trusted_domains --value=…leaves a bare string. It is the one key the Configure action does not carry in its patch, somergewrites back whatever the read produced — blanking it toarray ( )makes Nextcloud reject every request as an untrusted domain. A bare*is never promoted: PHP ignores a scalar outright, so promoting one would newly trust every Host header.And a value the grammar cannot model is no longer a refusal at all.
occruns inside the service container, so a value that stops the read is a brick with no way to reach the command that would remove it — recovery needs filesystem access toconfig.php, which a StartOS user does not have. Two such values are reachable from documentedoccinvocations, becausevar_exportwrites them as expressions:Rather than teach the grammar those two literals,
Valuefalls back to capturing the source text of anything it cannot model and the serializer re-emits it byte for byte. The fallback needs a terminator lookahead to be reachable at all:Numbermatches the leading-9223372036854775807ofPHP_INT_MIN, and PEG does not retry an alternative once the enclosing sequence fails.It is bounded so that malformed input still fails loudly rather than being absorbed.
Rawstops at a newline, sincevar_exportnever spreads a scalar expression over two lines; a bare positional entry may not be raw; and;terminates it. Each of those three came from a case that misbehaved without it — a missing comma merged two entries into one blob,'a' 1,became a positional entry that re-serialized as invalid PHP, and a top-level raw value swallowed the statement terminator. Verified that a missing comma, a missing=>, an unterminated string, an unterminated comment, a multi-line short array and a truncated file all still throw.An unreadable
config.phpnow logs the parse error before rethrowing — the file, the line and the column, and never the line's text, which holds the database password and the instance secret. It is still fatal, but it says where.Verification
Interop against real PHP 8.4, both directions, over a corpus covering
NULL, booleans, ints, floats, exponents,INF/-INF/NAN, quotes, backslashes,$, unicode, embedded LF/TAB/CR/CRLF/ESC/NUL, a PEM blob, a literal\n, numeric-string keys, and nested, gapped and empty arrays includingapps_paths:var_exportbehind the realCONF_WARNINGbanner, we parse it, and every value equals PHP's own. PASSincludes the file, and every value compares===. PASSorigin/masterfails at the banner, line 3 column 1. So the test is not vacuous.config.phpon every start.config.phptaken off a running 33.0.8:3 instance (33 keys, including'\\OC\\Memcache\\APCu'andapps_paths) parses to a byte-identical value under old and new. Existing installs see no change, so no migration is needed.tsc --noEmit, the SDK lint gatelint.mjs(whichs9pk.mkruns betweencheckandbuild, and whichtscalone does not cover),npm run buildandprettier --checkare all clean; CI passes on both arches; a full localmake x86produces a valid s9pk reporting34.0.3:1.End-to-end, before the fix: on a StartOS 0.4.0 VM running 33.0.8:3, setting one unreadable value put the service into the restart loop described above, and deleting the line from
config.phprecovered it with no other intervention.Not covered
Numbers pass through a JavaScript double, so an integer above 2^53 loses precision on the next write and a whole-valued float (
1.0) comes back an integer. Both are recorded inAGENTS.md. Neither stops the service; they are silent rewrites of a value, and the same passthrough mechanism could close them by returning source text wheneverString(parseFloat(t)) !== t— deliberately left out of this PR as a separate call.A genuinely malformed
config.php— a missing comma, an unterminated string or comment — still throws, by design, and now logs the file and the position rather than failing mute.Review
Five review rounds ran over this branch, and each found something a previous one had introduced or missed:
INF/NANwere still reachable throughocc config:system:set --type=float --value=1e400, andarraywas still case-sensitive while the other keywords had become case-insensitive.The SDK lint gate rejected an
unknowninterpolation, somakeaborted and CI was red whiletscpassed; logging from the validator printed a config-read failure on healthy starts, becauseFileHelper.mergevalidates on-disk data inside a deliberatecatch (_) {}; and Peggy'sformatechoed the offending source line, which can be a secret.A de-listed trusted domain made backups throw, fixed by giving
trusted_domainsthe.catch()every other field already had.That fix was worse than the bug it fixed.
.catch([])wrote'trusted_domains' => array ( )through the Configure action, which locks you out of Nextcloud entirely. It recovers the hostnames now.The banner comment above, plus the non-array
$CONFIGguard — round 3 had removed the last field that would have refused such a merge.The remaining unparseable forms above.
occlives inside the service, so any value the grammar rejects is a brick with no in-band way out — measured on a StartOS box,config:system:set --type=integerwith an out-of-range negative writes one.Round 5 also explains how the banner survived four rounds: "PHP comments are unparseable" was established early and carried forward as a documented limitation, and every subsequent round was told not to re-raise it. Nobody asked whether anything actually writes one. Detail on each round is in the PR comments.
Version
34.0.3:0was published on 2026-08-24 and has since been pulled — de-indexed from the alpha registry, and its GitHub release, tag and S3 s9pks deleted — but anyone who installed it during that window still needs a path off it, so this takes34.0.3:1rather than reusing:0. No new migration, socurrent.tsis edited in place and the 34.0.3 release notes are kept — a user coming from 33.0.8:3 still needs them — with a one-lineFixedentry added in all five locales.