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 986d4b2..3034950 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 @@ -29,7 +29,7 @@ - [ ] 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. -- [ ] 3.11 Add immutable asset/digest declarations and plugin package validation; defer generated Run-package execution wiring until the complete protocol/result envelope and independent Run capability evidence in group 4 are frozen. +- [x] 3.11 Add immutable asset/digest declarations and plugin package validation; defer generated Run-package execution wiring until the complete protocol/result envelope and independent Run capability evidence in group 4 are frozen. - [x] 3.12 Remove SCUM Workflow/projection declarations and obsolete page/action declarations from the plugin manifest while preserving the five required pages and AI configuration assistance. - [ ] 3.13 Match the observed fingerprint/evidence matrix against each completed adapter, add per-capability compatibility/release-gate tests, and leave every unsupported or ambiguous player/squad/vehicle/flag/position/write capability disabled. diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index 6dc1973..fcac71b 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import crypto from "node:crypto"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -13,6 +14,10 @@ function readJson(filePath: string): unknown { return JSON.parse(fs.readFileSync(filePath, "utf8")); } +function sha256FileDigest(filePath: string): string { + return `sha256:${crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex")}`; +} + function formatErrors(prefix: string, errors: ErrorObject[] | null | undefined): string[] { return (errors ?? []).map((error) => `${prefix}${error.instancePath}: ${error.message}`); } @@ -1054,7 +1059,7 @@ export function validateRuntimeLogEventCatalog(manifest: unknown): string[] { return errors; } -export function validateSCUMLiveDataManifest(manifest: unknown): string[] { +export function validateSCUMLiveDataManifest(manifest: unknown, manifestDir?: string): string[] { if (typeof manifest !== "object" || manifest === null) return []; type Gate = { capability?: string; gate?: string; adapterVersion?: string; requiredSchemaFingerprint?: string; requiredAssetDigests?: string[]; evidenceStatus?: string; safeReason?: string }; type Probe = { capability?: string; targetKey?: string; bounds?: { maxSampleRows?: number; timeoutMs?: number; maxResultBytes?: number } }; @@ -1127,6 +1132,21 @@ export function validateSCUMLiveDataManifest(manifest: unknown): string[] { if (!asset.assetPath || !isSafeRelativePathRef(asset.assetPath)) errors.push(`${assetLocation}.assetPath: must be a contained package-relative path`); if (asset.assetPath && !assetFiles.has(asset.assetPath)) errors.push(`${assetLocation}.assetPath: must be declared in manifest.assetFiles`); if (!digestPattern.test(asset.digest ?? "")) errors.push(`${assetLocation}.digest: must be a sha256 digest`); + if (asset.assetPath && asset.digest) validateSCUMAssetDigest(asset.assetPath, asset.digest, `${assetLocation}.digest`); + }; + const validateSCUMAssetDigest = (assetPath: string, expectedDigest: string, digestLocation: string): void => { + if (!manifestDir || !isSafeRelativePathRef(assetPath) || !digestPattern.test(expectedDigest)) return; + const absoluteAssetPath = path.resolve(manifestDir, assetPath); + if (!fs.existsSync(absoluteAssetPath) || !fs.statSync(absoluteAssetPath).isFile()) { + errors.push(`${digestLocation}: missing packaged asset file ${assetPath}`); + return; + } + const relativeRealPath = path.relative(fs.realpathSync(manifestDir), fs.realpathSync(absoluteAssetPath)); + if (relativeRealPath === ".." || relativeRealPath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRealPath)) { + errors.push(`${digestLocation}: asset file must remain inside the plugin manifest directory`); + return; + } + if (sha256FileDigest(absoluteAssetPath) !== expectedDigest) errors.push(`${digestLocation}: digest does not match packaged asset content`); }; const requireUniqueAssetKeys = (assets: Asset[] | undefined, collection: string): void => { const seenKeys = new Set(); @@ -1218,6 +1238,7 @@ export function validateSCUMLiveDataManifest(manifest: unknown): string[] { if (!mapAsset.transformAssetPath || !isSafeRelativePathRef(mapAsset.transformAssetPath)) errors.push(`${itemLocation}.transformAssetPath: must be a contained package-relative path`); if (mapAsset.transformAssetPath && !assetFiles.has(mapAsset.transformAssetPath)) errors.push(`${itemLocation}.transformAssetPath: must be declared in manifest.assetFiles`); if (!digestPattern.test(mapAsset.transformDigest ?? "")) errors.push(`${itemLocation}.transformDigest: must be a sha256 digest`); + if (mapAsset.transformAssetPath && mapAsset.transformDigest) validateSCUMAssetDigest(mapAsset.transformAssetPath, mapAsset.transformDigest, `${itemLocation}.transformDigest`); const bounds = mapAsset.worldBounds; if (!bounds || !Number.isFinite(bounds.minX) || !Number.isFinite(bounds.minY) || !Number.isFinite(bounds.maxX) || !Number.isFinite(bounds.maxY) || (bounds.minX ?? 0) >= (bounds.maxX ?? 0) || (bounds.minY ?? 0) >= (bounds.maxY ?? 0)) errors.push(`${itemLocation}.worldBounds: must define finite increasing bounds`); if (!Number.isInteger(mapAsset.image?.width) || (mapAsset.image?.width ?? 0) < 1 || !Number.isInteger(mapAsset.image?.height) || (mapAsset.image?.height ?? 0) < 1) errors.push(`${itemLocation}.image: width and height must be positive integers`); @@ -1554,7 +1575,7 @@ export function validateManifestFile(manifestPath: string): string[] { errors.push(...validateClientManagerProfiles(manifest)); errors.push(...validateDLLExtensionProfiles(manifest)); errors.push(...validateGameClientBridgeCatalog(manifest)); - errors.push(...validateSCUMLiveDataManifest(manifest)); + errors.push(...validateSCUMLiveDataManifest(manifest, manifestDir)); errors.push(...validateGameClientBridgeSchemaFiles(manifest, manifestDir)); errors.push(...validateGameClientBridgeCompanionConfig(manifest, manifestDir)); errors.push(...validateRuntimeLogEventCatalog(manifest)); diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index d55bb59..e592617 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -79,6 +80,10 @@ function writeFixtureJSON(fixtureDir: string, relativePath: string, value: unkno fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } +function sha256FixtureDigest(fixtureDir: string, relativePath: string): string { + return `sha256:${crypto.createHash("sha256").update(fs.readFileSync(path.join(fixtureDir, relativePath))).digest("hex")}`; +} + function listProductionGoFiles(directory: string): string[] { return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const target = path.join(directory, entry.name); @@ -222,7 +227,6 @@ describe("plugin manifest validation", () => { }); it("accepts safe SCUM live-data asset declarations without enabling unproven capabilities", () => { - const digest = (char: string) => `sha256:${char.repeat(64)}`; const fingerprint = `sha256:${"f".repeat(64)}`; const assetPaths = [ "assets/scum-live/login-parser.json", @@ -236,20 +240,31 @@ describe("plugin manifest validation", () => { const errors = validateTemporaryScumCompanionManifest((manifest, fixtureDir) => { manifest.assetFiles = [...manifest.assetFiles, ...assetPaths.map((assetPath) => ({ path: assetPath, mode: 384 }))]; for (const assetPath of assetPaths) writeFixtureJSON(fixtureDir, assetPath, { packaged: true }); + const digest = (assetPath: string) => sha256FixtureDigest(fixtureDir, assetPath); manifest.scumLiveData = { ...manifest.scumLiveData, - logParsers: [{ key: "login-parser", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[0], digest: digest("a"), parserVersion: "login-v1", sourceKey: "scum-login-events", eventType: "scum.login", eventSchemaRef: "schemas/scum-live/login-event.schema.json", maxLineBytes: 4096, cursorPolicy: "source-generation-sequence", privacy: { stripNetworkIdentifiers: true, logicalEventIdentity: "native-or-sanitized-fields" } }], - sqliteQueries: [{ key: "players-read", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[1], digest: digest("b"), capability: "players.read", requiredSchemaFingerprint: fingerprint, transportKey: "scum-database", targetKey: "scum-database", parameterSchemaRef: "schemas/scum-live/players-read.parameters.schema.json", resultSchemaRef: "schemas/scum-live/players-read.result.schema.json", maxRows: 100, timeoutMs: 5000, maxResultBytes: 65536 }], + logParsers: [{ key: "login-parser", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[0], digest: digest(assetPaths[0]), parserVersion: "login-v1", sourceKey: "scum-login-events", eventType: "scum.login", eventSchemaRef: "schemas/scum-live/login-event.schema.json", maxLineBytes: 4096, cursorPolicy: "source-generation-sequence", privacy: { stripNetworkIdentifiers: true, logicalEventIdentity: "native-or-sanitized-fields" } }], + sqliteQueries: [{ key: "players-read", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[1], digest: digest(assetPaths[1]), capability: "players.read", requiredSchemaFingerprint: fingerprint, transportKey: "scum-database", targetKey: "scum-database", parameterSchemaRef: "schemas/scum-live/players-read.parameters.schema.json", resultSchemaRef: "schemas/scum-live/players-read.result.schema.json", maxRows: 100, timeoutMs: 5000, maxResultBytes: 65536 }], syncCadences: [{ capability: "players.read", intervalSeconds: 300, jitterPercent: 20, timeoutMs: 5000, maxConcurrentPerServer: 1 }], - typedRconTemplates: [{ key: "gift-grant", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[2], digest: digest("c"), capability: "gift-command.write", requiredSchemaFingerprint: fingerprint, transportKey: "scum-management", targetKey: "scum-management", permission: "server.game-client.command", payloadSchemaRef: "schemas/scum-live/gift-grant.payload.schema.json", resultSchemaRef: "schemas/scum-live/gift-grant.result.schema.json", confirmationSchemaRef: "schemas/scum-live/gift-grant.confirmation.schema.json", timeoutMs: 5000, maxPayloadBytes: 4096 }], - guardedMutations: [{ key: "profile-xml-patch", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[3], digest: digest("d"), capability: "profile-xml.write", requiredSchemaFingerprint: fingerprint, transportKey: "scum-database", targetKey: "scum-database", permission: "server.game-client.maintenance", payloadSchemaRef: "schemas/scum-live/profile-xml-patch.payload.schema.json", resultSchemaRef: "schemas/scum-live/profile-xml-patch.result.schema.json", confirmationSchemaRef: "schemas/scum-live/profile-xml-patch.confirmation.schema.json", timeoutMs: 10000, maxPayloadBytes: 8192, maxRowsAffected: 1, safety: { requiresExpectedChecksum: true, requiresBackupEvidence: true, requiresOfflineOrMaintenance: true, requiresReadAfterWrite: true } }], - mapAssets: [{ key: "island-map", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[4], digest: digest("e"), requiredSchemaFingerprint: fingerprint, metadataSchemaRef: "schemas/scum-live/map-metadata.schema.json", transformAssetPath: assetPaths[5], transformDigest: digest("1"), worldBounds: { minX: -100000, minY: -100000, maxX: 100000, maxY: 100000 }, image: { width: 4096, height: 4096 } }], - giftCatalogs: [{ key: "starter-gifts", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[6], digest: digest("2"), catalogVersion: "catalog-v1", itemSchemaRef: "schemas/scum-live/gift-item.schema.json", transportTemplateKeys: ["gift-grant"] }] + typedRconTemplates: [{ key: "gift-grant", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[2], digest: digest(assetPaths[2]), capability: "gift-command.write", requiredSchemaFingerprint: fingerprint, transportKey: "scum-management", targetKey: "scum-management", permission: "server.game-client.command", payloadSchemaRef: "schemas/scum-live/gift-grant.payload.schema.json", resultSchemaRef: "schemas/scum-live/gift-grant.result.schema.json", confirmationSchemaRef: "schemas/scum-live/gift-grant.confirmation.schema.json", timeoutMs: 5000, maxPayloadBytes: 4096 }], + guardedMutations: [{ key: "profile-xml-patch", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[3], digest: digest(assetPaths[3]), capability: "profile-xml.write", requiredSchemaFingerprint: fingerprint, transportKey: "scum-database", targetKey: "scum-database", permission: "server.game-client.maintenance", payloadSchemaRef: "schemas/scum-live/profile-xml-patch.payload.schema.json", resultSchemaRef: "schemas/scum-live/profile-xml-patch.result.schema.json", confirmationSchemaRef: "schemas/scum-live/profile-xml-patch.confirmation.schema.json", timeoutMs: 10000, maxPayloadBytes: 8192, maxRowsAffected: 1, safety: { requiresExpectedChecksum: true, requiresBackupEvidence: true, requiresOfflineOrMaintenance: true, requiresReadAfterWrite: true } }], + mapAssets: [{ key: "island-map", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[4], digest: digest(assetPaths[4]), requiredSchemaFingerprint: fingerprint, metadataSchemaRef: "schemas/scum-live/map-metadata.schema.json", transformAssetPath: assetPaths[5], transformDigest: digest(assetPaths[5]), worldBounds: { minX: -100000, minY: -100000, maxX: 100000, maxY: 100000 }, image: { width: 4096, height: 4096 } }], + giftCatalogs: [{ key: "starter-gifts", adapterVersion: "scum-live-data-v1", assetPath: assetPaths[6], digest: digest(assetPaths[6]), catalogVersion: "catalog-v1", itemSchemaRef: "schemas/scum-live/gift-item.schema.json", transportTemplateKeys: ["gift-grant"] }] }; }); expect(errors).toEqual([]); }); + it("rejects SCUM live-data asset digests that do not match packaged files", () => { + const errors = validateTemporaryScumCompanionManifest((manifest, fixtureDir) => { + const assetPath = "assets/scum-live/login-parser.json"; + manifest.assetFiles = [...manifest.assetFiles, { path: assetPath, mode: 384 }]; + writeFixtureJSON(fixtureDir, assetPath, { packaged: true }); + manifest.scumLiveData.logParsers = [{ key: "login-parser", adapterVersion: "scum-live-data-v1", assetPath, digest: `sha256:${"a".repeat(64)}`, parserVersion: "login-v1", sourceKey: "scum-login-events", eventType: "scum.login", eventSchemaRef: "schemas/scum-live/login-event.schema.json", maxLineBytes: 4096, cursorPolicy: "source-generation-sequence", privacy: { stripNetworkIdentifiers: true, logicalEventIdentity: "native-or-sanitized-fields" } }]; + }); + expect(errors.some((error) => error.includes("digest does not match packaged asset content"))).toBe(true); + }); + it("rejects unsafe SCUM live-data asset declarations", () => { const errors = validateTemporaryScumCompanionManifest((manifest) => { manifest.scumLiveData.sqliteQueries = [{ key: "players-read", adapterVersion: "scum-live-data-v1", assetPath: "/srv/scum/SCUM.db", digest: "sha256:bad", capability: "players.read", requiredSchemaFingerprint: "sha256:bad", transportKey: "scum-database", targetKey: "scum-database", parameterSchemaRef: "schemas/scum-live/players-read.parameters.schema.json", resultSchemaRef: "schemas/scum-live/players-read.result.schema.json", maxRows: 1000, timeoutMs: 70000, maxResultBytes: 2097152, rawSql: "SELECT * FROM prisoner" }];