From 22e6110acb3f73f84ff1f0292ef2adee3cb5c18c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:26:33 +0800 Subject: [PATCH] fix(apple): preserve modern cache subtype metadata --- docs/apple-application-analysis.md | 3 +- scripts/verify/apple/dyld-shared-cache.mjs | 31 ++++++++++++++++- src/artifacts/apple/DyldSharedCacheReader.ts | 13 +++++++ src/domain/apple/dyldSharedCache.ts | 10 ++++++ .../filesystem/dyldSharedCache.test.ts | 34 +++++++++++++++++++ tests/boundary/mcp/dyldSharedCacheMcp.test.ts | 7 +++- 6 files changed, 95 insertions(+), 3 deletions(-) diff --git a/docs/apple-application-analysis.md b/docs/apple-application-analysis.md index f75511868..d921be89b 100644 --- a/docs/apple-application-analysis.md +++ b/docs/apple-application-analysis.md @@ -228,7 +228,8 @@ rea inspect-dyld-shared-cache /System/Volumes/Preboot/Cryptexes/OS/System/Librar The result reports: - the magic, architecture, UUID, platform and OS version, any alternate platform - (Mac Catalyst), cache type, shared region and maximum slide; + (Mac Catalyst), cache type, the development/production subtype for multi-caches + (including its raw ID when unknown), shared region and maximum slide; - every VM mapping, with the file that holds it; - every subcache by file suffix, with its UUID checked against the main cache. A missing or mismatched subcache makes coverage `partial`, and images mapped diff --git a/scripts/verify/apple/dyld-shared-cache.mjs b/scripts/verify/apple/dyld-shared-cache.mjs index 9c24dd55e..f61ccb52e 100644 --- a/scripts/verify/apple/dyld-shared-cache.mjs +++ b/scripts/verify/apple/dyld-shared-cache.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { access, mkdtemp, realpath, rm } from "node:fs/promises"; +import { access, mkdtemp, open, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -120,6 +120,33 @@ try { assert.equal(inspected.images_total, expected.size); assert.equal(inspected.coverage.status, "complete"); assert.equal(inspected.inspected_images[0].status, "parsed"); + // Check the producer header directly, independently of REA's header projection. + const handle = await open(cachePath, "r"); + try { + const header = Buffer.alloc(0x1cc); + const { bytesRead } = await handle.read(header, 0, header.length, 0); + const multi = header.readBigUInt64LE(0x68) === 2n; + const hasSubtype = + bytesRead === header.length && header.readUInt32LE(0x10) >= header.length; + const subtype = multi && hasSubtype ? header.readUInt32LE(0x1c8) : null; + assert.deepEqual( + inspected.cache_subtype, + subtype === null + ? null + : { + id: subtype, + name: + subtype === 0 + ? "development" + : subtype === 1 + ? "production" + : null, + }, + "multi-cache subtype differs from the producer header", + ); + } finally { + await handle.close(); + } await withArtifactMcp(null, async (client) => { const viaMcp = await artifactMcpResult( client, @@ -181,6 +208,8 @@ try { architecture: inspected.architecture, os_version: inspected.os_version, images: inspected.images_total, + type: inspected.cache_type, + subtype: inspected.cache_subtype, }, images_matching_dyld_info: compared, trace_shared_cache_edges: cached.length, diff --git a/src/artifacts/apple/DyldSharedCacheReader.ts b/src/artifacts/apple/DyldSharedCacheReader.ts index e3bb2259e..9168aa2a7 100644 --- a/src/artifacts/apple/DyldSharedCacheReader.ts +++ b/src/artifacts/apple/DyldSharedCacheReader.ts @@ -571,6 +571,7 @@ export class DyldSharedCache { const cacheType = has(parsed, FIELD.cacheType, 8) ? Number(header.readBigUInt64LE(FIELD.cacheType)) : null; + const cacheSubType = cacheType === 2 ? u32(FIELD.cacheSubType) : null; const symbols = has(parsed, FIELD.symbolFileUuid, 16) ? header.subarray(FIELD.symbolFileUuid, FIELD.symbolFileUuid + 16) : undefined; @@ -606,6 +607,18 @@ export class DyldSharedCache { cacheType, has(parsed, FIELD.cacheSubType, 4), ), + cache_subtype: + cacheSubType !== null + ? { + id: cacheSubType, + name: + cacheSubType === 0 + ? "development" + : cacheSubType === 1 + ? "production" + : null, + } + : null, shared_region: u64(FIELD.sharedRegionStart) === null ? null diff --git a/src/domain/apple/dyldSharedCache.ts b/src/domain/apple/dyldSharedCache.ts index 0970a5b4d..8ed0e0698 100644 --- a/src/domain/apple/dyldSharedCache.ts +++ b/src/domain/apple/dyldSharedCache.ts @@ -45,6 +45,16 @@ const headerSchema = z.object({ alt_platform: applePlatformSchema.nullable(), alt_os_version: z.string().nullable(), cache_type: z.enum(["development", "production", "multi-cache"]).nullable(), + cache_subtype: z + .object({ + id: z.number().int().nonnegative(), + name: z.enum(["development", "production"]).nullable(), + }) + .nullable() + .default(null) + .describe( + "Multi-cache subtype from the producer header: 0 is development, 1 is production; unknown values retain their ID with a null name. Null when absent or not applicable.", + ), shared_region: z.object({ start: hexSchema, size: hexSchema }).nullable(), max_slide: hexSchema.nullable(), mappings: z.array(mappingSchema), diff --git a/tests/boundary/filesystem/dyldSharedCache.test.ts b/tests/boundary/filesystem/dyldSharedCache.test.ts index 48673f89d..7cbfa2a9c 100644 --- a/tests/boundary/filesystem/dyldSharedCache.test.ts +++ b/tests/boundary/filesystem/dyldSharedCache.test.ts @@ -174,6 +174,40 @@ const IMAGES = [ }, ]; +it.each([ + { type: 2, subtype: 0, expected: { id: 0, name: "development" } }, + { type: 2, subtype: 1, expected: { id: 1, name: "production" } }, + { type: 2, subtype: 9, expected: { id: 9, name: null } }, + { type: 0, subtype: 1, expected: null }, + { type: 1, subtype: 0, expected: null }, +])( + "preserves applicable multi-cache subtype $subtype for type $type", + async ({ type, subtype, expected }) => { + const fixture = dyldCacheFixture(IMAGES); + const main = Buffer.from(fixture.main); + main.writeBigUInt64LE(BigInt(type), 0x68); + main.writeUInt32LE(subtype, 0x1c8); + const path = await writeCache({ ...fixture, main }); + const result = await inspectDyldSharedCache({ cache_path: path }); + if (!result.ok) throw result.error; + const cache = dyldSharedCacheResultSchema.parse(result.value); + expect(cache.cache_type).toBe( + type === 2 ? "multi-cache" : type === 0 ? "development" : "production", + ); + expect(cache.cache_subtype).toEqual(expected); + }, +); + +it("keeps a legacy multi-cache subtype unknown when its header lacks the field", async () => { + const fixture = dyldCacheFixture([], { legacy: true }); + const main = Buffer.from(fixture.main); + main.writeBigUInt64LE(2n, 0x68); + const path = await writeCache({ ...fixture, main }); + const result = await inspectDyldSharedCache({ cache_path: path }); + if (!result.ok) throw result.error; + expect(result.value.cache_subtype).toBeNull(); +}); + describe("dyld shared cache inspection", () => { it("reads the header, subcaches, image list and cached load commands", async () => { const path = await writeCache(dyldCacheFixture(IMAGES)); diff --git a/tests/boundary/mcp/dyldSharedCacheMcp.test.ts b/tests/boundary/mcp/dyldSharedCacheMcp.test.ts index 036f00961..a3795f6c3 100644 --- a/tests/boundary/mcp/dyldSharedCacheMcp.test.ts +++ b/tests/boundary/mcp/dyldSharedCacheMcp.test.ts @@ -44,7 +44,10 @@ it("inspects a dyld shared cache through MCP without a target", async () => { }), }, ]); - await writeFile(path, fixture.main); + const main = Buffer.from(fixture.main); + main.writeBigUInt64LE(2n, 0x68); + main.writeUInt32LE(1, 0x1c8); + await writeFile(path, main); const tools = await client.listTools(); expect(tools.tools.map(({ name }) => name)).toContain( @@ -62,6 +65,8 @@ it("inspects a dyld shared cache through MCP without a target", async () => { ); expect(result).toMatchObject({ architecture: "arm64e", + cache_type: "multi-cache", + cache_subtype: { id: 1, name: "production" }, images_total: 1, inspected_images: [ { path: "/usr/lib/libSystem.B.dylib", status: "parsed" },