Files
audita/internal/core/diagnostics/output_session.go

433 lines
12 KiB
Go

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