Skip to content

fix: replace config.xml atomically instead of truncating it - #901

Merged
blaipr merged 1 commit into
mainfrom
fix/config-xml-is-replaced-atomically
Sep 2, 2026
Merged

fix: replace config.xml atomically instead of truncating it#901
blaipr merged 1 commit into
mainfrom
fix/config-xml-is-replaced-atomically

Conversation

@blaipr

@blaipr blaipr commented Sep 2, 2026

Copy link
Copy Markdown
Member

FileHandler::save() did ftruncate(0) and then fwrite(). Between those two calls the file is
empty on disk.

config.xml goes through this method. It holds the database credentials, the password salt and the
master-password hash, so a process killed in that window — an OOM kill, a container stopped
mid-save, the host losing power — leaves an installation that cannot boot. It also cannot be
repaired through the UI: the DI container is built before Init runs, and the install route refuses
once <installed> has been set. Recovery means editing files on the server.

There is nothing to fall back on, either. ConfigBackupService::backup() exists and is called from
nowhere in src/ — which is a second finding, and not this PR's to fix.

The change

save() writes a sibling temp file and renames it over the target. rename() within a filesystem
is atomic, so a reader sees either the whole old file or the whole new one, and a process that dies
at any point leaves the original untouched.

The lock stays, but it is worth being clear that it was never what protected readers and could not
have been: XmlFileStorage::load() hands the path to DOMDocument, and readToString() reads
by path too, so neither has ever taken this handle's lock. What the lock still does is order two
writers holding the same open file — and losing one of two concurrent saves is a much smaller
problem than losing the file.

Two things a rename has to carry over by hand, and does:

  • Permissions. The temp file is created private, then given the target's own mode before the
    rename. config/config.xml is 0644 with its directory held at 0750 and that arrangement is
    deliberate, so a save must not change either half of it.
  • The stat cache. This handle refers to the file that was just replaced. SplFileInfo stats the
    pathname rather than the descriptor, so clearing the stat cache is enough — which matters because
    ConfigFile::isExpired() compares the config cache against getFileTime(), and a stale mtime
    there would leave the cache looking current after every save.

One behaviour change, deliberate

Replacing a file needs write permission on its directory, not just on the file. In this
application that is already the case — ConfigUtil holds config/ at 0750 owned by the web user —
but it is a real difference and worth stating.

A read-only handle is still refused. That used to fall out of fwrite() failing on the stream;
since the write no longer goes through the handle, save() checks the mode it was opened with.

Tests

Five, of which two fail against the old implementation:

test old
the file is replaced, not truncated (the inode changes) fails
a save that cannot write its temp file leaves the file as it was fails
permissions survive the replacement passes — an invariant, not a distinguisher
no temp file is left behind passes
getFileTime()/getFileSize() still describe what is on disk passes

The failure is forced deterministically by putting a directory where the temp file wants to go,
which blocks root as well — the suite runs as root, so permission-based denial would prove nothing.

FileHandler::save() did ftruncate(0) and then fwrite(), so the file is empty on disk
between the two. config.xml goes through this method — database credentials, password
salt, master-password hash — and a process killed in that window leaves an installation
that cannot boot and cannot be repaired through the UI, because the DI container is built
before Init runs and the install route refuses once <installed> is set.

There is nothing to fall back on either: ConfigBackupService::backup() exists and is
called from nowhere in src/. That is a separate finding, not fixed here.

save() now writes a sibling temp file and renames it over the target. rename() within a
filesystem is atomic, so a reader sees either the whole old file or the whole new one, and
a process that dies at any point leaves the original untouched. The lock stays, but it was
never what protected readers and could not have been: XmlFileStorage::load() hands the
path to DOMDocument and readToString() reads by path, so neither ever took it.

Two things the rename carries over by hand — the target's permissions, and a cleared stat
cache, which matters because ConfigFile::isExpired() compares the config cache against
getFileTime().

One deliberate behaviour change: replacing a file needs write permission on its directory,
not just on the file. ConfigUtil already holds config/ at 0750 owned by the web user. A
read-only handle is still refused, now by checking the mode it was opened with rather than
by letting fwrite() fail on the stream.
@blaipr
blaipr merged commit 11e320d into main Sep 2, 2026
8 checks passed
@blaipr
blaipr deleted the fix/config-xml-is-replaced-atomically branch September 2, 2026 23:27
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