Simplify atomic file replacement and bounded reads

This commit is contained in:
2026-09-01 17:40:08 +00:00
parent b7d989f61f
commit 0d57422b61
7 changed files with 135 additions and 63 deletions

View File

@@ -12,9 +12,8 @@ import (
"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.
// 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
@@ -26,13 +25,10 @@ type ReplaceFileOptions struct {
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.
// 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 replaceFileFromReaderConfined(dst, bytes.NewReader(data), options)
return replaceFileFromReader(dst, bytes.NewReader(data), options)
}
// WriteFileAtomic creates the destination parent with workspace permissions
@@ -85,7 +81,7 @@ func WriteReaderAtomicWithChecksum(dst string, src io.Reader, perm os.FileMode)
}
digest := sha256.New()
if err := replaceFileFromReaderConfined(
if err := replaceFileFromReader(
dst,
io.TeeReader(src, digest),
ReplaceFileOptions{Mode: perm},
@@ -95,6 +91,46 @@ func WriteReaderAtomicWithChecksum(dst string, src io.Reader, perm os.FileMode)
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.

View File

@@ -139,30 +139,23 @@ func TestInstallDownloadedTempFileSetsPermissions(t *testing.T) {
}
}
func TestWriteFileAtomicRejectsSymlinkedDestinationAncestor(t *testing.T) {
func TestWriteFileAtomicUsesOrdinaryDestinationPathSemantics(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
sentinel := filepath.Join(outside, "sentinel.txt")
if err := os.WriteFile(sentinel, []byte("unchanged"), 0o600); err != nil {
t.Fatalf("WriteFile(sentinel) error = %v", err)
}
if err := os.Symlink(outside, filepath.Join(root, "redirect")); err != nil {
t.Skipf("Symlink unavailable: %v", err)
}
err := WriteFileAtomic(filepath.Join(root, "redirect", "output.txt"), []byte("new"), 0o640)
if err == nil {
t.Fatal("WriteFileAtomic() error = nil, want symlink ancestor rejection")
path := filepath.Join(root, "redirect", "output.txt")
if err := WriteFileAtomic(path, []byte("new"), 0o640); err != nil {
t.Fatalf("WriteFileAtomic() error = %v", err)
}
data, err := os.ReadFile(sentinel)
data, err := os.ReadFile(filepath.Join(outside, "output.txt"))
if err != nil {
t.Fatalf("ReadFile(sentinel) error = %v", err)
t.Fatalf("ReadFile(output) error = %v", err)
}
if string(data) != "unchanged" {
t.Fatalf("outside sentinel = %q, want unchanged", data)
}
if _, err := os.Stat(filepath.Join(outside, "output.txt")); !os.IsNotExist(err) {
t.Fatalf("outside output exists: stat err = %v", err)
if string(data) != "new" {
t.Fatalf("outside output = %q, want new", data)
}
}

View File

@@ -3,6 +3,7 @@ package fileops
import (
"fmt"
"os"
"path/filepath"
"strings"
)
@@ -22,8 +23,38 @@ func EnsureWorkspaceDirectory(directory string) error {
return fmt.Errorf("workspace directory is required")
}
if err := ensureConfinedDirectory(directory, WorkspaceDirectoryMode); err != nil {
return fmt.Errorf("set workspace directory permissions %q: %w", directory, err)
clean := filepath.Clean(directory)
ancestor := clean
var missing []string
for {
info, err := os.Stat(ancestor)
if err == nil {
if !info.IsDir() {
return fmt.Errorf("workspace directory %q is not a directory", ancestor)
}
break
}
if !os.IsNotExist(err) {
return fmt.Errorf("inspect workspace directory %q: %w", ancestor, err)
}
parent := filepath.Dir(ancestor)
if parent == ancestor {
return fmt.Errorf("find workspace directory ancestor for %q", clean)
}
missing = append(missing, ancestor)
ancestor = parent
}
for index := len(missing) - 1; index >= 0; index-- {
created := missing[index]
if err := os.Mkdir(created, WorkspaceDirectoryMode); err != nil && !os.IsExist(err) {
return fmt.Errorf("create workspace directory %q: %w", created, err)
}
if err := os.Chmod(created, WorkspaceDirectoryMode); err != nil {
return fmt.Errorf("set workspace directory permissions %q: %w", created, err)
}
}
if err := os.Chmod(clean, WorkspaceDirectoryMode); err != nil {
return fmt.Errorf("set workspace directory permissions %q: %w", clean, err)
}
return nil
}

View File

@@ -0,0 +1,32 @@
package fileops
import (
"fmt"
"io"
"os"
"strings"
)
// ReadFileBounded reads at most limit bytes from path using ordinary workspace
// path semantics. It does not return partial data when the limit is exceeded.
func ReadFileBounded(path string, limit int64) ([]byte, error) {
if strings.TrimSpace(path) == "" {
return nil, fmt.Errorf("file path is required")
}
if limit < 0 {
return nil, fmt.Errorf("file read limit %d is invalid", limit)
}
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open file %q: %w", path, err)
}
defer func() { _ = file.Close() }()
data, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil {
return nil, fmt.Errorf("read file %q: %w", path, err)
}
if int64(len(data)) > limit {
return nil, fmt.Errorf("file %q exceeds %d-byte limit", path, limit)
}
return data, nil
}