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 the bundle and `index.json` are retained for audit and resume validation but
are not selectable or published implicitly. 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: Run-local diagnostics are:
- `runs/{run_id}/extract/notarius.receipt.json` - `runs/{run_id}/extract/notarius.receipt.json`

View File

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

View File

@@ -8,7 +8,6 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"syscall"
) )
const ( const (
@@ -16,10 +15,17 @@ const (
promotedFileMode = 0o644 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 // PromoteDirectory copies an existing regular-file tree into a new directory
// and installs the complete copy atomically. It never removes the source or // and installs the complete copy atomically. It never removes the source or
// replaces an existing destination. // replaces an existing destination.
func PromoteDirectory(src, dst string) error { func PromoteDirectory(src, dst string) error {
if err := checkAtomicDirectoryPromotionSupport(); err != nil {
return err
}
return promoteDirectory(src, dst, renameDirectoryNoReplace) return promoteDirectory(src, dst, renameDirectoryNoReplace)
} }
@@ -84,7 +90,9 @@ func promoteDirectory(src, dst string, install func(string, string) error) error
} }
removeTemporary = false removeTemporary = false
_ = syncDirectory(destinationParent) if err := syncDirectory(destinationParent); err != nil {
return fmt.Errorf("sync destination parent: %w", err)
}
return nil return nil
} }
@@ -173,20 +181,6 @@ func copyRegularFile(src, dst string, inspected os.FileInfo) error {
return nil 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) { func pathWithin(parent, candidate string) (bool, error) {
absoluteParent, err := filepath.Abs(parent) absoluteParent, err := filepath.Abs(parent)
if err != nil { if err != nil {

View File

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

View File

@@ -1,3 +1,5 @@
//go:build linux || darwin || windows
package fileops package fileops
import ( import (
@@ -5,6 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"runtime"
"strings" "strings"
"testing" "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, "nested", "binary.dat"), []byte{0, 1, 2, 0xff})
assertFileBytes(t, filepath.Join(dst, "z-last.txt"), []byte("last")) assertFileBytes(t, filepath.Join(dst, "z-last.txt"), []byte("last"))
for _, path := range []string{dst, filepath.Join(dst, "nested"), filepath.Join(dst, "empty")} { if runtime.GOOS != "windows" {
info, err := os.Stat(path) for _, path := range []string{dst, filepath.Join(dst, "nested"), filepath.Join(dst, "empty")} {
if err != nil { info, err := os.Stat(path)
t.Fatalf("Stat(%q) error = %v", path, err) 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 { for _, path := range []string{filepath.Join(dst, "a-first.txt"), filepath.Join(dst, "nested", "binary.dat"), filepath.Join(dst, "z-last.txt")} {
t.Fatalf("directory mode for %q = %o, want %o", path, got, promotedDirectoryMode) info, err := os.Stat(path)
} if err != nil {
} t.Fatalf("Stat(%q) error = %v", path, err)
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 got := info.Mode().Perm(); got != promotedFileMode {
if err != nil { t.Fatalf("file mode for %q = %o, want %o", path, got, promotedFileMode)
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" import "golang.org/x/sys/unix"
func checkAtomicDirectoryPromotionSupport() error { return nil }
func renameDirectoryNoReplace(src, dst string) error { func renameDirectoryNoReplace(src, dst string) error {
return unix.Renameat2(unix.AT_FDCWD, src, unix.AT_FDCWD, dst, unix.RENAME_NOREPLACE) 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 package fileops
import "fmt" import (
"fmt"
"runtime"
)
func checkAtomicDirectoryPromotionSupport() error {
return fmt.Errorf("%w on %s", ErrAtomicDirectoryPromotionUnsupported, runtime.GOOS)
}
func renameDirectoryNoReplace(_, _ string) error { 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
}