go-firewall/atomicfile.go
2026-08-10 17:17:03 -05:00

148 lines
4.6 KiB
Go

package firewall
import (
"bufio"
"errors"
"os"
"path/filepath"
)
// atomicFile stages an atomic replacement of a config file. It creates a temp
// file in the destination's directory and captures the destination's ownership
// and permission mode up front; Commit restores that metadata onto the temp
// file before renaming it into place. A destination that does not yet exist
// falls back to defaultMode with the temp file's default ownership.
//
// The struct embeds *os.File so callers stream into it with the usual Write and
// fmt.Fprintln calls, whether they buffer the whole file or scan the original
// and rewrite line by line. Writes pass through an internal bufio.Writer that
// Commit flushes, so a line-by-line rewrite of a large save file does not issue
// a syscall per line; the embedded fd remains reachable for the metadata calls
// Commit makes.
type atomicFile struct {
*os.File
w *bufio.Writer
dst string
tmp string
mode os.FileMode
uid, gid int
chown bool // True only when the destination already existed.
done bool
}
// newAtomicFile opens a temp file next to dst for streaming. The caller writes
// to the returned handle, then calls Commit to install it or Abort to discard
// it. Capturing the destination's mode and ownership here means a later
// scan-and-rewrite of the original still commits with the original's metadata.
// A symlinked destination is resolved so the rename replaces the link's target
// rather than turning the link itself into a regular file.
func newAtomicFile(dst string, defaultMode os.FileMode) (*atomicFile, error) {
if resolved, err := filepath.EvalSymlinks(dst); err == nil {
dst = resolved
}
mode := defaultMode
var uid, gid int
var chown bool
if fi, err := os.Stat(dst); err == nil {
mode = fi.Mode().Perm()
uid, gid, chown = statOwner(fi)
}
fd, err := os.CreateTemp(filepath.Dir(dst), filepath.Base(dst)+".tmp.*")
if err != nil {
return nil, err
}
return &atomicFile{
File: fd,
w: bufio.NewWriter(fd),
dst: dst,
tmp: fd.Name(),
mode: mode,
uid: uid,
gid: gid,
chown: chown,
}, nil
}
// Write buffers p into the staged file. It shadows the embedded fd's own Write
// so every caller — the line-by-line rewrites and the whole-buffer writers
// alike — shares one bufio.Writer; Commit flushes it before installing the
// file, and Abort discards it with the temp file.
func (a *atomicFile) Write(p []byte) (int, error) {
return a.w.Write(p)
}
// Commit flushes, applies the captured mode and ownership to the temp file, and
// renames it over the destination. Ownership is best-effort: a caller that can
// write the file but cannot chown it keeps the temp file's owner and still gets
// the original mode.
func (a *atomicFile) Commit() error {
if a.done {
return nil
}
// Flush the buffered writes down to the fd before any of the metadata and
// durability steps below act on it.
if err := a.w.Flush(); err != nil {
return a.fail(err)
}
// Apply mode and ownership through the fd so the staged file is never
// briefly installed with the wrong permissions.
if err := a.Chmod(a.mode); err != nil {
return a.fail(err)
}
if a.chown {
if err := a.Chown(a.uid, a.gid); err != nil && !errors.Is(err, os.ErrPermission) {
return a.fail(err)
}
}
// Sync before the rename: these files are reboot-persistence-critical
// (csf.conf, iptables save files), and a crash could otherwise reorder the
// rename ahead of the data reaching disk, installing a truncated config.
if err := a.Sync(); err != nil {
return a.fail(err)
}
if err := a.Close(); err != nil {
return a.fail(err)
}
// Install the staged file.
if err := os.Rename(a.tmp, a.dst); err != nil {
_ = os.Remove(a.tmp)
a.done = true
return err
}
a.done = true
return nil
}
// Abort discards the temp file. It is a no-op after a successful Commit, so a
// caller may defer Abort immediately after opening.
func (a *atomicFile) Abort() {
if a.done {
return
}
_ = a.Close()
_ = os.Remove(a.tmp)
a.done = true
}
// fail closes and removes the temp file, then returns the triggering error.
func (a *atomicFile) fail(err error) error {
_ = a.Close()
_ = os.Remove(a.tmp)
a.done = true
return err
}
// writeConfigFile atomically replaces path with data for callers that already
// hold the whole file in memory, preserving the existing file's mode and
// ownership (falling back to defaultMode for a file that does not yet exist).
func writeConfigFile(path string, data []byte, defaultMode os.FileMode) error {
af, err := newAtomicFile(path, defaultMode)
if err != nil {
return err
}
defer af.Abort()
if _, err := af.Write(data); err != nil {
return err
}
return af.Commit()
}