// Package fileutil provides narrow filesystem helpers for operator-owned outputs. package fileutil import ( "context" "encoding/json" "fmt" "os" "path/filepath" ) const ( maxFileNameBytes = 255 temporaryFilePattern = ".weatherreporter-*.tmp" ) // ValidateAtomicPath verifies that the final path can be safely used with this // package's same-directory atomic-write implementation. It accepts only an // absent path or an existing regular file. func ValidateAtomicPath(path string) error { if len(filepath.Base(path)) > maxFileNameBytes { return fmt.Errorf("final file name exceeds the %d-byte limit", maxFileNameBytes) } info, err := os.Lstat(path) if os.IsNotExist(err) { return nil } if err != nil { return fmt.Errorf("inspect final destination %q: %w", path, err) } if !info.Mode().IsRegular() { return fmt.Errorf("final destination %q must be absent or a regular file", path) } return nil } func WriteFileAtomic(path string, data []byte) error { return WriteFileAtomicContext(context.Background(), path, data) } // WriteFileAtomicContext writes data through a same-directory temporary file. // It checks cancellation immediately before the rename that publishes the file. func WriteFileAtomicContext(ctx context.Context, path string, data []byte) error { if ctx == nil { ctx = context.Background() } if err := ValidateAtomicPath(path); err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("create directory %q: %w", filepath.Dir(path), err) } tmp, err := os.CreateTemp(filepath.Dir(path), temporaryFilePattern) if err != nil { return fmt.Errorf("create temporary file for %q: %w", path, err) } tmpName := tmp.Name() defer os.Remove(tmpName) if _, err := tmp.Write(data); err != nil { tmp.Close() return fmt.Errorf("write temporary file for %q: %w", path, err) } if err := tmp.Close(); err != nil { return fmt.Errorf("close temporary file for %q: %w", path, err) } if err := ValidateAtomicPath(path); err != nil { return err } if err := ctx.Err(); err != nil { return err } if err := os.Rename(tmpName, path); err != nil { return fmt.Errorf("save %q: %w", path, err) } return nil } func WriteJSONAtomic(path string, value any) error { data, err := json.MarshalIndent(value, "", " ") if err != nil { return fmt.Errorf("marshal %q: %w", path, err) } return WriteFileAtomic(path, data) }