Align runtime profiles with plugin-owned records
This commit is contained in:
@@ -1,40 +0,0 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConsoleSemanticEventProducerParsesOnlyBoundedKnownOutput(t *testing.T) {
|
||||
availability := VerifiedSemanticEventProducer()
|
||||
if !availability.Available || availability.Reason == "" {
|
||||
t.Fatalf("console event producer should be available: %+v", availability)
|
||||
}
|
||||
batch := ParseConsoleRecords("server-1", []ConsoleRecord{{ServerID: "server-1", Stream: "stdout", Sequence: 1, OccurredAt: time.Now(), Text: "SCUM LOGIN 76561198000000001 10.0.0.1"}, {ServerID: "server-1", Stream: "stderr", Sequence: 2, OccurredAt: time.Now(), Text: "unrecognised output"}}, "fixture-secret")
|
||||
if len(batch.Events) != 1 || batch.Events[0].Type != "scum.login" || batch.Events[0].NetworkCorrelation == "" || len(batch.Diagnostics) != 1 || batch.Diagnostics[0].Code != "unknown-console-format" {
|
||||
t.Fatalf("unsafe console parsing result: %+v", batch)
|
||||
}
|
||||
if batch.Events[0].NetworkCorrelation == "10.0.0.1" {
|
||||
t.Fatal("raw network value leaked")
|
||||
}
|
||||
if len(batch.Events[0].NetworkCorrelation) != 64 {
|
||||
t.Fatalf("network correlation must be full sha256 hex, got %q", batch.Events[0].NetworkCorrelation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleSemanticEventProducerParsesScumLoginLog(t *testing.T) {
|
||||
observedAt := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
|
||||
batch := ParseConsoleRecords("server-1", []ConsoleRecord{
|
||||
{ServerID: "server-1", Stream: "stdout", Sequence: 1, OccurredAt: observedAt, Text: "2026.08.27-12.00.00: '10.0.0.2 76561198000000002:Ada(42)' logged in at: X=1 Y=2 Z=3"},
|
||||
{ServerID: "server-1", Stream: "stdout", Sequence: 2, OccurredAt: observedAt.Add(time.Second), Text: "2026.08.27-12.00.01: '10.0.0.2 76561198000000002:Ada(42)' logged out"},
|
||||
}, "fixture-secret")
|
||||
if len(batch.Events) != 2 || batch.Events[0].Type != "scum.login" || batch.Events[1].Type != "scum.logout" {
|
||||
t.Fatalf("login log events not parsed: %+v", batch)
|
||||
}
|
||||
if batch.Events[0].PlayerID != "76561198000000002" || batch.Events[0].DisplayName != "Ada" || len(batch.Events[0].NetworkCorrelation) != 64 {
|
||||
t.Fatalf("login event fields are incomplete: %+v", batch.Events[0])
|
||||
}
|
||||
if batch.Events[0].NetworkCorrelation == "10.0.0.2" {
|
||||
t.Fatal("raw login log IP leaked")
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"version": 2,
|
||||
"encoding": "utf-16le",
|
||||
"lineEnding": "lf",
|
||||
"continuationPolicy": "append-to-previous-timestamped-record",
|
||||
"timestampFormat": "yyyy.MM.dd-HH.mm.ss",
|
||||
"defaultPattern": "^([0-9]{4}\\.[0-9]{2}\\.[0-9]{2}-[0-9]{2}\\.[0-9]{2}\\.[0-9]{2}):?\\s*(.*)$",
|
||||
"defaultFields": ["occurredAt", "payload"],
|
||||
"parsers": [
|
||||
{ "key": "login", "filePattern": "^login_[0-9]{14}\\.log$", "eventType": "scum.login" },
|
||||
{ "key": "chat", "filePattern": "^chat_[0-9]{14}\\.log$", "eventType": "scum.chat" },
|
||||
{ "key": "admin", "filePattern": "^admin_[0-9]{14}\\.log$", "eventType": "scum.admin" },
|
||||
{ "key": "kill", "filePattern": "^kill_[0-9]{14}\\.log$", "eventType": "scum.kill" },
|
||||
{ "key": "event-kill", "filePattern": "^event_kill_[0-9]{14}\\.log$", "eventType": "scum.event.kill" },
|
||||
{ "key": "quests", "filePattern": "^quests_[0-9]{14}\\.log$", "eventType": "scum.quest" },
|
||||
{ "key": "famepoints", "filePattern": "^famepoints_[0-9]{14}\\.log$", "eventType": "scum.famepoints" },
|
||||
{ "key": "economy", "filePattern": "^economy_[0-9]{14}\\.log$", "eventType": "scum.economy" },
|
||||
{ "key": "gameplay", "filePattern": "^gameplay_[0-9]{14}\\.log$", "eventType": "scum.gameplay" },
|
||||
{ "key": "vehicle-destruction", "filePattern": "^vehicle_destruction_[0-9]{14}\\.log$", "eventType": "scum.vehicle.destruction" },
|
||||
{ "key": "raid-protection", "filePattern": "^raid_protection_[0-9]{14}\\.log$", "eventType": "scum.raid.protection" },
|
||||
{ "key": "base-building-destruction", "filePattern": "^base_building_destruction_[0-9]{14}\\.log$", "eventType": "scum.base.destruction" },
|
||||
{ "key": "chest-ownership", "filePattern": "^chest_ownership_[0-9]{14}\\.log$", "eventType": "scum.chest.ownership" },
|
||||
{ "key": "loot", "filePattern": "^loot_[0-9]{14}\\.log$", "eventType": "scum.loot" },
|
||||
{ "key": "violations", "filePattern": "^violations_[0-9]{14}\\.log$", "eventType": "scum.violation" },
|
||||
{ "key": "sentry", "filePattern": "^sentry_[0-9]{14}\\.log$", "eventType": "scum.sentry" },
|
||||
{ "key": "server-notifications", "filePattern": "^server_notifications_[0-9]{14}\\.log$", "eventType": "scum.server.notification" },
|
||||
{ "key": "armor-absorption", "filePattern": "^armor_absorption_[0-9]{14}\\.log$", "eventType": "scum.armor.absorption" },
|
||||
{ "key": "network-objects", "filePattern": "^network_objects_[0-9]{14}\\.log$", "eventType": "scum.network.object" }
|
||||
]
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"type":"object","additionalProperties":false,"required":["events"],"properties":{"events":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"object","additionalProperties":false,"required":["type","occurredAt"],"properties":{"type":{"type":"string","enum":["scum.login","scum.logout"],"maxLength":16},"occurredAt":{"type":"string","minLength":20,"maxLength":40},"playerId":{"type":"string","minLength":1,"maxLength":96},"displayName":{"type":"string","minLength":1,"maxLength":80},"networkCorrelation":{"type":"string","pattern":"^[a-f0-9]{64}$","maxLength":64}}}}}}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["occurredAt", "adminActorId", "actionCategory", "approved"],
|
||||
"properties": {
|
||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
||||
"adminActorId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"actionCategory": { "type": "string", "enum": ["announcement", "teleport", "spawn", "kick", "ban", "unban", "restart", "config-review", "other"] },
|
||||
"targetPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"reason": { "type": "string", "minLength": 1, "maxLength": 256 },
|
||||
"approved": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["occurredAt", "playerId", "playerName", "channel", "message"],
|
||||
"properties": {
|
||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
||||
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"playerName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"channel": { "type": "string", "enum": ["local", "global", "squad", "admin", "unknown"] },
|
||||
"message": { "type": "string", "minLength": 1, "maxLength": 512 }
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["occurredAt", "victimPlayerId", "victimName", "weaponClass", "distanceMeters"],
|
||||
"properties": {
|
||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
||||
"killerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"killerName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"victimPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"victimName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"weaponClass": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"distanceMeters": { "type": "number", "minimum": 0, "maximum": 5000 },
|
||||
"suicide": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["occurredAt", "playerId", "playerName", "sessionId", "outcome"],
|
||||
"properties": {
|
||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
||||
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"playerName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"sessionId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"outcome": { "type": "string", "enum": ["accepted", "rejected"] },
|
||||
"networkFingerprint": { "type": "string", "minLength": 1, "maxLength": 128, "writeOnly": true, "description": "Transient source material for server-local irreversible correlation only; Platform never persists or returns this value." }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["occurredAt", "playerId", "playerName", "sessionId", "reason"],
|
||||
"properties": {
|
||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
||||
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"playerName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"sessionId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"reason": { "type": "string", "enum": ["disconnect", "timeout", "kicked", "server-stop", "unknown"] }
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["occurredAt", "playerId", "action", "mineClass", "zone", "suspicious"],
|
||||
"properties": {
|
||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
||||
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"action": { "type": "string", "enum": ["placed", "triggered", "detonated", "disarmed", "removed"] },
|
||||
"mineClass": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"zone": { "type": "string", "minLength": 1, "maxLength": 32 },
|
||||
"suspicious": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["occurredAt", "serverFps", "frameTimeMs", "onlinePlayers", "entityCount"],
|
||||
"properties": {
|
||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
||||
"serverFps": { "type": "number", "minimum": 0, "maximum": 1000 },
|
||||
"frameTimeMs": { "type": "number", "minimum": 0, "maximum": 1000 },
|
||||
"onlinePlayers": { "type": "integer", "minimum": 0, "maximum": 1000 },
|
||||
"entityCount": { "type": "integer", "minimum": 0, "maximum": 10000000 }
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["occurredAt", "playerId", "tradeKind", "itemCount", "currencyDelta", "suspicious"],
|
||||
"properties": {
|
||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
||||
"source": { "type": "string", "enum": ["companion", "log-projection", "scum.trade"] },
|
||||
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"counterpartyPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"itemCode": { "type": "string", "pattern": "^[A-Za-z0-9_.-]{1,128}$", "minLength": 1, "maxLength": 128 },
|
||||
"tradeVerb": { "type": "string", "enum": ["purchased", "sold"] },
|
||||
"tradeKind": { "type": "string", "enum": ["purchase", "sale", "transfer", "unknown"] },
|
||||
"quantity": { "type": "integer", "minimum": 0, "maximum": 1000000000 },
|
||||
"itemCount": { "type": "integer", "minimum": 0, "maximum": 1000 },
|
||||
"price": { "type": "integer", "minimum": -1000000000, "maximum": 1000000000 },
|
||||
"currencyDelta": { "type": "integer", "minimum": -1000000000, "maximum": 1000000000 },
|
||||
"suspicious": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["occurredAt", "playerId", "targetKind", "targetId", "outcome", "suspicious"],
|
||||
"properties": {
|
||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
||||
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
||||
"targetKind": { "type": "string", "enum": ["door", "container", "vehicle", "base", "unknown"] },
|
||||
"targetId": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"outcome": { "type": "string", "enum": ["success", "failed", "cancelled"] },
|
||||
"suspicious": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
@@ -17,19 +17,6 @@ function formatErrors(prefix: string, errors: ErrorObject[] | null | undefined):
|
||||
return (errors ?? []).map((error) => `${prefix}${error.instancePath}: ${error.message}`);
|
||||
}
|
||||
|
||||
function extractNamedCaptureNames(pattern: string): string[] {
|
||||
const captures: string[] = [];
|
||||
const capturePattern = /\(\?(?:P)?<([A-Za-z][A-Za-z0-9_]*)>/g;
|
||||
for (const match of pattern.matchAll(capturePattern)) {
|
||||
captures.push(match[1]);
|
||||
}
|
||||
return captures;
|
||||
}
|
||||
|
||||
function normalizeNamedCaptureSyntax(pattern: string): string {
|
||||
return pattern.replace(/\(\?P<([A-Za-z][A-Za-z0-9_]*)>/g, "(?<$1>");
|
||||
}
|
||||
|
||||
function unsafeFieldReason(fieldName: string): string | undefined {
|
||||
const compact = fieldName.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
if (compact.includes("rawapikey") || compact.includes("apikey") || compact.includes("providerkey")) {
|
||||
@@ -283,57 +270,6 @@ function validateBoundedBridgeSchema(value: unknown, location: string): string[]
|
||||
return errors;
|
||||
}
|
||||
|
||||
function unsafeSemanticLogEventTypeReason(value: string): string | undefined {
|
||||
const tokens = identifierTokens(value);
|
||||
const tokenSet = new Set(tokens);
|
||||
if (
|
||||
tokens.some((token) => ["shell", "powershell", "script", "terminal", "execute", "exec", "eval"].includes(token)) ||
|
||||
tokens.some((token) => ["credential", "password", "secret", "socket"].includes(token)) ||
|
||||
(tokenSet.has("run") && (tokenSet.has("direct") || tokenSet.has("socket"))) ||
|
||||
(tokenSet.has("path") && (tokenSet.has("host") || tokenSet.has("raw"))) ||
|
||||
(tokenSet.has("sql") && tokens.some((token) => ["query", "statement", "raw", "execute", "exec"].includes(token)))
|
||||
) {
|
||||
return "unsafe SQL, shell, path, credential, or socket event types are not allowed";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function validateBoundedSemanticLogSchema(value: unknown, location: string): string[] {
|
||||
const errors = validateBoundedBridgeSchema(value, location);
|
||||
const visit = (node: unknown, nodeLocation: string): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((item, index) => visit(item, `${nodeLocation}[${index}]`));
|
||||
return;
|
||||
}
|
||||
if (typeof node !== "object" || node === null) {
|
||||
return;
|
||||
}
|
||||
const record = node as Record<string, unknown>;
|
||||
if (record.type === "array") {
|
||||
if (!Number.isInteger(record.maxItems) || (record.maxItems as number) < 1 || (record.maxItems as number) > 1000) {
|
||||
errors.push(`${nodeLocation}.maxItems: bounded event arrays must set maxItems between 1 and 1000`);
|
||||
}
|
||||
}
|
||||
if (record.type === "string" && !Object.hasOwn(record, "enum") && !Object.hasOwn(record, "const")) {
|
||||
if (!Number.isInteger(record.maxLength) || (record.maxLength as number) < 1 || (record.maxLength as number) > 4096) {
|
||||
errors.push(`${nodeLocation}.maxLength: bounded event strings must set maxLength between 1 and 4096`);
|
||||
}
|
||||
}
|
||||
if (record.type === "integer" || record.type === "number") {
|
||||
if (typeof record.minimum !== "number" || !Number.isFinite(record.minimum) || typeof record.maximum !== "number" || !Number.isFinite(record.maximum)) {
|
||||
errors.push(`${nodeLocation}: bounded event numbers must set finite minimum and maximum values`);
|
||||
} else if (record.minimum > record.maximum) {
|
||||
errors.push(`${nodeLocation}: event number minimum must not exceed maximum`);
|
||||
}
|
||||
}
|
||||
for (const [key, child] of Object.entries(record)) {
|
||||
visit(child, `${nodeLocation}.${key}`);
|
||||
}
|
||||
};
|
||||
visit(value, location);
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateLifecycleActionFile(actionPath: string, expectedAction?: string): string[] {
|
||||
const action = readJson(path.resolve(rootDir, actionPath));
|
||||
const ajv = new Ajv2020({ allErrors: true });
|
||||
@@ -689,20 +625,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
timeoutSeconds?: number;
|
||||
pollIntervalSeconds?: number;
|
||||
};
|
||||
type BridgeLogProjectionTarget = { collection?: string; upsertKeys?: string[]; captureMappings?: Record<string, string>; fixedValues?: Record<string, string>; observedAtField?: string };
|
||||
type BridgeLogProjection = {
|
||||
key?: string;
|
||||
streamKeys?: string[];
|
||||
steps?: Array<{ pattern?: string }>;
|
||||
correlationFields?: string[];
|
||||
maxInterveningLines?: number;
|
||||
target?: BridgeLogProjectionTarget;
|
||||
presence?: {
|
||||
timestampField?: string;
|
||||
activeWindowSeconds?: number;
|
||||
activityTarget?: BridgeLogProjectionTarget;
|
||||
};
|
||||
};
|
||||
type BridgePage = { pageKey?: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] };
|
||||
type BridgeCompanion = {
|
||||
profileKey?: string;
|
||||
@@ -726,7 +648,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] };
|
||||
pages?: PluginPage[];
|
||||
runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[]; clientManagers?: RuntimeClientManager[] };
|
||||
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; logProjections?: BridgeLogProjection[]; pages?: BridgePage[]; companion?: BridgeCompanion };
|
||||
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion };
|
||||
};
|
||||
const bridge = declaration.gameClientBridge;
|
||||
if (!bridge) {
|
||||
@@ -736,7 +658,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
const commands = new Set<string>();
|
||||
const snapshots = new Set((bridge.snapshots ?? []).map((snapshot) => snapshot.type ?? ""));
|
||||
const queryTemplates = new Map<string, BridgeQueryTemplate>();
|
||||
const logProjections = new Set<string>();
|
||||
const declaredPermissions = new Set(declaration.permissions ?? []);
|
||||
const declaredCapabilities = new Set(declaration.capabilities ?? []);
|
||||
const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []);
|
||||
@@ -844,63 +765,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
errors.push(`${location}: sqlite query templates require the plugin and remote-access sqlite query capability`);
|
||||
}
|
||||
}
|
||||
const captureNamePattern = /^[A-Za-z][A-Za-z0-9_]{0,79}$/;
|
||||
const fieldNamePattern = /^[A-Za-z][A-Za-z0-9._-]{0,79}$/;
|
||||
const collectionPattern = /^[A-Za-z][A-Za-z0-9._-]{0,119}$/;
|
||||
const validateProjectionTarget = (location: string, target: BridgeLogProjectionTarget | undefined, captures: Set<string>): string[] => {
|
||||
const targetErrors: string[] = [];
|
||||
if (!target || !collectionPattern.test(target.collection ?? "")) targetErrors.push(`${location}.collection: must be a safe collection`);
|
||||
if (!Array.isArray(target?.upsertKeys) || target.upsertKeys.length < 1 || target.upsertKeys.length > 8 || !target.upsertKeys.every((key) => fieldNamePattern.test(key))) targetErrors.push(`${location}.upsertKeys: must contain 1 to 8 safe fields`);
|
||||
const mappings = target?.captureMappings;
|
||||
if (!mappings || Array.isArray(mappings) || Object.keys(mappings).length < 1 || Object.keys(mappings).length > 64) {
|
||||
targetErrors.push(`${location}.captureMappings: must contain 1 to 64 mappings`);
|
||||
} else {
|
||||
for (const [destination, capture] of Object.entries(mappings)) {
|
||||
if (!fieldNamePattern.test(destination) || typeof capture !== "string" || !captureNamePattern.test(capture)) targetErrors.push(`${location}.captureMappings: contains an invalid field or capture`);
|
||||
if (!captures.has(capture)) targetErrors.push(`${location}.captureMappings: references undeclared capture ${capture}`);
|
||||
}
|
||||
}
|
||||
const fixedValues = target?.fixedValues ?? {};
|
||||
if (Array.isArray(fixedValues) || Object.keys(fixedValues).length > 64 || !Object.entries(fixedValues).every(([destination, value]) => fieldNamePattern.test(destination) && typeof value === "string" && value.length <= 4096)) targetErrors.push(`${location}.fixedValues: contains an invalid field or value`);
|
||||
const declaredFields = new Set([...Object.keys(mappings ?? {}), ...Object.keys(fixedValues)]);
|
||||
if (Object.keys(mappings ?? {}).some((field) => Object.prototype.hasOwnProperty.call(fixedValues, field))) targetErrors.push(`${location}: a field cannot be declared by both captureMappings and fixedValues`);
|
||||
if (target?.observedAtField && (!fieldNamePattern.test(target.observedAtField) || declaredFields.has(target.observedAtField))) targetErrors.push(`${location}.observedAtField: must be a safe unique field`);
|
||||
if (target?.observedAtField) declaredFields.add(target.observedAtField);
|
||||
if (Array.isArray(target?.upsertKeys) && !target.upsertKeys.every((key) => declaredFields.has(key))) targetErrors.push(`${location}.upsertKeys: every key must be projected`);
|
||||
return targetErrors;
|
||||
};
|
||||
for (const [index, projection] of (bridge.logProjections ?? []).entries()) {
|
||||
const location = `manifest.gameClientBridge.logProjections[${index}]`;
|
||||
const key = projection.key ?? "";
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(key)) errors.push(`${location}.key: log projection key is unsafe`);
|
||||
if (logProjections.has(key)) errors.push(`${location}.key: duplicate log projection ${key}`);
|
||||
logProjections.add(key);
|
||||
if (!Array.isArray(projection.streamKeys) || projection.streamKeys.length < 1 || projection.streamKeys.length > 64 || new Set(projection.streamKeys).size !== projection.streamKeys.length || !projection.streamKeys.every((streamKey) => /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/.test(streamKey))) errors.push(`${location}.streamKeys: must contain 1 to 64 unique safe streams`);
|
||||
const captures = new Set<string>();
|
||||
if (!Array.isArray(projection.steps) || projection.steps.length < 1 || projection.steps.length > 64) {
|
||||
errors.push(`${location}.steps: must contain 1 to 64 regular expressions`);
|
||||
} else {
|
||||
for (const [stepIndex, step] of projection.steps.entries()) {
|
||||
try {
|
||||
if (!step.pattern || step.pattern.length > 16384) throw new Error("invalid");
|
||||
new RegExp(normalizeNamedCaptureSyntax(step.pattern));
|
||||
for (const capture of extractNamedCaptureNames(step.pattern)) captures.add(capture);
|
||||
} catch {
|
||||
errors.push(`${location}.steps[${stepIndex}].pattern: must be a valid bounded regular expression`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(projection.correlationFields) || projection.correlationFields.length < 1 || projection.correlationFields.length > 64 || new Set(projection.correlationFields).size !== projection.correlationFields.length || !projection.correlationFields.every((field) => captureNamePattern.test(field) && captures.has(field))) errors.push(`${location}.correlationFields: must reference unique named captures`);
|
||||
if (!Number.isInteger(projection.maxInterveningLines) || (projection.maxInterveningLines ?? -1) < 0 || (projection.maxInterveningLines ?? 0) > 100000) errors.push(`${location}.maxInterveningLines: must be between 0 and 100000`);
|
||||
errors.push(...validateProjectionTarget(`${location}.target`, projection.target, captures));
|
||||
const presence = projection.presence;
|
||||
if (!presence) continue;
|
||||
const target = projection.target;
|
||||
const targetFields = new Set([...Object.keys(target?.captureMappings ?? {}), ...Object.keys(target?.fixedValues ?? {}), ...(target?.observedAtField ? [target.observedAtField] : [])]);
|
||||
if (!fieldNamePattern.test(presence.timestampField ?? "") || !targetFields.has(presence.timestampField ?? "")) errors.push(`${location}.presence.timestampField: must reference a projected target field`);
|
||||
if (!Number.isInteger(presence.activeWindowSeconds) || (presence.activeWindowSeconds ?? 0) < 1 || (presence.activeWindowSeconds ?? 0) > 31536000) errors.push(`${location}.presence.activeWindowSeconds: must be between 1 and 31536000`);
|
||||
if (presence.activityTarget) errors.push(...validateProjectionTarget(`${location}.presence.activityTarget`, presence.activityTarget, captures));
|
||||
}
|
||||
for (const [index, page] of (bridge.pages ?? []).entries()) {
|
||||
for (const commandType of page.commandTypes ?? []) {
|
||||
if (!commands.has(commandType)) {
|
||||
@@ -932,7 +796,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
|
||||
function validateGameClientBridgeDataPacks(manifest: unknown, manifestDir: string, declaredAssets: Set<string>): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) return [];
|
||||
const dataPacks = (manifest as { gameClientBridge?: { dataPacks?: Array<{ key?: string; databaseUserVersion?: number; logParserRefs?: string[]; configMapRefs?: string[]; dataRefs?: string[] }> } }).gameClientBridge?.dataPacks ?? [];
|
||||
const dataPacks = (manifest as { gameClientBridge?: { dataPacks?: Array<{ key?: string; databaseUserVersion?: number; configMapRefs?: string[]; dataRefs?: string[] }> } }).gameClientBridge?.dataPacks ?? [];
|
||||
const errors: string[] = [];
|
||||
const keys = new Set<string>();
|
||||
for (const [index, dataPack] of dataPacks.entries()) {
|
||||
@@ -940,7 +804,7 @@ function validateGameClientBridgeDataPacks(manifest: unknown, manifestDir: strin
|
||||
if (!/^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(dataPack.key ?? "") || keys.has(dataPack.key ?? "")) errors.push(`${location}.key: must be a unique data-pack key`);
|
||||
keys.add(dataPack.key ?? "");
|
||||
if (!Number.isInteger(dataPack.databaseUserVersion) || (dataPack.databaseUserVersion ?? 0) < 1) errors.push(`${location}.databaseUserVersion: must be a positive SQLite user_version`);
|
||||
for (const field of ["logParserRefs", "configMapRefs", "dataRefs"] as const) {
|
||||
for (const field of ["configMapRefs", "dataRefs"] as const) {
|
||||
const refs = dataPack[field] ?? [];
|
||||
if (field !== "dataRefs" && refs.length === 0) errors.push(`${location}.${field}: must declare at least one package asset`);
|
||||
for (const ref of refs) {
|
||||
@@ -980,70 +844,6 @@ function validateGameClientBridgeSQLAssets(manifest: unknown, manifestDir: strin
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateRuntimeLogEventCatalog(manifest: unknown): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
type RuntimeLogSource = { key?: string; retentionDays?: number };
|
||||
type RuntimeLogEvent = {
|
||||
key?: string;
|
||||
sourceKey?: string;
|
||||
eventType?: string;
|
||||
permission?: string;
|
||||
schemaRef?: string;
|
||||
retentionDays?: number;
|
||||
severity?: string;
|
||||
};
|
||||
const declaration = manifest as {
|
||||
permissions?: string[];
|
||||
runtimeProfiles?: { logSources?: RuntimeLogSource[]; logEvents?: RuntimeLogEvent[] };
|
||||
};
|
||||
const logEvents = declaration.runtimeProfiles?.logEvents ?? [];
|
||||
const logSources = new Map((declaration.runtimeProfiles?.logSources ?? []).map((source) => [source.key ?? "", source]));
|
||||
const permissions = new Set(declaration.permissions ?? []);
|
||||
const keys = new Set<string>();
|
||||
const eventTypes = new Set<string>();
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const [index, event] of logEvents.entries()) {
|
||||
const location = `manifest.runtimeProfiles.logEvents[${index}]`;
|
||||
const key = event.key ?? "";
|
||||
const eventType = event.eventType ?? "";
|
||||
if (keys.has(key)) {
|
||||
errors.push(`${location}.key: duplicate semantic log event key ${key}`);
|
||||
}
|
||||
keys.add(key);
|
||||
if (eventTypes.has(eventType)) {
|
||||
errors.push(`${location}.eventType: duplicate semantic log event type ${eventType}`);
|
||||
}
|
||||
eventTypes.add(eventType);
|
||||
const unsafeTypeReason = unsafeSemanticLogEventTypeReason(eventType);
|
||||
if (unsafeTypeReason) {
|
||||
errors.push(`${location}.eventType: ${unsafeTypeReason}`);
|
||||
}
|
||||
const source = logSources.get(event.sourceKey ?? "");
|
||||
if (!source) {
|
||||
errors.push(`${location}.sourceKey: undeclared log source ${event.sourceKey ?? ""}`);
|
||||
}
|
||||
if (!event.permission || !permissions.has(event.permission)) {
|
||||
errors.push(`${location}.permission: permission must be declared by the plugin manifest`);
|
||||
}
|
||||
if (!event.schemaRef || !isSafeRelativeJsonRef(event.schemaRef)) {
|
||||
errors.push(`${location}.schemaRef: raw host paths and unsafe schema references are not allowed`);
|
||||
}
|
||||
if (!Number.isInteger(event.retentionDays) || (event.retentionDays ?? 0) < 1 || (event.retentionDays ?? 0) > 365) {
|
||||
errors.push(`${location}.retentionDays: must be an integer between 1 and 365`);
|
||||
}
|
||||
if (source?.retentionDays && (event.retentionDays ?? 0) > source.retentionDays) {
|
||||
errors.push(`${location}.retentionDays: must not exceed source retentionDays`);
|
||||
}
|
||||
if (!event.severity || !["info", "notice", "warning", "critical"].includes(event.severity)) {
|
||||
errors.push(`${location}.severity: must be info, notice, warning, or critical`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
type GameClientBridgeSchemaReference = {
|
||||
location: string;
|
||||
ref: string;
|
||||
@@ -1127,54 +927,6 @@ function validateGameClientBridgeSchemaFiles(manifest: unknown, manifestDir: str
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateRuntimeLogEventSchemaFiles(manifest: unknown, manifestDir: string): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
const logEvents = (manifest as { runtimeProfiles?: { logEvents?: Array<{ schemaRef?: string }> } }).runtimeProfiles?.logEvents ?? [];
|
||||
const errors: string[] = [];
|
||||
for (const [index, event] of logEvents.entries()) {
|
||||
const location = `manifest.runtimeProfiles.logEvents[${index}].schemaRef`;
|
||||
const ref = event.schemaRef;
|
||||
if (!ref || !isSafeRelativeJsonRef(ref)) {
|
||||
errors.push(`${location}: raw host paths and unsafe schema references are not allowed`);
|
||||
continue;
|
||||
}
|
||||
const schemaPath = path.resolve(manifestDir, ref);
|
||||
if (!fs.existsSync(schemaPath) || !fs.statSync(schemaPath).isFile()) {
|
||||
errors.push(`${location}: missing semantic log event schema file ${ref}`);
|
||||
continue;
|
||||
}
|
||||
const relativeRealPath = path.relative(fs.realpathSync(manifestDir), fs.realpathSync(schemaPath));
|
||||
if (relativeRealPath === ".." || relativeRealPath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRealPath)) {
|
||||
errors.push(`${location}: semantic log event schema must remain inside the plugin manifest directory`);
|
||||
continue;
|
||||
}
|
||||
let schema: unknown;
|
||||
try {
|
||||
schema = readJson(schemaPath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "invalid JSON";
|
||||
errors.push(`${location}: semantic log event schema is not valid JSON: ${message}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const schemaAjv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false });
|
||||
if (!schemaAjv.validateSchema(schema as AnySchema)) {
|
||||
errors.push(...formatErrors(`${location}.schema`, schemaAjv.errors));
|
||||
} else {
|
||||
schemaAjv.compile(schema as AnySchema);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "invalid JSON Schema";
|
||||
errors.push(`${location}: semantic log event schema is invalid: ${message}`);
|
||||
}
|
||||
errors.push(...scanUnsafeBridgeSchema(schema, `${location}.schema`));
|
||||
errors.push(...validateBoundedSemanticLogSchema(schema, `${location}.schema`));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
type CompanionConfigDeclaration = {
|
||||
profileKey?: string;
|
||||
configSchemaRef?: string;
|
||||
@@ -1352,8 +1104,6 @@ export function validateManifestFile(manifestPath: string): string[] {
|
||||
errors.push(...validateGameClientBridgeCatalog(manifest));
|
||||
errors.push(...validateGameClientBridgeSchemaFiles(manifest, manifestDir));
|
||||
errors.push(...validateGameClientBridgeCompanionConfig(manifest, manifestDir));
|
||||
errors.push(...validateRuntimeLogEventCatalog(manifest));
|
||||
errors.push(...validateRuntimeLogEventSchemaFiles(manifest, manifestDir));
|
||||
const assetValidation = validateManifestAssetFiles(manifest, manifestDir);
|
||||
errors.push(...assetValidation.errors);
|
||||
errors.push(...validateGameClientBridgeSQLAssets(manifest, manifestDir, assetValidation.declared));
|
||||
|
||||
+13
-44
@@ -266,38 +266,9 @@ export interface GameClientBridgeQueryProjectionDeclaration {
|
||||
mergeExisting?: boolean;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeLogProjectionStepDeclaration {
|
||||
pattern: string;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeLogProjectionTargetDeclaration {
|
||||
collection: string;
|
||||
upsertKeys: string[];
|
||||
captureMappings: Record<string, string>;
|
||||
fixedValues?: Record<string, string>;
|
||||
observedAtField?: string;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeLogProjectionPresenceDeclaration {
|
||||
timestampField: string;
|
||||
activeWindowSeconds: number;
|
||||
activityTarget?: GameClientBridgeLogProjectionTargetDeclaration;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeLogProjectionDeclaration {
|
||||
key: string;
|
||||
streamKeys: string[];
|
||||
steps: GameClientBridgeLogProjectionStepDeclaration[];
|
||||
correlationFields: string[];
|
||||
maxInterveningLines: number;
|
||||
target: GameClientBridgeLogProjectionTargetDeclaration;
|
||||
presence?: GameClientBridgeLogProjectionPresenceDeclaration;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeDataPackDeclaration {
|
||||
key: string;
|
||||
databaseUserVersion: number;
|
||||
logParserRefs: string[];
|
||||
configMapRefs: string[];
|
||||
}
|
||||
|
||||
@@ -331,7 +302,6 @@ export interface GameClientBridgeManifest {
|
||||
commands: GameClientBridgeCommandDeclaration[];
|
||||
snapshots: GameClientBridgeSnapshotDeclaration[];
|
||||
queryTemplates?: GameClientBridgeQueryTemplateDeclaration[];
|
||||
logProjections?: GameClientBridgeLogProjectionDeclaration[];
|
||||
dataPacks?: GameClientBridgeDataPackDeclaration[];
|
||||
commandRetentionSeconds: number;
|
||||
maxCommands: number;
|
||||
@@ -491,19 +461,6 @@ export interface RuntimeLogSource {
|
||||
retentionDays?: number;
|
||||
}
|
||||
|
||||
export type RuntimeLogEventSeverity = "info" | "notice" | "warning" | "critical";
|
||||
|
||||
export interface RuntimeLogEventDeclaration {
|
||||
key: string;
|
||||
title: string;
|
||||
sourceKey: string;
|
||||
eventType: string;
|
||||
permission: PluginPermission;
|
||||
schemaRef: string;
|
||||
retentionDays: number;
|
||||
severity: RuntimeLogEventSeverity;
|
||||
}
|
||||
|
||||
export interface RuntimeTransportProfile {
|
||||
key: string;
|
||||
kind: "file" | "ftp" | "rsync" | "mysql" | "sqlite" | "rcon";
|
||||
@@ -511,6 +468,18 @@ export interface RuntimeTransportProfile {
|
||||
capabilities: RunCapability[];
|
||||
}
|
||||
|
||||
export interface RuntimeDataTarget {
|
||||
key: string;
|
||||
kind: "sqlite.snapshot";
|
||||
transportKey: string;
|
||||
sourceRootKey: string;
|
||||
sourcePath: string;
|
||||
workspaceKey: string;
|
||||
refreshPolicy: "on-demand-snapshot";
|
||||
maxBytes: number;
|
||||
platforms?: RuntimePlatform[];
|
||||
}
|
||||
|
||||
export interface RuntimeClientManagerProfile {
|
||||
key: string;
|
||||
displayName?: string;
|
||||
@@ -588,8 +557,8 @@ export interface GamePluginRuntimeProfiles {
|
||||
dependencyProbes?: RuntimeDependencyProbe[];
|
||||
installPlans?: RuntimeInstallPlan[];
|
||||
logSources?: RuntimeLogSource[];
|
||||
logEvents?: RuntimeLogEventDeclaration[];
|
||||
transportProfiles?: RuntimeTransportProfile[];
|
||||
dataTargets?: RuntimeDataTarget[];
|
||||
clientManagers?: RuntimeClientManagerProfile[];
|
||||
dllExtensions?: RuntimeDLLExtensionProfile[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user