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" maxComparisonComponentBytes = 255 temporaryRandomBytes = 10 stagingDirectoryPattern = ".weatherreporter-staging-*" backupDirectoryPattern = ".weatherreporter-backup-*" ) // 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 state destinationState } type destinationState uint8 const ( destinationAbsent destinationState = iota destinationEmpty destinationBundle ) // 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) { return planDestination(workingDirectory, target, replace, RecognizeBundle) } func planDestination(workingDirectory, target string, replace bool, recognize func(string) (Manifest, error)) (DestinationPlan, error) { plan, err := newDestinationPlan(workingDirectory, target, replace) if err != nil { return DestinationPlan{}, err } return inspectDestination(plan, recognize) } func newDestinationPlan(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) } if err := validateTransactionSiblingNames(target); err != nil { return DestinationPlan{}, newDestinationError(DestinationInvalidPath, target, err) } return DestinationPlan{WorkingDirectory: workingDirectory, Target: target, Replace: replace}, nil } func reauthorizeDestination(plan DestinationPlan) (DestinationPlan, error) { plan, err := newDestinationPlan(plan.WorkingDirectory, plan.Target, plan.Replace) if err != nil { return DestinationPlan{}, err } return inspectDestination(plan, nil) } func inspectDestination(plan DestinationPlan, recognize func(string) (Manifest, error)) (DestinationPlan, error) { target := plan.Target 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.state = destinationEmpty return plan, nil } if !plan.Replace { return DestinationPlan{}, newDestinationError(DestinationNotEmpty, target, nil) } if recognize != nil { if _, err := recognize(target); err != nil { return DestinationPlan{}, newDestinationError(DestinationUnrecognized, target, err) } } plan.state = destinationBundle return plan, nil } func validateTransactionSiblingNames(target string) error { if len(filepath.Base(target)) > maxComparisonComponentBytes { return fmt.Errorf("destination name exceeds the %d-byte limit", maxComparisonComponentBytes) } for _, pattern := range []string{stagingDirectoryPattern, backupDirectoryPattern} { if !temporaryPatternFitsComponentLimit(pattern) { return fmt.Errorf("comparison transaction sibling pattern exceeds the %d-byte limit", maxComparisonComponentBytes) } } return nil } func temporaryPatternFitsComponentLimit(pattern string) bool { return len(strings.Replace(pattern, "*", strings.Repeat("0", temporaryRandomBytes), 1)) <= maxComparisonComponentBytes } func absoluteCleanPath(value string) (string, error) { 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) { return recognizeBundle(directory, defaultRecognitionOperations) } type recognitionOperations struct { lstat func(string) (os.FileInfo, error) readDir func(string) ([]os.DirEntry, error) open func(string) (io.ReadCloser, error) } var defaultRecognitionOperations = recognitionOperations{ lstat: os.Lstat, readDir: os.ReadDir, open: func(path string) (io.ReadCloser, error) { return os.Open(path) }, } func recognizeBundle(directory string, operations recognitionOperations) (Manifest, error) { info, err := operations.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 := operations.readDir(directory) if err != nil { return Manifest{}, unrecognizedBundleError("read directory", err) } manifestData, err := readBundleFile(directory, ManifestFilename, operations) 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 entry.Name() == ManifestFilename || entry.Name() == DataPackageFilename { continue } if err := inspectBundleFile(directory, entry.Name(), operations); err != nil { return Manifest{}, err } } dataPackage, err := readBundleFile(directory, DataPackageFilename, operations) 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, operations recognitionOperations) ([]byte, error) { file, err := openBundleFile(directory, name, operations) if err != nil { return nil, err } defer file.Close() data, err := io.ReadAll(file) if err != nil { return nil, unrecognizedBundleError("read bundle entry", err) } return data, nil } func inspectBundleFile(directory, name string, operations recognitionOperations) error { file, err := openBundleFile(directory, name, operations) if err != nil { return err } if err := file.Close(); err != nil { return unrecognizedBundleError("close bundle entry", err) } return nil } func openBundleFile(directory, name string, operations recognitionOperations) (io.ReadCloser, error) { if !isArtifactBasename(name) { return nil, unrecognizedBundleError("bundle entry name is unsafe", nil) } filePath := filepath.Join(directory, name) info, err := operations.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) } file, err := operations.open(filePath) if err != nil { return nil, unrecognizedBundleError("read bundle entry", err) } return file, nil } func decodeManifest(data []byte) (Manifest, error) { if err := validateManifestJSON(data); err != nil { return Manifest{}, unrecognizedBundleError("decode manifest", err) } 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 } var manifestFields = map[string]jsonValueValidator{ "schemaVersion": nil, "comparisonId": nil, "startedAt": nil, "finishedAt": nil, "reportId": nil, "validPeriod": validateValidPeriodJSON, "timezone": nil, "promptId": nil, "promptVersion": nil, "promptHash": nil, "dataPackage": validateDataPackageJSON, "total": nil, "succeeded": nil, "failed": nil, "results": validateResultsJSON, } var validPeriodFields = map[string]jsonValueValidator{ "start": nil, "end": nil, } var dataPackageFields = map[string]jsonValueValidator{ "path": nil, "sha256": nil, } var resultFields = map[string]jsonValueValidator{ "position": nil, "profileId": nil, "backendId": nil, "modelName": nil, "status": nil, "validationStatus": nil, "repairAttempts": validateRepairAttemptsJSON, "reportPath": nil, "error": validateSafeErrorJSON, } var safeErrorFields = map[string]jsonValueValidator{ "category": nil, "message": nil, } type jsonValueValidator func(*json.Decoder) error func validateManifestJSON(data []byte) error { decoder := json.NewDecoder(bytes.NewReader(data)) if err := validateJSONObject(decoder, manifestFields); err != nil { return err } if _, err := decoder.Token(); err != io.EOF { if err == nil { return fmt.Errorf("manifest has multiple JSON values") } return err } return nil } func validateValidPeriodJSON(decoder *json.Decoder) error { return validateJSONObject(decoder, validPeriodFields) } func validateDataPackageJSON(decoder *json.Decoder) error { return validateJSONObject(decoder, dataPackageFields) } func validateResultsJSON(decoder *json.Decoder) error { token, err := decoder.Token() if err != nil { return err } if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' { return fmt.Errorf("results must be an array") } for decoder.More() { if err := validateOrderedJSONObject(decoder, resultFields, []string{"position", "profileId", "backendId", "modelName", "status", "validationStatus", "repairAttempts", "reportPath", "error"}); err != nil { return err } } token, err = decoder.Token() if err != nil { return err } if delimiter, ok := token.(json.Delim); !ok || delimiter != ']' { return fmt.Errorf("results has an invalid array terminator") } return nil } func validateOrderedJSONObject(decoder *json.Decoder, fields map[string]jsonValueValidator, order []string) error { token, err := decoder.Token() if err != nil { return err } if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' { return fmt.Errorf("manifest value must be an object") } seen := make(map[string]struct{}, len(fields)) last := -1 for decoder.More() { token, err := decoder.Token() if err != nil { return err } name, ok := token.(string) if !ok { return fmt.Errorf("manifest field name is invalid") } validator, known := fields[name] if !known { return fmt.Errorf("manifest field %q is not canonical", name) } if _, duplicate := seen[name]; duplicate { return fmt.Errorf("manifest field %q is duplicated", name) } position := -1 for index, candidate := range order { if candidate == name { position = index break } } if position <= last { return fmt.Errorf("manifest field %q is out of canonical order", name) } seen[name] = struct{}{} last = position if validator == nil { var value json.RawMessage if err := decoder.Decode(&value); err != nil { return err } continue } if err := validator(decoder); err != nil { return err } } token, err = decoder.Token() if err != nil { return err } if delimiter, ok := token.(json.Delim); !ok || delimiter != '}' { return fmt.Errorf("manifest object has an invalid terminator") } return nil } func validateSafeErrorJSON(decoder *json.Decoder) error { token, err := decoder.Token() if err != nil { return err } if token == nil { return nil } if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' { return fmt.Errorf("error must be an object") } return validateJSONObjectBody(decoder, safeErrorFields) } func validateRepairAttemptsJSON(decoder *json.Decoder) error { var value int if err := decoder.Decode(&value); err != nil { return err } if value < 0 { return fmt.Errorf("repairAttempts must not be negative") } return nil } func validateJSONObject(decoder *json.Decoder, fields map[string]jsonValueValidator) error { token, err := decoder.Token() if err != nil { return err } if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' { return fmt.Errorf("manifest value must be an object") } return validateJSONObjectBody(decoder, fields) } func validateJSONObjectBody(decoder *json.Decoder, fields map[string]jsonValueValidator) error { seen := make(map[string]struct{}, len(fields)) for decoder.More() { token, err := decoder.Token() if err != nil { return err } name, ok := token.(string) if !ok { return fmt.Errorf("manifest field name is invalid") } validator, known := fields[name] if !known { return fmt.Errorf("manifest field %q is not canonical", name) } if _, duplicate := seen[name]; duplicate { return fmt.Errorf("manifest field %q is duplicated", name) } seen[name] = struct{}{} if validator == nil { var value json.RawMessage if err := decoder.Decode(&value); err != nil { return err } continue } if err := validator(decoder); err != nil { return err } } token, err := decoder.Token() if err != nil { return err } if delimiter, ok := token.(json.Delim); !ok || delimiter != '}' { return fmt.Errorf("manifest object has an invalid terminator") } return 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) } // BackupRecoveryState describes what remains of a prior bundle after cleanup // reports an error. type BackupRecoveryState string const ( BackupRecoveryComplete BackupRecoveryState = "complete" BackupRecoveryPartial BackupRecoveryState = "partial" BackupRecoveryAbsent BackupRecoveryState = "absent" BackupRecoveryUnknown BackupRecoveryState = "unknown" ) // PublicationResult describes the durable state of a publication attempt. type PublicationResult struct { Committed bool RecoveryState BackupRecoveryState RecoveryPath string } // PublicationCleanupError reports that a committed bundle could not completely // remove its prior sibling backup. The new bundle remains installed; recovery // fields describe the state observed after cleanup failed. type PublicationCleanupError struct { RecoveryState BackupRecoveryState RecoveryPath string Err error } func (err *PublicationCleanupError) Error() string { switch err.RecoveryState { case BackupRecoveryComplete: return fmt.Sprintf("remove comparison backup %q: %v; complete recovery bundle remains", err.RecoveryPath, err.Err) case BackupRecoveryPartial: return fmt.Sprintf("remove comparison backup %q: %v; partial remnants remain", err.RecoveryPath, err.Err) case BackupRecoveryAbsent: return fmt.Sprintf("remove comparison backup: %v; no recovery bundle remains", err.Err) default: return fmt.Sprintf("remove comparison backup: %v; recovery state is unknown", err.Err) } } func (err *PublicationCleanupError) Unwrap() error { return err.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) (PublicationResult, error) { return publish(ctx, plan, bundle, publishOperations{rename: os.Rename, removeAll: os.RemoveAll}) } type publishOperations struct { rename func(string, string) error removeAll func(string) error beforeCommit func() recognize func(string) (Manifest, error) } func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, operations publishOperations) (PublicationResult, error) { if operations.rename == nil { operations.rename = os.Rename } if operations.removeAll == nil { operations.removeAll = os.RemoveAll } if operations.recognize == nil { operations.recognize = RecognizeBundle } if err := bundle.Validate(); err != nil { return PublicationResult{}, fmt.Errorf("validate comparison bundle: %w", err) } manifestData, err := EncodeManifest(bundle.Manifest) if err != nil { return PublicationResult{}, err } if err := ctx.Err(); err != nil { return PublicationResult{}, err } plan, err = reauthorizeDestination(plan) if err != nil { return PublicationResult{}, err } if err := os.MkdirAll(filepath.Dir(plan.Target), 0o755); err != nil { return PublicationResult{}, fmt.Errorf("create comparison destination parent %q: %w", filepath.Dir(plan.Target), err) } temporaryDirectory, err := os.MkdirTemp(filepath.Dir(plan.Target), stagingDirectoryPattern) if err != nil { return PublicationResult{}, fmt.Errorf("create comparison staging directory: %w", err) } if err := os.Chmod(temporaryDirectory, 0o700); err != nil { os.RemoveAll(temporaryDirectory) return PublicationResult{}, 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 PublicationResult{}, err } currentPlan, err := reauthorizeDestination(plan) if err != nil { return PublicationResult{}, err } if operations.beforeCommit != nil { operations.beforeCommit() } if err := ctx.Err(); err != nil { return PublicationResult{}, err } if currentPlan.state == destinationAbsent { if err := operations.rename(temporaryDirectory, currentPlan.Target); err != nil { return PublicationResult{}, fmt.Errorf("publish comparison bundle to %q: %w", currentPlan.Target, err) } temporaryDirectory = "" return PublicationResult{Committed: true}, nil } backupDirectory, err := uniqueSiblingPath(filepath.Dir(currentPlan.Target), backupDirectoryPattern) if err != nil { return PublicationResult{}, err } if err := operations.rename(currentPlan.Target, backupDirectory); err != nil { return PublicationResult{}, fmt.Errorf("back up comparison destination %q: %w", currentPlan.Target, err) } if err := authorizeMovedDestination(currentPlan, backupDirectory, operations.recognize); err != nil { return PublicationResult{}, restoreMovedDestination(operations, backupDirectory, currentPlan.Target, err) } if err := ctx.Err(); err != nil { return PublicationResult{}, restoreMovedDestination(operations, backupDirectory, currentPlan.Target, err) } if err := operations.rename(temporaryDirectory, currentPlan.Target); err != nil { return PublicationResult{}, restoreMovedDestination(operations, backupDirectory, currentPlan.Target, fmt.Errorf("replace comparison destination %q: %w", currentPlan.Target, err)) } temporaryDirectory = "" if err := operations.removeAll(backupDirectory); err != nil { recoveryState, recoveryPath := inspectBackupRecovery(backupDirectory) cleanupErr := &PublicationCleanupError{RecoveryState: recoveryState, RecoveryPath: recoveryPath, Err: err} return PublicationResult{Committed: true, RecoveryState: recoveryState, RecoveryPath: recoveryPath}, cleanupErr } return PublicationResult{Committed: true}, nil } func inspectBackupRecovery(backupDirectory string) (BackupRecoveryState, string) { if _, err := os.Lstat(backupDirectory); err != nil { if errors.Is(err, os.ErrNotExist) { return BackupRecoveryAbsent, "" } return BackupRecoveryUnknown, "" } if _, err := RecognizeBundle(backupDirectory); err == nil { return BackupRecoveryComplete, backupDirectory } return BackupRecoveryPartial, backupDirectory } func authorizeMovedDestination(plan DestinationPlan, backupDirectory string, recognize func(string) (Manifest, error)) error { backupPlan, err := planDestination(plan.WorkingDirectory, backupDirectory, plan.Replace, recognize) if err != nil { return fmt.Errorf("authorize moved comparison destination %q: %w", backupDirectory, err) } if backupPlan.state != destinationEmpty && backupPlan.state != destinationBundle { return fmt.Errorf("authorize moved comparison destination %q: destination disappeared", backupDirectory) } return nil } func restoreMovedDestination(operations publishOperations, backupDirectory, target string, cause error) error { if _, err := os.Lstat(target); err == nil { return errors.Join( cause, fmt.Errorf("restore prior comparison destination from %q: destination %q reappeared", backupDirectory, target), ) } else if !errors.Is(err, os.ErrNotExist) { return errors.Join( cause, fmt.Errorf("inspect comparison destination %q before restoring from %q: %w", target, backupDirectory, err), ) } if restoreErr := operations.rename(backupDirectory, target); restoreErr != nil { return errors.Join( cause, fmt.Errorf("restore prior comparison destination from %q: %w", backupDirectory, restoreErr), ) } return cause } 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 }