392 lines
14 KiB
Go
392 lines
14 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"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 = validateWorkflow(errs, destinationContext+".workflow", destination.Workflow)
|
|
errs = validateRetentionPolicy(errs, destinationContext+".retention", destination.Retention)
|
|
}
|
|
}
|
|
|
|
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.CssHref != "" && !transform.MarkdownToHTML.Enabled {
|
|
return fmt.Errorf("transform.markdown_to_html.css_href 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 err := validateCSSHref(transform.MarkdownToHTML.CssHref); err != nil {
|
|
return fmt.Errorf("transform.markdown_to_html.css_href %w", err)
|
|
}
|
|
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 validateCSSHref(value string) error {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
for _, character := range value {
|
|
if unicode.IsControl(character) || unicode.IsSpace(character) {
|
|
return fmt.Errorf("must not contain whitespace or control characters")
|
|
}
|
|
}
|
|
if strings.ContainsAny(value, "\\<>\"'") {
|
|
return fmt.Errorf("must not contain backslashes or HTML-sensitive characters")
|
|
}
|
|
if strings.HasPrefix(value, "//") {
|
|
return fmt.Errorf("must not be scheme-relative")
|
|
}
|
|
parsed, err := url.Parse(value)
|
|
if err != nil {
|
|
return fmt.Errorf("must be a valid URL reference: %w", err)
|
|
}
|
|
if parsed.Fragment != "" {
|
|
return fmt.Errorf("must not include a fragment")
|
|
}
|
|
if parsed.Scheme != "" {
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return fmt.Errorf("scheme must be http or https")
|
|
}
|
|
if parsed.Host == "" {
|
|
return fmt.Errorf("host is required for absolute URLs")
|
|
}
|
|
if parsed.User != nil {
|
|
return fmt.Errorf("must not include userinfo")
|
|
}
|
|
return nil
|
|
}
|
|
if parsed.Host != "" {
|
|
return fmt.Errorf("must not be scheme-relative")
|
|
}
|
|
if parsed.Path == "" {
|
|
return fmt.Errorf("relative URL path is required")
|
|
}
|
|
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 validateWorkflow(errs ValidationErrors, context, workflow string) ValidationErrors {
|
|
if workflow != WorkflowAdditive && workflow != WorkflowReplacement {
|
|
errs = append(errs, context+" must be "+WorkflowAdditive+" or "+WorkflowReplacement)
|
|
}
|
|
return errs
|
|
}
|
|
|
|
func validateRetentionPolicy(errs ValidationErrors, context string, policy RetentionPolicy) ValidationErrors {
|
|
prune := policy.Prune
|
|
if !prune.Enabled {
|
|
return errs
|
|
}
|
|
if prune.OlderThan == nil && prune.KeepLatest == nil {
|
|
errs = append(errs, context+".prune must set older_than or keep_latest when enabled is true")
|
|
}
|
|
if prune.OlderThan != nil && *prune.OlderThan <= 0 {
|
|
errs = append(errs, context+".prune.older_than must be greater than zero")
|
|
}
|
|
if prune.KeepLatest != nil && *prune.KeepLatest < 0 {
|
|
errs = append(errs, context+".prune.keep_latest must be zero or greater")
|
|
}
|
|
return errs
|
|
}
|