Preflight comparison transaction names

This commit is contained in:
2026-08-13 03:09:00 +00:00
parent 707db5394c
commit 0314a302f1
4 changed files with 78 additions and 2 deletions

View File

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

View File

@@ -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")