config: stop trusting that config writes always succeed - #10646
Open
cclements wants to merge 1 commit into
Open
Conversation
save() ignored the results of ftruncate()/fwrite()/fflush(), so a short write (disk full, I/O error) would leave a truncated config.xml behind while reporting success. backup() then archived that damaged file via File::file_put_contents() -- also unchecked -- and cleanupBackups() pruned the older intact backups regardless. With a backup count of one this can delete the last recoverable configuration and send the next boot to factory defaults; with default settings it still loses the revision silently. Verify the written byte count, fsync before touching the backups, throw ConfigException on failure (write_config() already handles that), drop partial backup files instead of letting them shadow intact ones, and skip backup pruning whenever the new backup did not make it to disk. The in-place write stays: replacing it with rename() would break the flock()/fstat() coordination on the persistent file handle that every reader and writer relies on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Member
|
Although in theory it might sound nice to guard this code, in reality trying to fix a full disk is rather futile, removing a broken backup file isn't a huge issue, but being able to restore anything useful is probably not possible anyway in that case. If there is a real world scenario where it makes sense to guard this further I'm all open for it, but in practice there haven't been reports on these parts in a very long time. |
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.
While digging into how config.xml is written I noticed that
Config::save()neverlooks at what
ftruncate(),fwrite()andfflush()return. The file has alreadybeen truncated at that point, so if the write comes up short — disk full is the easy
way to hit this — we end up with a truncated config.xml and no error anywhere.
It gets worse from there.
backup()then copies that damaged file into the backupdirectory (the
File::file_put_contents()result is ignored too, and it even@touches the target first, so a completely failed write still leaves a zero-byte"backup" behind), and
cleanupBackups()prunes old backups purely by count. Withbackupcountset to 1 that sequence deletes the last good backup, and the next bootwalks through the recovery loop in
init(), finds nothing parseable, and lands onthe factory config. With the default of 100 you "only" lose the revision silently
and the system quietly restores an older backup on the next boot.
The both-files-broken scenario isn't hypothetical — 680846d already dealt with
the kernel-crash variant of it.
What this does:
save()builds the XML string once, checks the truncate, compares the byte countfrom
fwrite()againststrlen(), and adds anfsync()so the data isn't stillsitting in the page cache while we start copying backups around. On any failure it
releases the lock and throws
ConfigException—write_config()on the legacy sidealready catches exactly that, so callers get a real failure instead of a thumbs up.
backup()now returns null when the source read or the copy fails, and unlinks thepartial file so it can't shadow the intact older backups during boot-time recovery.
Backup pruning is skipped whenever the new backup didn't make it to disk, so a
failed save can never eat the history anymore. A failed backup no longer passes in
silence either: the config was written, so save() doesn't throw for it, but it does
log an error now (previously the audit trail would just show a backup that was in
fact broken).
One thing reviewers may care about:
backup()is public, so any out-of-tree callerthat assumed it always returns a string needs to cope with null on failure now. In
core the only caller is
save()itself.What it deliberately doesn't do: switch to write-temp-then-rename. That would be the
textbook fix, but the whole locking model here is
flock()on a persistent handle,plus
hasChanged()doingfstat()on it, plusloadFromStream()readers waitingon the writer's lock — rename swaps the inode out from under all of that. Overwriting
in place while keeping the handle open is load-bearing, so I stayed inside that
design.
fsync()needs PHP 8.1, which we are comfortably past.🤖 Generated with Claude Code