Close out the repository audit

This commit is contained in:
2026-08-13 13:52:16 +00:00
parent 13b06039b1
commit fc8ddada9a
29 changed files with 421 additions and 263 deletions

View File

@@ -359,7 +359,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
var cancellation error
for index, planned := range plannedReports {
if cancellation = batchCancellationCause(ctx, nil); cancellation != nil {
if cancellation = batchContextCancellationCause(ctx); cancellation != nil {
appendCanceledBatchReports(result, plannedReports[index:])
break
}
@@ -382,7 +382,8 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
copyBatchReportDetails(&item, reportResult)
}
if err != nil {
if cancellation = batchCancellationCause(ctx, err); cancellation != nil {
if reportCancellation := batchReportCancellationCause(err); reportCancellation != nil {
cancellation = reportCancellation
item.Status = "canceled"
result.Canceled++
} else {
@@ -396,7 +397,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
}
result.Reports = append(result.Reports, item)
if cancellation == nil {
cancellation = batchCancellationCause(ctx, nil)
cancellation = batchContextCancellationCause(ctx)
}
if cancellation != nil {
appendCanceledBatchReports(result, plannedReports[index+1:])
@@ -404,7 +405,11 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
}
}
result.Total = len(result.Reports)
batchNotification := notifyBatch(ctx, req.Config, req.Batch, batchRunID(startedAt, req.Batch), startedAt, result, plannedReports, req.Notifier)
batchNotification := notifyBatch(batchNotificationInput{
ctx: ctx, cancellation: cancellation, cfg: req.Config, batch: req.Batch,
runID: batchRunID(startedAt, req.Batch), startedAt: startedAt,
result: result, planned: plannedReports, notifier: req.Notifier,
})
if batchNotification != nil {
result.Notification = batchNotification
}
@@ -414,12 +419,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
return nil, fmt.Errorf("run is not implemented")
}
func batchCancellationCause(ctx context.Context, err error) error {
if ctx != nil {
if contextErr := ctx.Err(); contextErr != nil {
return contextErr
}
}
func batchReportCancellationCause(err error) error {
if errors.Is(err, context.Canceled) {
return context.Canceled
}
@@ -429,6 +429,13 @@ func batchCancellationCause(ctx context.Context, err error) error {
return nil
}
func batchContextCancellationCause(ctx context.Context) error {
if ctx == nil {
return nil
}
return ctx.Err()
}
func appendCanceledBatchReports(result *BatchResult, plannedReports []plannedBatchReport) {
if result == nil {
return

View File

@@ -9,6 +9,7 @@ import (
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
func TestRunBatchDetailedKeepsSuccessfulOutputAndSkipsNotificationAfterPartialFailure(t *testing.T) {
@@ -54,6 +55,48 @@ func TestRunBatchDetailedStopsAfterReportCancellation(t *testing.T) {
}
}
func TestRunBatchDetailedPreservesIndependentFailureDuringCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
notifier := &generationNotifier{}
executor := &generationExecutor{
executeErr: errors.New("independent report failure"),
beforeExecute: func(promptexec.ExecuteRequest) {
cancel()
},
}
result, err := RunBatchDetailed(ctx, BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
})
if !errors.Is(err, context.Canceled) || result == nil || result.Total != 2 || result.Succeeded != 0 || result.Failed != 1 || result.Canceled != 1 || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
}
if result.Reports[0].Status != "failed" || result.Reports[0].Error == "" || result.Reports[1].Status != "canceled" {
t.Fatalf("report results = %#v", result.Reports)
}
}
func TestNotifyBatchSkipsCancellationObservedAfterReportsComplete(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
notifier := &generationNotifier{}
result := notifyBatch(batchNotificationInput{
ctx: ctx, cfg: generationDistributorConfig(), batch: BatchMorning,
runID: "run-id", startedAt: generationTime("2026-05-29T08:30:00-05:00"),
result: &BatchResult{Total: 1, Succeeded: 1, Reports: []BatchReportResult{{Status: "succeeded"}}},
notifier: notifier,
})
if result == nil || result.Status != "skipped" || result.Reason != "batch canceled" || notifier.batchCalls != 0 {
t.Fatalf("notifyBatch() result/notifier = %#v/%#v", result, notifier)
}
}
func TestRunBatchDetailedRetainsPublishedReportBeforeCancellation(t *testing.T) {
for _, cause := range []error{context.Canceled, context.DeadlineExceeded} {
t.Run(cause.Error(), func(t *testing.T) {

View File

@@ -42,47 +42,59 @@ type batchNotifier interface {
NotifyBatch(context.Context, batchNotificationRequest) (*NotificationResult, error)
}
type batchNotificationInput struct {
ctx context.Context
cancellation error
cfg config.Config
batch BatchKind
runID string
startedAt time.Time
result *BatchResult
planned []plannedBatchReport
notifier Notifier
}
func batchRunID(startedAt time.Time, batch BatchKind) string {
return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch)
}
func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, result *BatchResult, planned []plannedBatchReport, notifier Notifier) *BatchNotificationResult {
if !cfg.Notify.Distributor.Enabled {
func notifyBatch(input batchNotificationInput) *BatchNotificationResult {
if !input.cfg.Notify.Distributor.Enabled {
return nil
}
if !cfg.Notify.Distributor.Batch.Enabled {
if !input.cfg.Notify.Distributor.Batch.Enabled {
return nil
}
if result == nil {
if input.result == nil {
return failedBatchNotificationResult(batchNotificationRequest{}, fmt.Errorf("batch result is required"))
}
if result.Canceled > 0 {
if input.cancellation != nil || batchContextCancellationCause(input.ctx) != nil || input.result.Canceled > 0 {
return &BatchNotificationResult{
Status: "skipped",
Reason: "batch canceled",
}
}
if result.Failed > 0 {
if input.result.Failed > 0 {
return &BatchNotificationResult{
Status: "skipped",
Reason: "one or more reports failed",
}
}
req, err := buildBatchNotificationRequest(cfg, batch, runID, startedAt, result.Reports, planned)
req, err := buildBatchNotificationRequest(input.cfg, input.batch, input.runID, input.startedAt, input.result.Reports, input.planned)
if err != nil {
return failedBatchNotificationResult(batchNotificationRequest{}, err)
}
batchNotifier, err := resolveBatchNotifier(cfg, notifier)
batchNotifier, err := resolveBatchNotifier(input.cfg, input.notifier)
if err != nil {
return failedBatchNotificationResult(req, err)
}
notification, notifyErr := batchNotifier.NotifyBatch(ctx, req)
notification, notifyErr := batchNotifier.NotifyBatch(input.ctx, req)
wrappedErr := notifyErr
if notifyErr != nil {
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", batch, runID, req.BundleID, notifyErr)
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", input.batch, input.runID, req.BundleID, notifyErr)
}
batchResult := batchNotificationResult(req, notification)
if wrappedErr != nil {

View File

@@ -101,6 +101,9 @@ func TestExecuteComparisonProfilesUsesDistinctDeterministicDebugReferences(t *te
{ProfileID: "deep/two", BackendID: "cloud", ModelName: "deep"},
}
debugWriter, err := promptdebug.NewPromptDebugWriter(t.TempDir())
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
t.Skipf("secure prompt debug capture is unavailable: %v", err)
}
if err != nil {
t.Fatalf("NewPromptDebugWriter() error = %v", err)
}

View File

@@ -13,8 +13,10 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
@@ -510,7 +512,7 @@ func TestGenerateDetailedDoesNotReplaceSymbolicLinkOutput(t *testing.T) {
t.Fatal(err)
}
outputPath := filepath.Join(dir, "daily.md")
requireSymlink(t, backing, outputPath)
testutil.RequireSymlink(t, backing, outputPath)
bundle := generationBundle(t)
collector := &generationCollector{bundle: &bundle}
executor := &generationExecutor{}
@@ -531,6 +533,9 @@ func TestGenerateDetailedWritesRequestedPromptDebugArtifacts(t *testing.T) {
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), LLMDebugDir: debugRoot, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
})
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
t.Skipf("secure prompt debug capture is unavailable: %v", err)
}
if err != nil || result == nil || result.LLMDebugPath == "" {
t.Fatalf("GenerateDetailed() result/error = %#v/%v", result, err)
}

View File

@@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
)
func TestResolveComparisonOutputDirectory(t *testing.T) {
@@ -46,7 +48,7 @@ func TestResolveComparisonOutputDirectory(t *testing.T) {
func TestResolveOutputDirRejectsDanglingSymlinkComponents(t *testing.T) {
workingDir := t.TempDir()
dangling := filepath.Join(workingDir, "dangling")
requireSymlink(t, filepath.Join(workingDir, "missing"), dangling)
testutil.RequireSymlink(t, filepath.Join(workingDir, "missing"), dangling)
for _, directory := range []string{dangling, filepath.Join(dangling, "reports")} {
t.Run(filepath.Base(directory), func(t *testing.T) {
@@ -61,7 +63,7 @@ func TestResolveOutputDirAllowsMissingDirectoryBelowValidSymlink(t *testing.T) {
workingDir := t.TempDir()
target := t.TempDir()
link := filepath.Join(workingDir, "linked")
requireSymlink(t, target, link)
testutil.RequireSymlink(t, target, link)
directory := filepath.Join(link, "reports")
got, err := resolveOutputDir(workingDir, directory)
@@ -69,10 +71,3 @@ func TestResolveOutputDirAllowsMissingDirectoryBelowValidSymlink(t *testing.T) {
t.Fatalf("resolveOutputDir() = %q, %v, want %q, nil", got, err, directory)
}
}
func requireSymlink(t *testing.T, target string, link string) {
t.Helper()
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlink support is unavailable: %v", err)
}
}

View File

@@ -37,6 +37,9 @@ func TestExecutePreparedProfileRendersWithoutPublishing(t *testing.T) {
func TestExecutePreparedProfileKeepsDebugCallbackFailureLocal(t *testing.T) {
prepared, inspection := preparedDailyProfile(t)
debugWriter, err := promptdebug.NewPromptDebugWriter(t.TempDir())
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
t.Skipf("secure prompt debug capture is unavailable: %v", err)
}
if err != nil {
t.Fatalf("NewPromptDebugWriter() error = %v", err)
}

View File

@@ -36,7 +36,7 @@ Options:
--units VALUE Override weather API units.
--tz NAME Override weather API timezone.
--out PATH Write the generated Markdown report to PATH.
--llm-debug-dir PATH Write sensitive prompt debug artifacts under PATH.
--llm-debug-dir PATH Write sensitive prompt debug artifacts under PATH (Unix only).
--profile PROFILE Select a prompt profile for compare; repeat for every profile.
--date YYYY-MM-DD Required for generate/compare daily; optional for generate/compare today.
--out-dir PATH Write generated Markdown reports beneath PATH for run commands, or select the exact comparison directory.
@@ -407,7 +407,7 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
fs.StringVar(&opts.Units, "units", "", "weather API units")
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
fs.StringVar(&opts.LLMDebugDir, "llm-debug-dir", "", "write sensitive prompt debug artifacts under PATH")
fs.StringVar(&opts.LLMDebugDir, "llm-debug-dir", "", "write sensitive prompt debug artifacts under PATH (Unix only)")
if includeOutput {
fs.StringVar(&opts.Output, "out", "", "generated Markdown report path")
}

View File

@@ -11,6 +11,8 @@ import (
"runtime"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
)
func TestPlanDestination(t *testing.T) {
@@ -51,11 +53,11 @@ func TestPlanDestination(t *testing.T) {
t.Run("symbolic links", func(t *testing.T) {
link := filepath.Join(workingDirectory, "link")
requireSymlink(t, empty, link)
testutil.RequireSymlink(t, empty, link)
assertDestinationErrorKind(t, workingDirectory, link, false, DestinationSymlink)
dangling := filepath.Join(workingDirectory, "dangling")
requireSymlink(t, filepath.Join(workingDirectory, "missing"), dangling)
testutil.RequireSymlink(t, filepath.Join(workingDirectory, "missing"), dangling)
assertDestinationErrorKind(t, workingDirectory, dangling, false, DestinationSymlink)
assertDestinationErrorKind(t, workingDirectory, filepath.Join(dangling, "child"), false, DestinationInspection)
})
@@ -116,7 +118,7 @@ func TestRecognizeBundleRejectsUnsafeAndMalformedContents(t *testing.T) {
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
requireSymlink(t, filepath.Join(directory, DataPackageFilename), path)
testutil.RequireSymlink(t, filepath.Join(directory, DataPackageFilename), path)
}},
{name: "digest mismatch", mutate: func(t *testing.T, directory string) {
t.Helper()
@@ -365,7 +367,7 @@ func TestPublishReauthorizesMovedDestination(t *testing.T) {
},
mutate: func(t *testing.T, target string) {
t.Helper()
requireSymlink(t, "unrelated-target", target)
testutil.RequireSymlink(t, "unrelated-target", target)
},
verify: func(t *testing.T, target string) {
t.Helper()
@@ -430,13 +432,6 @@ func TestPublishReauthorizesMovedDestination(t *testing.T) {
}
}
func requireSymlink(t *testing.T, target string, link string) {
t.Helper()
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlink support is unavailable: %v", err)
}
}
func TestPublishRetainsUnauthorizedMovedDestinationWhenRestoreFails(t *testing.T) {
workingDirectory := t.TempDir()
target := filepath.Join(workingDirectory, "comparison-daily")

View File

@@ -11,6 +11,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
"gopkg.in/yaml.v3"
)
@@ -2000,9 +2001,7 @@ func TestLoadSecretsRejectsInvalidDirectoryEntries(t *testing.T) {
if err := os.WriteFile(target, []byte("secret-value"), 0o600); err != nil {
t.Fatalf("write target: %v", err)
}
if err := os.Symlink(target, filepath.Join(dir, "SYMLINK")); err != nil {
t.Fatalf("create symlink: %v", err)
}
testutil.RequireSymlink(t, target, filepath.Join(dir, "SYMLINK"))
},
wantErr: "not a symlink",
},

View File

@@ -7,6 +7,8 @@ import (
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
)
func TestWriteFileAtomicCreatesParentDirectory(t *testing.T) {
@@ -96,9 +98,7 @@ func TestWriteFileAtomicRejectsUnsafeFinalDestinations(t *testing.T) {
name: "symbolic link",
setup: func(t *testing.T, path string) {
t.Helper()
if err := os.Symlink(backing, path); err != nil {
t.Fatal(err)
}
testutil.RequireSymlink(t, backing, path)
},
},
} {

View File

@@ -4,6 +4,7 @@ package promptdebug
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"path/filepath"
@@ -14,6 +15,11 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
// ErrSecureCaptureUnsupported reports that the host cannot provide the
// handle-relative, no-follow filesystem operations required for prompt debug
// artifacts.
var ErrSecureCaptureUnsupported = errors.New("secure prompt debug capture is unavailable on this platform")
const (
promptPreparationDebugSchemaVersion = "weatherreporter.prompt_preparation_debug.v2"
promptExecutionDebugSchemaVersion = "weatherreporter.prompt_execution_debug.v2"

View File

@@ -0,0 +1,28 @@
//go:build !unix
package promptdebug
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestPromptDebugWriterFailsClosedWithoutSecureTraversal(t *testing.T) {
root := filepath.Join(t.TempDir(), "debug")
writer, err := NewPromptDebugWriter(root)
if writer != nil || !errors.Is(err, ErrSecureCaptureUnsupported) {
t.Fatalf("NewPromptDebugWriter() = %#v, %v, want unsupported error", writer, err)
}
if _, statErr := os.Stat(root); !os.IsNotExist(statErr) {
t.Fatalf("prompt debug root was accessed: %v", statErr)
}
}
func TestDisabledPromptDebugWriterRemainsPortable(t *testing.T) {
writer, err := NewPromptDebugWriter("")
if err != nil || writer == nil || writer.Enabled() {
t.Fatalf("NewPromptDebugWriter(empty) = %#v, %v", writer, err)
}
}

View File

@@ -1,10 +1,11 @@
//go:build unix
package promptdebug
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
@@ -12,6 +13,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
)
func TestPromptDebugWriterWritesIsolatedArtifacts(t *testing.T) {
@@ -58,12 +60,10 @@ func TestPromptDebugWriterWritesIsolatedArtifacts(t *testing.T) {
t.Fatalf("debug artifact contains credentials:\n%s", data)
}
}
if runtime.GOOS != "windows" {
assertPromptDebugMode(t, root, debugDirectoryMode)
assertPromptDebugMode(t, preparationDir, debugDirectoryMode)
assertPromptDebugMode(t, filepath.Join(preparationDir, "preparation.json"), debugFileMode)
assertPromptDebugMode(t, filepath.Join(executionDir, "execution.json"), debugFileMode)
}
assertPromptDebugMode(t, root, debugDirectoryMode)
assertPromptDebugMode(t, preparationDir, debugDirectoryMode)
assertPromptDebugMode(t, filepath.Join(preparationDir, "preparation.json"), debugFileMode)
assertPromptDebugMode(t, filepath.Join(executionDir, "execution.json"), debugFileMode)
}
func TestPromptDebugWriterProjectsProviderConfigurationSafely(t *testing.T) {
@@ -222,21 +222,16 @@ func TestPromptDebugWriterCreatesSharedMissingAncestorsConcurrently(t *testing.T
if len(directories) != writerCount {
t.Fatalf("debug directories = %#v", directories)
}
if runtime.GOOS != "windows" {
assertPromptDebugMode(t, root, debugDirectoryMode)
assertPromptDebugMode(t, filepath.Join(root, "daily"), debugDirectoryMode)
assertPromptDebugMode(t, filepath.Join(root, "daily", "2026-05-29"), debugDirectoryMode)
for directory := range directories {
assertPromptDebugMode(t, directory, debugDirectoryMode)
assertPromptDebugMode(t, filepath.Join(directory, "preparation.json"), debugFileMode)
}
assertPromptDebugMode(t, root, debugDirectoryMode)
assertPromptDebugMode(t, filepath.Join(root, "daily"), debugDirectoryMode)
assertPromptDebugMode(t, filepath.Join(root, "daily", "2026-05-29"), debugDirectoryMode)
for directory := range directories {
assertPromptDebugMode(t, directory, debugDirectoryMode)
assertPromptDebugMode(t, filepath.Join(directory, "preparation.json"), debugFileMode)
}
}
func TestPromptDebugWriterKeepsWritesAnchoredToOpenedRoot(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("directory replacement behavior is covered on Unix hosts")
}
parent := t.TempDir()
root := filepath.Join(parent, "debug")
writer, err := NewPromptDebugWriter(root)
@@ -253,7 +248,7 @@ func TestPromptDebugWriterKeepsWritesAnchoredToOpenedRoot(t *testing.T) {
if err := os.Rename(root, anchoredRoot); err != nil {
t.Fatalf("replace opened root: %v", err)
}
requireSymlink(t, outside, root)
testutil.RequireSymlink(t, outside, root)
ref := promptDebugRef()
ref.RunID = "run-anchored"
@@ -299,16 +294,14 @@ func TestPromptDebugWriterRejectsUnsafeRootsAndReferences(t *testing.T) {
if _, err := NewPromptDebugWriter(nonDirectory); err == nil {
t.Fatal("NewPromptDebugWriter(file) error = nil")
}
if runtime.GOOS != "windows" {
target := filepath.Join(root, "target")
link := filepath.Join(root, "root-link")
if err := os.Mkdir(target, debugDirectoryMode); err != nil {
t.Fatalf("create root symlink target: %v", err)
}
requireSymlink(t, target, link)
if _, err := NewPromptDebugWriter(link); err == nil {
t.Fatal("NewPromptDebugWriter(symlink) error = nil")
}
target := filepath.Join(root, "target")
link := filepath.Join(root, "root-link")
if err := os.Mkdir(target, debugDirectoryMode); err != nil {
t.Fatalf("create root symlink target: %v", err)
}
testutil.RequireSymlink(t, target, link)
if _, err := NewPromptDebugWriter(link); err == nil {
t.Fatal("NewPromptDebugWriter(symlink) error = nil")
}
writer, err := NewPromptDebugWriter(filepath.Join(root, "debug"))
@@ -325,22 +318,13 @@ func TestPromptDebugWriterRejectsUnsafeRootsAndReferences(t *testing.T) {
t.Fatalf("WritePreparation(%#v) error = nil", ref)
}
}
if runtime.GOOS != "windows" {
outside := filepath.Join(root, "outside")
if err := os.Mkdir(outside, debugDirectoryMode); err != nil {
t.Fatalf("create symlink component target: %v", err)
}
requireSymlink(t, outside, filepath.Join(root, "debug", "daily"))
if _, err := writer.WritePreparation(promptDebugRef(), promptDebugPreparationFixture(), nil); err == nil {
t.Fatal("WritePreparation(symlink component) error = nil")
}
outside := filepath.Join(root, "outside")
if err := os.Mkdir(outside, debugDirectoryMode); err != nil {
t.Fatalf("create symlink component target: %v", err)
}
}
func requireSymlink(t *testing.T, target string, link string) {
t.Helper()
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlink support is unavailable: %v", err)
testutil.RequireSymlink(t, outside, filepath.Join(root, "debug", "daily"))
if _, err := writer.WritePreparation(promptDebugRef(), promptDebugPreparationFixture(), nil); err == nil {
t.Fatal("WritePreparation(symlink component) error = nil")
}
}

View File

@@ -3,141 +3,29 @@
package promptdebug
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
type secureDirectory struct {
root *os.Root
}
// secureDirectory has no enabled implementation on platforms that cannot
// provide handle-relative, no-follow directory traversal.
type secureDirectory struct{}
func openSecureDirectory(path string) (*secureDirectory, error) {
if !filepath.IsAbs(path) {
return nil, fmt.Errorf("directory must be absolute")
}
if err := os.MkdirAll(path, debugDirectoryMode); err != nil {
return nil, err
}
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return nil, fmt.Errorf("prompt debug root is not a directory")
}
if err := os.Chmod(path, debugDirectoryMode); err != nil {
return nil, err
}
root, err := os.OpenRoot(path)
if err != nil {
return nil, err
}
return &secureDirectory{root: root}, nil
return nil, ErrSecureCaptureUnsupported
}
func (directory *secureDirectory) Close() error {
if directory == nil || directory.root == nil {
return nil
}
root := directory.root
directory.root = nil
return root.Close()
}
func (directory *secureDirectory) openDirectory(components ...string) (*secureDirectory, error) {
if directory == nil || directory.root == nil {
return nil, fmt.Errorf("prompt debug directory is closed")
}
for _, component := range components {
if err := validateSecureDirectoryName(component); err != nil {
return nil, err
}
}
path := filepath.Join(components...)
if err := directory.root.MkdirAll(path, debugDirectoryMode); err != nil {
return nil, err
}
child, err := directory.root.OpenRoot(path)
if err != nil {
return nil, err
}
return &secureDirectory{root: child}, nil
}
func validateSecureDirectoryName(name string) error {
if name == "" || name == "." || name == ".." || strings.ContainsRune(name, filepath.Separator) {
return fmt.Errorf("prompt debug directory component %q is invalid", name)
}
func (*secureDirectory) Close() error {
return nil
}
func (directory *secureDirectory) writeJSON(name string, value any) error {
if directory == nil || directory.root == nil {
return fmt.Errorf("prompt debug directory is closed")
}
if err := validateSecureDirectoryName(name); err != nil {
return err
}
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return fmt.Errorf("marshal prompt debug artifact: %w", err)
}
if info, err := directory.root.Lstat(name); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("prompt debug file %q is not a regular file", name)
}
} else if !os.IsNotExist(err) {
return err
}
temporaryName, temporary, err := directory.createTemporaryFile(name)
if err != nil {
return err
}
defer func() {
if temporary != nil {
_ = temporary.Close()
}
_ = directory.root.Remove(temporaryName)
}()
if err := temporary.Chmod(debugFileMode); err != nil {
return err
}
if written, err := temporary.Write(data); err != nil {
return err
} else if written != len(data) {
return io.ErrShortWrite
}
if err := temporary.Close(); err != nil {
return err
}
temporary = nil
if err := directory.root.Rename(temporaryName, name); err != nil {
return fmt.Errorf("replace prompt debug file %q: %w", name, err)
}
return nil
func (*secureDirectory) openDirectory(...string) (*secureDirectory, error) {
return nil, ErrSecureCaptureUnsupported
}
func (directory *secureDirectory) createTemporaryFile(name string) (string, *os.File, error) {
for attempt := 0; attempt < 16; attempt++ {
random := make([]byte, 12)
if _, err := rand.Read(random); err != nil {
return "", nil, err
}
temporaryName := "." + name + "." + hex.EncodeToString(random) + ".tmp"
temporary, err := directory.root.OpenFile(temporaryName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, debugFileMode)
if os.IsExist(err) {
continue
}
if err != nil {
return "", nil, err
}
return temporaryName, temporary, nil
}
return "", nil, fmt.Errorf("create temporary prompt debug file: too many name collisions")
func (*secureDirectory) writeJSON(string, any) error {
return ErrSecureCaptureUnsupported
}

View File

@@ -0,0 +1,26 @@
// Package testutil contains test-only helpers shared across package suites.
package testutil
import (
"errors"
"io/fs"
"os"
"testing"
)
// RequireSymlink creates a symbolic link or skips when the host explicitly
// reports that the operation is unsupported or unavailable to the test user.
// Unexpected setup failures remain test failures.
func RequireSymlink(t testing.TB, target, link string) {
t.Helper()
if err := os.Symlink(target, link); err != nil {
if symlinkUnavailable(err) {
t.Skipf("symlink support is unavailable: %v", err)
}
t.Fatalf("create symlink %q -> %q: %v", link, target, err)
}
}
func symlinkUnavailable(err error) bool {
return errors.Is(err, fs.ErrPermission) || errors.Is(err, errors.ErrUnsupported) || platformSymlinkUnavailable(err)
}

View File

@@ -0,0 +1,7 @@
//go:build !windows
package testutil
func platformSymlinkUnavailable(error) bool {
return false
}

View File

@@ -0,0 +1,25 @@
package testutil
import (
"errors"
"io/fs"
"testing"
)
func TestSymlinkUnavailableClassifiesOnlyCapabilityErrors(t *testing.T) {
for _, test := range []struct {
name string
err error
want bool
}{
{name: "permission", err: fs.ErrPermission, want: true},
{name: "unsupported", err: errors.ErrUnsupported, want: true},
{name: "unexpected", err: errors.New("fixture path is invalid"), want: false},
} {
t.Run(test.name, func(t *testing.T) {
if got := symlinkUnavailable(test.err); got != test.want {
t.Fatalf("symlinkUnavailable(%v) = %t, want %t", test.err, got, test.want)
}
})
}
}

View File

@@ -0,0 +1,12 @@
//go:build windows
package testutil
import (
"errors"
"syscall"
)
func platformSymlinkUnavailable(err error) bool {
return errors.Is(err, syscall.ERROR_PRIVILEGE_NOT_HELD)
}