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

314 lines
9.0 KiB
Go

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