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

@@ -118,6 +118,12 @@ The directory is immutable once promoted. Configured lanes become
the bundle and `index.json` are retained for audit and resume validation but
are not selectable or published implicitly.
Atomic Notarius bundle promotion is supported on Linux, macOS, and Windows.
On other operating systems, extraction fails before copying the bundle into a
temporary promotion tree because Narratio has no verified atomic no-replace
directory primitive there. This is an extraction limitation, not a broader
platform-support guarantee for every Narratio workflow.
Run-local diagnostics are:
- `runs/{run_id}/extract/notarius.receipt.json`

View File

@@ -30,7 +30,7 @@ implementation sequence.
| Stage 10 | Complete |
| Stage 11 | Complete |
| Stage 12 | Complete |
| Stage 13 | Pending |
| Stage 13 | Complete |
| Stage 14 | Pending |
After completing and validating a stage, update only that stage's row to

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
}