mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 13:31:26 -10:00
* 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:d97d1cf7aeOriginal-commit:9b994c1903Original-commit:48f65ca634Original-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]>
420 lines
17 KiB
Go
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
|
|
}
|