From c192c58815ddeaf5cf30159e7082c742c0bc36b7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:45:54 +0800 Subject: [PATCH] fix(apple): retain unavailable application components Preserve unread file and slice facts before Apple attribution. Report partial component coverage while keeping manifest count completeness independent. Directories and symlinks retain their existing representation. Consolidate occurrence identity interpretation and component schemas across Apple, Android and Harmony; remove superseded producers. Android/Harmony completed source replies and projection IDs remain byte-identical in controls. Apple's additive unavailable_components field changes its projection IDs. Adapt the unavailable-component proposal and real ZIP regressions from PR #4692 by @rodricksz4h5, adding directory, CLI, MCP and non-encrypted controls. Validation: 77 focused checks; check:fast; docs:check (5 README checks). 18 tiny completed source cost controls; no real-provider/target execution or heavy profiling. Published npm package and large-input costs unverified. Fixes #4688 --- docs/apple-application-analysis.md | 9 ++ src/domain/android/androidApplication.ts | 12 +- src/domain/apple/appleApplication.test.ts | 64 +++++++++-- src/domain/apple/appleApplication.ts | 92 ++++++++------- src/domain/apple/appleBundleAnatomy.ts | 22 +--- src/domain/harmony/harmonyApplication.ts | 12 +- .../mobileApplicationGraphProjection.ts | 75 ++++++++----- .../cli/appleUnavailableComponentsCli.test.ts | 88 +++++++++++++++ .../filesystem/appleApplication.test.ts | 106 ++++++++++++++++++ .../mcp/mobileApplicationGraphMcp.test.ts | 101 ++++++++++------- 10 files changed, 432 insertions(+), 149 deletions(-) create mode 100644 tests/boundary/cli/appleUnavailableComponentsCli.test.ts 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]) }, + }); + }, +);