Implement SCUM direct data plane
This commit is contained in:
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user