From 22c4cb21bb13a37ba8abb41c6f34897de3c6c8f8 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Thu, 13 Aug 2026 09:43:27 +0800 Subject: [PATCH] Add SCUM query asset boundary tests --- .../tasks.md | 2 +- plugins/tests/manifest-validation.test.ts | 137 +++++++++++++++++- 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md b/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md index abc04cc..1cd124f 100644 --- a/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md +++ b/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md @@ -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. diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 847cdb8..a318438 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -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>; +}; + +function sortedValues(values: Iterable): 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,11 +325,65 @@ describe("plugin manifest validation", () => { for (const schemaRef of [query.parameterSchemaRef, query.resultSchemaRef]) { expect(fs.existsSync(path.join(pluginDir, schemaRef))).toBe(true); } + 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(query.maxRows); + 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", () => { const errors = validateTemporaryScumCompanionManifest((manifest) => { manifest.runtimeProfiles.dataTargets = [];