From 665039f4dc50723ac7aa9d968c689f40686e24bf Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 10 Aug 2026 01:58:52 +0000 Subject: [PATCH] Support atomic directory promotion across platforms --- docs/operations.md | 6 +++ docs/roadmap/implementation.md | 2 +- internal/fileops/directory.go | 26 +++++------- .../fileops/directory_special_unix_test.go | 2 +- internal/fileops/directory_test.go | 35 +++++++++------- .../fileops/directory_unsupported_test.go | 37 +++++++++++++++++ internal/fileops/rename_noreplace_darwin.go | 11 +++++ internal/fileops/rename_noreplace_linux.go | 2 + internal/fileops/rename_noreplace_other.go | 13 ++++-- .../rename_noreplace_supported_test.go | 26 ++++++++++++ internal/fileops/rename_noreplace_windows.go | 19 +++++++++ internal/fileops/sync_directory_unix.go | 25 ++++++++++++ .../fileops/sync_directory_unsupported.go | 7 ++++ internal/fileops/sync_directory_windows.go | 40 +++++++++++++++++++ 14 files changed, 215 insertions(+), 36 deletions(-) create mode 100644 internal/fileops/directory_unsupported_test.go create mode 100644 internal/fileops/rename_noreplace_darwin.go create mode 100644 internal/fileops/rename_noreplace_supported_test.go create mode 100644 internal/fileops/rename_noreplace_windows.go create mode 100644 internal/fileops/sync_directory_unix.go create mode 100644 internal/fileops/sync_directory_unsupported.go create mode 100644 internal/fileops/sync_directory_windows.go diff --git a/docs/operations.md b/docs/operations.md index 722d4ef..65e8118 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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` diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 56ae592..237c0ae 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 diff --git a/internal/fileops/directory.go b/internal/fileops/directory.go index 9c6b11b..c01ff47 100644 --- a/internal/fileops/directory.go +++ b/internal/fileops/directory.go @@ -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 { diff --git a/internal/fileops/directory_special_unix_test.go b/internal/fileops/directory_special_unix_test.go index 294a90a..02c514f 100644 --- a/internal/fileops/directory_special_unix_test.go +++ b/internal/fileops/directory_special_unix_test.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build linux || darwin package fileops diff --git a/internal/fileops/directory_test.go b/internal/fileops/directory_test.go index 61c0d99..7f35330 100644 --- a/internal/fileops/directory_test.go +++ b/internal/fileops/directory_test.go @@ -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) + } } } diff --git a/internal/fileops/directory_unsupported_test.go b/internal/fileops/directory_unsupported_test.go new file mode 100644 index 0000000..4ff079a --- /dev/null +++ b/internal/fileops/directory_unsupported_test.go @@ -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()) + } + } +} diff --git a/internal/fileops/rename_noreplace_darwin.go b/internal/fileops/rename_noreplace_darwin.go new file mode 100644 index 0000000..399fa48 --- /dev/null +++ b/internal/fileops/rename_noreplace_darwin.go @@ -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) +} diff --git a/internal/fileops/rename_noreplace_linux.go b/internal/fileops/rename_noreplace_linux.go index 74f3748..42fe2e5 100644 --- a/internal/fileops/rename_noreplace_linux.go +++ b/internal/fileops/rename_noreplace_linux.go @@ -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) } diff --git a/internal/fileops/rename_noreplace_other.go b/internal/fileops/rename_noreplace_other.go index 2608cc6..c6a7d5c 100644 --- a/internal/fileops/rename_noreplace_other.go +++ b/internal/fileops/rename_noreplace_other.go @@ -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() } diff --git a/internal/fileops/rename_noreplace_supported_test.go b/internal/fileops/rename_noreplace_supported_test.go new file mode 100644 index 0000000..2685464 --- /dev/null +++ b/internal/fileops/rename_noreplace_supported_test.go @@ -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) + } +} diff --git a/internal/fileops/rename_noreplace_windows.go b/internal/fileops/rename_noreplace_windows.go new file mode 100644 index 0000000..41fbe14 --- /dev/null +++ b/internal/fileops/rename_noreplace_windows.go @@ -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) +} diff --git a/internal/fileops/sync_directory_unix.go b/internal/fileops/sync_directory_unix.go new file mode 100644 index 0000000..d5df543 --- /dev/null +++ b/internal/fileops/sync_directory_unix.go @@ -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 +} diff --git a/internal/fileops/sync_directory_unsupported.go b/internal/fileops/sync_directory_unsupported.go new file mode 100644 index 0000000..8fd8ccb --- /dev/null +++ b/internal/fileops/sync_directory_unsupported.go @@ -0,0 +1,7 @@ +//go:build !linux && !darwin && !windows + +package fileops + +func syncDirectory(string) error { + return checkAtomicDirectoryPromotionSupport() +} diff --git a/internal/fileops/sync_directory_windows.go b/internal/fileops/sync_directory_windows.go new file mode 100644 index 0000000..8a41421 --- /dev/null +++ b/internal/fileops/sync_directory_windows.go @@ -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 +}