package config import ( "errors" "fmt" "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) func validateStorage(cfg StorageConfig) error { backend := strings.ToLower(strings.TrimSpace(cfg.Backend)) switch backend { case "", StorageBackendLocal: if cfg.S3 != nil { return fmt.Errorf("pipeline.storage.s3 is only supported when pipeline.storage.backend is s3") } return nil case StorageBackendS3: if cfg.S3 == nil { return fmt.Errorf("pipeline.storage.s3 is required when pipeline.storage.backend is s3") } default: return fmt.Errorf("pipeline.storage.backend must be one of: local, s3") } 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 validatePublish(cfg *PublishConfig, scriptorium *ScriptoriumConfig, notarius *NotariusConfig) error { if cfg == nil { return nil } configuredOutputs := scriptoriumOutputPathMap(scriptorium) extractionOutputs := notariusOutputKeySet(notarius) seenDest := map[string]struct{}{} for i, item := range cfg.Outputs { prefix := fmt.Sprintf("pipeline.publish.outputs[%d]", i) source := strings.TrimSpace(item.Source) if source == "" { return fmt.Errorf("%s.source is required", prefix) } if _, err := artifactpolicy.ValidatePublishSourceWithExtractions(source, configuredOutputs, extractionOutputs); err != nil { return fmt.Errorf("%s.source %q is unsupported: %w", prefix, item.Source, err) } dest := strings.TrimSpace(item.Dest) if dest == "" { derivedDest, err := artifactpolicy.ResolvePublishedDestinationWithExtractions(source, "", configuredOutputs, extractionOutputs) if err != nil { return fmt.Errorf("%s.dest is required when destination cannot be derived from %q: %w", prefix, source, err) } dest = derivedDest cfg.Outputs[i].Dest = derivedDest } normalizedDest, err := pathsafe.NormalizeRelativeDestination(dest) if err != nil { switch { case errors.Is(err, pathsafe.ErrRelativePathAbsolute): return fmt.Errorf("%s.dest must be a relative path", prefix) case errors.Is(err, pathsafe.ErrRelativePathEscape): return fmt.Errorf("%s.dest must not contain path traversal", prefix) default: return fmt.Errorf("%s.dest must be non-empty", prefix) } } if _, ok := seenDest[normalizedDest]; ok { return fmt.Errorf("%s.dest %q duplicates another publish output destination", prefix, dest) } seenDest[normalizedDest] = struct{}{} } return nil } func scriptoriumOutputPathMap(scriptorium *ScriptoriumConfig) map[string]string { out := map[string]string{} if scriptorium == nil || len(scriptorium.Artifacts) == 0 { return out } for _, key := range sortedStringKeys(scriptorium.Artifacts) { artifactCfg := scriptorium.Artifacts[key] if !artifactpolicy.IsConfiguredKey(key) { continue } out[strings.TrimSpace(key)] = strings.TrimSpace(artifactCfg.OutputPath) } return out } func publishUploadConfiguredForS3(pipeline *PipelineConfig) bool { if pipeline == nil || pipeline.Publish == nil { return false } if !strings.EqualFold(strings.TrimSpace(pipeline.Storage.Backend), StorageBackendS3) { return false } enabled := true if pipeline.Publish.Enabled != nil { enabled = *pipeline.Publish.Enabled } upload := true if pipeline.Publish.UploadRun != nil { upload = *pipeline.Publish.UploadRun } return enabled && upload }