Make atomic file replacement crash durable
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
package fileops
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -10,7 +12,32 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WriteFileAtomic writes data to dst atomically via temp file + rename.
|
||||
// ErrDirectorySyncUnsupported reports that the platform or filesystem cannot
|
||||
// make a renamed directory entry crash-durable. The replacement may be visible,
|
||||
// but callers must not treat it as having the same durability guarantee.
|
||||
var ErrDirectorySyncUnsupported = errors.New("directory sync is unsupported")
|
||||
|
||||
// ReplaceFileOptions describes the caller-owned parts of a byte-file
|
||||
// replacement. The destination parent must already exist. BeforeRename runs
|
||||
// after the temporary file is complete and durable, but before replacement is
|
||||
// committed; callers can use it for cancellation or other commit checks.
|
||||
type ReplaceFileOptions struct {
|
||||
Mode os.FileMode
|
||||
BeforeRename func() error
|
||||
}
|
||||
|
||||
// ReplaceFileAtomic durably replaces dst with data. It creates a sibling
|
||||
// temporary file, writes and syncs its bytes and mode, atomically renames it,
|
||||
// then syncs the destination parent. On platforms where replacement of an
|
||||
// existing destination or directory syncing is unavailable, it returns an
|
||||
// error rather than claiming an equivalent durability guarantee.
|
||||
func ReplaceFileAtomic(dst string, data []byte, options ReplaceFileOptions) error {
|
||||
return replaceFileAtomicWithOperations(dst, data, options, systemAtomicReplacementOperations)
|
||||
}
|
||||
|
||||
// WriteFileAtomic creates the destination parent with workspace permissions
|
||||
// and durably replaces dst with data. Callers that own parent-directory policy
|
||||
// or need a pre-commit check should use ReplaceFileAtomic directly.
|
||||
func WriteFileAtomic(dst string, data []byte, perm os.FileMode) error {
|
||||
if strings.TrimSpace(dst) == "" {
|
||||
return fmt.Errorf("destination path is required")
|
||||
@@ -18,48 +45,17 @@ func WriteFileAtomic(dst string, data []byte, perm os.FileMode) error {
|
||||
if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil {
|
||||
return fmt.Errorf("create destination directory: %w", err)
|
||||
}
|
||||
|
||||
base := filepath.Base(dst)
|
||||
tmp, err := os.CreateTemp(filepath.Dir(dst), "."+base+".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
removeTmp := true
|
||||
defer func() {
|
||||
if removeTmp {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("sync temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmpPath, perm); err != nil {
|
||||
return fmt.Errorf("set temp file permissions: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, dst); err != nil {
|
||||
return fmt.Errorf("install temp file: %w", err)
|
||||
}
|
||||
removeTmp = false
|
||||
return nil
|
||||
return ReplaceFileAtomic(dst, data, ReplaceFileOptions{Mode: perm})
|
||||
}
|
||||
|
||||
// CopyFileAtomic copies src to dst atomically via temp file + rename.
|
||||
// CopyFileAtomic copies src to dst through the durable replacement sequence.
|
||||
func CopyFileAtomic(src, dst string, perm os.FileMode) error {
|
||||
_, err := CopyFileAtomicWithChecksum(src, dst, perm)
|
||||
return err
|
||||
}
|
||||
|
||||
// CopyFileAtomicWithChecksum copies src to dst atomically and returns the SHA-256 checksum.
|
||||
// CopyFileAtomicWithChecksum copies src to dst through the durable replacement
|
||||
// sequence and returns the SHA-256 checksum of the copied bytes.
|
||||
func CopyFileAtomicWithChecksum(src, dst string, perm os.FileMode) (string, error) {
|
||||
if strings.TrimSpace(src) == "" || strings.TrimSpace(dst) == "" {
|
||||
return "", fmt.Errorf("source and destination paths are required")
|
||||
@@ -75,42 +71,22 @@ func CopyFileAtomicWithChecksum(src, dst string, perm os.FileMode) (string, erro
|
||||
return "", fmt.Errorf("create destination directory: %w", err)
|
||||
}
|
||||
|
||||
base := filepath.Base(dst)
|
||||
tmp, err := os.CreateTemp(filepath.Dir(dst), "."+base+".tmp-*")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
removeTmp := true
|
||||
defer func() {
|
||||
if removeTmp {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
|
||||
digest := sha256.New()
|
||||
if _, err := io.Copy(io.MultiWriter(tmp, digest), in); err != nil {
|
||||
_ = tmp.Close()
|
||||
return "", fmt.Errorf("copy file: %w", err)
|
||||
err = replaceFileFromReaderWithOperations(
|
||||
dst,
|
||||
io.TeeReader(in, digest),
|
||||
ReplaceFileOptions{Mode: perm},
|
||||
systemAtomicReplacementOperations,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return "", fmt.Errorf("sync temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmpPath, perm); err != nil {
|
||||
return "", fmt.Errorf("set temp file permissions: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, dst); err != nil {
|
||||
return "", fmt.Errorf("install temp file: %w", err)
|
||||
}
|
||||
removeTmp = false
|
||||
return hex.EncodeToString(digest.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// InstallDownloadedTempFile installs a previously downloaded temp file at dst.
|
||||
// InstallDownloadedTempFile installs a fully downloaded sibling temporary file
|
||||
// at dst. On failure before the rename, the caller retains ownership of tmpPath
|
||||
// and is responsible for cleanup.
|
||||
func InstallDownloadedTempFile(tmpPath, dst string, perm os.FileMode) error {
|
||||
if strings.TrimSpace(tmpPath) == "" || strings.TrimSpace(dst) == "" {
|
||||
return fmt.Errorf("temp and destination paths are required")
|
||||
@@ -118,11 +94,137 @@ func InstallDownloadedTempFile(tmpPath, dst string, perm os.FileMode) error {
|
||||
if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil {
|
||||
return fmt.Errorf("create destination directory: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmpPath, perm); err != nil {
|
||||
return fmt.Errorf("set temp file permissions: %w", err)
|
||||
return installDownloadedTempFileWithOperations(tmpPath, dst, perm, systemAtomicReplacementOperations)
|
||||
}
|
||||
|
||||
type temporaryFile interface {
|
||||
io.Writer
|
||||
Name() string
|
||||
Sync() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type atomicReplacementOperations struct {
|
||||
createTemp func(string, string) (temporaryFile, error)
|
||||
chmod func(string, os.FileMode) error
|
||||
rename func(string, string) error
|
||||
remove func(string) error
|
||||
syncDirectory func(string) error
|
||||
syncFile func(string) error
|
||||
}
|
||||
|
||||
var systemAtomicReplacementOperations = atomicReplacementOperations{
|
||||
createTemp: func(dir, pattern string) (temporaryFile, error) {
|
||||
return os.CreateTemp(dir, pattern)
|
||||
},
|
||||
chmod: os.Chmod,
|
||||
rename: os.Rename,
|
||||
remove: os.Remove,
|
||||
syncDirectory: syncDirectory,
|
||||
syncFile: syncFile,
|
||||
}
|
||||
|
||||
func replaceFileAtomicWithOperations(
|
||||
dst string,
|
||||
data []byte,
|
||||
options ReplaceFileOptions,
|
||||
ops atomicReplacementOperations,
|
||||
) error {
|
||||
return replaceFileFromReaderWithOperations(dst, bytes.NewReader(data), options, ops)
|
||||
}
|
||||
|
||||
func replaceFileFromReaderWithOperations(
|
||||
dst string,
|
||||
source io.Reader,
|
||||
options ReplaceFileOptions,
|
||||
ops atomicReplacementOperations,
|
||||
) (resultErr error) {
|
||||
if strings.TrimSpace(dst) == "" {
|
||||
return fmt.Errorf("destination path is required")
|
||||
}
|
||||
if err := os.Rename(tmpPath, dst); err != nil {
|
||||
return fmt.Errorf("install downloaded file: %w", err)
|
||||
|
||||
dir := filepath.Dir(dst)
|
||||
tmp, err := ops.createTemp(dir, "."+filepath.Base(dst)+".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
removeTmp := true
|
||||
defer func() {
|
||||
if removeTmp {
|
||||
if err := ops.remove(tmpPath); err != nil {
|
||||
cleanupErr := fmt.Errorf("remove temporary file: %w", err)
|
||||
if resultErr == nil {
|
||||
resultErr = cleanupErr
|
||||
} else {
|
||||
resultErr = errors.Join(resultErr, cleanupErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := io.Copy(tmp, source); err != nil {
|
||||
return closeTemporaryFileAfterError(tmp, fmt.Errorf("write temp file: %w", err))
|
||||
}
|
||||
if err := ops.chmod(tmpPath, options.Mode); err != nil {
|
||||
return closeTemporaryFileAfterError(tmp, fmt.Errorf("set temp file permissions: %w", err))
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return closeTemporaryFileAfterError(tmp, fmt.Errorf("sync temp file: %w", err))
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
if options.BeforeRename != nil {
|
||||
if err := options.BeforeRename(); err != nil {
|
||||
return fmt.Errorf("check before file replacement: %w", err)
|
||||
}
|
||||
}
|
||||
if err := ops.rename(tmpPath, dst); err != nil {
|
||||
return fmt.Errorf("install temp file: %w", err)
|
||||
}
|
||||
removeTmp = false
|
||||
if err := ops.syncDirectory(dir); err != nil {
|
||||
return fmt.Errorf("sync destination directory after replacement: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func closeTemporaryFileAfterError(tmp temporaryFile, operationErr error) error {
|
||||
if err := tmp.Close(); err != nil {
|
||||
return errors.Join(operationErr, fmt.Errorf("close temporary file: %w", err))
|
||||
}
|
||||
return operationErr
|
||||
}
|
||||
|
||||
func installDownloadedTempFileWithOperations(
|
||||
tmpPath, dst string,
|
||||
perm os.FileMode,
|
||||
ops atomicReplacementOperations,
|
||||
) error {
|
||||
if err := ops.chmod(tmpPath, perm); err != nil {
|
||||
return fmt.Errorf("set temp file permissions: %w", err)
|
||||
}
|
||||
if err := ops.syncFile(tmpPath); err != nil {
|
||||
return fmt.Errorf("sync downloaded temp file: %w", err)
|
||||
}
|
||||
if err := ops.rename(tmpPath, dst); err != nil {
|
||||
return fmt.Errorf("install downloaded file: %w", err)
|
||||
}
|
||||
if err := ops.syncDirectory(filepath.Dir(dst)); err != nil {
|
||||
return fmt.Errorf("sync destination directory after downloaded-file replacement: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncFile(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user