148 lines
4.9 KiB
Go
148 lines
4.9 KiB
Go
package fileops
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// 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, 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 atomically 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 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 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 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
|
|
}
|
|
|
|
// InstallSiblingTempFile installs a fully written and validated sibling
|
|
// temporary file at dst. The caller retains ownership of tmpPath if installation
|
|
// fails and is responsible for removing it.
|
|
func InstallSiblingTempFile(tmpPath, dst string, perm os.FileMode) error {
|
|
if strings.TrimSpace(tmpPath) == "" || strings.TrimSpace(dst) == "" {
|
|
return fmt.Errorf("temp and destination paths are required")
|
|
}
|
|
if filepath.Clean(filepath.Dir(tmpPath)) != filepath.Clean(filepath.Dir(dst)) {
|
|
return fmt.Errorf("temporary file must be a sibling of the destination")
|
|
}
|
|
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 downloaded file: %w", err)
|
|
}
|
|
return nil
|
|
}
|