package export import ( "encoding/json" "fmt" "log" "os" "path/filepath" "strings" "tower/internal/model" ) // WriteIssues writes a JSON snapshot of issues to path. // // It attempts to be atomic by writing to a temporary file in the same directory // and then renaming it into place. func WriteIssues(path string, issues []model.Issue) error { if path == "" { return fmt.Errorf("export: path is empty") } cleanPath := filepath.Clean(path) if strings.Contains(cleanPath, ".."+string(filepath.Separator)) { return fmt.Errorf("export: path traversal not allowed: %s", path) } if filepath.IsAbs(cleanPath) { return fmt.Errorf("export: absolute paths not allowed: %s", path) } // Ensure we always write a JSON array, even if caller passes a nil slice. if issues == nil { issues = []model.Issue{} } dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("export: create dir %q: %w", dir, err) } base := filepath.Base(path) tmp, err := os.CreateTemp(dir, base+".*.tmp") if err != nil { return fmt.Errorf("export: create temp file: %w", err) } // Make the resulting snapshot readable by default. if err := tmp.Chmod(0o644); err != nil { log.Printf("export: warning: failed to chmod temp file %q: %v", tmp.Name(), err) } tmpName := tmp.Name() cleanup := func() { if err := tmp.Close(); err != nil { log.Printf("export: warning: failed to close temp file %q: %v", tmpName, err) } if err := os.Remove(tmpName); err != nil && !os.IsNotExist(err) { log.Printf("export: warning: failed to remove temp file %q: %v", tmpName, err) } } enc := json.NewEncoder(tmp) enc.SetIndent("", " ") // This is a snapshot file for humans; keep it readable. enc.SetEscapeHTML(false) if err := enc.Encode(issues); err != nil { cleanup() return fmt.Errorf("export: encode json: %w", err) } // Best effort durability before rename. if err := tmp.Sync(); err != nil { cleanup() return fmt.Errorf("export: sync temp file: %w", err) } if err := tmp.Close(); err != nil { cleanup() return fmt.Errorf("export: close temp file: %w", err) } // On POSIX, rename is atomic when source and destination are on the same FS. if err := os.Rename(tmpName, path); err != nil { // Best-effort fallback for platforms where rename fails if destination exists. if rmErr := os.Remove(path); rmErr == nil { if err2 := os.Rename(tmpName, path); err2 == nil { return nil } } cleanup() return fmt.Errorf("export: rename into place: %w", err) } return nil }