atomicfile

package module
v1.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 10, 2026 License: GPL-2.0, GPL-3.0 Imports: 14 Imported by: 0

README

atomicfile

CI Go Reference

Crash-safe atomic file writes for Go

A standalone Go library providing atomic file writes (temp→fsync→rename→dir-fsync), path-traversal validation, bounded reads, streaming writes, and JSON helpers. Standard-library only, no external dependencies.

Platform Support

Linux only (including Docker/containers). Windows is unsupported by design — os.Rename cannot guarantee atomicity on Windows (golang/go#22397). macOS/BSD may work but is untested.

Install

go get github.com/cplieger/atomicfile@latest

Usage

package main

import (
	"context"
	"strings"
	"sync"

	"github.com/cplieger/atomicfile"
)

func main() {
	ctx := context.Background()

	// Atomic write with default mode (0644)
	atomicfile.WriteFile(ctx, "/tmp/data.txt", []byte("hello"))

	// Atomic write with custom mode
	atomicfile.WriteFile(ctx, "/tmp/secret.txt", []byte("s3cr3t"),
		atomicfile.WithMode(0o600))

	// Streaming write from io.Reader
	atomicfile.WriteReader(ctx, "/tmp/stream.txt", strings.NewReader("streamed"), 0o644)

	// PendingFile for incremental writes (mirrors google/renameio)
	pf, _ := atomicfile.NewPendingFile("/tmp/pending.txt", 0o644)
	defer pf.Cleanup()
	pf.Write([]byte("incremental"))
	pf.CommitFile()

	// Preserve existing file permissions across replace
	atomicfile.WriteFile(ctx, "/tmp/data.txt", []byte("updated"),
		atomicfile.WithPreserveMode())

	// Auto-create parent directories
	atomicfile.WriteFile(ctx, "/tmp/nested/dir/file.txt", []byte("deep"),
		atomicfile.WithMkdirMode(0o755))

	// Skip fsync for speed (atomicity without durability)
	atomicfile.WriteFile(ctx, "/tmp/cache.txt", []byte("fast"),
		atomicfile.WithNoSync())

	// JSON round-trip
	var mu sync.Mutex
	atomicfile.SaveJSON("/tmp/config.json", &mu, map[string]int{"x": 1}, "app", 0o644)
	var cfg map[string]int
	atomicfile.LoadJSON(ctx, "/tmp/config.json", 1<<20, &cfg)
}

API

Write Functions
  • WriteFile(ctx, path, data, opts ...Option) — atomic write (default mode 0644)
  • WriteReader(ctx, path, r, mode, opts ...Option) — atomic write from io.Reader (uses io.WriterTo optimization when available)
  • Prepare(ctx, path, data, opts ...Option) — create temp file ready for commit
  • Commit(tmpPath, finalPath, opts ...Option) — rename temp to final + dir fsync
  • SaveBytes(path, data, perm, opts ...Option) — atomic write with auto-mkdir
  • SaveJSON(path, mu, v, label, perm, opts ...Option) — marshal + atomic write
Streaming Writer
  • NewPendingFile(path, mode, opts ...Option) — open a temp file for incremental writing
  • (*PendingFile).CommitFile() — fsync + close + rename (finalize)
  • (*PendingFile).Cleanup() — close + remove (abort; no-op after commit)

PendingFile embeds *os.File, providing full io.Writer/io.ReaderFrom/fmt.Fprintf support.

Read Functions
  • ReadBounded(ctx, path, maxBytes) — size-checked read
  • LoadJSON(ctx, path, maxBytes, v) — bounded read + JSON unmarshal
Utilities
  • CleanupStaleTemps(dir, maxAge, opts ...Option) — remove stale temp files left by interrupted writes; recognizes both built-in temp schemes and, when a WithTempPattern option is passed, that custom pattern too (pass the same one you write with)

Functional Options

All write functions accept variadic Option values to customize behavior. Omit options for defaults.

Option Description
WithLogger(l) Custom *slog.Logger for diagnostic output (default: slog.Default())
WithTempPattern(p) os.CreateTemp pattern for temp files (default: ".atomicfile-*.tmp")
WithMode(mode) File permission (default: 0o644). Used by WriteFile and Prepare.
WithMkdirMode(mode) When set, create parent directories with this mode before writing
WithPreserveMode() Stat target and reuse its mode (like renameio.WithExistingPermissions)
WithPreserveOwnership() Stat target and chown temp to match uid/gid (requires CAP_CHOWN)
WithNoSync() Skip fsync for speed (atomicity without durability, like google/renameio)
WithAllowSymlinkTarget() Permit writing to a symlink path (default: refuse with ErrSymlinkTarget)

Durability Guarantees

Every atomic write follows the sequence: create temp file → write data → fsync temp → close → rename to final path → fsync parent directory. This ensures that after a crash, the file contains either the complete old content or the complete new content — never a partial write. The directory fsync makes the rename durable even if the system loses power immediately after the call returns.

If the parent-directory fsync fails (for example an EIO from a failing disk), the write returns a *WriteError with Phase: PhaseDirSync. The rename has already completed at that point, so the new content is present at the final path, but its durability across an immediate crash is not guaranteed. Callers that require strict durability should treat a PhaseDirSync error as actionable (retry or alert); callers that only need atomicity can ignore it or use WithNoSync() to skip the directory fsync entirely.

Note on auto-created directories. When WithMkdirMode (or SaveBytes's implicit MkdirAll) creates new parent directories, only the file's immediate parent is fsynced. The newly created intermediate directories are not fsynced into their own parents, so the durability guarantee above applies only when the full parent path already exists. If you need durability into a freshly created directory tree, create and fsync the directories before writing.

By default, all write functions refuse to write to a path that is currently a symlink, returning ErrSymlinkTarget. This is because os.Rename replaces the symlink itself rather than the file it points to — which is rarely the caller's intent and can lead to data loss or security issues.

To opt in to writing through symlinks (replacing the symlink with a regular file), use WithAllowSymlinkTarget().

Durability vs Speed

By default, all writes are durable: temp files are fsynced before rename, and the parent directory is fsynced after rename. This ensures data survives power loss.

Use WithNoSync() for atomicity without durability (like google/renameio's design). The file will never be partially written, but may be lost entirely on power failure. Useful for caches, metrics, and other reconstructible data.

Unsupported by Design

The following features are intentionally not implemented:

Feature Rationale
Windows rename-over semantics Target platform is Linux. os.Rename is atomic on Linux. Windows cannot guarantee atomicity (golang/go#22397). google/renameio also refuses Windows.
fs.FS interop fs.FS is a read-only interface. Atomic writes are inherently outside its scope.
Atomic symlink replacement Out of scope. Use google/renameio if needed.
Umask-aware permissions The library uses Chmod for exact permissions (ignoring umask). This is the correct secure default for server/CLI tools. Equivalent to renameio's WithStaticPermissions.
TempDir cross-mount detection Temp files are always created in the target directory (same mount point), the only correct approach for atomic rename.
os.Root scoped operations Niche security feature not needed by target consumers.
Context on SaveBytes/SaveJSON Convenience wrappers for small payloads. Use WriteFile/WriteReader for cancellable operations.

License

GPL-3.0 — see LICENSE.

Documentation

Overview

Package atomicfile provides crash-safe atomic file writes via temp→fsync→rename→dir-fsync, path-traversal validation, bounded reads, and JSON save helpers. Standard-library only.

Index

Examples

Constants

View Source
const DefaultTempPrefix = ".atomicfile-*.tmp"

DefaultTempPrefix is the default pattern used for temp files.

Variables

View Source
var ErrEmptyPath = errors.New("atomic write: empty path")

ErrEmptyPath is returned when a path argument is empty.

View Source
var ErrFileTooLarge = errors.New("file too large")

ErrFileTooLarge is returned when a file exceeds the size limit.

View Source
var ErrSymlinkTarget = errors.New("atomic write: target is a symlink")

ErrSymlinkTarget is returned when the target path is a symlink and AllowSymlinkTarget is not set. Atomic rename replaces the symlink itself, not the file it points to — which is rarely the caller's intent.

View Source
var ErrUnsafePath = errors.New("atomic write: unsafe path")

ErrUnsafePath is returned when a path fails the local safety check.

Functions

func CleanupStaleTemps

func CleanupStaleTemps(dir string, maxAge time.Duration, opts ...Option)

CleanupStaleTemps removes stale temp files in dir older than maxAge. It recognizes the two built-in temp-naming conventions (DefaultTempPrefix ".atomicfile-*.tmp" and "<base>.tmp-<random>") and, when a WithTempPattern option is passed, that custom pattern too — pass the same WithTempPattern you use for writes so its orphaned temps are reclaimed. Best-effort; errors are logged but not returned.

func Commit

func Commit(tmpPath, finalPath string, opts ...Option) error

Commit renames the prepared temp file to the final path and fsyncs the parent directory. Pass the SAME options (notably WithNoSync) that were given to Prepare: the durability barrier spans both calls, and a mismatch (e.g. WithNoSync on Commit but not Prepare) drops the parent-dir fsync that makes the rename durable.

func LoadJSON

func LoadJSON(ctx context.Context, path string, maxBytes int64, v any) error

LoadJSON reads a JSON file with size bounds and unmarshals into v. Symmetric with SaveJSON. maxBytes limits the file size to prevent unbounded memory allocation.

Example
package main

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"sync"

	"github.com/cplieger/atomicfile"
)

func main() {
	dir, _ := os.MkdirTemp("", "example")
	defer os.RemoveAll(dir)
	path := filepath.Join(dir, "config.json")
	var mu sync.Mutex
	_ = atomicfile.SaveJSON(path, &mu, map[string]string{"key": "value"}, "example", 0o644)
	var cfg map[string]string
	_ = atomicfile.LoadJSON(context.Background(), path, 1<<20, &cfg)
	fmt.Println(cfg["key"])
}
Output:
value

func Prepare

func Prepare(ctx context.Context, path string, data []byte, opts ...Option) (tmpPath string, cleanup func(), err error)

Prepare creates a temp file with data written, synced, and closed — ready for a final rename via Commit.

func ReadBounded

func ReadBounded(ctx context.Context, path string, maxBytes int64) ([]byte, error)

ReadBounded opens a file, validates its size against maxBytes, and reads it with a LimitReader. Returns ErrFileTooLarge if the file exceeds the limit.

Example
package main

import (
	"context"
	"fmt"
	"os"
	"path/filepath"

	"github.com/cplieger/atomicfile"
)

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 SaveBytes

func SaveBytes(path string, data []byte, perm os.FileMode, opts ...Option) error

SaveBytes writes raw bytes to path atomically, creating parent directories as needed. Directory permissions are 0755 for world-readable files, 0700 for private-only files.

func SaveJSON

func SaveJSON(path string, mu *sync.Mutex, v any, label string, perm os.FileMode, opts ...Option) error

SaveJSON marshals v to indented JSON and writes it atomically. mu must not be nil; it serializes concurrent writes to the same path. label identifies the caller in log output for diagnostics.

Example
package main

import (
	"fmt"
	"os"
	"path/filepath"
	"sync"

	"github.com/cplieger/atomicfile"
)

func main() {
	dir, _ := os.MkdirTemp("", "example")
	defer os.RemoveAll(dir)
	path := filepath.Join(dir, "config.json")
	var mu sync.Mutex
	_ = atomicfile.SaveJSON(path, &mu, map[string]string{"key": "value"}, "example", 0o644)
	data, _ := os.ReadFile(path)
	fmt.Println(string(data))
}
Output:
{
  "key": "value"
}

func WriteFile

func WriteFile(ctx context.Context, path string, data []byte, opts ...Option) error

WriteFile writes data to path atomically. Mode defaults to 0o644; override with WithMode. Additional behavior configured via opts.

Example
package main

import (
	"context"
	"fmt"
	"os"
	"path/filepath"

	"github.com/cplieger/atomicfile"
)

func main() {
	dir, _ := os.MkdirTemp("", "example")
	defer os.RemoveAll(dir)
	path := filepath.Join(dir, "data.txt")
	_ = atomicfile.WriteFile(context.Background(), path, []byte("hello"))
	data, _ := os.ReadFile(path)
	fmt.Println(string(data))
}
Output:
hello
Example (WithMkdirMode)
package main

import (
	"context"
	"fmt"
	"os"
	"path/filepath"

	"github.com/cplieger/atomicfile"
)

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"
)

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"
)

func main() {
	dir, _ := os.MkdirTemp("", "example")
	defer os.RemoveAll(dir)
	path := filepath.Join(dir, "cache.txt")
	_ = atomicfile.WriteFile(context.Background(), path, []byte("fast"),
		atomicfile.WithNoSync())
	data, _ := os.ReadFile(path)
	fmt.Println(string(data))
}
Output:
fast

func WriteReader

func WriteReader(ctx context.Context, path string, r io.Reader, mode os.FileMode, opts ...Option) error

WriteReader atomically writes the contents of r to path with the given mode. If r implements io.WriterTo, it is used for efficient copying.

Example
package main

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"github.com/cplieger/atomicfile"
)

func main() {
	dir, _ := os.MkdirTemp("", "example")
	defer os.RemoveAll(dir)
	path := filepath.Join(dir, "stream.txt")
	_ = atomicfile.WriteReader(context.Background(), path,
		strings.NewReader("streamed"), 0o644)
	data, _ := os.ReadFile(path)
	fmt.Println(string(data))
}
Output:
streamed

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 follow the link to write through to its target. If you intend to update the file the symlink points to, resolve it yourself with filepath.EvalSymlinks before calling.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger sets a custom logger. If not provided, slog.Default() is used.

func WithMkdirMode

func WithMkdirMode(mode os.FileMode) Option

WithMkdirMode causes write functions to create parent directories with the specified permission before writing.

func WithMode

func WithMode(mode os.FileMode) Option

WithMode sets the file permission for the written file. Defaults to 0o644 if not set.

func WithNoSync

func WithNoSync() Option

WithNoSync skips fsync on the temp file and parent directory. This provides atomicity without durability.

func WithPreserveMode

func WithPreserveMode() Option

WithPreserveMode stats the target file before writing and applies its existing mode to the new file. Falls back to the explicit mode if the target does not exist.

func WithPreserveOwnership

func WithPreserveOwnership() Option

WithPreserveOwnership stats the target file before writing and chowns the temp file to match the target's uid/gid. Requires CAP_CHOWN or root. No-op if target doesn't exist. Best-effort: the chown is applied after the temp-file fsync, so unlike file content and mode it is not covered by the durability barrier; a crash immediately after a successful write may leave the file with the writing process's ownership.

func WithTempPattern

func WithTempPattern(pattern string) Option

WithTempPattern sets the os.CreateTemp pattern for temp files. Defaults to DefaultTempPrefix if not set.

type PendingFile

type PendingFile struct {
	*os.File
	// contains filtered or unexported fields
}

PendingFile is a pending temporary file, waiting to replace the destination path in a call to CommitFile. It embeds *os.File so callers get full io.Writer/io.ReaderFrom/fmt.Fprintf support.

func NewPendingFile

func NewPendingFile(path string, mode os.FileMode, opts ...Option) (*PendingFile, error)

NewPendingFile creates a temporary file destined to atomically replace the file at path. Write to it, then call CommitFile to finalize or Cleanup to abort.

Example
package main

import (
	"fmt"
	"os"
	"path/filepath"

	"github.com/cplieger/atomicfile"
)

func main() {
	dir, _ := os.MkdirTemp("", "example")
	defer os.RemoveAll(dir)
	path := filepath.Join(dir, "pending.txt")
	pf, _ := atomicfile.NewPendingFile(path, 0o644)
	defer pf.Cleanup()
	pf.Write([]byte("incremental"))
	_ = pf.CommitFile()
	data, _ := os.ReadFile(path)
	fmt.Println(string(data))
}
Output:
incremental

func (*PendingFile) Cleanup

func (p *PendingFile) Cleanup() error

Cleanup closes and removes the temp file. It is a no-op if CommitFile has already been called. Safe to defer immediately after NewPendingFile.

func (*PendingFile) CommitFile

func (p *PendingFile) CommitFile() error

CommitFile syncs, closes, and atomically renames the temp file to the destination path. After CommitFile, Cleanup is a no-op.

type WriteError

type WriteError struct {
	Err   error
	Phase WritePhase
}

WriteError wraps an atomic-write failure with the phase that failed.

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.

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 final path.
	PhaseRename
	// PhaseChown indicates failure changing ownership of the temp file.
	PhaseChown
	// PhaseDirSync indicates failure fsyncing the parent directory after a
	// successful rename. The new content is already at the final path, but
	// durability across a crash is not guaranteed until the directory entry
	// reaches stable storage.
	PhaseDirSync
)

func (WritePhase) String

func (p WritePhase) String() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL