Align runtime profiles with plugin-owned records

This commit is contained in:
npc0-hue
2026-09-02 10:22:14 +08:00
parent 6018d8f0fc
commit a027ca70eb
36 changed files with 234 additions and 1590 deletions
@@ -1,61 +0,0 @@
package validator
import (
"strings"
"testing"
"browser.local/platform/domain"
)
func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
bridge := domain.GameClientBridgeManifest{
LogProjections: []domain.GameClientBridgeLogProjectionDeclaration{{
Key: "player.login", StreamKeys: []string{"process.stdout"}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 16,
Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{
{Pattern: `Player "(?<name>[^"]+)" reported as player (?<slot>\d+)`},
{Pattern: `Player (?<slot>\d+) SteamID \(assumed\): (?<steamId>\d+)`},
},
Target: domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId", "name": "name"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"},
Presence: &domain.GameClientBridgeLogProjectionPresenceDeclaration{
TimestampField: "lastLoginAt", ActiveWindowSeconds: 600,
ActivityTarget: &domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, ObservedAtField: "observedAt"},
},
}},
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
}
profiles := domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{"game-client.bridge"}}}}}
if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, []string{"server.game-client.command"}, nil, nil, profiles); len(violations) != 0 {
t.Fatalf("expected repeated named captures across steps to validate, got %v", violations)
}
tests := []struct {
name string
expected string
mutate func(*domain.GameClientBridgeManifest, *domain.GamePluginRuntimeProfiles)
}{
{name: "invalid regex", expected: "valid regular expression", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
value.LogProjections[0].Steps[0].Pattern = "("
}},
{name: "missing capture", expected: "references undeclared capture missing", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
value.LogProjections[0].Target.CaptureMappings["steamId"] = "missing"
}},
{name: "invalid timestamp field", expected: "presence.timestampField must reference", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
value.LogProjections[0].Presence.TimestampField = "missingAt"
}},
{name: "invalid activity target", expected: "presence.activityTarget.upsertKeys field missing is not projected", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
value.LogProjections[0].Presence.ActivityTarget.UpsertKeys = []string{"missing"}
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
candidate := domain.CopyGameClientBridgeManifest(bridge)
candidateProfiles := profiles
candidateProfiles.ClientManagers = append([]domain.RuntimeClientManagerProfile(nil), profiles.ClientManagers...)
test.mutate(&candidate, &candidateProfiles)
violations := validateGameClientBridgeManifest("gameClientBridge", candidate, []string{"server.game-client.command"}, nil, nil, candidateProfiles)
if !strings.Contains(strings.Join(violations, "; "), test.expected) {
t.Fatalf("expected %q violation, got %v", test.expected, violations)
}
})
}
}
-7
View File
@@ -12,7 +12,6 @@ import (
const (
MaxLogBatchEntries = 512
MaxLogLineLength = 8192
MaxLogQueryLimit = 10000
)
@@ -63,12 +62,6 @@ func ValidateLogBatchIngest(batch domain.LogBatchIngest) error {
if entry.Seq != batch.FirstSeq+uint64(i) {
violations = append(violations, fmt.Sprintf("entries[%d].seq must be contiguous", i))
}
if strings.TrimSpace(entry.Line) == "" {
violations = append(violations, fmt.Sprintf("entries[%d].line is required", i))
}
if len(entry.Line) > MaxLogLineLength {
violations = append(violations, fmt.Sprintf("entries[%d].line is too long", i))
}
for key := range entry.Fields {
if strings.TrimSpace(key) == "" {
violations = append(violations, fmt.Sprintf("entries[%d].fields key is required", i))
+24
View File
@@ -0,0 +1,24 @@
package validator
import (
"strings"
"testing"
"time"
"browser.local/platform/domain"
)
func TestValidateLogBatchIngestAcceptsVerbatimBlankAndLongLines(t *testing.T) {
entries := []domain.LogEntry{
{Seq: 1, Timestamp: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), Line: ""},
{Seq: 2, Timestamp: time.Date(2026, 9, 1, 0, 0, 1, 0, time.UTC), Line: strings.Repeat("x", 32*1024)},
}
checksum, err := LogEntriesChecksum(entries)
if err != nil {
t.Fatalf("checksum: %v", err)
}
batch := domain.LogBatchIngest{RunEndpointID: "run-1", SessionToken: "session-1", LogStreamID: "run.run-1.server-1.stdout", ServerInstanceID: "server-1", StreamKey: "stdout", Source: domain.LogStreamSourceProcess, FirstSeq: 1, LastSeq: 2, Compression: "none", Checksum: checksum, Entries: entries}
if err := ValidateLogBatchIngest(batch); err != nil {
t.Fatalf("verbatim log batch was rejected: %v", err)
}
}
+4 -166
View File
@@ -166,7 +166,6 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error {
violations = append(violations, err.Error())
}
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...)
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("runtimeProfiles.logEvents", plugin.RuntimeProfiles, plugin.DeclaredPermissions)...)
violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.RequiredRunCapabilities, plugin.Pages, plugin.RuntimeProfiles)...)
violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...)
violations = append(violations, validatePluginAssetFiles("lifecycleAssets", plugin.LifecycleAssets)...)
@@ -237,7 +236,6 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
violations = append(violations, err.Error())
}
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(manifest.RuntimeProfiles, manifest.Capabilities)...)
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("manifest.runtimeProfiles.logEvents", manifest.RuntimeProfiles, manifest.Permissions)...)
violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Capabilities, manifest.Pages, manifest.RuntimeProfiles)...)
violations = append(violations, validatePluginAssetFileDeclarations("manifest.assetFiles", manifest.AssetFiles)...)
violations = append(violations, validatePluginAssetFiles("assetFiles", registration.AssetFiles)...)
@@ -469,7 +467,7 @@ func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[st
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, runCapabilities []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{})
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.LogProjections) == 0 && len(bridge.LifecycleProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.LifecycleProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
return nil
}
var violations []string
@@ -628,18 +626,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
violations = append(violations, prefix+" transport must be sqlite with remote.run.db.sqlite.query capability")
}
}
logProjectionKeys := map[string]struct{}{}
for index, projection := range bridge.LogProjections {
prefix := fmt.Sprintf("%s.logProjections[%d]", field, index)
if !clientManagerIdentifierPattern.MatchString(projection.Key) {
violations = append(violations, prefix+".key is invalid")
}
if _, exists := logProjectionKeys[projection.Key]; exists {
violations = append(violations, prefix+".key is duplicated")
}
logProjectionKeys[projection.Key] = struct{}{}
violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection)...)
}
lifecycleProjectionKeys := map[string]struct{}{}
for index, projection := range bridge.LifecycleProjections {
prefix := fmt.Sprintf("%s.lifecycleProjections[%d]", field, index)
@@ -662,10 +648,10 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
violations = append(violations, prefix+".key is duplicated")
}
dataPackKeys[dataPack.Key] = struct{}{}
if dataPack.DatabaseUserVersion < 1 || len(dataPack.LogParserRefs) == 0 || len(dataPack.ConfigMapRefs) == 0 {
violations = append(violations, prefix+" must declare a database version and parser/config assets")
if dataPack.DatabaseUserVersion < 1 || len(dataPack.ConfigMapRefs) == 0 {
violations = append(violations, prefix+" must declare a database version and config assets")
}
refs := append(domain.CopyStringSlice(dataPack.LogParserRefs), dataPack.ConfigMapRefs...)
refs := domain.CopyStringSlice(dataPack.ConfigMapRefs)
refs = append(refs, dataPack.DataRefs...)
for _, ref := range refs {
if !safeRelativeJSONRef(ref) {
@@ -939,154 +925,6 @@ func validateGameClientBridgeBulkActivityTarget(prefix string, target domain.Gam
return violations
}
func validateGameClientBridgeLogProjection(prefix string, projection domain.GameClientBridgeLogProjectionDeclaration) []string {
var violations []string
if len(projection.StreamKeys) < 1 || len(projection.StreamKeys) > 64 {
violations = append(violations, prefix+".streamKeys must contain between 1 and 64 streams")
}
for _, streamKey := range projection.StreamKeys {
if !clientManagerIdentifierPattern.MatchString(streamKey) {
violations = append(violations, prefix+".streamKeys contains an invalid stream key")
}
}
violations = append(violations, duplicateViolations(prefix+".streamKeys", projection.StreamKeys)...)
captures := map[string]struct{}{}
if len(projection.Steps) < 1 || len(projection.Steps) > 64 {
violations = append(violations, prefix+".steps must contain between 1 and 64 patterns")
}
for index, step := range projection.Steps {
stepPrefix := fmt.Sprintf("%s.steps[%d].pattern", prefix, index)
if strings.TrimSpace(step.Pattern) == "" || len([]rune(step.Pattern)) > 16384 {
violations = append(violations, stepPrefix+" is empty or too large")
continue
}
compiled, err := regexp.Compile(step.Pattern)
if err != nil {
violations = append(violations, stepPrefix+" must be a valid regular expression")
continue
}
for _, capture := range compiled.SubexpNames() {
if capture != "" {
captures[capture] = struct{}{}
}
}
}
if len(projection.CorrelationFields) < 1 || len(projection.CorrelationFields) > 64 {
violations = append(violations, prefix+".correlationFields must contain between 1 and 64 captures")
}
for _, field := range projection.CorrelationFields {
if !gameClientBridgeCaptureNamePattern.MatchString(field) {
violations = append(violations, prefix+".correlationFields contains an invalid capture name")
continue
}
if _, exists := captures[field]; !exists {
violations = append(violations, prefix+".correlationFields references undeclared capture "+field)
}
}
violations = append(violations, duplicateViolations(prefix+".correlationFields", projection.CorrelationFields)...)
if projection.MaxInterveningLines < 0 || projection.MaxInterveningLines > 100000 {
violations = append(violations, prefix+".maxInterveningLines is invalid")
}
violations = append(violations, validateGameClientBridgeLogProjectionTarget(prefix+".target", projection.Target, captures)...)
if projection.Presence == nil {
return violations
}
presence := projection.Presence
if !gameClientBridgeFieldPattern.MatchString(presence.TimestampField) || !gameClientBridgeLogProjectionTargetDeclaresField(projection.Target, presence.TimestampField) {
violations = append(violations, prefix+".presence.timestampField must reference a projected target field")
}
if presence.ActiveWindowSeconds < 1 || presence.ActiveWindowSeconds > 31536000 {
violations = append(violations, prefix+".presence.activeWindowSeconds is invalid")
}
if presence.ActivityTarget != nil {
violations = append(violations, validateGameClientBridgeLogProjectionTarget(prefix+".presence.activityTarget", *presence.ActivityTarget, captures)...)
}
return violations
}
func validateGameClientBridgeLogProjectionTarget(prefix string, target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]struct{}) []string {
var violations []string
if !gameClientBridgeCollectionPattern.MatchString(target.Collection) {
violations = append(violations, prefix+".collection is invalid")
}
if len(target.UpsertKeys) < 1 || len(target.UpsertKeys) > 8 {
violations = append(violations, prefix+".upsertKeys must contain between 1 and 8 fields")
}
for _, key := range target.UpsertKeys {
if !gameClientBridgeFieldPattern.MatchString(key) {
violations = append(violations, prefix+".upsertKeys contains an invalid field")
}
if !gameClientBridgeLogProjectionTargetDeclaresField(target, key) {
violations = append(violations, prefix+".upsertKeys field "+key+" is not projected")
}
}
violations = append(violations, duplicateViolations(prefix+".upsertKeys", target.UpsertKeys)...)
if len(target.CaptureMappings) < 1 || len(target.CaptureMappings) > 64 {
violations = append(violations, prefix+".captureMappings must contain between 1 and 64 mappings")
}
projectedFields := map[string]struct{}{}
for destination, capture := range target.CaptureMappings {
if !gameClientBridgeFieldPattern.MatchString(destination) || !gameClientBridgeCaptureNamePattern.MatchString(capture) {
violations = append(violations, prefix+".captureMappings contains an invalid field or capture")
}
if _, exists := captures[capture]; !exists {
violations = append(violations, prefix+".captureMappings references undeclared capture "+capture)
}
projectedFields[destination] = struct{}{}
}
if len(target.HashMappings) > 64 {
violations = append(violations, prefix+".hashMappings contains too many fields")
}
for destination, capture := range target.HashMappings {
if !gameClientBridgeFieldPattern.MatchString(destination) || !gameClientBridgeCaptureNamePattern.MatchString(capture) {
violations = append(violations, prefix+".hashMappings contains an invalid field or capture")
}
if _, exists := captures[capture]; !exists {
violations = append(violations, prefix+".hashMappings references undeclared capture "+capture)
}
if _, exists := projectedFields[destination]; exists {
violations = append(violations, prefix+" declares field "+destination+" more than once")
}
projectedFields[destination] = struct{}{}
}
if len(target.FixedValues) > 64 {
violations = append(violations, prefix+".fixedValues contains too many fields")
}
for destination, value := range target.FixedValues {
if !gameClientBridgeFieldPattern.MatchString(destination) || len([]rune(value)) > 4096 {
violations = append(violations, prefix+".fixedValues contains an invalid field or oversized value")
}
if _, exists := projectedFields[destination]; exists {
violations = append(violations, prefix+" declares field "+destination+" more than once")
}
projectedFields[destination] = struct{}{}
}
if target.ObservedAtField != "" {
if !gameClientBridgeFieldPattern.MatchString(target.ObservedAtField) {
violations = append(violations, prefix+".observedAtField is invalid")
}
if _, exists := projectedFields[target.ObservedAtField]; exists {
violations = append(violations, prefix+" declares field "+target.ObservedAtField+" more than once")
}
}
return violations
}
func gameClientBridgeLogProjectionTargetDeclaresField(target domain.GameClientBridgeLogProjectionTargetDeclaration, field string) bool {
if target.ObservedAtField == field {
return true
}
if _, exists := target.CaptureMappings[field]; exists {
return true
}
_, exists := target.FixedValues[field]
return exists
}
func validCompanionProofEnvironment(value string) bool {
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
return false
@@ -0,0 +1,48 @@
package validator
import (
"strings"
"testing"
"browser.local/platform/domain"
)
func TestValidateGamePluginRuntimeProfilesAllowsSafeCasePreservingDataTargetPath(t *testing.T) {
profiles := validRuntimeDataTargetProfiles("SCUM/Saved/SaveFiles/SCUM.db")
if err := ValidateGamePluginRuntimeProfiles(profiles); err != nil {
t.Fatalf("expected safe cross-platform data target path to validate: %v", err)
}
}
func TestValidateGamePluginRuntimeProfilesRejectsUnsafeDataTargetPath(t *testing.T) {
for _, sourcePath := range []string{"../SCUM.db", "/SCUM.db", `SCUM\\Saved\\SCUM.db`, "C:/SCUM.db", "https://example.test/SCUM.db"} {
t.Run(sourcePath, func(t *testing.T) {
err := ValidateGamePluginRuntimeProfiles(validRuntimeDataTargetProfiles(sourcePath))
if err == nil || !strings.Contains(err.Error(), "sourcePath must be a safe relative path") {
t.Fatalf("expected unsafe source path rejection, got %v", err)
}
})
}
}
func validRuntimeDataTargetProfiles(sourcePath string) domain.GamePluginRuntimeProfiles {
return domain.GamePluginRuntimeProfiles{
TransportProfiles: []domain.RuntimeTransportProfile{{
Key: "scum-database",
Kind: "sqlite",
TargetKey: "scum-database",
Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery},
}},
DataTargets: []domain.RuntimeDataTarget{{
Key: "scum-database",
Kind: "sqlite.snapshot",
TransportKey: "scum-database",
SourceRootKey: "server-root",
SourcePath: sourcePath,
WorkspaceKey: "databases/scum-database",
RefreshPolicy: "on-demand-snapshot",
MaxBytes: 1024 * 1024,
Platforms: []string{"windows"},
}},
}
}
@@ -1,122 +0,0 @@
package validator
import (
"strings"
"testing"
"browser.local/platform/domain"
)
func validRuntimeLogEventProfiles() domain.GamePluginRuntimeProfiles {
return domain.GamePluginRuntimeProfiles{
LogSources: []domain.RuntimeLogSource{{Key: "chat-log", Kind: "file.tail", StreamKey: "chat", CursorKind: "offset", RetentionDays: 30}},
LogEvents: []domain.RuntimeLogEvent{{
Key: "chat-message", Title: "Chat message", SourceKey: "chat-log", EventType: "chat.message",
Permission: "server.logs.read", SchemaRef: "schemas/log-events/chat-message.schema.json", RetentionDays: 30, Severity: "info",
}},
}
}
func TestValidateGamePluginRuntimeProfilesValidatesLogEvents(t *testing.T) {
if err := ValidateGamePluginRuntimeProfiles(validRuntimeLogEventProfiles()); err != nil {
t.Fatalf("expected valid runtime log event declaration, got %v", err)
}
tests := []struct {
name string
expected string
mutate func(*domain.GamePluginRuntimeProfiles)
}{
{name: "undeclared source", expected: "sourceKey must reference", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].SourceKey = "missing" }},
{name: "invalid event type", expected: "eventType is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].EventType = "chat message" }},
{name: "invalid permission", expected: "permission is not allowed", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Permission = "server.admin" }},
{name: "unsafe schema", expected: "schemaRef must be a bounded safe relative JSON reference", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
profiles.LogEvents[0].SchemaRef = "schemas/log events/chat.json"
}},
{name: "retention bound", expected: "retentionDays is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].RetentionDays = 366 }},
{name: "retention exceeds source", expected: "retentionDays must not exceed the source retention", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].RetentionDays = 31 }},
{name: "invalid severity", expected: "severity is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Severity = "emergency" }},
{name: "plugin error severity", expected: "severity is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Severity = "error" }},
{name: "duplicate key", expected: "key is duplicated", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
profiles.LogEvents = append(profiles.LogEvents, profiles.LogEvents[0])
}},
{name: "duplicate event type", expected: "eventType is duplicated", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
duplicate := profiles.LogEvents[0]
duplicate.Key = "chat-message-copy"
profiles.LogEvents = append(profiles.LogEvents, duplicate)
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
profiles := domain.CopyGamePluginRuntimeProfiles(validRuntimeLogEventProfiles())
test.mutate(&profiles)
err := ValidateGamePluginRuntimeProfiles(profiles)
if err == nil || !strings.Contains(err.Error(), test.expected) {
t.Fatalf("expected %q validation error, got %v", test.expected, err)
}
})
}
}
func TestValidateGamePluginRuntimeProfilesAcceptsDeclaredLogEventSeverities(t *testing.T) {
for _, severity := range []domain.RuntimeLogEventSeverity{
domain.RuntimeLogEventSeverityInfo,
domain.RuntimeLogEventSeverityNotice,
domain.RuntimeLogEventSeverityWarning,
domain.RuntimeLogEventSeverityCritical,
} {
t.Run(string(severity), func(t *testing.T) {
profiles := validRuntimeLogEventProfiles()
profiles.LogEvents[0].Severity = severity
if err := ValidateGamePluginRuntimeProfiles(profiles); err != nil {
t.Fatalf("expected severity %q to validate, got %v", severity, err)
}
})
}
}
func TestValidateGamePluginRuntimeProfilesAllowsEventRetentionWhenSourceUsesDefault(t *testing.T) {
profiles := validRuntimeLogEventProfiles()
profiles.LogSources[0].RetentionDays = 0
if err := ValidateGamePluginRuntimeProfiles(profiles); err != nil {
t.Fatalf("expected source default retention to allow bounded event retention, got %v", err)
}
}
func TestValidateGamePluginRuntimeProfilesRejectsUnsafeLogEventSemantics(t *testing.T) {
unsafeEventTypes := []string{
"ops.shell.execute",
"ops.execute",
"ops.sql.query",
"ops.raw-host-path",
"run.socket.open",
"auth.credential.exposed",
"auth.api-key.exposed",
}
for _, eventType := range unsafeEventTypes {
t.Run(eventType, func(t *testing.T) {
profiles := validRuntimeLogEventProfiles()
profiles.LogEvents[0].EventType = eventType
err := ValidateGamePluginRuntimeProfiles(profiles)
if err == nil || !strings.Contains(err.Error(), "eventType contains unsafe operation semantics") {
t.Fatalf("expected unsafe event type %q to be rejected, got %v", eventType, err)
}
})
}
}
func TestValidateGamePluginManifestRegistrationRequiresDeclaredLogEventPermission(t *testing.T) {
registration := validGamePluginManifestRegistration()
registration.Manifest.RuntimeProfiles = validRuntimeLogEventProfiles()
registration.Manifest.RuntimeProfiles.LogEvents[0].Permission = "server.game-client.read"
err := ValidateGamePluginManifestRegistration(registration)
if err == nil || !strings.Contains(err.Error(), "manifest.runtimeProfiles.logEvents[0].permission must be declared by the plugin") {
t.Fatalf("expected undeclared log event permission rejection, got %v", err)
}
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read")
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
t.Fatalf("expected declared log event permission to validate, got %v", err)
}
}
+35 -98
View File
@@ -12,10 +12,8 @@ import (
)
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}$`)
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 {
@@ -23,6 +21,9 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
var violations []string
lifecycleKeys := map[string]struct{}{}
transportKeys := map[string]struct{}{}
transportProfiles := map[string]domain.RuntimeTransportProfile{}
dataTargetKeys := map[string]struct{}{}
dataTargetWorkspaces := map[string]struct{}{}
managerKeys := map[string]struct{}{}
dllExtensionKeys := map[string]struct{}{}
dllExtensionStates := map[string]string{}
@@ -31,9 +32,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
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)
@@ -237,9 +235,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
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")
}
@@ -254,44 +249,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
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)...)
@@ -311,6 +268,36 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
}
}
violations = append(violations, duplicateViolations(prefix+".capabilities", transport.Capabilities)...)
if transport.Key != "" {
transportProfiles[transport.Key] = transport
}
}
if len(profiles.DataTargets) > 16 {
violations = append(violations, "runtimeProfiles.dataTargets must not exceed 16")
}
for i, target := range profiles.DataTargets {
prefix := fmt.Sprintf("runtimeProfiles.dataTargets[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", target.Key)...)
violations = append(violations, recordRuntimeProfileKey(dataTargetKeys, prefix+".key", target.Key)...)
violations = append(violations, validateProfileKey(prefix+".transportKey", target.TransportKey)...)
violations = append(violations, validateProfileKey(prefix+".sourceRootKey", target.SourceRootKey)...)
violations = append(violations, validateSafeRelativeRuntimePath(prefix+".sourcePath", target.SourcePath)...)
violations = append(violations, validateProfileKey(prefix+".workspaceKey", target.WorkspaceKey)...)
if target.Kind != "sqlite.snapshot" || target.RefreshPolicy != "on-demand-snapshot" || !strings.HasPrefix(target.WorkspaceKey, "databases/") {
violations = append(violations, prefix+" must declare an on-demand sqlite snapshot workspace")
}
if target.MaxBytes < 1 || target.MaxBytes > 1024*1024*1024 {
violations = append(violations, prefix+".maxBytes is invalid")
}
violations = append(violations, validateRuntimePlatforms(prefix+".platforms", target.Platforms)...)
if _, exists := dataTargetWorkspaces[target.WorkspaceKey]; exists {
violations = append(violations, prefix+".workspaceKey is duplicated")
}
dataTargetWorkspaces[target.WorkspaceKey] = struct{}{}
transport, exists := transportProfiles[target.TransportKey]
if !exists || transport.Kind != "sqlite" || transport.TargetKey != target.TransportKey || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
violations = append(violations, prefix+".transportKey must reference a declared SQLite query transport")
}
}
for i, manager := range profiles.ClientManagers {
prefix := fmt.Sprintf("runtimeProfiles.clientManagers[%d]", i)
@@ -609,42 +596,6 @@ func validateSafeRuntimeValue(field, value string) []string {
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
@@ -681,20 +632,6 @@ func validateRuntimeProfileCapabilityDeclarations(profiles domain.GamePluginRunt
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} {