package diagnostics import ( "errors" "fmt" "io" "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 Stdout io.Writer } 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 temp *os.File tempPath string tempDir string publishPath string direct bool stdout io.Writer } // SelectOutput checks active destinations before any output resources are opened. func SelectOutput(selection OutputSelection) (*OutputSession, error) { session := &OutputSession{enabled: selection.ReportingEnabled, replace: atomicReplace, stdout: selection.Stdout} 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) } } } return session, nil } // PrepareOutput validates destinations and acquires the transcript resource. func PrepareOutput(selection OutputSelection) (*OutputSession, error) { session, err := SelectOutput(selection) if err != nil { return nil, err } if err := session.Prepare(); err != nil { return nil, err } return session, nil } // Prepare opens a sibling temporary file for regular transcript output. func (s *OutputSession) Prepare() error { if s == nil { return fmt.Errorf("output session is required") } if s.transcript == "" || s.temp != nil || s.direct { return nil } info, err := os.Stat(s.transcript) if err == nil && info.IsDir() { return fmt.Errorf("transcript output %q is a directory", s.transcript) } if err == nil && !info.Mode().IsRegular() { s.direct = true return nil } if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("transcript output %q: %w", s.transcript, err) } s.publishPath, err = canonicalDestination(s.transcript) if err != nil { return err } s.tempDir, err = os.MkdirTemp(filepath.Dir(s.publishPath), ".audita-output-*") if err != nil { return fmt.Errorf("prepare transcript output %q: %w", s.transcript, err) } s.tempPath = filepath.Join(s.tempDir, "payload") s.temp, err = os.OpenFile(s.tempPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) if err != nil { _ = s.Close() return fmt.Errorf("prepare transcript output %q: %w", s.transcript, err) } if info != nil { if err := s.temp.Chmod(info.Mode().Perm()); err != nil { _ = s.Close() return err } } return nil } // Close releases unpublished output resources; caller-owned streams remain open. func (s *OutputSession) Close() error { if s == nil { return nil } var errs []error if s.temp != nil { errs = append(errs, s.temp.Close()) s.temp = nil } if s.tempPath != "" { if err := os.Remove(s.tempPath); err != nil && !errors.Is(err, os.ErrNotExist) { errs = append(errs, err) } s.tempPath = "" } if s.tempDir != "" { if err := os.Remove(s.tempDir); err != nil && !errors.Is(err, os.ErrNotExist) { errs = append(errs, err) } s.tempDir = "" } return errors.Join(errs...) } 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 publishes each initial report and attempts failed-status reports if any fail. func (s *OutputSession) Initialize(initial ReportBundle, failed func([]FileFailure) ReportBundle) error { if s == nil || !s.enabled { return nil } var failures []FileFailure for i, payload := range bundleParts(initial) { if err := s.replace(s.reports[i], payload); err != nil { failures = append(failures, FileFailure{Path: s.reports[i], Kind: "report initialization", Err: err}) } } if len(failures) == 0 { return nil } if err := s.Close(); err != nil { failures = append(failures, FileFailure{Path: s.transcript, Kind: "output cleanup", Err: err}) } if failed != nil { for i, payload := range bundleParts(failed(failures)) { if err := s.replace(s.reports[i], payload); err != nil { failures = append(failures, FileFailure{Path: s.reports[i], Kind: "failed report initialization", Err: err}) } } } errs := make([]error, 0, len(failures)) for _, failure := range failures { errs = append(errs, failure) } return errors.Join(errs...) } // PrepareDebug creates optional initial diagnostics and returns any nonfatal problems. func (s *OutputSession) PrepareDebug(workDir, runID string, metadata InvocationMetadata, effective []byte, secrets ...string) (*RunDirectory, []error) { run, err := NewRunDirectory(workDir, runID, secrets...) if err != nil { return nil, []error{err} } var problems []error if err := run.WriteInvocationMetadata(metadata); err != nil { problems = append(problems, err) } if effective != nil { if err := run.WriteEffectiveConfig(effective); err != nil { problems = append(problems, err) } } return run, problems } // 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 { if err := s.writeTranscript(transcript); err != nil { failures = append(failures, FileFailure{Path: s.transcript, Kind: "transcript write", Err: err}) } } if err := s.Close(); err != nil { failures = append(failures, FileFailure{Path: s.transcript, Kind: "output cleanup", 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 && failed != nil { for i, payload := range bundleParts(failed(failures)) { if err := s.replace(s.reports[i], payload); err != nil { failures = append(failures, FileFailure{Path: s.reports[i], Kind: "failed report write", Err: err}) } } } } return failures } // Stream destinations are opened only when delivering the transcript. // Finish retains an earlier processing error while reporting delivery failures. func (s *OutputSession) Finish(primary error, transcript []byte, reports ReportBundle, failed func([]FileFailure) ReportBundle) error { if primary != nil { transcript = nil } failures := s.Finalize(transcript, reports, failed) errs := make([]error, 0, len(failures)+1) errs = append(errs, primary) for _, failure := range failures { errs = append(errs, failure) } return errors.Join(errs...) } func (s *OutputSession) writeTranscript(payload []byte) error { if s.transcript == "" { if s.stdout == nil { return fmt.Errorf("transcript stdout writer is required") } n, err := s.stdout.Write(payload) if err == nil && n != len(payload) { return io.ErrShortWrite } return err } if s.direct { file, err := os.OpenFile(s.transcript, os.O_WRONLY, 0) if err != nil { return err } n, writeErr := file.Write(payload) if writeErr == nil && n != len(payload) { writeErr = io.ErrShortWrite } return errors.Join(writeErr, file.Close()) } if s.temp == nil { return fmt.Errorf("transcript output is not prepared") } n, writeErr := s.temp.Write(payload) if writeErr == nil && n != len(payload) { writeErr = io.ErrShortWrite } closeErr := s.temp.Close() s.temp = nil if err := errors.Join(writeErr, closeErr); err != nil { return err } if err := os.Rename(s.tempPath, s.publishPath); err != nil { return err } s.tempPath = "" return nil } func bundleParts(bundle ReportBundle) [3][]byte { return [3][]byte{bundle.Errors, bundle.Recoveries, bundle.Markdown} } 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) }