Files
2026-09-27 16:04:02 -06:00

166 lines
4.6 KiB
Go

// Package fsutil contiene operaciones de archivo seguras compartidas: escritura
// atómica y copias de seguridad.
package fsutil
import (
"fmt"
"io"
"os"
"path/filepath"
"syscall"
"time"
)
// BackupFile copia path a "path.sysagent.YYYYMMDD-HHMMSS.bak" en el mismo
// directorio, conservando permisos y, si es posible, propietario (§62).
//
// Si ya existe un backup con ese nombre (dos ediciones en el mismo segundo)
// se añade un sufijo numérico en lugar de sobrescribirlo.
// Devuelve la ruta del backup.
func BackupFile(path string, now time.Time) (string, error) {
src, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("failed to open %s for backup: %w", path, err)
}
defer src.Close()
st, err := src.Stat()
if err != nil {
return "", fmt.Errorf("failed to stat %s: %w", path, err)
}
if !st.Mode().IsRegular() {
return "", fmt.Errorf("cannot back up %s: not a regular file", path)
}
base := fmt.Sprintf("%s.sysagent.%s.bak", path, now.Format("20060102-150405"))
dst := base
var out *os.File
for i := 1; ; i++ {
out, err = os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, st.Mode().Perm())
if err == nil {
break
}
if !os.IsExist(err) || i > 100 {
return "", fmt.Errorf("failed to create backup %s: %w", dst, err)
}
dst = fmt.Sprintf("%s.%d", base, i)
}
if _, err := io.Copy(out, src); err != nil {
out.Close()
os.Remove(dst)
return "", fmt.Errorf("failed to write backup %s: %w", dst, err)
}
if err := out.Sync(); err != nil {
out.Close()
return "", fmt.Errorf("failed to sync backup %s: %w", dst, err)
}
out.Close()
copyOwner(dst, st)
return dst, nil
}
// WriteAtomic reemplaza path con data de forma atómica (§30):
//
// 1. crea un archivo temporal en el mismo directorio (mismo filesystem);
// 2. escribe todo el contenido y hace fsync;
// 3. aplica permisos/propietario (los del original si existía);
// 4. ejecuta validate(tmp) si se indicó: si falla, el original queda intacto;
// 5. renombra sobre el original y hace fsync del directorio.
//
// Si path es un symlink, se escribe en su destino (como hacen los editores),
// para no sustituir el enlace por un archivo normal.
func WriteAtomic(path string, data []byte, mode os.FileMode, validate func(tmp string) error) error {
if real, err := filepath.EvalSymlinks(path); err == nil {
path = real
}
dir := filepath.Dir(path)
var orig os.FileInfo
if st, err := os.Stat(path); err == nil {
if st.IsDir() {
return fmt.Errorf("%s is a directory", path)
}
orig = st
if mode == 0 {
mode = st.Mode().Perm()
}
}
if mode == 0 {
mode = 0o644
}
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".sysagent-tmp-*")
if err != nil {
return fmt.Errorf("failed to create temporary file in %s: %w", dir, err)
}
tmpName := tmp.Name()
cleanup := func() { os.Remove(tmpName) }
if _, err := tmp.Write(data); err != nil {
tmp.Close()
cleanup()
return fmt.Errorf("failed to write %s: %w", tmpName, err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
cleanup()
return fmt.Errorf("failed to sync %s: %w", tmpName, err)
}
if err := tmp.Close(); err != nil {
cleanup()
return fmt.Errorf("failed to close %s: %w", tmpName, err)
}
if err := os.Chmod(tmpName, mode); err != nil {
cleanup()
return fmt.Errorf("failed to chmod %s: %w", tmpName, err)
}
if orig != nil {
copyOwner(tmpName, orig)
}
if validate != nil {
if err := validate(tmpName); err != nil {
cleanup()
return fmt.Errorf("validation failed, %s left unchanged: %w", path, err)
}
}
if err := os.Rename(tmpName, path); err != nil {
cleanup()
return fmt.Errorf("failed to rename %s to %s: %w", tmpName, path, err)
}
_ = SyncDir(dir)
return nil
}
// copyOwner intenta replicar uid/gid; solo funciona como root y se ignora
// el error en otro caso (el archivo queda a nombre del usuario actual).
func copyOwner(path string, st os.FileInfo) {
if sys, ok := st.Sys().(*syscall.Stat_t); ok {
_ = os.Lchown(path, int(sys.Uid), int(sys.Gid))
}
}
// SyncDir hace persistente en disco un rename dentro de dir.
func SyncDir(dir string) error {
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
// CopyFile copia src a dst de forma atómica conservando permisos.
func CopyFile(src, dst string) error {
st, err := os.Stat(src)
if err != nil {
return fmt.Errorf("failed to stat %s: %w", src, err)
}
if !st.Mode().IsRegular() {
return fmt.Errorf("%s is not a regular file (use exec cp -a for directories)", src)
}
data, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("failed to read %s: %w", src, err)
}
return WriteAtomic(dst, data, st.Mode().Perm(), nil)
}