Publish comparison bundles safely
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user