go-firewall/configfile.go
2026-07-08 15:54:48 -05:00

118 lines
3.4 KiB
Go

package firewall
import (
"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.
type atomicFile struct {
*os.File
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.
func newAtomicFile(dst string, defaultMode os.FileMode) (*atomicFile, error) {
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,
dst: dst,
tmp: fd.Name(),
mode: mode,
uid: uid,
gid: gid,
chown: chown,
}, nil
}
// 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
}
// Apply mode and ownership through the fd so the staged file is never
// briefly installed with the wrong permissions.
if err := a.File.Chmod(a.mode); err != nil {
return a.fail(err)
}
if a.chown {
if err := a.File.Chown(a.uid, a.gid); err != nil && !errors.Is(err, os.ErrPermission) {
return a.fail(err)
}
}
if err := a.File.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.File.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.File.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()
}