Make atomic file replacement crash durable
This commit is contained in:
35
docs/internal/fileops.md
Normal file
35
docs/internal/fileops.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Internal: File Operations
|
||||||
|
|
||||||
|
`internal/fileops` owns the narrow mechanics for durable replacement of one
|
||||||
|
byte file. Callers keep ownership of serialization, validation, cancellation,
|
||||||
|
and destination-directory policy.
|
||||||
|
|
||||||
|
## Replacement Contract
|
||||||
|
|
||||||
|
`ReplaceFileAtomic` requires an existing destination directory. It creates a
|
||||||
|
sibling temporary file, writes the complete byte sequence, applies the
|
||||||
|
caller-supplied mode, syncs and closes the file, runs an optional pre-rename
|
||||||
|
check, replaces the destination with a rename, then syncs the containing
|
||||||
|
directory.
|
||||||
|
|
||||||
|
The pre-rename check is the last point at which a caller can cancel without
|
||||||
|
installing a new destination. A failure before the rename leaves the old
|
||||||
|
destination unchanged and removes the temporary file; any cleanup failure is
|
||||||
|
returned alongside the primary failure. A failure after the rename may leave
|
||||||
|
the new file visible, but it is not reported as crash-durable.
|
||||||
|
|
||||||
|
Replacement follows the operating system's same-filesystem rename semantics.
|
||||||
|
If a platform cannot replace an existing destination, the operation returns an
|
||||||
|
error and never removes the old file as an emulation step.
|
||||||
|
|
||||||
|
## Directory-Sync Support
|
||||||
|
|
||||||
|
Linux and macOS attempt to sync the destination directory. Windows opens the
|
||||||
|
directory with backup semantics and flushes its buffers. If either operation
|
||||||
|
is unavailable for the platform, directory handle, or filesystem,
|
||||||
|
`ErrDirectorySyncUnsupported` is returned. Narratio does not treat that result
|
||||||
|
as successful crash-durable replacement.
|
||||||
|
|
||||||
|
`WriteFileAtomic`, copy helpers, and downloaded temporary-file installation
|
||||||
|
retain their compatibility behavior of creating the destination parent with
|
||||||
|
the repository's workspace permissions before using this contract.
|
||||||
@@ -48,9 +48,15 @@ The model admits these stage states:
|
|||||||
|
|
||||||
- validates loaded documents;
|
- validates loaded documents;
|
||||||
- normalizes missing maps/stage records;
|
- normalizes missing maps/stage records;
|
||||||
- writes atomically via temp file + rename;
|
- writes through a sibling temporary file, syncing the completed file and
|
||||||
|
destination directory after atomic replacement;
|
||||||
- updates `updated_at` on save.
|
- updates `updated_at` on save.
|
||||||
|
|
||||||
|
If the operating system or filesystem cannot sync a directory, save returns an
|
||||||
|
explicit error instead of claiming crash-durable replacement. A returned error
|
||||||
|
after the rename can therefore leave the new manifest visible but not confirmed
|
||||||
|
durable; callers must reload it before retrying.
|
||||||
|
|
||||||
## Execution Semantics
|
## Execution Semantics
|
||||||
|
|
||||||
The application runner marks an executing stage running and then succeeded or
|
The application runner marks an executing stage running and then succeeded or
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ progress and artifact services resolve durable inputs and outputs.
|
|||||||
| Artifacts and paths | `internal/artifacts`, `internal/pathsafe` | Artifact identities and resolution, local and remote path/key models, current-state discovery, and confined relative destinations. |
|
| Artifacts and paths | `internal/artifacts`, `internal/pathsafe` | Artifact identities and resolution, local and remote path/key models, current-state discovery, and confined relative destinations. |
|
||||||
| Previous-session cache | `internal/previouscache` | Deterministic planning and materialization requirements for configured previous-session inputs. |
|
| Previous-session cache | `internal/previouscache` | Deterministic planning and materialization requirements for configured previous-session inputs. |
|
||||||
| Artifact policy | `internal/artifactpolicy` | Source and destination policy, configured artifact identity validation, and publish destination safety. |
|
| Artifact policy | `internal/artifactpolicy` | Source and destination policy, configured artifact identity validation, and publish destination safety. |
|
||||||
| Shared models and file operations | `internal/artifactmodel`, `internal/contracts`, `internal/fileops` | Transcript and artifact data contracts plus narrow atomic filesystem helpers. |
|
| Shared models and file operations | `internal/artifactmodel`, `internal/contracts`, [`internal/fileops`](fileops.md) | Transcript and artifact data contracts plus durable single-file replacement helpers; unsupported directory syncing is reported explicitly. |
|
||||||
| Logging | `internal/logging` | Application logger construction and shared structured logging behavior. |
|
| Logging | `internal/logging` | Application logger construction and shared structured logging behavior. |
|
||||||
|
|
||||||
The application boundary composes concrete implementations. Stages depend on
|
The application boundary composes concrete implementations. Stages depend on
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ All stages are pending when this plan is created.
|
|||||||
| ---: | --- | --- | --- |
|
| ---: | --- | --- | --- |
|
||||||
| 1 | Align data classification and group workspace modes | RSK-004 | Completed |
|
| 1 | Align data classification and group workspace modes | RSK-004 | Completed |
|
||||||
| 2 | Enforce safe identifiers and fuzz path/source contracts | COR-002, TST-013 | Completed |
|
| 2 | Enforce safe identifiers and fuzz path/source contracts | COR-002, TST-013 | Completed |
|
||||||
| 3 | Consolidate crash-durable atomic file replacement | RSK-002, DUP-001, DUP-005 | Pending |
|
| 3 | Consolidate crash-durable atomic file replacement | RSK-002, DUP-001, DUP-005 | Completed |
|
||||||
| 4 | Add confined destination and download/install capabilities | COR-003, DUP-003, TST-003 | Pending |
|
| 4 | Add confined destination and download/install capabilities | COR-003, DUP-003, TST-003 | Pending |
|
||||||
| 5 | Confine recursive cleanup and replace sentinel locks | RSK-003 | Pending |
|
| 5 | Confine recursive cleanup and replace sentinel locks | RSK-003 | Pending |
|
||||||
| 6 | Harden API-key file acquisition | RSK-010 | Pending |
|
| 6 | Harden API-key file acquisition | RSK-010 | Pending |
|
||||||
|
|||||||
@@ -128,48 +128,14 @@ func WriteYAMLAtomic(path string, value any, perm os.FileMode) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteFileAtomic writes bytes via same-directory temp file + atomic rename.
|
// WriteFileAtomic writes bytes through the shared durable replacement primitive.
|
||||||
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
|
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||||
if strings.TrimSpace(path) == "" {
|
if strings.TrimSpace(path) == "" {
|
||||||
return fmt.Errorf("write file: path is required")
|
return fmt.Errorf("write file: path is required")
|
||||||
}
|
}
|
||||||
|
if err := fileops.WriteFileAtomic(path, data, perm); err != nil {
|
||||||
dir := filepath.Dir(path)
|
return fmt.Errorf("write file %q: %w", path, err)
|
||||||
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
|
|
||||||
return fmt.Errorf("create parent directory %q: %w", dir, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
base := filepath.Base(path)
|
|
||||||
tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create temp file: %w", err)
|
|
||||||
}
|
|
||||||
tmpPath := tmp.Name()
|
|
||||||
removeTmp := true
|
|
||||||
defer func() {
|
|
||||||
if removeTmp {
|
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if _, err := tmp.Write(data); err != nil {
|
|
||||||
_ = tmp.Close()
|
|
||||||
return fmt.Errorf("write temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Sync(); err != nil {
|
|
||||||
_ = tmp.Close()
|
|
||||||
return fmt.Errorf("sync temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
return fmt.Errorf("close temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Chmod(tmpPath, perm); err != nil {
|
|
||||||
return fmt.Errorf("chmod temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmpPath, path); err != nil {
|
|
||||||
return fmt.Errorf("rename temp file: %w", err)
|
|
||||||
}
|
|
||||||
removeTmp = false
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -282,41 +282,8 @@ func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
|||||||
if strings.TrimSpace(path) == "" {
|
if strings.TrimSpace(path) == "" {
|
||||||
return fmt.Errorf("path is required")
|
return fmt.Errorf("path is required")
|
||||||
}
|
}
|
||||||
dir := filepath.Dir(path)
|
if err := fileops.WriteFileAtomic(path, data, perm); err != nil {
|
||||||
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
|
return fmt.Errorf("write file %q: %w", path, err)
|
||||||
return fmt.Errorf("create parent dir %q: %w", dir, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
base := filepath.Base(path)
|
|
||||||
tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create temp file: %w", err)
|
|
||||||
}
|
|
||||||
tmpPath := tmp.Name()
|
|
||||||
removeTmp := true
|
|
||||||
defer func() {
|
|
||||||
if removeTmp {
|
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if _, err := tmp.Write(data); err != nil {
|
|
||||||
_ = tmp.Close()
|
|
||||||
return fmt.Errorf("write temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Sync(); err != nil {
|
|
||||||
_ = tmp.Close()
|
|
||||||
return fmt.Errorf("sync temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
return fmt.Errorf("close temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Chmod(tmpPath, perm); err != nil {
|
|
||||||
return fmt.Errorf("chmod temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmpPath, path); err != nil {
|
|
||||||
return fmt.Errorf("rename temp file: %w", err)
|
|
||||||
}
|
|
||||||
removeTmp = false
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package fileops
|
package fileops
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -10,7 +12,32 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WriteFileAtomic writes data to dst atomically via temp file + rename.
|
// ErrDirectorySyncUnsupported reports that the platform or filesystem cannot
|
||||||
|
// make a renamed directory entry crash-durable. The replacement may be visible,
|
||||||
|
// but callers must not treat it as having the same durability guarantee.
|
||||||
|
var ErrDirectorySyncUnsupported = errors.New("directory sync is unsupported")
|
||||||
|
|
||||||
|
// ReplaceFileOptions describes the caller-owned parts of a byte-file
|
||||||
|
// replacement. The destination parent must already exist. BeforeRename runs
|
||||||
|
// after the temporary file is complete and durable, but before replacement is
|
||||||
|
// committed; callers can use it for cancellation or other commit checks.
|
||||||
|
type ReplaceFileOptions struct {
|
||||||
|
Mode os.FileMode
|
||||||
|
BeforeRename func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceFileAtomic durably replaces dst with data. It creates a sibling
|
||||||
|
// temporary file, writes and syncs its bytes and mode, atomically renames it,
|
||||||
|
// then syncs the destination parent. On platforms where replacement of an
|
||||||
|
// existing destination or directory syncing is unavailable, it returns an
|
||||||
|
// error rather than claiming an equivalent durability guarantee.
|
||||||
|
func ReplaceFileAtomic(dst string, data []byte, options ReplaceFileOptions) error {
|
||||||
|
return replaceFileAtomicWithOperations(dst, data, options, systemAtomicReplacementOperations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFileAtomic creates the destination parent with workspace permissions
|
||||||
|
// and durably replaces dst with data. Callers that own parent-directory policy
|
||||||
|
// or need a pre-commit check should use ReplaceFileAtomic directly.
|
||||||
func WriteFileAtomic(dst string, data []byte, perm os.FileMode) error {
|
func WriteFileAtomic(dst string, data []byte, perm os.FileMode) error {
|
||||||
if strings.TrimSpace(dst) == "" {
|
if strings.TrimSpace(dst) == "" {
|
||||||
return fmt.Errorf("destination path is required")
|
return fmt.Errorf("destination path is required")
|
||||||
@@ -18,48 +45,17 @@ func WriteFileAtomic(dst string, data []byte, perm os.FileMode) error {
|
|||||||
if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil {
|
if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil {
|
||||||
return fmt.Errorf("create destination directory: %w", err)
|
return fmt.Errorf("create destination directory: %w", err)
|
||||||
}
|
}
|
||||||
|
return ReplaceFileAtomic(dst, data, ReplaceFileOptions{Mode: perm})
|
||||||
base := filepath.Base(dst)
|
|
||||||
tmp, err := os.CreateTemp(filepath.Dir(dst), "."+base+".tmp-*")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create temp file: %w", err)
|
|
||||||
}
|
|
||||||
tmpPath := tmp.Name()
|
|
||||||
removeTmp := true
|
|
||||||
defer func() {
|
|
||||||
if removeTmp {
|
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if _, err := tmp.Write(data); err != nil {
|
|
||||||
_ = tmp.Close()
|
|
||||||
return fmt.Errorf("write temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Sync(); err != nil {
|
|
||||||
_ = tmp.Close()
|
|
||||||
return fmt.Errorf("sync temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
return fmt.Errorf("close temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Chmod(tmpPath, perm); err != nil {
|
|
||||||
return fmt.Errorf("set temp file permissions: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmpPath, dst); err != nil {
|
|
||||||
return fmt.Errorf("install temp file: %w", err)
|
|
||||||
}
|
|
||||||
removeTmp = false
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CopyFileAtomic copies src to dst atomically via temp file + rename.
|
// CopyFileAtomic copies src to dst through the durable replacement sequence.
|
||||||
func CopyFileAtomic(src, dst string, perm os.FileMode) error {
|
func CopyFileAtomic(src, dst string, perm os.FileMode) error {
|
||||||
_, err := CopyFileAtomicWithChecksum(src, dst, perm)
|
_, err := CopyFileAtomicWithChecksum(src, dst, perm)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CopyFileAtomicWithChecksum copies src to dst atomically and returns the SHA-256 checksum.
|
// CopyFileAtomicWithChecksum copies src to dst through the durable replacement
|
||||||
|
// sequence and returns the SHA-256 checksum of the copied bytes.
|
||||||
func CopyFileAtomicWithChecksum(src, dst string, perm os.FileMode) (string, error) {
|
func CopyFileAtomicWithChecksum(src, dst string, perm os.FileMode) (string, error) {
|
||||||
if strings.TrimSpace(src) == "" || strings.TrimSpace(dst) == "" {
|
if strings.TrimSpace(src) == "" || strings.TrimSpace(dst) == "" {
|
||||||
return "", fmt.Errorf("source and destination paths are required")
|
return "", fmt.Errorf("source and destination paths are required")
|
||||||
@@ -75,42 +71,22 @@ func CopyFileAtomicWithChecksum(src, dst string, perm os.FileMode) (string, erro
|
|||||||
return "", fmt.Errorf("create destination directory: %w", err)
|
return "", fmt.Errorf("create destination directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
base := filepath.Base(dst)
|
|
||||||
tmp, err := os.CreateTemp(filepath.Dir(dst), "."+base+".tmp-*")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("create temp file: %w", err)
|
|
||||||
}
|
|
||||||
tmpPath := tmp.Name()
|
|
||||||
removeTmp := true
|
|
||||||
defer func() {
|
|
||||||
if removeTmp {
|
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
digest := sha256.New()
|
digest := sha256.New()
|
||||||
if _, err := io.Copy(io.MultiWriter(tmp, digest), in); err != nil {
|
err = replaceFileFromReaderWithOperations(
|
||||||
_ = tmp.Close()
|
dst,
|
||||||
return "", fmt.Errorf("copy file: %w", err)
|
io.TeeReader(in, digest),
|
||||||
|
ReplaceFileOptions{Mode: perm},
|
||||||
|
systemAtomicReplacementOperations,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
if err := tmp.Sync(); err != nil {
|
|
||||||
_ = tmp.Close()
|
|
||||||
return "", fmt.Errorf("sync temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
return "", fmt.Errorf("close temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Chmod(tmpPath, perm); err != nil {
|
|
||||||
return "", fmt.Errorf("set temp file permissions: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmpPath, dst); err != nil {
|
|
||||||
return "", fmt.Errorf("install temp file: %w", err)
|
|
||||||
}
|
|
||||||
removeTmp = false
|
|
||||||
return hex.EncodeToString(digest.Sum(nil)), nil
|
return hex.EncodeToString(digest.Sum(nil)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstallDownloadedTempFile installs a previously downloaded temp file at dst.
|
// InstallDownloadedTempFile installs a fully downloaded sibling temporary file
|
||||||
|
// at dst. On failure before the rename, the caller retains ownership of tmpPath
|
||||||
|
// and is responsible for cleanup.
|
||||||
func InstallDownloadedTempFile(tmpPath, dst string, perm os.FileMode) error {
|
func InstallDownloadedTempFile(tmpPath, dst string, perm os.FileMode) error {
|
||||||
if strings.TrimSpace(tmpPath) == "" || strings.TrimSpace(dst) == "" {
|
if strings.TrimSpace(tmpPath) == "" || strings.TrimSpace(dst) == "" {
|
||||||
return fmt.Errorf("temp and destination paths are required")
|
return fmt.Errorf("temp and destination paths are required")
|
||||||
@@ -118,11 +94,137 @@ func InstallDownloadedTempFile(tmpPath, dst string, perm os.FileMode) error {
|
|||||||
if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil {
|
if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil {
|
||||||
return fmt.Errorf("create destination directory: %w", err)
|
return fmt.Errorf("create destination directory: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.Chmod(tmpPath, perm); err != nil {
|
return installDownloadedTempFileWithOperations(tmpPath, dst, perm, systemAtomicReplacementOperations)
|
||||||
return fmt.Errorf("set temp file permissions: %w", err)
|
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmpPath, dst); err != nil {
|
|
||||||
return fmt.Errorf("install downloaded file: %w", err)
|
type temporaryFile interface {
|
||||||
|
io.Writer
|
||||||
|
Name() string
|
||||||
|
Sync() error
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type atomicReplacementOperations struct {
|
||||||
|
createTemp func(string, string) (temporaryFile, error)
|
||||||
|
chmod func(string, os.FileMode) error
|
||||||
|
rename func(string, string) error
|
||||||
|
remove func(string) error
|
||||||
|
syncDirectory func(string) error
|
||||||
|
syncFile func(string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemAtomicReplacementOperations = atomicReplacementOperations{
|
||||||
|
createTemp: func(dir, pattern string) (temporaryFile, error) {
|
||||||
|
return os.CreateTemp(dir, pattern)
|
||||||
|
},
|
||||||
|
chmod: os.Chmod,
|
||||||
|
rename: os.Rename,
|
||||||
|
remove: os.Remove,
|
||||||
|
syncDirectory: syncDirectory,
|
||||||
|
syncFile: syncFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceFileAtomicWithOperations(
|
||||||
|
dst string,
|
||||||
|
data []byte,
|
||||||
|
options ReplaceFileOptions,
|
||||||
|
ops atomicReplacementOperations,
|
||||||
|
) error {
|
||||||
|
return replaceFileFromReaderWithOperations(dst, bytes.NewReader(data), options, ops)
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceFileFromReaderWithOperations(
|
||||||
|
dst string,
|
||||||
|
source io.Reader,
|
||||||
|
options ReplaceFileOptions,
|
||||||
|
ops atomicReplacementOperations,
|
||||||
|
) (resultErr error) {
|
||||||
|
if strings.TrimSpace(dst) == "" {
|
||||||
|
return fmt.Errorf("destination path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(dst)
|
||||||
|
tmp, err := ops.createTemp(dir, "."+filepath.Base(dst)+".tmp-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
removeTmp := true
|
||||||
|
defer func() {
|
||||||
|
if removeTmp {
|
||||||
|
if err := ops.remove(tmpPath); err != nil {
|
||||||
|
cleanupErr := fmt.Errorf("remove temporary file: %w", err)
|
||||||
|
if resultErr == nil {
|
||||||
|
resultErr = cleanupErr
|
||||||
|
} else {
|
||||||
|
resultErr = errors.Join(resultErr, cleanupErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err := io.Copy(tmp, source); err != nil {
|
||||||
|
return closeTemporaryFileAfterError(tmp, fmt.Errorf("write temp file: %w", err))
|
||||||
|
}
|
||||||
|
if err := ops.chmod(tmpPath, options.Mode); err != nil {
|
||||||
|
return closeTemporaryFileAfterError(tmp, fmt.Errorf("set temp file permissions: %w", err))
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
return closeTemporaryFileAfterError(tmp, fmt.Errorf("sync temp file: %w", err))
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close temp file: %w", err)
|
||||||
|
}
|
||||||
|
if options.BeforeRename != nil {
|
||||||
|
if err := options.BeforeRename(); err != nil {
|
||||||
|
return fmt.Errorf("check before file replacement: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := ops.rename(tmpPath, dst); err != nil {
|
||||||
|
return fmt.Errorf("install temp file: %w", err)
|
||||||
|
}
|
||||||
|
removeTmp = false
|
||||||
|
if err := ops.syncDirectory(dir); err != nil {
|
||||||
|
return fmt.Errorf("sync destination directory after replacement: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func closeTemporaryFileAfterError(tmp temporaryFile, operationErr error) error {
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return errors.Join(operationErr, fmt.Errorf("close temporary file: %w", err))
|
||||||
|
}
|
||||||
|
return operationErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func installDownloadedTempFileWithOperations(
|
||||||
|
tmpPath, dst string,
|
||||||
|
perm os.FileMode,
|
||||||
|
ops atomicReplacementOperations,
|
||||||
|
) error {
|
||||||
|
if err := ops.chmod(tmpPath, perm); err != nil {
|
||||||
|
return fmt.Errorf("set temp file permissions: %w", err)
|
||||||
|
}
|
||||||
|
if err := ops.syncFile(tmpPath); err != nil {
|
||||||
|
return fmt.Errorf("sync downloaded temp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := ops.rename(tmpPath, dst); err != nil {
|
||||||
|
return fmt.Errorf("install downloaded file: %w", err)
|
||||||
|
}
|
||||||
|
if err := ops.syncDirectory(filepath.Dir(dst)); err != nil {
|
||||||
|
return fmt.Errorf("sync destination directory after downloaded-file replacement: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncFile(path string) error {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.Sync(); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return file.Close()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package fileops
|
package fileops
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errAtomicReplacementTest = errors.New("atomic replacement test failure")
|
||||||
|
|
||||||
func TestWriteFileAtomicOverwritesAndLeavesNoTempFile(t *testing.T) {
|
func TestWriteFileAtomicOverwritesAndLeavesNoTempFile(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
dst := filepath.Join(root, "out", "value.txt")
|
dst := filepath.Join(root, "out", "value.txt")
|
||||||
@@ -111,6 +115,231 @@ func TestInstallDownloadedTempFileSetsPermissions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReplaceFileAtomicOrdersDurableOperations(t *testing.T) {
|
||||||
|
events := make([]string, 0, 8)
|
||||||
|
tmp := &recordingTemporaryFile{name: "/work/.result.tmp-1", events: &events}
|
||||||
|
ops := recordingAtomicReplacementOperations(&events, tmp)
|
||||||
|
|
||||||
|
err := replaceFileAtomicWithOperations(
|
||||||
|
"/work/result",
|
||||||
|
[]byte("replacement"),
|
||||||
|
ReplaceFileOptions{
|
||||||
|
Mode: 0o640,
|
||||||
|
BeforeRename: func() error {
|
||||||
|
events = append(events, "check")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ops,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("replaceFileAtomicWithOperations() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"create", "write", "chmod:rw-r-----", "sync", "close", "check", "rename", "sync-directory"}
|
||||||
|
if !equalStrings(events, want) {
|
||||||
|
t.Fatalf("operation order = %v, want %v", events, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaceFileAtomicFailuresCleanUninstalledTemporaryFile(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configure func(*recordingTemporaryFile, *atomicReplacementOperations)
|
||||||
|
wantErr error
|
||||||
|
wantRemove bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "short write",
|
||||||
|
configure: func(tmp *recordingTemporaryFile, _ *atomicReplacementOperations) {
|
||||||
|
tmp.shortWrite = true
|
||||||
|
},
|
||||||
|
wantErr: io.ErrShortWrite,
|
||||||
|
wantRemove: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "write",
|
||||||
|
configure: func(tmp *recordingTemporaryFile, _ *atomicReplacementOperations) {
|
||||||
|
tmp.writeErr = errAtomicReplacementTest
|
||||||
|
},
|
||||||
|
wantErr: errAtomicReplacementTest,
|
||||||
|
wantRemove: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sync",
|
||||||
|
configure: func(tmp *recordingTemporaryFile, _ *atomicReplacementOperations) {
|
||||||
|
tmp.syncErr = errAtomicReplacementTest
|
||||||
|
},
|
||||||
|
wantErr: errAtomicReplacementTest,
|
||||||
|
wantRemove: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rename",
|
||||||
|
configure: func(_ *recordingTemporaryFile, ops *atomicReplacementOperations) {
|
||||||
|
ops.rename = func(string, string) error { return errAtomicReplacementTest }
|
||||||
|
},
|
||||||
|
wantErr: errAtomicReplacementTest,
|
||||||
|
wantRemove: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "directory sync",
|
||||||
|
configure: func(_ *recordingTemporaryFile, ops *atomicReplacementOperations) {
|
||||||
|
ops.syncDirectory = func(string) error { return ErrDirectorySyncUnsupported }
|
||||||
|
},
|
||||||
|
wantErr: ErrDirectorySyncUnsupported,
|
||||||
|
wantRemove: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
events := make([]string, 0, 8)
|
||||||
|
tmp := &recordingTemporaryFile{name: "/work/.result.tmp-1", events: &events}
|
||||||
|
ops := recordingAtomicReplacementOperations(&events, tmp)
|
||||||
|
test.configure(tmp, &ops)
|
||||||
|
|
||||||
|
err := replaceFileAtomicWithOperations("/work/result", []byte("replacement"), ReplaceFileOptions{Mode: 0o640}, ops)
|
||||||
|
if !errors.Is(err, test.wantErr) {
|
||||||
|
t.Fatalf("replaceFileAtomicWithOperations() error = %v, want %v", err, test.wantErr)
|
||||||
|
}
|
||||||
|
gotRemove := containsString(events, "remove")
|
||||||
|
if gotRemove != test.wantRemove {
|
||||||
|
t.Fatalf("remove temporary file = %t, want %t; operations = %v", gotRemove, test.wantRemove, events)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaceFileAtomicReportsCleanupFailureWithPrimaryFailure(t *testing.T) {
|
||||||
|
events := make([]string, 0, 5)
|
||||||
|
tmp := &recordingTemporaryFile{
|
||||||
|
name: "/work/.result.tmp-1",
|
||||||
|
events: &events,
|
||||||
|
writeErr: errAtomicReplacementTest,
|
||||||
|
}
|
||||||
|
ops := recordingAtomicReplacementOperations(&events, tmp)
|
||||||
|
cleanupErr := errors.New("temporary cleanup failed")
|
||||||
|
ops.remove = func(string) error {
|
||||||
|
events = append(events, "remove")
|
||||||
|
return cleanupErr
|
||||||
|
}
|
||||||
|
|
||||||
|
err := replaceFileAtomicWithOperations("/work/result", []byte("replacement"), ReplaceFileOptions{Mode: 0o640}, ops)
|
||||||
|
if !errors.Is(err, errAtomicReplacementTest) {
|
||||||
|
t.Fatalf("replacement error = %v, want write failure", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, cleanupErr) {
|
||||||
|
t.Fatalf("replacement error = %v, want cleanup failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallDownloadedTempFileOrdersDurableOperations(t *testing.T) {
|
||||||
|
events := make([]string, 0, 4)
|
||||||
|
ops := atomicReplacementOperations{
|
||||||
|
chmod: func(string, os.FileMode) error {
|
||||||
|
events = append(events, "chmod")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
syncFile: func(string) error {
|
||||||
|
events = append(events, "sync-file")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
rename: func(string, string) error {
|
||||||
|
events = append(events, "rename")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
syncDirectory: func(string) error {
|
||||||
|
events = append(events, "sync-directory")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := installDownloadedTempFileWithOperations("/work/.download.tmp", "/work/result", 0o640, ops); err != nil {
|
||||||
|
t.Fatalf("installDownloadedTempFileWithOperations() error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"chmod", "sync-file", "rename", "sync-directory"}
|
||||||
|
if !equalStrings(events, want) {
|
||||||
|
t.Fatalf("operation order = %v, want %v", events, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingTemporaryFile struct {
|
||||||
|
name string
|
||||||
|
events *[]string
|
||||||
|
shortWrite bool
|
||||||
|
writeErr error
|
||||||
|
syncErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *recordingTemporaryFile) Write(data []byte) (int, error) {
|
||||||
|
*f.events = append(*f.events, "write")
|
||||||
|
if f.writeErr != nil {
|
||||||
|
return 0, f.writeErr
|
||||||
|
}
|
||||||
|
if f.shortWrite {
|
||||||
|
return len(data) - 1, nil
|
||||||
|
}
|
||||||
|
return len(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *recordingTemporaryFile) Name() string { return f.name }
|
||||||
|
|
||||||
|
func (f *recordingTemporaryFile) Sync() error {
|
||||||
|
*f.events = append(*f.events, "sync")
|
||||||
|
return f.syncErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *recordingTemporaryFile) Close() error {
|
||||||
|
*f.events = append(*f.events, "close")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordingAtomicReplacementOperations(events *[]string, tmp temporaryFile) atomicReplacementOperations {
|
||||||
|
return atomicReplacementOperations{
|
||||||
|
createTemp: func(string, string) (temporaryFile, error) {
|
||||||
|
*events = append(*events, "create")
|
||||||
|
return tmp, nil
|
||||||
|
},
|
||||||
|
chmod: func(_ string, mode os.FileMode) error {
|
||||||
|
*events = append(*events, "chmod:"+mode.Perm().String()[1:])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
rename: func(string, string) error {
|
||||||
|
*events = append(*events, "rename")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
remove: func(string) error {
|
||||||
|
*events = append(*events, "remove")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
syncDirectory: func(string) error {
|
||||||
|
*events = append(*events, "sync-directory")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(values []string, target string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if value == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalStrings(got, want []string) bool {
|
||||||
|
if len(got) != len(want) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func assertNoMatchingTempFiles(t *testing.T, dir, prefix string) {
|
func assertNoMatchingTempFiles(t *testing.T, dir, prefix string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package fileops
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
@@ -16,10 +17,10 @@ func syncDirectory(path string) error {
|
|||||||
defer func() { _ = directory.Close() }()
|
defer func() { _ = directory.Close() }()
|
||||||
|
|
||||||
err = directory.Sync()
|
err = directory.Sync()
|
||||||
// Some Unix filesystems do not implement directory syncing. Only their
|
// Some Unix filesystems do not implement directory syncing. Report this
|
||||||
// explicit unsupported-operation errors are safe to treat as best effort.
|
// explicitly so callers do not confuse visible replacement with a durable one.
|
||||||
if errors.Is(err, syscall.EINVAL) || errors.Is(err, syscall.ENOTSUP) {
|
if errors.Is(err, syscall.EINVAL) || errors.Is(err, syscall.ENOTSUP) {
|
||||||
return nil
|
return fmt.Errorf("%w for %q: %w", ErrDirectorySyncUnsupported, path, err)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
package fileops
|
package fileops
|
||||||
|
|
||||||
func syncDirectory(string) error {
|
import (
|
||||||
return checkAtomicDirectoryPromotionSupport()
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
func syncDirectory(path string) error {
|
||||||
|
return fmt.Errorf("%w for %q on %s", ErrDirectorySyncUnsupported, path, runtime.GOOS)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package fileops
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
@@ -29,12 +30,12 @@ func syncDirectory(path string) error {
|
|||||||
|
|
||||||
err = windows.FlushFileBuffers(directory)
|
err = windows.FlushFileBuffers(directory)
|
||||||
// Windows filesystems may reject flushing a directory handle even when it
|
// Windows filesystems may reject flushing a directory handle even when it
|
||||||
// was opened correctly. Preserve every error except the documented forms
|
// was opened correctly. Report documented unavailable forms explicitly so
|
||||||
// that mean this operation is unavailable for the handle or filesystem.
|
// callers do not confuse visible replacement with a durable one.
|
||||||
if errors.Is(err, windows.ERROR_INVALID_FUNCTION) ||
|
if errors.Is(err, windows.ERROR_INVALID_FUNCTION) ||
|
||||||
errors.Is(err, windows.ERROR_INVALID_HANDLE) ||
|
errors.Is(err, windows.ERROR_INVALID_HANDLE) ||
|
||||||
errors.Is(err, windows.ERROR_NOT_SUPPORTED) {
|
errors.Is(err, windows.ERROR_NOT_SUPPORTED) {
|
||||||
return nil
|
return fmt.Errorf("%w for %q: %w", ErrDirectorySyncUnsupported, path, err)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func (s *LocalStore) Load(ctx context.Context, path string) (*Manifest, error) {
|
|||||||
return &m, nil
|
return &m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save writes the manifest to path atomically via temp file + rename.
|
// Save writes the manifest to path through the durable file replacement primitive.
|
||||||
func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error {
|
func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error {
|
||||||
if err := checkContext(ctx); err != nil {
|
if err := checkContext(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -95,46 +95,9 @@ func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error {
|
|||||||
}
|
}
|
||||||
data = append(data, '\n')
|
data = append(data, '\n')
|
||||||
|
|
||||||
dir := filepath.Dir(path)
|
if err := writeJSONAtomically(ctx, path, data); err != nil {
|
||||||
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
|
return fmt.Errorf("save manifest: %w", err)
|
||||||
return fmt.Errorf("save manifest: create directory %q: %w", dir, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tmp, err := os.CreateTemp(dir, ".manifest.json.tmp-*")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("save manifest: create temp file: %w", err)
|
|
||||||
}
|
|
||||||
tmpName := tmp.Name()
|
|
||||||
removeTmp := true
|
|
||||||
defer func() {
|
|
||||||
if removeTmp {
|
|
||||||
_ = os.Remove(tmpName)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if _, err := tmp.Write(data); err != nil {
|
|
||||||
_ = tmp.Close()
|
|
||||||
return fmt.Errorf("save manifest: write temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Sync(); err != nil {
|
|
||||||
_ = tmp.Close()
|
|
||||||
return fmt.Errorf("save manifest: sync temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
return fmt.Errorf("save manifest: close temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Chmod(tmpName, fileops.WorkspaceFileMode); err != nil {
|
|
||||||
return fmt.Errorf("save manifest: set temp file permissions: %w", err)
|
|
||||||
}
|
|
||||||
if err := checkContext(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Rename(tmpName, path); err != nil {
|
|
||||||
return fmt.Errorf("save manifest: rename temp file: %w", err)
|
|
||||||
}
|
|
||||||
removeTmp = false
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +184,7 @@ func (s *LocalStore) SaveRun(ctx context.Context, path string, m *RunManifest) e
|
|||||||
}
|
}
|
||||||
data = append(data, '\n')
|
data = append(data, '\n')
|
||||||
|
|
||||||
return writeJSONAtomically(ctx, path, ".run-manifest.json.tmp-*", data)
|
return writeJSONAtomically(ctx, path, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateLoadedManifest(m *Manifest) error {
|
func validateLoadedManifest(m *Manifest) error {
|
||||||
@@ -330,48 +293,17 @@ func normalizeRunManifest(m *RunManifest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSONAtomically(ctx context.Context, path, tempPattern string, data []byte) error {
|
func writeJSONAtomically(ctx context.Context, path string, data []byte) error {
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
|
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
|
||||||
return fmt.Errorf("create directory %q: %w", dir, err)
|
return fmt.Errorf("create directory %q: %w", dir, err)
|
||||||
}
|
}
|
||||||
|
return fileops.ReplaceFileAtomic(path, data, fileops.ReplaceFileOptions{
|
||||||
tmp, err := os.CreateTemp(dir, tempPattern)
|
Mode: fileops.WorkspaceFileMode,
|
||||||
if err != nil {
|
BeforeRename: func() error {
|
||||||
return fmt.Errorf("create temp file: %w", err)
|
return checkContext(ctx)
|
||||||
}
|
},
|
||||||
tmpName := tmp.Name()
|
})
|
||||||
removeTmp := true
|
|
||||||
defer func() {
|
|
||||||
if removeTmp {
|
|
||||||
_ = os.Remove(tmpName)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if _, err := tmp.Write(data); err != nil {
|
|
||||||
_ = tmp.Close()
|
|
||||||
return fmt.Errorf("write temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Sync(); err != nil {
|
|
||||||
_ = tmp.Close()
|
|
||||||
return fmt.Errorf("sync temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
return fmt.Errorf("close temp file: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Chmod(tmpName, fileops.WorkspaceFileMode); err != nil {
|
|
||||||
return fmt.Errorf("set temp file permissions: %w", err)
|
|
||||||
}
|
|
||||||
if err := checkContext(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Rename(tmpName, path); err != nil {
|
|
||||||
return fmt.Errorf("rename temp file: %w", err)
|
|
||||||
}
|
|
||||||
removeTmp = false
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkContext(ctx context.Context) error {
|
func checkContext(ctx context.Context) error {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package manifest
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -205,6 +206,30 @@ func TestLocalStoreSaveAtomicPractical(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteJSONAtomicallyChecksCancellationBeforeReplacement(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "manifest.json")
|
||||||
|
err := writeJSONAtomically(ctx, path, []byte("{\"session_id\":\"session\"}\n"))
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("writeJSONAtomically() error = %v, want context cancellation", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("replacement destination exists after cancellation: stat err = %v", err)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDir() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), ".manifest.json.tmp-") {
|
||||||
|
t.Fatalf("temporary manifest remained after cancellation: %s", entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadRejectsInvalidManifest(t *testing.T) {
|
func TestLoadRejectsInvalidManifest(t *testing.T) {
|
||||||
store := &LocalStore{}
|
store := &LocalStore{}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
Reference in New Issue
Block a user