Preflight comparison transaction names
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user