package config import ( "fmt" "regexp" "strings" "gitea.maximumdirect.net/eric/distributor/internal/link" ) var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`) func IsSlugLikeID(value string) bool { return idPattern.MatchString(value) } 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 errs = validateHTTPServer(errs, "server.http", cfg.Server.HTTP) if len(cfg.Pipelines) == 0 { errs = append(errs, "pipelines is required") } pipelineIDs := make(map[string]struct{}, len(cfg.Pipelines)) uploadPipelineIDs := make(map[string]struct{}) for pipelineIndex, pipeline := range cfg.Pipelines { pipelineContext := fmt.Sprintf("pipelines[%d]", pipelineIndex) if pipeline.ID == "" { errs = append(errs, pipelineContext+".id is required") } else if !IsSlugLikeID(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) if pipeline.Source.Backend == BackendHTTPUpload && pipeline.ID != "" { uploadPipelineIDs[pipeline.ID] = struct{}{} } 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 !IsSlugLikeID(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 = validatePathMapping(errs, destinationContext+".path_mapping", destination.PathMap) errs = validateLinks(errs, destinationContext+".links", destination.Links) errs = validateTransferPolicy(errs, destinationContext+".transfer", destination.Transfer) } } errs = validateUploadTokens(errs, cfg.UploadTokens, pipelineIDs, uploadPipelineIDs) if len(errs) > 0 { return errs } return nil } func validateHTTPServer(errs ValidationErrors, context string, server HTTPServer) ValidationErrors { if server.Bind == "" { errs = append(errs, context+".bind is required") } if server.StagingRoot == "" { errs = append(errs, context+".staging_root is required") } if server.MaxUploadSize == nil || *server.MaxUploadSize <= 0 { errs = append(errs, context+".max_upload_size must be greater than zero") } if server.QueueSize <= 0 { errs = append(errs, context+".queue_size must be greater than zero") } if server.MaxConcurrency <= 0 { errs = append(errs, context+".max_concurrency must be greater than zero") } if server.Retention == nil || *server.Retention <= 0 { errs = append(errs, context+".retention must be greater than zero") } return errs } func validateSourceBackend(errs ValidationErrors, context string, backend Backend) ValidationErrors { if backend.Backend == BackendHTTPUpload { return validateHTTPUploadSource(errs, context, backend.Upload) } return validateBackend(errs, context, backendViewFromSource(backend)) } func validateDestinationBackend(errs ValidationErrors, context string, destination Destination) ValidationErrors { if destination.Backend == BackendHTTPUpload { errs = append(errs, context+".backend "+BackendHTTPUpload+" is only supported for sources") return errs } return validateBackend(errs, context, backendViewFromDestination(destination)) } func validateHTTPUploadSource(errs ValidationErrors, context string, upload HTTPUpload) ValidationErrors { if upload.StagingPath == "" { errs = append(errs, context+".staging_path is required for http_upload backend") } if upload.MaxUploadSize == nil || *upload.MaxUploadSize <= 0 { errs = append(errs, context+".max_upload_size must be greater than zero") } return errs } func validateUploadTokens(errs ValidationErrors, tokens []UploadToken, pipelineIDs, uploadPipelineIDs map[string]struct{}) ValidationErrors { if len(uploadPipelineIDs) == 0 { if len(tokens) > 0 { errs = append(errs, "upload_tokens must reference configured http_upload pipelines") } return errs } if len(tokens) == 0 { return append(errs, "upload_tokens is required when any pipeline source backend is http_upload") } tokenIDs := make(map[string]struct{}, len(tokens)) allowedUploadPipelineIDs := make(map[string]struct{}, len(uploadPipelineIDs)) for tokenIndex, token := range tokens { context := fmt.Sprintf("upload_tokens[%d]", tokenIndex) if token.ID == "" { errs = append(errs, context+".id is required") } else if !IsSlugLikeID(token.ID) { errs = append(errs, context+".id must be a slug-like identifier") } else if _, exists := tokenIDs[token.ID]; exists { errs = append(errs, "upload token id "+token.ID+" is duplicated") } else { tokenIDs[token.ID] = struct{}{} } if token.TokenEnv == "" { errs = append(errs, context+".token_env is required") } if len(token.AllowPipelines) == 0 { errs = append(errs, context+".allow_pipelines is required") } seenAllowed := make(map[string]struct{}, len(token.AllowPipelines)) for allowIndex, pipelineID := range token.AllowPipelines { allowContext := fmt.Sprintf("%s.allow_pipelines[%d]", context, allowIndex) if pipelineID == "" { errs = append(errs, allowContext+" is required") continue } if _, exists := seenAllowed[pipelineID]; exists { errs = append(errs, context+".allow_pipelines contains duplicate pipeline id "+pipelineID) continue } seenAllowed[pipelineID] = struct{}{} if _, exists := pipelineIDs[pipelineID]; !exists { errs = append(errs, allowContext+" references unknown pipeline "+pipelineID) continue } if _, exists := uploadPipelineIDs[pipelineID]; !exists { errs = append(errs, allowContext+" references non-http_upload pipeline "+pipelineID) continue } allowedUploadPipelineIDs[pipelineID] = struct{}{} } } for pipelineID := range uploadPipelineIDs { if _, exists := allowedUploadPipelineIDs[pipelineID]; !exists { errs = append(errs, "http_upload pipeline "+pipelineID+" is not allowed by any upload token") } } return errs } func validateBackend(errs ValidationErrors, context string, backend backendView) ValidationErrors { switch backend.Backend { case "": errs = append(errs, context+".backend is required") case BackendLocal: if backend.Path == "" { errs = append(errs, context+".path is required for local backend") } case BackendSSH: if backend.Host == "" { errs = append(errs, context+".host is required for ssh backend") } if backend.Path == "" { errs = append(errs, context+".path is required for ssh backend") } if backend.Port < 0 || backend.Port > 65535 { errs = append(errs, context+".port must be between 1 and 65535") } if backend.Port == 0 { errs = append(errs, context+".port is required for ssh backend after defaults are applied") } if backend.SSH.HostKeyPolicy != "" { if _, ok := NormalizeHostKeyPolicy(string(backend.SSH.HostKeyPolicy)); !ok { errs = append(errs, context+".host_key_policy must be strict, true, accept-new, off, or false") } } case BackendS3: if backend.Endpoint == "" { errs = append(errs, context+".endpoint is required for s3 backend") } if backend.Bucket == "" { errs = append(errs, context+".bucket is required for s3 backend") } if err := ValidateS3Prefix(backend.Prefix); err != nil { errs = append(errs, context+".prefix must be a clean relative slash-separated path") } if (backend.Creds.AccessKeyIDEnv == "") != (backend.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.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 } mode := transform.MarkdownToHTML.Mode if mode == "" { mode = TransformModeSidecar } if mode != TransformModeSidecar && mode != TransformModeIndex { return fmt.Errorf("transform.markdown_to_html.mode must be %s or %s", TransformModeSidecar, TransformModeIndex) } if transform.MarkdownToHTML.Input != "" && !transform.MarkdownToHTML.Enabled { return fmt.Errorf("transform.markdown_to_html.input requires transform.markdown_to_html.enabled to be true") } if transform.MarkdownToHTML.Input != "" && mode != TransformModeIndex { return fmt.Errorf("transform.markdown_to_html.input is only valid when mode is %s", TransformModeIndex) } if transform.MarkdownToHTML.Enabled && !publish.HTML { return fmt.Errorf("transform.markdown_to_html.enabled requires publish.html to be true") } if publish.HTML && !transform.MarkdownToHTML.Enabled { return fmt.Errorf("transform.markdown_to_html.enabled must be true when publish.html is true") } return nil } func validatePathMapping(errs ValidationErrors, context string, mapping PathMapping) ValidationErrors { if mapping.Mode != PathMappingPreserveRelative && mapping.Mode != PathMappingFixed { errs = append(errs, context+".mode must be "+PathMappingPreserveRelative+" or "+PathMappingFixed) } return errs } func validateLinks(errs ValidationErrors, context string, links *Links) ValidationErrors { if links == nil { return errs } if links.BaseURL == "" { errs = append(errs, context+".base_url is required") } else if err := link.ValidateHTTPURL(links.BaseURL); err != nil { errs = append(errs, context+".base_url "+err.Error()) } switch links.Primary { case LinkPrimaryAuto, LinkPrimaryHTML, LinkPrimarySource: default: errs = append(errs, context+".primary must be "+LinkPrimaryAuto+", "+LinkPrimaryHTML+", or "+LinkPrimarySource) } return errs } 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 && policy.OnDestinationNewer != TransferActionReplace { errs = append(errs, context+".on_destination_newer must be skip, replace, or fail") } if policy.OnConflict != TransferActionFail && policy.OnConflict != TransferActionReplace { errs = append(errs, context+".on_conflict must be fail or replace") } return errs }