Publish comparison bundles safely
This commit is contained in:
439
internal/comparison/publish_test.go
Normal file
439
internal/comparison/publish_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user