424 lines
12 KiB
Go
424 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, failed ReportBundle) error {
|
|
if s == nil || !s.enabled {
|
|
return nil
|
|
}
|
|
var failures []error
|
|
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 {
|
|
for i, payload := range bundleParts(failed) {
|
|
if err := s.replace(s.reports[i], payload); err != nil {
|
|
failures = append(failures, FileFailure{Path: s.reports[i], Kind: "failed report initialization", Err: err})
|
|
}
|
|
}
|
|
failures = append(failures, s.Close())
|
|
}
|
|
return errors.Join(failures...)
|
|
}
|
|
|
|
// 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)
|
|
}
|