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
+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));