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