Harden configuration validation

This commit is contained in:
2026-08-10 21:32:15 +00:00
parent b39b68add7
commit 72a200968a
20 changed files with 595 additions and 1072 deletions

View File

@@ -83,6 +83,13 @@ type StorageConfig struct {
S3 *StorageS3Config `yaml:"s3"`
}
const (
// StorageBackendLocal disables remote object-store operations.
StorageBackendLocal = "local"
// StorageBackendS3 enables the configured S3 object store.
StorageBackendS3 = "s3"
)
// StorageS3Config configures S3 storage coordinates.
type StorageS3Config struct {
Bucket string `yaml:"bucket"`

View File

@@ -74,15 +74,23 @@ func LoadSessionBytesWithOptions(label string, data []byte, opts SessionLoadOpti
strings.TrimSpace(cfg.SessionID),
)
}
if strings.TrimSpace(opts.PreviousSessionID) != "" &&
strings.TrimSpace(cfg.PreviousSessionID) != "" &&
strings.TrimSpace(cfg.PreviousSessionID) != strings.TrimSpace(opts.PreviousSessionID) {
return nil, fmt.Errorf(
"load session config: session file %q: previous_session_id mismatch: --previous-session-id %q does not match previous_session_id %q",
label,
strings.TrimSpace(opts.PreviousSessionID),
strings.TrimSpace(cfg.PreviousSessionID),
)
if expectedPreviousSessionID := strings.TrimSpace(opts.PreviousSessionID); expectedPreviousSessionID != "" {
actualPreviousSessionID := strings.TrimSpace(cfg.PreviousSessionID)
if actualPreviousSessionID == "" {
return nil, fmt.Errorf(
"load session config: session file %q: previous_session_id is required when --previous-session-id %q is provided",
label,
expectedPreviousSessionID,
)
}
if actualPreviousSessionID != expectedPreviousSessionID {
return nil, fmt.Errorf(
"load session config: session file %q: previous_session_id mismatch: --previous-session-id %q does not match previous_session_id %q",
label,
expectedPreviousSessionID,
actualPreviousSessionID,
)
}
}
return &cfg, nil
}
@@ -296,8 +304,10 @@ func decodeStrictYAMLFromReader(kind, path string, r io.Reader, out any) error {
return fmt.Errorf("%s file %q: strict decode failed: %w", kind, path, err)
}
var extra any
if err := dec.Decode(&extra); err != nil && err != io.EOF {
var extra yaml.Node
if err := dec.Decode(&extra); err == nil {
return fmt.Errorf("%s file %q: must contain exactly one YAML document", kind, path)
} else if err != io.EOF {
return fmt.Errorf("%s file %q: trailing content decode failed: %w", kind, path, err)
}
@@ -392,8 +402,13 @@ func applyStorageDefaults(cfg *StorageConfig) {
if cfg == nil {
return
}
backend := strings.ToLower(strings.TrimSpace(cfg.Backend))
if backend == "" {
backend = StorageBackendLocal
}
cfg.Backend = backend
if cfg.S3 == nil {
cfg.S3 = &StorageS3Config{}
return
}
if cfg.S3.RootPrefix == "" {
cfg.S3.RootPrefix = DefaultStorageS3RootPrefix

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,91 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoadersRejectTrailingYAMLDocuments(t *testing.T) {
dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml")
campaignPath := filepath.Join(dir, "campaign.yml")
if err := os.WriteFile(pipelinePath, []byte("workspace:\n root: /tmp/narratio\n---\nworkspace:\n root: /other\n"), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err)
}
if err := os.WriteFile(campaignPath, []byte("campaign_id: sample-campaign\n---\nnull\n"), 0o644); err != nil {
t.Fatalf("write campaign.yml: %v", err)
}
tests := []struct {
name string
load func() error
}{
{
name: "pipeline",
load: func() error {
_, err := LoadPipeline(pipelinePath)
return err
},
},
{
name: "campaign",
load: func() error {
_, err := LoadCampaign(campaignPath)
return err
},
},
{
name: "remote session bytes",
load: func() error {
_, err := LoadSessionBytesWithOptions("s3://bucket/session.yml", []byte("session_id: 2026-05-03\n---\n# another document\nnull\n"), SessionLoadOptions{})
return err
},
},
{
name: "publish lock store",
load: func() error {
_, err := LoadPublishLockStoreBytes("s3://bucket/locks.yml", []byte("locks: []\n---\n{}\n"), nil, nil)
return err
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.load()
if err == nil || !strings.Contains(err.Error(), "exactly one YAML document") {
t.Fatalf("load error = %v, want exactly-one-document error", err)
}
})
}
}
func TestStrictYAMLRejectsParserExposedEmptyTrailingDocument(t *testing.T) {
var target struct {
Name string `yaml:"name"`
}
err := decodeStrictYAMLFromReader("test", "memory", strings.NewReader("name: one\n---\n"), &target)
if err == nil {
t.Fatal("decodeStrictYAMLFromReader() error = nil, want trailing-document error")
}
if !strings.Contains(err.Error(), "exactly one YAML document") {
t.Fatalf("decodeStrictYAMLFromReader() error = %v, want exactly-one-document error", err)
}
}
func TestStrictYAMLAcceptsSingleDocumentAndRejectsUnknownFields(t *testing.T) {
var target struct {
Name string `yaml:"name"`
}
if err := decodeStrictYAMLFromReader("test", "memory", strings.NewReader("name: one\n"), &target); err != nil {
t.Fatalf("decodeStrictYAMLFromReader(single document) error = %v", err)
}
if target.Name != "one" {
t.Fatalf("Name = %q, want one", target.Name)
}
if err := decodeStrictYAMLFromReader("test", "memory", strings.NewReader("unknown: one\n"), &target); err == nil || !strings.Contains(err.Error(), "strict decode failed") {
t.Fatalf("decodeStrictYAMLFromReader(unknown field) error = %v, want strict decode error", err)
}
}

View File

@@ -121,6 +121,24 @@ inputs:
}
}
func TestLoadSessionWithOptionsRequiresExpectedPreviousSession(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml")
sessionYAML := `session_id: 2026-05-03
campaign: sample-campaign
inputs:
audio_dir: ./audio
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
}
_, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{PreviousSessionID: "2026-04-26"})
if err == nil || !strings.Contains(err.Error(), "previous_session_id is required") {
t.Fatalf("LoadSessionWithOptions() error = %v, want missing previous-session expectation error", err)
}
}
func TestLoadSessionWithOptionsUnknownFieldStillRejected(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml")
@@ -217,6 +235,17 @@ func TestLoadSessionBytesWithOptionsMismatchFails(t *testing.T) {
}
}
func TestLoadSessionBytesWithOptionsRequiresExpectedPreviousSession(t *testing.T) {
_, err := LoadSessionBytesWithOptions(
"s3://bucket/session.yml",
[]byte("session_id: 2026-05-03\n"),
SessionLoadOptions{PreviousSessionID: "2026-04-26"},
)
if err == nil || !strings.Contains(err.Error(), "previous_session_id is required") {
t.Fatalf("LoadSessionBytesWithOptions() error = %v, want missing previous-session expectation error", err)
}
}
func assertConcreteSessionTemplateError(t *testing.T, err error, vars ...string) {
t.Helper()
if !strings.Contains(err.Error(), "session.yml must be concrete") {

View File

@@ -153,6 +153,108 @@ storage:
}
}
func TestStorageBackendSelectionValidation(t *testing.T) {
tests := []struct {
name string
storageYAML string
sessionYAML string
wantErr string
wantBackend string
}{
{
name: "omitted storage defaults local",
wantBackend: StorageBackendLocal,
},
{
name: "explicit local backend",
storageYAML: `storage:
backend: local
`,
wantBackend: StorageBackendLocal,
},
{
name: "case insensitive s3 backend normalizes",
storageYAML: `storage:
backend: S3
s3:
bucket: my-dnd-archive
`,
wantBackend: StorageBackendS3,
},
{
name: "unknown backend",
storageYAML: `storage:
backend: s33
`,
wantErr: "pipeline.storage.backend must be one of: local, s3",
},
{
name: "s3 block requires s3 backend",
storageYAML: `storage:
backend: local
s3:
bucket: my-dnd-archive
`,
wantErr: "pipeline.storage.s3 is only supported when pipeline.storage.backend is s3",
},
{
name: "s3 backend requires s3 block",
storageYAML: `storage:
backend: s3
`,
wantErr: "pipeline.storage.s3 is required when pipeline.storage.backend is s3",
},
{
name: "s3 backend requires bucket for publish",
storageYAML: `storage:
backend: s3
s3: {}
`,
wantErr: "pipeline.storage.s3.bucket is required when S3 session audio or publish upload is enabled",
},
{
name: "s3 audio requires s3 backend",
sessionYAML: `session_id: 2026-05-03
inputs:
audio_s3:
prefix: audio/
`,
wantErr: "pipeline.storage.backend must be s3 when session.inputs.audio_s3 is configured",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pipelineYAML := testPipelineBaseYAML
if tt.storageYAML != "" {
pipelineYAML += "\n" + tt.storageYAML
}
sessionYAML := testSessionBaseYAML
if tt.sessionYAML != "" {
sessionYAML = tt.sessionYAML
}
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, sessionYAML)
cfg, err := Load(pipelinePath, sessionPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
err = Validate(cfg)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
if cfg.Pipeline.Storage.Backend != tt.wantBackend {
t.Fatalf("storage.backend = %q, want %q", cfg.Pipeline.Storage.Backend, tt.wantBackend)
}
})
}
}
func TestSpoolAndPublishDefaults(t *testing.T) {
pipelinePath, sessionPath := writeConfigFiles(t, testPipelineBaseYAML, testSessionBaseYAML)
@@ -681,6 +783,7 @@ func TestStorageS3BucketRequiredWhenS3DependentFeatureEnabled(t *testing.T) {
pipelineYAML := testPipelineBaseYAML + `
storage:
backend: s3
s3: {}
`
sessionYAML := `session_id: 2026-05-03
campaign: forsaken

View File

@@ -130,8 +130,19 @@ func validatePipeline(cfg *PipelineConfig) error {
}
func validateStorage(cfg StorageConfig) error {
if cfg.S3 == nil {
backend := strings.ToLower(strings.TrimSpace(cfg.Backend))
switch backend {
case "", StorageBackendLocal:
if cfg.S3 != nil {
return fmt.Errorf("pipeline.storage.s3 is only supported when pipeline.storage.backend is s3")
}
return nil
case StorageBackendS3:
if cfg.S3 == nil {
return fmt.Errorf("pipeline.storage.s3 is required when pipeline.storage.backend is s3")
}
default:
return fmt.Errorf("pipeline.storage.backend must be one of: local, s3")
}
if strings.TrimSpace(cfg.S3.RootPrefix) == "" {
return fmt.Errorf("pipeline.storage.s3.root_prefix must be non-empty")
@@ -783,13 +794,16 @@ func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig) error
if pipeline == nil || session == nil {
return nil
}
if pipeline.Storage.S3 == nil {
return nil
}
audioS3Enabled := session.Inputs.AudioS3 != nil
publishUploadEnabled := publishUploadConfiguredForS3(pipeline)
if (audioS3Enabled || publishUploadEnabled) && strings.TrimSpace(pipeline.Storage.S3.Bucket) == "" {
if audioS3Enabled && !strings.EqualFold(strings.TrimSpace(pipeline.Storage.Backend), StorageBackendS3) {
return fmt.Errorf("pipeline.storage.backend must be s3 when session.inputs.audio_s3 is configured")
}
if !audioS3Enabled && !publishUploadEnabled {
return nil
}
if pipeline.Storage.S3 == nil || strings.TrimSpace(pipeline.Storage.S3.Bucket) == "" {
return fmt.Errorf("pipeline.storage.s3.bucket is required when S3 session audio or publish upload is enabled")
}
return nil
@@ -799,7 +813,7 @@ func publishUploadConfiguredForS3(pipeline *PipelineConfig) bool {
if pipeline == nil || pipeline.Publish == nil {
return false
}
if !strings.EqualFold(strings.TrimSpace(pipeline.Storage.Backend), "s3") {
if !strings.EqualFold(strings.TrimSpace(pipeline.Storage.Backend), StorageBackendS3) {
return false
}
enabled := true
@@ -975,9 +989,13 @@ func validateDuration(fieldName, value string) error {
return nil
}
if _, err := time.ParseDuration(trimmed); err != nil {
duration, err := time.ParseDuration(trimmed)
if err != nil {
return fmt.Errorf("%s must be a valid duration: %w", fieldName, err)
}
if duration <= 0 {
return fmt.Errorf("%s must be positive", fieldName)
}
return nil
}

View File

@@ -0,0 +1,114 @@
package config
import (
"strings"
"testing"
)
func TestValidateDurationsRequirePositiveValues(t *testing.T) {
tests := []struct {
name string
set func(*PipelineConfig, string)
want string
}{
{name: "whisperx timeout", set: func(p *PipelineConfig, value string) { p.WhisperX.Timeout = value }, want: "pipeline.whisperx.timeout"},
{name: "whisperx retry delay", set: func(p *PipelineConfig, value string) { p.WhisperX.RetryDelay = value }, want: "pipeline.whisperx.retry_delay"},
{name: "seriatim timeout", set: func(p *PipelineConfig, value string) { p.Seriatim.Timeout = value }, want: "pipeline.seriatim.timeout"},
{name: "audita timeout", set: func(p *PipelineConfig, value string) { p.Audita.Timeout = value }, want: "pipeline.audita.timeout"},
{name: "scriptorium timeout", set: func(p *PipelineConfig, value string) { p.Scriptorium.Timeout = value }, want: "pipeline.scriptorium.timeout"},
{name: "scriptorium artifact timeout", set: func(p *PipelineConfig, value string) {
if p.Scriptorium.Artifacts == nil {
p.Scriptorium.Artifacts = map[string]ScriptoriumArtifactConfig{}
}
p.Scriptorium.Artifacts["session_recap"] = ScriptoriumArtifactConfig{Timeout: value}
}, want: "pipeline.scriptorium.artifacts.session_recap.timeout"},
{name: "trim bounds timeout", set: func(p *PipelineConfig, value string) { p.Trim.Bounds.Timeout = value }, want: "pipeline.trim.bounds.timeout"},
{name: "notification timeout", set: func(p *PipelineConfig, value string) { p.Notification.Timeout = value }, want: "pipeline.notification.timeout"},
}
for _, value := range []string{"0s", "-1ms"} {
for _, tt := range tests {
t.Run(tt.name+"/"+value, func(t *testing.T) {
cfg := loadedValidConfig(t)
tt.set(cfg.Pipeline, value)
err := Validate(cfg)
if err == nil || !strings.Contains(err.Error(), tt.want+" must be positive") {
t.Fatalf("Validate() error = %v, want positive-value error for %s", err, tt.want)
}
})
}
}
}
func TestValidateNotariusTimeoutRequiresPositiveValue(t *testing.T) {
for _, value := range []string{"0s", "-1ms"} {
t.Run(value, func(t *testing.T) {
cfg := loadedValidConfig(t)
cfg.Pipeline.Notarius = &NotariusConfig{
Enabled: true,
Binary: "notarius",
ConfigPath: "/tmp/notarius.yml",
PipelineID: "session",
Timeout: value,
WorkingDirectory: "/tmp",
Outputs: map[string]NotariusOutputConfig{
"npc_registry": {
LaneID: "npc-registry",
MediaType: "application/json",
SchemaID: "notarius.dnd.npc_registry",
SchemaVersion: "v1",
},
},
}
err := Validate(cfg)
if err == nil || !strings.Contains(err.Error(), "pipeline.notarius.timeout must be positive") {
t.Fatalf("Validate() error = %v, want positive Notarius timeout error", err)
}
})
}
}
func TestValidateDurationRejectsOverflowAndAcceptsPositiveSubsecondValues(t *testing.T) {
cfg := loadedValidConfig(t)
cfg.Pipeline.WhisperX.Timeout = "999999999999999999999h"
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "pipeline.whisperx.timeout must be a valid duration") {
t.Fatalf("Validate() overflow error = %v, want duration parse error", err)
}
cfg = loadedValidConfig(t)
cfg.Pipeline.WhisperX.Timeout = "1ms"
cfg.Pipeline.WhisperX.RetryDelay = "1ms"
cfg.Pipeline.Seriatim.Timeout = "1ms"
cfg.Pipeline.Audita.Timeout = "1ms"
cfg.Pipeline.Scriptorium.Timeout = "1ms"
cfg.Pipeline.Trim.Bounds.Timeout = "1ms"
cfg.Pipeline.Notification.Timeout = "1ms"
if cfg.Pipeline.Scriptorium.Artifacts == nil {
cfg.Pipeline.Scriptorium.Artifacts = map[string]ScriptoriumArtifactConfig{}
}
cfg.Pipeline.Scriptorium.Artifacts["session_recap"] = ScriptoriumArtifactConfig{Timeout: "1ms"}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() positive subsecond durations error = %v", err)
}
}
func TestValidateAllowsEmptyArtifactTimeoutFallback(t *testing.T) {
cfg := loadedValidConfig(t)
if cfg.Pipeline.Scriptorium.Artifacts == nil {
cfg.Pipeline.Scriptorium.Artifacts = map[string]ScriptoriumArtifactConfig{}
}
cfg.Pipeline.Scriptorium.Artifacts["session_recap"] = ScriptoriumArtifactConfig{Timeout: ""}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() empty artifact timeout error = %v", err)
}
}
func loadedValidConfig(t *testing.T) *Config {
t.Helper()
pipelinePath, sessionPath := writeConfigFiles(t, testPipelineBaseYAML, testSessionBaseYAML)
cfg, err := Load(pipelinePath, sessionPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
return cfg
}