Separate configuration validation ownership
This commit is contained in:
@@ -117,7 +117,7 @@ During every stage:
|
||||
|
||||
## Stage 1 — Separate Core And Shared Validation Ownership
|
||||
|
||||
**Status:** Pending
|
||||
**Status:** Completed
|
||||
|
||||
### Goal
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
214
internal/config/validate_adapters.go
Normal file
214
internal/config/validate_adapters.go
Normal file
@@ -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
|
||||
}
|
||||
161
internal/config/validate_core.go
Normal file
161
internal/config/validate_core.go
Normal file
@@ -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
|
||||
}
|
||||
70
internal/config/validate_helpers.go
Normal file
70
internal/config/validate_helpers.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user