package config import ( "fmt" "regexp" "strings" ) var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`) type ValidationErrors []string func (e ValidationErrors) Error() string { if len(e) == 1 { return e[0] } return strings.Join(e, "; ") } func Validate(cfg Config) error { var errs ValidationErrors if len(cfg.Pipelines) == 0 { errs = append(errs, "pipelines is required") } pipelineIDs := make(map[string]struct{}, len(cfg.Pipelines)) for pipelineIndex, pipeline := range cfg.Pipelines { pipelineContext := fmt.Sprintf("pipelines[%d]", pipelineIndex) if pipeline.ID == "" { errs = append(errs, pipelineContext+".id is required") } else if !idPattern.MatchString(pipeline.ID) { errs = append(errs, pipelineContext+".id must be a slug-like identifier") } else if _, exists := pipelineIDs[pipeline.ID]; exists { errs = append(errs, "pipeline id "+pipeline.ID+" is duplicated") } else { pipelineIDs[pipeline.ID] = struct{}{} } errs = validateSourceBackend(errs, pipelineContext+".source", pipeline.Source) errs = validateValidationPolicy(errs, pipelineContext+".validation", pipeline.Validation) if len(pipeline.Destinations) == 0 { errs = append(errs, pipelineContext+".destinations is required") } destinationIDs := make(map[string]struct{}, len(pipeline.Destinations)) for destinationIndex, destination := range pipeline.Destinations { destinationContext := fmt.Sprintf("%s.destinations[%d]", pipelineContext, destinationIndex) if destination.ID == "" { errs = append(errs, destinationContext+".id is required") } else if !idPattern.MatchString(destination.ID) { errs = append(errs, destinationContext+".id must be a slug-like identifier") } else if _, exists := destinationIDs[destination.ID]; exists { errs = append(errs, "destination id "+destination.ID+" is duplicated in pipeline "+pipeline.ID) } else { destinationIDs[destination.ID] = struct{}{} } errs = validateDestinationBackend(errs, destinationContext, destination) errs = validatePublishTransformPolicy(errs, destinationContext, destination.Publish, destination.Transform) errs = validateTransferPolicy(errs, destinationContext+".transfer", destination.Transfer) } } if len(errs) > 0 { return errs } return nil } func validateSourceBackend(errs ValidationErrors, context string, backend Backend) ValidationErrors { return validateBackend(errs, context, backend.Backend, backend.Host, backend.Port, backend.Path, backend.URI, backend.Endpoint, backend.Bucket, backend.Prefix, backend.SSH.HostKeyPolicy, backend.Creds) } func validateDestinationBackend(errs ValidationErrors, context string, destination Destination) ValidationErrors { return validateBackend(errs, context, destination.Backend, destination.Host, destination.Port, destination.Path, destination.URI, destination.Endpoint, destination.Bucket, destination.Prefix, destination.SSH.HostKeyPolicy, destination.Creds) } func validateBackend(errs ValidationErrors, context, backend, host string, port int, path, uri, endpoint, bucket, prefix string, hostKeyPolicy HostKeyPolicy, creds Credentials) ValidationErrors { switch backend { case "": errs = append(errs, context+".backend is required") case BackendLocal: if path == "" { errs = append(errs, context+".path is required for local backend") } case BackendSSH: if host == "" { errs = append(errs, context+".host is required for ssh backend") } if path == "" { errs = append(errs, context+".path is required for ssh backend") } if uri != "" { errs = append(errs, context+".uri is not supported for ssh backend; use host, user, port, and path") } if port < 0 || port > 65535 { errs = append(errs, context+".port must be between 1 and 65535") } if port == 0 { errs = append(errs, context+".port is required for ssh backend after defaults are applied") } if hostKeyPolicy != "" { if _, ok := NormalizeHostKeyPolicy(string(hostKeyPolicy)); !ok { errs = append(errs, context+".host_key_policy must be strict, true, accept-new, off, or false") } } case BackendS3: if endpoint == "" { errs = append(errs, context+".endpoint is required for s3 backend") } if bucket == "" { errs = append(errs, context+".bucket is required for s3 backend") } if err := ValidateS3Prefix(prefix); err != nil { errs = append(errs, context+".prefix must be a clean relative slash-separated path") } if (creds.AccessKeyIDEnv == "") != (creds.SecretAccessKeyEnv == "") { errs = append(errs, context+".credentials.access_key_id_env and credentials.secret_access_key_env must be configured together") } default: errs = append(errs, context+".backend "+backend+" is unsupported") } return errs } func validateValidationPolicy(errs ValidationErrors, context string, policy ValidationPolicy) ValidationErrors { if policy.OnDigestMismatch != ValidationActionFail { errs = append(errs, context+".on_digest_mismatch must be "+ValidationActionFail) } return errs } func validatePublishTransformPolicy(errs ValidationErrors, context string, policy *PublishPolicy, transform Transform) ValidationErrors { if policy == nil { errs = append(errs, context+".publish is required") return errs } if err := ValidatePublishTransformPolicy(*policy, transform); err != nil { errs = append(errs, context+"."+err.Error()) } return errs } func ValidatePublishTransformPolicy(publish PublishPolicy, transform Transform) error { if !publish.Source && !publish.HTML { return fmt.Errorf("publish must enable source or html") } if publish.HTML && transform.MarkdownToHTML == nil { return fmt.Errorf("transform.markdown_to_html is required when publish.html is true") } if transform.MarkdownToHTML == nil { return nil } if publish.HTML && !transform.MarkdownToHTML.Enabled { return fmt.Errorf("transform.markdown_to_html.enabled must be true when publish.html is true") } if publish.HTML && transform.MarkdownToHTML.Mode != TransformModeSidecar { return fmt.Errorf("transform.markdown_to_html.mode must be %s", TransformModeSidecar) } if transform.MarkdownToHTML.Enabled && transform.MarkdownToHTML.Mode != TransformModeSidecar { return fmt.Errorf("transform.markdown_to_html.mode must be %s", TransformModeSidecar) } if !transform.MarkdownToHTML.Enabled && transform.MarkdownToHTML.Mode != "" && transform.MarkdownToHTML.Mode != TransformModeSidecar { return fmt.Errorf("transform.markdown_to_html.mode must be %s", TransformModeSidecar) } return nil } func validateTransferPolicy(errs ValidationErrors, context string, policy TransferPolicy) ValidationErrors { if policy.OnDestinationSame != TransferActionSkip && policy.OnDestinationSame != TransferActionFail { errs = append(errs, context+".on_destination_same must be skip or fail") } if policy.OnDestinationOlder != TransferActionReplace && policy.OnDestinationOlder != TransferActionFail { errs = append(errs, context+".on_destination_older must be replace or fail") } if policy.OnDestinationNewer != TransferActionSkip && policy.OnDestinationNewer != TransferActionFail { errs = append(errs, context+".on_destination_newer must be skip or fail") } if policy.OnConflict != TransferActionFail { errs = append(errs, context+".on_conflict must be fail") } return errs }