503 lines
17 KiB
Go
503 lines
17 KiB
Go
package notarius
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
|
"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,
|
|
DiagnosticOwner: "notarius",
|
|
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 {
|
|
data, err := fileops.ReadRegularFile(path, limit)
|
|
if err != nil {
|
|
return fmt.Errorf("notarius JSON result exceeds or cannot be read within %d-byte limit: %w", limit, err)
|
|
}
|
|
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
|
|
}
|