Files
JATMNandAyush7614 88ec97d563 fix(tools): publish file writes atomically instead of truncating in place (#1214)
* fix(tools): publish file writes atomically instead of truncating in place

Stage replacements before publication so failed writes preserve existing
contents, successful renames detach hard-link siblings, and creates publish
without replacing raced files. Preserve modes and contained symlinks, with
the existing validated in-place fallback when replacement is refused.

Rebuild Ayush7614's complete change from Twigpine/zero#1159 on current main.
The net patch is unchanged; original authorship is retained.

Original-commit: d97d1cf7ae
Original-commit: 9b994c1903
Original-commit: 48f65ca634
Original-PR: https://github.com/Twigpine/zero/pull/1159
Fixes: #1156

* fix(tools): preserve atomic publication when replacement is refused

Keep regular-file preimages and hard-link siblings intact after rename errors. Record published creates even when staging cleanup fails while preserving the cleanup error. Make the permission regression fixture independent of umask.

---------

Co-authored-by: Ayush7614 <[email protected]>
2026-11-09 12:25:24 -08:00

420 lines
17 KiB
Go

package tools
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"github.com/Twigpine/zero/internal/pathjail"
)
var errFileChangedDuringWrite = errors.New("file changed on disk before the write committed")
// errFilePublishedWithCleanupFailure marks an error after publication. Callers
// must record the committed bytes while still reporting the cleanup failure.
var errFilePublishedWithCleanupFailure = errors.New("file published, but staging cleanup failed")
// fileWriteBeforeCommit is a deterministic test hook. Production leaves it
// nil; tests use it to replace a path after observation but before opening the
// object that will actually be mutated.
var fileWriteBeforeCommit func(path string)
// fileWriteBeforePublish is a deterministic test hook that runs after the
// replacement bytes have been staged and before the destination's identity is
// re-checked for the publish. Tests use it to install a competing writer at the
// last point before publication.
var fileWriteBeforePublish func(path string)
// fileWriteStat is a deterministic test seam for proving that the opened-file
// identity is captured before the final preimage comparison. Production uses
// the file descriptor directly.
var fileWriteStat = func(file *os.File) (os.FileInfo, error) { return file.Stat() }
// fileWriteTempWrite is a deterministic test seam for a failure while the
// replacement bytes are written to the side file, after the observed file's
// identity and preimage have been accepted. Production writes through
// io.WriteString. The failure it stands in for (a full disk, a failing
// device) is exactly the moment a truncate-then-write has already destroyed
// the observed file.
var fileWriteTempWrite = func(file *os.File, content string) error {
_, err := io.WriteString(file, content)
return err
}
// fileWriteRename publishes the staged side file over the destination.
// Tests replace it to exercise refused atomic publication deterministically.
var fileWriteRename = func(root *os.Root, oldName, newName string) error {
return root.Rename(oldName, newName)
}
// fileWriteNoReplaceRename publishes a create without replacing an existing
// name. Tests replace it to exercise the hard-link fallback that filesystems
// without a no-replace rename use.
var fileWriteNoReplaceRename = publishRootedNoReplace
// fileWriteRemove removes a staging name. Tests inject cleanup failures after
// publication to distinguish a committed create from a failed write.
var fileWriteRemove = func(root *os.Root, name string) error {
return root.Remove(name)
}
// commitRootedFileContents binds an overwrite to the file identity and bytes
// that the caller observed, opening every component through an already-open
// *os.Root descriptor rather than re-resolving the absolute path. Because the
// ancestor directories are traversed relative to the root handle, a parent
// swapped for a symlink that escapes the workspace between validation and this
// call is refused by the kernel instead of redirecting the write.
//
// Both publishes are atomic against a concurrent reader: the replacement bytes
// are written whole to a side file in the target's directory and the target is
// swapped wholesale. A create publishes through a kernel no-replace rename, so
// losing the create race refuses instead of replacing the raced file. An
// overwrite publishes through a rename after re-checking that the destination
// still names the observed object; that check is the last operation before the
// rename, but no supported platform offers a compare-and-swap rename, so a
// writer that replaces the name between the check and the syscall can still be
// replaced. A failure at any point before the publish removes the side file
// and leaves the observed bytes standing — the caller's file outlives a write
// that could not complete, and no partial state is ever visible at the path
// itself.
//
// A final-component symlink keeps main's behaviour: the link stays a link and
// the resolved file is what is replaced. A hard link is detached by the rename
// instead of rewritten through the shared inode. If the platform refuses the
// rename, the overwrite fails and the observed file remains intact.
//
// relativePath is the target expressed relative to root; absolutePath is
// retained only for the test hooks and error reporting.
//
// expectedInfo nil means the caller observed a missing path. expectedContent
// may be nil for an existing but unreadable file; that path may still be
// overwritten, but callers must omit rich before/after evidence.
func commitRootedFileContents(root *os.Root, absolutePath, relativePath string, expectedInfo os.FileInfo, expectedContent *string, content string) error {
if fileWriteBeforeCommit != nil {
fileWriteBeforeCommit(absolutePath)
}
if expectedInfo == nil {
return commitRootedFileCreate(root, relativePath, content)
}
flags := os.O_WRONLY
if expectedContent != nil {
flags = os.O_RDWR
}
file, err := root.OpenFile(relativePath, flags, 0)
if err != nil {
return err
}
openedInfo, err := fileWriteStat(file)
if err != nil {
_ = file.Close()
return err
}
if !os.SameFile(expectedInfo, openedInfo) {
_ = file.Close()
return errFileChangedDuringWrite
}
pathInfo, err := root.Stat(relativePath)
if err != nil || !os.SameFile(openedInfo, pathInfo) {
_ = file.Close()
return errFileChangedDuringWrite
}
if expectedContent != nil {
current, readErr := io.ReadAll(file)
if readErr != nil {
_ = file.Close()
return readErr
}
if string(current) != *expectedContent {
_ = file.Close()
return errFileChangedDuringWrite
}
}
// A device, FIFO or socket is not a file this path should replace with a
// regular one, and the previous in-place write is the only publish that
// keeps its semantics. Regular files (including symlink targets) go through
// staging and rename.
if !openedInfo.Mode().IsRegular() {
return writeRootedFileInPlace(root, relativePath, file, openedInfo, expectedContent, content)
}
mode := openedInfo.Mode().Perm()
publishPath, err := resolveRootedFinalPath(root, relativePath)
if err != nil {
_ = file.Close()
return err
}
if publishPath != relativePath {
targetInfo, statErr := root.Stat(publishPath)
if statErr != nil || !os.SameFile(openedInfo, targetInfo) {
_ = file.Close()
return fmt.Errorf("%w: link target changed", errFileChangedDuringWrite)
}
}
if err := file.Close(); err != nil {
return err
}
return commitRootedFileOverwrite(root, absolutePath, relativePath, publishPath, openedInfo, mode, content)
}
// commitRootedFileCreate publishes a brand-new file whose absence the caller
// observed. The bytes are written whole to a private side file beside the
// target and handed to the name with a kernel no-replace rename: the create is
// either absent or the whole file, a concurrent create is refused rather than
// replaced, and a write that cannot complete leaves no file at all.
func commitRootedFileCreate(root *os.Root, relativePath, content string) error {
tempName, err := writeRootedSideFile(root, relativePath, 0o644, true, content)
if err != nil {
return err
}
if err := publishRootedCreate(root, tempName, relativePath); err != nil {
if errors.Is(err, errFilePublishedWithCleanupFailure) {
return err
}
return errors.Join(err, removeWriteSideFile(root, tempName))
}
return nil
}
// publishRootedCreate hands the staged side file to a name that must not
// already exist. RenameNoReplace is the primary primitive; a hard link is the
// no-replace fallback for filesystems whose rename cannot promise it. Neither
// fallback copies into a visible target, so a reader sees only the absent name
// or the complete file.
func publishRootedCreate(root *os.Root, tempName, relativePath string) error {
renameErr := fileWriteNoReplaceRename(root, tempName, relativePath)
if renameErr == nil {
return nil
}
if errors.Is(renameErr, os.ErrExist) {
return fmt.Errorf("creating %s: %w", relativePath, renameErr)
}
if !renameNoReplaceUnsupported(renameErr) {
return fmt.Errorf("creating %s: %w", relativePath, renameErr)
}
if linkErr := root.Link(tempName, relativePath); linkErr != nil {
return fmt.Errorf("creating %s: %w", relativePath, errors.Join(renameErr, linkErr))
}
// The staging name now shares the published inode. Remove only the name,
// without chmod-based retries that could change the published file's mode.
if cleanupErr := fileWriteRemove(root, tempName); cleanupErr != nil && !errors.Is(cleanupErr, os.ErrNotExist) {
return fmt.Errorf("creating %s: %w: %w", relativePath, errFilePublishedWithCleanupFailure, cleanupErr)
}
return nil
}
// publishRootedNoReplace performs the no-replace rename through a root handle
// bound to the target's own directory. Binding the directory first is what
// keeps the raw rename inside the workspace: a parent link swapped after the
// caller resolved the path is refused when the directory is opened, and both
// names are single components below that handle.
func publishRootedNoReplace(root *os.Root, tempName, relativePath string) error {
parent := filepath.Dir(relativePath)
if filepath.Dir(tempName) != parent {
return fmt.Errorf("staging file %s is not beside %s", tempName, relativePath)
}
directory, err := root.OpenRoot(parent)
if err != nil {
return err
}
defer directory.Close()
return pathjail.RenameNoReplace(directory, filepath.Base(tempName), filepath.Base(relativePath))
}
// renameNoReplaceUnsupported reports whether the platform or filesystem told
// us only that it cannot perform a no-replace rename, so the no-replace hard
// link is the right fallback. A collision is not an unsupported operation and
// is handled by the caller before this is consulted.
func renameNoReplaceUnsupported(err error) bool {
return errors.Is(err, errors.ErrUnsupported) || errors.Is(err, syscall.EINVAL)
}
// commitRootedFileOverwrite publishes a replacement for an object whose
// identity and preimage just passed validation: the bytes are written to a
// side file beside the target, the observed identity is re-checked against the
// path as late as the rename allows, and a rename hands the path over in one
// step. A failure while the side file is being written — ENOSPC, a failing
// device — therefore costs a removed side file, not the observed file.
//
// Refused renames fail without mutating the observed inode. Writing in place
// here would expose partial content and rewrite hard-link siblings, defeating
// the guarantees that staging provides.
func commitRootedFileOverwrite(root *os.Root, absolutePath, relativePath, publishPath string, openedInfo os.FileInfo, mode os.FileMode, content string) error {
tempName, err := writeRootedSideFile(root, publishPath, mode, false, content)
if err != nil {
return err
}
if fileWriteBeforePublish != nil {
fileWriteBeforePublish(absolutePath)
}
if err := verifyRootedPublishTarget(root, publishPath, openedInfo); err != nil {
return errors.Join(err, removeWriteSideFile(root, tempName))
}
if err := fileWriteRename(root, tempName, publishPath); err != nil {
renameErr := fmt.Errorf("writing %s: atomic replacement refused: %w", relativePath, err)
return errors.Join(renameErr, removeWriteSideFile(root, tempName))
}
return nil
}
// verifyRootedPublishTarget refuses to replace anything but the exact object
// whose identity and preimage were validated. It is deliberately the last
// operation before the rename; no portable rename offers a compare-and-swap,
// so this check is the strongest condition available before an atomic replace.
func verifyRootedPublishTarget(root *os.Root, publishPath string, openedInfo os.FileInfo) error {
info, err := root.Lstat(publishPath)
if err != nil || info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 || !os.SameFile(openedInfo, info) {
return fmt.Errorf("%w: path identity changed", errFileChangedDuringWrite)
}
return nil
}
// resolveRootedFinalPath follows final-component symlinks inside the root and
// returns the path of the file the caller's link resolves to. Publishing over
// that path preserves the link, unlike a rename over the link itself.
func resolveRootedFinalPath(root *os.Root, relativePath string) (string, error) {
current := relativePath
for links := 0; links < 255; links++ {
info, err := root.Lstat(current)
if err != nil {
return "", err
}
if info.Mode()&os.ModeSymlink == 0 {
return current, nil
}
target, err := root.Readlink(current)
if err != nil {
return "", err
}
if filepath.IsAbs(target) || filepath.VolumeName(target) != "" {
return "", fmt.Errorf("%w: %s is an absolute link", errFileChangedDuringWrite, current)
}
next := filepath.Clean(filepath.Join(filepath.Dir(current), target))
if next == ".." || strings.HasPrefix(next, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("%w: %s points outside the workspace", errFileChangedDuringWrite, current)
}
current = next
}
return "", fmt.Errorf("%w: more than 255 links at %s", errFileChangedDuringWrite, relativePath)
}
// writeRootedSideFile writes content to a fresh side file beside the target,
// relative to the root handle, and leaves it closed and ready to publish. The
// caller owns removing the side file when the publish that follows does not
// commit.
//
// umaskDerived selects the permission contract. A create asks the kernel for
// 0o644 and remembers what the process umask actually granted; the bytes are
// made owner-only while they are staged, then the granted bits are restored.
// An overwrite stages 0o600 and finally carries the observed file's permission
// bits over, so the published file looks like the one it replaced.
func writeRootedSideFile(root *os.Root, relativePath string, mode os.FileMode, umaskDerived bool, content string) (string, error) {
createMode := mode.Perm()
if !umaskDerived {
createMode = 0o600
}
temp, tempName, err := pathjail.CreateTempMode(root, filepath.Dir(relativePath), "zero-write", ".tmp", createMode)
if err != nil {
return "", fmt.Errorf("writing %s: %w", relativePath, err)
}
granted := mode.Perm()
if umaskDerived {
info, statErr := temp.Stat()
if statErr != nil {
_ = temp.Close()
_ = removeWriteSideFile(root, tempName)
return "", fmt.Errorf("writing %s: %w", relativePath, statErr)
}
granted = info.Mode().Perm()
if chmodErr := temp.Chmod(0o600); chmodErr != nil {
_ = temp.Close()
_ = removeWriteSideFile(root, tempName)
return "", fmt.Errorf("writing %s: %w", relativePath, chmodErr)
}
}
writeErr := func() error {
if err := fileWriteTempWrite(temp, content); err != nil {
return err
}
return temp.Chmod(granted.Perm())
}()
closeErr := temp.Close()
if writeErr != nil || closeErr != nil {
_ = removeWriteSideFile(root, tempName)
return "", fmt.Errorf("writing %s: %w", relativePath, errors.Join(writeErr, closeErr))
}
return tempName, nil
}
// writeRootedFileInPlace publishes through an already-validated handle. It is
// the non-regular path, so it keeps the previous truncate-and-write behaviour
// and restores the observed bytes if a write fails and the preimage is available.
func writeRootedFileInPlace(root *os.Root, relativePath string, file *os.File, openedInfo os.FileInfo, expectedContent *string, content string) error {
if err := file.Truncate(0); err != nil {
_ = file.Close()
return err
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
_ = file.Close()
return err
}
if _, err := io.WriteString(file, content); err != nil {
restoreErr := restoreRootedFile(file, expectedContent)
closeErr := file.Close()
return errors.Join(err, restoreErr, closeErr)
}
if err := file.Close(); err != nil {
return err
}
pathInfo, err := root.Stat(relativePath)
if err != nil || !os.SameFile(openedInfo, pathInfo) {
return fmt.Errorf("%w: path identity changed", errFileChangedDuringWrite)
}
return nil
}
// restoreRootedFile puts the observed bytes back after a failed in-place
// write. The write already truncated the file, so this is best effort: the
// caller receives both the original failure and any restore failure.
func restoreRootedFile(file *os.File, preimage *string) error {
if preimage == nil {
return nil
}
if err := file.Truncate(0); err != nil {
return fmt.Errorf("restoring the preimage: %w", err)
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("restoring the preimage: %w", err)
}
if _, err := io.WriteString(file, *preimage); err != nil {
return fmt.Errorf("restoring the preimage: %w", err)
}
return nil
}
// removeWriteSideFile removes a staged replacement that never reached (or did
// not survive) its publish, through the root handle.
func removeWriteSideFile(root *os.Root, name string) error {
err := fileWriteRemove(root, name)
if err == nil || errors.Is(err, os.ErrNotExist) {
return nil
}
// Windows refuses to delete a read-only file. The staged file only carries
// that bit after its published mode was applied; clear it through the root
// and retry. The name belongs to a random side file this commit created.
if errors.Is(err, os.ErrPermission) {
if chmodErr := root.Chmod(name, 0o600); chmodErr == nil {
err = fileWriteRemove(root, name)
if err == nil || errors.Is(err, os.ErrNotExist) {
return nil
}
}
}
return err
}