Publish comparison bundles safely
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,6 +52,35 @@ func resolveOutputDirWithConfigured(workingDir, override, configuredDir string)
|
|||||||
return resolveOutputDir(workingDir, directory)
|
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) {
|
func resolveOutputDir(workingDir, override string) (string, error) {
|
||||||
workingDir, err := validateWorkingDir(workingDir)
|
workingDir, err := validateWorkingDir(workingDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -6,6 +6,43 @@ import (
|
|||||||
"testing"
|
"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) {
|
func TestResolveOutputDirRejectsDanglingSymlinkComponents(t *testing.T) {
|
||||||
workingDir := t.TempDir()
|
workingDir := t.TempDir()
|
||||||
dangling := filepath.Join(workingDir, "dangling")
|
dangling := filepath.Join(workingDir, "dangling")
|
||||||
|
|||||||
394
internal/comparison/publish.go
Normal file
394
internal/comparison/publish.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
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