feat(scum-companion): validate typed adapters

This commit is contained in:
npc0-hue
2026-07-29 10:50:32 +08:00
parent 7155e755f8
commit acf4e4a8f0
5 changed files with 261 additions and 6 deletions
@@ -0,0 +1,103 @@
package companion
import (
"context"
"fmt"
"sort"
"strings"
)
// AuthorizedConfigPort is supplied by a version-bound Companion integration.
// It exposes logical configuration values only: never a host path, connection
// string, credential, arbitrary command, or direct database handle.
type AuthorizedConfigPort interface {
ReadConfig(context.Context) (map[string]string, error)
ApplyConfigPatch(ctx context.Context, revision string, fields []ConfigFieldPatch) (map[string]string, error)
}
type ConfigFieldPatch struct {
Key string
Value string
}
type VersionedAdapter struct {
ServerVersion string
Config AuthorizedConfigPort
DiagnosticsState map[string]string
}
func (adapter VersionedAdapter) ReadConfiguration(ctx context.Context) (map[string]any, error) {
if !supportedAdapterVersion(adapter.ServerVersion) || adapter.Config == nil {
return nil, fmt.Errorf("configuration adapter is unsupported")
}
fields, err := adapter.Config.ReadConfig(ctx)
if err != nil {
return nil, err
}
return map[string]any{"version": adapter.ServerVersion, "fields": redactConfigValues(fields)}, nil
}
func (adapter VersionedAdapter) PatchConfiguration(ctx context.Context, payload map[string]any) (map[string]any, error) {
if !supportedAdapterVersion(adapter.ServerVersion) || adapter.Config == nil {
return nil, fmt.Errorf("configuration adapter is unsupported")
}
revision, _ := payload["revision"].(string)
raw, _ := payload["fields"].([]any)
fields := make([]ConfigFieldPatch, 0, len(raw))
for _, value := range raw {
item, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("configuration patch payload is invalid")
}
key, keyOK := item["key"].(string)
fieldValue, valueOK := item["value"].(string)
if !keyOK || !valueOK || !supportedConfigKey(key) {
return nil, fmt.Errorf("configuration patch field is unsupported")
}
fields = append(fields, ConfigFieldPatch{Key: key, Value: fieldValue})
}
applied, err := adapter.Config.ApplyConfigPatch(ctx, revision, fields)
if err != nil {
return nil, err
}
return map[string]any{"version": adapter.ServerVersion, "appliedFields": redactConfigValues(applied)}, nil
}
func (adapter VersionedAdapter) Diagnostics(context.Context) (map[string]any, error) {
state := map[string]any{"version": adapter.ServerVersion, "adapter": "version-bound", "configuration": supportedAdapterVersion(adapter.ServerVersion)}
for key, value := range adapter.DiagnosticsState {
if safeDiagnosticField(key, value) {
state[key] = value
}
}
return state, nil
}
func (VersionedAdapter) PatchGameState(context.Context, map[string]any) (map[string]any, error) {
return nil, fmt.Errorf("state adapter is unsupported")
}
func (VersionedAdapter) DeliverReward(context.Context, map[string]any) (map[string]any, error) {
return nil, fmt.Errorf("reward adapter is unsupported")
}
func (VersionedAdapter) NotifyPlayer(context.Context, map[string]any) (map[string]any, error) {
return nil, fmt.Errorf("notification adapter is unsupported")
}
func supportedAdapterVersion(version string) bool { return version == "0.9.700.90357" }
func supportedConfigKey(key string) bool {
return map[string]bool{"ServerName": true, "GamePort": true, "QueryPort": true, "MaxPlayers": true, "WelcomeMessage": true}[key]
}
func redactConfigValues(values map[string]string) map[string]string {
result := map[string]string{}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if supportedConfigKey(key) && safeDiagnosticField(key, values[key]) {
result[key] = values[key]
}
}
return result
}
func safeDiagnosticField(key, value string) bool {
lowered := strings.ToLower(key + "=" + value)
return !strings.Contains(lowered, "path") && !strings.Contains(lowered, "credential") && !strings.Contains(lowered, "password") && !strings.Contains(lowered, "bearer ") && !strings.Contains(lowered, "rcon") && !strings.Contains(lowered, "sql") && !strings.Contains(lowered, "://")
}
@@ -0,0 +1,43 @@
package companion
import (
"context"
"testing"
)
type configPortFixture struct {
fields map[string]string
patches []ConfigFieldPatch
}
func (fixture *configPortFixture) ReadConfig(context.Context) (map[string]string, error) {
return fixture.fields, nil
}
func (fixture *configPortFixture) ApplyConfigPatch(_ context.Context, _ string, fields []ConfigFieldPatch) (map[string]string, error) {
fixture.patches = fields
return map[string]string{"ServerName": "Moon", "hostPath": "C:/secret"}, nil
}
func TestVersionedAdapterUsesOnlyLogicalConfigValuesAndRedactsDiagnostics(t *testing.T) {
port := &configPortFixture{fields: map[string]string{"ServerName": "Moon", "hostPath": "C:/secret", "Password": "nope"}}
adapter := VersionedAdapter{ServerVersion: "0.9.700.90357", Config: port, DiagnosticsState: map[string]string{"status": "healthy", "hostPath": "C:/secret"}}
read, err := adapter.ReadConfiguration(context.Background())
if err != nil {
t.Fatalf("read config: %v", err)
}
fields := read["fields"].(map[string]string)
if fields["ServerName"] != "Moon" || len(fields) != 1 {
t.Fatalf("unsafe config was exposed: %+v", fields)
}
patched, err := adapter.PatchConfiguration(context.Background(), map[string]any{"revision": "r1", "fields": []any{map[string]any{"key": "ServerName", "value": "Moon"}}})
if err != nil {
t.Fatalf("patch config: %v", err)
}
if len(port.patches) != 1 || patched["appliedFields"].(map[string]string)["hostPath"] != "" {
t.Fatalf("patch leaked unsafe details: %+v", patched)
}
diagnostics, _ := adapter.Diagnostics(context.Background())
if diagnostics["hostPath"] != nil || diagnostics["status"] != "healthy" {
t.Fatalf("diagnostics leaked unsafe details: %+v", diagnostics)
}
}
@@ -20,6 +20,7 @@ type SafeAdapter interface {
}
type HandlerAvailability struct {
BoundServerID string
ServerVersion string
Capabilities map[string]bool
Approved bool
@@ -66,7 +67,7 @@ func (registry *HandlerRegistry) Execute(ctx context.Context, command ClaimedCom
if err := validateDeclaredCommandAt(command, time.Now); err != nil {
return unsupportedResult("validation-failed"), nil
}
if !registry.availability.Approved || !registry.availability.Capabilities[command.CommandType] {
if strings.TrimSpace(registry.availability.BoundServerID) == "" || !registry.availability.Approved || !registry.availability.Capabilities[command.CommandType] {
return unsupportedResult("unsupported"), nil
}
handler, exists := registry.handlers[command.CommandType]
@@ -93,7 +94,104 @@ func validateDeclaredCommandAt(command ClaimedCommand, now func() time.Time) err
return fmt.Errorf("unsafe payload")
}
}
return nil
return validateCommandPayload(command.CommandType, command.Payload)
}
func validateCommandPayload(commandType string, payload map[string]any) error {
require := func(keys ...string) error {
for _, key := range keys {
if _, ok := payload[key]; !ok {
return fmt.Errorf("payload is incomplete")
}
}
return nil
}
noUnknown := func(keys ...string) error {
allowed := map[string]bool{}
for _, key := range keys {
allowed[key] = true
}
for key := range payload {
if !allowed[key] {
return fmt.Errorf("payload has unsupported field")
}
}
return nil
}
switch commandType {
case "config.read":
return noUnknown()
case "config.patch":
if err := require("revision", "fields"); err != nil {
return err
}
if err := noUnknown("revision", "fields"); err != nil {
return err
}
_, revisionOK := payload["revision"].(string)
fields, fieldsOK := payload["fields"].([]any)
if !revisionOK || !fieldsOK || len(fields) == 0 || len(fields) > 32 {
return fmt.Errorf("config patch payload is invalid")
}
return nil
case "companion.diagnostics":
if err := noUnknown("includeWindowState", "maxEntries"); err != nil {
return err
}
if value, ok := payload["includeWindowState"]; ok {
if _, valid := value.(bool); !valid {
return fmt.Errorf("diagnostics payload is invalid")
}
}
if value, ok := payload["maxEntries"]; ok {
if !boundedDiagnosticsEntries(value) {
return fmt.Errorf("diagnostics payload is invalid")
}
}
return nil
case "game-state.patch":
if err := require("playerId", "gameVersion", "expectedStateVersion", "safetyWindow", "reason", "changes"); err != nil {
return err
}
if err := noUnknown("playerId", "gameVersion", "expectedStateVersion", "safetyWindow", "reason", "changes"); err != nil {
return err
}
version, ok := payload["gameVersion"].(string)
changes, changesOK := payload["changes"].([]any)
if !ok || version == "" || !changesOK || len(changes) == 0 || len(changes) > 8 {
return fmt.Errorf("state patch payload is invalid")
}
return nil
case "reward.deliver":
if err := require("grantId", "playerId", "items"); err != nil {
return err
}
if err := noUnknown("grantId", "playerId", "items"); err != nil {
return err
}
_, grantOK := payload["grantId"].(string)
_, playerOK := payload["playerId"].(string)
items, itemsOK := payload["items"].([]any)
if !grantOK || !playerOK || !itemsOK || len(items) == 0 || len(items) > 8 {
return fmt.Errorf("reward payload is invalid")
}
return nil
case "player.notify":
if err := require("playerId", "message"); err != nil {
return err
}
if err := noUnknown("playerId", "message"); err != nil {
return err
}
_, playerOK := payload["playerId"].(string)
message, messageOK := payload["message"].(string)
if !playerOK || !messageOK || strings.TrimSpace(message) == "" || len(message) > 200 {
return fmt.Errorf("notification payload is invalid")
}
return nil
default:
return fmt.Errorf("command type is not declared")
}
}
func safeCommandField(key string, value any) bool {
@@ -49,7 +49,7 @@ func (*adapterFixture) NotifyPlayer(context.Context, map[string]any) (map[string
func TestDispatcherAcknowledgesOnlyLiveValidatedTypedCommands(t *testing.T) {
stamp := time.Now().UTC()
adapter := &adapterFixture{}
registry := NewHandlerRegistry(HandlerAvailability{ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{"config.read": true}}, adapter)
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{"config.read": true}}, adapter)
fixture := &dispatchFixture{commands: []ClaimedCommand{{ID: "read-1", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 7, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}, {ID: "expired-1", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 8, LeaseExpiresAt: stamp.Add(-time.Second), ExpiresAt: stamp.Add(-time.Second)}}}
dispatcher := Dispatcher{Client: fixture, Registry: registry, Now: func() time.Time { return stamp }}
if err := dispatcher.DispatchOnce(context.Background()); err != nil {
@@ -66,7 +66,7 @@ func TestDispatcherAcknowledgesOnlyLiveValidatedTypedCommands(t *testing.T) {
func TestRegistryReturnsCachedResultForDuplicateDelivery(t *testing.T) {
stamp := time.Now().UTC()
adapter := &adapterFixture{}
registry := NewHandlerRegistry(HandlerAvailability{ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{"config.read": true}}, adapter)
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{"config.read": true}}, adapter)
command := ClaimedCommand{ID: "duplicate-1", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 7, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}
if _, err := registry.Execute(context.Background(), command); err != nil {
t.Fatalf("first execute: %v", err)
@@ -78,3 +78,14 @@ func TestRegistryReturnsCachedResultForDuplicateDelivery(t *testing.T) {
t.Fatalf("duplicate delivery invoked adapter %d times", adapter.reads)
}
}
func TestRegistryRejectsUndeclaredAndMalformedPayloads(t *testing.T) {
stamp := time.Now().UTC()
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", ServerVersion: "0.9.700.90357", Approved: true, Capabilities: map[string]bool{"config.patch": true}}, &adapterFixture{})
for _, command := range []ClaimedCommand{{ID: "bad-type", ProfileKey: ProfileKey, CommandType: "raw.rcon", Payload: map[string]any{}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}, {ID: "bad-payload", ProfileKey: ProfileKey, CommandType: "config.patch", Payload: map[string]any{"revision": "r1"}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}} {
result, err := registry.Execute(context.Background(), command)
if err != nil || result.Payload["result"] != "validation-failed" {
t.Fatalf("unsafe command was not rejected: result=%+v err=%v", result, err)
}
}
}