Validate portable workspace identifiers

This commit is contained in:
2026-08-10 17:35:52 +00:00
parent 0b40cf8026
commit 1dccf5f140
31 changed files with 490 additions and 48 deletions

View File

@@ -38,6 +38,15 @@ If local session discovery fails and a `session_id` is known, Narratio attempts
using configured object storage.
### Identity segments
Campaign IDs (`campaign_id` and `default_campaign_id`), session IDs, previous
session IDs, and Narratio run IDs are opaque portable segments. They must use
only ASCII letters, digits, `.`, `_`, and `-`; empty values, `.`/`..`, path
separators, drive forms, whitespace, control characters, and non-ASCII text are
rejected. Narratio does not trim or rewrite these values. Existing manifests or
remote state with an unsafe legacy identity must be migrated before use.
## Validation and Merge Rules
- YAML decode is strict (`KnownFields(true)`): unknown fields fail load.
@@ -272,7 +281,7 @@ For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_nam
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `campaign_id` | string | Yes | canonical campaign identity |
| `campaign_id` | string | Yes | canonical opaque campaign identity |
| `session_template_file` | string | No | used by `session init` when set |
| `inputs.speakers_file` | string | Yes | stable input default |
| `inputs.autocorrect_file` | string | Yes | stable input default |
@@ -284,9 +293,9 @@ For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_nam
| Field | Type | Required in session file | Notes |
| --- | --- | --- | --- |
| `session_id` | string | Yes | must match CLI session target when provided |
| `previous_session_id` | string | No | must not equal `session_id` |
| `campaign` | string | No | filled from `campaign_id` during resolve if omitted |
| `session_id` | string | Yes | opaque identity; must match CLI session target when provided |
| `previous_session_id` | string | No | opaque identity; must not equal `session_id` |
| `campaign` | string | No | opaque identity; filled from `campaign_id` during resolve if omitted |
| `date` | string | No | metadata |
| `title` | string | No | metadata |
| `inputs.speakers_file` | string | No | overrides campaign stable input |

View File

@@ -30,9 +30,11 @@ and content validator. The focused stage documents own their input/output flow;
- extraction source ID format: `narratio.extraction.<output_key>`
- previous-session source ID format: `narratio.previous_session.artifact.<artifact_key>`
All formats are validated by strict source-policy rules. Extraction sources are
registered only from `pipeline.notarius.outputs`; the Notarius index has no
selectable source ID.
All formats are validated by strict source-policy rules. Configured artifact and
extraction keys use `^[a-z][a-z0-9_]*$`; source parsers never normalize an
unrecognized token into a valid source. Extraction sources are registered only
from `pipeline.notarius.outputs`; the Notarius index has no selectable source
ID.
## Runtime Catalog
@@ -131,6 +133,12 @@ Caller policy is intentionally outside artifacts helpers:
- spool/cache paths;
- S3 session/run/current-state key layout.
Campaign, session, and Narratio run IDs are validated as portable opaque
segments at configuration and artifact boundaries before they can be used in a
workspace or S3 namespace. Previous-artifact destinations remain typed,
multi-segment relative paths and are confined beneath `previous/artifacts`; they
are not treated as opaque identifiers.
See [Workspace Internals](workspace.md) for how callers consume local helpers
and [Operations](../operations.md#local-state-layout) for the authoritative
physical layout.

View File

@@ -17,6 +17,11 @@ Explain the session-progress and invocation-audit models implemented by
- durable `artifacts` records
- per-stage `stages` map
Session, campaign, and run identities in local and downloaded manifests must be
portable opaque segments. Unsafe legacy identities are rejected with migration
guidance rather than being normalized into a different workspace or remote
namespace.
The model admits these stage states:
- `pending`

View File

@@ -17,7 +17,7 @@ All stages are pending when this plan is created.
| Stage | Summary | Primary findings | Status |
| ---: | --- | --- | --- |
| 1 | Align data classification and group workspace modes | RSK-004 | Completed |
| 2 | Enforce safe identifiers and fuzz path/source contracts | COR-002, TST-013 | Pending |
| 2 | Enforce safe identifiers and fuzz path/source contracts | COR-002, TST-013 | Completed |
| 3 | Consolidate crash-durable atomic file replacement | RSK-002, DUP-001, DUP-005 | Pending |
| 4 | Add confined destination and download/install capabilities | COR-003, DUP-003, TST-003 | Pending |
| 5 | Confine recursive cleanup and replace sentinel locks | RSK-003 | Pending |

View File

@@ -6,6 +6,7 @@ import (
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
func resolveCampaignConfigPath(pipelineCfg *config.PipelineConfig, campaignIDFlag, campaignFileFlag string) (string, error) {
@@ -33,12 +34,8 @@ func resolveCampaignConfigPath(pipelineCfg *config.PipelineConfig, campaignIDFla
}
func validateCampaignIDToken(campaignID string) error {
if filepath.IsAbs(campaignID) ||
strings.Contains(campaignID, "/") ||
strings.Contains(campaignID, `\`) ||
campaignID == "." ||
campaignID == ".." {
return fmt.Errorf("campaign id %q must be a single path segment", campaignID)
if err := pathsafe.ValidateOpaqueSegment(campaignID); err != nil {
return fmt.Errorf("campaign id %q must be a single path segment and opaque identifier: %w", campaignID, err)
}
return nil
}

View File

@@ -0,0 +1,16 @@
package app
import (
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
)
func mustPreviousArtifactPathForCampaign(t *testing.T, root, campaign, sessionID, relative string) string {
t.Helper()
path, err := artifacts.SessionPreviousArtifactPathForCampaign(root, campaign, sessionID, relative)
if err != nil {
t.Fatalf("SessionPreviousArtifactPathForCampaign() error = %v", err)
}
return path
}

View File

@@ -277,7 +277,7 @@ func cleanupFixtureConfig(t *testing.T) (*config.Config, cleanupSeed) {
runWorkDir := artifacts.SessionRunRootForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, runID)
otherRunDir := artifacts.SessionRunRootForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, "20260516T010204Z-5e6f7a8b")
spoolAudioDir := artifacts.SessionSpoolAudioDir(cfg.Pipeline.Spool.Root, cfg.Session.Campaign, cfg.Session.SessionID, runID)
previousCachePath := artifacts.SessionPreviousArtifactPathForCampaign(
previousCachePath := mustPreviousArtifactPathForCampaign(t,
cfg.Pipeline.Workspace.Root,
cfg.Session.Campaign,
cfg.Session.SessionID,

View File

@@ -0,0 +1,32 @@
package artifactpolicy
import "testing"
func FuzzArtifactSourceParsers(f *testing.F) {
for _, seed := range []string{
"narratio.artifact.session_recap",
"narratio.extraction.npc_registry",
"narratio.previous_session.artifact.session_recap",
"narratio.artifact../escape",
`narratio.artifact.key\name`,
"narratio.artifact.café",
} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, source string) {
for _, parse := range []func(string) (string, bool){ParseConfiguredSource, ParseExtractionSource, ParsePreviousSessionSource} {
if key, ok := parse(source); ok && !IsConfiguredKey(key) {
t.Fatalf("parser accepted non-configured key %q from %q", key, source)
}
}
classified, err := ClassifySource(source)
if err == nil {
if classified.ID == "" {
t.Fatalf("ClassifySource(%q) returned an empty identifier", source)
}
if classified.ConfiguredKey != "" && !IsConfiguredKey(classified.ConfiguredKey) {
t.Fatalf("ClassifySource(%q) returned invalid configured key %q", source, classified.ConfiguredKey)
}
}
})
}

View File

@@ -391,7 +391,11 @@ func previousSessionCacheCandidatePaths(paths SessionPaths, canonicalRelPath str
out := make([]string, 0, len(relCandidates))
seen := map[string]struct{}{}
for _, rel := range relCandidates {
abs := filepath.Clean(SessionPreviousArtifactPath(paths, rel))
abs, err := SessionPreviousArtifactPath(paths, rel)
if err != nil {
continue
}
abs = filepath.Clean(abs)
if _, ok := seen[abs]; ok {
continue
}

View File

@@ -436,8 +436,8 @@ func TestResolvePreviousSessionArtifactWithCatalogPrefersManifestInputRecord(t *
workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session")
manifestBackedPath := SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
fallbackPath := SessionPreviousArtifactPath(paths, "session_recap.md")
manifestBackedPath := mustSessionPreviousArtifactPath(t, paths, "artifacts/session_recap.md")
fallbackPath := mustSessionPreviousArtifactPath(t, paths, "session_recap.md")
if err := os.MkdirAll(filepath.Dir(manifestBackedPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
@@ -487,7 +487,7 @@ func TestResolvePreviousSessionArtifactWithCatalogFallsBackToPreparedCachePath(t
workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session")
fallbackPath := SessionPreviousArtifactPath(paths, "session_recap.md")
fallbackPath := mustSessionPreviousArtifactPath(t, paths, "session_recap.md")
if err := os.MkdirAll(filepath.Dir(fallbackPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}

View File

@@ -92,8 +92,8 @@ func (c *ArtifactCatalog) RegisterExtractionArtifacts(configured map[string]Extr
for _, key := range keys {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
return fmt.Errorf("extraction artifact keys must be non-empty")
if !artifactpolicy.IsConfiguredKey(trimmed) {
return fmt.Errorf("extraction artifact key %q must match ^[a-z][a-z0-9_]*$", key)
}
if _, exists := c.extractionIndex[trimmed]; exists {
return fmt.Errorf("duplicate extraction artifact key %q", trimmed)
@@ -152,16 +152,16 @@ func (c *ArtifactCatalog) RegisterConfiguredArtifacts(
selectedSet := map[string]struct{}{}
for _, key := range selected {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
return fmt.Errorf("selected artifact keys must be non-empty")
if !artifactpolicy.IsConfiguredKey(trimmed) {
return fmt.Errorf("selected artifact key %q must match ^[a-z][a-z0-9_]*$", key)
}
selectedSet[trimmed] = struct{}{}
}
for _, key := range keys {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
return fmt.Errorf("configured artifact keys must be non-empty")
if !artifactpolicy.IsConfiguredKey(trimmed) {
return fmt.Errorf("configured artifact key %q must match ^[a-z][a-z0-9_]*$", key)
}
def := configured[key]
sourceID := ConfiguredArtifactSourceID(trimmed)

View File

@@ -114,6 +114,26 @@ func TestArtifactCatalogRejectsSelectedUnknownArtifact(t *testing.T) {
}
}
func TestArtifactCatalogRejectsUnsafeArtifactKeys(t *testing.T) {
unsafe := []string{"", "artifact/name", `artifact\name`, "artifact-name", "café"}
for _, key := range unsafe {
t.Run("configured", func(t *testing.T) {
catalog := NewArtifactCatalog()
err := catalog.RegisterConfiguredArtifacts(map[string]ConfiguredArtifactDefinition{key: {OutputPath: "artifacts/output.md"}}, nil)
if err == nil {
t.Fatalf("RegisterConfiguredArtifacts(%q) error = nil, want rejection", key)
}
})
t.Run("extraction", func(t *testing.T) {
catalog := NewArtifactCatalog()
err := catalog.RegisterExtractionArtifacts(map[string]ExtractionArtifactDefinition{key: {LaneID: "lane"}})
if err == nil {
t.Fatalf("RegisterExtractionArtifacts(%q) error = nil, want rejection", key)
}
})
}
}
func TestArtifactCatalogRejectsConfiguredSourceConflictAcrossRegistrations(t *testing.T) {
catalog := NewArtifactCatalog()
if err := catalog.RegisterConfiguredArtifacts(

View File

@@ -86,6 +86,9 @@ func LoadCurrentRunPointer(ctx context.Context, store storage.ObjectStore, curre
if runID == "" {
return "", fmt.Errorf("current run pointer %q is empty", key)
}
if err := ValidateRunIdentity(runID); err != nil {
return "", fmt.Errorf("current run pointer %q contains an unsafe legacy run id; migrate remote state before use: %w", key, err)
}
return runID, nil
}
@@ -163,6 +166,16 @@ func ValidateCurrentStateIdentity(state *CurrentState, validation CurrentStateVa
manifestSessionID := strings.TrimSpace(state.Manifest.SessionID)
manifestCampaign := strings.TrimSpace(state.Manifest.Campaign)
manifestRunID := strings.TrimSpace(state.Manifest.RunID)
if manifestCampaign != "" {
if err := ValidateSessionIdentity(manifestCampaign, manifestSessionID); err != nil {
return fmt.Errorf("current manifest contains unsafe legacy identities; migrate remote state before use: %w", err)
}
}
if manifestRunID != "" {
if err := ValidateRunIdentity(manifestRunID); err != nil {
return fmt.Errorf("current manifest contains an unsafe legacy run id; migrate remote state before use: %w", err)
}
}
if expectedSessionID != "" && manifestSessionID != expectedSessionID {
return fmt.Errorf(

View File

@@ -0,0 +1,40 @@
package artifacts
import (
"os"
"path/filepath"
"testing"
)
func TestEnsureLayoutForRejectsUnsafeIdentityBeforeFilesystemAccess(t *testing.T) {
workspace := t.TempDir()
store := NewLocalStore(workspace)
for _, identity := range []struct {
name string
campaign string
session string
}{
{name: "campaign traversal", campaign: "../outside", session: "session"},
{name: "session separator", campaign: "campaign", session: `session\other`},
{name: "session drive", campaign: "campaign", session: `C:\outside`},
{name: "unicode", campaign: "café", session: "session"},
} {
t.Run(identity.name, func(t *testing.T) {
if _, err := store.EnsureLayoutFor(identity.campaign, identity.session); err == nil {
t.Fatal("EnsureLayoutFor() error = nil, want unsafe identity rejection")
}
if _, err := os.Stat(filepath.Join(workspace, "work")); !os.IsNotExist(err) {
t.Fatalf("workspace path was created for rejected identity, stat err = %v", err)
}
})
}
}
func TestSessionPreviousArtifactPathRejectsEscapingRelativePath(t *testing.T) {
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
for _, relative := range []string{"../escape.json", "/absolute.json", `C:\escape.json`} {
if _, err := SessionPreviousArtifactPath(paths, relative); err == nil {
t.Fatalf("SessionPreviousArtifactPath(%q) error = nil, want confinement failure", relative)
}
}
}

View File

@@ -51,6 +51,9 @@ func (s *LocalStore) EnsureLayoutFor(campaign, sessionID string) (SessionPaths,
if campaign == "" {
return SessionPaths{}, fmt.Errorf("campaign is required")
}
if err := ValidateSessionIdentity(campaign, sessionID); err != nil {
return SessionPaths{}, err
}
return s.ensureLayout(s.SessionPathsFor(campaign, sessionID))
}
@@ -62,6 +65,9 @@ func (s *LocalStore) ensureLayout(paths SessionPaths) (SessionPaths, error) {
if strings.TrimSpace(paths.SessionID) == "" {
return SessionPaths{}, fmt.Errorf("sessionID is required")
}
if err := ValidateSessionIdentity(paths.CampaignID, paths.SessionID); err != nil {
return SessionPaths{}, err
}
dirs := []string{
paths.Root,

View File

@@ -7,6 +7,7 @@ import (
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
// SessionPaths contains canonical local paths for one session work directory.
@@ -63,11 +64,15 @@ func SessionPreviousArtifactsDirForCampaign(rootDir, campaign, sessionID string)
return filepath.Join(SessionPreviousDirForCampaign(rootDir, campaign, sessionID), config.PathArtifactsDirSegment)
}
// SessionPreviousArtifactPathForCampaign returns a path under previous/artifacts for one artifact.
func SessionPreviousArtifactPathForCampaign(rootDir, campaign, sessionID, artifactRelativePath string) string {
return filepath.Join(
// SessionPreviousArtifactPathForCampaign returns a confined path under
// previous/artifacts for one artifact.
func SessionPreviousArtifactPathForCampaign(rootDir, campaign, sessionID, artifactRelativePath string) (string, error) {
if err := ValidateSessionIdentity(campaign, sessionID); err != nil {
return "", err
}
return pathsafe.JoinSlashRelativeUnderRoot(
SessionPreviousArtifactsDirForCampaign(rootDir, campaign, sessionID),
filepath.FromSlash(previousArtifactCacheRelativePath(artifactRelativePath)),
previousArtifactCacheRelativePath(artifactRelativePath),
)
}
@@ -190,9 +195,34 @@ func SessionPreviousArtifactsDir(paths SessionPaths) string {
return paths.PreviousArtifactsDir
}
// SessionPreviousArtifactPath returns a path under previous/artifacts for already-resolved session paths.
func SessionPreviousArtifactPath(paths SessionPaths, artifactRelativePath string) string {
return filepath.Join(paths.PreviousArtifactsDir, filepath.FromSlash(previousArtifactCacheRelativePath(artifactRelativePath)))
// SessionPreviousArtifactPath returns a confined path under previous/artifacts
// for already-resolved session paths.
func SessionPreviousArtifactPath(paths SessionPaths, artifactRelativePath string) (string, error) {
if err := ValidateSessionIdentity(paths.CampaignID, paths.SessionID); err != nil {
return "", err
}
return pathsafe.JoinSlashRelativeUnderRoot(paths.PreviousArtifactsDir, previousArtifactCacheRelativePath(artifactRelativePath))
}
// ValidateSessionIdentity validates the opaque components used in workspace
// and object-store session paths.
func ValidateSessionIdentity(campaign, sessionID string) error {
if err := pathsafe.ValidateOpaqueSegment(campaign); err != nil {
return fmt.Errorf("campaign identity: %w", err)
}
if err := pathsafe.ValidateOpaqueSegment(sessionID); err != nil {
return fmt.Errorf("session identity: %w", err)
}
return nil
}
// ValidateRunIdentity validates the opaque component used below a session's
// run directory and object-store prefix.
func ValidateRunIdentity(runID string) error {
if err := pathsafe.ValidateOpaqueSegment(runID); err != nil {
return fmt.Errorf("run identity: %w", err)
}
return nil
}
func previousArtifactCacheRelativePath(artifactRelativePath string) string {

View File

@@ -96,15 +96,15 @@ func TestSessionPreviousPathsForCampaign(t *testing.T) {
t.Fatalf("SessionPreviousArtifactsDirForCampaign() = %q, want %q", artifactsDir, wantArtifactsDir)
}
artifactPath := SessionPreviousArtifactPathForCampaign(root, "forsaken", "2026-04-19", "session_recap.md")
artifactPath := mustSessionPreviousArtifactPathForCampaign(t, root, "forsaken", "2026-04-19", "session_recap.md")
wantArtifactPath := filepath.Join(root, "work", "forsaken", "2026-04-19", "previous", "artifacts", "session_recap.md")
if artifactPath != wantArtifactPath {
t.Fatalf("SessionPreviousArtifactPathForCampaign() = %q, want %q", artifactPath, wantArtifactPath)
t.Fatalf("mustSessionPreviousArtifactPathForCampaign(t, ) = %q, want %q", artifactPath, wantArtifactPath)
}
previousRelativeArtifactPath := SessionPreviousArtifactPathForCampaign(root, "forsaken", "2026-04-19", "artifacts/session_recap.md")
previousRelativeArtifactPath := mustSessionPreviousArtifactPathForCampaign(t, root, "forsaken", "2026-04-19", "artifacts/session_recap.md")
if previousRelativeArtifactPath != wantArtifactPath {
t.Fatalf("SessionPreviousArtifactPathForCampaign(previous-relative) = %q, want %q", previousRelativeArtifactPath, wantArtifactPath)
t.Fatalf("mustSessionPreviousArtifactPathForCampaign(t, previous-relative) = %q, want %q", previousRelativeArtifactPath, wantArtifactPath)
}
}
@@ -120,13 +120,13 @@ func TestSessionPreviousPathsFromSessionPaths(t *testing.T) {
t.Fatalf("SessionPreviousArtifactsDir() = %q, want %q", got, paths.PreviousArtifactsDir)
}
got := SessionPreviousArtifactPath(paths, "quest_log.json")
got := mustSessionPreviousArtifactPath(t, paths, "quest_log.json")
want := filepath.Join(paths.PreviousArtifactsDir, "quest_log.json")
if got != want {
t.Fatalf("SessionPreviousArtifactPath() = %q, want %q", got, want)
}
got = SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
got = mustSessionPreviousArtifactPath(t, paths, "artifacts/session_recap.md")
want = filepath.Join(paths.PreviousArtifactsDir, "session_recap.md")
if got != want {
t.Fatalf("SessionPreviousArtifactPath(previous-relative) = %q, want %q", got, want)

View File

@@ -0,0 +1,21 @@
package artifacts
import "testing"
func mustSessionPreviousArtifactPath(t *testing.T, paths SessionPaths, relative string) string {
t.Helper()
path, err := SessionPreviousArtifactPath(paths, relative)
if err != nil {
t.Fatalf("SessionPreviousArtifactPath() error = %v", err)
}
return path
}
func mustSessionPreviousArtifactPathForCampaign(t *testing.T, root, campaign, sessionID, relative string) string {
t.Helper()
path, err := SessionPreviousArtifactPathForCampaign(root, campaign, sessionID, relative)
if err != nil {
t.Fatalf("SessionPreviousArtifactPathForCampaign() error = %v", err)
}
return path
}

View File

@@ -39,6 +39,9 @@ func ResolvePublishSessionPrefix(cfg *config.Config, m *manifest.Manifest) (stri
if cfg.Pipeline.Storage.S3 == nil {
return "", fmt.Errorf("pipeline.storage.s3 configuration is required")
}
if err := ValidateSessionIdentity(campaign, sessionID); err != nil {
return "", err
}
sessionPrefix := S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, campaign, sessionID)
if strings.TrimSpace(sessionPrefix) == "" {
@@ -68,6 +71,9 @@ func ResolvePublishRunPrefix(cfg *config.Config, m *manifest.Manifest) (string,
if runID == "" {
return "", fmt.Errorf("run id is required")
}
if err := ValidateRunIdentity(runID); err != nil {
return "", err
}
return S3RunPrefix(sessionPrefix, runID), nil
}

View File

@@ -0,0 +1,25 @@
package config
import (
"strings"
"testing"
)
func TestIdentityValidationRejectsUnsafeSegments(t *testing.T) {
unsafe := []string{"../escape", `campaign\name`, `C:\campaign`, "café", "campaign name", ".", ".."}
for _, value := range unsafe {
t.Run("campaign/"+strings.ReplaceAll(value, "/", "_"), func(t *testing.T) {
cfg := &CampaignConfig{CampaignID: value, Inputs: CampaignInputsConfig{
SpeakersFile: "speakers.yml", AutocorrectFile: "autocorrect.yml", GlossaryFile: "glossary.yml", PlayersFile: "players.yml", PartyFile: "party.yml",
}}
if err := validateCampaign(cfg); err == nil {
t.Fatalf("validateCampaign(%q) error = nil, want rejection", value)
}
})
t.Run("session/"+strings.ReplaceAll(value, "/", "_"), func(t *testing.T) {
if err := validateSessionIdentifier("session.session_id", value, true); err == nil {
t.Fatalf("validateSessionIdentifier(%q) error = nil, want rejection", value)
}
})
}
}

View File

@@ -53,6 +53,9 @@ func validateCampaign(cfg *CampaignConfig) error {
if CampaignID(cfg) == "" {
return fmt.Errorf("campaign.campaign_id is required")
}
if err := pathsafe.ValidateOpaqueSegment(cfg.CampaignID); err != nil {
return fmt.Errorf("campaign.campaign_id: %w", err)
}
if strings.TrimSpace(cfg.Inputs.SpeakersFile) == "" {
return fmt.Errorf("campaign.inputs.speakers_file is required")
}
@@ -75,6 +78,11 @@ func validatePipeline(cfg *PipelineConfig) error {
if strings.TrimSpace(cfg.Workspace.Root) == "" {
return fmt.Errorf("pipeline.workspace.root is required")
}
if campaignID := cfg.Campaigns.DefaultCampaignID; campaignID != "" {
if err := pathsafe.ValidateOpaqueSegment(campaignID); err != nil {
return fmt.Errorf("pipeline.campaigns.default_campaign_id: %w", err)
}
}
if err := validateSecrets(cfg.Secrets); err != nil {
return err
}
@@ -717,6 +725,9 @@ func validateSession(cfg *SessionConfig) error {
if strings.TrimSpace(cfg.Campaign) == "" {
return fmt.Errorf("session.campaign is required")
}
if err := pathsafe.ValidateOpaqueSegment(cfg.Campaign); err != nil {
return fmt.Errorf("session.campaign: %w", err)
}
if strings.TrimSpace(cfg.Inputs.SpeakersFile) == "" {
return fmt.Errorf("session.inputs.speakers_file is required")
@@ -762,6 +773,9 @@ func validateSessionIdentifier(fieldName, value string, required bool) error {
}
return nil
}
if err := pathsafe.ValidateOpaqueSegment(value); err != nil {
return fmt.Errorf("%s: %w", fieldName, err)
}
return nil
}

View File

@@ -0,0 +1,36 @@
package manifest
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestLocalStoreRejectsUnsafeLegacyIdentity(t *testing.T) {
now := time.Now().UTC()
path := filepath.Join(t.TempDir(), "manifest.json")
data := []byte(`{"session_id":"../escape","campaign":"campaign","created_at":"` + now.Format(time.RFC3339Nano) + `","updated_at":"` + now.Format(time.RFC3339Nano) + `","stages":{}}`)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
_, err := (&LocalStore{}).Load(context.Background(), path)
if err == nil || !strings.Contains(err.Error(), "migrate the legacy manifest") {
t.Fatalf("Load() error = %v, want migration guidance", err)
}
}
func TestLocalStoreRefusesUnsafeIdentityOnSave(t *testing.T) {
now := time.Now().UTC()
m := New("session", now)
m.Campaign = "../outside"
path := filepath.Join(t.TempDir(), "manifest.json")
err := (&LocalStore{}).Save(context.Background(), path, m)
if err == nil || !strings.Contains(err.Error(), "migrate the legacy manifest") {
t.Fatalf("Save() error = %v, want migration guidance", err)
}
}

View File

@@ -10,6 +10,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
// Store persists manifests to and from durable storage.
@@ -79,6 +80,9 @@ func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error {
if m.CreatedAt.IsZero() {
return fmt.Errorf("save manifest: created_at is required")
}
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
return fmt.Errorf("save manifest: %w", err)
}
m.UpdatedAt = time.Now().UTC()
if m.Stages == nil {
@@ -202,6 +206,9 @@ func (s *LocalStore) SaveRun(ctx context.Context, path string, m *RunManifest) e
if m.CreatedAt.IsZero() {
return fmt.Errorf("save run manifest: created_at is required")
}
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
return fmt.Errorf("save run manifest: %w", err)
}
m.UpdatedAt = time.Now().UTC()
if m.Stages == nil {
@@ -230,6 +237,9 @@ func validateLoadedManifest(m *Manifest) error {
if m.UpdatedAt.IsZero() {
return fmt.Errorf("updated_at is required")
}
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
return err
}
return nil
}
@@ -265,10 +275,40 @@ func validateLoadedRunManifest(m *RunManifest) error {
if m.UpdatedAt.IsZero() {
return fmt.Errorf("updated_at is required")
}
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
return err
}
return nil
}
func validateManifestIdentities(sessionID, campaign, runID string) error {
identities := []struct {
field string
value string
required bool
}{
{field: "session_id", value: sessionID, required: true},
{field: "campaign", value: campaign},
{field: "run_id", value: runID},
}
for _, identity := range identities {
value := strings.TrimSpace(identity.value)
if value == "" && !identity.required {
continue
}
if err := pathsafe.ValidateOpaqueSegment(identity.value); err != nil {
return fmt.Errorf(
"manifest %s %q is not a portable opaque identifier; migrate the legacy manifest before use: %w",
identity.field,
identity.value,
err,
)
}
}
return nil
}
func normalizeRunManifest(m *RunManifest) {
if m.Stages == nil {
m.Stages = map[string]*RunStageRecord{}

View File

@@ -0,0 +1,34 @@
package pathsafe
import (
"errors"
"fmt"
)
var (
ErrOpaqueSegmentRequired = errors.New("opaque identifier is required")
ErrOpaqueSegmentInvalid = errors.New("opaque identifier must contain only ASCII letters, digits, '.', '_', or '-'")
)
// ValidateOpaqueSegment validates an identity token that occupies exactly one
// portable local-path and object-key segment. It deliberately does not trim or
// normalize input: callers must reject rather than silently rewrite identity.
func ValidateOpaqueSegment(value string) error {
if value == "" {
return ErrOpaqueSegmentRequired
}
if value == "." || value == ".." {
return fmt.Errorf("%w: dot segments are not allowed", ErrOpaqueSegmentInvalid)
}
for index := 0; index < len(value); index++ {
character := value[index]
if (character >= 'a' && character <= 'z') ||
(character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') ||
character == '.' || character == '_' || character == '-' {
continue
}
return fmt.Errorf("%w: invalid byte at offset %d", ErrOpaqueSegmentInvalid, index)
}
return nil
}

View File

@@ -0,0 +1,61 @@
package pathsafe
import "testing"
func TestValidateOpaqueSegment(t *testing.T) {
valid := []string{"campaign", "2026-05-03", "run_01", "a.b-c_D9"}
for _, value := range valid {
t.Run("valid/"+value, func(t *testing.T) {
if err := ValidateOpaqueSegment(value); err != nil {
t.Fatalf("ValidateOpaqueSegment(%q) error = %v", value, err)
}
})
}
invalid := []string{"", ".", "..", "campaign/name", `campaign\name`, "/campaign", `C:\campaign`, "campaign name", "café", "campaign\x00name", "campaign\nname"}
for _, value := range invalid {
t.Run("invalid", func(t *testing.T) {
if err := ValidateOpaqueSegment(value); err == nil {
t.Fatalf("ValidateOpaqueSegment(%q) error = nil, want rejection", value)
}
})
}
}
func FuzzValidateOpaqueSegment(f *testing.F) {
for _, seed := range []string{"campaign", "2026-05-03", "..", `C:\campaign`, "café", "a/b", "a\x00b"} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, value string) {
if err := ValidateOpaqueSegment(value); err == nil {
if value == "" || value == "." || value == ".." {
t.Fatalf("accepted reserved segment %q", value)
}
for index := 0; index < len(value); index++ {
character := value[index]
if !((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') || character == '.' || character == '_' || character == '-') {
t.Fatalf("accepted invalid byte %q in %q", character, value)
}
}
}
})
}
func FuzzNormalizeRelativeDestination(f *testing.F) {
for _, seed := range []string{"reports/result.json", `reports\result.json`, "../escape", `C:\escape`, "/absolute", "a/./b", "a/../b"} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, value string) {
normalized, err := NormalizeRelativeDestination(value)
if err != nil {
return
}
if normalized == "" || normalized == "." || normalized == ".." || normalized[0] == '/' {
t.Fatalf("NormalizeRelativeDestination(%q) = %q, want confined relative path", value, normalized)
}
if len(normalized) >= 3 && normalized[:3] == "../" {
t.Fatalf("NormalizeRelativeDestination(%q) escaped with %q", value, normalized)
}
})
}

View File

@@ -166,7 +166,10 @@ func BuildPlan(
continue
}
localPath := artifacts.SessionPreviousArtifactPath(paths, selectedRel)
localPath, err := artifacts.SessionPreviousArtifactPath(paths, selectedRel)
if err != nil {
return nil, fmt.Errorf("resolve previous-session artifact path: %w", err)
}
localRel, err := relativeToSession(paths, localPath)
if err != nil {
return nil, err

View File

@@ -385,7 +385,7 @@ func TestAnalyzeIncludesCanonicalPreviousRecapWhenPreparedCacheExists(t *testing
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[]}`)
previousRecapPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
previousRecapPath := mustPreviousArtifactPath(t, paths, "artifacts/session_recap.md")
writeAnalyzeFile(t, previousRecapPath, "previous recap\n")
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = config.ScriptoriumArtifactConfig{
@@ -776,7 +776,7 @@ func TestAnalyzeResolvesCanonicalPreviousSessionArtifactFromManifestInput(t *tes
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
previousPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
previousPath := mustPreviousArtifactPath(t, paths, "artifacts/session_recap.md")
writeAnalyzeFile(t, previousPath, "previous recap\n")
m.Inputs = append(m.Inputs, manifest.InputRecord{
Kind: "previous_artifact",
@@ -832,7 +832,7 @@ func TestAnalyzeRenderDebugWithCanonicalPreviousSessionInput(t *testing.T) {
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
env.Config.Pipeline.Scriptorium.RenderDebug = true
previousPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
previousPath := mustPreviousArtifactPath(t, paths, "artifacts/session_recap.md")
writeAnalyzeFile(t, previousPath, "previous recap\n")
m.Inputs = append(m.Inputs, manifest.InputRecord{
Kind: "previous_artifact",
@@ -866,7 +866,7 @@ func TestAnalyzeDoesNotCallObjectStoreForCanonicalPreviousSessionInput(t *testin
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
previousPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
previousPath := mustPreviousArtifactPath(t, paths, "artifacts/session_recap.md")
writeAnalyzeFile(t, previousPath, "previous recap\n")
m.Inputs = append(m.Inputs, manifest.InputRecord{
Kind: "previous_artifact",
@@ -1552,13 +1552,13 @@ func setupAnalyzeEnv(t *testing.T) (*Env, *manifest.Manifest, *scriptorium.FakeR
},
Session: &config.SessionConfig{
SessionID: "2026-05-03",
Campaign: "Icewind Dale",
Campaign: "icewind-dale",
Date: "2026-05-03",
},
}
store := artifacts.NewLocalStore(workspace)
if _, err := store.EnsureLayoutFor("sample-campaign", "2026-05-03"); err != nil {
if _, err := store.EnsureLayoutFor("icewind-dale", "2026-05-03"); err != nil {
t.Fatalf("EnsureLayout() error = %v", err)
}

View File

@@ -0,0 +1,16 @@
package stage
import (
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
)
func mustPreviousArtifactPath(t *testing.T, paths artifacts.SessionPaths, relative string) string {
t.Helper()
path, err := artifacts.SessionPreviousArtifactPath(paths, relative)
if err != nil {
t.Fatalf("SessionPreviousArtifactPath() error = %v", err)
}
return path
}

View File

@@ -46,7 +46,7 @@ func TestHydratePreviousSessionArtifactsDownloadsManifestAndRequiredArtifact(t *
if _, err := os.Stat(sessionPaths.PreviousManifestPath); err != nil {
t.Fatalf("previous manifest missing: %v", err)
}
recapPath := artifacts.SessionPreviousArtifactPath(sessionPaths, "artifacts/session_recap.md")
recapPath := mustPreviousArtifactPath(t, sessionPaths, "artifacts/session_recap.md")
if _, err := os.Stat(recapPath); err != nil {
t.Fatalf("previous artifact missing: %v", err)
}

View File

@@ -508,7 +508,7 @@ func TestPrepareStageHydratesRequiredPreviousArtifactAndRecordsInputs(t *testing
if _, err := os.Stat(paths.PreviousManifestPath); err != nil {
t.Fatalf("expected previous manifest: %v", err)
}
recapPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
recapPath := mustPreviousArtifactPath(t, paths, "artifacts/session_recap.md")
if _, err := os.Stat(recapPath); err != nil {
t.Fatalf("expected previous artifact: %v", err)
}
@@ -556,7 +556,7 @@ func TestPrepareStageRerunOverwritesPreviousCache(t *testing.T) {
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
t.Fatalf("first prepare run error = %v", err)
}
recapPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
recapPath := mustPreviousArtifactPath(t, paths, "artifacts/session_recap.md")
firstBytes, err := os.ReadFile(recapPath)
if err != nil {
t.Fatalf("read first hydrated artifact: %v", err)

View File

@@ -54,6 +54,12 @@ func resolveRunStageLayout(
if runID == "" || campaign == "" {
return runStageLayout{}, nil
}
if err := artifacts.ValidateSessionIdentity(campaign, sessionID); err != nil {
return runStageLayout{}, err
}
if err := artifacts.ValidateRunIdentity(runID); err != nil {
return runStageLayout{}, err
}
root := artifacts.SessionRunStageDirForCampaign(
env.Config.Pipeline.Workspace.Root,