Support atomic directory promotion across platforms
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build linux || darwin
|
||||
|
||||
package fileops
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
internal/fileops/directory_unsupported_test.go
Normal file
37
internal/fileops/directory_unsupported_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
11
internal/fileops/rename_noreplace_darwin.go
Normal file
11
internal/fileops/rename_noreplace_darwin.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
26
internal/fileops/rename_noreplace_supported_test.go
Normal file
26
internal/fileops/rename_noreplace_supported_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
19
internal/fileops/rename_noreplace_windows.go
Normal file
19
internal/fileops/rename_noreplace_windows.go
Normal 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)
|
||||
}
|
||||
25
internal/fileops/sync_directory_unix.go
Normal file
25
internal/fileops/sync_directory_unix.go
Normal 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
|
||||
}
|
||||
7
internal/fileops/sync_directory_unsupported.go
Normal file
7
internal/fileops/sync_directory_unsupported.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !linux && !darwin && !windows
|
||||
|
||||
package fileops
|
||||
|
||||
func syncDirectory(string) error {
|
||||
return checkAtomicDirectoryPromotionSupport()
|
||||
}
|
||||
40
internal/fileops/sync_directory_windows.go
Normal file
40
internal/fileops/sync_directory_windows.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user