From 0314a302f1b658a78e0372a1d2ef74e1f647a7d5 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 13 Aug 2026 03:09:00 +0000 Subject: [PATCH] Preflight comparison transaction names --- docs/internal/comparison-publication.md | 6 ++++ docs/operations.md | 3 ++ internal/comparison/publish.go | 28 ++++++++++++++-- internal/comparison/publish_test.go | 43 +++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/docs/internal/comparison-publication.md b/docs/internal/comparison-publication.md index f7f42d3..daba804 100644 --- a/docs/internal/comparison-publication.md +++ b/docs/internal/comparison-publication.md @@ -21,6 +21,12 @@ For replacement, it moves the prior bundle to a private sibling backup, reauthorizes that moved entry, and restores it if installing the new bundle fails. +Planning also validates the final component and the bounded fixed names used +for private staging and backup siblings. A destination that cannot form those +names is rejected before publication creates a missing parent directory; a +maximum-length valid destination remains usable because transaction siblings do +not incorporate its basename. + The new bundle is committed only after the staged directory has been installed at the target. From that point its artifact paths are authoritative: a failure to remove the retained sibling backup does not roll back the new bundle. diff --git a/docs/operations.md b/docs/operations.md index cd04a7c..4491ae2 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -126,6 +126,9 @@ directories, symlinks, and unsafe destinations are rejected. Cancellation and all failures before publication preserve an existing bundle. Profile failures are different: the command publishes a complete partial bundle, with failed profiles represented in the manifest and no Markdown file for those profiles. +Comparison preflight also checks that private publication siblings can be +formed. An infeasible destination name is rejected before a missing parent +directory is created. ## Local Prompt Profile Override diff --git a/internal/comparison/publish.go b/internal/comparison/publish.go index 1415635..9a2d1f9 100644 --- a/internal/comparison/publish.go +++ b/internal/comparison/publish.go @@ -24,6 +24,11 @@ const ( DestinationNotEmpty DestinationErrorKind = "not_empty" DestinationUnrecognized DestinationErrorKind = "unrecognized" DestinationInspection DestinationErrorKind = "inspection" + + maxComparisonComponentBytes = 255 + temporaryRandomBytes = 10 + stagingDirectoryPattern = ".weatherreporter-staging-*" + backupDirectoryPattern = ".weatherreporter-backup-*" ) // DestinationError provides inspectable context without making filesystem @@ -82,6 +87,9 @@ func PlanDestination(workingDirectory, target string, replace bool) (Destination if target == workingDirectory { return DestinationPlan{}, newDestinationError(DestinationWorkingDirectory, target, nil) } + if err := validateTransactionSiblingNames(target); err != nil { + return DestinationPlan{}, newDestinationError(DestinationInvalidPath, target, err) + } plan := DestinationPlan{WorkingDirectory: workingDirectory, Target: target, Replace: replace} info, err := os.Lstat(target) @@ -119,6 +127,22 @@ func PlanDestination(workingDirectory, target string, replace bool) (Destination return plan, nil } +func validateTransactionSiblingNames(target string) error { + if len(filepath.Base(target)) > maxComparisonComponentBytes { + return fmt.Errorf("destination name exceeds the %d-byte limit", maxComparisonComponentBytes) + } + for _, pattern := range []string{stagingDirectoryPattern, backupDirectoryPattern} { + if !temporaryPatternFitsComponentLimit(pattern) { + return fmt.Errorf("comparison transaction sibling pattern exceeds the %d-byte limit", maxComparisonComponentBytes) + } + } + return nil +} + +func temporaryPatternFitsComponentLimit(pattern string) bool { + return len(strings.Replace(pattern, "*", strings.Repeat("0", temporaryRandomBytes), 1)) <= maxComparisonComponentBytes +} + func absoluteCleanPath(value string) (string, error) { if strings.TrimSpace(value) == "" { return "", fmt.Errorf("path is required") @@ -480,7 +504,7 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op if err := os.MkdirAll(filepath.Dir(plan.Target), 0o755); err != nil { return PublicationResult{}, fmt.Errorf("create comparison destination parent %q: %w", filepath.Dir(plan.Target), err) } - temporaryDirectory, err := os.MkdirTemp(filepath.Dir(plan.Target), "."+filepath.Base(plan.Target)+".staging-") + temporaryDirectory, err := os.MkdirTemp(filepath.Dir(plan.Target), stagingDirectoryPattern) if err != nil { return PublicationResult{}, fmt.Errorf("create comparison staging directory: %w", err) } @@ -516,7 +540,7 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op return PublicationResult{Committed: true}, nil } - backupDirectory, err := uniqueSiblingPath(filepath.Dir(currentPlan.Target), "."+filepath.Base(currentPlan.Target)+".backup-") + backupDirectory, err := uniqueSiblingPath(filepath.Dir(currentPlan.Target), backupDirectoryPattern) if err != nil { return PublicationResult{}, err } diff --git a/internal/comparison/publish_test.go b/internal/comparison/publish_test.go index 4a88624..f565404 100644 --- a/internal/comparison/publish_test.go +++ b/internal/comparison/publish_test.go @@ -506,6 +506,49 @@ func TestPublishReplacesEmptyDirectory(t *testing.T) { } } +func TestComparisonDestinationPreflightsTransactionSiblingNames(t *testing.T) { + workingDirectory := t.TempDir() + missingParent := filepath.Join(workingDirectory, "missing") + target := filepath.Join(missingParent, strings.Repeat("a", maxComparisonComponentBytes+1)) + if _, err := PlanDestination(workingDirectory, target, false); err == nil { + t.Fatal("PlanDestination() accepted an overlong destination name") + } + if _, err := os.Lstat(missingParent); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("missing parent stat error = %v, want not exist", err) + } +} + +func TestPublishSupportsLongestDestinationComponent(t *testing.T) { + workingDirectory := t.TempDir() + parent := filepath.Join(workingDirectory, "nested") + target := filepath.Join(parent, strings.Repeat("a", maxComparisonComponentBytes)) + + plan, err := PlanDestination(workingDirectory, target, false) + if err != nil { + t.Fatalf("PlanDestination() error = %v", err) + } + if _, err := Publish(context.Background(), plan, testBundle()); err != nil { + t.Fatalf("Publish() error = %v", err) + } + if _, err := RecognizeBundle(target); err != nil { + t.Fatalf("RecognizeBundle() error = %v", err) + } + + next := testBundle() + next.Reports[0].Markdown = []byte("# Replacement\n") + plan, err = PlanDestination(workingDirectory, target, true) + if err != nil { + t.Fatalf("PlanDestination(replace) error = %v", err) + } + if _, err := Publish(context.Background(), plan, next); err != nil { + t.Fatalf("Publish(replace) error = %v", err) + } + if data := readFile(t, filepath.Join(target, "01-weather-light.md")); string(data) != "# Replacement\n" { + t.Fatalf("replacement report = %q", data) + } + assertOnlyDestinationEntry(t, parent, filepath.Base(target)) +} + func TestPublishReportsCommittedBundleWhenBackupCleanupFails(t *testing.T) { workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "comparison-daily")