754 lines
38 KiB
Go
754 lines
38 KiB
Go
package validator
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"browser.local/platform/domain"
|
|
)
|
|
|
|
var (
|
|
runtimeLogEventSchemaRefPattern = regexp.MustCompile(`^[A-Za-z0-9_./-]+\.json$`)
|
|
runtimeLogEventTypePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,119}$`)
|
|
runtimeDLLModKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,79}$`)
|
|
runtimeDLLABIPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,80}$`)
|
|
)
|
|
|
|
func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles) error {
|
|
profiles = domain.CopyGamePluginRuntimeProfiles(profiles)
|
|
var violations []string
|
|
lifecycleKeys := map[string]struct{}{}
|
|
transportKeys := map[string]struct{}{}
|
|
managerKeys := map[string]struct{}{}
|
|
dllExtensionKeys := map[string]struct{}{}
|
|
dllExtensionStates := map[string]string{}
|
|
discoveryKeys := map[string]struct{}{}
|
|
dependencyKeys := map[string]struct{}{}
|
|
installPlanKeys := map[string]struct{}{}
|
|
serverDeploymentKeys := map[string]struct{}{}
|
|
logSourceKeys := map[string]struct{}{}
|
|
logSourceRetentions := map[string]int{}
|
|
logEventKeys := map[string]struct{}{}
|
|
logEventTypes := map[string]struct{}{}
|
|
|
|
for i, probe := range profiles.Discovery {
|
|
prefix := fmt.Sprintf("runtimeProfiles.discovery[%d]", i)
|
|
violations = append(violations, validateProfileKey(prefix+".key", probe.Key)...)
|
|
violations = append(violations, recordRuntimeProfileKey(discoveryKeys, prefix+".key", probe.Key)...)
|
|
violations = append(violations, validateProfileKey(prefix+".targetKey", probe.TargetKey)...)
|
|
if !oneOf(probe.Kind, "file.exists", "command.version", "service.status", "port.open", "steam.app", "docker.container") {
|
|
violations = append(violations, prefix+".kind is invalid")
|
|
}
|
|
violations = append(violations, validateRuntimePlatforms(prefix+".platforms", probe.Platforms)...)
|
|
violations = append(violations, validateSafeRuntimeValue(prefix+".expected", probe.Expected)...)
|
|
}
|
|
for i, profile := range profiles.LifecycleProfiles {
|
|
prefix := fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d]", i)
|
|
violations = append(violations, validateProfileKey(prefix+".key", profile.Key)...)
|
|
violations = append(violations, recordRuntimeProfileKey(lifecycleKeys, prefix+".key", profile.Key)...)
|
|
if !oneOf(profile.Mode, "local-process", "hosted-ftp-rcon", "ftp-only", "custom-client") {
|
|
violations = append(violations, prefix+".mode is invalid")
|
|
}
|
|
if len(profile.Capabilities) == 0 {
|
|
violations = append(violations, prefix+".capabilities must not be empty")
|
|
}
|
|
for j, capability := range profile.Capabilities {
|
|
if !validPluginRunCapability(capability) {
|
|
violations = append(violations, fmt.Sprintf("%s.capabilities[%d] is not allowed", prefix, j))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(prefix+".capabilities", profile.Capabilities)...)
|
|
violations = append(violations, validateLifecycleActionsOptional(profile.ActionRefs)...)
|
|
for j, key := range profile.TransportKeys {
|
|
violations = append(violations, validateProfileKey(fmt.Sprintf("%s.transportKeys[%d]", prefix, j), key)...)
|
|
}
|
|
violations = append(violations, duplicateViolations(prefix+".transportKeys", profile.TransportKeys)...)
|
|
if profile.ClientManagerRef != "" {
|
|
violations = append(violations, validateProfileKey(prefix+".clientManagerRef", profile.ClientManagerRef)...)
|
|
}
|
|
for j, key := range profile.DLLExtensionRefs {
|
|
violations = append(violations, validateProfileKey(fmt.Sprintf("%s.dllExtensionRefs[%d]", prefix, j), key)...)
|
|
}
|
|
violations = append(violations, duplicateViolations(prefix+".dllExtensionRefs", profile.DLLExtensionRefs)...)
|
|
violations = append(violations, validateRuntimePlatforms(prefix+".platforms", profile.Platforms)...)
|
|
}
|
|
for i, probe := range profiles.DependencyProbes {
|
|
prefix := fmt.Sprintf("runtimeProfiles.dependencyProbes[%d]", i)
|
|
violations = append(violations, validateProfileKey(prefix+".key", probe.Key)...)
|
|
violations = append(violations, recordRuntimeProfileKey(dependencyKeys, prefix+".key", probe.Key)...)
|
|
violations = append(violations, validateProfileKey(prefix+".targetKey", probe.TargetKey)...)
|
|
if !oneOf(probe.Kind, "command.version", "service.exists", "port.available", "steam.app", "java.version", "docker.available", "package.installed", "file.exists") {
|
|
violations = append(violations, prefix+".kind is invalid")
|
|
}
|
|
violations = append(violations, validateSafeRuntimeValue(prefix+".minimumVersion", probe.MinimumVersion)...)
|
|
violations = append(violations, validateRuntimePlatforms(prefix+".platforms", probe.Platforms)...)
|
|
}
|
|
for i, plan := range profiles.InstallPlans {
|
|
prefix := fmt.Sprintf("runtimeProfiles.installPlans[%d]", i)
|
|
violations = append(violations, validateProfileKey(prefix+".key", plan.Key)...)
|
|
violations = append(violations, recordRuntimeProfileKey(installPlanKeys, prefix+".key", plan.Key)...)
|
|
violations = appendRequired(violations, prefix+".title", plan.Title)
|
|
violations = append(violations, validateSafeRuntimeValue(prefix+".title", plan.Title)...)
|
|
if len(plan.Steps) == 0 {
|
|
violations = append(violations, prefix+".steps must not be empty")
|
|
}
|
|
if len(plan.Steps) > 64 {
|
|
violations = append(violations, prefix+".steps must not exceed 64")
|
|
}
|
|
for j, step := range plan.Steps {
|
|
stepPrefix := fmt.Sprintf("%s.steps[%d]", prefix, j)
|
|
if !oneOf(step.Type, "package", "verified-download", "steamcmd-app", "manual") {
|
|
violations = append(violations, stepPrefix+".type is invalid")
|
|
}
|
|
violations = append(violations, validateProfileKey(stepPrefix+".targetKey", step.TargetKey)...)
|
|
for field, value := range map[string]string{"packageManager": step.PackageManager, "packageName": step.PackageName, "version": step.Version} {
|
|
violations = append(violations, validateSafeRuntimeValue(stepPrefix+"."+field, value)...)
|
|
}
|
|
if step.DownloadRef != "" {
|
|
parsed, err := url.Parse(step.DownloadRef)
|
|
host := ""
|
|
if parsed != nil {
|
|
host = strings.ToLower(parsed.Hostname())
|
|
}
|
|
ip := net.ParseIP(host)
|
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || host == "localhost" || strings.HasSuffix(host, ".localhost") || ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast()) {
|
|
violations = append(violations, stepPrefix+".downloadRef must be a credential-free HTTPS URL")
|
|
}
|
|
}
|
|
if step.Checksum != "" {
|
|
encoded := strings.TrimPrefix(step.Checksum, "sha256:")
|
|
if !strings.HasPrefix(step.Checksum, "sha256:") || len(encoded) != 64 {
|
|
violations = append(violations, stepPrefix+".checksum is invalid")
|
|
} else if _, err := hex.DecodeString(encoded); err != nil {
|
|
violations = append(violations, stepPrefix+".checksum is invalid")
|
|
}
|
|
}
|
|
switch step.Type {
|
|
case "package":
|
|
if !oneOf(step.PackageManager, "winget", "choco", "scoop", "apt", "yum", "dnf", "pacman", "zypper", "brew") {
|
|
violations = append(violations, stepPrefix+".packageManager is unsupported for package step")
|
|
}
|
|
if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:+@/-]{0,119}$`).MatchString(step.PackageName) {
|
|
violations = append(violations, stepPrefix+".packageName is invalid")
|
|
}
|
|
case "verified-download":
|
|
if step.DownloadRef == "" || step.Checksum == "" {
|
|
violations = append(violations, stepPrefix+" requires downloadRef and checksum")
|
|
}
|
|
case "steamcmd-app":
|
|
if step.PackageManager != "" && step.PackageManager != "steamcmd" || !regexp.MustCompile(`^[0-9]{1,12}$`).MatchString(step.PackageName) {
|
|
violations = append(violations, stepPrefix+" requires a numeric Steam app and steamcmd adapter")
|
|
}
|
|
case "manual":
|
|
if step.DownloadRef != "" || step.Checksum != "" || step.PackageName != "" {
|
|
violations = append(violations, stepPrefix+" manual step cannot contain machine execution fields")
|
|
}
|
|
}
|
|
}
|
|
violations = append(violations, validateRuntimePlatforms(prefix+".platforms", plan.Platforms)...)
|
|
}
|
|
for i, profile := range profiles.ServerDeployments {
|
|
prefix := fmt.Sprintf("runtimeProfiles.serverDeployments[%d]", i)
|
|
violations = append(violations, validateProfileKey(prefix+".key", profile.Key)...)
|
|
violations = append(violations, recordRuntimeProfileKey(serverDeploymentKeys, prefix+".key", profile.Key)...)
|
|
if !validSemanticVersion(profile.Version) {
|
|
violations = append(violations, prefix+".version must be semantic")
|
|
}
|
|
if !regexp.MustCompile(`^[0-9]{1,12}$`).MatchString(profile.SteamAppID) {
|
|
violations = append(violations, prefix+".steamAppId must be numeric")
|
|
}
|
|
for field, value := range map[string]string{"executableKey": profile.ExecutableKey, "installRootKey": profile.InstallRootKey, "configKey": profile.ConfigKey} {
|
|
violations = append(violations, validateProfileKey(prefix+"."+field, value)...)
|
|
}
|
|
if profile.ConfigFormat != "ini" && profile.ConfigFormat != "json" && profile.ConfigFormat != "yaml" && profile.ConfigFormat != "properties" {
|
|
violations = append(violations, prefix+".configFormat is invalid")
|
|
}
|
|
if len(profile.SupportedTargets) == 0 {
|
|
violations = append(violations, prefix+".supportedTargets must not be empty")
|
|
}
|
|
prerequisiteKeys := map[string]struct{}{}
|
|
for j, prerequisite := range profile.Prerequisites {
|
|
prerequisitePrefix := fmt.Sprintf("%s.prerequisites[%d]", prefix, j)
|
|
violations = append(violations, validateProfileKey(prerequisitePrefix+".key", prerequisite.Key)...)
|
|
if _, exists := prerequisiteKeys[prerequisite.Key]; exists {
|
|
violations = append(violations, prerequisitePrefix+".key duplicates another prerequisite")
|
|
}
|
|
prerequisiteKeys[prerequisite.Key] = struct{}{}
|
|
if !oneOf(prerequisite.Kind, "steamcmd", "windows-vcredist", "windows-directx") {
|
|
violations = append(violations, prerequisitePrefix+".kind is invalid")
|
|
}
|
|
}
|
|
for j, target := range profile.SupportedTargets {
|
|
if !validPluginSupportedOS(target.OS) || !oneOf(target.Arch, "amd64", "arm64") {
|
|
violations = append(violations, fmt.Sprintf("%s.supportedTargets[%d] is invalid", prefix, j))
|
|
}
|
|
}
|
|
mappingKeys := map[string]struct{}{}
|
|
for j, mapping := range profile.ConfigMappings {
|
|
mappingPrefix := fmt.Sprintf("%s.configMappings[%d]", prefix, j)
|
|
if !regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._/-]{0,79}$`).MatchString(mapping.FieldKey) {
|
|
violations = append(violations, mappingPrefix+".fieldKey is invalid")
|
|
}
|
|
violations = append(violations, validateProfileKey(mappingPrefix+".configKey", mapping.ConfigKey)...)
|
|
if _, exists := mappingKeys[mapping.FieldKey]; exists {
|
|
violations = append(violations, mappingPrefix+".fieldKey duplicates another mapping")
|
|
}
|
|
mappingKeys[mapping.FieldKey] = struct{}{}
|
|
if !oneOf(mapping.ValueType, "text", "integer", "number", "boolean", "port") {
|
|
violations = append(violations, mappingPrefix+".valueType is invalid")
|
|
}
|
|
}
|
|
markerKeys := map[string]struct{}{}
|
|
for j, marker := range profile.DiscoveryMarkers {
|
|
markerPrefix := fmt.Sprintf("%s.discoveryMarkers[%d]", prefix, j)
|
|
violations = append(violations, validateProfileKey(markerPrefix+".key", marker.Key)...)
|
|
violations = append(violations, validateProfileKey(markerPrefix+".targetKey", marker.TargetKey)...)
|
|
if _, exists := markerKeys[marker.Key]; exists {
|
|
violations = append(violations, markerPrefix+".key duplicates another marker")
|
|
}
|
|
markerKeys[marker.Key] = struct{}{}
|
|
if !oneOf(marker.Kind, "file.exists", "command.version", "port.open", "steam.app") {
|
|
violations = append(violations, markerPrefix+".kind is invalid")
|
|
}
|
|
violations = append(violations, validateSafeRuntimeValue(markerPrefix+".expected", marker.Expected)...)
|
|
}
|
|
checkKeys := map[string]struct{}{}
|
|
for j, check := range profile.VerificationChecks {
|
|
checkPrefix := fmt.Sprintf("%s.verificationChecks[%d]", prefix, j)
|
|
violations = append(violations, validateProfileKey(checkPrefix+".key", check.Key)...)
|
|
violations = append(violations, validateProfileKey(checkPrefix+".targetKey", check.TargetKey)...)
|
|
if _, exists := checkKeys[check.Key]; exists {
|
|
violations = append(violations, checkPrefix+".key duplicates another check")
|
|
}
|
|
checkKeys[check.Key] = struct{}{}
|
|
if !oneOf(check.Kind, "executable.present", "version.matches", "port.bound", "config.readable", "process.healthy") {
|
|
violations = append(violations, checkPrefix+".kind is invalid")
|
|
}
|
|
}
|
|
if len(profile.VerificationChecks) == 0 || !containsRequiredVerification(profile.VerificationChecks) {
|
|
violations = append(violations, prefix+".verificationChecks must include executable, config, port, and process checks")
|
|
}
|
|
}
|
|
for i, source := range profiles.LogSources {
|
|
prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i)
|
|
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
|
|
violations = append(violations, recordRuntimeProfileKey(logSourceKeys, prefix+".key", source.Key)...)
|
|
if source.Key != "" {
|
|
logSourceRetentions[source.Key] = source.RetentionDays
|
|
}
|
|
if !oneOf(source.Kind, "process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query", "client-manager") {
|
|
violations = append(violations, prefix+".kind is invalid")
|
|
}
|
|
if source.TargetKey != "" {
|
|
violations = append(violations, validateProfileKey(prefix+".targetKey", source.TargetKey)...)
|
|
}
|
|
violations = append(violations, validateProfileKey(prefix+".streamKey", source.StreamKey)...)
|
|
if source.CursorKind != "" && !oneOf(source.CursorKind, "sequence", "offset", "fingerprint", "ftp-listing", "sql-cursor") {
|
|
violations = append(violations, prefix+".cursorKind is invalid")
|
|
}
|
|
if source.RetentionDays < 0 || source.RetentionDays > 365 {
|
|
violations = append(violations, prefix+".retentionDays is invalid")
|
|
}
|
|
}
|
|
if len(profiles.LogEvents) > 128 {
|
|
violations = append(violations, "runtimeProfiles.logEvents must not exceed 128")
|
|
}
|
|
for i, event := range profiles.LogEvents {
|
|
prefix := fmt.Sprintf("runtimeProfiles.logEvents[%d]", i)
|
|
violations = append(violations, validateProfileKey(prefix+".key", event.Key)...)
|
|
violations = append(violations, recordRuntimeProfileKey(logEventKeys, prefix+".key", event.Key)...)
|
|
if strings.TrimSpace(event.Title) == "" || len([]rune(event.Title)) > 80 {
|
|
violations = append(violations, prefix+".title is invalid")
|
|
}
|
|
violations = append(violations, validateSafeRuntimeValue(prefix+".title", event.Title)...)
|
|
violations = append(violations, validateProfileKey(prefix+".sourceKey", event.SourceKey)...)
|
|
if _, exists := logSourceKeys[event.SourceKey]; !exists {
|
|
violations = append(violations, prefix+".sourceKey must reference a declared runtime log source")
|
|
}
|
|
if !runtimeLogEventTypePattern.MatchString(event.EventType) {
|
|
violations = append(violations, prefix+".eventType is invalid")
|
|
}
|
|
violations = append(violations, recordRuntimeProfileKey(logEventTypes, prefix+".eventType", event.EventType)...)
|
|
if hasUnsafeRuntimeLogEventSemantics(event.EventType) {
|
|
violations = append(violations, prefix+".eventType contains unsafe operation semantics")
|
|
}
|
|
if !validPluginPermission(event.Permission) {
|
|
violations = append(violations, prefix+".permission is not allowed")
|
|
}
|
|
if len(event.SchemaRef) > 240 || !runtimeLogEventSchemaRefPattern.MatchString(event.SchemaRef) || !safeRelativeJSONRef(event.SchemaRef) {
|
|
violations = append(violations, prefix+".schemaRef must be a bounded safe relative JSON reference")
|
|
}
|
|
if event.RetentionDays < 1 || event.RetentionDays > 365 {
|
|
violations = append(violations, prefix+".retentionDays is invalid")
|
|
}
|
|
if sourceRetention, exists := logSourceRetentions[event.SourceKey]; exists && sourceRetention > 0 && event.RetentionDays > sourceRetention {
|
|
violations = append(violations, prefix+".retentionDays must not exceed the source retention")
|
|
}
|
|
if !oneOf(string(event.Severity), string(domain.RuntimeLogEventSeverityInfo), string(domain.RuntimeLogEventSeverityNotice), string(domain.RuntimeLogEventSeverityWarning), string(domain.RuntimeLogEventSeverityCritical)) {
|
|
violations = append(violations, prefix+".severity is invalid")
|
|
}
|
|
}
|
|
for i, transport := range profiles.TransportProfiles {
|
|
prefix := fmt.Sprintf("runtimeProfiles.transportProfiles[%d]", i)
|
|
violations = append(violations, validateProfileKey(prefix+".key", transport.Key)...)
|
|
violations = append(violations, recordRuntimeProfileKey(transportKeys, prefix+".key", transport.Key)...)
|
|
if !oneOf(transport.Kind, "file", "ftp", "rsync", "mysql", "sqlite", "rcon", "program") {
|
|
violations = append(violations, prefix+".kind is invalid")
|
|
}
|
|
if transport.TargetKey != "" {
|
|
violations = append(violations, validateProfileKey(prefix+".targetKey", transport.TargetKey)...)
|
|
}
|
|
if len(transport.Capabilities) == 0 {
|
|
violations = append(violations, prefix+".capabilities must not be empty")
|
|
}
|
|
for j, capability := range transport.Capabilities {
|
|
if !validPluginRunCapability(capability) {
|
|
violations = append(violations, fmt.Sprintf("%s.capabilities[%d] is not allowed", prefix, j))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(prefix+".capabilities", transport.Capabilities)...)
|
|
}
|
|
for i, manager := range profiles.ClientManagers {
|
|
prefix := fmt.Sprintf("runtimeProfiles.clientManagers[%d]", i)
|
|
violations = append(violations, validateProfileKey(prefix+".key", manager.Key)...)
|
|
violations = append(violations, recordRuntimeProfileKey(managerKeys, prefix+".key", manager.Key)...)
|
|
parsed, err := url.Parse(manager.RepositoryURL)
|
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || !strings.HasSuffix(parsed.Path, ".git") {
|
|
violations = append(violations, prefix+".repository.url must be a credential-free HTTPS .git URL")
|
|
}
|
|
if !oneOf(manager.RevisionPolicy, "pinned", "branch", "tag") {
|
|
violations = append(violations, prefix+".repository.revisionPolicy is invalid")
|
|
}
|
|
switch manager.RevisionPolicy {
|
|
case "pinned":
|
|
if manager.Revision == "" {
|
|
violations = append(violations, prefix+".repository.revision is required for pinned policy")
|
|
}
|
|
case "branch":
|
|
if manager.Branch == "" {
|
|
violations = append(violations, prefix+".repository.branch is required for branch policy")
|
|
}
|
|
case "tag":
|
|
if manager.Tag == "" {
|
|
violations = append(violations, prefix+".repository.tag is required for tag policy")
|
|
}
|
|
}
|
|
if !oneOf(manager.BuildSystem, "go", "npm", "cargo", "make") {
|
|
violations = append(violations, prefix+".build.system is invalid")
|
|
}
|
|
if len(manager.SupportedTargets) == 0 {
|
|
violations = append(violations, prefix+".supportedTargets must not be empty")
|
|
}
|
|
if len(manager.OutputArtifacts) == 0 {
|
|
violations = append(violations, prefix+".outputArtifacts must not be empty")
|
|
}
|
|
for field, value := range map[string]string{"displayName": manager.DisplayName, "branch": manager.Branch, "tag": manager.Tag, "revision": manager.Revision, "workspaceRef": manager.WorkspaceRef, "entryRef": manager.EntryRef} {
|
|
violations = append(violations, validateSafeRuntimeValue(prefix+"."+field, value)...)
|
|
}
|
|
targets := map[string]struct{}{}
|
|
for j, target := range manager.SupportedTargets {
|
|
if !validPluginSupportedOS(target.OS) || !oneOf(target.Arch, "amd64", "arm64") {
|
|
violations = append(violations, fmt.Sprintf("%s.supportedTargets[%d] is invalid", prefix, j))
|
|
}
|
|
targetKey := target.OS + "/" + target.Arch
|
|
if _, exists := targets[targetKey]; exists {
|
|
violations = append(violations, fmt.Sprintf("%s.supportedTargets[%d] is duplicated", prefix, j))
|
|
}
|
|
targets[targetKey] = struct{}{}
|
|
}
|
|
configKeys := map[string]struct{}{}
|
|
for j, config := range manager.ConfigTemplates {
|
|
violations = append(violations, validateProfileKey(fmt.Sprintf("%s.configTemplates[%d].key", prefix, j), config.Key)...)
|
|
violations = append(violations, recordRuntimeProfileKey(configKeys, fmt.Sprintf("%s.configTemplates[%d].key", prefix, j), config.Key)...)
|
|
violations = append(violations, validateSafeRuntimeValue(prefix+".configTemplates.templateRef", config.TemplateRef)...)
|
|
violations = append(violations, validateSafeRuntimeValue(prefix+".configTemplates.outputRef", config.OutputRef)...)
|
|
}
|
|
for j, output := range manager.OutputArtifacts {
|
|
violations = append(violations, validateSafeRuntimeValue(fmt.Sprintf("%s.outputArtifacts[%d]", prefix, j), output)...)
|
|
}
|
|
violations = append(violations, duplicateViolations(prefix+".outputArtifacts", manager.OutputArtifacts)...)
|
|
if manager.Deployment.Mode != "" {
|
|
if manager.Deployment.Mode != "run-supervised" {
|
|
violations = append(violations, prefix+".deployment.mode is invalid")
|
|
}
|
|
if !validSemanticVersion(manager.Version) {
|
|
violations = append(violations, prefix+".version must be semantic when deployment is declared")
|
|
}
|
|
violations = append(violations, validateSafeRelativeRuntimePath(prefix+".deployment.executableRef", manager.Deployment.ExecutableRef)...)
|
|
if !containsString(manager.OutputArtifacts, manager.Deployment.ExecutableRef) {
|
|
violations = append(violations, prefix+".deployment.executableRef must name an output artifact")
|
|
}
|
|
for j, argument := range manager.Deployment.Arguments {
|
|
if !regexp.MustCompile(`^[A-Za-z0-9_./:=@+-]{1,120}$`).MatchString(argument) {
|
|
violations = append(violations, fmt.Sprintf("%s.deployment.arguments[%d] is invalid", prefix, j))
|
|
}
|
|
violations = append(violations, validateSafeRuntimeValue(fmt.Sprintf("%s.deployment.arguments[%d]", prefix, j), argument)...)
|
|
}
|
|
if len(manager.Deployment.RequiredRunCapabilities) == 0 || !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerDeploy) {
|
|
violations = append(violations, prefix+".deployment.requiredRunCapabilities must include client-manager.deploy")
|
|
}
|
|
for j, capability := range manager.Deployment.RequiredRunCapabilities {
|
|
if !oneOf(capability, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall) {
|
|
violations = append(violations, fmt.Sprintf("%s.deployment.requiredRunCapabilities[%d] is invalid", prefix, j))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(prefix+".deployment.requiredRunCapabilities", manager.Deployment.RequiredRunCapabilities)...)
|
|
if len(manager.Lifecycle.Actions) == 0 || manager.Lifecycle.StartupTimeoutSeconds < 1 || manager.Lifecycle.StartupTimeoutSeconds > 300 || manager.Lifecycle.StopTimeoutSeconds < 1 || manager.Lifecycle.StopTimeoutSeconds > 120 {
|
|
violations = append(violations, prefix+".lifecycle actions and bounded timeouts are required")
|
|
}
|
|
for j, action := range manager.Lifecycle.Actions {
|
|
if !oneOf(action, "start", "stop", "restart", "status", "update", "rollback", "uninstall") {
|
|
violations = append(violations, fmt.Sprintf("%s.lifecycle.actions[%d] is invalid", prefix, j))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(prefix+".lifecycle.actions", manager.Lifecycle.Actions)...)
|
|
if containsAny(manager.Lifecycle.Actions, []string{"start", "stop", "restart", "status"}) && !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerControl) {
|
|
violations = append(violations, prefix+".lifecycle control actions require client-manager.control")
|
|
}
|
|
if containsString(manager.Lifecycle.Actions, "update") && !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerUpdate) {
|
|
violations = append(violations, prefix+".lifecycle update requires client-manager.update")
|
|
}
|
|
if containsString(manager.Lifecycle.Actions, "rollback") && !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerRollback) {
|
|
violations = append(violations, prefix+".lifecycle rollback requires client-manager.rollback")
|
|
}
|
|
if containsString(manager.Lifecycle.Actions, "uninstall") && !containsString(manager.Deployment.RequiredRunCapabilities, domain.JobCapabilityClientManagerUninstall) {
|
|
violations = append(violations, prefix+".lifecycle uninstall requires client-manager.uninstall")
|
|
}
|
|
if !oneOf(manager.Health.Mode, "component-heartbeat", "process") || manager.Health.IntervalSeconds < 5 || manager.Health.IntervalSeconds > 300 || manager.Health.DegradedAfterSeconds < manager.Health.IntervalSeconds*2 || manager.Health.OfflineAfterSeconds <= manager.Health.DegradedAfterSeconds || manager.Health.OfflineAfterSeconds > 3600 {
|
|
violations = append(violations, prefix+".health mode and thresholds are invalid")
|
|
}
|
|
for j, capability := range manager.Health.RequiredCapabilities {
|
|
if !oneOf(capability, "component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream") {
|
|
violations = append(violations, fmt.Sprintf("%s.health.requiredCapabilities[%d] is invalid", prefix, j))
|
|
}
|
|
}
|
|
if manager.Health.Mode == "component-heartbeat" && !containsAny(manager.Health.RequiredCapabilities, []string{"component.register"}) || manager.Health.Mode == "component-heartbeat" && !containsString(manager.Health.RequiredCapabilities, "component.heartbeat") || manager.Health.Mode == "component-heartbeat" && !containsString(manager.Health.RequiredCapabilities, "component.health") {
|
|
violations = append(violations, prefix+".health component-heartbeat requires register, heartbeat, and health capabilities")
|
|
}
|
|
minimum, minimumOK := semanticVersionTuple(manager.Compatibility.MinimumVersion)
|
|
maximum, maximumOK := semanticVersionTuple(manager.Compatibility.MaximumVersion)
|
|
version, _ := semanticVersionTuple(manager.Version)
|
|
if manager.Compatibility.MinimumVersion != "" && !minimumOK || manager.Compatibility.MaximumVersion != "" && !maximumOK || minimumOK && maximumOK && compareSemanticVersion(minimum, maximum) > 0 || minimumOK && compareSemanticVersion(version, minimum) < 0 || maximumOK && compareSemanticVersion(version, maximum) > 0 {
|
|
violations = append(violations, prefix+".compatibility version bounds are invalid")
|
|
}
|
|
if manager.UpdatePolicy.Strategy != "manual-staged" || !manager.UpdatePolicy.RequireApproval || !manager.UpdatePolicy.RetainPrevious || manager.UpdatePolicy.HealthConfirmationSeconds < manager.Health.IntervalSeconds || manager.UpdatePolicy.HealthConfirmationSeconds > 600 {
|
|
violations = append(violations, prefix+".updatePolicy must be approved, staged, health checked, and retain previous")
|
|
}
|
|
}
|
|
}
|
|
for i, extension := range profiles.DLLExtensions {
|
|
prefix := fmt.Sprintf("runtimeProfiles.dllExtensions[%d]", i)
|
|
violations = append(violations, validateProfileKey(prefix+".key", extension.Key)...)
|
|
violations = append(violations, recordRuntimeProfileKey(dllExtensionKeys, prefix+".key", extension.Key)...)
|
|
if extension.Key != "" {
|
|
dllExtensionStates[extension.Key] = extension.ReleaseState
|
|
}
|
|
violations = append(violations, validateRuntimeDLLExtensionProfile(prefix, extension)...)
|
|
}
|
|
for i, profile := range profiles.LifecycleProfiles {
|
|
for _, key := range profile.TransportKeys {
|
|
if _, ok := transportKeys[key]; !ok {
|
|
violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d].transportKeys references undeclared transport %q", i, key))
|
|
}
|
|
}
|
|
if profile.ClientManagerRef != "" {
|
|
if _, ok := managerKeys[profile.ClientManagerRef]; !ok {
|
|
violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d].clientManagerRef references undeclared client manager", i))
|
|
}
|
|
}
|
|
if len(profile.DLLExtensionRefs) > 0 {
|
|
if profile.Mode != "local-process" || !containsString(profile.Capabilities, domain.LifecycleCapabilityStart) || len(profile.Platforms) != 1 || profile.Platforms[0] != "windows" {
|
|
violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d] DLL extensions require a windows local-process start profile", i))
|
|
}
|
|
for _, key := range profile.DLLExtensionRefs {
|
|
state, exists := dllExtensionStates[key]
|
|
if !exists {
|
|
violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d].dllExtensionRefs references undeclared DLL extension %q", i, key))
|
|
continue
|
|
}
|
|
if state != "ready" {
|
|
violations = append(violations, fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d].dllExtensionRefs references unpublished DLL extension %q", i, key))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func containsRequiredVerification(checks []domain.RuntimeServerVerificationCheck) bool {
|
|
required := map[string]bool{"executable.present": false, "port.bound": false, "config.readable": false, "process.healthy": false}
|
|
for _, check := range checks {
|
|
if check.Required {
|
|
if _, ok := required[check.Kind]; ok {
|
|
required[check.Kind] = true
|
|
}
|
|
}
|
|
}
|
|
for _, present := range required {
|
|
if !present {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validateRuntimeDLLExtensionProfile(prefix string, extension domain.RuntimeDLLExtensionProfile) []string {
|
|
var violations []string
|
|
if extension.Kind != "ue4ss-dll" || extension.Activation != "server-start" {
|
|
violations = append(violations, prefix+".kind and activation must be ue4ss-dll/server-start")
|
|
}
|
|
if !validSemanticVersion(extension.Version) {
|
|
violations = append(violations, prefix+".version must be semantic")
|
|
}
|
|
violations = append(violations, validateSafeRuntimeValue(prefix+".displayName", extension.DisplayName)...)
|
|
violations = append(violations, validateProfileKey(prefix+".targetKey", extension.TargetKey)...)
|
|
if !runtimeDLLModKeyPattern.MatchString(extension.ModKey) {
|
|
violations = append(violations, prefix+".modKey is invalid")
|
|
}
|
|
if extension.DLLRef != "ue4ss/Mods/"+extension.ModKey+"/dlls/main.dll" {
|
|
violations = append(violations, prefix+".dllRef must be the declared UE4SS main.dll path")
|
|
}
|
|
if extension.UpdateOnStart != true {
|
|
violations = append(violations, prefix+".updateOnStart must be true")
|
|
}
|
|
if extension.RCONPort < 1024 || extension.RCONPort > 65535 {
|
|
violations = append(violations, prefix+".rconPort must be an unprivileged port")
|
|
}
|
|
if len(extension.SupportedTargets) != 1 || extension.SupportedTargets[0].OS != "windows" || extension.SupportedTargets[0].Arch != "amd64" {
|
|
violations = append(violations, prefix+".supportedTargets must contain only windows/amd64")
|
|
}
|
|
if extension.ReleaseState != "ready" && extension.ReleaseState != "unpublished" {
|
|
violations = append(violations, prefix+".releaseState is invalid")
|
|
}
|
|
if extension.ReleaseURL != "" {
|
|
violations = append(violations, validateRuntimeDLLReleaseURL(prefix+".releaseUrl", extension.ReleaseURL)...)
|
|
}
|
|
if extension.ReleaseState == "ready" {
|
|
if extension.ReleaseURL == "" {
|
|
violations = append(violations, prefix+".releaseUrl is required for a ready release")
|
|
}
|
|
if !validSHA256Checksum(extension.Checksum) || !validSHA256Checksum(extension.SCUMExecutableChecksum) {
|
|
violations = append(violations, prefix+".checksum and scumExecutableChecksum must be SHA-256")
|
|
}
|
|
if extension.SizeBytes < 1 || extension.SizeBytes > 128*1024*1024 {
|
|
violations = append(violations, prefix+".sizeBytes is out of bounds")
|
|
}
|
|
if !runtimeDLLABIPattern.MatchString(extension.UE4SSABI) {
|
|
violations = append(violations, prefix+".ue4ssAbi is invalid")
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validateRuntimeDLLReleaseURL(field string, value string) []string {
|
|
parsed, err := url.Parse(value)
|
|
host := ""
|
|
if parsed != nil {
|
|
host = strings.ToLower(parsed.Hostname())
|
|
}
|
|
ip := net.ParseIP(host)
|
|
if err != nil || parsed == nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" || parsed.Port() != "" && parsed.Port() != "443" || host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") || ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast()) || !strings.HasSuffix(strings.ToLower(parsed.Path), ".dll") {
|
|
return []string{field + " must be a public credential-free HTTPS DLL URL"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRuntimeDLLExtensionPlan(prefix string, plan domain.RuntimeDLLExtensionPlan) []string {
|
|
return validateRuntimeDLLExtensionProfile(prefix, domain.RuntimeDLLExtensionProfile{
|
|
Key: plan.Key,
|
|
Kind: "ue4ss-dll",
|
|
Activation: "server-start",
|
|
Version: plan.Version,
|
|
ReleaseState: "ready",
|
|
ReleaseURL: plan.ReleaseURL,
|
|
Checksum: plan.Checksum,
|
|
SizeBytes: plan.SizeBytes,
|
|
TargetKey: plan.TargetKey,
|
|
ModKey: plan.ModKey,
|
|
DLLRef: plan.DLLRef,
|
|
SCUMExecutableChecksum: plan.SCUMExecutableChecksum,
|
|
UE4SSABI: plan.UE4SSABI,
|
|
SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}},
|
|
UpdateOnStart: true,
|
|
RCONPort: plan.RCONPort,
|
|
})
|
|
}
|
|
|
|
func validateProfileKey(field, value string) []string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return []string{field + " is required"}
|
|
}
|
|
if !validDistributionLogicalKey(value) {
|
|
return []string{field + " is invalid"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRuntimePlatforms(field string, platforms []string) []string {
|
|
var violations []string
|
|
for i, platform := range platforms {
|
|
if !validPluginSupportedOS(platform) {
|
|
violations = append(violations, fmt.Sprintf("%s[%d] is invalid", field, i))
|
|
}
|
|
}
|
|
return append(violations, duplicateViolations(field, platforms)...)
|
|
}
|
|
|
|
func validateSafeRuntimeValue(field, value string) []string {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
lowered := strings.ToLower(value)
|
|
if strings.HasPrefix(value, "/") || strings.HasPrefix(value, `\`) || strings.Contains(value, "://") || containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(value, "..") || strings.ContainsAny(value, "\r\n") || strings.Contains(lowered, "bash -c") || strings.Contains(lowered, "powershell -") || strings.Contains(lowered, "cmd.exe") || strings.Contains(lowered, "curl |") {
|
|
return []string{field + " contains unsafe runtime content"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hasUnsafeRuntimeLogEventSemantics(eventType string) bool {
|
|
lowered := strings.ToLower(strings.TrimSpace(eventType))
|
|
tokens := strings.FieldsFunc(lowered, func(char rune) bool {
|
|
return char == '.' || char == '_' || char == '-' || char == '/'
|
|
})
|
|
unsafeTokens := map[string]struct{}{
|
|
"apikey": {}, "credential": {}, "credentials": {}, "eval": {}, "exec": {},
|
|
"execute": {}, "password": {}, "powershell": {}, "script": {}, "secret": {},
|
|
"shell": {}, "socket": {}, "terminal": {}, "token": {},
|
|
}
|
|
for _, token := range tokens {
|
|
if _, unsafe := unsafeTokens[token]; unsafe {
|
|
return true
|
|
}
|
|
}
|
|
for index := 0; index+1 < len(tokens); index++ {
|
|
pair := tokens[index] + "." + tokens[index+1]
|
|
switch pair {
|
|
case "absolute.path", "access.key", "api.key", "component.key", "database.query", "direct.socket", "file.path", "host.path", "private.key", "raw.path", "run.direct", "run.socket", "unix.socket":
|
|
return true
|
|
}
|
|
}
|
|
tokenSet := make(map[string]struct{}, len(tokens))
|
|
for _, token := range tokens {
|
|
tokenSet[token] = struct{}{}
|
|
}
|
|
if _, hasSQL := tokenSet["sql"]; hasSQL {
|
|
for _, token := range []string{"query", "statement", "raw"} {
|
|
if _, unsafe := tokenSet[token]; unsafe {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func recordRuntimeProfileKey(seen map[string]struct{}, field, key string) []string {
|
|
if key == "" {
|
|
return nil
|
|
}
|
|
if _, exists := seen[key]; exists {
|
|
return []string{field + " is duplicated"}
|
|
}
|
|
seen[key] = struct{}{}
|
|
return nil
|
|
}
|
|
|
|
func validateRuntimeProfileCapabilityDeclarations(profiles domain.GamePluginRuntimeProfiles, declared []string) []string {
|
|
declaredSet := map[string]struct{}{}
|
|
for _, capability := range declared {
|
|
declaredSet[capability] = struct{}{}
|
|
}
|
|
var violations []string
|
|
check := func(field string, capabilities []string) {
|
|
for i, capability := range capabilities {
|
|
if _, ok := declaredSet[capability]; !ok {
|
|
violations = append(violations, fmt.Sprintf("%s[%d] must also be declared in manifest capabilities", field, i))
|
|
}
|
|
}
|
|
}
|
|
for i, profile := range profiles.LifecycleProfiles {
|
|
check(fmt.Sprintf("runtimeProfiles.lifecycleProfiles[%d].capabilities", i), profile.Capabilities)
|
|
}
|
|
for i, transport := range profiles.TransportProfiles {
|
|
check(fmt.Sprintf("runtimeProfiles.transportProfiles[%d].capabilities", i), transport.Capabilities)
|
|
}
|
|
for i, manager := range profiles.ClientManagers {
|
|
check(fmt.Sprintf("runtimeProfiles.clientManagers[%d].deployment.requiredRunCapabilities", i), manager.Deployment.RequiredRunCapabilities)
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validateRuntimeLogEventPermissionDeclarations(field string, profiles domain.GamePluginRuntimeProfiles, declared []string) []string {
|
|
declaredSet := make(map[string]struct{}, len(declared))
|
|
for _, permission := range declared {
|
|
declaredSet[permission] = struct{}{}
|
|
}
|
|
var violations []string
|
|
for i, event := range profiles.LogEvents {
|
|
if _, exists := declaredSet[event.Permission]; !exists {
|
|
violations = append(violations, fmt.Sprintf("%s[%d].permission must be declared by the plugin", field, i))
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validateLifecycleActionsOptional(actions domain.PluginLifecycleActions) []string {
|
|
var violations []string
|
|
for field, value := range map[string]string{"install": actions.Install, "start": actions.Start, "stop": actions.Stop, "restart": actions.Restart, "status": actions.Status} {
|
|
if value != "" && !safeRelativeJSONRef(value) {
|
|
violations = append(violations, "runtime actionRefs."+field+" must be a safe relative JSON reference")
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func oneOf(value string, allowed ...string) bool {
|
|
for _, candidate := range allowed {
|
|
if value == candidate {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validateSafeRelativeRuntimePath(field, value string) []string {
|
|
if strings.TrimSpace(value) == "" || strings.HasPrefix(value, "/") || strings.HasPrefix(value, `\`) || strings.Contains(value, "..") || strings.Contains(value, "://") || strings.ContainsAny(value, "\r\n|;&`$<>") || len(value) >= 2 && value[1] == ':' || !regexp.MustCompile(`^[A-Za-z0-9_./-]{1,160}$`).MatchString(value) {
|
|
return []string{field + " must be a safe relative path"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validSemanticVersion(value string) bool {
|
|
_, ok := semanticVersionTuple(value)
|
|
return ok
|
|
}
|
|
|
|
func semanticVersionTuple(value string) ([3]int, bool) {
|
|
match := regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$`).FindStringSubmatch(value)
|
|
if match == nil {
|
|
return [3]int{}, false
|
|
}
|
|
var result [3]int
|
|
for i := 0; i < 3; i++ {
|
|
if _, err := fmt.Sscanf(match[i+1], "%d", &result[i]); err != nil {
|
|
return [3]int{}, false
|
|
}
|
|
}
|
|
return result, true
|
|
}
|
|
|
|
func compareSemanticVersion(left, right [3]int) int {
|
|
for i := 0; i < 3; i++ {
|
|
if left[i] < right[i] {
|
|
return -1
|
|
}
|
|
if left[i] > right[i] {
|
|
return 1
|
|
}
|
|
}
|
|
return 0
|
|
}
|