Files
narratio/internal/fileops/fileops.go

278 lines
8.5 KiB
Go

package fileops
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// ErrDirectorySyncUnsupported remains for legacy confined migration paths.
// The retained replacement entry points do not perform directory syncing.
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 replaces dst with data through a sibling temporary file.
// It provides atomic visibility, not a crash- or power-loss-durability claim.
func ReplaceFileAtomic(dst string, data []byte, options ReplaceFileOptions) error {
return replaceFileFromReader(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() }()
return WriteReaderAtomicWithChecksum(dst, in, perm)
}
// WriteReaderAtomicWithChecksum streams src through the durable replacement
// sequence and returns the SHA-256 checksum of the installed bytes. The caller
// retains ownership of src.
func WriteReaderAtomicWithChecksum(dst string, src io.Reader, perm os.FileMode) (string, error) {
if strings.TrimSpace(dst) == "" {
return "", fmt.Errorf("destination path is required")
}
if src == nil {
return "", fmt.Errorf("source reader is required")
}
if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil {
return "", fmt.Errorf("create destination directory: %w", err)
}
digest := sha256.New()
if err := replaceFileFromReader(
dst,
io.TeeReader(src, digest),
ReplaceFileOptions{Mode: perm},
); err != nil {
return "", err
}
return hex.EncodeToString(digest.Sum(nil)), nil
}
func replaceFileFromReader(dst string, source io.Reader, options ReplaceFileOptions) (resultErr error) {
if strings.TrimSpace(dst) == "" {
return fmt.Errorf("destination path is required")
}
tmp, err := os.CreateTemp(filepath.Dir(dst), "."+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 := os.Remove(tmpPath); err != nil && !errors.Is(err, os.ErrNotExist) {
resultErr = errors.Join(resultErr, fmt.Errorf("remove temporary file: %w", err))
}
}
}()
if _, err := io.Copy(tmp, source); err != nil {
_ = tmp.Close()
return fmt.Errorf("write temp file: %w", err)
}
if err := tmp.Chmod(options.Mode); err != nil {
_ = tmp.Close()
return fmt.Errorf("set temp file permissions: %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 := os.Rename(tmpPath, dst); err != nil {
return fmt.Errorf("install temp file: %w", err)
}
removeTmp = false
return 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()
}