diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 786d4f4..d013719 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -117,7 +117,7 @@ During every stage: ## Stage 1 — Separate Core And Shared Validation Ownership -**Status:** Pending +**Status:** Completed ### Goal diff --git a/internal/config/validate.go b/internal/config/validate.go index ac65195..52073ae 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -3,9 +3,7 @@ package config import ( "errors" "fmt" - "net/url" "path/filepath" - "regexp" "sort" "strings" "time" @@ -67,34 +65,6 @@ func ValidateCampaignConfig(cfg *CampaignConfig) error { return validateCampaign(cfg) } -func validateCampaign(cfg *CampaignConfig) error { - if cfg == nil { - return fmt.Errorf("campaign config is required") - } - 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") - } - if strings.TrimSpace(cfg.Inputs.AutocorrectFile) == "" { - return fmt.Errorf("campaign.inputs.autocorrect_file is required") - } - if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" { - return fmt.Errorf("campaign.inputs.glossary_file is required") - } - if strings.TrimSpace(cfg.Inputs.PartyFile) == "" { - return fmt.Errorf("campaign.inputs.party_file is required") - } - if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" { - return fmt.Errorf("campaign.inputs.spell_catalog_file must be non-empty when provided") - } - return nil -} - func validatePipeline(cfg *PipelineConfig) error { if strings.TrimSpace(cfg.Workspace.Root) == "" { return fmt.Errorf("pipeline.workspace.root is required") @@ -150,13 +120,6 @@ func validatePipeline(cfg *PipelineConfig) error { return nil } -func validateNotification(cfg NotificationConfig) error { - if strings.EqualFold(strings.TrimSpace(cfg.Mode), DefaultNotificationMode) { - return nil - } - return fmt.Errorf("pipeline.notification.mode must be %q until a notification provider is configured", DefaultNotificationMode) -} - func validateStorage(cfg StorageConfig) error { backend := strings.ToLower(strings.TrimSpace(cfg.Backend)) switch backend { @@ -190,17 +153,6 @@ func validateStorage(cfg StorageConfig) error { return nil } -func validateSpool(cfg SpoolConfig) error { - return nil -} - -func validateCache(cfg CacheConfig) error { - if cfg.S3Audio != nil && *cfg.S3Audio && strings.TrimSpace(cfg.Root) == "" { - return fmt.Errorf("pipeline.cache.root is required when pipeline.cache.s3_audio is true") - } - return nil -} - func validatePublish(cfg *PublishConfig, scriptorium *ScriptoriumConfig, notarius *NotariusConfig) error { if cfg == nil { return nil @@ -332,216 +284,6 @@ func notariusOutputKeySet(notarius *NotariusConfig) map[string]struct{} { return out } -func validateSecrets(cfg *SecretsConfig) error { - if cfg == nil { - return nil - } - if strings.TrimSpace(cfg.EnvDir) == "" { - return fmt.Errorf("pipeline.secrets.env_dir must be non-empty when pipeline.secrets is configured") - } - return nil -} - -func validateNormalize(cfg *NormalizeConfig) error { - if cfg == nil { - return nil - } - if strings.TrimSpace(cfg.OutputPath) == "" { - return fmt.Errorf("pipeline.normalize.output_path must be non-empty") - } - if err := validateSeriatimOutputSchema("pipeline.normalize.output_schema", cfg.OutputSchema); err != nil { - return err - } - return nil -} - -func validateTrim(cfg *TrimConfig) error { - if cfg == nil { - return nil - } - if cfg.Enabled == nil { - return fmt.Errorf("pipeline.trim.enabled must be set (defaults should populate this)") - } - if !*cfg.Enabled { - return nil - } - - if strings.TrimSpace(cfg.OutputPath) == "" { - return fmt.Errorf("pipeline.trim.output_path is required when pipeline.trim.enabled is true") - } - if strings.TrimSpace(cfg.Bounds.PromptID) == "" { - return fmt.Errorf("pipeline.trim.bounds.prompt_id is required when pipeline.trim.enabled is true") - } - if strings.TrimSpace(cfg.Bounds.TranscriptInputName) == "" { - return fmt.Errorf("pipeline.trim.bounds.transcript_input_name is required when pipeline.trim.enabled is true") - } - if strings.TrimSpace(cfg.Bounds.OutputPath) == "" { - return fmt.Errorf("pipeline.trim.bounds.output_path is required when pipeline.trim.enabled is true") - } - if err := validateDuration("pipeline.trim.bounds.timeout", cfg.Bounds.Timeout); err != nil { - return err - } - if cfg.Bounds.RenderDebug && strings.TrimSpace(cfg.Bounds.RenderOutputPath) == "" { - return fmt.Errorf("pipeline.trim.bounds.render_output_path is required when pipeline.trim.bounds.render_debug is true") - } - - return nil -} - -func validateRender(cfg *RenderConfig) error { - if cfg == nil { - return nil - } - if cfg.Enabled == nil { - return fmt.Errorf("pipeline.render.enabled must be set (defaults should populate this)") - } - if cfg.IncludeTimestamps == nil { - return fmt.Errorf("pipeline.render.include_timestamps must be set (defaults should populate this)") - } - if cfg.IncludeSegmentIDs == nil { - return fmt.Errorf("pipeline.render.include_segment_ids must be set (defaults should populate this)") - } - format := strings.TrimSpace(cfg.Format) - if format != "markdown" { - return fmt.Errorf("pipeline.render.format must be markdown") - } - if cfg.Title != "" && strings.TrimSpace(cfg.Title) == "" { - return fmt.Errorf("pipeline.render.title must be non-empty when provided") - } - return nil -} - -func validateWhisperX(cfg WhisperXConfig) error { - if strings.TrimSpace(cfg.TranscribeURL) == "" { - return fmt.Errorf("pipeline.whisperx.transcribe_url is required") - } - u, err := url.Parse(cfg.TranscribeURL) - if err != nil || !u.IsAbs() || u.Host == "" || (strings.ToLower(u.Scheme) != "http" && strings.ToLower(u.Scheme) != "https") { - if err != nil { - return fmt.Errorf("pipeline.whisperx.transcribe_url must be an absolute http or https URL: %w", err) - } - return fmt.Errorf("pipeline.whisperx.transcribe_url must be an absolute http or https URL") - } - if err := validateDuration("pipeline.whisperx.timeout", cfg.Timeout); err != nil { - return err - } - if err := validateDuration("pipeline.whisperx.retry_delay", cfg.RetryDelay); err != nil { - return err - } - if cfg.Retries == nil { - return fmt.Errorf("pipeline.whisperx.retries must be set (defaults should populate this)") - } - if *cfg.Retries < 0 { - return fmt.Errorf("pipeline.whisperx.retries must be >= 0") - } - if cfg.Concurrency == nil { - return fmt.Errorf("pipeline.whisperx.concurrency must be set (defaults should populate this)") - } - if *cfg.Concurrency <= 0 { - return fmt.Errorf("pipeline.whisperx.concurrency must be > 0") - } - return nil -} - -func validateSeriatim(cfg SeriatimConfig) error { - if strings.TrimSpace(cfg.Binary) == "" { - return fmt.Errorf("pipeline.seriatim.binary is required") - } - if err := validateDuration("pipeline.seriatim.timeout", cfg.Timeout); err != nil { - return err - } - if err := validateSeriatimOutputSchema("pipeline.seriatim.output_schema", cfg.OutputSchema); err != nil { - return err - } - - if cfg.CoalesceGap == nil { - return fmt.Errorf("pipeline.seriatim.coalesce_gap must be set (defaults should populate this)") - } - if *cfg.CoalesceGap < 0 { - return fmt.Errorf("pipeline.seriatim.coalesce_gap must be >= 0") - } - - for _, item := range []struct { - name string - value *float64 - }{ - {name: "pipeline.seriatim.env.overlap_word_run_gap", value: cfg.Env.OverlapWordRunGap}, - {name: "pipeline.seriatim.env.overlap_word_run_reorder_window", value: cfg.Env.OverlapWordRunReorderWindow}, - {name: "pipeline.seriatim.env.backchannel_max_duration", value: cfg.Env.BackchannelMaxDuration}, - {name: "pipeline.seriatim.env.filler_max_duration", value: cfg.Env.FillerMaxDuration}, - } { - if item.value != nil && *item.value <= 0 { - return fmt.Errorf("%s must be > 0 when provided", item.name) - } - } - - return nil -} - -func validateSeriatimOutputSchema(field, value string) error { - switch strings.TrimSpace(value) { - case "seriatim-minimal", "seriatim-intermediate", "seriatim-full": - return nil - default: - return fmt.Errorf("%s must be one of: seriatim-minimal, seriatim-intermediate, seriatim-full", field) - } -} - -func validateAudita(cfg AuditaConfig) error { - if strings.TrimSpace(cfg.Binary) == "" { - return fmt.Errorf("pipeline.audita.binary is required") - } - if err := validateDuration("pipeline.audita.timeout", cfg.Timeout); err != nil { - return err - } - for i, m := range cfg.Modules { - module := strings.TrimSpace(m) - if module == "" { - return fmt.Errorf("pipeline.audita.modules[%d] must be non-empty", i) - } - switch module { - case "glossary", "homophones", "spoken_word", "grammar": - default: - return fmt.Errorf("pipeline.audita.modules[%d] must be one of: glossary, homophones, spoken_word, grammar", i) - } - } - if strings.TrimSpace(cfg.BaseURL) != "" { - u, err := url.Parse(cfg.BaseURL) - if err != nil || u.Scheme == "" || u.Host == "" { - if err != nil { - return fmt.Errorf("pipeline.audita.base_url must be a valid URL: %w", err) - } - return fmt.Errorf("pipeline.audita.base_url must be a valid URL") - } - } - if cfg.TotalLLMConcurrency != nil && *cfg.TotalLLMConcurrency <= 0 { - return fmt.Errorf("pipeline.audita.total_llm_concurrency must be > 0") - } - if cfg.ProposalLLMConcurrency != nil && *cfg.ProposalLLMConcurrency <= 0 { - return fmt.Errorf("pipeline.audita.proposal_llm_concurrency must be > 0") - } - if cfg.ValidationLLMConcurrency != nil && *cfg.ValidationLLMConcurrency <= 0 { - return fmt.Errorf("pipeline.audita.validation_llm_concurrency must be > 0") - } - if strings.TrimSpace(cfg.TranscriptDescription) == "" && cfg.TranscriptDescription != "" { - return fmt.Errorf("pipeline.audita.transcript_description must be non-empty when provided") - } - if strings.TrimSpace(cfg.ConfigPath) == "" && cfg.ConfigPath != "" { - return fmt.Errorf("pipeline.audita.config_path must be non-empty when provided") - } - switch strings.TrimSpace(cfg.OutputSchema) { - case "", "bare-segments", "audita-v1": - default: - return fmt.Errorf("pipeline.audita.output_schema must be one of: bare-segments, audita-v1") - } - switch strings.TrimSpace(cfg.WorkDirRetention) { - case "", "always", "auto", "never": - default: - return fmt.Errorf("pipeline.audita.work_dir_retention must be one of: always, auto, never") - } - return nil -} - func validateNotarius(cfg *NotariusConfig, scriptorium *ScriptoriumConfig) error { if cfg == nil || !cfg.Enabled { return nil @@ -790,109 +532,6 @@ func validateScriptorium(cfg *ScriptoriumConfig, notarius *NotariusConfig) error return nil } -func validateSession(cfg *SessionConfig) error { - if err := validateSessionIdentifier("session.session_id", cfg.SessionID, true); err != nil { - return err - } - if err := validateSessionIdentifier("session.previous_session_id", cfg.PreviousSessionID, false); err != nil { - return err - } - if strings.TrimSpace(cfg.PreviousSessionID) != "" && strings.TrimSpace(cfg.PreviousSessionID) == strings.TrimSpace(cfg.SessionID) { - return fmt.Errorf("session.previous_session_id must not equal session.session_id") - } - 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") - } - if strings.TrimSpace(cfg.Inputs.AutocorrectFile) == "" { - return fmt.Errorf("session.inputs.autocorrect_file is required") - } - if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" { - return fmt.Errorf("session.inputs.glossary_file is required") - } - if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" { - return fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided") - } - - hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != "" - hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0 - hasAudioS3 := cfg.Inputs.AudioS3 != nil - if hasAudioS3 { - if strings.TrimSpace(cfg.Inputs.AudioS3.Prefix) == "" { - return fmt.Errorf("session.inputs.audio_s3.prefix is required when session.inputs.audio_s3 is configured") - } - if err := validateRelativeSafePath("session.inputs.audio_s3.prefix", cfg.Inputs.AudioS3.Prefix); err != nil { - return err - } - } - if hasAudioS3 && (hasAudioDir || hasAudioFiles) { - return fmt.Errorf("session.inputs.audio_dir/audio_files and session.inputs.audio_s3 are mutually exclusive") - } - if !hasAudioDir && !hasAudioFiles && !hasAudioS3 { - return fmt.Errorf("session.inputs requires audio_dir, at least one audio_files entry, or audio_s3") - } - - return nil -} - -func validateResolvedPartyInputs(cfg *Config) error { - if cfg == nil || cfg.Session == nil { - return fmt.Errorf("session config is required") - } - if cfg.Party.Canonical == nil { - return fmt.Errorf("canonical party data is required") - } - if cfg.Party.Source.Source != "campaign_config" { - return fmt.Errorf("canonical party must be sourced by campaign_config") - } - return nil -} - -func validateSessionIdentifier(fieldName, value string, required bool) error { - if strings.TrimSpace(value) == "" { - if required { - return fmt.Errorf("%s is required", fieldName) - } - return nil - } - if err := pathsafe.ValidateOpaqueSegment(value); err != nil { - return fmt.Errorf("%s: %w", fieldName, err) - } - return nil -} - -func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig, stableInputs ResolvedStableInputs) error { - if pipeline == nil || session == nil { - return nil - } - if pipeline.Notarius != nil && pipeline.Notarius.Enabled { - for selector, source := range pipeline.Notarius.References { - if source == artifactpolicy.SourceInputSpellCatalog && strings.TrimSpace(stableInputs.SpellCatalogFile.Path) == "" { - return fmt.Errorf("pipeline.notarius.references.%s requires campaign.inputs.spell_catalog_file or session.inputs.spell_catalog_file", selector) - } - } - } - - audioS3Enabled := session.Inputs.AudioS3 != nil - publishUploadEnabled := publishUploadConfiguredForS3(pipeline) - 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 -} - func publishUploadConfiguredForS3(pipeline *PipelineConfig) bool { if pipeline == nil || pipeline.Publish == nil { return false @@ -911,9 +550,6 @@ func publishUploadConfiguredForS3(pipeline *PipelineConfig) bool { return enabled && upload } -var windowsAbsPathRE = regexp.MustCompile(`^[A-Za-z]:[\\/].*`) -var envVarNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) - func validateScriptoriumInputSource( artifactName, inputName, source string, configuredArtifacts map[string]struct{}, @@ -971,47 +607,6 @@ func validateScriptoriumInputSource( return "", nil } -func validateEnvVarNameField(fieldName, value string) error { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return fmt.Errorf("%s must be non-empty", fieldName) - } - if !envVarNameRE.MatchString(trimmed) { - return fmt.Errorf("%s must be a valid environment variable name", fieldName) - } - return nil -} - -func validateRelativeSafePath(fieldName, value string) error { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return fmt.Errorf("%s must be non-empty", fieldName) - } - if filepath.IsAbs(trimmed) || strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "\\") || windowsAbsPathRE.MatchString(trimmed) { - return fmt.Errorf("%s must be a relative path", fieldName) - } - - normalized := strings.ReplaceAll(trimmed, "\\", "/") - for _, segment := range strings.Split(normalized, "/") { - if segment == ".." { - return fmt.Errorf("%s must not contain path traversal", fieldName) - } - } - return nil -} - -func validatePathWithinRoot(fieldName, value, root string) error { - normalizedValue := filepath.ToSlash(filepath.Clean(strings.TrimSpace(value))) - normalizedRoot := filepath.ToSlash(filepath.Clean(strings.TrimSpace(root))) - if normalizedValue == normalizedRoot { - return nil - } - if strings.HasPrefix(normalizedValue, normalizedRoot+"/") { - return nil - } - return fmt.Errorf("%s must be under %s/", fieldName, normalizedRoot) -} - // ValidateScriptoriumArtifactDependencies validates the configured dependency // graph independently of execution selection. Disabled artifacts remain valid // prerequisites for explicit selections and therefore participate in cycles. @@ -1080,20 +675,3 @@ func ValidateScriptoriumArtifactDependencies(artifacts map[string]ScriptoriumArt } return nil } - -func validateDuration(fieldName, value string) error { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return 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 -} diff --git a/internal/config/validate_adapters.go b/internal/config/validate_adapters.go new file mode 100644 index 0000000..d0b6ed7 --- /dev/null +++ b/internal/config/validate_adapters.go @@ -0,0 +1,214 @@ +package config + +import ( + "fmt" + "net/url" + "strings" +) + +func validateNotification(cfg NotificationConfig) error { + if strings.EqualFold(strings.TrimSpace(cfg.Mode), DefaultNotificationMode) { + return nil + } + return fmt.Errorf("pipeline.notification.mode must be %q until a notification provider is configured", DefaultNotificationMode) +} + +func validateNormalize(cfg *NormalizeConfig) error { + if cfg == nil { + return nil + } + if strings.TrimSpace(cfg.OutputPath) == "" { + return fmt.Errorf("pipeline.normalize.output_path must be non-empty") + } + if err := validateSeriatimOutputSchema("pipeline.normalize.output_schema", cfg.OutputSchema); err != nil { + return err + } + return nil +} + +func validateTrim(cfg *TrimConfig) error { + if cfg == nil { + return nil + } + if cfg.Enabled == nil { + return fmt.Errorf("pipeline.trim.enabled must be set (defaults should populate this)") + } + if !*cfg.Enabled { + return nil + } + + if strings.TrimSpace(cfg.OutputPath) == "" { + return fmt.Errorf("pipeline.trim.output_path is required when pipeline.trim.enabled is true") + } + if strings.TrimSpace(cfg.Bounds.PromptID) == "" { + return fmt.Errorf("pipeline.trim.bounds.prompt_id is required when pipeline.trim.enabled is true") + } + if strings.TrimSpace(cfg.Bounds.TranscriptInputName) == "" { + return fmt.Errorf("pipeline.trim.bounds.transcript_input_name is required when pipeline.trim.enabled is true") + } + if strings.TrimSpace(cfg.Bounds.OutputPath) == "" { + return fmt.Errorf("pipeline.trim.bounds.output_path is required when pipeline.trim.enabled is true") + } + if err := validateDuration("pipeline.trim.bounds.timeout", cfg.Bounds.Timeout); err != nil { + return err + } + if cfg.Bounds.RenderDebug && strings.TrimSpace(cfg.Bounds.RenderOutputPath) == "" { + return fmt.Errorf("pipeline.trim.bounds.render_output_path is required when pipeline.trim.bounds.render_debug is true") + } + + return nil +} + +func validateRender(cfg *RenderConfig) error { + if cfg == nil { + return nil + } + if cfg.Enabled == nil { + return fmt.Errorf("pipeline.render.enabled must be set (defaults should populate this)") + } + if cfg.IncludeTimestamps == nil { + return fmt.Errorf("pipeline.render.include_timestamps must be set (defaults should populate this)") + } + if cfg.IncludeSegmentIDs == nil { + return fmt.Errorf("pipeline.render.include_segment_ids must be set (defaults should populate this)") + } + format := strings.TrimSpace(cfg.Format) + if format != "markdown" { + return fmt.Errorf("pipeline.render.format must be markdown") + } + if cfg.Title != "" && strings.TrimSpace(cfg.Title) == "" { + return fmt.Errorf("pipeline.render.title must be non-empty when provided") + } + return nil +} + +func validateWhisperX(cfg WhisperXConfig) error { + if strings.TrimSpace(cfg.TranscribeURL) == "" { + return fmt.Errorf("pipeline.whisperx.transcribe_url is required") + } + u, err := url.Parse(cfg.TranscribeURL) + if err != nil || !u.IsAbs() || u.Host == "" || (strings.ToLower(u.Scheme) != "http" && strings.ToLower(u.Scheme) != "https") { + if err != nil { + return fmt.Errorf("pipeline.whisperx.transcribe_url must be an absolute http or https URL: %w", err) + } + return fmt.Errorf("pipeline.whisperx.transcribe_url must be an absolute http or https URL") + } + if err := validateDuration("pipeline.whisperx.timeout", cfg.Timeout); err != nil { + return err + } + if err := validateDuration("pipeline.whisperx.retry_delay", cfg.RetryDelay); err != nil { + return err + } + if cfg.Retries == nil { + return fmt.Errorf("pipeline.whisperx.retries must be set (defaults should populate this)") + } + if *cfg.Retries < 0 { + return fmt.Errorf("pipeline.whisperx.retries must be >= 0") + } + if cfg.Concurrency == nil { + return fmt.Errorf("pipeline.whisperx.concurrency must be set (defaults should populate this)") + } + if *cfg.Concurrency <= 0 { + return fmt.Errorf("pipeline.whisperx.concurrency must be > 0") + } + return nil +} + +func validateSeriatim(cfg SeriatimConfig) error { + if strings.TrimSpace(cfg.Binary) == "" { + return fmt.Errorf("pipeline.seriatim.binary is required") + } + if err := validateDuration("pipeline.seriatim.timeout", cfg.Timeout); err != nil { + return err + } + if err := validateSeriatimOutputSchema("pipeline.seriatim.output_schema", cfg.OutputSchema); err != nil { + return err + } + + if cfg.CoalesceGap == nil { + return fmt.Errorf("pipeline.seriatim.coalesce_gap must be set (defaults should populate this)") + } + if *cfg.CoalesceGap < 0 { + return fmt.Errorf("pipeline.seriatim.coalesce_gap must be >= 0") + } + + for _, item := range []struct { + name string + value *float64 + }{ + {name: "pipeline.seriatim.env.overlap_word_run_gap", value: cfg.Env.OverlapWordRunGap}, + {name: "pipeline.seriatim.env.overlap_word_run_reorder_window", value: cfg.Env.OverlapWordRunReorderWindow}, + {name: "pipeline.seriatim.env.backchannel_max_duration", value: cfg.Env.BackchannelMaxDuration}, + {name: "pipeline.seriatim.env.filler_max_duration", value: cfg.Env.FillerMaxDuration}, + } { + if item.value != nil && *item.value <= 0 { + return fmt.Errorf("%s must be > 0 when provided", item.name) + } + } + + return nil +} + +func validateSeriatimOutputSchema(field, value string) error { + switch strings.TrimSpace(value) { + case "seriatim-minimal", "seriatim-intermediate", "seriatim-full": + return nil + default: + return fmt.Errorf("%s must be one of: seriatim-minimal, seriatim-intermediate, seriatim-full", field) + } +} + +func validateAudita(cfg AuditaConfig) error { + if strings.TrimSpace(cfg.Binary) == "" { + return fmt.Errorf("pipeline.audita.binary is required") + } + if err := validateDuration("pipeline.audita.timeout", cfg.Timeout); err != nil { + return err + } + for i, m := range cfg.Modules { + module := strings.TrimSpace(m) + if module == "" { + return fmt.Errorf("pipeline.audita.modules[%d] must be non-empty", i) + } + switch module { + case "glossary", "homophones", "spoken_word", "grammar": + default: + return fmt.Errorf("pipeline.audita.modules[%d] must be one of: glossary, homophones, spoken_word, grammar", i) + } + } + if strings.TrimSpace(cfg.BaseURL) != "" { + u, err := url.Parse(cfg.BaseURL) + if err != nil || u.Scheme == "" || u.Host == "" { + if err != nil { + return fmt.Errorf("pipeline.audita.base_url must be a valid URL: %w", err) + } + return fmt.Errorf("pipeline.audita.base_url must be a valid URL") + } + } + if cfg.TotalLLMConcurrency != nil && *cfg.TotalLLMConcurrency <= 0 { + return fmt.Errorf("pipeline.audita.total_llm_concurrency must be > 0") + } + if cfg.ProposalLLMConcurrency != nil && *cfg.ProposalLLMConcurrency <= 0 { + return fmt.Errorf("pipeline.audita.proposal_llm_concurrency must be > 0") + } + if cfg.ValidationLLMConcurrency != nil && *cfg.ValidationLLMConcurrency <= 0 { + return fmt.Errorf("pipeline.audita.validation_llm_concurrency must be > 0") + } + if strings.TrimSpace(cfg.TranscriptDescription) == "" && cfg.TranscriptDescription != "" { + return fmt.Errorf("pipeline.audita.transcript_description must be non-empty when provided") + } + if strings.TrimSpace(cfg.ConfigPath) == "" && cfg.ConfigPath != "" { + return fmt.Errorf("pipeline.audita.config_path must be non-empty when provided") + } + switch strings.TrimSpace(cfg.OutputSchema) { + case "", "bare-segments", "audita-v1": + default: + return fmt.Errorf("pipeline.audita.output_schema must be one of: bare-segments, audita-v1") + } + switch strings.TrimSpace(cfg.WorkDirRetention) { + case "", "always", "auto", "never": + default: + return fmt.Errorf("pipeline.audita.work_dir_retention must be one of: always, auto, never") + } + return nil +} diff --git a/internal/config/validate_core.go b/internal/config/validate_core.go new file mode 100644 index 0000000..16f3e94 --- /dev/null +++ b/internal/config/validate_core.go @@ -0,0 +1,161 @@ +package config + +import ( + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" + "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" +) + +func validateCampaign(cfg *CampaignConfig) error { + if cfg == nil { + return fmt.Errorf("campaign config is required") + } + 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") + } + if strings.TrimSpace(cfg.Inputs.AutocorrectFile) == "" { + return fmt.Errorf("campaign.inputs.autocorrect_file is required") + } + if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" { + return fmt.Errorf("campaign.inputs.glossary_file is required") + } + if strings.TrimSpace(cfg.Inputs.PartyFile) == "" { + return fmt.Errorf("campaign.inputs.party_file is required") + } + if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" { + return fmt.Errorf("campaign.inputs.spell_catalog_file must be non-empty when provided") + } + return nil +} + +func validateSecrets(cfg *SecretsConfig) error { + if cfg == nil { + return nil + } + if strings.TrimSpace(cfg.EnvDir) == "" { + return fmt.Errorf("pipeline.secrets.env_dir must be non-empty when pipeline.secrets is configured") + } + return nil +} + +func validateSpool(cfg SpoolConfig) error { + return nil +} + +func validateCache(cfg CacheConfig) error { + if cfg.S3Audio != nil && *cfg.S3Audio && strings.TrimSpace(cfg.Root) == "" { + return fmt.Errorf("pipeline.cache.root is required when pipeline.cache.s3_audio is true") + } + return nil +} + +func validateSession(cfg *SessionConfig) error { + if err := validateSessionIdentifier("session.session_id", cfg.SessionID, true); err != nil { + return err + } + if err := validateSessionIdentifier("session.previous_session_id", cfg.PreviousSessionID, false); err != nil { + return err + } + if strings.TrimSpace(cfg.PreviousSessionID) != "" && strings.TrimSpace(cfg.PreviousSessionID) == strings.TrimSpace(cfg.SessionID) { + return fmt.Errorf("session.previous_session_id must not equal session.session_id") + } + 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") + } + if strings.TrimSpace(cfg.Inputs.AutocorrectFile) == "" { + return fmt.Errorf("session.inputs.autocorrect_file is required") + } + if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" { + return fmt.Errorf("session.inputs.glossary_file is required") + } + if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" { + return fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided") + } + + hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != "" + hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0 + hasAudioS3 := cfg.Inputs.AudioS3 != nil + if hasAudioS3 { + if strings.TrimSpace(cfg.Inputs.AudioS3.Prefix) == "" { + return fmt.Errorf("session.inputs.audio_s3.prefix is required when session.inputs.audio_s3 is configured") + } + if err := validateRelativeSafePath("session.inputs.audio_s3.prefix", cfg.Inputs.AudioS3.Prefix); err != nil { + return err + } + } + if hasAudioS3 && (hasAudioDir || hasAudioFiles) { + return fmt.Errorf("session.inputs.audio_dir/audio_files and session.inputs.audio_s3 are mutually exclusive") + } + if !hasAudioDir && !hasAudioFiles && !hasAudioS3 { + return fmt.Errorf("session.inputs requires audio_dir, at least one audio_files entry, or audio_s3") + } + + return nil +} + +func validateResolvedPartyInputs(cfg *Config) error { + if cfg == nil || cfg.Session == nil { + return fmt.Errorf("session config is required") + } + if cfg.Party.Canonical == nil { + return fmt.Errorf("canonical party data is required") + } + if cfg.Party.Source.Source != "campaign_config" { + return fmt.Errorf("canonical party must be sourced by campaign_config") + } + return nil +} + +func validateSessionIdentifier(fieldName, value string, required bool) error { + if strings.TrimSpace(value) == "" { + if required { + return fmt.Errorf("%s is required", fieldName) + } + return nil + } + if err := pathsafe.ValidateOpaqueSegment(value); err != nil { + return fmt.Errorf("%s: %w", fieldName, err) + } + return nil +} + +func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig, stableInputs ResolvedStableInputs) error { + if pipeline == nil || session == nil { + return nil + } + if pipeline.Notarius != nil && pipeline.Notarius.Enabled { + for selector, source := range pipeline.Notarius.References { + if source == artifactpolicy.SourceInputSpellCatalog && strings.TrimSpace(stableInputs.SpellCatalogFile.Path) == "" { + return fmt.Errorf("pipeline.notarius.references.%s requires campaign.inputs.spell_catalog_file or session.inputs.spell_catalog_file", selector) + } + } + } + + audioS3Enabled := session.Inputs.AudioS3 != nil + publishUploadEnabled := publishUploadConfiguredForS3(pipeline) + 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 +} diff --git a/internal/config/validate_helpers.go b/internal/config/validate_helpers.go new file mode 100644 index 0000000..b37b6d9 --- /dev/null +++ b/internal/config/validate_helpers.go @@ -0,0 +1,70 @@ +package config + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" + "time" +) + +var windowsAbsPathRE = regexp.MustCompile(`^[A-Za-z]:[\\/].*`) +var envVarNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +func validateEnvVarNameField(fieldName, value string) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fmt.Errorf("%s must be non-empty", fieldName) + } + if !envVarNameRE.MatchString(trimmed) { + return fmt.Errorf("%s must be a valid environment variable name", fieldName) + } + return nil +} + +func validateRelativeSafePath(fieldName, value string) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fmt.Errorf("%s must be non-empty", fieldName) + } + if filepath.IsAbs(trimmed) || strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "\\") || windowsAbsPathRE.MatchString(trimmed) { + return fmt.Errorf("%s must be a relative path", fieldName) + } + + normalized := strings.ReplaceAll(trimmed, "\\", "/") + for _, segment := range strings.Split(normalized, "/") { + if segment == ".." { + return fmt.Errorf("%s must not contain path traversal", fieldName) + } + } + return nil +} + +func validatePathWithinRoot(fieldName, value, root string) error { + normalizedValue := filepath.ToSlash(filepath.Clean(strings.TrimSpace(value))) + normalizedRoot := filepath.ToSlash(filepath.Clean(strings.TrimSpace(root))) + if normalizedValue == normalizedRoot { + return nil + } + if strings.HasPrefix(normalizedValue, normalizedRoot+"/") { + return nil + } + return fmt.Errorf("%s must be under %s/", fieldName, normalizedRoot) +} + +func validateDuration(fieldName, value string) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return 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 +}