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
|
reauthorizes that moved entry, and restores it if installing the new bundle
|
||||||
fails.
|
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
|
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
|
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.
|
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
|
all failures before publication preserve an existing bundle. Profile failures
|
||||||
are different: the command publishes a complete partial bundle, with failed
|
are different: the command publishes a complete partial bundle, with failed
|
||||||
profiles represented in the manifest and no Markdown file for those profiles.
|
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
|
## Local Prompt Profile Override
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ const (
|
|||||||
DestinationNotEmpty DestinationErrorKind = "not_empty"
|
DestinationNotEmpty DestinationErrorKind = "not_empty"
|
||||||
DestinationUnrecognized DestinationErrorKind = "unrecognized"
|
DestinationUnrecognized DestinationErrorKind = "unrecognized"
|
||||||
DestinationInspection DestinationErrorKind = "inspection"
|
DestinationInspection DestinationErrorKind = "inspection"
|
||||||
|
|
||||||
|
maxComparisonComponentBytes = 255
|
||||||
|
temporaryRandomBytes = 10
|
||||||
|
stagingDirectoryPattern = ".weatherreporter-staging-*"
|
||||||
|
backupDirectoryPattern = ".weatherreporter-backup-*"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DestinationError provides inspectable context without making filesystem
|
// DestinationError provides inspectable context without making filesystem
|
||||||
@@ -82,6 +87,9 @@ func PlanDestination(workingDirectory, target string, replace bool) (Destination
|
|||||||
if target == workingDirectory {
|
if target == workingDirectory {
|
||||||
return DestinationPlan{}, newDestinationError(DestinationWorkingDirectory, target, nil)
|
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}
|
plan := DestinationPlan{WorkingDirectory: workingDirectory, Target: target, Replace: replace}
|
||||||
info, err := os.Lstat(target)
|
info, err := os.Lstat(target)
|
||||||
@@ -119,6 +127,22 @@ func PlanDestination(workingDirectory, target string, replace bool) (Destination
|
|||||||
return plan, nil
|
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) {
|
func absoluteCleanPath(value string) (string, error) {
|
||||||
if strings.TrimSpace(value) == "" {
|
if strings.TrimSpace(value) == "" {
|
||||||
return "", fmt.Errorf("path is required")
|
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 {
|
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)
|
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 {
|
if err != nil {
|
||||||
return PublicationResult{}, fmt.Errorf("create comparison staging directory: %w", err)
|
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
|
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 {
|
if err != nil {
|
||||||
return PublicationResult{}, err
|
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) {
|
func TestPublishReportsCommittedBundleWhenBackupCleanupFails(t *testing.T) {
|
||||||
workingDirectory := t.TempDir()
|
workingDirectory := t.TempDir()
|
||||||
target := filepath.Join(workingDirectory, "comparison-daily")
|
target := filepath.Join(workingDirectory, "comparison-daily")
|
||||||
|
|||||||
Reference in New Issue
Block a user