Verify prepared inputs from manifest evidence
This commit is contained in:
@@ -18,7 +18,7 @@ different contract.
|
|||||||
| ---: | --- | --- |
|
| ---: | --- | --- |
|
||||||
| 1 | Add the reference-selector and configuration vocabulary, including optional spell-catalog inputs. | Completed |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 6 | Prove assembled extraction lifecycle and downstream invalidation behavior. | Pending |
|
||||||
|
|||||||
191
internal/artifacts/prepared_input.go
Normal file
191
internal/artifacts/prepared_input.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
222
internal/artifacts/prepared_input_test.go
Normal file
222
internal/artifacts/prepared_input_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -627,17 +627,26 @@ func resolveScriptoriumInput(inputCfg config.ScriptoriumInputConfig, execution a
|
|||||||
return analyzeInputFailure(describeErr)
|
return analyzeInputFailure(describeErr)
|
||||||
}
|
}
|
||||||
if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput {
|
if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput {
|
||||||
resolvedPath, ok, err := resolvePreparedStableInput(descriptor.Source.ID, execution.Paths)
|
identity, err := artifacts.ResolvePreparedInput(execution.Paths, execution.Manifest, descriptor.Source.ID)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
|
return analyzeInputFound(identity.Path, nil)
|
||||||
|
}
|
||||||
|
if errors.Is(err, artifacts.ErrPreparedInputAbsent) {
|
||||||
if inputCfg.Required {
|
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()
|
return analyzeInputMissing()
|
||||||
}
|
}
|
||||||
if !ok {
|
return analyzeInputFailure(fmt.Errorf(
|
||||||
return analyzeInputMissing()
|
"prepared input source %q is invalid; run narratio run-stage prepare %s --force: %w",
|
||||||
}
|
descriptor.Source.ID,
|
||||||
return analyzeInputFound(resolvedPath, nil)
|
execution.SessionID,
|
||||||
|
err,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact {
|
if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact {
|
||||||
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(execution.Paths, execution.Manifest, source, execution.Catalog)
|
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(
|
func buildAnalyzeRuntimeArtifactCatalog(
|
||||||
paths artifacts.SessionPaths,
|
paths artifacts.SessionPaths,
|
||||||
m *manifest.Manifest,
|
m *manifest.Manifest,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
"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/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
@@ -1099,9 +1100,11 @@ func TestAnalyzeResolvesPreparedStableInputSources(t *testing.T) {
|
|||||||
playersPath := filepath.Join(paths.InputsDir, "players.yml")
|
playersPath := filepath.Join(paths.InputsDir, "players.yml")
|
||||||
partyPath := filepath.Join(paths.InputsDir, "party.yml")
|
partyPath := filepath.Join(paths.InputsDir, "party.yml")
|
||||||
glossaryPath := filepath.Join(paths.InputsDir, "glossary.yml")
|
glossaryPath := filepath.Join(paths.InputsDir, "glossary.yml")
|
||||||
writeAnalyzeFile(t, playersPath, "- Eric\n")
|
spellCatalogPath := filepath.Join(paths.InputsDir, "spell_catalog.json")
|
||||||
writeAnalyzeFile(t, partyPath, "- Arannis\n")
|
recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputPlayers, playersPath, "- Eric\n")
|
||||||
writeAnalyzeFile(t, glossaryPath, "- term: Ten Towns\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 := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
|
||||||
artifact.Inputs["players"] = config.ScriptoriumInputConfig{
|
artifact.Inputs["players"] = config.ScriptoriumInputConfig{
|
||||||
@@ -1116,6 +1119,10 @@ func TestAnalyzeResolvesPreparedStableInputSources(t *testing.T) {
|
|||||||
Source: "narratio.input.glossary",
|
Source: "narratio.input.glossary",
|
||||||
Required: true,
|
Required: true,
|
||||||
}
|
}
|
||||||
|
artifact.Inputs["spells"] = config.ScriptoriumInputConfig{
|
||||||
|
Source: "narratio.input.spell_catalog",
|
||||||
|
Required: true,
|
||||||
|
}
|
||||||
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
|
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
|
||||||
|
|
||||||
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||||
@@ -1134,6 +1141,9 @@ func TestAnalyzeResolvesPreparedStableInputSources(t *testing.T) {
|
|||||||
if fake.RunRequests[0].InputPaths["glossary"] != glossaryPath {
|
if fake.RunRequests[0].InputPaths["glossary"] != glossaryPath {
|
||||||
t.Fatalf("glossary input = %q, want %q", 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) {
|
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) {
|
func TestAnalyzeSupportsCanonicalNormalizedTranscriptSourceFromManifestOutput(t *testing.T) {
|
||||||
env, m, fake := setupAnalyzeEnv(t)
|
env, m, fake := setupAnalyzeEnv(t)
|
||||||
paths := sessionPathsForEnv(env, m.SessionID)
|
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) {
|
func writeAnalyzeFileNoTest(path, contents string) {
|
||||||
if strings.TrimSpace(path) == "" {
|
if strings.TrimSpace(path) == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user