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