Add SCUM query asset boundary tests

This commit is contained in:
npc0-hue
2026-08-13 09:43:27 +08:00
parent e313081204
commit 22c4cb21bb
2 changed files with 133 additions and 6 deletions
@@ -61,7 +61,7 @@
- [x] 3.4 Implement the versioned SCUM login/logout parser and tests for successful login/logout, failed login, partial/undecodable/oversized/malformed lines, copy-truncate/rotation overlap under a new generation, Run restart/resume, duplicate delivery, and out-of-order delivery while discarding IP/network material before storage or logical fingerprinting.
- [x] 3.5 Add parameterized, read-only player identity/detail/economy/session-enrichment query assets and exact result schemas only for joins and fields proven by the probe.
- [x] 3.6 Add parameterized squad/member, vehicle, flag/territory, and position query assets and exact result schemas, keeping ambiguous ranks, ownership, coordinates, and missing numeric values null.
- [ ] 3.7 Add query-asset tests for single SELECT/CTE or approved introspection boundaries, parameter binding, pagination/cursors, timeout/row/byte limits, schema-version matching, and rejection of DDL, mutation, `ATTACH`, extension loading, write PRAGMAs, and multi-statement input.
- [x] 3.7 Add query-asset tests for single SELECT/CTE or approved introspection boundaries, parameter binding, pagination/cursors, timeout/row/byte limits, schema-version matching, and rejection of DDL, mutation, `ATTACH`, extension loading, write PRAGMAs, and multi-statement input.
- [ ] 3.8 Package the authorized SCUM map asset, identity/version, verified world bounds, layer metadata, and coordinate transform, with fixture tests for known points, out-of-bounds/non-finite coordinates, and adapter incompatibility.
- [ ] 3.9 Declare only verified typed RCON templates for supported Fame/currency/notification/gift operations and a version-scoped gift item catalog; omit any command whose execution and confirmation semantics remain unknown.
- [ ] 3.10 Declare a guarded preserving XML mutation only after the real XML source and named attributes are proven; expose `855` only as a reviewed named-attribute preset and never as a database column, generic integer field, or guessed mapping.
+133 -6
View File
@@ -80,6 +80,68 @@ function writeFixtureJSON(fixtureDir: string, relativePath: string, value: unkno
fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
const currentSCUMSchemaFingerprint = "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0";
const scumSQLiteQueryAssetVersion = "scum-sqlite-query-v1";
const scumPaginatedQueryKeys = new Set(["scum-players-read", "scum-squads-read", "scum-squad-members-read", "scum-vehicles-read", "scum-flags-read", "scum-positions-read"]);
const unsafeSCUMSQLiteReadStatementPattern = /;|\b(INSERT|UPDATE|DELETE|REPLACE|DROP|ALTER|CREATE|VACUUM|ATTACH|DETACH|ANALYZE|REINDEX)\b|\bload_extension\s*\(|\bPRAGMA\s+(?!(?:table_info|foreign_key_list|index_list|index_info|schema_version|data_version)\b)/i;
type SCUMSQLiteQueryAssetParameter = {
name: string;
binding: string;
type?: string;
required?: boolean;
nullable?: boolean;
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
enum?: string[];
};
type SCUMSQLiteQueryAsset = {
key?: string;
adapterVersion?: string;
queryVersion?: string;
capability?: string;
requiredSchemaFingerprint?: string;
statementType?: string;
statement?: string;
parameters?: SCUMSQLiteQueryAssetParameter[];
safety?: {
queryOnly?: boolean;
readOnlyConnection?: boolean;
forbidMultipleStatements?: boolean;
forbidAttach?: boolean;
forbidWritePragmas?: boolean;
forbidExtensionLoading?: boolean;
maxRows?: number;
timeoutMs?: number;
maxResultBytes?: number;
};
};
type JSONSchemaObject = {
type?: unknown;
additionalProperties?: boolean;
required?: string[];
properties?: Record<string, Record<string, unknown>>;
};
function sortedValues(values: Iterable<string>): string[] {
return [...values].sort((left, right) => left.localeCompare(right));
}
function extractSQLiteParameterBindings(statement: string): string[] {
return [...statement.matchAll(/:[A-Za-z_][A-Za-z0-9_]*/g)].map((match) => match[0]);
}
function expectSCUMSQLiteReadStatementBoundary(statementType: string | undefined, statement: string | undefined): void {
expect(statementType).toBe("single-select-or-cte");
const trimmed = statement?.trim() ?? "";
expect(trimmed).toMatch(/^(WITH|SELECT)\b/i);
expect(trimmed).not.toMatch(unsafeSCUMSQLiteReadStatementPattern);
}
function sha256FixtureDigest(fixtureDir: string, relativePath: string): string {
return `sha256:${crypto.createHash("sha256").update(fs.readFileSync(path.join(fixtureDir, relativePath))).digest("hex")}`;
}
@@ -223,6 +285,7 @@ describe("plugin manifest validation", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as GamePluginManifest & { scumLiveData: SCUMLiveDataManifestDeclaration };
const assetPaths = new Set(manifest.assetFiles?.map((file) => file.path) ?? []);
const queries = manifest.scumLiveData.sqliteQueries ?? [];
expect(manifest.scumLiveData.schemaVersion).toBe("1");
expect(queries.map((query) => query.key)).toEqual([
"scum-players-read",
"scum-player-details-read",
@@ -240,11 +303,21 @@ describe("plugin manifest validation", () => {
const assetPath = path.join(pluginDir, query.assetPath);
const actualDigest = `sha256:${crypto.createHash("sha256").update(fs.readFileSync(assetPath)).digest("hex")}`;
expect(query.digest).toBe(actualDigest);
const asset = JSON.parse(fs.readFileSync(assetPath, "utf8")) as { requiredSchemaFingerprint?: string; statement?: string; safety?: { queryOnly?: boolean; readOnlyConnection?: boolean } };
const asset = JSON.parse(fs.readFileSync(assetPath, "utf8")) as SCUMSQLiteQueryAsset;
expect(asset).toMatchObject({ key: `${query.key}-v1`, adapterVersion: query.adapterVersion, queryVersion: scumSQLiteQueryAssetVersion, capability: query.capability });
expect(query.requiredSchemaFingerprint).toBe(currentSCUMSchemaFingerprint);
expect(asset.requiredSchemaFingerprint).toBe(query.requiredSchemaFingerprint);
expect(asset.safety).toMatchObject({ queryOnly: true, readOnlyConnection: true });
expect(asset.statement).toMatch(/^(WITH|SELECT)\b/i);
expect(asset.statement).not.toMatch(/;|\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|ATTACH)\b/i);
expectSCUMSQLiteReadStatementBoundary(asset.statementType, asset.statement);
expect(query.maxRows).toBeGreaterThan(0);
expect(query.maxRows).toBeLessThanOrEqual(500);
expect(query.timeoutMs).toBeGreaterThan(0);
expect(query.timeoutMs).toBeLessThanOrEqual(60000);
expect(query.maxResultBytes).toBeGreaterThan(0);
expect(query.maxResultBytes).toBeLessThanOrEqual(1048576);
expect(asset.safety).toMatchObject({ queryOnly: true, readOnlyConnection: true, forbidMultipleStatements: true, forbidAttach: true, forbidWritePragmas: true, forbidExtensionLoading: true });
expect(asset.safety?.maxRows).toBeLessThanOrEqual(query.maxRows);
expect(asset.safety?.timeoutMs).toBeLessThanOrEqual(query.timeoutMs);
expect(asset.safety?.maxResultBytes).toBeLessThanOrEqual(query.maxResultBytes);
if (["scum-squads-read", "scum-squad-members-read", "scum-vehicles-read", "scum-flags-read"].includes(query.key)) {
expect(asset.statement).toContain("CAST(NULL");
}
@@ -252,9 +325,63 @@ describe("plugin manifest validation", () => {
for (const schemaRef of [query.parameterSchemaRef, query.resultSchemaRef]) {
expect(fs.existsSync(path.join(pluginDir, schemaRef))).toBe(true);
}
const resultSchema = JSON.parse(fs.readFileSync(path.join(pluginDir, query.resultSchemaRef), "utf8")) as { properties?: { rows?: { maxItems?: number } } };
expect(resultSchema.properties?.rows?.maxItems).toBeLessThanOrEqual(query.maxRows);
const parameterSchema = JSON.parse(fs.readFileSync(path.join(pluginDir, query.parameterSchemaRef), "utf8")) as JSONSchemaObject;
const parameters = asset.parameters ?? [];
const parameterNames = parameters.map((parameter) => parameter.name);
const parameterBindings = new Set(extractSQLiteParameterBindings(asset.statement ?? ""));
expect(parameterSchema).toMatchObject({ type: "object", additionalProperties: false });
expect(new Set(parameterNames).size).toBe(parameterNames.length);
expect(sortedValues(parameterSchema.required ?? [])).toEqual(sortedValues(parameterNames));
expect(sortedValues(Object.keys(parameterSchema.properties ?? {}))).toEqual(sortedValues(parameterNames));
for (const parameter of parameters) {
expect(parameter.required).toBe(true);
expect(parameter.binding).toBe(`:${parameter.name}`);
expect(parameterBindings.has(parameter.binding)).toBe(true);
const schemaProperty = parameterSchema.properties?.[parameter.name] ?? {};
if (parameter.enum !== undefined) {
expect(schemaProperty.enum).toEqual(parameter.enum);
if (schemaProperty.type !== undefined) expect(schemaProperty.type).toBe(parameter.type);
} else if (parameter.type !== undefined) {
expect(schemaProperty.type).toEqual(parameter.nullable ? [parameter.type, "null"] : parameter.type);
}
for (const bound of ["minimum", "maximum", "minLength", "maxLength"] as const) {
if (parameter[bound] !== undefined) expect(schemaProperty[bound]).toBe(parameter[bound]);
}
}
expect(sortedValues(parameterBindings)).toEqual(sortedValues(parameters.map((parameter) => parameter.binding)));
if (scumPaginatedQueryKeys.has(query.key)) {
const limit = parameters.find((parameter) => parameter.name === "limit");
const offset = parameters.find((parameter) => parameter.name === "offset");
expect(limit).toMatchObject({ binding: ":limit", type: "integer", required: true, minimum: 1 });
expect(limit?.maximum).toBeLessThanOrEqual(query.maxRows);
expect(offset).toMatchObject({ binding: ":offset", type: "integer", required: true, minimum: 0 });
expect(offset?.maximum).toBeLessThanOrEqual(1000000);
expect(asset.statement).toMatch(/\bLIMIT\s+:limit\b/i);
expect(asset.statement).toMatch(/\bOFFSET\s+:offset\b/i);
}
const resultSchema = JSON.parse(fs.readFileSync(path.join(pluginDir, query.resultSchemaRef), "utf8")) as { properties?: { rows?: { maxItems?: number } } };
expect(resultSchema.properties?.rows?.maxItems).toBeLessThanOrEqual(asset.safety?.maxRows ?? query.maxRows);
}
});
it("guards SCUM live query asset SQL boundary rules against unsafe input classes", () => {
const rejectedStatements = [
"SELECT 1; SELECT 2",
"INSERT INTO players VALUES (1)",
"UPDATE players SET name = 'x'",
"DELETE FROM players",
"DROP TABLE players",
"ALTER TABLE players ADD COLUMN x INTEGER",
"CREATE TABLE unsafe (id INTEGER)",
"ATTACH DATABASE 'other.db' AS other",
"DETACH DATABASE other",
"SELECT load_extension('unsafe')",
"PRAGMA journal_mode = WAL",
"PRAGMA writable_schema = ON"
];
const allowedReadBoundaries = ["SELECT 1", "WITH one AS (SELECT 1) SELECT * FROM one", "PRAGMA table_info('players')", "PRAGMA foreign_key_list('players')"];
for (const statement of rejectedStatements) expect(statement).toMatch(unsafeSCUMSQLiteReadStatementPattern);
for (const statement of allowedReadBoundaries) expect(statement).not.toMatch(unsafeSCUMSQLiteReadStatementPattern);
});
it("requires SCUM schema probe targets to have generated Run workspace data targets", () => {