package config import ( "fmt" "net/url" "path/filepath" "regexp" "strings" "time" ) // Validate checks resolved configuration for required fields and parseable durations. func Validate(cfg *Config) error { if cfg == nil { return fmt.Errorf("config is nil") } if cfg.Pipeline == nil { return fmt.Errorf("pipeline config is required") } if cfg.Session == nil { return fmt.Errorf("session config is required") } if err := validatePipeline(cfg.Pipeline); err != nil { return fmt.Errorf("pipeline config %q invalid: %w", shortName(cfg.PipelinePath, "pipeline.yml"), err) } if err := validateSession(cfg.Session); err != nil { return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err) } if err := validateCrossConfig(cfg.Pipeline, cfg.Session); err != nil { return fmt.Errorf("pipeline/session config invalid: %w", err) } return nil } func validatePipeline(cfg *PipelineConfig) error { if strings.TrimSpace(cfg.Workspace.Root) == "" { return fmt.Errorf("pipeline.workspace.root is required") } if err := validateSecrets(cfg.Secrets); err != nil { return err } if err := validateStorage(cfg.Storage); err != nil { return err } if err := validateSpool(cfg.Spool); err != nil { return err } if err := validateArchive(cfg.Archive); err != nil { return err } if err := validateWhisperX(cfg.WhisperX); err != nil { return err } if err := validateSeriatim(cfg.Seriatim); err != nil { return err } if err := validateAudita(cfg.Audita); err != nil { return err } if err := validateNormalize(cfg.Normalize); err != nil { return err } if err := validateTrim(cfg.Trim); err != nil { return err } if err := validateScriptorium(cfg.Scriptorium); err != nil { return err } if err := validateDuration("pipeline.analyzer.timeout", cfg.Analyzer.Timeout); err != nil { return err } if err := validateDuration("pipeline.notification.timeout", cfg.Notification.Timeout); err != nil { return err } return nil } func validateStorage(cfg StorageConfig) error { if cfg.S3 == nil { return nil } if strings.TrimSpace(cfg.S3.RootPrefix) == "" { return fmt.Errorf("pipeline.storage.s3.root_prefix must be non-empty") } if err := validateRelativeSafePath("pipeline.storage.s3.root_prefix", cfg.S3.RootPrefix); err != nil { return err } if cfg.S3.Endpoint != "" && strings.TrimSpace(cfg.S3.Endpoint) == "" { return fmt.Errorf("pipeline.storage.s3.endpoint must be non-empty when provided") } if err := validateEnvVarNameField("pipeline.storage.s3.access_key_id_env", cfg.S3.AccessKeyIDEnv); err != nil { return err } if err := validateEnvVarNameField("pipeline.storage.s3.secret_access_key_env", cfg.S3.SecretKeyEnv); err != nil { return err } return nil } func validateSpool(cfg SpoolConfig) error { return nil } func validateArchive(cfg *ArchiveConfig) error { if cfg == nil { return nil } for i, item := range cfg.PromoteArtifacts { prefix := fmt.Sprintf("pipeline.archive.promote_artifacts[%d]", i) if strings.TrimSpace(item.From) == "" { return fmt.Errorf("%s.from is required", prefix) } if strings.TrimSpace(item.To) == "" { return fmt.Errorf("%s.to is required", prefix) } if err := validateRelativeSafePath(prefix+".from", item.From); err != nil { return err } if err := validateRelativeSafePath(prefix+".to", item.To); err != nil { return err } } 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 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 { 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 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.Scheme == "" || u.Host == "" { if err != nil { return fmt.Errorf("pipeline.whisperx.transcribe_url must be a valid URL: %w", err) } return fmt.Errorf("pipeline.whisperx.transcribe_url must be a valid 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 validateScriptorium(cfg *ScriptoriumConfig) error { if cfg == nil { return nil } if strings.TrimSpace(cfg.Binary) == "" { return fmt.Errorf("pipeline.scriptorium.binary is required when pipeline.scriptorium is configured") } if cfg.ConfigPath != "" && strings.TrimSpace(cfg.ConfigPath) == "" { return fmt.Errorf("pipeline.scriptorium.config_path must be non-empty when provided") } if err := validateDuration("pipeline.scriptorium.timeout", cfg.Timeout); err != nil { return err } for artifactName, artifactCfg := range cfg.Artifacts { trimmedArtifactName := strings.TrimSpace(artifactName) if trimmedArtifactName == "" { return fmt.Errorf("pipeline.scriptorium.artifacts keys must be non-empty") } if artifactCfg.Enabled && strings.TrimSpace(artifactCfg.PromptID) == "" { return fmt.Errorf("pipeline.scriptorium.artifacts.%s.prompt_id is required when enabled", artifactName) } if artifactCfg.Enabled && strings.TrimSpace(artifactCfg.OutputPath) == "" { return fmt.Errorf("pipeline.scriptorium.artifacts.%s.output_path is required when enabled", artifactName) } if err := validateDuration("pipeline.scriptorium.artifacts."+artifactName+".timeout", artifactCfg.Timeout); err != nil { return err } for inputName, inputCfg := range artifactCfg.Inputs { trimmedInputName := strings.TrimSpace(inputName) if trimmedInputName == "" { return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs keys must be non-empty", artifactName) } source := strings.TrimSpace(inputCfg.Source) if source == "" { return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs.%s.source is required", artifactName, inputName) } if !isSupportedScriptoriumInputSource(source) { return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported", artifactName, inputName, inputCfg.Source) } } for varName, varValue := range artifactCfg.Vars { if strings.TrimSpace(varName) == "" { return fmt.Errorf("pipeline.scriptorium.artifacts.%s.vars keys must be non-empty", artifactName) } switch varValue.(type) { case bool, string: default: return fmt.Errorf("pipeline.scriptorium.artifacts.%s.vars.%s must be a string or boolean", artifactName, varName) } } } return nil } func validateSession(cfg *SessionConfig) error { if strings.TrimSpace(cfg.SessionID) == "" { return fmt.Errorf("session.session_id is required") } if strings.TrimSpace(cfg.Campaign) == "" { return fmt.Errorf("session.campaign is required") } 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") } 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 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 archiveUploadEnabled := archiveUploadConfiguredForS3(pipeline) if (audioS3Enabled || archiveUploadEnabled) && strings.TrimSpace(pipeline.Storage.S3.Bucket) == "" { return fmt.Errorf("pipeline.storage.s3.bucket is required when S3 session audio or archive upload is enabled") } return nil } func archiveUploadConfiguredForS3(pipeline *PipelineConfig) bool { if pipeline == nil || pipeline.Archive == nil { return false } if !strings.EqualFold(strings.TrimSpace(pipeline.Storage.Backend), "s3") { return false } enabled := true if pipeline.Archive.Enabled != nil { enabled = *pipeline.Archive.Enabled } upload := true if pipeline.Archive.UploadRun != nil { upload = *pipeline.Archive.UploadRun } return enabled && upload } func isSupportedScriptoriumInputSource(source string) bool { switch strings.TrimSpace(source) { case "previous_session_artifact": return true case "processed_transcript": return true case "normalized_transcript": return true case "trimmed_transcript": return true case "narratio.transcript.merged": return true case "narratio.transcript.polished": return true case "narratio.transcript.full": return true case "narratio.transcript.trimmed": return true case "narratio.bounds.session": return true case "narratio.artifact.session_recap": return true default: return false } } 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 validateDuration(fieldName, value string) error { trimmed := strings.TrimSpace(value) if trimmed == "" { return nil } if _, err := time.ParseDuration(trimmed); err != nil { return fmt.Errorf("%s must be a valid duration: %w", fieldName, err) } return nil }