Support atomic directory promotion across platforms

This commit is contained in:
2026-08-10 01:58:52 +00:00
parent ef8dae776e
commit 665039f4dc
14 changed files with 215 additions and 36 deletions

View File

@@ -8,7 +8,6 @@ import (
"path/filepath"
"sort"
"strings"
"syscall"
)
const (
@@ -16,10 +15,17 @@ const (
promotedFileMode = 0o644
)
// ErrAtomicDirectoryPromotionUnsupported indicates that the current operating
// system lacks the atomic no-replace primitive required by PromoteDirectory.
var ErrAtomicDirectoryPromotionUnsupported = errors.New("atomic no-replace directory promotion is unsupported")
// PromoteDirectory copies an existing regular-file tree into a new directory
// and installs the complete copy atomically. It never removes the source or
// replaces an existing destination.
func PromoteDirectory(src, dst string) error {
if err := checkAtomicDirectoryPromotionSupport(); err != nil {
return err
}
return promoteDirectory(src, dst, renameDirectoryNoReplace)
}
@@ -84,7 +90,9 @@ func promoteDirectory(src, dst string, install func(string, string) error) error
}
removeTemporary = false
_ = syncDirectory(destinationParent)
if err := syncDirectory(destinationParent); err != nil {
return fmt.Errorf("sync destination parent: %w", err)
}
return nil
}
@@ -173,20 +181,6 @@ func copyRegularFile(src, dst string, inspected os.FileInfo) error {
return nil
}
func syncDirectory(path string) error {
directory, err := os.Open(path)
if err != nil {
return err
}
defer func() { _ = directory.Close() }()
err = directory.Sync()
if errors.Is(err, syscall.EINVAL) || errors.Is(err, syscall.ENOTSUP) {
return nil
}
return err
}
func pathWithin(parent, candidate string) (bool, error) {
absoluteParent, err := filepath.Abs(parent)
if err != nil {

View File

@@ -1,4 +1,4 @@
//go:build !windows
//go:build linux || darwin
package fileops

View File

@@ -1,3 +1,5 @@
//go:build linux || darwin || windows
package fileops
import (
@@ -5,6 +7,7 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
)
@@ -32,22 +35,24 @@ func TestPromoteDirectoryCopiesNestedRegularTree(t *testing.T) {
assertFileBytes(t, filepath.Join(dst, "nested", "binary.dat"), []byte{0, 1, 2, 0xff})
assertFileBytes(t, filepath.Join(dst, "z-last.txt"), []byte("last"))
for _, path := range []string{dst, filepath.Join(dst, "nested"), filepath.Join(dst, "empty")} {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat(%q) error = %v", path, err)
if runtime.GOOS != "windows" {
for _, path := range []string{dst, filepath.Join(dst, "nested"), filepath.Join(dst, "empty")} {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat(%q) error = %v", path, err)
}
if got := info.Mode().Perm(); got != promotedDirectoryMode {
t.Fatalf("directory mode for %q = %o, want %o", path, got, promotedDirectoryMode)
}
}
if got := info.Mode().Perm(); got != promotedDirectoryMode {
t.Fatalf("directory mode for %q = %o, want %o", path, got, promotedDirectoryMode)
}
}
for _, path := range []string{filepath.Join(dst, "a-first.txt"), filepath.Join(dst, "nested", "binary.dat"), filepath.Join(dst, "z-last.txt")} {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat(%q) error = %v", path, err)
}
if got := info.Mode().Perm(); got != promotedFileMode {
t.Fatalf("file mode for %q = %o, want %o", path, got, promotedFileMode)
for _, path := range []string{filepath.Join(dst, "a-first.txt"), filepath.Join(dst, "nested", "binary.dat"), filepath.Join(dst, "z-last.txt")} {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat(%q) error = %v", path, err)
}
if got := info.Mode().Perm(); got != promotedFileMode {
t.Fatalf("file mode for %q = %o, want %o", path, got, promotedFileMode)
}
}
}

View File

@@ -0,0 +1,37 @@
//go:build !linux && !darwin && !windows
package fileops
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestPromoteDirectoryFailsBeforeCreatingTemporaryTree(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
dst := filepath.Join(root, "destination")
if err := os.Mkdir(src, 0o755); err != nil {
t.Fatalf("Mkdir(source) error = %v", err)
}
if err := os.WriteFile(filepath.Join(src, "value.txt"), []byte("source"), 0o644); err != nil {
t.Fatalf("WriteFile(source) error = %v", err)
}
err := PromoteDirectory(src, dst)
if !errors.Is(err, ErrAtomicDirectoryPromotionUnsupported) {
t.Fatalf("PromoteDirectory() error = %v, want unsupported capability", err)
}
entries, readErr := os.ReadDir(root)
if readErr != nil {
t.Fatalf("ReadDir(root) error = %v", readErr)
}
for _, entry := range entries {
if entry.Name() == filepath.Base(dst) || strings.HasPrefix(entry.Name(), ".destination.tmp-") {
t.Fatalf("unsupported promotion created %q", entry.Name())
}
}
}

View File

@@ -0,0 +1,11 @@
//go:build darwin
package fileops
import "golang.org/x/sys/unix"
func checkAtomicDirectoryPromotionSupport() error { return nil }
func renameDirectoryNoReplace(src, dst string) error {
return unix.RenamexNp(src, dst, unix.RENAME_EXCL)
}

View File

@@ -4,6 +4,8 @@ package fileops
import "golang.org/x/sys/unix"
func checkAtomicDirectoryPromotionSupport() error { return nil }
func renameDirectoryNoReplace(src, dst string) error {
return unix.Renameat2(unix.AT_FDCWD, src, unix.AT_FDCWD, dst, unix.RENAME_NOREPLACE)
}

View File

@@ -1,9 +1,16 @@
//go:build !linux
//go:build !linux && !darwin && !windows
package fileops
import "fmt"
import (
"fmt"
"runtime"
)
func checkAtomicDirectoryPromotionSupport() error {
return fmt.Errorf("%w on %s", ErrAtomicDirectoryPromotionUnsupported, runtime.GOOS)
}
func renameDirectoryNoReplace(_, _ string) error {
return fmt.Errorf("atomic no-replace directory rename is unsupported on this platform")
return checkAtomicDirectoryPromotionSupport()
}

View File

@@ -0,0 +1,26 @@
//go:build linux || darwin || windows
package fileops
import (
"os"
"path/filepath"
"testing"
)
func TestRenameDirectoryNoReplacePreservesExistingDestination(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source")
dst := filepath.Join(root, "destination")
mustWriteFile(t, filepath.Join(src, "value.txt"), []byte("source"), 0o644)
mustWriteFile(t, filepath.Join(dst, "value.txt"), []byte("existing"), 0o644)
if err := renameDirectoryNoReplace(src, dst); err == nil {
t.Fatal("renameDirectoryNoReplace() error = nil, want existing destination failure")
}
assertFileBytes(t, filepath.Join(src, "value.txt"), []byte("source"))
assertFileBytes(t, filepath.Join(dst, "value.txt"), []byte("existing"))
if info, err := os.Stat(src); err != nil || !info.IsDir() {
t.Fatalf("source directory was not preserved: info=%v err=%v", info, err)
}
}

View File

@@ -0,0 +1,19 @@
//go:build windows
package fileops
import "golang.org/x/sys/windows"
func checkAtomicDirectoryPromotionSupport() error { return nil }
func renameDirectoryNoReplace(src, dst string) error {
from, err := windows.UTF16PtrFromString(src)
if err != nil {
return err
}
to, err := windows.UTF16PtrFromString(dst)
if err != nil {
return err
}
return windows.MoveFileEx(from, to, 0)
}

View File

@@ -0,0 +1,25 @@
//go:build linux || darwin
package fileops
import (
"errors"
"os"
"syscall"
)
func syncDirectory(path string) error {
directory, err := os.Open(path)
if err != nil {
return err
}
defer func() { _ = directory.Close() }()
err = directory.Sync()
// Some Unix filesystems do not implement directory syncing. Only their
// explicit unsupported-operation errors are safe to treat as best effort.
if errors.Is(err, syscall.EINVAL) || errors.Is(err, syscall.ENOTSUP) {
return nil
}
return err
}

View File

@@ -0,0 +1,7 @@
//go:build !linux && !darwin && !windows
package fileops
func syncDirectory(string) error {
return checkAtomicDirectoryPromotionSupport()
}

View File

@@ -0,0 +1,40 @@
//go:build windows
package fileops
import (
"errors"
"golang.org/x/sys/windows"
)
func syncDirectory(path string) error {
pathPointer, err := windows.UTF16PtrFromString(path)
if err != nil {
return err
}
directory, err := windows.CreateFile(
pathPointer,
windows.GENERIC_WRITE,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil,
windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS,
0,
)
if err != nil {
return err
}
defer func() { _ = windows.CloseHandle(directory) }()
err = windows.FlushFileBuffers(directory)
// Windows filesystems may reject flushing a directory handle even when it
// was opened correctly. Preserve every error except the documented forms
// that mean this operation is unavailable for the handle or filesystem.
if errors.Is(err, windows.ERROR_INVALID_FUNCTION) ||
errors.Is(err, windows.ERROR_INVALID_HANDLE) ||
errors.Is(err, windows.ERROR_NOT_SUPPORTED) {
return nil
}
return err
}