diff --git a/backend/fileutil/atomicwrite.go b/backend/fileutil/atomicwrite.go new file mode 100644 index 0000000..d8c79b6 --- /dev/null +++ b/backend/fileutil/atomicwrite.go @@ -0,0 +1,101 @@ +// Package fileutil provides file system utilities for safe file operations. +package fileutil + +import ( + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "syscall" +) + +// ErrCrossDevice is returned when an atomic rename fails because the temp file +// and target reside on different filesystems. There is no copy-then-delete +// fallback — callers must ensure both paths share the same mount point. +var ErrCrossDevice = errors.New("atomic write: cross-device rename not supported") + +// tmpSuffix is the deterministic extension appended to the target path for the +// temporary file. A fixed suffix (rather than a random one) makes orphan +// cleanup straightforward. +const tmpSuffix = ".yj-tmp" + +// AtomicWrite writes to targetPath atomically. It creates a temporary file in +// the same directory as targetPath (with a ".yj-tmp" suffix), passes it to fn +// for writing, syncs and closes the file, then renames it over targetPath. If +// the target already exists its permission bits are preserved; otherwise the +// new file receives mode 0644. +// +// Before writing, any orphaned .yj-tmp file from a previous interrupted +// operation is removed. If removal fails it is logged at debug level and the +// write proceeds. +// +// On any error after the temp file is created the temp file is removed. +func AtomicWrite(logger *slog.Logger, targetPath string, fn func(tmp *os.File) error) (err error) { + tmpPath := targetPath + tmpSuffix + + // --- 1. Clean orphaned temp file from a previous crash ---------------- + if _, statErr := os.Lstat(tmpPath); statErr == nil { + if rmErr := os.Remove(tmpPath); rmErr != nil { + logger.Debug("could not remove orphaned temp file", + slog.String("path", tmpPath), + slog.String("err", rmErr.Error()), + ) + } + } + + // --- 2. Read target permissions (if target exists) -------------------- + mode := fs.FileMode(0o644) //nolint:mnd // default for new files + + if info, statErr := os.Stat(targetPath); statErr == nil { + mode = info.Mode().Perm() + } + + // --- 3. Create temp file --------------------------------------------- + tmp, createErr := os.Create(tmpPath) + if createErr != nil { + return fmt.Errorf("atomic write: create temp: %w", createErr) + } + + // Ensure temp file is removed on any failure path after creation. + defer func() { + if err != nil { + _ = os.Remove(tmpPath) + } + }() + + // --- 4. Caller writes data ------------------------------------------- + if err = fn(tmp); err != nil { + _ = tmp.Close() + + return fmt.Errorf("atomic write: callback: %w", err) + } + + // --- 5. Sync for durability ------------------------------------------ + if err = tmp.Sync(); err != nil { + _ = tmp.Close() + + return fmt.Errorf("atomic write: sync: %w", err) + } + + // --- 6. Close -------------------------------------------------------- + if err = tmp.Close(); err != nil { + return fmt.Errorf("atomic write: close: %w", err) + } + + // --- 7. Preserve permissions ----------------------------------------- + if err = os.Chmod(tmpPath, mode); err != nil { + return fmt.Errorf("atomic write: chmod: %w", err) + } + + // --- 8. Atomic rename ------------------------------------------------ + if err = os.Rename(tmpPath, targetPath); err != nil { + if errors.Is(err, syscall.EXDEV) { + return fmt.Errorf("%w: %w", ErrCrossDevice, err) + } + + return fmt.Errorf("atomic write: rename: %w", err) + } + + return nil +}