diff --git a/docs/operations.md b/docs/operations.md index 7081437..522b6a1 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -27,8 +27,10 @@ consumed application and PromptKit sources, and effective host message files. An intentional in-place transcript replacement is allowed. A regular transcript output is prepared in a sibling temporary file, leaving the final destination untouched until successful publication. Pipes and devices are opened only for -delivery; a pipe can wait for its reader. Parent directories for required outputs -must already exist. +delivery, and a pipe can wait for its reader. If a transcript destination changes +between a regular file and a pipe or device during processing, Audita can report a +delivery failure while preserving the new destination. Parent directories for +required outputs must already exist. Enabled reports are initialized as `in_progress` before processing. The transcript is published before final reports; the files do not form a single diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index d730639..9541728 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -384,6 +384,8 @@ keep their native behavior. ## R11 — P2: Preserve Pipes That Appear After Output Preparation +**Status:** Completed. Delivery checks the destination type before opening a prepared stream or replacing a file. Tests cover both type changes, preservation of the replacement destination, and cleanup of staged output. + **Category** The new Stage 7 preparation changes the existing special-file delivery behavior. diff --git a/internal/core/diagnostics/output_session.go b/internal/core/diagnostics/output_session.go index 6b75e16..2ffb269 100644 --- a/internal/core/diagnostics/output_session.go +++ b/internal/core/diagnostics/output_session.go @@ -327,6 +327,13 @@ func (s *OutputSession) writeTranscript(payload []byte) error { return err } if s.direct { + info, err := os.Stat(s.transcript) + if err != nil { + return err + } + if info.IsDir() || info.Mode().IsRegular() { + return fmt.Errorf("transcript output is no longer a stream destination") + } file, err := os.OpenFile(s.transcript, os.O_WRONLY, 0) if err != nil { return err @@ -349,6 +356,13 @@ func (s *OutputSession) writeTranscript(payload []byte) error { if err := errors.Join(writeErr, closeErr); err != nil { return err } + info, err := os.Stat(s.publishPath) + if err == nil && !info.Mode().IsRegular() { + return fmt.Errorf("transcript output changed type after preparation") + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } if err := os.Rename(s.tempPath, s.publishPath); err != nil { return err } diff --git a/internal/core/diagnostics/output_session_test.go b/internal/core/diagnostics/output_session_test.go index 5a44b7d..a3d69e4 100644 --- a/internal/core/diagnostics/output_session_test.go +++ b/internal/core/diagnostics/output_session_test.go @@ -418,14 +418,8 @@ func TestTranscriptWriteFailureCleansTemporaryResource(t *testing.T) { } func TestNamedPipeIsNotOpenedDuringPreparation(t *testing.T) { - tool, err := exec.LookPath("mkfifo") - if err != nil { - t.Skip("mkfifo is unavailable") - } pipe := filepath.Join(t.TempDir(), "output-pipe") - if output, err := exec.Command(tool, pipe).CombinedOutput(); err != nil { - t.Fatalf("mkfifo: %v %s", err, output) - } + makeNamedPipe(t, pipe) session, err := PrepareOutput(OutputSelection{TranscriptPath: pipe}) if err != nil { t.Fatal(err) @@ -437,3 +431,60 @@ func TestNamedPipeIsNotOpenedDuringPreparation(t *testing.T) { t.Fatal(err) } } + +func TestLateNamedPipeIsPreserved(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "output") + session, err := PrepareOutput(OutputSelection{TranscriptPath: path}) + if err != nil { + t.Fatal(err) + } + defer session.Close() + makeNamedPipe(t, path) + err = session.Finish(nil, []byte("transcript"), ReportBundle{}, nil) + if err == nil || !strings.Contains(err.Error(), "changed type") { + t.Fatalf("expected clear delivery failure, got %v", err) + } + info, err := os.Stat(path) + if err != nil || info.Mode()&os.ModeNamedPipe == 0 { + t.Fatalf("late pipe was lost: %v %v", info, err) + } + entries, err := os.ReadDir(dir) + if err != nil || len(entries) != 1 { + t.Fatalf("unpublished output resources remain: %v %v", entries, err) + } +} + +func TestStreamChangedToRegularFileIsPreserved(t *testing.T) { + path := filepath.Join(t.TempDir(), "output") + makeNamedPipe(t, path) + session, err := PrepareOutput(OutputSelection{TranscriptPath: path}) + if err != nil { + t.Fatal(err) + } + defer session.Close() + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("existing content"), 0600); err != nil { + t.Fatal(err) + } + err = session.Finish(nil, []byte("new"), ReportBundle{}, nil) + if err == nil || !strings.Contains(err.Error(), "no longer a stream") { + t.Fatalf("expected clear delivery failure, got %v", err) + } + if data, err := os.ReadFile(path); err != nil || string(data) != "existing content" { + t.Fatalf("replacement file changed: %q %v", data, err) + } +} + +func makeNamedPipe(t *testing.T, path string) { + t.Helper() + tool, err := exec.LookPath("mkfifo") + if err != nil { + t.Skip("mkfifo is unavailable") + } + if output, err := exec.Command(tool, path).CombinedOutput(); err != nil { + t.Fatalf("mkfifo: %v %s", err, output) + } +}