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 ¶
- Constants
- Variables
- func CleanupStaleTemps(dir string, maxAge time.Duration, opts ...Option)
- func Commit(tmpPath, finalPath string, opts ...Option) error
- func LoadJSON(ctx context.Context, path string, maxBytes int64, v any) error
- func Prepare(ctx context.Context, path string, data []byte, opts ...Option) (tmpPath string, cleanup func(), err error)
- func ReadBounded(ctx context.Context, path string, maxBytes int64) ([]byte, error)
- func SaveBytes(path string, data []byte, perm os.FileMode, opts ...Option) error
- func SaveJSON(path string, mu *sync.Mutex, v any, label string, perm os.FileMode, ...) error
- func WriteFile(ctx context.Context, path string, data []byte, opts ...Option) error
- func WriteReader(ctx context.Context, path string, r io.Reader, mode os.FileMode, ...) error
- type Option
- type PendingFile
- type WriteError
- type WritePhase
Examples ¶
Constants ¶
const DefaultTempPrefix = ".atomicfile-*.tmp"
DefaultTempPrefix is the default pattern used for temp files.
Variables ¶
var ErrEmptyPath = errors.New("atomic write: empty path")
ErrEmptyPath is returned when a path argument is empty.
var ErrFileTooLarge = errors.New("file too large")
ErrFileTooLarge is returned when a file exceeds the size limit.
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.
var ErrUnsafePath = errors.New("atomic write: unsafe path")
ErrUnsafePath is returned when a path fails the local safety check.
Functions ¶
func CleanupStaleTemps ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
WithLogger sets a custom logger. If not provided, slog.Default() is used.
func WithMkdirMode ¶
WithMkdirMode causes write functions to create parent directories with the specified permission before writing.
func WithMode ¶
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 ¶
WithTempPattern sets the os.CreateTemp pattern for temp files. Defaults to DefaultTempPrefix if not set.
type PendingFile ¶
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 ¶
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