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,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 +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" }
}
}
+3 -253
View File
@@ -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
View File
@@ -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[];
}