314 lines
9.0 KiB
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)
|
|
}
|