Documentation
¶
Overview ¶
Package atomicfile provides crash-safe atomic file writes via the temp → fsync → rename → dir-fsync sequence, with path validation and bounded reads. Standard-library only.
Result and durability ¶
The write primitives (WriteFile, WriteReader, WriteFileInRoot, WriteReaderInRoot, and PendingFile.Commit) return a Result alongside an error. A nil error means the data reached its final path; the write either fully succeeded or, at worst, was renamed into place but the parent-directory fsync failed. Result.Durable distinguishes those two outcomes: it is true only when both the file and its parent directory were fsynced, so a caller that cares about crash durability inspects Result.Durable rather than decoding error types. A non-nil error always means the data did NOT reach its final path.
Temp files ¶
Every temp file this package creates is named ".atomicfile-<digits>.tmp" (os.CreateTemp replaces the pattern's "*" with a decimal random string). CleanupStaleTemps reclaims orphaned temps of exactly that shape and nothing else, so it never deletes a caller-owned file.
Index ¶
- Variables
- func CleanupStaleTemps(dir string, maxAge time.Duration, opts ...Option) (removed int, err error)
- func ReadBounded(ctx context.Context, path string, maxBytes int64) ([]byte, error)
- func ReadBoundedFile(ctx context.Context, f *os.File, maxBytes int64) ([]byte, error)
- type Option
- type PendingFile
- type Result
- func WriteFile(ctx context.Context, path string, data []byte, opts ...Option) (Result, error)
- func WriteFileInRoot(ctx context.Context, root *os.Root, name string, data []byte, opts ...Option) (Result, error)
- func WriteReader(ctx context.Context, path string, r io.Reader, opts ...Option) (Result, error)
- func WriteReaderInRoot(ctx context.Context, root *os.Root, name string, r io.Reader, opts ...Option) (Result, error)
- type WriteError
- type WritePhase
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrEmptyPath is returned when a path argument is empty. ErrEmptyPath = errors.New("atomicfile: empty path") // ErrUnsafePath is returned when a path fails the local safety check. ErrUnsafePath = errors.New("atomicfile: unsafe path") // ErrFileTooLarge is returned when a file exceeds the read size limit. ErrFileTooLarge = errors.New("atomicfile: file too large") // ErrSymlinkTarget is returned when the target path is a symlink and // WithAllowSymlinkTarget was not set. ErrSymlinkTarget = errors.New("atomicfile: target is a symlink") // ErrAborted is returned by PendingFile.Commit when the pending file was // already aborted by a prior Cleanup. The temp file was removed and nothing // reached the final path, so Commit reports this rather than a zero-value // Result with a nil error, which would falsely signal success. ErrAborted = errors.New("atomicfile: pending file aborted") )
Sentinel errors.
Functions ¶
func CleanupStaleTemps ¶
CleanupStaleTemps removes temp files in dir that this package created (".atomicfile-<digits>.tmp") and whose mtime is older than maxAge. It returns the number removed. A missing dir is not an error. Best-effort per file: individual stat/remove failures are logged at Debug and skipped (see reapStaleTemp); only a readdir failure is returned.
Example ¶
package main
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/cplieger/atomicfile/v2"
)
func main() {
dir, _ := os.MkdirTemp("", "example")
defer os.RemoveAll(dir)
// Simulate an orphaned temp left by an interrupted write.
stale := filepath.Join(dir, ".atomicfile-123456.tmp")
_ = os.WriteFile(stale, []byte("partial"), 0o644)
old := time.Now().Add(-2 * time.Hour)
_ = os.Chtimes(stale, old, old)
removed, _ := atomicfile.CleanupStaleTemps(dir, time.Hour)
fmt.Println(removed)
}
Output: 1
func ReadBounded ¶
ReadBounded opens path, validates its size against maxBytes, and reads it via an io.LimitReader. Returns ErrFileTooLarge if the file exceeds maxBytes (including if it grows past the limit during the read). ctx is checked before the open and before the read.
Unlike the write primitives, ReadBounded does NOT refuse symlink targets: os.Open follows symlinks, so a symlink at path is resolved and its target is read. Callers reading from a directory writable by a less-trusted principal should confine the path themselves: open the file through an *os.Root and read it with ReadBoundedFile, which applies the same size and context bounds.
Example ¶
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/cplieger/atomicfile/v2"
)
func main() {
dir, _ := os.MkdirTemp("", "example")
defer os.RemoveAll(dir)
path := filepath.Join(dir, "data.txt")
_ = os.WriteFile(path, []byte("bounded"), 0o644)
data, _ := atomicfile.ReadBounded(context.Background(), path, 1<<20)
fmt.Println(string(data))
}
Output: bounded
func ReadBoundedFile ¶ added in v2.1.0
ReadBoundedFile reads up to maxBytes from an already-open file using the same size validation as ReadBounded: it returns ErrFileTooLarge if the file exceeds maxBytes (including if it grows past the limit during the read), and checks ctx before the size stat and before the read. The caller owns f; ReadBoundedFile does not close it.
This is the seam for callers that must open the file themselves before reading it — most importantly, opening through an *os.Root (Go 1.24+) to confine the path to a trusted directory, which ReadBounded cannot do because os.Open follows symlinks. Open the file via the root, then read it here to get the identical bounds and context handling.
Types ¶
type Option ¶
type Option func(*cfg)
Option configures an atomic write operation.
func WithAllowSymlinkTarget ¶
func WithAllowSymlinkTarget() Option
WithAllowSymlinkTarget permits writing to a path that is currently a symlink. By default symlink targets are refused with ErrSymlinkTarget. Note the atomic rename REPLACES the symlink with a regular file; it does not write through to the link's target. Resolve with filepath.EvalSymlinks first if that is the intent.
func WithLogger ¶
WithLogger sets a custom logger. If not provided, slog.Default() is used.
func WithMkdirMode ¶
WithMkdirMode creates the parent directory (and any missing ancestors) with the given permission before writing. Without it, a missing parent directory is an error.
func WithNoSync ¶
func WithNoSync() Option
WithNoSync skips the fsync on both the temp file and the parent directory, providing atomicity without durability. Result.Durable is then always false.
func WithPreserveMode ¶
func WithPreserveMode() Option
WithPreserveMode stats the target before writing and applies its existing mode to the new file, falling back to the WithMode value if the target does not exist or cannot be stat-ed (a non-ErrNotExist stat failure is logged at Warn).
func WithPreserveOwnership ¶
func WithPreserveOwnership() Option
WithPreserveOwnership stats the target before writing and chowns the temp file to match the target's uid/gid. Requires CAP_CHOWN or root; a no-op when the target does not exist, cannot be stat-ed, or off Unix (a non-ErrNotExist stat failure is logged at Warn). Best-effort: the chown runs after the temp-file fsync, so unlike content and mode it is not crash-covered. A chown failure is logged at Warn and does not fail the write (the file lands with the writer's ownership).
type PendingFile ¶
PendingFile is a temp file open for writing, destined to atomically replace path on Commit. It embeds *os.File, so callers get the full io.Writer/io.ReaderFrom/fmt.Fprintf surface. The configuration is captured at construction and reused at Commit, so durability options cannot drift between the two calls.
A PendingFile is not safe for concurrent use: Commit and Cleanup mutate unsynchronized lifecycle state (state, result, err). Confine each PendingFile to a single goroutine. The package-level WriteFile, WriteReader, ReadBounded, and CleanupStaleTemps are stateless and safe to call concurrently on distinct paths.
Example ¶
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/cplieger/atomicfile/v2"
)
func main() {
dir, _ := os.MkdirTemp("", "example")
defer os.RemoveAll(dir)
path := filepath.Join(dir, "pending.txt")
pf, _ := atomicfile.NewPendingFile(context.Background(), path)
defer func() { _ = pf.Cleanup() }()
_, _ = pf.Write([]byte("incremental"))
_, _ = pf.Commit(context.Background())
data, _ := os.ReadFile(path)
fmt.Println(string(data))
}
Output: incremental
func NewPendingFile ¶
NewPendingFile creates a temp file destined to atomically replace path. Write to it, then call Commit to finalize or Cleanup to abort. Mode defaults to 0o644 (override with WithMode). ctx is checked before the temp is created.
Example ¶
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/cplieger/atomicfile/v2"
)
func main() {
dir, _ := os.MkdirTemp("", "example")
defer os.RemoveAll(dir)
path := filepath.Join(dir, "pending.txt")
pf, _ := atomicfile.NewPendingFile(context.Background(), path)
defer func() { _ = pf.Cleanup() }()
_, _ = pf.Write([]byte("incremental"))
res, _ := pf.Commit(context.Background())
data, _ := os.ReadFile(res.Path)
fmt.Println(string(data))
}
Output: incremental
func (*PendingFile) Cleanup ¶
func (p *PendingFile) Cleanup() error
Cleanup closes and removes the temp file, aborting the pending write. It is a no-op once Commit has been called — a successful Commit already moved the data into place, and a failed Commit already removed the temp. After a successful Cleanup, a subsequent Commit returns ErrAborted.
If the temp removal fails (for a reason other than the file already being gone), Cleanup returns that error WITHOUT marking the write cleaned, so a later Cleanup retries the removal — a failed Cleanup never falsely reports the temp gone, and a subsequent Commit still returns ErrAborted. Repeated Cleanup calls are therefore a no-op after success but a retry after a removal failure. Safe to defer immediately after NewPendingFile.
func (*PendingFile) Commit ¶
func (p *PendingFile) Commit(ctx context.Context) (Result, error)
Commit runs the durability barrier (chmod, optional fsync, close), applies ownership, atomically renames the temp into place, and fsyncs the parent directory. Commit is idempotent: repeated calls return the first result. Calling Commit after Cleanup returns ErrAborted — the temp was already removed, so there is nothing to commit, and a nil error would falsely signal that the data reached its final path. After a successful Commit, Cleanup is a no-op. ctx is checked through the temp-side barrier (before chmod, around the fsync, and before close); a context cancelled at or before close aborts and removes the temp. Once the barrier passes, ownership and the rename run without a further ctx check, so a context cancelled in the narrow window between close and rename still commits.
type Result ¶
type Result struct {
// Path is the cleaned final path the data was written to. It is
// absolute for the package-level write functions (which require an
// absolute path); for WriteFileInRoot and WriteReaderInRoot it is
// root.Name() joined with the cleaned relative name, so it is relative
// when the *os.Root was opened with a relative path.
Path string
// Durable reports whether the write is guaranteed to survive a crash:
// true only when the file and its parent directory were both fsynced.
// It is false when WithNoSync was set, or when the parent-directory fsync
// failed after the rename — in that case the data is present at Path but
// may not survive an immediate power loss, and the fsync failure is logged
// at Warn.
Durable bool
}
Result reports the outcome of an atomic write that reached its final path.
func WriteFile ¶
WriteFile atomically writes data to path. Mode defaults to 0o644 (override with WithMode). A nil error means the data is at path; check Result.Durable for crash durability.
Example ¶
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/cplieger/atomicfile/v2"
)
func main() {
dir, _ := os.MkdirTemp("", "example")
defer os.RemoveAll(dir)
path := filepath.Join(dir, "data.txt")
res, _ := atomicfile.WriteFile(context.Background(), path, []byte("hello"))
data, _ := os.ReadFile(path)
fmt.Println(string(data), res.Durable)
}
Output: hello true
Example (WithMkdirMode) ¶
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/cplieger/atomicfile/v2"
)
func main() {
dir, _ := os.MkdirTemp("", "example")
defer os.RemoveAll(dir)
path := filepath.Join(dir, "nested", "dir", "file.txt")
_, _ = atomicfile.WriteFile(context.Background(), path, []byte("deep"),
atomicfile.WithMkdirMode(0o755))
data, _ := os.ReadFile(path)
fmt.Println(string(data))
}
Output: deep
Example (WithMode) ¶
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/cplieger/atomicfile/v2"
)
func main() {
dir, _ := os.MkdirTemp("", "example")
defer os.RemoveAll(dir)
path := filepath.Join(dir, "secret.txt")
_, _ = atomicfile.WriteFile(context.Background(), path, []byte("s3cr3t"),
atomicfile.WithMode(0o600))
fi, _ := os.Stat(path)
fmt.Println(fi.Mode().Perm())
}
Output: -rw-------
Example (WithNoSync) ¶
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/cplieger/atomicfile/v2"
)
func main() {
dir, _ := os.MkdirTemp("", "example")
defer os.RemoveAll(dir)
path := filepath.Join(dir, "cache.txt")
res, _ := atomicfile.WriteFile(context.Background(), path, []byte("fast"),
atomicfile.WithNoSync())
// WithNoSync trades durability for speed, so the result is not durable.
fmt.Println(res.Durable)
}
Output: false
func WriteFileInRoot ¶ added in v2.1.0
func WriteFileInRoot(ctx context.Context, root *os.Root, name string, data []byte, opts ...Option) (Result, error)
WriteFileInRoot atomically writes data to name, a path relative to root, with the same temp-then-rename durability and symlink refusal as WriteFile but confined to root: every filesystem operation runs through the *os.Root (Go 1.24+), so a symlink or ".." component in name can never write outside root's tree. It is the write-side analogue of opening a file through an *os.Root and reading it with ReadBoundedFile. Mode defaults to 0o644 (override with WithMode). A nil error means the data is at name; check Result.Durable for crash durability. Result.Path is root's directory joined with the cleaned relative name. A nil root returns ErrUnsafePath.
Example ¶
package main
import (
"context"
"fmt"
"os"
"github.com/cplieger/atomicfile/v2"
)
func main() {
dir, _ := os.MkdirTemp("", "example")
defer os.RemoveAll(dir)
root, _ := os.OpenRoot(dir)
defer func() { _ = root.Close() }()
// name is relative to the root; a symlink or ".." in it cannot escape the
// root's tree (Go 1.24+).
_, _ = atomicfile.WriteFileInRoot(context.Background(), root, "rooted.txt",
[]byte("confined"))
// Read it back through the same root with the bounded-read seam.
f, _ := root.Open("rooted.txt")
defer func() { _ = f.Close() }()
data, _ := atomicfile.ReadBoundedFile(context.Background(), f, 1<<20)
fmt.Println(string(data))
}
Output: confined
func WriteReader ¶
WriteReader atomically writes the contents of r to path. Mode defaults to 0o644 (override with WithMode). If r implements io.WriterTo it is used for efficient copying; that fast path bypasses the per-Read context check, so cancellation is coarse (per-chunk for chunked sources, post-copy for single-shot sources). ctx is still honored at the durability barrier, so a cancelled write leaves no partial target.
Example ¶
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/cplieger/atomicfile/v2"
)
func main() {
dir, _ := os.MkdirTemp("", "example")
defer os.RemoveAll(dir)
path := filepath.Join(dir, "stream.txt")
_, _ = atomicfile.WriteReader(context.Background(), path,
strings.NewReader("streamed"), atomicfile.WithMode(0o600))
data, _ := os.ReadFile(path)
fmt.Println(string(data))
}
Output: streamed
Example (WithMode) ¶
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/cplieger/atomicfile/v2"
)
func main() {
dir, _ := os.MkdirTemp("", "example")
defer os.RemoveAll(dir)
path := filepath.Join(dir, "stream.txt")
_, _ = atomicfile.WriteReader(context.Background(), path,
strings.NewReader("streamed"), atomicfile.WithMode(0o644))
data, _ := os.ReadFile(path)
fmt.Println(string(data))
}
Output: streamed
func WriteReaderInRoot ¶ added in v2.1.0
func WriteReaderInRoot(ctx context.Context, root *os.Root, name string, r io.Reader, opts ...Option) (Result, error)
WriteReaderInRoot atomically writes the contents of r to name, a path relative to root, confined to root's tree (see WriteFileInRoot). If r implements io.WriterTo it is used for efficient copying; that fast path bypasses the per-Read context check, so cancellation is coarse (per-chunk for chunked sources, post-copy for single-shot sources). ctx is still honored at the durability barrier, so a cancelled write leaves no partial target. Mode defaults to 0o644 (override with WithMode). A nil root returns ErrUnsafePath.
type WriteError ¶
type WriteError struct {
Err error
Phase WritePhase
}
WriteError wraps a hard atomic-write failure with the phase that failed. A WriteError always means the data did NOT reach its final path.
func (*WriteError) Error ¶
func (e *WriteError) Error() string
func (*WriteError) Unwrap ¶
func (e *WriteError) Unwrap() error
type WritePhase ¶
type WritePhase int
WritePhase identifies which step of an atomic write failed. Each value appears only on a WriteError, which is returned exclusively for hard failures (the data did not reach its final path). Two outcomes are deliberately absent from this enum because they are not hard failures: a parent-directory fsync failure (surfaced via Result.Durable) and a preserve-ownership chown failure (best-effort, logged at Warn).
const ( // PhaseTempCreate indicates failure creating the temp file. PhaseTempCreate WritePhase = iota + 1 // PhaseTempWrite indicates failure writing to the temp file. PhaseTempWrite // PhaseTempChmod indicates failure setting permissions on the temp file. PhaseTempChmod // PhaseTempSync indicates failure syncing the temp file. PhaseTempSync // PhaseTempClose indicates failure closing the temp file. PhaseTempClose // PhaseRename indicates failure renaming temp to the final path. PhaseRename )
func (WritePhase) String ¶
func (p WritePhase) String() string