diff --git a/internal/app/output.go b/internal/app/output.go index 0153a91..ca44f3e 100644 --- a/internal/app/output.go +++ b/internal/app/output.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "gitea.maximumdirect.net/eric/weatherreporter/internal/comparison" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" ) @@ -51,6 +52,35 @@ func resolveOutputDirWithConfigured(workingDir, override, configuredDir string) return resolveOutputDir(workingDir, directory) } +func resolveComparisonOutputDirectory(workingDir, override, configuredDir, reportOutputName string) (string, error) { + workingDir, err := validateWorkingDir(workingDir) + if err != nil { + return "", err + } + if override != "" { + return resolveComparisonDirectoryPath(workingDir, override) + } + outputDir, err := resolveOutputDir(workingDir, configuredDir) + if err != nil { + return "", err + } + name, err := comparison.DefaultDirectoryName(reportOutputName) + if err != nil { + return "", err + } + return filepath.Join(outputDir, name), nil +} + +func resolveComparisonDirectoryPath(workingDir, directory string) (string, error) { + if strings.TrimSpace(directory) == "" { + return "", fmt.Errorf("comparison output directory is required") + } + if !filepath.IsAbs(directory) { + directory = filepath.Join(workingDir, directory) + } + return filepath.Clean(directory), nil +} + func resolveOutputDir(workingDir, override string) (string, error) { workingDir, err := validateWorkingDir(workingDir) if err != nil { diff --git a/internal/app/output_test.go b/internal/app/output_test.go index 9379ea5..aedacbc 100644 --- a/internal/app/output_test.go +++ b/internal/app/output_test.go @@ -6,6 +6,43 @@ import ( "testing" ) +func TestResolveComparisonOutputDirectory(t *testing.T) { + workingDir := t.TempDir() + configured := filepath.Join(workingDir, "configured") + blocked := filepath.Join(workingDir, "not-a-directory") + if err := os.WriteFile(blocked, []byte("blocked"), 0o600); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + override string + configuredDir string + reportOutputName string + want string + wantErr bool + }{ + {name: "working directory default", reportOutputName: "today.md", want: filepath.Join(workingDir, "comparison-today")}, + {name: "configured relative directory", configuredDir: "configured", reportOutputName: "tomorrow.md", want: filepath.Join(configured, "comparison-tomorrow")}, + {name: "configured absolute directory", configuredDir: configured, reportOutputName: "hourly.md", want: filepath.Join(configured, "comparison-hourly")}, + {name: "relative explicit directory", override: "exact", configuredDir: blocked, reportOutputName: "daily-2026-08-24.md", want: filepath.Join(workingDir, "exact")}, + {name: "absolute explicit directory", override: filepath.Join(workingDir, "absolute"), reportOutputName: "today.md", want: filepath.Join(workingDir, "absolute")}, + {name: "invalid report suffix", reportOutputName: "today.txt", wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := resolveComparisonOutputDirectory(workingDir, test.override, test.configuredDir, test.reportOutputName) + if (err != nil) != test.wantErr { + t.Fatalf("resolveComparisonOutputDirectory() error = %v, want error %t", err, test.wantErr) + } + if !test.wantErr && got != test.want { + t.Fatalf("resolveComparisonOutputDirectory() = %q, want %q", got, test.want) + } + }) + } +} + func TestResolveOutputDirRejectsDanglingSymlinkComponents(t *testing.T) { workingDir := t.TempDir() dangling := filepath.Join(workingDir, "dangling") diff --git a/internal/comparison/publish.go b/internal/comparison/publish.go new file mode 100644 index 0000000..ade9dea --- /dev/null +++ b/internal/comparison/publish.go @@ -0,0 +1,394 @@ +package comparison + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// DestinationErrorKind identifies a comparison destination safety failure. +type DestinationErrorKind string + +const ( + DestinationInvalidPath DestinationErrorKind = "invalid_path" + DestinationFilesystemRoot DestinationErrorKind = "filesystem_root" + DestinationWorkingDirectory DestinationErrorKind = "working_directory" + DestinationSymlink DestinationErrorKind = "symlink" + DestinationNotDirectory DestinationErrorKind = "not_directory" + DestinationNotEmpty DestinationErrorKind = "not_empty" + DestinationUnrecognized DestinationErrorKind = "unrecognized" + DestinationInspection DestinationErrorKind = "inspection" +) + +// DestinationError provides inspectable context without making filesystem +// implementation details part of a manifest or command-result schema. +type DestinationError struct { + Kind DestinationErrorKind + Target string + Err error +} + +func (err *DestinationError) Error() string { + if err.Err == nil { + return fmt.Sprintf("comparison destination %q is %s", err.Target, err.Kind) + } + return fmt.Sprintf("comparison destination %q is %s: %v", err.Target, err.Kind, err.Err) +} + +func (err *DestinationError) Unwrap() error { + return err.Err +} + +// DestinationPlan records a preflighted, exact bundle directory. Callers must +// pass it to Publish rather than recreating destination policy themselves. +type DestinationPlan struct { + WorkingDirectory string + Target string + Replace bool + Exists bool +} + +// ErrUnrecognizedBundle marks a directory that is not a valid current-schema +// Weatherreporter comparison bundle. +var ErrUnrecognizedBundle = errors.New("unrecognized comparison bundle") + +// PlanDestination performs the read-only comparison destination preflight. +func PlanDestination(workingDirectory, target string, replace bool) (DestinationPlan, error) { + workingDirectory, err := absoluteCleanPath(workingDirectory) + if err != nil { + return DestinationPlan{}, newDestinationError(DestinationInvalidPath, workingDirectory, err) + } + target, err = absoluteCleanPath(target) + if err != nil { + return DestinationPlan{}, newDestinationError(DestinationInvalidPath, target, err) + } + if filepath.Dir(target) == target { + return DestinationPlan{}, newDestinationError(DestinationFilesystemRoot, target, nil) + } + if target == workingDirectory { + return DestinationPlan{}, newDestinationError(DestinationWorkingDirectory, target, nil) + } + + plan := DestinationPlan{WorkingDirectory: workingDirectory, Target: target, Replace: replace} + info, err := os.Lstat(target) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return DestinationPlan{}, newDestinationError(DestinationInspection, target, err) + } + if err := inspectMissingDestinationParent(target); err != nil { + return DestinationPlan{}, err + } + return plan, nil + } + if info.Mode()&os.ModeSymlink != 0 { + return DestinationPlan{}, newDestinationError(DestinationSymlink, target, nil) + } + if !info.IsDir() { + return DestinationPlan{}, newDestinationError(DestinationNotDirectory, target, nil) + } + + entries, err := os.ReadDir(target) + if err != nil { + return DestinationPlan{}, newDestinationError(DestinationInspection, target, err) + } + if len(entries) == 0 { + plan.Exists = true + return plan, nil + } + if !replace { + return DestinationPlan{}, newDestinationError(DestinationNotEmpty, target, nil) + } + if _, err := RecognizeBundle(target); err != nil { + return DestinationPlan{}, newDestinationError(DestinationUnrecognized, target, err) + } + plan.Exists = true + return plan, nil +} + +func absoluteCleanPath(value string) (string, error) { + if strings.TrimSpace(value) == "" { + return "", fmt.Errorf("path is required") + } + if !filepath.IsAbs(value) { + return "", fmt.Errorf("path %q must be absolute", value) + } + return filepath.Clean(value), nil +} + +func inspectMissingDestinationParent(target string) error { + for parent := filepath.Dir(target); ; parent = filepath.Dir(parent) { + info, err := os.Lstat(parent) + if err == nil { + if info.Mode()&os.ModeSymlink != 0 { + resolved, statErr := os.Stat(parent) + if statErr != nil { + return newDestinationError(DestinationInspection, target, statErr) + } + if !resolved.IsDir() { + return newDestinationError(DestinationNotDirectory, target, nil) + } + return nil + } + if !info.IsDir() { + return newDestinationError(DestinationNotDirectory, target, nil) + } + return nil + } + if !errors.Is(err, os.ErrNotExist) { + return newDestinationError(DestinationInspection, target, err) + } + if filepath.Dir(parent) == parent { + return newDestinationError(DestinationInspection, target, fmt.Errorf("no existing directory ancestor")) + } + } +} + +func newDestinationError(kind DestinationErrorKind, target string, err error) error { + return &DestinationError{Kind: kind, Target: target, Err: err} +} + +// RecognizeBundle verifies that directory contains exactly one valid current +// comparison bundle. It never follows bundle entries through symlinks. +func RecognizeBundle(directory string) (Manifest, error) { + info, err := os.Lstat(directory) + if err != nil { + return Manifest{}, unrecognizedBundleError("inspect directory", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return Manifest{}, unrecognizedBundleError("directory is not a real directory", nil) + } + + entries, err := os.ReadDir(directory) + if err != nil { + return Manifest{}, unrecognizedBundleError("read directory", err) + } + manifestData, err := readBundleFile(directory, ManifestFilename) + if err != nil { + return Manifest{}, err + } + manifest, err := decodeManifest(manifestData) + if err != nil { + return Manifest{}, err + } + if err := manifest.Validate(); err != nil { + return Manifest{}, unrecognizedBundleError("validate manifest", err) + } + + expected := map[string]struct{}{ + ManifestFilename: {}, + DataPackageFilename: {}, + } + for _, result := range manifest.Results { + if result.Status == StatusSucceeded { + expected[result.ReportPath] = struct{}{} + } + } + if len(entries) != len(expected) { + return Manifest{}, unrecognizedBundleError("directory entries do not match manifest", nil) + } + for _, entry := range entries { + if _, ok := expected[entry.Name()]; !ok { + return Manifest{}, unrecognizedBundleError("directory has an undeclared entry", nil) + } + if _, err := readBundleFile(directory, entry.Name()); err != nil { + return Manifest{}, err + } + } + + dataPackage, err := readBundleFile(directory, DataPackageFilename) + if err != nil { + return Manifest{}, err + } + if SHA256(dataPackage) != manifest.DataPackage.SHA256 { + return Manifest{}, unrecognizedBundleError("data package digest does not match manifest", nil) + } + return manifest, nil +} + +func readBundleFile(directory, name string) ([]byte, error) { + if !isArtifactBasename(name) { + return nil, unrecognizedBundleError("bundle entry name is unsafe", nil) + } + filePath := filepath.Join(directory, name) + info, err := os.Lstat(filePath) + if err != nil { + return nil, unrecognizedBundleError("inspect bundle entry", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, unrecognizedBundleError("bundle entry is not a regular file", nil) + } + data, err := os.ReadFile(filePath) + if err != nil { + return nil, unrecognizedBundleError("read bundle entry", err) + } + return data, nil +} + +func decodeManifest(data []byte) (Manifest, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var manifest Manifest + if err := decoder.Decode(&manifest); err != nil { + return Manifest{}, unrecognizedBundleError("decode manifest", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return Manifest{}, unrecognizedBundleError("manifest has multiple JSON values", nil) + } + return Manifest{}, unrecognizedBundleError("decode manifest", err) + } + return manifest, nil +} + +func unrecognizedBundleError(action string, err error) error { + if err == nil { + return fmt.Errorf("%w: %s", ErrUnrecognizedBundle, action) + } + return fmt.Errorf("%w: %s: %v", ErrUnrecognizedBundle, action, err) +} + +// Publish writes a complete logical bundle through a private sibling directory +// and atomically installs it at a preflighted destination. +func Publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle) error { + return publish(ctx, plan, bundle, publishOperations{rename: os.Rename}) +} + +type publishOperations struct { + rename func(string, string) error + beforeCommit func() +} + +func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, operations publishOperations) error { + if err := bundle.Validate(); err != nil { + return fmt.Errorf("validate comparison bundle: %w", err) + } + manifestData, err := EncodeManifest(bundle.Manifest) + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + plan, err = PlanDestination(plan.WorkingDirectory, plan.Target, plan.Replace) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(plan.Target), 0o755); err != nil { + return 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-") + if err != nil { + return fmt.Errorf("create comparison staging directory: %w", err) + } + if err := os.Chmod(temporaryDirectory, 0o700); err != nil { + os.RemoveAll(temporaryDirectory) + return fmt.Errorf("secure comparison staging directory: %w", err) + } + defer func() { + if temporaryDirectory != "" { + _ = os.RemoveAll(temporaryDirectory) + } + }() + + if err := writeLogicalBundle(ctx, temporaryDirectory, bundle, manifestData); err != nil { + return err + } + + currentPlan, err := PlanDestination(plan.WorkingDirectory, plan.Target, plan.Replace) + if err != nil { + return err + } + if operations.beforeCommit != nil { + operations.beforeCommit() + } + if err := ctx.Err(); err != nil { + return err + } + if !currentPlan.Exists { + if err := operations.rename(temporaryDirectory, currentPlan.Target); err != nil { + return fmt.Errorf("publish comparison bundle to %q: %w", currentPlan.Target, err) + } + temporaryDirectory = "" + return nil + } + + backupDirectory, err := uniqueSiblingPath(filepath.Dir(currentPlan.Target), "."+filepath.Base(currentPlan.Target)+".backup-") + if err != nil { + return err + } + if err := operations.rename(currentPlan.Target, backupDirectory); err != nil { + return fmt.Errorf("back up comparison destination %q: %w", currentPlan.Target, err) + } + if err := operations.rename(temporaryDirectory, currentPlan.Target); err != nil { + restoreErr := operations.rename(backupDirectory, currentPlan.Target) + if restoreErr != nil { + return errors.Join( + fmt.Errorf("replace comparison destination %q: %w", currentPlan.Target, err), + fmt.Errorf("restore prior comparison destination from %q: %w", backupDirectory, restoreErr), + ) + } + return fmt.Errorf("replace comparison destination %q: %w", currentPlan.Target, err) + } + temporaryDirectory = "" + if err := os.RemoveAll(backupDirectory); err != nil { + return fmt.Errorf("remove comparison backup %q: %w", backupDirectory, err) + } + return nil +} + +func writeLogicalBundle(ctx context.Context, directory string, bundle LogicalBundle, manifestData []byte) error { + if err := writeBundleFile(ctx, filepath.Join(directory, DataPackageFilename), bundle.DataPackage); err != nil { + return err + } + for _, report := range bundle.Reports { + if err := writeBundleFile(ctx, filepath.Join(directory, report.Path), report.Markdown); err != nil { + return err + } + } + if err := writeBundleFile(ctx, filepath.Join(directory, ManifestFilename), manifestData); err != nil { + return err + } + return nil +} + +func writeBundleFile(ctx context.Context, filePath string, content []byte) error { + if err := ctx.Err(); err != nil { + return err + } + file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return fmt.Errorf("create comparison bundle file %q: %w", filePath, err) + } + if _, err := file.Write(content); err != nil { + file.Close() + return fmt.Errorf("write comparison bundle file %q: %w", filePath, err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close comparison bundle file %q: %w", filePath, err) + } + return nil +} + +func uniqueSiblingPath(parent, prefix string) (string, error) { + file, err := os.CreateTemp(parent, prefix) + if err != nil { + return "", fmt.Errorf("reserve comparison backup path: %w", err) + } + path := file.Name() + if err := file.Close(); err != nil { + os.Remove(path) + return "", fmt.Errorf("close comparison backup reservation: %w", err) + } + if err := os.Remove(path); err != nil { + return "", fmt.Errorf("release comparison backup reservation: %w", err) + } + return path, nil +} diff --git a/internal/comparison/publish_test.go b/internal/comparison/publish_test.go new file mode 100644 index 0000000..c24b23c --- /dev/null +++ b/internal/comparison/publish_test.go @@ -0,0 +1,439 @@ +package comparison + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPlanDestination(t *testing.T) { + workingDirectory := t.TempDir() + absent := filepath.Join(workingDirectory, "absent") + plan, err := PlanDestination(workingDirectory, absent, false) + if err != nil { + t.Fatalf("PlanDestination() error = %v", err) + } + if plan.Target != absent || plan.Exists { + t.Fatalf("PlanDestination() = %#v, want absent target", plan) + } + nested := filepath.Join(workingDirectory, "missing-parent", "comparison") + if _, err := PlanDestination(workingDirectory, nested, false); err != nil { + t.Fatalf("PlanDestination(nested) error = %v", err) + } + if _, err := os.Stat(filepath.Dir(nested)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("read-only plan created parent: stat error = %v", err) + } + + empty := filepath.Join(workingDirectory, "empty") + if err := os.Mkdir(empty, 0o755); err != nil { + t.Fatal(err) + } + plan, err = PlanDestination(workingDirectory, empty, false) + if err != nil || !plan.Exists { + t.Fatalf("PlanDestination(empty) = %#v, %v", plan, err) + } + + file := filepath.Join(workingDirectory, "file") + if err := os.WriteFile(file, []byte("not a directory"), 0o600); err != nil { + t.Fatal(err) + } + assertDestinationErrorKind(t, workingDirectory, file, false, DestinationNotDirectory) + assertDestinationErrorKind(t, workingDirectory, workingDirectory, false, DestinationWorkingDirectory) + assertDestinationErrorKind(t, workingDirectory, string(os.PathSeparator), false, DestinationFilesystemRoot) + assertDestinationErrorKind(t, workingDirectory, "relative", false, DestinationInvalidPath) + + link := filepath.Join(workingDirectory, "link") + if err := os.Symlink(empty, link); err != nil { + t.Fatal(err) + } + assertDestinationErrorKind(t, workingDirectory, link, false, DestinationSymlink) + + dangling := filepath.Join(workingDirectory, "dangling") + if err := os.Symlink(filepath.Join(workingDirectory, "missing"), dangling); err != nil { + t.Fatal(err) + } + assertDestinationErrorKind(t, workingDirectory, dangling, false, DestinationSymlink) + assertDestinationErrorKind(t, workingDirectory, filepath.Join(dangling, "child"), false, DestinationInspection) + + nonempty := filepath.Join(workingDirectory, "nonempty") + if err := os.Mkdir(nonempty, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nonempty, "extra"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + assertDestinationErrorKind(t, workingDirectory, nonempty, false, DestinationNotEmpty) + assertDestinationErrorKind(t, workingDirectory, nonempty, true, DestinationUnrecognized) +} + +func TestPlanDestinationRejectsUnreadableDirectory(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can inspect directories regardless of mode") + } + workingDirectory := t.TempDir() + target := filepath.Join(workingDirectory, "unreadable") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(target, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(target, 0o700) }) + assertDestinationErrorKind(t, workingDirectory, target, false, DestinationInspection) +} + +func TestRecognizeBundleRejectsUnsafeAndMalformedContents(t *testing.T) { + tests := []struct { + name string + mutate func(t *testing.T, directory string) + }{ + {name: "extra file", mutate: func(t *testing.T, directory string) { + t.Helper() + if err := os.WriteFile(filepath.Join(directory, "extra.txt"), []byte("extra"), 0o600); err != nil { + t.Fatal(err) + } + }}, + {name: "subdirectory", mutate: func(t *testing.T, directory string) { + t.Helper() + if err := os.Mkdir(filepath.Join(directory, "nested"), 0o700); err != nil { + t.Fatal(err) + } + }}, + {name: "missing report", mutate: func(t *testing.T, directory string) { + t.Helper() + if err := os.Remove(filepath.Join(directory, "01-weather-light.md")); err != nil { + t.Fatal(err) + } + }}, + {name: "symlink report", mutate: func(t *testing.T, directory string) { + t.Helper() + path := filepath.Join(directory, "01-weather-light.md") + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(directory, DataPackageFilename), path); err != nil { + t.Fatal(err) + } + }}, + {name: "digest mismatch", mutate: func(t *testing.T, directory string) { + t.Helper() + if err := os.WriteFile(filepath.Join(directory, DataPackageFilename), []byte("altered"), 0o600); err != nil { + t.Fatal(err) + } + }}, + {name: "unknown manifest field", mutate: func(t *testing.T, directory string) { + t.Helper() + path := filepath.Join(directory, ManifestFilename) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + data = append(data[:len(data)-2], []byte(",\n \"unknown\": true\n}\n")...) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + }}, + {name: "traversal report path", mutate: func(t *testing.T, directory string) { + t.Helper() + path := filepath.Join(directory, ManifestFilename) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var manifest Manifest + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatal(err) + } + manifest.Results[0].ReportPath = "../outside.md" + data, err = json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + directory := publishTestBundle(t, testBundle()) + test.mutate(t, directory) + if _, err := RecognizeBundle(directory); !errors.Is(err, ErrUnrecognizedBundle) { + t.Fatalf("RecognizeBundle() error = %v, want ErrUnrecognizedBundle", err) + } + }) + } +} + +func TestPlanDestinationAcceptsRecognizedReplacement(t *testing.T) { + directory := publishTestBundle(t, testBundle()) + plan, err := PlanDestination(filepath.Dir(directory), directory, true) + if err != nil { + t.Fatalf("PlanDestination() error = %v", err) + } + if !plan.Exists { + t.Fatal("PlanDestination() did not record recognized existing destination") + } +} + +func TestPublishWritesAndReplacesBundle(t *testing.T) { + workingDirectory := t.TempDir() + target := filepath.Join(workingDirectory, "comparison-daily") + plan, err := PlanDestination(workingDirectory, target, false) + if err != nil { + t.Fatal(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) + } + assertMode(t, target, 0o700) + for _, name := range []string{ManifestFilename, DataPackageFilename, "01-weather-light.md"} { + assertMode(t, filepath.Join(target, name), 0o600) + } + + next := testBundle() + next.Manifest.Results[0].ProfileID = "weather-balanced" + next.Manifest.Results[0].ReportPath = "01-weather-balanced.md" + next.Reports[0].Path = "01-weather-balanced.md" + next.Reports[0].Markdown = []byte("# Replacement\n") + plan, err = PlanDestination(workingDirectory, target, true) + if err != nil { + t.Fatal(err) + } + if err := Publish(context.Background(), plan, next); err != nil { + t.Fatalf("Publish(replace) error = %v", err) + } + if _, err := os.Stat(filepath.Join(target, "01-weather-light.md")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("stale report stat error = %v, want not exist", err) + } + if data, err := os.ReadFile(filepath.Join(target, "01-weather-balanced.md")); err != nil || string(data) != "# Replacement\n" { + t.Fatalf("replacement report = %q, %v", data, err) + } +} + +func TestPublishReplacesEmptyDirectory(t *testing.T) { + workingDirectory := t.TempDir() + target := filepath.Join(workingDirectory, "comparison-daily") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + plan, err := PlanDestination(workingDirectory, target, false) + if err != nil { + t.Fatal(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) + } +} + +func TestPublishRestoresExistingBundleAfterReplacementFailure(t *testing.T) { + workingDirectory := t.TempDir() + target := filepath.Join(workingDirectory, "comparison-daily") + initial := testBundle() + initialPlan, err := PlanDestination(workingDirectory, target, false) + if err != nil { + t.Fatal(err) + } + if err := Publish(context.Background(), initialPlan, initial); err != nil { + t.Fatal(err) + } + before := directorySnapshot(t, target) + + next := testBundle() + next.Reports[0].Markdown = []byte("# New\n") + plan, err := PlanDestination(workingDirectory, target, true) + if err != nil { + t.Fatal(err) + } + calls := 0 + err = publish(context.Background(), plan, next, publishOperations{rename: func(oldPath, newPath string) error { + calls++ + if calls == 2 { + return errors.New("replace failed") + } + return os.Rename(oldPath, newPath) + }}) + if err == nil { + t.Fatal("publish() succeeded despite replacement failure") + } + after := directorySnapshot(t, target) + if !equalSnapshots(before, after) { + t.Fatal("failed publication changed the prior bundle") + } + entries, err := os.ReadDir(workingDirectory) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != filepath.Base(target) { + t.Fatalf("failed publication left sibling artifacts: %#v", entries) + } +} + +func TestPublishRetainsBackupWhenRestorationFails(t *testing.T) { + workingDirectory := t.TempDir() + target := filepath.Join(workingDirectory, "comparison-daily") + initialPlan, err := PlanDestination(workingDirectory, target, false) + if err != nil { + t.Fatal(err) + } + if err := Publish(context.Background(), initialPlan, testBundle()); err != nil { + t.Fatal(err) + } + plan, err := PlanDestination(workingDirectory, target, true) + if err != nil { + t.Fatal(err) + } + var backupPath string + calls := 0 + err = publish(context.Background(), plan, testBundle(), publishOperations{rename: func(oldPath, newPath string) error { + calls++ + if calls == 1 { + backupPath = newPath + } + if calls == 2 || calls == 3 { + return errors.New("rename failed") + } + return os.Rename(oldPath, newPath) + }}) + if err == nil || !strings.Contains(err.Error(), backupPath) { + t.Fatalf("publish() error = %v, want retained backup path %q", err, backupPath) + } + if info, statErr := os.Stat(backupPath); statErr != nil || !info.IsDir() { + t.Fatalf("backup stat = %v, %v, want retained directory", info, statErr) + } +} + +func TestPublishCancellationBeforeCommitLeavesNoDestination(t *testing.T) { + workingDirectory := t.TempDir() + target := filepath.Join(workingDirectory, "comparison-daily") + plan, err := PlanDestination(workingDirectory, target, false) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + if err := publish(ctx, plan, testBundle(), publishOperations{rename: os.Rename, beforeCommit: cancel}); !errors.Is(err, context.Canceled) { + t.Fatalf("Publish() error = %v, want context cancellation", err) + } + if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("destination stat error = %v, want not exist", err) + } +} + +func assertDestinationErrorKind(t *testing.T, workingDirectory, target string, replace bool, want DestinationErrorKind) { + t.Helper() + _, err := PlanDestination(workingDirectory, target, replace) + var destinationError *DestinationError + if !errors.As(err, &destinationError) || destinationError.Kind != want { + t.Fatalf("PlanDestination(%q) error = %v, want destination error %q", target, err, want) + } +} + +func publishTestBundle(t *testing.T, bundle LogicalBundle) string { + t.Helper() + workingDirectory := t.TempDir() + target := filepath.Join(workingDirectory, "comparison-daily") + plan, err := PlanDestination(workingDirectory, target, false) + if err != nil { + t.Fatal(err) + } + if err := Publish(context.Background(), plan, bundle); err != nil { + t.Fatal(err) + } + return target +} + +func testBundle() LogicalBundle { + dataPackage := []byte("report: daily\n") + manifest := validManifest() + manifest.DataPackage.SHA256 = SHA256(dataPackage) + return LogicalBundle{ + Manifest: manifest, + DataPackage: dataPackage, + Reports: []BundleReport{{ + Position: 1, + Path: "01-weather-light.md", + Markdown: []byte("# Daily\n"), + }}, + } +} + +func assertMode(t *testing.T, path string, want os.FileMode) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != want { + t.Fatalf("mode for %q = %#o, want %#o", path, got, want) + } +} + +func directorySnapshot(t *testing.T, directory string) map[string]string { + t.Helper() + entries, err := os.ReadDir(directory) + if err != nil { + t.Fatal(err) + } + snapshot := make(map[string]string, len(entries)) + for _, entry := range entries { + data, err := os.ReadFile(filepath.Join(directory, entry.Name())) + if err != nil { + t.Fatal(err) + } + snapshot[entry.Name()] = string(data) + } + return snapshot +} + +func equalSnapshots(left, right map[string]string) bool { + if len(left) != len(right) { + return false + } + for name, value := range left { + if right[name] != value { + return false + } + } + return true +} + +func TestRecognizeBundleRejectsTrailingJSONValue(t *testing.T) { + directory := publishTestBundle(t, testBundle()) + path := filepath.Join(directory, ManifestFilename) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(data, []byte("{}")...), 0o600); err != nil { + t.Fatal(err) + } + if _, err := RecognizeBundle(directory); !errors.Is(err, ErrUnrecognizedBundle) { + t.Fatalf("RecognizeBundle() error = %v, want ErrUnrecognizedBundle", err) + } +} + +func TestPlanDestinationRejectsWhitespaceTarget(t *testing.T) { + workingDirectory := t.TempDir() + assertDestinationErrorKind(t, workingDirectory, " \t", false, DestinationInvalidPath) +} + +func TestRecognizeBundleDoesNotAcceptNonRegularManifest(t *testing.T) { + directory := t.TempDir() + if err := os.Mkdir(filepath.Join(directory, ManifestFilename), 0o700); err != nil { + t.Fatal(err) + } + if _, err := RecognizeBundle(directory); !strings.Contains(err.Error(), "regular file") { + t.Fatalf("RecognizeBundle() error = %v, want regular file rejection", err) + } +}