Align runtime profiles with plugin-owned records
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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} {
|
||||
|
||||
Reference in New Issue
Block a user