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

@@ -1,13 +1,11 @@
package artifacts
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
@@ -22,11 +20,6 @@ const (
extractionMetadataRoot = "bundle_root"
)
type hydratedExtraction struct {
sourceID string
path string
}
// HydrateExtractionArtifacts marks extraction sources available only when the current
// manifest contains one complete, internally consistent, succeeded extraction bundle.
// Invalid, stale, incomplete, or unsafe records leave every extraction source unavailable.
@@ -38,71 +31,12 @@ func (c *ArtifactCatalog) HydrateExtractionArtifacts(
if c == nil || m == nil || len(configured) == 0 {
return
}
record := m.Stages[extractStageName]
if record == nil || record.Name != extractStageName || record.Status != manifest.StatusSucceeded {
proof := InspectExtractionEvidence(paths, m, configured)
if proof.State != ExtractionEvidenceValid {
return
}
producerRunID := extractionMetadataString(record.Metadata, extractionMetadataRun)
if !safeExtractionPathSegment(producerRunID) {
return
}
bundleRoot := filepath.Clean(filepath.Join(paths.ArtifactsDir, "notarius", producerRunID))
if !filepath.IsAbs(bundleRoot) || extractionMetadataString(record.Metadata, extractionMetadataRoot) != bundleRoot {
return
}
if !safeExistingExtractionDirectory(paths.Root, bundleRoot) {
return
}
receiptRunID, receiptPipelineID := extractionReceiptIdentity(record.Metadata)
if receiptRunID == "" || receiptPipelineID == "" {
return
}
expected := make(map[string]ExtractionArtifactDefinition, len(configured))
for key, definition := range configured {
sourceID, ok := c.SourceIDForExtractionKey(key)
if !ok {
return
}
expected[sourceID] = definition
}
seen := make(map[string]struct{}, len(expected))
hydrated := make([]hydratedExtraction, 0, len(expected))
indexSeen := false
for _, output := range record.Outputs {
if strings.TrimSpace(output.ProducerRunID) != producerRunID {
return
}
if output.SourceID == "" {
if indexSeen || output.Kind != extractionIndexKind || filepath.Clean(output.LocalPath) != filepath.Join(bundleRoot, "index.json") ||
!validExtractionPayload(bundleRoot, output.LocalPath, output.Checksum) {
return
}
indexSeen = true
continue
}
definition, ok := expected[output.SourceID]
if !ok || output.Kind != extractionLaneKind {
return
}
if _, duplicate := seen[output.SourceID]; duplicate {
return
}
if !compatibleCatalogExtractionContract(output.Contract, definition) ||
!compatibleCatalogExtractionProvenance(output.ExternalProvenance, receiptRunID, receiptPipelineID, definition) ||
!validExtractionPayload(bundleRoot, output.LocalPath, output.Checksum) {
return
}
seen[output.SourceID] = struct{}{}
hydrated = append(hydrated, hydratedExtraction{sourceID: output.SourceID, path: output.LocalPath})
}
if !indexSeen || len(seen) != len(expected) || len(record.Outputs) != len(expected)+1 {
return
}
for _, item := range hydrated {
_ = c.markAvailableFromExtractManifest(item.sourceID, item.path, producerRunID)
for sourceID, path := range proof.Outputs {
_ = c.markAvailableFromExtractManifest(sourceID, path, proof.ProducerRunID)
}
}
@@ -120,25 +54,6 @@ func compatibleCatalogExtractionProvenance(
pipelineID == strings.TrimSpace(want.PipelineID) && got.ArtifactID == want.LaneID
}
func validExtractionPayload(bundleRoot, path, checksum string) bool {
if !filepath.IsAbs(path) || !pathWithinExtractionRoot(bundleRoot, path) || strings.TrimSpace(checksum) == "" {
return false
}
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return false
}
if !safeExtractionComponents(bundleRoot, path) {
return false
}
actual, err := SHA256File(path)
if err != nil || actual != checksum {
return false
}
body, err := fileops.ReadRegularFile(path, MaxExtractionPayloadBytes)
return err == nil && json.Valid(body)
}
func safeExistingExtractionDirectory(sessionRoot, bundleRoot string) bool {
if !pathWithinExtractionRoot(sessionRoot, bundleRoot) || !safeExtractionComponents(sessionRoot, bundleRoot) {
return false

View File

@@ -4,6 +4,7 @@ import (
"errors"
"os"
"path/filepath"
"runtime"
"testing"
"time"
@@ -76,6 +77,60 @@ func TestHydrateExtractionArtifactsAcceptsOnlyCompleteCurrentBundle(t *testing.T
}
}
func TestInspectExtractionEvidenceClassifiesBundleStates(t *testing.T) {
tests := []struct {
name string
state ExtractionEvidenceState
mutate func(*testing.T, *SessionPaths, *manifest.Manifest)
}{
{name: "valid", state: ExtractionEvidenceValid, mutate: func(_ *testing.T, _ *SessionPaths, _ *manifest.Manifest) {}},
{name: "absent", state: ExtractionEvidenceAbsent, mutate: func(_ *testing.T, _ *SessionPaths, m *manifest.Manifest) {
m.Stages["extract"].Status = manifest.StatusFailed
}},
{name: "obsolete version", state: ExtractionEvidenceObsolete, mutate: func(_ *testing.T, _ *SessionPaths, m *manifest.Manifest) {
m.Stages["extract"].Outputs[0].Contract.SchemaVersion = "99"
}},
{name: "incomplete", state: ExtractionEvidenceObsolete, mutate: func(_ *testing.T, _ *SessionPaths, m *manifest.Manifest) {
m.Stages["extract"].Outputs = m.Stages["extract"].Outputs[1:]
}},
{name: "unsafe root", state: ExtractionEvidenceUnsafe, mutate: func(t *testing.T, paths *SessionPaths, _ *manifest.Manifest) {
paths.Root = t.TempDir()
}},
{name: "unsafe symlink", state: ExtractionEvidenceUnsafe, mutate: func(t *testing.T, _ *SessionPaths, 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")
writeExtractionFixtureFile(t, outside, `{"outside":true}`)
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, path); err != nil {
t.Fatal(err)
}
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
paths, currentManifest, definitions := validExtractionCatalogFixture(t)
test.mutate(t, &paths, currentManifest)
proof := InspectExtractionEvidence(paths, currentManifest, definitions)
if proof.State != test.state {
t.Fatalf("proof = %#v, want %q", proof, test.state)
}
catalog := registeredExtractionCatalog(t, definitions)
catalog.HydrateExtractionArtifacts(paths, currentManifest, definitions)
entry, _ := catalog.Lookup(ExtractionArtifactSourceID("encounters"))
if entry.Available != (test.state == ExtractionEvidenceValid) {
t.Fatalf("catalog availability for %s = %v", test.state, entry.Available)
}
})
}
}
func TestHydrateExtractionArtifactsRejectsUntrustedManifestState(t *testing.T) {
tests := []struct {
name string

View File

@@ -0,0 +1,131 @@
package artifacts
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
type ExtractionEvidenceState string
const (
ExtractionEvidenceValid ExtractionEvidenceState = "valid"
ExtractionEvidenceAbsent ExtractionEvidenceState = "absent"
ExtractionEvidenceObsolete ExtractionEvidenceState = "obsolete"
ExtractionEvidenceUnsafe ExtractionEvidenceState = "unsafe"
)
// ExtractionEvidence is a policy-neutral proof of configured extraction output.
type ExtractionEvidence struct {
State ExtractionEvidenceState
Reason, ProducerRunID string
Outputs map[string]string
}
// InspectExtractionEvidence verifies structure, confinement, identity, contracts, and payload bytes.
func InspectExtractionEvidence(paths SessionPaths, m *manifest.Manifest, configured map[string]ExtractionArtifactDefinition) ExtractionEvidence {
if m == nil || len(configured) == 0 {
return ExtractionEvidence{State: ExtractionEvidenceAbsent, Reason: "extraction evidence is absent"}
}
r := m.Stages[extractStageName]
if r == nil || r.Name != extractStageName || r.Status != manifest.StatusSucceeded {
return ExtractionEvidence{State: ExtractionEvidenceAbsent, Reason: "extract stage has no succeeded result"}
}
runID := extractionMetadataString(r.Metadata, extractionMetadataRun)
if !safeExtractionPathSegment(runID) {
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract result has an invalid producing run ID"}
}
root := filepath.Clean(filepath.Join(paths.ArtifactsDir, "notarius", runID))
if !filepath.IsAbs(root) || extractionMetadataString(r.Metadata, extractionMetadataRoot) != root {
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract result does not identify its canonical immutable bundle"}
}
info, err := os.Lstat(root)
if os.IsNotExist(err) {
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "immutable Notarius bundle is missing"}
}
if err != nil || info.Mode()&os.ModeSymlink != 0 {
return ExtractionEvidence{State: ExtractionEvidenceUnsafe, Reason: "immutable Notarius bundle is unsafe"}
}
if !info.IsDir() {
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "immutable Notarius bundle is not a directory"}
}
if !safeExistingExtractionDirectory(paths.Root, root) {
return ExtractionEvidence{State: ExtractionEvidenceUnsafe, Reason: "immutable Notarius bundle is unsafe"}
}
receiptRunID, receiptPipelineID := extractionReceiptIdentity(r.Metadata)
if receiptRunID == "" || receiptPipelineID == "" {
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract result has incompatible Notarius receipt identity"}
}
expected := make(map[string]ExtractionArtifactDefinition, len(configured))
for key, d := range configured {
expected[ExtractionArtifactSourceID(key)] = d
}
seen, outputs := map[string]struct{}{}, map[string]string{}
indexSeen := false
for _, out := range r.Outputs {
if strings.TrimSpace(out.ProducerRunID) != runID {
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract output producer identity is inconsistent"}
}
if out.SourceID == "" {
if indexSeen || out.Kind != extractionIndexKind || filepath.Clean(out.LocalPath) != filepath.Join(root, "index.json") {
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract index path is not canonical"}
}
if state, reason := inspectExtractionPayload(root, out.LocalPath, out.Checksum); state != ExtractionEvidenceValid {
return ExtractionEvidence{State: state, Reason: reason}
}
indexSeen = true
continue
}
d, ok := expected[out.SourceID]
if !ok || out.Kind != extractionLaneKind {
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract result source set differs from current configuration"}
}
if _, duplicate := seen[out.SourceID]; duplicate {
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract result contains a duplicate configured source"}
}
if !compatibleCatalogExtractionContract(out.Contract, d) || !compatibleCatalogExtractionProvenance(out.ExternalProvenance, receiptRunID, receiptPipelineID, d) {
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract output contract or provenance is incompatible"}
}
if state, reason := inspectExtractionPayload(root, out.LocalPath, out.Checksum); state != ExtractionEvidenceValid {
return ExtractionEvidence{State: state, Reason: reason}
}
seen[out.SourceID] = struct{}{}
outputs[out.SourceID] = out.LocalPath
}
if !indexSeen || len(seen) != len(expected) || len(r.Outputs) != len(expected)+1 {
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract result is incomplete"}
}
return ExtractionEvidence{State: ExtractionEvidenceValid, ProducerRunID: runID, Outputs: outputs}
}
func inspectExtractionPayload(root, path, checksum string) (ExtractionEvidenceState, string) {
if !filepath.IsAbs(path) || !pathWithinExtractionRoot(root, path) || strings.TrimSpace(checksum) == "" {
return ExtractionEvidenceUnsafe, "extract output path or checksum is unsafe"
}
info, err := os.Lstat(path)
if os.IsNotExist(err) {
return ExtractionEvidenceObsolete, "extract output is missing"
}
if !safeExtractionComponents(root, path) {
return ExtractionEvidenceUnsafe, "extract output path contains unsafe components"
}
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return ExtractionEvidenceUnsafe, "extract output is not a regular file"
}
actual, err := SHA256File(path)
if err != nil {
return ExtractionEvidenceUnsafe, "extract output checksum cannot be read"
}
if actual != checksum {
return ExtractionEvidenceObsolete, "extract output checksum does not match durable bytes"
}
body, err := fileops.ReadRegularFile(path, MaxExtractionPayloadBytes)
if err != nil || !json.Valid(body) {
return ExtractionEvidenceObsolete, "extract output is not valid JSON"
}
return ExtractionEvidenceValid, ""
}

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"])
}
})
}
}