From a2409a1fd12b72872fb6a67af2d47f1a9c3321bd Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 29 Aug 2026 15:11:59 +0000 Subject: [PATCH] Verify prepared inputs from manifest evidence --- docs/roadmap/implementation.md | 2 +- internal/artifacts/prepared_input.go | 191 +++++++++++++++++++ internal/artifacts/prepared_input_test.go | 222 ++++++++++++++++++++++ internal/stage/analyze.go | 45 ++--- internal/stage/analyze_test.go | 56 +++++- 5 files changed, 483 insertions(+), 33 deletions(-) create mode 100644 internal/artifacts/prepared_input.go create mode 100644 internal/artifacts/prepared_input_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9cdecc0..09b2467 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -18,7 +18,7 @@ different contract. | ---: | --- | --- | | 1 | Add the reference-selector and configuration vocabulary, including optional spell-catalog inputs. | Completed | | 2 | Materialize and inventory the optional spell catalog through the prepare and operator lifecycle. | Completed | -| 3 | Centralize manifest-authoritative prepared-input resolution and migrate analyze to it. | Pending | +| 3 | Centralize manifest-authoritative prepared-input resolution and migrate analyze to it. | Completed | | 4 | Add deterministic Notarius v0.6 reference arguments at the subprocess adapter boundary. | Pending | | 5 | Resolve references in extract and bind fingerprints, resume, and metadata to their identities. | Pending | | 6 | Prove assembled extraction lifecycle and downstream invalidation behavior. | Pending | diff --git a/internal/artifacts/prepared_input.go b/internal/artifacts/prepared_input.go new file mode 100644 index 0000000..ab166d0 --- /dev/null +++ b/internal/artifacts/prepared_input.go @@ -0,0 +1,191 @@ +package artifacts + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "path/filepath" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" + "gitea.maximumdirect.net/eric/narratio/internal/fileops" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +// ErrPreparedInputAbsent reports that the current manifest has no record for a +// supported prepared stable input. +var ErrPreparedInputAbsent = errors.New("prepared input absent") + +// PreparedInputAbsentError identifies the prepared source absent from the +// current manifest. +type PreparedInputAbsentError struct { + SourceID string +} + +func (e *PreparedInputAbsentError) Error() string { + return fmt.Sprintf("%s: %q", ErrPreparedInputAbsent, e.SourceID) +} + +func (e *PreparedInputAbsentError) Unwrap() error { + return ErrPreparedInputAbsent +} + +// PreparedInputIdentity is the verified identity of one canonical prepared +// session input. +type PreparedInputIdentity struct { + SourceID string + ManifestKind string + Path string + RelativePath string + Checksum string + Size int64 +} + +// ResolvePreparedInput resolves a prepared stable source exclusively from its +// current manifest record and verifies the canonical file's identity. +func ResolvePreparedInput(paths SessionPaths, m *manifest.Manifest, sourceID string) (PreparedInputIdentity, error) { + descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID) + if !ok { + return PreparedInputIdentity{}, fmt.Errorf("unsupported prepared input source %q", sourceID) + } + + rootPath, canonicalPath, relativePath, err := preparedInputCanonicalPaths(paths, descriptor) + if err != nil { + return PreparedInputIdentity{}, err + } + + matching := make([]manifest.InputRecord, 0, 1) + if m != nil { + for _, record := range m.Inputs { + if strings.TrimSpace(record.Kind) == descriptor.ManifestKind { + matching = append(matching, record) + } + } + } + if len(matching) == 0 { + if m != nil { + for _, record := range m.Inputs { + recordedPath, pathErr := resolvePreparedManifestPath(paths, record.Path, rootPath) + if pathErr == nil && recordedPath == canonicalPath { + return PreparedInputIdentity{}, fmt.Errorf( + "prepared input source %q canonical path is recorded with manifest kind %q, want %q", + descriptor.SourceID, + strings.TrimSpace(record.Kind), + descriptor.ManifestKind, + ) + } + } + } + return PreparedInputIdentity{}, &PreparedInputAbsentError{SourceID: descriptor.SourceID} + } + if len(matching) != 1 { + return PreparedInputIdentity{}, fmt.Errorf( + "prepared input source %q has %d manifest records for kind %q; want exactly one", + descriptor.SourceID, + len(matching), + descriptor.ManifestKind, + ) + } + + record := matching[0] + recordedPath, err := resolvePreparedManifestPath(paths, record.Path, rootPath) + if err != nil { + return PreparedInputIdentity{}, fmt.Errorf("prepared input source %q manifest path: %w", descriptor.SourceID, err) + } + if recordedPath != canonicalPath { + return PreparedInputIdentity{}, fmt.Errorf( + "prepared input source %q manifest path %q does not match canonical path %q", + descriptor.SourceID, + recordedPath, + canonicalPath, + ) + } + declaredChecksum := strings.TrimSpace(record.Checksum) + if declaredChecksum == "" { + return PreparedInputIdentity{}, fmt.Errorf("prepared input source %q manifest checksum is required", descriptor.SourceID) + } + + file, err := fileops.OpenConfinedRegularFile(rootPath, relativePath) + if err != nil { + return PreparedInputIdentity{}, fmt.Errorf("open prepared input source %q: %w", descriptor.SourceID, err) + } + digest := sha256.New() + size, readErr := io.Copy(digest, file) + closeErr := file.Close() + if readErr != nil { + return PreparedInputIdentity{}, fmt.Errorf("checksum prepared input source %q: %w", descriptor.SourceID, readErr) + } + if closeErr != nil { + return PreparedInputIdentity{}, fmt.Errorf("close prepared input source %q: %w", descriptor.SourceID, closeErr) + } + if size == 0 { + return PreparedInputIdentity{}, fmt.Errorf("prepared input source %q is empty", descriptor.SourceID) + } + checksum := hex.EncodeToString(digest.Sum(nil)) + if !strings.EqualFold(checksum, declaredChecksum) { + return PreparedInputIdentity{}, fmt.Errorf( + "prepared input source %q checksum mismatch: manifest=%q actual=%q", + descriptor.SourceID, + declaredChecksum, + checksum, + ) + } + + return PreparedInputIdentity{ + SourceID: descriptor.SourceID, + ManifestKind: descriptor.ManifestKind, + Path: canonicalPath, + RelativePath: filepath.ToSlash(relativePath), + Checksum: checksum, + Size: size, + }, nil +} + +func preparedInputCanonicalPaths( + paths SessionPaths, + descriptor artifactpolicy.PreparedInputSourceDescriptor, +) (rootPath, canonicalPath, relativePath string, err error) { + rootPath, err = filepath.Abs(strings.TrimSpace(paths.Root)) + if err != nil || strings.TrimSpace(paths.Root) == "" { + if err == nil { + err = fmt.Errorf("session root is required") + } + return "", "", "", err + } + canonicalPath, err = filepath.Abs(filepath.Join(paths.InputsDir, descriptor.Filename)) + if err != nil { + return "", "", "", fmt.Errorf("resolve prepared input canonical path: %w", err) + } + relativePath, err = filepath.Rel(rootPath, canonicalPath) + if err != nil || relativePath == "." || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) { + if err != nil { + return "", "", "", fmt.Errorf("resolve prepared input below session root: %w", err) + } + return "", "", "", fmt.Errorf("prepared input canonical path %q is outside session root %q", canonicalPath, rootPath) + } + return filepath.Clean(rootPath), filepath.Clean(canonicalPath), filepath.Clean(relativePath), nil +} + +func resolvePreparedManifestPath(paths SessionPaths, recordedPath, rootPath string) (string, error) { + if strings.TrimSpace(recordedPath) == "" { + return "", fmt.Errorf("recorded path is required") + } + resolved := ResolveSessionLocalPathForRead(paths, recordedPath) + if strings.TrimSpace(resolved) == "" { + return "", fmt.Errorf("recorded path is required") + } + absolute, err := filepath.Abs(resolved) + if err != nil { + return "", fmt.Errorf("resolve recorded path: %w", err) + } + relative, err := filepath.Rel(rootPath, absolute) + if err != nil { + return "", fmt.Errorf("resolve recorded path below session root: %w", err) + } + if relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("recorded path %q is outside session root %q", absolute, rootPath) + } + return filepath.Clean(absolute), nil +} diff --git a/internal/artifacts/prepared_input_test.go b/internal/artifacts/prepared_input_test.go new file mode 100644 index 0000000..b40b72a --- /dev/null +++ b/internal/artifacts/prepared_input_test.go @@ -0,0 +1,222 @@ +package artifacts + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +func TestResolvePreparedInputReturnsVerifiedIdentity(t *testing.T) { + paths, m, canonicalPath, checksum := preparedInputFixture(t, artifactpolicy.SourceInputSpellCatalog, []byte("{\"spells\":[]}\n")) + m.Inputs[0].Path = filepath.ToSlash(filepath.Join("inputs", "spell_catalog.json")) + + identity, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputSpellCatalog) + if err != nil { + t.Fatalf("ResolvePreparedInput() error = %v", err) + } + wantAbsolute, err := filepath.Abs(canonicalPath) + if err != nil { + t.Fatalf("filepath.Abs() error = %v", err) + } + if identity.SourceID != artifactpolicy.SourceInputSpellCatalog || + identity.ManifestKind != "spell_catalog" || + identity.Path != wantAbsolute || + identity.RelativePath != "inputs/spell_catalog.json" || + identity.Checksum != checksum || + identity.Size != int64(len("{\"spells\":[]}\n")) { + t.Fatalf("ResolvePreparedInput() = %#v", identity) + } +} + +func TestResolvePreparedInputRequiresCurrentManifestRecord(t *testing.T) { + paths, _, _, _ := preparedInputFixture(t, artifactpolicy.SourceInputPlayers, []byte("- Alice\n")) + + for _, m := range []*manifest.Manifest{nil, manifest.New("session", time.Now().UTC())} { + _, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputPlayers) + if !errors.Is(err, ErrPreparedInputAbsent) { + t.Fatalf("ResolvePreparedInput() error = %v, want ErrPreparedInputAbsent", err) + } + var absent *PreparedInputAbsentError + if !errors.As(err, &absent) || absent.SourceID != artifactpolicy.SourceInputPlayers { + t.Fatalf("ResolvePreparedInput() error = %#v, want typed players absence", err) + } + } +} + +func TestResolvePreparedInputRejectsInvalidManifestEvidence(t *testing.T) { + tests := []struct { + name string + mutate func(*testing.T, SessionPaths, *manifest.Manifest, string) + wantErr string + }{ + { + name: "duplicate record", + mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) { + m.Inputs = append(m.Inputs, m.Inputs[0]) + }, + wantErr: "2 manifest records", + }, + { + name: "wrong kind", + mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) { + m.Inputs[0].Kind = "players" + }, + wantErr: "recorded with manifest kind", + }, + { + name: "wrong canonical path", + mutate: func(t *testing.T, paths SessionPaths, m *manifest.Manifest, _ string) { + wrong := filepath.Join(paths.InputsDir, "other.json") + if err := os.WriteFile(wrong, []byte("other\n"), 0o644); err != nil { + t.Fatalf("WriteFile(wrong) error = %v", err) + } + m.Inputs[0].Path = wrong + }, + wantErr: "does not match canonical path", + }, + { + name: "traversal path", + mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) { + m.Inputs[0].Path = filepath.Join("..", "..", "outside.json") + }, + wantErr: "outside session root", + }, + { + name: "missing checksum", + mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) { + m.Inputs[0].Checksum = " " + }, + wantErr: "manifest checksum is required", + }, + { + name: "checksum mismatch", + mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) { + m.Inputs[0].Checksum = strings.Repeat("0", 64) + }, + wantErr: "checksum mismatch", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + paths, m, canonicalPath, _ := preparedInputFixture(t, artifactpolicy.SourceInputSpellCatalog, []byte("{\"spells\":[]}\n")) + tt.mutate(t, paths, m, canonicalPath) + _, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputSpellCatalog) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("ResolvePreparedInput() error = %v, want containing %q", err, tt.wantErr) + } + }) + } +} + +func TestResolvePreparedInputRejectsInvalidCanonicalFile(t *testing.T) { + tests := []struct { + name string + mutate func(*testing.T, string) + wantErr string + }{ + { + name: "missing", + mutate: func(t *testing.T, path string) { + if err := os.Remove(path); err != nil { + t.Fatalf("Remove() error = %v", err) + } + }, + wantErr: "open prepared input source", + }, + { + name: "symlink", + mutate: func(t *testing.T, path string) { + outside := filepath.Join(t.TempDir(), "outside.json") + if err := os.WriteFile(outside, []byte("outside\n"), 0o644); err != nil { + t.Fatalf("WriteFile(outside) error = %v", err) + } + if err := os.Remove(path); err != nil { + t.Fatalf("Remove() error = %v", err) + } + if err := os.Symlink(outside, path); err != nil { + t.Fatalf("Symlink() error = %v", err) + } + }, + wantErr: "not a regular file", + }, + { + name: "directory", + mutate: func(t *testing.T, path string) { + if err := os.Remove(path); err != nil { + t.Fatalf("Remove() error = %v", err) + } + if err := os.Mkdir(path, 0o755); err != nil { + t.Fatalf("Mkdir() error = %v", err) + } + }, + wantErr: "not a regular file", + }, + { + name: "empty", + mutate: func(t *testing.T, path string) { + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatalf("WriteFile(empty) error = %v", err) + } + }, + wantErr: "is empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + paths, m, canonicalPath, _ := preparedInputFixture(t, artifactpolicy.SourceInputSpellCatalog, []byte("{\"spells\":[]}\n")) + tt.mutate(t, canonicalPath) + _, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputSpellCatalog) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("ResolvePreparedInput() error = %v, want containing %q", err, tt.wantErr) + } + }) + } +} + +func TestResolvePreparedInputRejectsUnsupportedSource(t *testing.T) { + paths := buildSessionPaths(t.TempDir(), "campaign", "session") + _, err := ResolvePreparedInput(paths, nil, "narratio.input.unknown") + if err == nil || errors.Is(err, ErrPreparedInputAbsent) || !strings.Contains(err.Error(), "unsupported prepared input source") { + t.Fatalf("ResolvePreparedInput() error = %v", err) + } +} + +func preparedInputFixture( + t *testing.T, + sourceID string, + payload []byte, +) (SessionPaths, *manifest.Manifest, string, string) { + t.Helper() + paths := buildSessionPaths(t.TempDir(), "campaign", "session") + descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID) + if !ok { + t.Fatalf("DescribePreparedInputSource(%q) ok = false", sourceID) + } + canonicalPath := filepath.Join(paths.InputsDir, descriptor.Filename) + if err := os.MkdirAll(paths.InputsDir, 0o755); err != nil { + t.Fatalf("MkdirAll(inputs) error = %v", err) + } + if err := os.WriteFile(canonicalPath, payload, 0o644); err != nil { + t.Fatalf("WriteFile(canonical) error = %v", err) + } + checksum, err := SHA256File(canonicalPath) + if err != nil { + t.Fatalf("SHA256File() error = %v", err) + } + m := manifest.New("session", time.Now().UTC()) + m.Inputs = []manifest.InputRecord{{ + Kind: descriptor.ManifestKind, + Path: canonicalPath, + Checksum: checksum, + Source: "campaign_config", + }} + return paths, m, canonicalPath, checksum +} diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index a91b507..1ae63d9 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -627,17 +627,26 @@ func resolveScriptoriumInput(inputCfg config.ScriptoriumInputConfig, execution a return analyzeInputFailure(describeErr) } if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput { - resolvedPath, ok, err := resolvePreparedStableInput(descriptor.Source.ID, execution.Paths) - if err != nil { + identity, err := artifacts.ResolvePreparedInput(execution.Paths, execution.Manifest, descriptor.Source.ID) + if err == nil { + return analyzeInputFound(identity.Path, nil) + } + if errors.Is(err, artifacts.ErrPreparedInputAbsent) { if inputCfg.Required { - return analyzeInputFailure(err) + return analyzeInputFailure(fmt.Errorf( + "required prepared input source %q is unavailable; run narratio run-stage prepare %s --force", + descriptor.Source.ID, + execution.SessionID, + )) } return analyzeInputMissing() } - if !ok { - return analyzeInputMissing() - } - return analyzeInputFound(resolvedPath, nil) + return analyzeInputFailure(fmt.Errorf( + "prepared input source %q is invalid; run narratio run-stage prepare %s --force: %w", + descriptor.Source.ID, + execution.SessionID, + err, + )) } if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact { resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(execution.Paths, execution.Manifest, source, execution.Catalog) @@ -710,28 +719,6 @@ func requiredBuiltInInputError(source string, execution analyzeExecutionContext) ) } -func resolvePreparedStableInput(sourceID string, paths artifacts.SessionPaths) (string, bool, error) { - filename, ok := preparedStableInputFilename(sourceID) - if !ok { - return "", false, fmt.Errorf("unsupported prepared input source %q", sourceID) - } - path := filepath.Join(paths.InputsDir, filename) - if err := requireNonEmptyFile(path, "prepared input "+sourceID); err != nil { - return "", false, fmt.Errorf( - "prepared input source %q is unavailable; run narratio run-stage prepare %s --force: %w", - sourceID, - paths.SessionID, - err, - ) - } - return path, true, nil -} - -func preparedStableInputFilename(sourceID string) (string, bool) { - descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID) - return descriptor.Filename, ok -} - func buildAnalyzeRuntimeArtifactCatalog( paths artifacts.SessionPaths, m *manifest.Manifest, diff --git a/internal/stage/analyze_test.go b/internal/stage/analyze_test.go index c37a36a..aff412e 100644 --- a/internal/stage/analyze_test.go +++ b/internal/stage/analyze_test.go @@ -14,6 +14,7 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" + "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" @@ -1099,9 +1100,11 @@ func TestAnalyzeResolvesPreparedStableInputSources(t *testing.T) { playersPath := filepath.Join(paths.InputsDir, "players.yml") partyPath := filepath.Join(paths.InputsDir, "party.yml") glossaryPath := filepath.Join(paths.InputsDir, "glossary.yml") - writeAnalyzeFile(t, playersPath, "- Eric\n") - writeAnalyzeFile(t, partyPath, "- Arannis\n") - writeAnalyzeFile(t, glossaryPath, "- term: Ten Towns\n") + spellCatalogPath := filepath.Join(paths.InputsDir, "spell_catalog.json") + recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputPlayers, playersPath, "- Eric\n") + recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputParty, partyPath, "- Arannis\n") + recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputGlossary, glossaryPath, "- term: Ten Towns\n") + recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputSpellCatalog, spellCatalogPath, "{\"spells\":[]}\n") artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact.Inputs["players"] = config.ScriptoriumInputConfig{ @@ -1116,6 +1119,10 @@ func TestAnalyzeResolvesPreparedStableInputSources(t *testing.T) { Source: "narratio.input.glossary", Required: true, } + artifact.Inputs["spells"] = config.ScriptoriumInputConfig{ + Source: "narratio.input.spell_catalog", + Required: true, + } env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact _, err := (analyzeStage{}).Run(context.Background(), env, m) @@ -1134,6 +1141,9 @@ func TestAnalyzeResolvesPreparedStableInputSources(t *testing.T) { if fake.RunRequests[0].InputPaths["glossary"] != glossaryPath { t.Fatalf("glossary input = %q, want %q", fake.RunRequests[0].InputPaths["glossary"], glossaryPath) } + if fake.RunRequests[0].InputPaths["spells"] != spellCatalogPath { + t.Fatalf("spells input = %q, want %q", fake.RunRequests[0].InputPaths["spells"], spellCatalogPath) + } } func TestAnalyzeMissingRequiredPreparedStableInputFailsWithPrepareGuidance(t *testing.T) { @@ -1182,6 +1192,28 @@ func TestAnalyzeMissingOptionalPreparedStableInputIsOmitted(t *testing.T) { } } +func TestAnalyzeInvalidOptionalPreparedStableInputFailsWithPrepareGuidance(t *testing.T) { + env, m, _ := setupAnalyzeEnv(t) + paths := sessionPathsForEnv(env, m.SessionID) + writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`) + playersPath := filepath.Join(paths.InputsDir, "players.yml") + recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputPlayers, playersPath, "- Eric\n") + m.Inputs[len(m.Inputs)-1].Checksum = strings.Repeat("0", 64) + + artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] + artifact.Inputs["players"] = config.ScriptoriumInputConfig{ + Source: "narratio.input.players", + Required: false, + } + env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact + + _, err := (analyzeStage{}).Run(context.Background(), env, m) + if err == nil || !strings.Contains(err.Error(), "prepared input source \"narratio.input.players\" is invalid") || + !strings.Contains(err.Error(), "run narratio run-stage prepare 2026-05-03 --force") { + t.Fatalf("Run() error = %v, want invalid prepared input guidance", err) + } +} + func TestAnalyzeSupportsCanonicalNormalizedTranscriptSourceFromManifestOutput(t *testing.T) { env, m, fake := setupAnalyzeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) @@ -1650,6 +1682,24 @@ func writeAnalyzeFile(t *testing.T, path, contents string) { } } +func recordPreparedAnalyzeInput(t *testing.T, m *manifest.Manifest, sourceID, path, contents string) { + t.Helper() + writeAnalyzeFile(t, path, contents) + descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID) + if !ok { + t.Fatalf("DescribePreparedInputSource(%q) ok = false", sourceID) + } + checksum, err := artifacts.SHA256File(path) + if err != nil { + t.Fatalf("SHA256File(%q) error = %v", path, err) + } + m.Inputs = append(m.Inputs, manifest.InputRecord{ + Kind: descriptor.ManifestKind, + Path: path, + Checksum: checksum, + }) +} + func writeAnalyzeFileNoTest(path, contents string) { if strings.TrimSpace(path) == "" { return