Centralize extraction bundle evidence

This commit is contained in:
2026-08-10 22:06:48 +00:00
parent 8ff1b4fa66
commit 4e4eff6ba7
7 changed files with 255 additions and 286 deletions

View File

@@ -3,16 +3,11 @@ package stage
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
func (extractStage) ValidateResume(_ context.Context, env *Env, m *manifest.Manifest) (ResumeValidation, error) {
@@ -26,22 +21,6 @@ func (extractStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani
if m == nil {
return ResumeValidation{}, fmt.Errorf("extract resume: session manifest is required")
}
record := m.Stages[(extractStage{}).Name()]
if record == nil || record.Status != manifest.StatusSucceeded {
return NonResumable("extract stage has no succeeded result"), nil
}
if record.Name != (extractStage{}).Name() {
return NonResumable("extract stage record identity is inconsistent"), nil
}
producerRunID := metadataString(record.Metadata, "narratio_run_id")
if producerRunID == "" {
return NonResumable("extract result is missing its producing run ID"), nil
}
if !safePathSegment(producerRunID) {
return NonResumable("extract result has an invalid producing run ID"), nil
}
timeout, err := time.ParseDuration(strings.TrimSpace(cfg.Timeout))
if err != nil || timeout <= 0 {
return ResumeValidation{}, fmt.Errorf("extract resume: invalid Notarius timeout %q", cfg.Timeout)
@@ -62,7 +41,8 @@ func (extractStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani
if err != nil {
return ResumeValidation{}, fmt.Errorf("extract resume: build configuration fingerprint: %w", err)
}
if metadataString(record.Metadata, "configuration_fingerprint") != fingerprint {
record := m.Stages[(extractStage{}).Name()]
if record == nil || metadataString(record.Metadata, "configuration_fingerprint") != fingerprint {
return NonResumable("Notarius invocation contract changed"), nil
}
@@ -70,169 +50,27 @@ func (extractStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani
if sessionID == "" {
sessionID = strings.TrimSpace(env.Config.Session.SessionID)
}
campaign := strings.TrimSpace(m.Campaign)
if campaign == "" {
campaign = strings.TrimSpace(env.Config.Session.Campaign)
}
if sessionID == "" || campaign == "" {
return ResumeValidation{}, fmt.Errorf("extract resume: session ID and campaign are required")
if sessionID == "" {
return ResumeValidation{}, fmt.Errorf("extract resume: session ID is required")
}
paths := sessionPathsForEnv(env, sessionID)
workspaceRoot := strings.TrimSpace(paths.WorkspaceRoot)
if workspaceRoot == "" {
workspaceRoot = env.Config.Pipeline.Workspace.Root
}
bundleRoot, err := absolutePath(artifacts.SessionNotariusBundleDirForCampaign(workspaceRoot, campaign, sessionID, producerRunID))
if err != nil {
return ResumeValidation{}, fmt.Errorf("extract resume: resolve durable bundle path: %w", err)
}
storedBundleRoot := metadataString(record.Metadata, "bundle_root")
if storedBundleRoot == "" || !filepath.IsAbs(storedBundleRoot) || filepath.Clean(storedBundleRoot) != bundleRoot {
return NonResumable("extract result does not identify its canonical immutable bundle"), nil
}
bundleRelative, err := pathsafe.SlashRelativeFromRoot(paths.Root, bundleRoot)
if err != nil {
return ResumeValidation{}, fmt.Errorf("extract resume: immutable bundle path is unsafe: %w", err)
}
if err := rejectSymlinkComponents(paths.Root, bundleRelative); err != nil {
return ResumeValidation{}, err
}
bundleInfo, err := os.Lstat(bundleRoot)
if os.IsNotExist(err) {
return NonResumable("immutable Notarius bundle is missing"), nil
}
if err != nil {
return ResumeValidation{}, fmt.Errorf("extract resume: inspect immutable bundle: %w", err)
}
if bundleInfo.Mode()&os.ModeSymlink != 0 {
return ResumeValidation{}, fmt.Errorf("extract resume: immutable bundle must not be a symlink")
}
if !bundleInfo.IsDir() {
return NonResumable("immutable Notarius bundle is not a directory"), nil
}
receiptRunID, receiptPipelineID := receiptIdentity(record.Metadata)
if receiptRunID == "" || receiptPipelineID != cfg.PipelineID {
return NonResumable("extract result has incompatible Notarius receipt identity"), nil
}
expectedSources := make(map[string]config.NotariusOutputConfig, len(cfg.Outputs))
definitions := make(map[string]artifacts.ExtractionArtifactDefinition, len(cfg.Outputs))
for key, output := range cfg.Outputs {
expectedSources[artifacts.ExtractionArtifactSourceID(key)] = output
}
seenSources := make(map[string]struct{}, len(expectedSources))
indexSeen := false
for _, output := range record.Outputs {
if output.ProducerRunID != producerRunID {
return NonResumable("extract output producer identity is inconsistent"), nil
}
if output.SourceID == "" {
if indexSeen || output.Kind != extractIndexOutputKind {
return NonResumable("extract result has an unexpected non-selectable output"), nil
}
indexSeen = true
expectedIndex := filepath.Join(bundleRoot, "index.json")
if filepath.Clean(output.LocalPath) != expectedIndex {
return NonResumable("extract index path is not canonical"), nil
}
validation, err := validateResumePayload(bundleRoot, output.LocalPath, output.Checksum)
if err != nil || !validation.Resumable {
return validation, err
}
continue
}
expected, ok := expectedSources[output.SourceID]
if !ok || output.Kind != extractLaneOutputKind {
return NonResumable("extract result source set differs from current configuration"), nil
}
if _, duplicate := seenSources[output.SourceID]; duplicate {
return NonResumable("extract result contains a duplicate configured source"), nil
}
seenSources[output.SourceID] = struct{}{}
if !compatibleExtractionContract(output.Contract, expected) {
return NonResumable("extract output contract is incompatible with current configuration"), nil
}
if !compatibleExtractionProvenance(output.ExternalProvenance, receiptRunID, receiptPipelineID, expected.LaneID) {
return NonResumable("extract output has incompatible Notarius provenance"), nil
}
validation, err := validateResumePayload(bundleRoot, output.LocalPath, output.Checksum)
if err != nil || !validation.Resumable {
return validation, err
definitions[key] = artifacts.ExtractionArtifactDefinition{
LaneID: output.LaneID, PipelineID: cfg.PipelineID, MediaType: output.MediaType,
SchemaID: output.SchemaID, SchemaVersion: output.SchemaVersion, ModuleKey: output.ModuleKey,
}
}
if !indexSeen {
return NonResumable("extract result is missing its canonical index"), nil
proof := artifacts.InspectExtractionEvidence(paths, m, definitions)
if proof.State == artifacts.ExtractionEvidenceUnsafe {
return ResumeValidation{}, fmt.Errorf("extract resume: %s", proof.Reason)
}
if len(seenSources) != len(expectedSources) || len(record.Outputs) != len(expectedSources)+1 {
return NonResumable("extract result is missing configured sources"), nil
if proof.State != artifacts.ExtractionEvidenceValid {
return NonResumable(proof.Reason), nil
}
return Resumable(), nil
}
func validateResumePayload(bundleRoot, path, checksum string) (ResumeValidation, error) {
if !filepath.IsAbs(path) {
return ResumeValidation{}, fmt.Errorf("extract resume: persisted output path must be absolute")
}
relative, err := pathsafe.SlashRelativeFromRoot(bundleRoot, path)
if err != nil {
return ResumeValidation{}, fmt.Errorf("extract resume: persisted output path is unsafe: %w", err)
}
if err := rejectSymlinkComponents(bundleRoot, relative); err != nil {
return ResumeValidation{}, err
}
info, err := os.Lstat(path)
if os.IsNotExist(err) {
return NonResumable("extract output is missing"), nil
}
if err != nil {
return ResumeValidation{}, fmt.Errorf("extract resume: inspect output %q: %w", path, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return ResumeValidation{}, fmt.Errorf("extract resume: output %q must be a regular file without symlinks", path)
}
if strings.TrimSpace(checksum) == "" {
return NonResumable("extract output is missing its checksum"), nil
}
actual, err := artifacts.SHA256File(path)
if err != nil {
return ResumeValidation{}, fmt.Errorf("extract resume: checksum output %q: %w", path, err)
}
if actual != checksum {
return NonResumable("extract output checksum does not match durable bytes"), nil
}
return Resumable(), nil
}
func rejectSymlinkComponents(root, slashRelative string) error {
current := filepath.Clean(root)
parts := strings.Split(filepath.FromSlash(slashRelative), string(filepath.Separator))
for _, part := range parts[:len(parts)-1] {
current = filepath.Join(current, part)
info, err := os.Lstat(current)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("extract resume: inspect output directory %q: %w", current, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("extract resume: output directory %q must not be a symlink", current)
}
}
return nil
}
func compatibleExtractionContract(got *artifactmodel.ContractMetadata, want config.NotariusOutputConfig) bool {
return got != nil && got.MediaType == want.MediaType && got.SchemaID == want.SchemaID &&
got.SchemaVersion == want.SchemaVersion && (want.ModuleKey == "" || got.ModuleKey == want.ModuleKey)
}
func compatibleExtractionProvenance(got *artifactmodel.ExternalProvenance, runID, pipelineID, laneID string) bool {
return got != nil && got.System == "notarius" && got.RunID == runID &&
got.PipelineID == pipelineID && got.ArtifactID == laneID
}
func metadataString(metadata map[string]any, key string) string {
if metadata == nil {
return ""
@@ -240,11 +78,3 @@ func metadataString(metadata map[string]any, key string) string {
value, _ := metadata[key].(string)
return strings.TrimSpace(value)
}
func receiptIdentity(metadata map[string]any) (string, string) {
if metadata == nil {
return "", ""
}
receipt, _ := metadata["receipt"].(map[string]any)
return metadataString(receipt, "run_id"), metadataString(receipt, "pipeline_id")
}

View File

@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"time"
@@ -361,7 +362,7 @@ func TestExtractStageAdapterResultIsImmediatelyReusableAndCatalogVisible(t *test
}
}
func TestExtractStageResumeValidationRejectsObsoleteResults(t *testing.T) {
func TestExtractStageResumeValidationRequiresFreshEvidence(t *testing.T) {
tests := []struct {
name string
mutate func(*testing.T, *Env, *manifest.Manifest)
@@ -372,6 +373,9 @@ func TestExtractStageResumeValidationRejectsObsoleteResults(t *testing.T) {
{name: "config changed", mutate: func(_ *testing.T, env *Env, _ *manifest.Manifest) {
env.Config.Pipeline.Notarius.PipelineID = "changed"
}},
{name: "no succeeded extract record", mutate: func(_ *testing.T, _ *Env, m *manifest.Manifest) {
m.Stages["extract"].Status = manifest.StatusFailed
}},
{name: "missing lane", mutate: func(t *testing.T, _ *Env, m *manifest.Manifest) {
if err := os.Remove(m.Stages["extract"].Outputs[0].LocalPath); err != nil {
t.Fatalf("Remove(lane) error = %v", err)
@@ -419,16 +423,45 @@ func TestExtractStageResumeValidationRejectsObsoleteResults(t *testing.T) {
}
func TestExtractStageResumeValidationRejectsUnsafePathWithError(t *testing.T) {
env, m, _ := setupExtractEnv(t)
seedSucceededExtractResult(t, env, m)
prior := *m.Stages["extract"]
m.Stages["extract"].Outputs[0].LocalPath = filepath.Join(env.Config.Pipeline.Workspace.Root, "outside.json")
if _, err := (extractStage{}).ValidateResume(context.Background(), env, m); err == nil || !strings.Contains(err.Error(), "unsafe") {
t.Fatalf("ValidateResume() error = %v, want unsafe path error", err)
tests := []struct {
name string
mutate func(*testing.T, *Env, *manifest.Manifest)
}{
{name: "path outside bundle", mutate: func(_ *testing.T, env *Env, m *manifest.Manifest) {
m.Stages["extract"].Outputs[0].LocalPath = filepath.Join(env.Config.Pipeline.Workspace.Root, "outside.json")
}},
{name: "symlink", mutate: func(t *testing.T, _ *Env, m *manifest.Manifest) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on Windows")
}
path := m.Stages["extract"].Outputs[0].LocalPath
outside := filepath.Join(t.TempDir(), "outside.json")
if err := os.WriteFile(outside, []byte(`{"outside":true}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, path); err != nil {
t.Fatal(err)
}
}},
}
if m.Stages["extract"].Status != prior.Status || m.Stages["extract"].Error != prior.Error {
t.Fatalf("validation mutated stage record: %#v", m.Stages["extract"])
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
env, m, _ := setupExtractEnv(t)
seedSucceededExtractResult(t, env, m)
prior := *m.Stages["extract"]
test.mutate(t, env, m)
if _, err := (extractStage{}).ValidateResume(context.Background(), env, m); err == nil || !strings.Contains(err.Error(), "unsafe") {
t.Fatalf("ValidateResume() error = %v, want unsafe evidence error", err)
}
if m.Stages["extract"].Status != prior.Status || m.Stages["extract"].Error != prior.Error {
t.Fatalf("validation mutated stage record: %#v", m.Stages["extract"])
}
})
}
}