diff --git a/docs/desktop-applications.md b/docs/desktop-applications.md index 1664fe61f0..c9473cf3a3 100644 --- a/docs/desktop-applications.md +++ b/docs/desktop-applications.md @@ -91,17 +91,20 @@ same bundle. Export writes only the caller-selected output file. ## Facts and uncertainty -| Family | Evidence used | -| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| .NET | A PE optional header declares a nonempty CLI directory. | -| Unity Mono | A managed assembly below `*_Data/Managed` or `Contents/Resources/Data/Managed` has an exact UnityEngine assembly reference. | -| Unity IL2CPP | An IL2CPP metadata header coexists with a conventionally named native `GameAssembly.dll`, `GameAssembly.so`, `GameAssembly.dylib` or `libil2cpp.so` image. Build correspondence remains unknown. | -| Godot | A `GDPC` header at file start or a validated executable trailer offset. | -| GameMaker | A bounded `FORM` container starts with a `GEN8` chunk. | -| Unreal | `Content/Paks` path conventions or an IoStore TOC signature; arbitrary `.pak` filenames are insufficient. | -| Electron | A `resources/app.asar` or `resources/default_app.asar` path convention; archive validity is unverified. | +| Family | Evidence used | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| .NET | A PE optional header declares a nonempty CLI directory. | +| Unity Mono | A managed assembly below `*_Data/Managed` or `Contents/Resources/Data/Managed` has an exact UnityEngine assembly reference. | +| Unity IL2CPP | A recognized IL2CPP metadata magic (with the raw version when complete) coexists with a conventionally named native `GameAssembly.dll`, `GameAssembly.so`, `GameAssembly.dylib` or `libil2cpp.so` image. Build correspondence remains unknown. | +| Godot | A `GDPC` header at file start or a validated executable trailer offset. | +| GameMaker | A bounded `FORM` container starts with a `GEN8` chunk. | +| Unreal | `Content/Paks` path conventions or an IoStore TOC signature; arbitrary `.pak` filenames are insufficient. | +| Electron | A `resources/app.asar` or `resources/default_app.asar` path convention; archive validity is unverified. | -An observed metadata header with supported raw version 29, 31 or 39 offers an +When the four-byte metadata magic is present but version bytes are incomplete, +the fact retains `raw_version: null`; it does not offer an IL2CPP metadata +inspection probe. A complete magic/version prefix with supported raw version 29, +31 or 39 offers an `inspect_il2cpp_metadata` follow-up at its admitted file path, including when no native library was found. This probe does not establish full metadata validity or correspondence to a native binary. See [IL2CPP metadata](il2cpp-metadata.md). diff --git a/src/artifacts/DesktopFileInspection.ts b/src/artifacts/DesktopFileInspection.ts index 97656a056b..d82e9d0dd2 100644 --- a/src/artifacts/DesktopFileInspection.ts +++ b/src/artifacts/DesktopFileInspection.ts @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; import { setImmediate } from "node:timers/promises"; import type { DesktopFile, DesktopFact } from "../domain/desktopApplication.js"; import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; -import { IL2CPP_METADATA_MAGIC } from "../domain/il2cpp/il2cppMetadata.js"; +import { observeIl2cppMetadataHeaderPrefix } from "../domain/il2cpp/il2cppMetadataHeaderPrefix.js"; import { parsePeRecord, peRvaToFileOffset } from "../domain/binaryTarget.js"; import { readManagedMetadataLayout } from "../dotnet/ManagedMetadataLayout.js"; import { readAssemblyReferenceName } from "../dotnet/ManagedMetadataInventoryRows.js"; @@ -98,13 +98,14 @@ export const inspectDesktopFile = async ( row.facts.push(observation); }; const prefix = await read(0, Math.min(size, 64)); - if (prefix.length >= 8 && prefix.readUInt32LE(0) === IL2CPP_METADATA_MAGIC) + const metadataPrefix = observeIl2cppMetadataHeaderPrefix(prefix); + if (metadataPrefix.kind === "il2cpp") fact({ kind: "il2cpp-metadata-header", offset: 0, value: { magic: "0xfab11baf", - raw_version: prefix.readUInt32LE(4), + raw_version: metadataPrefix.rawVersion, }, }); if (prefix.length >= 20 && prefix.toString("latin1", 0, 4) === "GDPC") diff --git a/src/domain/desktopApplication.ts b/src/domain/desktopApplication.ts index 68a5b550d0..9091ec04d9 100644 --- a/src/domain/desktopApplication.ts +++ b/src/domain/desktopApplication.ts @@ -56,7 +56,10 @@ const desktopFactSchema = z.discriminatedUnion("kind", [ ), fact( "il2cpp-metadata-header", - z.strictObject({ magic: z.literal("0xfab11baf"), raw_version: byteOffset }), + z.strictObject({ + magic: z.literal("0xfab11baf"), + raw_version: byteOffset.nullable(), + }), ), fact( "godot-pck-header", diff --git a/src/domain/desktopApplicationProjection.ts b/src/domain/desktopApplicationProjection.ts index 7fc55ab496..4a061a9a51 100644 --- a/src/domain/desktopApplicationProjection.ts +++ b/src/domain/desktopApplicationProjection.ts @@ -87,7 +87,7 @@ export const projectDesktopApplication = ( candidate( "unity-il2cpp", [...metadata, ...il2cpp], - "IL2CPP metadata headers and conventionally named native libraries coexist; their build correspondence is unverified.", + "IL2CPP metadata magic and conventionally named native libraries coexist; metadata validity and build correspondence are unverified.", "inferred", ); for (const file of il2cpp) diff --git a/src/domain/il2cpp/il2cppMetadataHeaderPrefix.ts b/src/domain/il2cpp/il2cppMetadataHeaderPrefix.ts new file mode 100644 index 0000000000..6bc7f28ffb --- /dev/null +++ b/src/domain/il2cpp/il2cppMetadataHeaderPrefix.ts @@ -0,0 +1,59 @@ +import { IL2CPP_METADATA_MAGIC } from "./il2cppMetadata.js"; + +/** Parsed identity facts available before the complete IL2CPP header exists. */ +export type Il2cppMetadataHeaderPrefix = + | { + readonly kind: "unavailable-magic"; + readonly magic: null; + readonly rawVersion: null; + readonly version: null; + } + | { + readonly kind: "other-magic"; + readonly magic: number; + readonly rawVersion: null; + readonly version: null; + } + | { + readonly kind: "il2cpp"; + readonly magic: typeof IL2CPP_METADATA_MAGIC; + /** Unsigned bytes as stored; null means fewer than eight prefix bytes. */ + readonly rawVersion: number | null; + /** The decoder's signed Int32 interpretation; null means unavailable. */ + readonly version: number | null; + }; + +/** Parse the shared magic/version prefix once, retaining incomplete identity explicitly. */ +export const observeIl2cppMetadataHeaderPrefix = ( + bytes: Uint8Array, +): Il2cppMetadataHeaderPrefix => { + if (bytes.byteLength < 4) + return { + kind: "unavailable-magic", + magic: null, + rawVersion: null, + version: null, + }; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const magic = view.getUint32(0, true); + if (magic !== IL2CPP_METADATA_MAGIC) + return { + kind: "other-magic", + magic, + rawVersion: null, + version: null, + }; + if (bytes.byteLength < 8) + return { + kind: "il2cpp", + magic: IL2CPP_METADATA_MAGIC, + rawVersion: null, + version: null, + }; + return { + kind: "il2cpp", + magic: IL2CPP_METADATA_MAGIC, + rawVersion: view.getUint32(4, true), + version: view.getInt32(4, true), + }; +}; diff --git a/src/il2cpp/Il2cppMetadataFile.ts b/src/il2cpp/Il2cppMetadataFile.ts index 0b54f1d644..b5a2c0c6d9 100644 --- a/src/il2cpp/Il2cppMetadataFile.ts +++ b/src/il2cpp/Il2cppMetadataFile.ts @@ -4,13 +4,13 @@ import { Il2cppMetadataFormatFailure, } from "./Il2cppMetadataFormat.js"; import { - IL2CPP_METADATA_MAGIC, IL2CPP_METADATA_VERSIONS, il2cppMetadataSectionNames, type Il2cppMetadata, type Il2cppMetadataSection, type IL2cppMetadataSectionName, } from "../domain/il2cpp/il2cppMetadata.js"; +import { observeIl2cppMetadataHeaderPrefix } from "../domain/il2cpp/il2cppMetadataHeaderPrefix.js"; import { readIl2cppMembers, readIl2cppMemberTables, @@ -129,13 +129,18 @@ const isIndexWidth = (value: number): value is IndexWidth => value === 1 || value === 2 || value === 4; const readHeader = (bytes: Buffer) => { - const magic = bytes.length < 4 ? null : bytes.readUInt32LE(0); - if (bytes.length < 8 || magic !== IL2CPP_METADATA_MAGIC) + const prefix = observeIl2cppMetadataHeaderPrefix(bytes); + if (prefix.kind !== "il2cpp") throw new Il2cppMetadataFormatFailure( "unrecognized-magic", "the file does not start with the IL2CPP global-metadata magic 0xFAB11BAF; encrypted or non-metadata inputs are not decoded", ); - const version = bytes.readInt32LE(4); + if (prefix.version === null) + throw new Il2cppMetadataFormatFailure( + "truncated-header", + `the file has ${bytes.length} bytes, fewer than the 8-byte magic/version prefix; the metadata version is unknown`, + ); + const { magic, version } = prefix; if (!isSupportedVersion(version)) throw new Il2cppMetadataFormatFailure( "unsupported-version", diff --git a/tests/acceptance/il2cppMetadataCli.test.ts b/tests/acceptance/il2cppMetadataCli.test.ts index 7838961c9c..ab69cca9dd 100644 --- a/tests/acceptance/il2cppMetadataCli.test.ts +++ b/tests/acceptance/il2cppMetadataCli.test.ts @@ -17,7 +17,10 @@ import { il2cppMetadataSummaryViewResultSchema, } from "../../src/domain/analysisView/analysisView.js"; import { createEvidence, parseEvidence } from "../../src/domain/evidence.js"; -import { createEvidenceBundle } from "../../src/domain/evidenceBundle.js"; +import { + createEvidenceBundle, + parseEvidenceBundle, +} from "../../src/domain/evidenceBundle.js"; import { jsonObjectSchema } from "../../src/domain/jsonValue.js"; import { il2cppMetadataSchema } from "../../src/domain/il2cpp/il2cppMetadata.js"; import { @@ -221,6 +224,81 @@ cliTest( 30000, ); +cliTest( + "reports a recognized IL2CPP magic with an incomplete version as corrupt truncation through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-il2cpp-prefix-fail-"); + const { client, call } = await connectLocalToolsMcp(); + + for (const length of [4, 5, 6, 7]) { + const path = join(root, `magic-${length}.dat`); + const bytes = Buffer.alloc(length); + bytes.writeUInt32LE(0xfab11baf, 0); + await writeFile(path, bytes); + + const expected = { + code: "invalid_request", + details: { + operation: "inspect_il2cpp_metadata", + issues: [ + { + path: ["path"], + reason: "invalid_format", + message: expect.stringContaining( + "truncated-header: the file has", + ), + }, + ], + partial_observation: { + normalized_result: { + artifact: { + path, + bytes: length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + inventory_status: "not-returned-as-complete", + }, + }, + }, + }; + const result = await cli.run({ + arguments: ["inspect-il2cpp-metadata", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject(expected); + + const response = await call("inspect_il2cpp_metadata", { path }); + const projected = parseMcpToolError(response).error; + expect(projected).toMatchObject({ + ...expected, + details: { + ...expected.details, + partial_observation: { + kind: "retained-evidence", + evidence_id: expect.any(String), + }, + }, + }); + const reference = projected.details?.partial_observation as { + evidence_id: string; + }; + const bundle = parseEvidenceBundle( + toolContract("get_evidence_bundle").outputSchema.parse( + (await call("get_evidence_bundle", { detail: "complete" })) + .structuredContent, + ).result, + ); + expect( + bundle.records.find( + ({ evidence_id }) => evidence_id === reference.evidence_id, + ), + ).toMatchObject(expected.details.partial_observation); + } + await client.ping(); + }, + 30000, +); + const metadataPageReader = ( call: Awaited>["call"], diff --git a/tests/boundary/filesystem/desktopApplication.test.ts b/tests/boundary/filesystem/desktopApplication.test.ts index eaadfc27af..55aeb22d03 100644 --- a/tests/boundary/filesystem/desktopApplication.test.ts +++ b/tests/boundary/filesystem/desktopApplication.test.ts @@ -1181,8 +1181,8 @@ it("preserves admitted Unicode and escaped AssemblyRef names in complete Evidenc ); }); -it.each([27, 28, 31, 106])( - "suggests metadata-only inspection for supported raw version %s", +it.each([27, 28, 31, 106, 0xffffffff])( + "preserves raw metadata version %s and suggests inspection only when supported", async (version) => { const root = await createTestTempDirectory("rea-desktop-metadata-probe-"); const bytes = Buffer.alloc(8); @@ -1209,3 +1209,26 @@ it.each([27, 28, 31, 106])( ); }, ); + +it.each([4, 5, 6, 7])( + "retains IL2CPP magic with an unknown version for a %i-byte prefix", + async (length) => { + const root = await createTestTempDirectory("rea-desktop-metadata-prefix-"); + const bytes = Buffer.alloc(length); + bytes.writeUInt32LE(0xfab11baf); + await put(root, "global-metadata.dat", bytes); + const { report } = await inspect(root); + const metadata = report.files.find((file) => + file.path.endsWith("global-metadata.dat"), + ); + + expect(metadata?.facts).toContainEqual({ + kind: "il2cpp-metadata-header", + offset: 0, + value: { magic: "0xfab11baf", raw_version: null }, + }); + expect(report.next_probes).not.toContainEqual( + expect.objectContaining({ operation: "inspect_il2cpp_metadata" }), + ); + }, +); diff --git a/tests/boundary/il2cpp/il2cppMetadataFile.test.ts b/tests/boundary/il2cpp/il2cppMetadataFile.test.ts index 1bee0800fa..1402a86d3b 100644 --- a/tests/boundary/il2cpp/il2cppMetadataFile.test.ts +++ b/tests/boundary/il2cpp/il2cppMetadataFile.test.ts @@ -7,6 +7,7 @@ import { il2cppIndexWidth, il2cppMetadataRecordBytes, readIl2cppMetadata, + readIl2cppMetadataForLinking, readIl2cppMetadataSteps, } from "../../../src/il2cpp/Il2cppMetadataFile.js"; import { @@ -24,9 +25,14 @@ import { type Il2cppMetadataFixture, } from "../../fixtures/il2cpp/metadata.js"; -const failure = (bytes: Buffer) => { +const failure = ( + bytes: Buffer, + reader: + | typeof readIl2cppMetadata + | typeof readIl2cppMetadataForLinking = readIl2cppMetadata, +) => { try { - readIl2cppMetadata(bytes); + reader(bytes); } catch (cause: unknown) { if (cause instanceof Il2cppMetadataFormatFailure) return cause; throw cause; @@ -161,7 +167,8 @@ describe("rejected IL2CPP global-metadata", () => { "unrecognized-magic", "unsupported", ); - rejects(Buffer.alloc(4), "unrecognized-magic", "unsupported"); + for (const length of [4, 5, 6, 7]) + rejects(Buffer.alloc(length), "unrecognized-magic", "unsupported"); rejects( mutated((_, bytes) => bytes.writeInt32LE(30, 4)), "unsupported-version", @@ -169,6 +176,40 @@ describe("rejected IL2CPP global-metadata", () => { ); }); + it("retains the decoder's signed interpretation of a complete high-bit version", () => { + const bytes = Buffer.alloc(8); + bytes.writeUInt32LE(0xfab11baf, 0); + bytes.writeUInt32LE(0xffffffff, 4); + const rejected = failure(bytes); + expect(rejected.reason).toBe("unsupported-version"); + expect(rejected.kind).toBe("unsupported"); + expect(rejected.message).toContain("metadata version -1 is not supported"); + }); + + it.each([4, 5, 6, 7])( + "retains recognized magic and reports a %i-byte version prefix as truncated", + (length) => { + const bytes = Buffer.alloc(length); + bytes.writeUInt32LE(0xfab11baf, 0); + const rejected = failure(bytes); + expect(rejected.reason).toBe("truncated-header"); + expect(rejected.kind).toBe("corrupt"); + expect(rejected.message).toContain( + "fewer than the 8-byte magic/version prefix", + ); + expect(rejected.message).toContain("metadata version is unknown"); + }, + ); + + it("uses the same truncated-prefix classification before native linking", () => { + const bytes = Buffer.alloc(7); + bytes.writeUInt32LE(0xfab11baf, 0); + const rejected = failure(bytes, readIl2cppMetadataForLinking); + expect(rejected.reason).toBe("truncated-header"); + expect(rejected.kind).toBe("corrupt"); + expect(rejected.message).toContain("metadata version is unknown"); + }); + it("rejects a truncated header and an unexpected header size", () => { rejects( createIl2cppMetadataFixture().bytes.subarray(0, 200), diff --git a/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts b/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts index bd3eae1a0c..0c28878fc5 100644 --- a/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts +++ b/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts @@ -352,6 +352,41 @@ it("retains the exact empty source observation without claiming a decoded invent }); }); +it("retains the exact source when recognized magic has no complete version", async () => { + const root = await createTestTempDirectory("rea-il2cpp-short-prefix-"); + const path = join(root, "global-metadata.dat"); + const bytes = Buffer.alloc(7); + bytes.writeUInt32LE(0xfab11baf, 0); + await writeFile(path, bytes); + const result = await inspectMetadata(path); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected truncated metadata prefix"); + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: [ + { + path: ["path"], + reason: "invalid_format", + message: expect.stringMatching( + /truncated-header:.*metadata version is unknown/u, + ), + }, + ], + }); + expect(result.error.partialObservation).toMatchObject({ + operation: "observe_il2cpp_metadata_source", + normalized_result: { + artifact: { + path, + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + inventory_status: "not-returned-as-complete", + }, + }); +}); + it("keeps the metadata source observation when the selected binary does not link", async () => { const root = await createTestTempDirectory("rea-il2cpp-binary-failure-"); const path = join(root, "global-metadata.dat");