package notarius import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) const ( maxReceiptBytes = 1 << 20 maxIndexBytes = 4 << 20 maxSummaryBytes = 4 << 20 canonicalIndexFile = "index.json" canonicalManifestFile = "manifest.json" canonicalRejectedFile = "rejected.json" canonicalWarningsFile = "warnings.json" ) type subprocessRun func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error) // SubprocessRunner invokes Notarius through its public CLI. type SubprocessRunner struct { run subprocessRun } // NewSubprocessRunner constructs a production Notarius subprocess runner. func NewSubprocessRunner() *SubprocessRunner { return &SubprocessRunner{run: subprocess.Run} } // Run executes a complete Notarius pipeline and discovers its published bundle. func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult, error) { if r == nil || r.run == nil { return RunResult{}, fmt.Errorf("notarius subprocess runner is nil") } if err := validateRunRequest(req); err != nil { return RunResult{}, err } args := []string{ "run", req.PipelineID, "--config", req.ConfigPath, "--input", req.InputPath, "--output-dir", req.OutputRoot, "--json", } processResult, err := r.run(ctx, subprocess.RunRequest{ Executable: req.Binary, Args: args, WorkingDir: req.WorkingDirectory, Timeout: req.Timeout, StdoutLogPath: req.ReceiptPath, StderrLogPath: req.LogPath, }) baseResult := RunResult{ ReceiptPath: req.ReceiptPath, LogPath: req.LogPath, ExitCode: processResult.ExitCode, Duration: processResult.Duration, } if err != nil { return baseResult, fmt.Errorf("run notarius pipeline %q: %w", req.PipelineID, err) } receipt, err := loadReceipt(req.ReceiptPath, req.PipelineID) if err != nil { return baseResult, err } bundleRoot, err := validateBundleRoot(req.OutputRoot, receipt.OutputDirectory) if err != nil { return baseResult, err } indexPath, err := resolveRegularFile(bundleRoot, receipt.IndexFile) if err != nil { return baseResult, fmt.Errorf("resolve receipt index file: %w", err) } index, err := loadIndex(bundleRoot, indexPath) if err != nil { return baseResult, err } rejections, err := loadRejections(index.RejectedPath) if err != nil { return baseResult, err } warnings, err := loadWarnings(index.WarningsPath) if err != nil { return baseResult, err } baseResult.Receipt = receipt baseResult.Index = index baseResult.BundleRoot = bundleRoot baseResult.Rejections = rejections baseResult.Warnings = warnings return baseResult, nil } func validateRunRequest(req RunRequest) error { if strings.TrimSpace(req.Binary) == "" { return fmt.Errorf("notarius binary is required") } if strings.TrimSpace(req.PipelineID) == "" { return fmt.Errorf("notarius pipeline id is required") } if req.Timeout <= 0 { return fmt.Errorf("notarius timeout must be positive") } for label, path := range map[string]string{ "config": req.ConfigPath, "input": req.InputPath, "output root": req.OutputRoot, "working directory": req.WorkingDirectory, "receipt": req.ReceiptPath, "log": req.LogPath, } { if strings.TrimSpace(path) == "" { return fmt.Errorf("notarius %s path is required", label) } if !filepath.IsAbs(path) { return fmt.Errorf("notarius %s path must be absolute", label) } } if filepath.Clean(req.ReceiptPath) == filepath.Clean(req.LogPath) { return fmt.Errorf("notarius receipt and log paths must be different") } if err := requireRegularFile(req.ConfigPath); err != nil { return fmt.Errorf("validate notarius config path: %w", err) } if err := requireRegularFile(req.InputPath); err != nil { return fmt.Errorf("validate notarius input path: %w", err) } if err := requireDirectory(req.OutputRoot); err != nil { return fmt.Errorf("validate notarius output root: %w", err) } if err := requireDirectory(req.WorkingDirectory); err != nil { return fmt.Errorf("validate notarius working directory: %w", err) } if err := validateLogDestination(req.ReceiptPath); err != nil { return fmt.Errorf("validate notarius receipt path: %w", err) } if err := validateLogDestination(req.LogPath); err != nil { return fmt.Errorf("validate notarius log path: %w", err) } return nil } type receiptDocument struct { SchemaVersion string `json:"schema_version"` RunID string `json:"run_id"` PipelineID string `json:"pipeline_id"` OutputDirectory string `json:"output_directory"` IndexFile string `json:"index_file"` NormalizedOutputCount *int `json:"normalized_output_count"` RejectedOutputCount *int `json:"rejected_output_count"` WarningCount *int `json:"warning_count"` ValidationStatus string `json:"validation_status"` DebugDirectory string `json:"debug_directory"` } func loadReceipt(path, pipelineID string) (Receipt, error) { var document receiptDocument if err := decodeBoundedJSON(path, maxReceiptBytes, &document); err != nil { return Receipt{}, fmt.Errorf("decode notarius receipt: %w", err) } if document.SchemaVersion != ReceiptSchemaVersion { return Receipt{}, fmt.Errorf("unsupported notarius receipt schema version %q", document.SchemaVersion) } if strings.TrimSpace(document.RunID) == "" || strings.TrimSpace(document.PipelineID) == "" || strings.TrimSpace(document.OutputDirectory) == "" || strings.TrimSpace(document.ValidationStatus) == "" || document.NormalizedOutputCount == nil || document.RejectedOutputCount == nil || document.WarningCount == nil { return Receipt{}, fmt.Errorf("notarius receipt is missing required fields") } if document.IndexFile != canonicalIndexFile { return Receipt{}, fmt.Errorf("notarius receipt index_file %q is incompatible; want %q", document.IndexFile, canonicalIndexFile) } if document.PipelineID != pipelineID { return Receipt{}, fmt.Errorf("notarius receipt pipeline id %q does not match requested pipeline %q", document.PipelineID, pipelineID) } if *document.NormalizedOutputCount < 0 || *document.RejectedOutputCount < 0 || *document.WarningCount < 0 { return Receipt{}, fmt.Errorf("notarius receipt counts must be non-negative") } if !filepath.IsAbs(document.OutputDirectory) { return Receipt{}, fmt.Errorf("notarius receipt output directory must be absolute") } if document.DebugDirectory != "" && !filepath.IsAbs(document.DebugDirectory) { return Receipt{}, fmt.Errorf("notarius receipt debug directory must be absolute when present") } return Receipt{ SchemaVersion: document.SchemaVersion, RunID: document.RunID, PipelineID: document.PipelineID, OutputDirectory: filepath.Clean(document.OutputDirectory), IndexFile: document.IndexFile, NormalizedOutputCount: *document.NormalizedOutputCount, RejectedOutputCount: *document.RejectedOutputCount, WarningCount: *document.WarningCount, ValidationStatus: document.ValidationStatus, DebugDirectory: document.DebugDirectory, }, nil } type indexDocument struct { ManifestFile string `json:"manifest_file"` OutputFiles *[]laneDocument `json:"output_files"` RejectedFile string `json:"rejected_file"` WarningsFile string `json:"warnings_file"` ChunkMap *pipelineDocument `json:"chunk_map"` EvidenceContext *pipelineDocument `json:"evidence_context"` } type laneDocument struct { LaneID string `json:"lane_id"` File string `json:"file"` MediaType string `json:"media_type"` ModuleKey string `json:"module_key"` SchemaID string `json:"schema_id"` SchemaName string `json:"schema_name"` SchemaVersion string `json:"schema_version"` } type pipelineDocument struct { ArtifactKind string `json:"artifact_kind"` File string `json:"file"` MediaType string `json:"media_type"` SchemaID string `json:"schema_id"` SchemaName string `json:"schema_name"` SchemaVersion string `json:"schema_version"` } func loadIndex(bundleRoot, indexPath string) (Index, error) { var document indexDocument if err := decodeBoundedJSON(indexPath, maxIndexBytes, &document); err != nil { return Index{}, fmt.Errorf("decode notarius index: %w", err) } for _, field := range []struct { name string got string want string }{ {name: "manifest_file", got: document.ManifestFile, want: canonicalManifestFile}, {name: "rejected_file", got: document.RejectedFile, want: canonicalRejectedFile}, {name: "warnings_file", got: document.WarningsFile, want: canonicalWarningsFile}, } { if field.got != field.want { return Index{}, fmt.Errorf("notarius index %s %q is incompatible; want %q", field.name, field.got, field.want) } } if document.OutputFiles == nil { return Index{}, fmt.Errorf("notarius index is missing required output_files") } index := Index{ Path: indexPath, ManifestFile: document.ManifestFile, RejectedFile: document.RejectedFile, WarningsFile: document.WarningsFile, } var err error if index.ManifestPath, err = resolveRegularFile(bundleRoot, index.ManifestFile); err != nil { return Index{}, fmt.Errorf("resolve notarius manifest file: %w", err) } if index.RejectedPath, err = resolveRegularFile(bundleRoot, index.RejectedFile); err != nil { return Index{}, fmt.Errorf("resolve notarius rejection file: %w", err) } if index.WarningsPath, err = resolveRegularFile(bundleRoot, index.WarningsFile); err != nil { return Index{}, fmt.Errorf("resolve notarius warning file: %w", err) } seenLanes := make(map[string]struct{}, len(*document.OutputFiles)) for _, lane := range *document.OutputFiles { if strings.TrimSpace(lane.LaneID) == "" || strings.TrimSpace(lane.File) == "" { return Index{}, fmt.Errorf("notarius lane descriptors require lane_id and file") } if _, exists := seenLanes[lane.LaneID]; exists { return Index{}, fmt.Errorf("notarius index contains duplicate lane id %q", lane.LaneID) } seenLanes[lane.LaneID] = struct{}{} path, err := resolveRegularFile(bundleRoot, lane.File) if err != nil { return Index{}, fmt.Errorf("resolve notarius lane %q file: %w", lane.LaneID, err) } index.Lanes = append(index.Lanes, LaneDescriptor{ LaneID: lane.LaneID, File: lane.File, Path: path, MediaType: lane.MediaType, ModuleKey: lane.ModuleKey, SchemaID: lane.SchemaID, SchemaName: lane.SchemaName, SchemaVersion: lane.SchemaVersion, }) } if document.ChunkMap != nil { index.ChunkMap, err = resolvePipelineDescriptor(bundleRoot, "chunk_map", *document.ChunkMap) if err != nil { return Index{}, err } } if document.EvidenceContext != nil { index.EvidenceContext, err = resolvePipelineDescriptor(bundleRoot, "evidence_context", *document.EvidenceContext) if err != nil { return Index{}, err } } return index, nil } func resolvePipelineDescriptor(bundleRoot, label string, document pipelineDocument) (*PipelineDescriptor, error) { if strings.TrimSpace(document.ArtifactKind) == "" || strings.TrimSpace(document.File) == "" || strings.TrimSpace(document.MediaType) == "" || strings.TrimSpace(document.SchemaID) == "" || strings.TrimSpace(document.SchemaName) == "" || strings.TrimSpace(document.SchemaVersion) == "" { return nil, fmt.Errorf("notarius %s descriptor is missing required fields", label) } path, err := resolveRegularFile(bundleRoot, document.File) if err != nil { return nil, fmt.Errorf("resolve notarius %s file: %w", label, err) } return &PipelineDescriptor{ ArtifactKind: document.ArtifactKind, File: document.File, Path: path, MediaType: document.MediaType, SchemaID: document.SchemaID, SchemaName: document.SchemaName, SchemaVersion: document.SchemaVersion, }, nil } type rejectionDocument struct { Rejected *[]struct { Stage string `json:"stage"` StepID string `json:"step_id"` LaneID string `json:"lane_id"` ModuleKey string `json:"module_key"` ChunkID string `json:"chunk_id"` ValidatorName string `json:"validator_name"` ReasonCode string `json:"reason_code"` Message string `json:"message"` } `json:"rejected"` } func loadRejections(path string) ([]RejectionSummary, error) { var document rejectionDocument if err := decodeBoundedJSON(path, maxSummaryBytes, &document); err != nil { return nil, fmt.Errorf("decode notarius rejections: %w", err) } if document.Rejected == nil { return nil, fmt.Errorf("notarius rejection document is missing rejected array") } summaries := make([]RejectionSummary, 0, len(*document.Rejected)) for _, item := range *document.Rejected { if strings.TrimSpace(item.Stage) == "" || strings.TrimSpace(item.Message) == "" { return nil, fmt.Errorf("notarius rejection entries require stage and message") } summaries = append(summaries, RejectionSummary{ Stage: item.Stage, StepID: item.StepID, LaneID: item.LaneID, ModuleKey: item.ModuleKey, ChunkID: item.ChunkID, ValidatorName: item.ValidatorName, ReasonCode: item.ReasonCode, }) } return summaries, nil } type warningDocument struct { Warnings *[]struct { Scope string `json:"scope"` ReasonCode string `json:"reason_code"` Message string `json:"message"` } `json:"warnings"` } func loadWarnings(path string) ([]WarningSummary, error) { var document warningDocument if err := decodeBoundedJSON(path, maxSummaryBytes, &document); err != nil { return nil, fmt.Errorf("decode notarius warnings: %w", err) } if document.Warnings == nil { return nil, fmt.Errorf("notarius warning document is missing warnings array") } summaries := make([]WarningSummary, 0, len(*document.Warnings)) for _, item := range *document.Warnings { if strings.TrimSpace(item.ReasonCode) == "" || strings.TrimSpace(item.Message) == "" { return nil, fmt.Errorf("notarius warning entries require reason_code and message") } summaries = append(summaries, WarningSummary{Scope: item.Scope, ReasonCode: item.ReasonCode}) } return summaries, nil } func decodeBoundedJSON(path string, limit int64, destination any) error { inspected, err := os.Lstat(path) if err != nil { return err } if inspected.Mode()&os.ModeSymlink != 0 || !inspected.Mode().IsRegular() { return fmt.Errorf("path %q must be a regular file without symlinks", path) } file, err := os.Open(path) if err != nil { return err } defer func() { _ = file.Close() }() opened, err := file.Stat() if err != nil { return err } if !opened.Mode().IsRegular() || !os.SameFile(inspected, opened) { return fmt.Errorf("file %q changed before it could be read", path) } reader := io.LimitReader(file, limit+1) data, err := io.ReadAll(reader) if err != nil { return err } if int64(len(data)) > limit { return fmt.Errorf("file %q exceeds %d-byte limit", path, limit) } if err := json.Unmarshal(data, destination); err != nil { return err } return nil } func validateBundleRoot(outputRoot, bundleRoot string) (string, error) { root := filepath.Clean(outputRoot) bundle := filepath.Clean(bundleRoot) relative, err := filepath.Rel(root, bundle) if err != nil { return "", fmt.Errorf("compare notarius output paths: %w", err) } if relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { return "", fmt.Errorf("notarius output directory %q is not beneath output root %q", bundleRoot, outputRoot) } if err := requireDirectoryTree(root, relative); err != nil { return "", fmt.Errorf("validate notarius output directory: %w", err) } return bundle, nil } func resolveRegularFile(root, logicalPath string) (string, error) { resolved, err := pathsafe.JoinSlashRelativeUnderRoot(root, logicalPath) if err != nil { return "", err } relative, err := filepath.Rel(root, resolved) if err != nil { return "", err } if err := requireRegularFileTree(root, relative); err != nil { return "", err } return resolved, nil } func requireDirectoryTree(root, relative string) error { if err := requireDirectory(root); err != nil { return err } current := root for _, component := range strings.Split(relative, string(filepath.Separator)) { current = filepath.Join(current, component) if err := requireDirectory(current); err != nil { return err } } return nil } func requireRegularFileTree(root, relative string) error { components := strings.Split(relative, string(filepath.Separator)) if len(components) == 0 { return fmt.Errorf("regular file path is required") } if err := requireDirectory(root); err != nil { return err } current := root for _, component := range components[:len(components)-1] { current = filepath.Join(current, component) if err := requireDirectory(current); err != nil { return err } } return requireRegularFile(filepath.Join(current, components[len(components)-1])) } func requireDirectory(path string) error { info, err := os.Lstat(path) if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { return fmt.Errorf("path %q must be a directory without symlinks", path) } return nil } func requireRegularFile(path string) error { info, err := os.Lstat(path) if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { return fmt.Errorf("path %q must be a regular file without symlinks", path) } return nil } func validateLogDestination(path string) error { if err := requireDirectory(filepath.Dir(path)); err != nil { return err } info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { return fmt.Errorf("path %q must be absent or a regular file without symlinks", path) } return nil }