package diagnostics import ( "errors" "fmt" "os" "path/filepath" "strings" ) const ( ErrorsFile = "errors.yml" RecoveriesFile = "recoveries.yml" MarkdownFile = "report.md" ) // OutputSelection contains only active destinations. ProtectedPaths are inputs // and loaded configuration/profile sources that must never be overwritten. // InputTranscriptPath may be replaced by final transcript publication, but not reports. type OutputSelection struct { InputTranscriptPath string TranscriptPath string ReportDir string ReportingEnabled bool ProtectedPaths []string } type ReportBundle struct { Errors []byte Recoveries []byte Markdown []byte } type FileFailure struct { Path string Kind string Err error } func (f FileFailure) Error() string { return fmt.Sprintf("%s %q: %v", f.Kind, f.Path, f.Err) } func (f FileFailure) Unwrap() error { return f.Err } // OutputSession owns checked destinations and per-file publication. Filesystem // failures remain typed so CLI can present safe, redacted diagnostics. type OutputSession struct { transcript string reports [3]string enabled bool replace func(string, []byte) error } func PrepareOutput(selection OutputSelection) (*OutputSession, error) { session := &OutputSession{enabled: selection.ReportingEnabled, replace: atomicReplace} active := make([]string, 0, 4) if selection.TranscriptPath != "" { session.transcript = selection.TranscriptPath active = append(active, session.transcript) } if session.enabled { if strings.TrimSpace(selection.ReportDir) == "" { return nil, fmt.Errorf("report directory is required") } info, err := os.Stat(selection.ReportDir) if err != nil { return nil, fmt.Errorf("report directory: %w", err) } if !info.IsDir() { return nil, fmt.Errorf("report destination is not a directory") } session.reports = [3]string{ filepath.Join(selection.ReportDir, ErrorsFile), filepath.Join(selection.ReportDir, RecoveriesFile), filepath.Join(selection.ReportDir, MarkdownFile), } active = append(active, session.reports[:]...) } protected := make([]string, 0, len(selection.ProtectedPaths)) for _, path := range selection.ProtectedPaths { if path != "" { protected = append(protected, path) } } for i, path := range active { if path != session.transcript { if info, err := os.Stat(path); err == nil { if !info.Mode().IsRegular() { return nil, fmt.Errorf("report output %q is not a regular file", path) } } else if !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("report output %q: %w", path, err) } } sources := protected if path != session.transcript && selection.InputTranscriptPath != "" { sources = append(append([]string(nil), protected...), selection.InputTranscriptPath) } for _, source := range sources { collision, err := sameDestination(path, source) if err != nil { return nil, err } if collision { return nil, fmt.Errorf("output destination %q conflicts with input %q", path, source) } } for _, prior := range active[:i] { collision, err := sameDestination(path, prior) if err != nil { return nil, err } if collision { return nil, fmt.Errorf("output destinations %q and %q conflict", path, prior) } } } if session.transcript != "" { if err := preflightTranscript(session.transcript); err != nil { return nil, err } } return session, nil } func (s *OutputSession) ReportPaths() []string { if s == nil || !s.enabled { return nil } return []string{s.reports[0], s.reports[1], s.reports[2]} } func (s *OutputSession) TranscriptPath() string { if s == nil { return "" } return s.transcript } // Initialize writes all report destinations before input/model processing. On // failure it makes one best-effort pass to mark already-created reports failed. func (s *OutputSession) Initialize(initial, failed ReportBundle) error { if s == nil || !s.enabled { return nil } created := make([]int, 0, len(s.reports)) for i, payload := range bundleParts(initial) { if err := s.replace(s.reports[i], payload); err != nil { for _, prior := range created { _ = s.replace(s.reports[prior], bundleParts(failed)[prior]) } return FileFailure{Path: s.reports[i], Kind: "report initialization", Err: err} } created = append(created, i) } return nil } // Finalize publishes the transcript first, then errors, recoveries, and Markdown. // An empty transcript payload means processing failed and no transcript is written. // Every enabled report is attempted even after an earlier write failure. func (s *OutputSession) Finalize(transcript []byte, reports ReportBundle, failed func([]FileFailure) ReportBundle) []FileFailure { if s == nil { return []FileFailure{{Kind: "output finalization", Err: fmt.Errorf("output session is required")}} } failures := make([]FileFailure, 0) if transcript != nil && s.transcript != "" { if err := s.writeTranscript(transcript); err != nil { failures = append(failures, FileFailure{Path: s.transcript, Kind: "transcript write", Err: err}) } } if s.enabled { for i, payload := range bundleParts(reports) { if err := s.replace(s.reports[i], payload); err != nil { failures = append(failures, FileFailure{Path: s.reports[i], Kind: "report write", Err: err}) } } if len(failures) > 0 { for i, payload := range bundleParts(failed(failures)) { _ = s.replace(s.reports[i], payload) } } } return failures } // Stream destinations use ordinary writes; replacing their directory entry would // destroy the device or pipe. Regular files retain per-file replacement. func (s *OutputSession) writeTranscript(payload []byte) error { info, err := os.Stat(s.transcript) if err != nil && !errors.Is(err, os.ErrNotExist) { return err } if err == nil && !info.Mode().IsRegular() { file, err := os.OpenFile(s.transcript, os.O_WRONLY, 0) if err != nil { return err } _, writeErr := file.Write(payload) return errors.Join(writeErr, file.Close()) } return s.replace(s.transcript, payload) } func bundleParts(bundle ReportBundle) [3][]byte { return [3][]byte{bundle.Errors, bundle.Recoveries, bundle.Markdown} } func preflightTranscript(path string) error { info, err := os.Stat(path) if err == nil { if info.IsDir() { return fmt.Errorf("transcript output %q is a directory", path) } if !info.Mode().IsRegular() { // Opening a pipe here can block before its reader is ready. return nil } file, openErr := os.OpenFile(path, os.O_WRONLY, 0) if openErr != nil { return fmt.Errorf("transcript output %q cannot be opened: %w", path, openErr) } if closeErr := file.Close(); closeErr != nil { return fmt.Errorf("transcript output %q cannot be closed: %w", path, closeErr) } } else if !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("transcript output %q: %w", path, err) } parent := filepath.Dir(path) probe, err := os.CreateTemp(parent, ".audita-probe-*") if err != nil { return fmt.Errorf("transcript output parent %q: %w", parent, err) } name := probe.Name() closeErr := probe.Close() removeErr := os.Remove(name) if closeErr != nil { return fmt.Errorf("transcript output probe close: %w", closeErr) } if removeErr != nil { return fmt.Errorf("transcript output probe removal: %w", removeErr) } return nil } func sameDestination(a, b string) (bool, error) { ca, err := canonicalDestination(a) if err != nil { return false, err } cb, err := canonicalDestination(b) if err != nil { return false, err } if ca == cb { return true, nil } ai, ae := os.Stat(a) bi, be := os.Stat(b) return ae == nil && be == nil && os.SameFile(ai, bi), nil } func canonicalDestination(path string) (string, error) { abs, err := filepath.Abs(path) if err != nil { return "", err } abs = filepath.Clean(abs) if resolved, err := filepath.EvalSymlinks(abs); err == nil { return resolved, nil } parent, err := filepath.EvalSymlinks(filepath.Dir(abs)) if err == nil { return filepath.Join(parent, filepath.Base(abs)), nil } return abs, nil } func atomicReplace(path string, payload []byte) error { publishPath, err := canonicalDestination(path) if err != nil { return err } // A private temporary directory lets ordinary file creation apply the umask // without reading or changing process-global state. Rename stays on the same // filesystem, and the incomplete payload is not accessible through the parent. dir, err := os.MkdirTemp(filepath.Dir(publishPath), ".audita-write-*") if err != nil { return err } defer os.Remove(dir) name := filepath.Join(dir, "payload") temp, err := os.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) if err != nil { return err } defer os.Remove(name) if info, err := os.Stat(publishPath); err == nil { if err := temp.Chmod(info.Mode().Perm()); err != nil { _ = temp.Close() return err } } else if !errors.Is(err, os.ErrNotExist) { _ = temp.Close() return err } if _, err := temp.Write(payload); err != nil { _ = temp.Close() return err } if err := temp.Close(); err != nil { return err } return os.Rename(name, publishPath) }