feat: 完整游戏运维功能
This commit is contained in:
@@ -9,7 +9,10 @@ import (
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
const MaxArtifactChunkBytes = 1024 * 1024
|
||||
const (
|
||||
MaxArtifactChunkBytes = 1024 * 1024
|
||||
MaxArtifactBytes = int64(512 * 1024 * 1024)
|
||||
)
|
||||
|
||||
func ValidateArtifactTransferOpen(open domain.ArtifactTransferOpen) error {
|
||||
var violations []string
|
||||
@@ -31,6 +34,9 @@ func ValidateArtifactTransferOpen(open domain.ArtifactTransferOpen) error {
|
||||
if open.SizeBytes <= 0 {
|
||||
violations = append(violations, "sizeBytes must be positive")
|
||||
}
|
||||
if open.SizeBytes > MaxArtifactBytes {
|
||||
violations = append(violations, fmt.Sprintf("sizeBytes must not exceed %d", MaxArtifactBytes))
|
||||
}
|
||||
if open.ChunkSizeBytes <= 0 {
|
||||
violations = append(violations, "chunkSizeBytes must be positive")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func ValidateAuthSessionRecord(session domain.AuthSessionRecord) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "id", session.ID)
|
||||
violations = appendRequired(violations, "userId", session.UserID)
|
||||
violations = appendRequired(violations, "tokenHash", session.TokenHash)
|
||||
if len(strings.TrimSpace(session.TokenHash)) != 64 {
|
||||
violations = append(violations, "tokenHash must be a SHA-256 verifier")
|
||||
}
|
||||
if session.Status != domain.AuthSessionStatusActive && session.Status != domain.AuthSessionStatusRevoked {
|
||||
violations = append(violations, "status is invalid")
|
||||
}
|
||||
if session.Generation <= 0 {
|
||||
violations = append(violations, "generation must be positive")
|
||||
}
|
||||
if session.IssuedAt.IsZero() || session.ExpiresAt.IsZero() || !session.ExpiresAt.After(session.IssuedAt) {
|
||||
violations = append(violations, "session expiry must be after issue time")
|
||||
}
|
||||
if session.Status == domain.AuthSessionStatusRevoked && session.RevokedAt.IsZero() {
|
||||
violations = append(violations, "revokedAt is required for revoked sessions")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateRunControlSession(session domain.RunControlSession) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "runEndpointId", session.RunEndpointID)
|
||||
violations = appendRequired(violations, "sessionTokenHash", session.SessionTokenHash)
|
||||
if len(strings.TrimSpace(session.SessionTokenHash)) != 64 {
|
||||
violations = append(violations, "sessionTokenHash must be a SHA-256 verifier")
|
||||
}
|
||||
if session.Status != domain.AuthSessionStatusActive && session.Status != domain.AuthSessionStatusRevoked {
|
||||
violations = append(violations, "status is invalid")
|
||||
}
|
||||
if session.Generation <= 0 {
|
||||
violations = append(violations, "generation must be positive")
|
||||
}
|
||||
if session.CreatedAt.IsZero() || session.ExpiresAt.IsZero() || !session.ExpiresAt.After(session.CreatedAt) {
|
||||
violations = append(violations, "session expiry must be after creation time")
|
||||
}
|
||||
if session.Status == domain.AuthSessionStatusRevoked && session.RevokedAt.IsZero() {
|
||||
violations = append(violations, "revokedAt is required for revoked sessions")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
var clientManagerIdentifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$`)
|
||||
|
||||
func ValidateClientManagerInstallation(value domain.ClientManagerInstallation) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "id", value.ID)
|
||||
violations = appendRequired(violations, "serverInstanceId", value.ServerInstanceID)
|
||||
violations = appendRequired(violations, "pluginId", value.PluginID)
|
||||
violations = appendRequired(violations, "profileKey", value.ProfileKey)
|
||||
violations = appendRequired(violations, "runEndpointId", value.RunEndpointID)
|
||||
if !validDistributionLogicalKey(value.ProfileKey) {
|
||||
violations = append(violations, "profileKey is invalid")
|
||||
}
|
||||
if value.TargetOS != "" || value.TargetArch != "" {
|
||||
violations = appendDistributionTargetViolations(violations, value.TargetOS, value.TargetArch)
|
||||
}
|
||||
if !validClientManagerLifecycleStatus(value.Status) {
|
||||
violations = append(violations, "status is invalid")
|
||||
}
|
||||
if !validClientManagerHealth(value.Health) {
|
||||
violations = append(violations, "health is invalid")
|
||||
}
|
||||
if value.KeyGeneration < 0 || value.DeploymentGeneration < 0 {
|
||||
violations = append(violations, "keyGeneration and deploymentGeneration must not be negative")
|
||||
}
|
||||
if value.Checksum != "" && !validSHA256Checksum(value.Checksum) {
|
||||
violations = append(violations, "checksum must be sha256:<hex>")
|
||||
}
|
||||
for field, content := range map[string]string{"phase": value.Phase, "healthReason": value.HealthReason} {
|
||||
if len(content) > 240 || unsafeLifecycleText(content) {
|
||||
violations = append(violations, field+" must be bounded and redacted")
|
||||
}
|
||||
}
|
||||
if value.CreatedAt.IsZero() || value.UpdatedAt.IsZero() {
|
||||
violations = append(violations, "createdAt and updatedAt are required")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateClientManagerLifecycleTransition(from, to domain.ClientManagerLifecycleStatus) error {
|
||||
if from == to {
|
||||
return nil
|
||||
}
|
||||
allowed := map[domain.ClientManagerLifecycleStatus][]domain.ClientManagerLifecycleStatus{
|
||||
domain.ClientManagerLifecycleRequested: {domain.ClientManagerLifecycleBuilding, domain.ClientManagerLifecycleAvailable, domain.ClientManagerLifecycleDeploying, domain.ClientManagerLifecycleFailed},
|
||||
domain.ClientManagerLifecycleBuilding: {domain.ClientManagerLifecycleAvailable, domain.ClientManagerLifecycleFailed},
|
||||
domain.ClientManagerLifecycleAvailable: {domain.ClientManagerLifecycleDeploying, domain.ClientManagerLifecycleFailed},
|
||||
domain.ClientManagerLifecycleDeploying: {domain.ClientManagerLifecycleInstalled, domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleFailed, domain.ClientManagerLifecycleUninstalled},
|
||||
domain.ClientManagerLifecycleInstalled: {domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleUninstalled, domain.ClientManagerLifecycleFailed},
|
||||
domain.ClientManagerLifecycleRegistering: {domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleDegraded, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleFailed},
|
||||
domain.ClientManagerLifecycleOnline: {domain.ClientManagerLifecycleDegraded, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleFailed},
|
||||
domain.ClientManagerLifecycleDegraded: {domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleFailed},
|
||||
domain.ClientManagerLifecycleOffline: {domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleUninstalled, domain.ClientManagerLifecycleFailed},
|
||||
domain.ClientManagerLifecycleUpdating: {domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleDegraded, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleFailed},
|
||||
domain.ClientManagerLifecycleRollingBack: {domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleOnline, domain.ClientManagerLifecycleDegraded, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleFailed},
|
||||
domain.ClientManagerLifecycleStopping: {domain.ClientManagerLifecycleInstalled, domain.ClientManagerLifecycleOffline, domain.ClientManagerLifecycleUninstalled, domain.ClientManagerLifecycleFailed},
|
||||
domain.ClientManagerLifecycleUninstalled: {domain.ClientManagerLifecycleDeploying, domain.ClientManagerLifecycleFailed},
|
||||
domain.ClientManagerLifecycleFailed: {domain.ClientManagerLifecycleDeploying, domain.ClientManagerLifecycleRegistering, domain.ClientManagerLifecycleUpdating, domain.ClientManagerLifecycleRollingBack, domain.ClientManagerLifecycleStopping, domain.ClientManagerLifecycleUninstalled},
|
||||
}
|
||||
for _, candidate := range allowed[from] {
|
||||
if candidate == to {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return finish([]string{"client-manager lifecycle transition is invalid"})
|
||||
}
|
||||
|
||||
func ValidateClientManagerSession(value domain.ClientManagerSession) error {
|
||||
var violations []string
|
||||
for field, content := range map[string]string{"id": value.ID, "installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, "profileKey": value.ProfileKey, "runEndpointId": value.RunEndpointID, "artifactId": value.ArtifactID, "tokenHash": value.TokenHash} {
|
||||
violations = appendRequired(violations, field, content)
|
||||
}
|
||||
if value.KeyGeneration <= 0 || value.DeploymentGeneration <= 0 {
|
||||
violations = append(violations, "keyGeneration and deploymentGeneration must be positive")
|
||||
}
|
||||
if len(value.TokenHash) != 64 || !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(value.TokenHash) {
|
||||
violations = append(violations, "tokenHash must be a SHA-256 digest")
|
||||
}
|
||||
if !oneOf(string(value.Status), string(domain.ClientManagerSessionActive), string(domain.ClientManagerSessionRevoked), string(domain.ClientManagerSessionExpired)) {
|
||||
violations = append(violations, "status is invalid")
|
||||
}
|
||||
if value.ExpiresAt.IsZero() || value.CreatedAt.IsZero() || value.UpdatedAt.IsZero() {
|
||||
violations = append(violations, "session timestamps are required")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateClientManagerNonce(value domain.ClientManagerRegistrationNonce) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "id", value.ID)
|
||||
violations = appendRequired(violations, "installationId", value.InstallationID)
|
||||
if len(value.ID) != 64 || !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(value.ID) {
|
||||
violations = append(violations, "id must be a nonce SHA-256 digest")
|
||||
}
|
||||
if value.ExpiresAt.IsZero() || value.CreatedAt.IsZero() || !value.ExpiresAt.After(value.CreatedAt) {
|
||||
violations = append(violations, "nonce expiry must follow creation")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateClientManagerDeployRequest(value domain.ClientManagerDeployRequest) error {
|
||||
return validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, value.DistributionID, value.IdempotencyKey, value.ExpectedDeploymentGeneration, true)
|
||||
}
|
||||
|
||||
func ValidateClientManagerControlRequest(value domain.ClientManagerControlRequest) error {
|
||||
if !oneOf(string(value.Operation), "start", "stop", "restart", "status", "rollback") {
|
||||
return finish([]string{"operation is invalid"})
|
||||
}
|
||||
return validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, "", value.IdempotencyKey, value.ExpectedDeploymentGeneration, false)
|
||||
}
|
||||
|
||||
func ValidateClientManagerUpdateRequest(value domain.ClientManagerUpdateRequest) error {
|
||||
var violations []string
|
||||
if !value.Approved {
|
||||
violations = append(violations, "approved must be true")
|
||||
}
|
||||
if err := validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, value.DistributionID, value.IdempotencyKey, value.ExpectedDeploymentGeneration, true); err != nil {
|
||||
violations = append(violations, err.Error())
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateClientManagerUninstallRequest(value domain.ClientManagerUninstallRequest) error {
|
||||
var violations []string
|
||||
if !value.Confirmed {
|
||||
violations = append(violations, "confirmed must be true")
|
||||
}
|
||||
if err := validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, "", value.IdempotencyKey, value.ExpectedDeploymentGeneration, false); err != nil {
|
||||
violations = append(violations, err.Error())
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateClientManagerRetryRequest(value domain.ClientManagerRetryRequest) error {
|
||||
return validateClientManagerOperationRequest(value.ServerInstanceID, value.ProfileKey, "", value.IdempotencyKey, value.ExpectedDeploymentGeneration, false)
|
||||
}
|
||||
|
||||
func ValidateClientManagerLifecycleInputRequest(value domain.ClientManagerLifecycleInputRequest) error {
|
||||
var violations []string
|
||||
for field, content := range map[string]string{"runEndpointId": value.RunEndpointID, "sessionToken": value.SessionToken, "jobId": value.JobID, "leaseToken": value.LeaseToken} {
|
||||
violations = appendRequired(violations, field, content)
|
||||
}
|
||||
if value.Attempt <= 0 {
|
||||
violations = append(violations, "attempt must be positive")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateClientManagerRegisterRequest(value domain.ClientManagerRegisterRequest) error {
|
||||
var violations []string
|
||||
for field, content := range map[string]string{"installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID, "version": value.Version, "sourceRevision": value.SourceRevision, "targetOs": value.TargetOS, "targetArch": value.TargetArch, "nonce": value.Nonce, "signature": value.Signature} {
|
||||
violations = appendRequired(violations, field, content)
|
||||
}
|
||||
violations = appendDistributionTargetViolations(violations, value.TargetOS, value.TargetArch)
|
||||
if value.KeyGeneration <= 0 || value.DeploymentGeneration <= 0 {
|
||||
violations = append(violations, "keyGeneration and deploymentGeneration must be positive")
|
||||
}
|
||||
if value.Timestamp.IsZero() {
|
||||
violations = append(violations, "timestamp is required")
|
||||
}
|
||||
if !regexp.MustCompile(`^[A-Za-z0-9_-]{16,128}$`).MatchString(value.Nonce) {
|
||||
violations = append(violations, "nonce is invalid")
|
||||
}
|
||||
if !regexp.MustCompile(`^sha256:[a-f0-9]{64}$`).MatchString(value.Signature) {
|
||||
violations = append(violations, "signature is invalid")
|
||||
}
|
||||
if len(value.Capabilities) == 0 {
|
||||
violations = append(violations, "capabilities must not be empty")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateClientManagerHeartbeat(value domain.ClientManagerHeartbeat) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "installationId", value.InstallationID)
|
||||
violations = appendRequired(violations, "sessionToken", value.SessionToken)
|
||||
if value.Sequence == 0 {
|
||||
violations = append(violations, "sequence must be positive")
|
||||
}
|
||||
if !validClientManagerHealth(value.Health) || value.Health == domain.ClientManagerHealthUnknown {
|
||||
violations = append(violations, "health is invalid")
|
||||
}
|
||||
if len(value.HealthReason) > 240 || unsafeLifecycleText(value.HealthReason) {
|
||||
violations = append(violations, "healthReason must be bounded and redacted")
|
||||
}
|
||||
if value.SentAt.IsZero() {
|
||||
violations = append(violations, "sentAt is required")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func validateClientManagerOperationRequest(serverID, profileKey, distributionID, idempotencyKey string, generation int, distributionRequired bool) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "serverInstanceId", serverID)
|
||||
violations = appendRequired(violations, "profileKey", profileKey)
|
||||
violations = appendRequired(violations, "idempotencyKey", idempotencyKey)
|
||||
if distributionRequired {
|
||||
violations = appendRequired(violations, "distributionId", distributionID)
|
||||
}
|
||||
if !validDistributionLogicalKey(profileKey) {
|
||||
violations = append(violations, "profileKey is invalid")
|
||||
}
|
||||
if !clientManagerIdentifierPattern.MatchString(idempotencyKey) {
|
||||
violations = append(violations, "idempotencyKey is invalid")
|
||||
}
|
||||
if generation < 0 {
|
||||
violations = append(violations, "expectedDeploymentGeneration must not be negative")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func validClientManagerLifecycleStatus(value domain.ClientManagerLifecycleStatus) bool {
|
||||
return oneOf(string(value), "requested", "building", "available", "deploying", "installed", "registering", "online", "degraded", "offline", "updating", "rolling_back", "stopping", "uninstalled", "failed")
|
||||
}
|
||||
|
||||
func validClientManagerHealth(value domain.ClientManagerHealthStatus) bool {
|
||||
return oneOf(string(value), "unknown", "healthy", "degraded", "unhealthy", "offline")
|
||||
}
|
||||
|
||||
func unsafeLifecycleText(value string) bool {
|
||||
lowered := strings.ToLower(strings.TrimSpace(value))
|
||||
return strings.Contains(lowered, "secret://") || strings.Contains(lowered, "bearer ") || strings.Contains(lowered, "password=") || strings.Contains(lowered, "token=") || strings.Contains(lowered, "unix://") || strings.Contains(lowered, "tcp://") || looksLikeRawHostPath(value)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestValidateClientManagerLifecycleContractsAndTransitions(t *testing.T) {
|
||||
profile := domain.RuntimeClientManagerProfile{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.2.3", RepositoryURL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "pinned", Revision: "0123456789abcdef", SupportedTargets: []domain.RuntimeTarget{{OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"client-manager"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "client-manager", Arguments: []string{"--config", "config.json"}, RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0", MaximumVersion: "2.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}
|
||||
if err := ValidateGamePluginRuntimeProfiles(domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{profile}}); err != nil {
|
||||
t.Fatalf("validate safe lifecycle profile: %v", err)
|
||||
}
|
||||
unsafe := profile
|
||||
unsafe.Deployment.ExecutableRef = "/Users/operator/client-manager"
|
||||
unsafe.Deployment.Arguments = []string{"bash -c", "curl | bash"}
|
||||
unsafe.Health.OfflineAfterSeconds = 30
|
||||
unsafe.Compatibility.MinimumVersion = "3.0.0"
|
||||
err := ValidateGamePluginRuntimeProfiles(domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{unsafe}})
|
||||
if err == nil || !strings.Contains(err.Error(), "safe relative path") || !strings.Contains(err.Error(), "health") || !strings.Contains(err.Error(), "compatibility") {
|
||||
t.Fatalf("expected unsafe lifecycle rejection, got %v", err)
|
||||
}
|
||||
if err := ValidateClientManagerLifecycleTransition(domain.ClientManagerLifecycleAvailable, domain.ClientManagerLifecycleDeploying); err != nil {
|
||||
t.Fatalf("valid lifecycle transition rejected: %v", err)
|
||||
}
|
||||
if err := ValidateClientManagerLifecycleTransition(domain.ClientManagerLifecycleAvailable, domain.ClientManagerLifecycleOnline); err == nil {
|
||||
t.Fatal("expected evidence-skipping lifecycle transition rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateClientManagerSessionHeartbeatAndRedaction(t *testing.T) {
|
||||
stamp := time.Date(2026, 7, 18, 4, 0, 0, 0, time.UTC)
|
||||
installation := domain.ClientManagerInstallation{ID: "cm-install-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client-manager", RunEndpointID: "run-1", TargetOS: "linux", TargetArch: "amd64", Status: domain.ClientManagerLifecycleOnline, Phase: "healthy", KeyGeneration: 1, DeploymentGeneration: 1, Health: domain.ClientManagerHealthHealthy, HealthReason: "ready", CreatedAt: stamp, UpdatedAt: stamp}
|
||||
if err := ValidateClientManagerInstallation(installation); err != nil {
|
||||
t.Fatalf("validate installation: %v", err)
|
||||
}
|
||||
installation.HealthReason = "Bearer stolen-session"
|
||||
if err := ValidateClientManagerInstallation(installation); err == nil {
|
||||
t.Fatal("expected session-bearing health reason rejection")
|
||||
}
|
||||
session := domain.ClientManagerSession{ID: "cm-session-1", InstallationID: "cm-install-1", ServerInstanceID: "server-1", ProfileKey: "scum-client-manager", RunEndpointID: "run-1", ArtifactID: "artifact-1", KeyGeneration: 1, DeploymentGeneration: 1, TokenHash: strings.Repeat("a", 64), Capabilities: []string{"component.heartbeat"}, Status: domain.ClientManagerSessionActive, ExpiresAt: stamp.Add(time.Minute), CreatedAt: stamp, UpdatedAt: stamp}
|
||||
if err := ValidateClientManagerSession(session); err != nil {
|
||||
t.Fatalf("validate hashed session: %v", err)
|
||||
}
|
||||
if err := ValidateClientManagerHeartbeat(domain.ClientManagerHeartbeat{InstallationID: "cm-install-1", SessionToken: "component-session", Sequence: 1, Health: domain.ClientManagerHealthHealthy, HealthReason: "password=leak", Capabilities: []string{"component.heartbeat"}, SentAt: stamp}); err == nil {
|
||||
t.Fatal("expected unsafe heartbeat reason rejection")
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,20 @@ func ValidateRunControlHello(hello domain.RunControlHello) error {
|
||||
violations = appendRequired(violations, "runEndpointId", hello.RunEndpointID)
|
||||
violations = appendRequired(violations, "displayName", hello.DisplayName)
|
||||
violations = appendRequired(violations, "version", hello.Version)
|
||||
if hello.Architecture != "" {
|
||||
if !validDistributionTargetOS(hello.Platform) {
|
||||
violations = append(violations, "platform is invalid")
|
||||
}
|
||||
if !validDistributionTargetArch(hello.Architecture) {
|
||||
violations = append(violations, "architecture is invalid")
|
||||
}
|
||||
}
|
||||
if (hello.UpdateJobID == "") != (hello.UpdateOutcome == "") {
|
||||
violations = append(violations, "updateJobId and updateOutcome must be provided together")
|
||||
}
|
||||
if hello.UpdateOutcome != "" && hello.UpdateOutcome != "succeeded" && hello.UpdateOutcome != "rolled-back" {
|
||||
violations = append(violations, "updateOutcome is invalid")
|
||||
}
|
||||
violations = appendRequired(violations, "capabilityReport.fingerprint", hello.CapabilityReport.Fingerprint)
|
||||
if hello.ServerInstanceID != "" || hello.PluginID != "" || hello.ComponentKind != "" || hello.ComponentKey != "" || hello.KeyGeneration != 0 {
|
||||
violations = appendRequired(violations, "serverInstanceId", hello.ServerInstanceID)
|
||||
|
||||
@@ -2,6 +2,7 @@ package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
@@ -14,6 +15,7 @@ func ValidateRuntimeBinding(binding domain.RuntimeBinding) error {
|
||||
violations = appendRequired(violations, "id", binding.ID)
|
||||
violations = appendRequired(violations, "serverInstanceId", binding.ServerInstanceID)
|
||||
violations = appendRequired(violations, "pluginId", binding.PluginID)
|
||||
violations = appendRequired(violations, "pluginVersion", binding.PluginVersion)
|
||||
violations = appendRequired(violations, "profileKey", binding.ProfileKey)
|
||||
violations = appendRequired(violations, "mode", binding.Mode)
|
||||
if !validRuntimeBindingStatus(binding.Status) {
|
||||
@@ -23,7 +25,10 @@ func ValidateRuntimeBinding(binding domain.RuntimeBinding) error {
|
||||
if !validDistributionLogicalKey(key) {
|
||||
violations = append(violations, "bindings key is invalid")
|
||||
}
|
||||
if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(strings.ToLower(value), "://") && !strings.HasPrefix(value, "secret://") {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
lowerKey := strings.ToLower(key)
|
||||
sensitiveKey := strings.Contains(lowerKey, "password") || strings.Contains(lowerKey, "credential") || strings.Contains(lowerKey, "secret") || strings.Contains(lowerKey, "token") || strings.Contains(lowerKey, "dsn")
|
||||
if trimmed != value || strings.HasPrefix(value, "/") || strings.HasPrefix(value, `\`) || containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(strings.ToLower(value), "://") && !strings.HasPrefix(value, "secret://") || sensitiveKey && value != "" && !strings.HasPrefix(value, "secret://") {
|
||||
violations = append(violations, "bindings."+key+" must use safe logical or secret refs")
|
||||
}
|
||||
}
|
||||
@@ -176,6 +181,18 @@ func ValidateDependencyStatus(status domain.DependencyStatus) error {
|
||||
if status.InstallPlanKey != "" && !validDistributionLogicalKey(status.InstallPlanKey) {
|
||||
violations = append(violations, "installPlanKey is invalid")
|
||||
}
|
||||
if status.PlanDigest != "" && !validSHA256Checksum(status.PlanDigest) {
|
||||
violations = append(violations, "planDigest must be sha256:<hex>")
|
||||
}
|
||||
if len(status.JobID) > 180 || containsUnsafeRuntimeSecret(status.JobID) || looksLikeRawHostPath(status.JobID) {
|
||||
violations = append(violations, "jobId is unsafe or too long")
|
||||
}
|
||||
if status.CompletedSteps < 0 || status.CompletedSteps > 64 {
|
||||
violations = append(violations, "completedSteps is out of bounds")
|
||||
}
|
||||
if len(status.Evidence) > maxDistributionMessageLength || containsUnsafeRuntimeSecret(status.Evidence) || looksLikeRawHostPath(status.Evidence) {
|
||||
violations = append(violations, "evidence is unsafe or too long")
|
||||
}
|
||||
if len(status.Message) > maxDistributionMessageLength || containsUnsafeRuntimeSecret(status.Message) || looksLikeRawHostPath(status.Message) {
|
||||
violations = append(violations, "message is unsafe or too long")
|
||||
}
|
||||
@@ -229,6 +246,9 @@ func ValidateRunUpdateJob(job domain.RunUpdateJob) error {
|
||||
violations = appendRequired(violations, "runEndpointId", job.RunEndpointID)
|
||||
violations = appendRequired(violations, "artifactId", job.ArtifactID)
|
||||
violations = appendRequired(violations, "checksum", job.Checksum)
|
||||
violations = appendRequired(violations, "targetOs", job.TargetOS)
|
||||
violations = appendRequired(violations, "targetArch", job.TargetArch)
|
||||
violations = appendRequired(violations, "targetRelease", job.TargetRelease)
|
||||
violations = appendRequired(violations, "idempotencyKey", job.IdempotencyKey)
|
||||
if job.Checksum != "" && !validSHA256Checksum(job.Checksum) {
|
||||
violations = append(violations, "checksum must be sha256:<hex>")
|
||||
@@ -236,6 +256,13 @@ func ValidateRunUpdateJob(job domain.RunUpdateJob) error {
|
||||
if !validDistributionJobStatus(job.Status) {
|
||||
violations = append(violations, "status is invalid")
|
||||
}
|
||||
violations = appendDistributionTargetViolations(violations, job.TargetOS, job.TargetArch)
|
||||
if !validRunUpdatePhase(job.Phase) {
|
||||
violations = append(violations, "phase is invalid")
|
||||
}
|
||||
if len(job.Message) > maxDistributionMessageLength || containsUnsafeRuntimeSecret(job.Message) || looksLikeRawHostPath(job.Message) {
|
||||
violations = append(violations, "message is unsafe or too long")
|
||||
}
|
||||
if containsUnsafeRuntimeSecret(job.IdempotencyKey) || looksLikeRawHostPath(job.IdempotencyKey) {
|
||||
violations = append(violations, "idempotencyKey is unsafe")
|
||||
}
|
||||
@@ -248,6 +275,15 @@ func ValidateRunUpdateJob(job domain.RunUpdateJob) error {
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func validRunUpdatePhase(phase domain.RunUpdatePhase) bool {
|
||||
switch phase {
|
||||
case domain.RunUpdatePhaseQueued, domain.RunUpdatePhaseDownloading, domain.RunUpdatePhaseStaged, domain.RunUpdatePhaseRestartRequested, domain.RunUpdatePhaseActivating, domain.RunUpdatePhaseSucceeded, domain.RunUpdatePhaseRolledBack, domain.RunUpdatePhaseFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateRunDistributionGenerateRequest(request domain.RunDistributionGenerateRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
||||
@@ -341,8 +377,8 @@ func validateRepositoryURL(field string, value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
lowered := strings.ToLower(strings.TrimSpace(value))
|
||||
if !strings.HasPrefix(lowered, "https://") || !strings.HasSuffix(lowered, ".git") {
|
||||
parsed, err := url.ParseRequestURI(strings.TrimSpace(value))
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || !strings.HasSuffix(parsed.Path, ".git") {
|
||||
return []string{field + " must be an HTTPS git repository URL"}
|
||||
}
|
||||
for _, reason := range unsafePluginStringReasons(value) {
|
||||
|
||||
@@ -45,6 +45,12 @@ func ValidateRunJobResult(result domain.RunJobResult) error {
|
||||
violations = appendProgressViolations(violations, result.Progress)
|
||||
violations = appendMessageLength(violations, "message", result.Message)
|
||||
violations = appendMessageLength(violations, "errorCode", result.ErrorCode)
|
||||
if len([]byte(result.ExecutionResult.Content)) > maxJobChannelMessageLength*256 {
|
||||
violations = append(violations, "executionResult.content is too large")
|
||||
}
|
||||
if result.ExecutionResult.Checksum != "" && !validSHA256Checksum(result.ExecutionResult.Checksum) {
|
||||
violations = append(violations, "executionResult.checksum must be sha256:<hex>")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
@@ -54,6 +60,35 @@ func ValidateDistributionBuildInputRequest(request domain.DistributionBuildInput
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateDependencyExecutionInputRequest(request domain.DependencyExecutionInputRequest) error {
|
||||
return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt))
|
||||
}
|
||||
|
||||
func ValidateRunUpdateInputRequest(request domain.RunUpdateInputRequest) error {
|
||||
return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt))
|
||||
}
|
||||
|
||||
func ValidateRunUpdateChunkRequest(request domain.RunUpdateChunkRequest) error {
|
||||
violations := appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
|
||||
if request.Offset < 0 {
|
||||
violations = append(violations, "offset must not be negative")
|
||||
}
|
||||
if request.Length <= 0 || request.Length > 1024*1024 {
|
||||
violations = append(violations, "length must be between 1 and 1048576")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateRunUpdateHealthReport(report domain.RunUpdateHealthReport) error {
|
||||
violations := appendLeaseFields(nil, report.RunEndpointID, report.SessionToken, report.JobID, report.LeaseToken, report.Attempt)
|
||||
if report.Outcome != "succeeded" && report.Outcome != "rolled-back" {
|
||||
violations = append(violations, "outcome must be succeeded or rolled-back")
|
||||
}
|
||||
violations = appendRequired(violations, "version", report.Version)
|
||||
violations = appendMessageLength(violations, "version", report.Version)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateRunJobCancelRequest(request domain.RunJobCancelRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "jobId", request.JobID)
|
||||
@@ -64,11 +99,7 @@ func ValidateRunJobCancelRequest(request domain.RunJobCancelRequest) error {
|
||||
|
||||
func ValidateRunJobCancelPoll(poll domain.RunJobCancelPoll) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "runEndpointId", poll.RunEndpointID)
|
||||
violations = appendRequired(violations, "sessionToken", poll.SessionToken)
|
||||
if poll.LeaseToken != "" && strings.TrimSpace(poll.JobID) == "" {
|
||||
violations = append(violations, "jobId is required when leaseToken is provided")
|
||||
}
|
||||
violations = appendLeaseFields(violations, poll.RunEndpointID, poll.SessionToken, poll.JobID, poll.LeaseToken, poll.Attempt)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
@@ -77,16 +108,17 @@ func ValidateRunJobReconcile(reconcile domain.RunJobReconcile) error {
|
||||
violations = appendRequired(violations, "runEndpointId", reconcile.RunEndpointID)
|
||||
violations = appendRequired(violations, "sessionToken", reconcile.SessionToken)
|
||||
seen := map[string]struct{}{}
|
||||
for i, jobID := range reconcile.ActiveJobIDs {
|
||||
jobID = strings.TrimSpace(jobID)
|
||||
if jobID == "" {
|
||||
violations = append(violations, fmt.Sprintf("activeJobIds[%d] is required", i))
|
||||
continue
|
||||
for i, entry := range reconcile.ActiveJobs {
|
||||
prefix := fmt.Sprintf("activeJobs[%d]", i)
|
||||
violations = appendRequired(violations, prefix+".jobId", entry.JobID)
|
||||
violations = appendRequired(violations, prefix+".leaseToken", entry.LeaseToken)
|
||||
if entry.Attempt <= 0 {
|
||||
violations = append(violations, prefix+".attempt must be positive")
|
||||
}
|
||||
if _, exists := seen[jobID]; exists {
|
||||
violations = append(violations, fmt.Sprintf("activeJobIds[%d] duplicates %q", i, jobID))
|
||||
if _, exists := seen[entry.JobID]; exists {
|
||||
violations = append(violations, fmt.Sprintf("%s duplicates %q", prefix, entry.JobID))
|
||||
}
|
||||
seen[jobID] = struct{}{}
|
||||
seen[entry.JobID] = struct{}{}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxMetricSamplesPerQuery = 500
|
||||
MaxBackupRecordsPerQuery = 200
|
||||
)
|
||||
|
||||
func ValidateMetricSample(sample domain.MetricSample) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "id", sample.ID)
|
||||
violations = appendRequired(violations, "serverInstanceId", sample.ServerInstanceID)
|
||||
violations = appendRequired(violations, "source", sample.Source)
|
||||
if sample.CollectedAt.IsZero() {
|
||||
violations = append(violations, "collectedAt is required")
|
||||
}
|
||||
for name, value := range map[string]*float64{"tps": sample.TPS, "latencyMs": sample.LatencyMS, "cpuPercent": sample.CPUPercent, "memoryPercent": sample.MemoryPercent, "diskPercent": sample.DiskPercent} {
|
||||
if value != nil && (value == nil || *value < 0) {
|
||||
violations = append(violations, fmt.Sprintf("%s must not be negative", name))
|
||||
}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateMetricSampleFilter(filter domain.MetricSampleFilter) error {
|
||||
var violations []string
|
||||
if filter.Limit < 0 || filter.Limit > MaxMetricSamplesPerQuery {
|
||||
violations = append(violations, fmt.Sprintf("limit must be between 0 and %d", MaxMetricSamplesPerQuery))
|
||||
}
|
||||
if filter.Before.Before(filter.After) {
|
||||
violations = append(violations, "before must not precede after")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateBackupRecord(record domain.BackupRecord) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "id", record.ID)
|
||||
violations = appendRequired(violations, "serverInstanceId", record.ServerInstanceID)
|
||||
violations = appendRequired(violations, "artifactId", record.ArtifactID)
|
||||
violations = appendRequired(violations, "checksum", record.Checksum)
|
||||
if record.SizeBytes <= 0 {
|
||||
violations = append(violations, "sizeBytes must be positive")
|
||||
}
|
||||
if record.State != domain.BackupStatePending && record.State != domain.BackupStateAvailable && record.State != domain.BackupStateFailed && record.State != domain.BackupStateExpired {
|
||||
violations = append(violations, "state is invalid")
|
||||
}
|
||||
if len(record.RecoveryStatus) > 256 {
|
||||
violations = append(violations, "recoveryStatus is too long")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateBackupFilter(filter domain.BackupFilter) error {
|
||||
if filter.State != "" && filter.State != domain.BackupStatePending && filter.State != domain.BackupStateAvailable && filter.State != domain.BackupStateFailed && filter.State != domain.BackupStateExpired {
|
||||
return ValidationError{Violations: []string{"state is invalid"}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateRemoteAdapterDeclaration(declaration domain.RemoteAdapterDeclaration) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "key", declaration.Key)
|
||||
violations = appendRequired(violations, "kind", string(declaration.Kind))
|
||||
if len(declaration.TargetKeys) == 0 {
|
||||
violations = append(violations, "targetKeys must not be empty")
|
||||
}
|
||||
if declaration.TimeoutSeconds <= 0 || declaration.TimeoutSeconds > 300 {
|
||||
violations = append(violations, "timeoutSeconds must be between 1 and 300")
|
||||
}
|
||||
if declaration.MaxAttempts <= 0 || declaration.MaxAttempts > 5 {
|
||||
violations = append(violations, "maxAttempts must be between 1 and 5")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateRemoteAdapterRequest(request domain.RemoteAdapterRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
||||
violations = appendRequired(violations, "declarationKey", request.DeclarationKey)
|
||||
violations = appendRequired(violations, "targetKey", request.TargetKey)
|
||||
violations = appendRequired(violations, "capability", request.Capability)
|
||||
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
||||
if request.TimeoutSeconds < 0 || request.TimeoutSeconds > 300 {
|
||||
violations = append(violations, "timeoutSeconds must be between 0 and 300")
|
||||
}
|
||||
if request.MaxAttempts < 0 || request.MaxAttempts > 5 {
|
||||
violations = append(violations, "maxAttempts must be between 0 and 5")
|
||||
}
|
||||
if strings.ContainsAny(request.TargetKey, "\\\n\r") || strings.Contains(request.TargetKey, "://") || strings.ContainsAny(request.TargetKey, " ") {
|
||||
violations = append(violations, "targetKey must be a logical key")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func TestObservabilityValidatorsBoundMetricsBackupsAndRemoteTargets(t *testing.T) {
|
||||
if err := ValidateMetricSample(domain.MetricSample{ID: "metric-1", ServerInstanceID: "server-1", Source: "run", CollectedAt: time.Now(), CPUPercent: floatPtr(-1)}); err == nil {
|
||||
t.Fatal("expected negative metric rejection")
|
||||
}
|
||||
if err := ValidateBackupRecord(domain.BackupRecord{ID: "backup-1", ServerInstanceID: "server-1", ArtifactID: "artifact-1", SizeBytes: 1, Checksum: "sha256:" + strings.Repeat("0", 64), State: domain.BackupStateAvailable, RecoveryStatus: strings.Repeat("x", 257)}); err == nil {
|
||||
t.Fatal("expected oversized recovery status rejection")
|
||||
}
|
||||
if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "ftp", TargetKey: "tcp://host", Capability: "remote.ftp.read", IdempotencyKey: "request-1"}); err == nil {
|
||||
t.Fatal("expected unsafe remote target rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func floatPtr(value float64) *float64 { return &value }
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
maxPluginBridgePayloadSize = 4096
|
||||
maxProgressMessageLength = 256
|
||||
maxServerConfigContentSize = 64 * 1024
|
||||
maxJobExecutionContentSize = 64 * 1024
|
||||
maxLogicalFileKeyLength = 160
|
||||
)
|
||||
|
||||
@@ -146,6 +147,10 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error {
|
||||
violations = append(violations, duplicateViolations("tags", plugin.Tags)...)
|
||||
violations = append(violations, validateAIPurposes(plugin.AIPurposes)...)
|
||||
violations = append(violations, validateRemoteAccess("remoteAccess", plugin.RemoteAccess, plugin.RequiredRunCapabilities)...)
|
||||
if err := ValidateGamePluginRuntimeProfiles(plugin.RuntimeProfiles); err != nil {
|
||||
violations = append(violations, err.Error())
|
||||
}
|
||||
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...)
|
||||
violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...)
|
||||
return finish(violations)
|
||||
}
|
||||
@@ -198,6 +203,10 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
|
||||
violations = append(violations, duplicateViolations("manifest.tags", manifest.Tags)...)
|
||||
violations = append(violations, validateAIPurposes(manifest.AI.Purposes)...)
|
||||
violations = append(violations, validateRemoteAccess("manifest.remoteAccess", manifest.RemoteAccess, manifest.Capabilities)...)
|
||||
if err := ValidateGamePluginRuntimeProfiles(manifest.RuntimeProfiles); err != nil {
|
||||
violations = append(violations, err.Error())
|
||||
}
|
||||
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(manifest.RuntimeProfiles, manifest.Capabilities)...)
|
||||
violations = append(violations, validateSafePluginStrings("manifest", manifestSafeStrings(registration))...)
|
||||
return finish(violations)
|
||||
}
|
||||
@@ -549,6 +558,9 @@ func ValidateServerConfig(config domain.ServerConfig) error {
|
||||
if len([]byte(config.Content)) > maxServerConfigContentSize {
|
||||
violations = append(violations, "content is too large")
|
||||
}
|
||||
if config.Checksum != "" && !validSHA256Checksum(config.Checksum) {
|
||||
violations = append(violations, "checksum must be sha256:<hex>")
|
||||
}
|
||||
if config.UpdatedAt.IsZero() {
|
||||
violations = append(violations, "updatedAt is required")
|
||||
}
|
||||
@@ -620,6 +632,12 @@ func ValidateFileOperationDispatchRequest(request domain.FileOperationDispatchRe
|
||||
if request.InputRef != "" && !validScopedInputRef(request.InputRef) {
|
||||
violations = append(violations, "inputRef is not allowed")
|
||||
}
|
||||
if len([]byte(request.Content)) > maxJobExecutionContentSize {
|
||||
violations = append(violations, "content is too large")
|
||||
}
|
||||
if containsUnsafeRuntimeSecret(request.Content) {
|
||||
violations = append(violations, "content must not expose raw secrets, host paths, or direct sockets")
|
||||
}
|
||||
if request.ExpectedConfigVersion < 0 {
|
||||
violations = append(violations, "expectedConfigVersion must not be negative")
|
||||
}
|
||||
@@ -672,6 +690,14 @@ func ValidateRunEndpoint(endpoint domain.RunEndpoint) error {
|
||||
violations = appendRequired(violations, "id", endpoint.ID)
|
||||
violations = appendRequired(violations, "displayName", endpoint.DisplayName)
|
||||
violations = appendRequired(violations, "version", endpoint.Version)
|
||||
if endpoint.Architecture != "" {
|
||||
if !validDistributionTargetOS(endpoint.Platform) {
|
||||
violations = append(violations, "platform is invalid")
|
||||
}
|
||||
if !validDistributionTargetArch(endpoint.Architecture) {
|
||||
violations = append(violations, "architecture is invalid")
|
||||
}
|
||||
}
|
||||
if !validRunEndpointStatus(endpoint.Status) {
|
||||
violations = append(violations, "status is invalid")
|
||||
}
|
||||
@@ -704,12 +730,51 @@ func ValidateJob(job domain.Job) error {
|
||||
if len(job.Progress.Message) > maxProgressMessageLength {
|
||||
violations = append(violations, "progress.message is too long")
|
||||
}
|
||||
if job.Attempt < 0 {
|
||||
violations = append(violations, "attempt must not be negative")
|
||||
}
|
||||
if job.RetryPolicy.MaxAttempts <= 0 {
|
||||
violations = append(violations, "retryPolicy.maxAttempts must be positive")
|
||||
}
|
||||
if job.RetryPolicy.InitialBackoffSeconds <= 0 || job.RetryPolicy.MaxBackoffSeconds < job.RetryPolicy.InitialBackoffSeconds {
|
||||
violations = append(violations, "retryPolicy backoff must be positive and bounded")
|
||||
}
|
||||
if job.Attempt > job.RetryPolicy.MaxAttempts {
|
||||
violations = append(violations, "attempt must not exceed retryPolicy.maxAttempts")
|
||||
}
|
||||
if job.LeaseTokenHash != "" && len(job.LeaseTokenHash) != 64 {
|
||||
violations = append(violations, "leaseTokenHash must be a SHA-256 hash")
|
||||
}
|
||||
if len(job.CancelReason) > maxProgressMessageLength {
|
||||
violations = append(violations, "cancelReason is too long")
|
||||
}
|
||||
if len(job.ReconcileOutcome) > maxProgressMessageLength {
|
||||
violations = append(violations, "reconcileOutcome is too long")
|
||||
}
|
||||
if job.TargetKey != "" && !validLogicalFileKey(job.TargetKey) {
|
||||
violations = append(violations, "targetKey is not allowed")
|
||||
}
|
||||
if job.InputRef != "" && !validScopedInputRef(job.InputRef) {
|
||||
violations = append(violations, "inputRef is not allowed")
|
||||
}
|
||||
if len([]byte(job.ExecutionInput.Content)) > maxJobExecutionContentSize {
|
||||
violations = append(violations, "executionInput.content is too large")
|
||||
}
|
||||
if job.ExecutionInput.MaxReadBytes < 0 || job.ExecutionInput.MaxReadBytes > maxJobExecutionContentSize {
|
||||
violations = append(violations, "executionInput.maxReadBytes is out of bounds")
|
||||
}
|
||||
if job.ExecutionInput.ExpectedChecksum != "" && !validSHA256Checksum(job.ExecutionInput.ExpectedChecksum) {
|
||||
violations = append(violations, "executionInput.expectedChecksum must be sha256:<hex>")
|
||||
}
|
||||
if job.ExecutionResult.Checksum != "" && !validSHA256Checksum(job.ExecutionResult.Checksum) {
|
||||
violations = append(violations, "executionResult.checksum must be sha256:<hex>")
|
||||
}
|
||||
if len([]byte(job.ExecutionResult.Content)) > maxJobExecutionContentSize {
|
||||
violations = append(violations, "executionResult.content is too large")
|
||||
}
|
||||
if len(job.ExecutionResult.AuditSummary) > maxAuditSummaryLength {
|
||||
violations = append(violations, "executionResult.auditSummary is too long")
|
||||
}
|
||||
if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite {
|
||||
if job.ServerInstanceID == "" {
|
||||
violations = append(violations, "serverInstanceId is required for scoped file jobs")
|
||||
@@ -1242,6 +1307,8 @@ func validPluginRunCapability(capability string) bool {
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
|
||||
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
|
||||
domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate,
|
||||
domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall,
|
||||
"artifacts.read", "artifacts.write", "artifact.read", "artifact.write",
|
||||
"ai.invoke":
|
||||
return true
|
||||
@@ -1497,7 +1564,7 @@ func validRunEndpointStatus(status domain.RunEndpointStatus) bool {
|
||||
|
||||
func validJobState(state domain.JobState) bool {
|
||||
switch state {
|
||||
case domain.JobStateQueued, domain.JobStateAccepted, domain.JobStateRunning, domain.JobStateSucceeded, domain.JobStateFailed, domain.JobStateCancelled:
|
||||
case domain.JobStateQueued, domain.JobStateAccepted, domain.JobStateRunning, domain.JobStateRetrying, domain.JobStateSucceeded, domain.JobStateFailed, domain.JobStateCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -52,6 +52,26 @@ func TestValidateGamePluginManifestRegistrationRejectsUnsafeRequests(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginManifestRegistrationValidatesRuntimeProfiles(t *testing.T) {
|
||||
t.Run("unsafe runtime value", func(t *testing.T) {
|
||||
registration := validGamePluginManifestRegistration()
|
||||
registration.Manifest.RuntimeProfiles = domain.GamePluginRuntimeProfiles{Discovery: []domain.RuntimeDiscoveryProbe{{Key: "server-root-check", Kind: "file.exists", TargetKey: "server-root", Required: true, Expected: "/Users/operator/server"}}}
|
||||
err := ValidateGamePluginManifestRegistration(registration)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe runtime content") {
|
||||
t.Fatalf("expected unsafe runtime profile rejection, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("undeclared transport reference", func(t *testing.T) {
|
||||
registration := validGamePluginManifestRegistration()
|
||||
registration.Manifest.RuntimeProfiles = domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.start"}, TransportKeys: []string{"missing-transport"}}}}
|
||||
err := ValidateGamePluginManifestRegistration(registration)
|
||||
if err == nil || !strings.Contains(err.Error(), "undeclared transport") {
|
||||
t.Fatalf("expected cross-profile reference rejection, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateGamePluginManifestRegistrationRejectsUnsafeCapabilitiesAndPermissions(t *testing.T) {
|
||||
registration := validGamePluginManifestRegistration()
|
||||
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, "run.socket")
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
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{}{}
|
||||
discoveryKeys := map[string]struct{}{}
|
||||
dependencyKeys := map[string]struct{}{}
|
||||
installPlanKeys := map[string]struct{}{}
|
||||
logSourceKeys := 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)...)
|
||||
}
|
||||
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, 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 !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")
|
||||
}
|
||||
}
|
||||
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") {
|
||||
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, 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
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 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 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
|
||||
}
|
||||
@@ -15,6 +15,7 @@ func ValidateServerLifecycleCreate(create domain.ServerLifecycleCreate) error {
|
||||
violations = appendRequired(violations, "pluginId", create.PluginID)
|
||||
violations = appendRequired(violations, "runEndpointId", create.RunEndpointID)
|
||||
violations = appendRequired(violations, "name", create.Name)
|
||||
violations = appendRequired(violations, "profileKey", create.ProfileKey)
|
||||
violations = appendLifecycleIdempotencyViolations(violations, create.IdempotencyKey)
|
||||
return finish(violations)
|
||||
}
|
||||
@@ -31,7 +32,7 @@ func ValidateServerLifecycleCommand(command domain.ServerLifecycleCommand) error
|
||||
|
||||
func ValidateServerLifecycleAction(action domain.ServerLifecycleAction) error {
|
||||
switch action {
|
||||
case domain.ServerLifecycleActionCreate, domain.ServerLifecycleActionStart, domain.ServerLifecycleActionStop:
|
||||
case domain.ServerLifecycleActionCreate, domain.ServerLifecycleActionStart, domain.ServerLifecycleActionStop, domain.ServerLifecycleActionStatus:
|
||||
return nil
|
||||
default:
|
||||
return ValidationError{Violations: []string{fmt.Sprintf("action %q is invalid", action)}}
|
||||
|
||||
Reference in New Issue
Block a user