Implement SCUM direct data plane

This commit is contained in:
npc0-hue
2026-08-13 16:33:11 +08:00
parent b07a792784
commit 8b236d7c15
56 changed files with 1466 additions and 90 deletions
+81
View File
@@ -134,6 +134,10 @@ function isSafeRelativeJsonRef(value: string): boolean {
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value);
}
function isSafeRelativeSQLRef(value: string): boolean {
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)sql\/[a-zA-Z0-9_./-]+\.sql$/.test(value);
}
function isSafeRelativePathRef(value: string): boolean {
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+$/.test(value);
}
@@ -672,6 +676,10 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
targetKey?: string;
parameterSchemaRef?: string;
resultSchemaRef?: string;
sqlRef?: string;
targetTable?: string;
upsertKeys?: string[];
columnMappings?: Record<string, string>;
maxRows?: number;
timeoutSeconds?: number;
};
@@ -840,6 +848,24 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
}
}
const persistsSCUMRows = queryTemplate.targetTable !== undefined || queryTemplate.sqlRef !== undefined || queryTemplate.upsertKeys !== undefined || queryTemplate.columnMappings !== undefined;
if (persistsSCUMRows) {
if (!queryTemplate.sqlRef || !isSafeRelativeSQLRef(queryTemplate.sqlRef)) {
errors.push(`${location}.sqlRef: persisted SCUM queries must reference a package-relative .sql asset`);
}
if (!/^scum_[a-z][a-z0-9_]{0,62}$/.test(queryTemplate.targetTable ?? "")) {
errors.push(`${location}.targetTable: persisted SCUM queries must target a scum_* table`);
}
if (!Array.isArray(queryTemplate.upsertKeys) || queryTemplate.upsertKeys.length === 0 || !queryTemplate.upsertKeys.every((key) => /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(key))) {
errors.push(`${location}.upsertKeys: persisted SCUM queries require non-empty safe keys`);
}
const mappings = queryTemplate.columnMappings;
if (!mappings || typeof mappings !== "object" || Array.isArray(mappings) || Object.keys(mappings).length === 0 || !Object.entries(mappings).every(([target, source]) => /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(target) && typeof source === "string" && /^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(source))) {
errors.push(`${location}.columnMappings: persisted SCUM queries require safe target-to-source mappings`);
} else if (Array.isArray(queryTemplate.upsertKeys) && !queryTemplate.upsertKeys.every((key) => key in mappings)) {
errors.push(`${location}.upsertKeys: every upsert key must be declared in columnMappings`);
}
}
if (!Number.isInteger(queryTemplate.maxRows) || (queryTemplate.maxRows ?? 0) < 1 || (queryTemplate.maxRows ?? 0) > 500) {
errors.push(`${location}.maxRows: must be an integer between 1 and 500`);
}
@@ -990,6 +1016,59 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
return errors;
}
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[] }> } }).gameClientBridge?.dataPacks ?? [];
const errors: string[] = [];
const keys = new Set<string>();
for (const [index, dataPack] of dataPacks.entries()) {
const location = `manifest.gameClientBridge.dataPacks[${index}]`;
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 safe 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"] as const) {
const refs = dataPack[field];
if (!Array.isArray(refs) || refs.length === 0) {
errors.push(`${location}.${field}: must declare at least one package mapping asset`);
continue;
}
for (const ref of refs) {
if (!isSafeRelativeJsonRef(ref)) {
errors.push(`${location}.${field}: must use package-relative JSON assets`);
continue;
}
if (!declaredAssets.has(ref)) errors.push(`${location}.${field}: ${ref} must be declared in manifest.assetFiles`);
const target = path.resolve(manifestDir, ref);
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) errors.push(`${location}.${field}: missing package mapping asset ${ref}`);
}
}
}
return errors;
}
function validateGameClientBridgeSQLAssets(manifest: unknown, manifestDir: string, declaredAssets: Set<string>): string[] {
if (typeof manifest !== "object" || manifest === null) return [];
const templates = (manifest as { gameClientBridge?: { queryTemplates?: Array<{ sqlRef?: string }> } }).gameClientBridge?.queryTemplates ?? [];
const errors: string[] = [];
for (const [index, template] of templates.entries()) {
if (!template.sqlRef) continue;
const location = `manifest.gameClientBridge.queryTemplates[${index}].sqlRef`;
if (!isSafeRelativeSQLRef(template.sqlRef)) {
errors.push(`${location}: must be a package-relative .sql asset`);
continue;
}
if (!declaredAssets.has(template.sqlRef)) errors.push(`${location}: ${template.sqlRef} must be declared in manifest.assetFiles`);
const assetPath = path.resolve(manifestDir, template.sqlRef);
if (!fs.existsSync(assetPath) || !fs.statSync(assetPath).isFile()) {
errors.push(`${location}: missing SQL asset ${template.sqlRef}`);
continue;
}
const body = fs.readFileSync(assetPath, "utf8").trim();
if (!/^select\b/i.test(body) || /;\s*\S/.test(body) || /\b(?:insert|update|delete|drop|alter|create|attach|pragma)\b/i.test(body)) errors.push(`${location}: SQL assets must contain one read-only SELECT statement`);
}
return errors;
}
export function validateRuntimeLogEventCatalog(manifest: unknown): string[] {
if (typeof manifest !== "object" || manifest === null) {
return [];
@@ -1378,6 +1457,8 @@ export function validateManifestFile(manifestPath: string): string[] {
errors.push(...validateRuntimeLogEventSchemaFiles(manifest, manifestDir));
const assetValidation = validateManifestAssetFiles(manifest, manifestDir);
errors.push(...assetValidation.errors);
errors.push(...validateGameClientBridgeSQLAssets(manifest, manifestDir, assetValidation.declared));
errors.push(...validateGameClientBridgeDataPacks(manifest, manifestDir, assetValidation.declared));
for (const declaration of referencedLifecycleActions(manifest)) {
if (!isSafeRelativeJsonRef(declaration.ref)) {