diff --git a/docs/apple-application-analysis.md b/docs/apple-application-analysis.md index 666717fc5e..2ac7139335 100644 --- a/docs/apple-application-analysis.md +++ b/docs/apple-application-analysis.md @@ -60,6 +60,15 @@ states that absence is unknown. Component arrays remain complete when candidate hypotheses are omitted; component arrays still include every component from the supplied inventory pages, and overall coverage is `partial` in that case. +Inventoried files whose bytes were not read, such as encrypted archive members, +are listed in `unavailable_components` with their original path, format, +encryption flag, limitations, and null content identity. The same application-root +and AppleDouble attribution rules apply. These files make overall coverage +`partial`; component absence is unknown even when `inventory_complete` is true. +Directories have no file bytes and symlinks remain in `symlinks`, so neither is +an unavailable file component. The additive field participates in `projection_id`, +including when empty, so Apple projection identifiers change. + ## Application roots | Inventory root | Application root | diff --git a/src/domain/android/androidApplication.ts b/src/domain/android/androidApplication.ts index 17014f1b51..6e92fea40e 100644 --- a/src/domain/android/androidApplication.ts +++ b/src/domain/android/androidApplication.ts @@ -13,12 +13,11 @@ import { projectCartesianCandidates, projectedBridgeCandidateSchema, projectedComponentSchema, - projectedComponents, + projectedInventoryEntries, projectionCoverage, projectionDigest, projectionEvidenceIdSchema, unavailableComponentSchema, - unavailableComponents, } from "../mobileApplicationGraphProjection.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; @@ -94,8 +93,13 @@ export const projectAndroidApplication = ( throw new TypeError( `Android application projection requires APK inventory Evidence; the inventory root format is ${inventory.manifest.root_format}. APK is chosen for a ZIP archive whose name ends in .apk.`, ); - const all = projectedComponents(inventory); - const unavailable = unavailableComponents(inventory); + const entries = projectedInventoryEntries(inventory); + const all = entries.flatMap(({ component }) => + component === undefined ? [] : [component], + ); + const unavailable = entries.flatMap(({ unavailable }) => + unavailable === undefined ? [] : [unavailable], + ); const classified = classify(all); const bridgeProjection = bridgeCandidates( [...classified.dex, ...classified.jvm_classes], diff --git a/src/domain/apple/appleApplication.test.ts b/src/domain/apple/appleApplication.test.ts index c169d7cdc9..4678930385 100644 --- a/src/domain/apple/appleApplication.test.ts +++ b/src/domain/apple/appleApplication.test.ts @@ -2,19 +2,22 @@ import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; +import type { ArtifactOccurrence } from "../artifactGraph.js"; + import { projectAppleApplication } from "./appleApplication.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { digestCanonicalValue } from "../canonicalDigest.js"; import { createEvidence, type Evidence } from "../evidence.js"; import { jsonValueSchema } from "../jsonValue.js"; -type EntryKind = "file" | "directory" | "symlink"; +type EntryKind = "file" | "directory" | "symlink" | "slice"; type RootFormat = "ipa" | "zip" | "dmg" | "directory" | "asar"; interface FixtureEntry { readonly path: string; readonly kind?: EntryKind; - readonly format?: string; + readonly format?: ArtifactOccurrence["artifact_format"]; + readonly unavailable?: boolean; } const sha = (text: string): string => @@ -53,8 +56,8 @@ const inventoryEvidence = ( path: string, kind: EntryKind, id: string | null, - format = "file", - ) => ({ + format: ArtifactOccurrence["artifact_format"] = "file", + ): ArtifactOccurrence => ({ occurrence_id: path === "." ? `occ_${digestCanonicalValue({ root: rootId }, "Artifact inventory")}` @@ -78,9 +81,20 @@ const inventoryEvidence = ( }); const nodes = new Map([[rootId, node(rootSha, rootFormat)]]); const occurrences = [occurrence(".", "file", rootId, rootFormat)]; - for (const { path, kind = "file", format = "file" } of entries) { - if (kind === "symlink") { - occurrences.push(occurrence(path, kind, null)); + for (const { + path, + kind = "file", + format = "file", + unavailable = false, + } of entries) { + if (kind === "symlink" || unavailable) { + occurrences.push({ + ...occurrence(path, kind, null, format), + hash_status: "unavailable", + limitations: unavailable + ? ["Authored fixture: content was not read."] + : [], + }); continue; } const contentSha = sha(`${kind}:${path}`); @@ -544,7 +558,7 @@ describe("Apple application bridge candidates", () => { it("reports candidate omission when one root expands beyond the bridge safety budget", () => { const escapedDirectory = `${"é".repeat(100)}${'"'.repeat(120)}`; - const scripts = Array.from({ length: 129 }, (_, index) => ({ + const scripts: FixtureEntry[] = Array.from({ length: 129 }, (_, index) => ({ path: `Payload/Fixture.app/${escapedDirectory}/script-${String(index).padStart(3, "0")}.js`, format: "javascript-bundle", })); @@ -734,3 +748,37 @@ describe("Apple path conventions", () => { }, ); }); + +it.each(["file", "slice"] as const)( + "retains non-encrypted unavailable %s facts without classifying content", + (kind) => { + const projection = projectAppleApplication({ + inventory_evidence: [ + inventoryEvidence("ipa", "Fixture.ipa", [ + { path: "Payload/Fixture.app/Info.plist", format: "plist" }, + { + path: "Payload/Fixture.app/libentry.dylib", + kind, + format: "file", + unavailable: true, + }, + ]), + ], + }); + expect(projection.unavailable_components).toEqual([ + { + path: "Payload/Fixture.app/libentry.dylib", + artifact_id: null, + sha256: null, + format: "file", + encrypted: false, + limitations: ["Authored fixture: content was not read."], + }, + ]); + expect(projection.components.native_libraries).toEqual([]); + expect(projection.coverage).toEqual({ + status: "partial", + inventory_complete: true, + }); + }, +); diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts index 7911559e2c..79ddf9e94c 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -4,14 +4,12 @@ import { anatomyNameIs, anatomyTailMatches, appleBundleSchema, - appleComponentSchema, applicationRoots, detectBundles, isMachOFormat, isSidecar, isWithin, platformsOf, - type AppleInventoryEntry, } from "./appleBundleAnatomy.js"; import { isSwiftRuntimeLibrary, @@ -28,7 +26,13 @@ import { bridgeCandidateCoverageSchema, projectCartesianCandidates, } from "../bridgeCandidateProjection.js"; -import { projectionDigest } from "../mobileApplicationGraphProjection.js"; +import { + projectedComponentSchema, + projectedInventoryEntries, + projectionDigest, + unavailableComponentSchema, + type ProjectedInventoryEntry, +} from "../mobileApplicationGraphProjection.js"; import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; const evidenceIdSchema = prefixedDigestSchema("ev"); @@ -51,18 +55,19 @@ export const appleApplicationProjectionResultSchema = z.strictObject({ application_roots: z.array(pathSchema), bundles: z.array(appleBundleSchema), components: z.strictObject({ - bundle_metadata: z.array(appleComponentSchema), - executables: z.array(appleComponentSchema), - frameworks: z.array(appleComponentSchema), - native_libraries: z.array(appleComponentSchema), - javascript: z.array(appleComponentSchema), - signing: z.array(appleComponentSchema), - privileged_helpers: z.array(appleComponentSchema), + bundle_metadata: z.array(projectedComponentSchema), + executables: z.array(projectedComponentSchema), + frameworks: z.array(projectedComponentSchema), + native_libraries: z.array(projectedComponentSchema), + javascript: z.array(projectedComponentSchema), + signing: z.array(projectedComponentSchema), + privileged_helpers: z.array(projectedComponentSchema), launchd_plists: z.array( - appleComponentSchema.extend({ domain: z.enum(["agent", "daemon"]) }), + projectedComponentSchema.extend({ domain: z.enum(["agent", "daemon"]) }), ), - helpers: z.array(appleComponentSchema), + helpers: z.array(projectedComponentSchema), }), + unavailable_components: z.array(unavailableComponentSchema), symlinks: z.array(pathSchema), runtime_families: z.array( z.enum([ @@ -102,7 +107,7 @@ export type AppleApplicationProjectionResult = z.infer< typeof appleApplicationProjectionResultSchema >; -type Component = z.infer; +type Component = z.infer; type RootFormat = (typeof APPLE_ROOT_FORMATS)[number]; /** Project exact Apple application paths and hashes without parsing or executing target code. */ @@ -114,34 +119,7 @@ export const projectAppleApplication = ( parsed.inventory_evidence, ); const rootFormat = appleRootFormat(inventory.manifest.root_format); - const nodes = new Map( - inventory.nodes.map((node) => [node.artifact_id, node]), - ); - const entries: AppleInventoryEntry[] = inventory.occurrences - .filter(({ logical_path: path }) => path !== ".") - .map((occurrence) => { - if (occurrence.artifact_id === null) - return { - path: occurrence.logical_path, - kind: occurrence.entry_kind, - component: undefined, - }; - const node = nodes.get(occurrence.artifact_id); - if (node === undefined) - throw new TypeError( - "Apple application occurrence has no artifact node", - ); - return { - path: occurrence.logical_path, - kind: occurrence.entry_kind, - component: { - path: occurrence.logical_path, - artifact_id: node.artifact_id, - sha256: node.sha256, - format: occurrence.artifact_format, - }, - }; - }); + const entries = projectedInventoryEntries(inventory); const inventoried = entries.flatMap(({ component }) => component === undefined ? [] : [component], ); @@ -154,6 +132,16 @@ export const projectAppleApplication = ( roots, rootFormat, ); + // Directories have no file bytes; symlinks already have their own path-only output. + const unavailable = attributeComponents( + entries.flatMap(({ kind, unavailable }) => + (kind === "file" || kind === "slice") && unavailable !== undefined + ? [unavailable] + : [], + ), + roots, + rootFormat, + ); const bundles = detectBundles(entries, roots); const classified = classifyComponents(entries, all, roots); const runtimeFamilies = identifyRuntimeFamilies(all); @@ -181,7 +169,8 @@ export const projectAppleApplication = ( bundles, sidecars: entries.some(({ path }) => isSidecar(path)), symlinks: entries.some(({ kind }) => kind === "symlink"), - unattributed, + unattributed: unattributed + unavailable.unattributed, + unavailable: unavailable.all.length > 0, bridgeCandidateCoverage: bridgeProjection.coverage, }), ]; @@ -195,6 +184,7 @@ export const projectAppleApplication = ( application_roots: roots, bundles, components: classified, + unavailable_components: unavailable.all, symlinks: entries .filter(({ kind }) => kind === "symlink") .map(({ path }) => path) @@ -204,7 +194,9 @@ export const projectAppleApplication = ( bridge_candidate_coverage: bridgeProjection.coverage, coverage: { status: - complete && bridgeProjection.coverage.status === "complete" + complete && + unavailable.all.length === 0 && + bridgeProjection.coverage.status === "complete" ? ("complete-within-inventory" as const) : ("partial" as const), inventory_complete: inventory.complete, @@ -227,11 +219,11 @@ export const appleProjectionId = (projection: unknown): string => * ZIP, and DMG inventories can hold installers and other siblings, so only * components inside an application root are attributed to it. */ -const attributeComponents = ( - inventoried: readonly Component[], +const attributeComponents = ( + inventoried: readonly Entry[], roots: readonly string[], rootFormat: RootFormat, -): { readonly all: Component[]; readonly unattributed: number } => { +): { readonly all: Entry[]; readonly unattributed: number } => { const visible = inventoried.filter(({ path }) => !isSidecar(path)); if (rootFormat === "ipa") return { all: visible, unattributed: 0 }; const all = visible.filter(({ path }) => @@ -253,7 +245,7 @@ const isNativeLibrary = (path: string): boolean => /\.(?:dylib|so)$/iu.test(path); const classifyComponents = ( - entries: readonly AppleInventoryEntry[], + entries: readonly ProjectedInventoryEntry[], all: readonly Component[], roots: readonly string[], ) => { @@ -402,6 +394,7 @@ const projectionLimitations = (facts: { readonly sidecars: boolean; readonly symlinks: boolean; readonly unattributed: number; + readonly unavailable: boolean; readonly bridgeCandidateCoverage: AppleApplicationProjectionResult["bridge_candidate_coverage"]; }): string[] => [ ...(!facts.complete @@ -445,6 +438,11 @@ const projectionLimitations = (facts: { `Bridge candidate pairs exceeded the projection safety budget; ${facts.bridgeCandidateCoverage.omitted_candidates} hypotheses are omitted. Component arrays still include every component from the supplied inventory pages.`, ] : []), + ...(facts.unavailable + ? [ + "Some inventoried files have no authenticated content identity. Their paths and limitations are retained in unavailable_components; component absence is unknown.", + ] + : []), "Bundle roles follow path conventions. Read each info_plist_path with inspect_plist for CFBundleExecutable, identifiers, and declared services.", "Bundle identifiers and signing claims require dedicated plist and CMS parsing; this projection reports only exact paths and hashes.", "Runtime families are inferred from inventory formats and bounded framework path conventions, not observed runtime loading.", diff --git a/src/domain/apple/appleBundleAnatomy.ts b/src/domain/apple/appleBundleAnatomy.ts index feaadc4ea5..d532ed11f6 100644 --- a/src/domain/apple/appleBundleAnatomy.ts +++ b/src/domain/apple/appleBundleAnatomy.ts @@ -1,6 +1,9 @@ import { z } from "zod"; -import { digestSchema, prefixedDigestSchema } from "../digests.js"; +import type { + ProjectedComponent as Component, + ProjectedInventoryEntry, +} from "../mobileApplicationGraphProjection.js"; import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; const pathSchema = z.string().min(1); @@ -9,14 +12,6 @@ const BUNDLE_EXTENSION = /\.(?:app|appex|xpc|framework|systemextension|dext|bundle|plugin|qlgenerator|mdimporter)$/iu; const MACH_O_FORMATS: readonly string[] = ["mach-o", "mach-o-universal"]; -/** One inventoried file or directory with its content identity. */ -export const appleComponentSchema = z.strictObject({ - path: pathSchema, - artifact_id: prefixedDigestSchema("art"), - sha256: digestSchema, - format: z.string().min(1), -}); - /** One application root or nested bundle classified by path convention. */ export const appleBundleSchema = z.strictObject({ path: pathSchema, @@ -42,16 +37,9 @@ export const appleBundleSchema = z.strictObject({ signing_paths: z.array(pathSchema), }); -type Component = z.infer; type Bundle = z.infer; -/** One inventory occurrence; symlinks and directories may lack content identity. */ -export interface AppleInventoryEntry { - readonly path: string; - readonly kind: "file" | "directory" | "symlink" | "slice"; - readonly component: Component | undefined; -} -type Entry = AppleInventoryEntry; +type Entry = Pick; /** Report whether an inventory format is a thin or universal Mach-O. */ export const isMachOFormat = (format: string): boolean => diff --git a/src/domain/harmony/harmonyApplication.ts b/src/domain/harmony/harmonyApplication.ts index 88fb19e0e7..f648e9cc52 100644 --- a/src/domain/harmony/harmonyApplication.ts +++ b/src/domain/harmony/harmonyApplication.ts @@ -13,12 +13,11 @@ import { projectCartesianCandidates, projectedBridgeCandidateSchema, projectedComponentSchema, - projectedComponents, + projectedInventoryEntries, projectionCoverage, projectionDigest, projectionEvidenceIdSchema, unavailableComponentSchema, - unavailableComponents, } from "../mobileApplicationGraphProjection.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; @@ -84,8 +83,13 @@ export const projectHarmonyApplication = ( parsed.inventory_evidence, ); const rootFormat = harmonyRootFormat(inventory.manifest.root_format); - const all = projectedComponents(inventory); - const unavailable = unavailableComponents(inventory); + const entries = projectedInventoryEntries(inventory); + const all = entries.flatMap(({ component }) => + component === undefined ? [] : [component], + ); + const unavailable = entries.flatMap(({ unavailable }) => + unavailable === undefined ? [] : [unavailable], + ); const classified = classify(all); const bridgeProjection = bridgeCandidates( classified.bytecode, diff --git a/src/domain/mobileApplicationGraphProjection.ts b/src/domain/mobileApplicationGraphProjection.ts index b143e44034..0aef81e811 100644 --- a/src/domain/mobileApplicationGraphProjection.ts +++ b/src/domain/mobileApplicationGraphProjection.ts @@ -73,50 +73,63 @@ export const projectionCoverage = ( inventory_complete: inventory.complete, }); -/** Flatten every inventoried occurrence into authenticated path components. */ -export const projectedComponents = ( +/** One occurrence, preserving entry kind and either authenticated or unavailable content. */ +export type ProjectedInventoryEntry = { + readonly path: string; + readonly kind: ParsedInventorySet["inventory"]["occurrences"][number]["entry_kind"]; +} & ( + | { readonly component: ProjectedComponent; readonly unavailable: undefined } + | { + readonly component: undefined; + readonly unavailable: z.infer; + } +); + +/** Interpret identity once, before application-specific attribution and classification. */ +export const projectedInventoryEntries = ( inventory: ParsedInventorySet["inventory"], -): ProjectedComponent[] => { +): ProjectedInventoryEntry[] => { const nodes = new Map( inventory.nodes.map((node) => [node.artifact_id, node]), ); return inventory.occurrences - .filter( - (occurrence) => - occurrence.artifact_id !== null && occurrence.logical_path !== ".", - ) - .map((occurrence) => { - const node = nodes.get(occurrence.artifact_id ?? ""); + .filter(({ logical_path }) => logical_path !== ".") + .map((occurrence): ProjectedInventoryEntry => { + const path = occurrence.logical_path; + const kind = occurrence.entry_kind; + if (occurrence.artifact_id === null) + return { + path, + kind, + component: undefined, + unavailable: { + path, + artifact_id: null, + sha256: null, + format: occurrence.artifact_format, + encrypted: occurrence.encrypted, + limitations: occurrence.limitations, + }, + }; + const node = nodes.get(occurrence.artifact_id); if (node === undefined) throw new TypeError( "Application projection occurrence has no artifact node", ); return { - path: occurrence.logical_path, - artifact_id: node.artifact_id, - sha256: node.sha256, - format: occurrence.artifact_format, - } satisfies ProjectedComponent; + path, + kind, + component: { + path, + artifact_id: node.artifact_id, + sha256: node.sha256, + format: occurrence.artifact_format, + }, + unavailable: undefined, + }; }); }; -export const unavailableComponents = ( - inventory: ParsedInventorySet["inventory"], -) => - inventory.occurrences - .filter( - (occurrence) => - occurrence.logical_path !== "." && occurrence.artifact_id === null, - ) - .map((occurrence) => ({ - path: occurrence.logical_path, - artifact_id: null, - sha256: null, - format: occurrence.artifact_format, - encrypted: occurrence.encrypted, - limitations: occurrence.limitations, - })); - export { bridgeCandidateCoverageSchema, projectCartesianCandidates }; /** Digest a canonical JSON projection payload for its stable identifier. */ diff --git a/tests/boundary/cli/appleUnavailableComponentsCli.test.ts b/tests/boundary/cli/appleUnavailableComponentsCli.test.ts new file mode 100644 index 0000000000..7066f08d39 --- /dev/null +++ b/tests/boundary/cli/appleUnavailableComponentsCli.test.ts @@ -0,0 +1,88 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect } from "vitest"; + +import { appleApplicationProjectionResultSchema } from "../../../src/domain/apple/appleApplication.js"; +import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "Apple CLI preserves unread paths and distinguishes readable coverage", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apple-unread-cli-"); + for (const encrypted of [false, true]) { + const path = join(root, `${encrypted ? "encrypted" : "readable"}.ipa`); + const unread = "Payload/Fixture.app/libentry.dylib"; + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("Payload/Fixture.app/", undefined, { directory: true }); + await writer.add( + "Payload/Fixture.app/Info.plist", + new TextReader(""), + ); + await writer.add( + unread, + new TextReader("harmless text"), + encrypted ? { password: "fixture-only" } : {}, + ); + await writeFile(path, await writer.close()); + const inspected = await cli.run({ + arguments: ["inspect-artifact", path, "--json"], + }); + expect(inspected.exitCode, inspected.stderr).toBe(0); + const inspection = artifactInspectionResultSchema.parse( + parseEvidence(inspected.json).normalized_result, + ); + const inventories = inspection.substeps.map(({ evidence }) => evidence); + const input = join(root, `${encrypted}.json`); + await writeFile( + input, + JSON.stringify({ inventory_evidence: inventories }), + ); + const output = await cli.run({ + arguments: ["project-apple-application-graph", input, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + const evidence = parseEvidence(output.json); + const projection = appleApplicationProjectionResultSchema.parse( + evidence.normalized_result, + ); + expect(projection.source_evidence_ids).toEqual( + inventories.map(({ evidence_id }) => evidence_id).sort(), + ); + expect(projection.coverage).toEqual({ + status: encrypted ? "partial" : "complete-within-inventory", + inventory_complete: true, + }); + if (encrypted) { + const occurrence = inventories[0]?.normalized_result.occurrences.find( + ({ logical_path }) => logical_path === unread, + ); + expect(occurrence).toMatchObject({ + artifact_id: null, + encrypted: true, + hash_status: "unavailable", + }); + expect(projection.unavailable_components).toEqual([ + { + path: unread, + artifact_id: null, + sha256: null, + format: occurrence?.artifact_format, + encrypted: true, + limitations: occurrence?.limitations, + }, + ]); + expect(projection.components.native_libraries).toEqual([]); + } else { + expect(projection.unavailable_components).toEqual([]); + expect( + projection.components.native_libraries.map(({ path }) => path), + ).toEqual([unread]); + } + } + }, +); diff --git a/tests/boundary/filesystem/appleApplication.test.ts b/tests/boundary/filesystem/appleApplication.test.ts index b067342d3e..c83dcaf589 100644 --- a/tests/boundary/filesystem/appleApplication.test.ts +++ b/tests/boundary/filesystem/appleApplication.test.ts @@ -6,6 +6,7 @@ import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter, + type ZipWriterAddDataOptions, } from "@zip.js/zip.js"; import { describe, expect, it } from "vitest"; @@ -223,6 +224,99 @@ describe("Apple application projection", () => { }); }); +const ENCRYPTED = { password: "fixture-only" }; +// A Unix symlink entry, which ZIP inventory reports without content identity. +const SYMLINK = { + msDosCompatible: false, + versionMadeBy: 0x0314, + externalFileAttributes: 0o120777 * 0x10000, +}; + +async function projectArchive( + name: string, + members: readonly (readonly [string, ZipWriterAddDataOptions?])[], +) { + const root = await createTestTempDirectory("rea-apple-unread-"); + const path = join(root, name); + const writer = new ZipWriter(new Uint8ArrayWriter()); + for (const [member, options = {}] of members) + await writer.add( + member, + options.directory ? undefined : new TextReader("fixture"), + options, + ); + await writeFile(path, await writer.close()); + const inventory = parseEvidence( + await runProviderAnalysis(path, "inventory_artifact", {}), + ); + return appleApplicationProjectionResultSchema.parse( + requireSuccessfulProjection( + projectAppleApplicationEvidence({ inventory_evidence: [inventory] }), + ).normalized_result, + ); +} + +it("retains an encrypted IPA native library as unknown component evidence", async () => { + const projection = await projectArchive("Encrypted.ipa", [ + ["Payload/Fixture.app/Info.plist"], + ["Payload/Fixture.app/libentry.dylib", ENCRYPTED], + ]); + + expect(projection.components.native_libraries).toEqual([]); + expect(projection.unavailable_components).toEqual([ + expect.objectContaining({ + path: "Payload/Fixture.app/libentry.dylib", + artifact_id: null, + sha256: null, + encrypted: true, + }), + ]); + expect(projection.coverage).toEqual({ + status: "partial", + inventory_complete: true, + }); + expect(projection.limitations).toContainEqual( + expect.stringContaining("component absence is unknown"), + ); +}); + +it("keeps symlinks out of unavailable components", async () => { + const projection = await projectArchive("Linked.zip", [ + ["Fixture.app/Contents/Info.plist"], + [ + "Fixture.app/Contents/Frameworks/Core.framework/Versions/Current", + SYMLINK, + ], + ]); + + expect(projection.symlinks).toEqual([ + "Fixture.app/Contents/Frameworks/Core.framework/Versions/Current", + ]); + expect(projection.unavailable_components).toEqual([]); + expect(projection.coverage.status).toBe("complete-within-inventory"); +}); + +it("applies application-root and sidecar rules to unread entries", async () => { + const projection = await projectArchive("Unread.zip", [ + ["Fixture.app/Contents/Info.plist"], + ["Fixture.app/Contents/MacOS/Fixture", ENCRYPTED], + ["Fixture.app/Contents/._Info.plist", ENCRYPTED], + ["Installer/setup.pkg", ENCRYPTED], + ]); + + expect(projection.application_roots).toEqual(["Fixture.app"]); + expect(projection.unavailable_components.map(({ path }) => path)).toEqual([ + "Fixture.app/Contents/MacOS/Fixture", + ]); + expect(projection.limitations).toContain( + "1 inventoried entries outside every application root are not attributed to the application.", + ); + expect(projection.coverage).toEqual({ + status: "partial", + inventory_complete: true, + }); +}); + describe("macOS application projection", () => { it("projects a macOS .app directory inventory with nested bundle roles", async () => { const root = await createTestTempDirectory("rea-apple-macos-"); @@ -344,3 +438,15 @@ describe("Apple application projection completeness", () => { }); }); }); + +it("keeps explicit directories out of unavailable file components", async () => { + const projection = await projectArchive("Directories.ipa", [ + ["Payload/Fixture.app/", { directory: true }], + ["Payload/Fixture.app/Info.plist"], + ]); + expect(projection.unavailable_components).toEqual([]); + expect(projection.coverage).toEqual({ + status: "complete-within-inventory", + inventory_complete: true, + }); +}); diff --git a/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts b/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts index fc092d466b..6dae74c18c 100644 --- a/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts +++ b/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts @@ -109,41 +109,66 @@ it("advertises and executes Android and Apple inventory projections", async () = }); }); -it("retains Harmony unavailable components and their source Evidence through MCP", async () => { - const client = await connect(); - const root = await createTestTempDirectory("rea-harmony-graph-mcp-"); - const path = join(root, "Locked.hap"); - const writer = new ZipWriter(new Uint8ArrayWriter()); - await writer.add("module.json", new TextReader("{}")); - await writer.add("ets/modules.abc", new TextReader("opaque bytecode"), { - password: "fixture-only", - }); - await writeFile(path, await writer.close()); - const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), - ); - const reply = await client.callTool({ - name: "project_harmony_application_graph", - arguments: { inventory_evidence: [inventory] }, - }); - if (reply.isError === true) throw new Error(JSON.stringify(reply)); - const evidence = parseEvidence(reply.structuredContent); - expect(evidence.normalized_result).toMatchObject({ - unavailable_components: [ - expect.objectContaining({ - path: "ets/modules.abc", - artifact_id: null, - sha256: null, - encrypted: true, - }), - ], - coverage: { status: "partial", inventory_complete: true }, - }); - const bundle = await client.callTool({ - name: "get_evidence_bundle", - arguments: {}, - }); - expect(bundle.structuredContent).toMatchObject({ - result: { records: expect.arrayContaining([inventory, evidence]) }, - }); -}); +it.each([ + { + family: "Harmony", + name: "Locked.hap", + manifest: "module.json", + unread: "ets/modules.abc", + operation: "project_harmony_application_graph", + }, + { + family: "Android", + name: "Locked.apk", + manifest: "AndroidManifest.xml", + unread: "classes.dex", + operation: "project_android_application_graph", + }, + { + family: "Apple", + name: "Locked.ipa", + manifest: "Payload/Locked.app/Info.plist", + unread: "Payload/Locked.app/libentry.dylib", + operation: "project_apple_application_graph", + }, +])( + "retains $family unavailable components and their source Evidence through MCP", + async ({ name, manifest, unread, operation }) => { + const client = await connect(); + const root = await createTestTempDirectory("rea-harmony-graph-mcp-"); + const path = join(root, name); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add(manifest, new TextReader("{}")); + await writer.add(unread, new TextReader("opaque bytecode"), { + password: "fixture-only", + }); + await writeFile(path, await writer.close()); + const inventory = parseEvidence( + await runProviderAnalysis(path, "inventory_artifact", {}), + ); + const reply = await client.callTool({ + name: operation, + arguments: { inventory_evidence: [inventory] }, + }); + if (reply.isError === true) throw new Error(JSON.stringify(reply)); + const evidence = parseEvidence(reply.structuredContent); + expect(evidence.normalized_result).toMatchObject({ + unavailable_components: [ + expect.objectContaining({ + path: unread, + artifact_id: null, + sha256: null, + encrypted: true, + }), + ], + coverage: { status: "partial", inventory_complete: true }, + }); + const bundle = await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + expect(bundle.structuredContent).toMatchObject({ + result: { records: expect.arrayContaining([inventory, evidence]) }, + }); + }, +);