package fileops import ( "bytes" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "os" "path/filepath" "strings" ) // ErrDirectorySyncUnsupported is used only by the retained directory-promotion // implementation. Single-file replacement does 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 } // 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 }