package fileops import ( "bytes" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "os" "path/filepath" "strings" ) // 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 replaceFileFromReaderConfined(dst, bytes.NewReader(data), options) } // 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") } if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil { return fmt.Errorf("create destination directory: %w", err) } return ReplaceFileAtomic(dst, data, ReplaceFileOptions{Mode: perm}) } // 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 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") } in, err := os.Open(src) if err != nil { return "", fmt.Errorf("open source file: %w", err) } defer func() { _ = in.Close() }() if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil { return "", fmt.Errorf("create destination directory: %w", err) } digest := sha256.New() err = replaceFileFromReaderConfined( dst, io.TeeReader(in, digest), ReplaceFileOptions{Mode: perm}, ) if err != nil { return "", err } return hex.EncodeToString(digest.Sum(nil)), nil } // 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") } if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil { return fmt.Errorf("create destination directory: %w", err) } return installDownloadedTempFileConfined(tmpPath, dst, perm) } 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") } 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() }