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

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

View File

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

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