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() }