136 lines
4.6 KiB
Go
136 lines
4.6 KiB
Go
package fileutil
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
maxReplaceRetries = 12
|
|
replaceRetryBase = 20 * time.Millisecond
|
|
|
|
// renameFile is a test seam: the two rename failure classes ReplaceFile
|
|
// distinguishes (transient lock vs cross-device) cannot be provoked
|
|
// portably on a real filesystem.
|
|
renameFile = os.Rename
|
|
)
|
|
|
|
// AtomicWriteFile writes data to path crash-safely: it writes to a sibling tmp
|
|
// file, fsyncs it so the bytes reach disk (guarding against power loss, not just
|
|
// process crash — see #4615), then atomically renames it onto path via
|
|
// ReplaceFile. A crash or power cut at any point leaves either the old file or
|
|
// the complete new file, never a truncated one. perm applies to the final file.
|
|
func AtomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
|
dir := filepath.Dir(path)
|
|
dirPerm := os.FileMode(0o755)
|
|
if perm&0o077 == 0 {
|
|
dirPerm = 0o700
|
|
}
|
|
if err := os.MkdirAll(dir, dirPerm); err != nil {
|
|
return fmt.Errorf("create dir for %s: %w", path, err)
|
|
}
|
|
tmp, err := os.CreateTemp(dir, ".atomic-*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("create tmp for %s: %w", path, err)
|
|
}
|
|
tmpPath := tmp.Name()
|
|
if _, err := tmp.Write(data); err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("write tmp for %s: %w", path, err)
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("fsync tmp for %s: %w", path, err)
|
|
}
|
|
// Chmod the still-open handle, before Close, so there is no window between
|
|
// close and a path-based chmod for another process (Windows AV / search
|
|
// indexer) to grab or move the tmp and make the chmod fail with "file not
|
|
// found". CreateTemp makes a 0600 file, so this only widens when perm asks.
|
|
if err := tmp.Chmod(perm); err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("chmod tmp for %s: %w", path, err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("close tmp for %s: %w", path, err)
|
|
}
|
|
if err := ReplaceFile(tmpPath, path); err != nil {
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReplaceFile renames tmp onto dest, publishing the new content atomically: a
|
|
// reader concurrent with the replace sees either the old file or the complete
|
|
// new one. The rename can fail in two ways, and they are handled differently:
|
|
//
|
|
// - A transient lock on dest (antivirus, the search indexer, a concurrent
|
|
// reader without delete sharing) fails the rename for a few hundred ms.
|
|
// The rename is retried with backoff, and the last error is returned if
|
|
// the lock never clears. The failure is loud on purpose: falling back to
|
|
// an in-place copy here would truncate dest first, letting a racing
|
|
// reader observe an empty or half-written file — exactly the torn state
|
|
// AtomicWriteFile promises its callers (session leases, credentials,
|
|
// plugin state) can never happen.
|
|
// - Windows encryption-software filter drivers report a cross-device link
|
|
// (ERROR_NOT_SAME_DEVICE / EXDEV) even for a same-dir rename (#2696), and
|
|
// every retry fails identically. Only this class falls back to the
|
|
// non-atomic copy, and immediately — retrying a structurally impossible
|
|
// rename would only delay it. Torn reads remain possible in that degraded
|
|
// mode; it is the only way to write at all on such hosts, and
|
|
// rename-capable filesystems never take it.
|
|
//
|
|
// A missing tmp means the write itself failed and no retry can help.
|
|
func ReplaceFile(tmp, dest string) error {
|
|
var err error
|
|
for attempt := 0; ; attempt++ {
|
|
if err = renameFile(tmp, dest); err == nil {
|
|
return nil
|
|
}
|
|
if renameCrossesDevice(err) {
|
|
if copyOnto(tmp, dest) == nil {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if attempt >= maxReplaceRetries || !fileExists(tmp) {
|
|
return err
|
|
}
|
|
time.Sleep(time.Duration(attempt+1) * replaceRetryBase)
|
|
}
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|
|
|
|
// copyOnto is the non-atomic last resort for hosts whose filesystem cannot
|
|
// rename tmp onto dest at all (see ReplaceFile). It truncates dest in place,
|
|
// so a concurrent reader can observe an empty or half-written file — it must
|
|
// never run for failures a retry could clear.
|
|
func copyOnto(tmp, dest string) error {
|
|
info, err := os.Stat(tmp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := os.ReadFile(tmp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(dest, data, info.Mode().Perm()); err != nil {
|
|
return err
|
|
}
|
|
// WriteFile keeps an existing dest's mode, so re-apply tmp's mode to match
|
|
// what the rename would have done (a 0600 config tmp must not widen to 0644).
|
|
_ = os.Chmod(dest, info.Mode().Perm())
|
|
_ = os.Remove(tmp)
|
|
return nil
|
|
}
|