Skip to content

basicio: write through a temp file so the original survives an interrupted metadata write (#9482) - #9483

Closed
AetherAI3 wants to merge 1 commit into
Exiv2:mainfrom
AetherAI3:fix/9482-transfer-atomic-rename
Closed

basicio: write through a temp file so the original survives an interrupted metadata write (#9482)#9483
AetherAI3 wants to merge 1 commit into
Exiv2:mainfrom
AetherAI3:fix/9482-transfer-atomic-rename

Conversation

@AetherAI3

@AetherAI3 AetherAI3 commented Sep 6, 2026

Copy link
Copy Markdown

Fix for #9482.

The bug

FileIo::transfer has two branches. When the source is another
FileIo (a temp file), it renames it into place. When the source is
not — which is the branch JPEG metadata writes reach through MemIo
— it opens the target with "w+b" first, and "w+b" truncates. If
anything between that truncate and the write completing goes wrong
(a crash, a failing src.open(), out of space during the write), the
original image is left as a zero-byte file with no backup.

The issue's syscall trace shows the same on the current main.

The change

Route the generic branch through the same shape the FileIo branch
already uses:

  • Build the new bytes in a temp file next to the target
    (<path>.exv-tmp-<pid>[-<counter>], same directory so fs::rename
    is atomic on POSIX and ReplaceFileA works on Windows).
  • Rename atomically only after the temp file closes cleanly.
  • On any failure along the temp path — tmp.open, src.open,
    write, close — remove the temp file and throw. The original is
    never touched.
  • Preserve the original file's permissions, the same way the FileIo
    branch already does.

Test

AFileIO.transferGenericBranchPreservesOriginalOnSourceOpenFailure_9482
in unitTests/test_FileIo.cpp. It writes a fixture file, calls
transfer with a MemIo whose open() returns -1, and asserts the
fixture is still there with its original bytes. On main at
dc9364b it fails with the fixture reduced to 0 bytes; with this
change it passes.

Also checked the happy path: exiv2 rm on a real JPEG still strips
metadata (118 685 → 115 163 bytes, no .exv-tmp files left behind),
and the full AFileIO.* suite runs 11 / 11.

Reporter

Thanks to @yottayoshida — the report has a syscall-level trace and
sets out the two possible responses; this PR is option 1.

AI assistance

I used an AI assistant to help draft the patch, the test and this
description. I read every line, checked the invariants against the
fixed code, and ran the tests myself before opening this.

  • I have re-read every line and this description is in my own words.

…xiv2#9482)

FileIo::transfer's generic branch opened the target with "w+b" (which
truncates) before the new bytes had been written. A crash, a source-open
failure, or an out-of-space error between the truncate and write() left
a zero-byte file where the original image was, with no backup.

This mirrors the FileIo branch above: build the new content in a
temporary file next to the target, then rename it into place — atomic
on POSIX (fs::rename) and via ReplaceFileA on Windows. The original
bytes are only unlinked at the moment the rename installs the new file.

Original permissions are preserved the same way the FileIo branch
already does.

Added unit test AFileIO.transferGenericBranchPreservesOriginalOnSourceOpenFailure_9482
that constructs a MemIo whose open() fails, calls transfer, and asserts
the target file still exists with its original bytes. It fails on main
(file becomes 0 bytes) and passes with this change.

Reported-by: yottayoshida (https://github.com/yottayoshida)
Signed-off-by: Brandon Barrante <aetherai@aethersystems.net>
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.

2 participants