diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 0dddb07..9cdecc0 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -17,7 +17,7 @@ different contract. | Stage | Outcome | Status | | ---: | --- | --- | | 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. | Pending | +| 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 | | 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 | diff --git a/internal/app/operator_helpers_test.go b/internal/app/operator_helpers_test.go index 0ad4413..4499a7e 100644 --- a/internal/app/operator_helpers_test.go +++ b/internal/app/operator_helpers_test.go @@ -458,6 +458,63 @@ inputs: } } +func TestOperatorCommandsReportConfiguredSpellCatalog(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + campaignBytes, err := os.ReadFile(campaignPath) + if err != nil { + t.Fatalf("read campaign: %v", err) + } + campaignYAML := strings.Replace(string(campaignBytes), " party_file: ./party.yml\n", " party_file: ./party.yml\n spell_catalog_file: ./spells.json\n", 1) + if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { + t.Fatalf("write campaign: %v", err) + } + spellPath := filepath.Join(filepath.Dir(campaignPath), "spells.json") + mustWriteTestFile(t, spellPath, "{\"spells\":[]}\n") + + fake := &storage.FakeBackend{} + var storeInitCalls int + restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) + commonArgs := []string{ + "2026-05-03", + "--config", pipelinePath, + "--campaign-file", campaignPath, + "--session", sessionPath, + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + validateArgs := append([]string{"session", "validate"}, commonArgs...) + if code := Execute(validateArgs, &stdout, &stderr); code != 0 { + t.Fatalf("validate exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "OK inputs spell_catalog: "+spellPath) { + t.Fatalf("validate stdout = %q, want spell catalog finding", stdout.String()) + } + + stdout.Reset() + stderr.Reset() + statusArgs := append([]string{"session", "status"}, commonArgs...) + if code := Execute(statusArgs, &stdout, &stderr); code != 0 { + t.Fatalf("status exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "Stable input spell_catalog: "+spellPath) { + t.Fatalf("status stdout = %q, want spell catalog inventory", stdout.String()) + } + + if err := os.Remove(spellPath); err != nil { + t.Fatalf("remove spell catalog: %v", err) + } + stdout.Reset() + stderr.Reset() + if code := Execute(validateArgs, &stdout, &stderr); code == 0 { + t.Fatalf("validate missing spell catalog exit code = 0; stdout=%q", stdout.String()) + } + if !strings.Contains(stdout.String(), "ERROR inputs spell_catalog missing:") { + t.Fatalf("validate stdout = %q, want missing spell catalog finding", stdout.String()) + } +} + func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) diff --git a/internal/app/operator_inspection.go b/internal/app/operator_inspection.go index d90aacd..0a05d6d 100644 --- a/internal/app/operator_inspection.go +++ b/internal/app/operator_inspection.go @@ -53,23 +53,28 @@ type effectiveLocksCheck struct { func inspectStableInputs(cfg *config.Config) []stableInputCheck { items := []struct { - name string - in config.ResolvedInputFile + name string + in config.ResolvedInputFile + optional bool }{ {name: "speakers", in: cfg.StableInputs.SpeakersFile}, {name: "autocorrect", in: cfg.StableInputs.AutocorrectFile}, {name: "glossary", in: cfg.StableInputs.GlossaryFile}, {name: "players", in: cfg.StableInputs.PlayersFile}, {name: "party", in: cfg.StableInputs.PartyFile}, + {name: "spell_catalog", in: cfg.StableInputs.SpellCatalogFile, optional: true}, } out := make([]stableInputCheck, 0, len(items)) for _, item := range items { + if item.optional && strings.TrimSpace(item.in.Path) == "" { + continue + } path, err := resolveHelperConfigRelativePath(item.in) if err != nil { out = append(out, stableInputCheck{Name: item.name, Err: err}) continue } - if _, err := os.Stat(path); err != nil { + if err := requireInspectionFile(path, item.name); err != nil { out = append(out, stableInputCheck{Name: item.name, Path: path, Err: err}) continue } diff --git a/internal/fileops/cleanup.go b/internal/fileops/cleanup.go index 26a8b78..069c9f1 100644 --- a/internal/fileops/cleanup.go +++ b/internal/fileops/cleanup.go @@ -22,6 +22,32 @@ func RemoveAllUnderRoot(rootPath, target string) error { return removeConfinedEntry(root, targetName) } +// RemoveFileUnderRoot removes an exact regular-file target below root without +// following symlinked ancestors or the leaf. A missing target is successful; +// directories, symlinks, and other non-regular entries are rejected. +func RemoveFileUnderRoot(rootPath, target string) error { + root, targetName, err := openConfinedCleanupTarget(rootPath, target) + if err != nil { + return err + } + defer func() { _ = root.Close() }() + + info, err := root.Lstat(targetName) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("inspect cleanup file %q: %w", targetName, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("refusing to delete non-regular file path %q", targetName) + } + if err := root.Remove(targetName); err != nil { + return fmt.Errorf("remove cleanup file %q: %w", targetName, err) + } + return nil +} + func openConfinedCleanupTarget(rootPath, target string) (*os.Root, string, error) { if strings.TrimSpace(rootPath) == "" { return nil, "", fmt.Errorf("cleanup root is required") diff --git a/internal/fileops/cleanup_test.go b/internal/fileops/cleanup_test.go index a5bdeea..4b43b3c 100644 --- a/internal/fileops/cleanup_test.go +++ b/internal/fileops/cleanup_test.go @@ -1,8 +1,10 @@ package fileops import ( + "errors" "os" "path/filepath" + "strings" "testing" ) @@ -77,3 +79,47 @@ func TestRemoveAllUnderRootRejectsSymlinkInTree(t *testing.T) { t.Fatalf("outside sentinel was changed: %v", err) } } + +func TestRemoveFileUnderRootRemovesOnlyRegularFile(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "inputs", "spell_catalog.json") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(target, []byte("{}\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if err := RemoveFileUnderRoot(root, target); err != nil { + t.Fatalf("RemoveFileUnderRoot() error = %v", err) + } + if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Lstat() error = %v, want not exist", err) + } + if err := RemoveFileUnderRoot(root, target); err != nil { + t.Fatalf("RemoveFileUnderRoot(missing) error = %v", err) + } + + for _, tt := range []struct { + name string + setup func(string) error + }{ + {name: "directory", setup: func(path string) error { return os.Mkdir(path, 0o755) }}, + {name: "symlink", setup: func(path string) error { return os.Symlink(filepath.Join(root, "outside"), path) }}, + } { + t.Run(tt.name, func(t *testing.T) { + if err := tt.setup(target); err != nil { + t.Fatalf("setup target: %v", err) + } + err := RemoveFileUnderRoot(root, target) + if err == nil || !strings.Contains(err.Error(), "non-regular") { + t.Fatalf("RemoveFileUnderRoot() error = %v, want non-regular rejection", err) + } + if _, err := os.Lstat(target); err != nil { + t.Fatalf("ambiguous target was removed: %v", err) + } + if err := os.Remove(target); err != nil { + t.Fatalf("cleanup target: %v", err) + } + }) + } +} diff --git a/internal/stage/prepare.go b/internal/stage/prepare.go index 8437065..beaaad2 100644 --- a/internal/stage/prepare.go +++ b/internal/stage/prepare.go @@ -12,6 +12,7 @@ import ( "strings" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" + "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/audio" "gitea.maximumdirect.net/eric/narratio/internal/config" @@ -64,6 +65,7 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S glossaryInput := stableInputSource(env.Config.StableInputs.GlossaryFile, env.Config.Session.Inputs.GlossaryFile, sessionSrc) playersInput := stableInputSource(env.Config.StableInputs.PlayersFile, env.Config.Session.Inputs.PlayersFile, sessionSrc) partyInput := stableInputSource(env.Config.StableInputs.PartyFile, env.Config.Session.Inputs.PartyFile, sessionSrc) + spellCatalogInput := stableInputSource(env.Config.StableInputs.SpellCatalogFile, env.Config.Session.Inputs.SpellCatalogFile, sessionSrc) speakersSrc, err := resolveConfigRelativePath(speakersInput) if err != nil { @@ -85,6 +87,17 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S if err != nil { return nil, fmt.Errorf("prepare: party path: %w", err) } + spellCatalogConfigured := strings.TrimSpace(spellCatalogInput.Path) != "" + spellCatalogSrc := "" + if spellCatalogConfigured { + spellCatalogSrc, err = resolveConfigRelativePath(spellCatalogInput) + if err != nil { + return nil, fmt.Errorf("prepare: spell catalog path: %w", err) + } + if err := requireRegularReadableFile(spellCatalogSrc, "spell catalog"); err != nil { + return nil, fmt.Errorf("prepare: %w", err) + } + } for _, required := range []struct { path string @@ -106,7 +119,7 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S return nil, fmt.Errorf("prepare: resolve audio inputs: %w", err) } - inputs := make([]manifest.InputRecord, 0, 8+len(resolvedLocalAudio)) + inputs := make([]manifest.InputRecord, 0, 9+len(resolvedLocalAudio)) registerInput := func(kind, path, checksum string) { inputs = append(inputs, manifest.InputRecord{Kind: kind, Path: path, Checksum: checksum}) } @@ -155,18 +168,36 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S } registerInput("pipeline_resolved", pipelineDst, pipelineChecksum) - for _, cfgFile := range []struct { + type preparedConfigFile struct { kind string src string dst string source string - }{ + } + preparedConfigFiles := []preparedConfigFile{ {kind: "speakers", src: speakersSrc, dst: filepath.Join(paths.InputsDir, "speakers.yml"), source: speakersInput.Source}, {kind: "autocorrect", src: autocorrectSrc, dst: filepath.Join(paths.InputsDir, "autocorrect.yml"), source: autocorrectInput.Source}, {kind: "glossary", src: glossarySrc, dst: filepath.Join(paths.InputsDir, "glossary.yml"), source: glossaryInput.Source}, {kind: "players", src: playersSrc, dst: filepath.Join(paths.InputsDir, "players.yml"), source: playersInput.Source}, {kind: "party", src: partySrc, dst: filepath.Join(paths.InputsDir, "party.yml"), source: partyInput.Source}, - } { + } + spellDescriptor, ok := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputSpellCatalog) + if !ok { + return nil, fmt.Errorf("prepare: spell catalog prepared-input descriptor is unavailable") + } + spellCatalogDst := filepath.Join(paths.InputsDir, spellDescriptor.Filename) + if spellCatalogConfigured { + preparedConfigFiles = append(preparedConfigFiles, preparedConfigFile{ + kind: spellDescriptor.ManifestKind, + src: spellCatalogSrc, + dst: spellCatalogDst, + source: spellCatalogInput.Source, + }) + } else if err := fileops.RemoveFileUnderRoot(paths.Root, spellCatalogDst); err != nil { + return nil, fmt.Errorf("prepare: remove obsolete spell catalog: %w", err) + } + + for _, cfgFile := range preparedConfigFiles { checksum, err := copyFileIfChanged(env.ArtifactStore, cfgFile.src, cfgFile.dst) if err != nil { return nil, fmt.Errorf("prepare: materialize %s: %w", cfgFile.kind, err) @@ -585,6 +616,28 @@ func requireFile(path string, label string) error { return nil } +func requireRegularReadableFile(path string, label string) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("%s path is required", label) + } + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("open %s %q: %w", label, path, err) + } + info, statErr := file.Stat() + closeErr := file.Close() + if statErr != nil { + return fmt.Errorf("stat %s %q: %w", label, path, statErr) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s %q is not a regular file", label, path) + } + if closeErr != nil { + return fmt.Errorf("close %s %q: %w", label, path, closeErr) + } + return nil +} + func isFlac(path string) bool { return strings.EqualFold(filepath.Ext(path), ".flac") } diff --git a/internal/stage/prepare_test.go b/internal/stage/prepare_test.go index 277c103..1d527a4 100644 --- a/internal/stage/prepare_test.go +++ b/internal/stage/prepare_test.go @@ -2,6 +2,8 @@ package stage import ( "context" + "crypto/sha256" + "encoding/hex" "os" "path/filepath" "strings" @@ -154,6 +156,167 @@ func TestPrepareStageIdempotent(t *testing.T) { } } +func TestPrepareStageMaterializesSpellCatalogWithProvenance(t *testing.T) { + tests := []struct { + name string + configPath func(*config.Config) string + source string + }{ + {name: "campaign", configPath: func(cfg *config.Config) string { return cfg.CampaignPath }, source: "campaign_config"}, + {name: "session", configPath: func(cfg *config.Config) string { return cfg.SessionPath }, source: "session_config"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env, m := setupPrepareEnv(t) + root := filepath.Dir(env.Config.SessionPath) + writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio") + env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"} + + payload := []byte(`{"spells":[{"name":"Fireball"}]}` + "\n") + sourcePath := filepath.Join(filepath.Dir(tt.configPath(env.Config)), tt.name+"-spells.json") + writeFile(t, sourcePath, string(payload)) + env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{ + Path: filepath.Base(sourcePath), + ConfigPath: tt.configPath(env.Config), + Source: tt.source, + } + + if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil { + t.Fatalf("prepare.Run() error = %v", err) + } + + destination := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "spell_catalog.json") + got, err := os.ReadFile(destination) + if err != nil { + t.Fatalf("ReadFile(destination) error = %v", err) + } + if string(got) != string(payload) { + t.Fatalf("destination bytes = %q, want %q", got, payload) + } + record := findManifestInput(t, m.Inputs, "spell_catalog") + digest := sha256.Sum256(payload) + wantChecksum := hex.EncodeToString(digest[:]) + if record.Path != destination || record.Source != tt.source || record.Checksum != wantChecksum { + t.Fatalf("spell catalog record = %#v, want path=%q source=%q checksum=%q", record, destination, tt.source, wantChecksum) + } + assertManifestInputsSorted(t, m.Inputs) + }) + } +} + +func TestPrepareStageSpellCatalogOptionalOmission(t *testing.T) { + env, m := setupPrepareEnv(t) + root := filepath.Dir(env.Config.SessionPath) + writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio") + env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"} + + if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil { + t.Fatalf("prepare.Run() error = %v", err) + } + for _, input := range m.Inputs { + if input.Kind == "spell_catalog" { + t.Fatalf("unexpected spell catalog record: %#v", input) + } + } + destination := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "spell_catalog.json") + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("Lstat(destination) error = %v, want not exist", err) + } +} + +func TestPrepareStageSpellCatalogMissingOrNonRegularSource(t *testing.T) { + tests := []struct { + name string + setup func(string) error + }{ + {name: "missing", setup: func(string) error { return nil }}, + {name: "directory", setup: func(path string) error { return os.Mkdir(path, 0o755) }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env, m := setupPrepareEnv(t) + root := filepath.Dir(env.Config.SessionPath) + writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio") + env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"} + sourcePath := filepath.Join(root, "spells.json") + if err := tt.setup(sourcePath); err != nil { + t.Fatalf("setup source: %v", err) + } + env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{ + Path: "./spells.json", + ConfigPath: env.Config.CampaignPath, + Source: "campaign_config", + } + + _, err := (prepareStage{}).Run(context.Background(), env, m) + if err == nil || !strings.Contains(err.Error(), "spell catalog") { + t.Fatalf("prepare.Run() error = %v, want spell catalog source error", err) + } + }) + } +} + +func TestPrepareStageReplacesAndRemovesSpellCatalog(t *testing.T) { + env, m := setupPrepareEnv(t) + root := filepath.Dir(env.Config.SessionPath) + writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio") + env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"} + sourcePath := filepath.Join(root, "spells.json") + writeFile(t, sourcePath, "first\n") + env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{ + Path: "./spells.json", + ConfigPath: env.Config.CampaignPath, + Source: "campaign_config", + } + + stage := prepareStage{} + if _, err := stage.Run(context.Background(), env, m); err != nil { + t.Fatalf("first prepare.Run() error = %v", err) + } + firstChecksum := findManifestInput(t, m.Inputs, "spell_catalog").Checksum + destination := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "spell_catalog.json") + + writeFile(t, sourcePath, "second\n") + if _, err := stage.Run(context.Background(), env, m); err != nil { + t.Fatalf("replacement prepare.Run() error = %v", err) + } + secondChecksum := findManifestInput(t, m.Inputs, "spell_catalog").Checksum + if secondChecksum == firstChecksum { + t.Fatalf("replacement checksum = %q, want different from %q", secondChecksum, firstChecksum) + } + mustReadFileEquals(t, destination, "second\n") + + env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{} + env.Config.Session.Inputs.SpellCatalogFile = "" + if _, err := stage.Run(context.Background(), env, m); err != nil { + t.Fatalf("removal prepare.Run() error = %v", err) + } + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("Lstat(destination) error = %v, want removed", err) + } + for _, input := range m.Inputs { + if input.Kind == "spell_catalog" { + t.Fatalf("stale spell catalog record remains: %#v", input) + } + } +} + +func TestPrepareStageRefusesAmbiguousObsoleteSpellCatalog(t *testing.T) { + env, m := setupPrepareEnv(t) + root := filepath.Dir(env.Config.SessionPath) + writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio") + env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"} + destination := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "spell_catalog.json") + writeFile(t, filepath.Join(destination, "keep.txt"), "keep") + + _, err := (prepareStage{}).Run(context.Background(), env, m) + if err == nil || !strings.Contains(err.Error(), "remove obsolete spell catalog") || !strings.Contains(err.Error(), "non-regular") { + t.Fatalf("prepare.Run() error = %v, want ambiguous destination rejection", err) + } + mustReadFileEquals(t, filepath.Join(destination, "keep.txt"), "keep") +} + func TestPrepareStageRecordsLocalSessionProvenance(t *testing.T) { env, m := setupPrepareEnv(t) root := filepath.Dir(env.Config.SessionPath) @@ -719,6 +882,17 @@ func findManifestInput(t *testing.T, inputs []manifest.InputRecord, kind string) return manifest.InputRecord{} } +func assertManifestInputsSorted(t *testing.T, inputs []manifest.InputRecord) { + t.Helper() + for index := 1; index < len(inputs); index++ { + previous := inputs[index-1] + current := inputs[index] + if previous.Kind > current.Kind || (previous.Kind == current.Kind && previous.Path > current.Path) { + t.Fatalf("manifest inputs are not sorted at %d: %#v before %#v", index, previous, current) + } + } +} + func mustReadFileEquals(t *testing.T, path, want string) { t.Helper() data, err := os.ReadFile(path)