diff --git a/docs/android-analysis.md b/docs/android-analysis.md index 7d81a2b90..e6352544a 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -233,8 +233,10 @@ rea project-android-application-graph '{"inventory_evidence":[.app` followed by `Contents/` | +File and slice occurrences supply component endpoints. Directory tree identities +remain in source inventory and bundle anatomy, and links remain in `symlinks`. + Root and layout discovery use known directories and ancestors implied by deeper inventory paths. A leaf file or symlink named `*.app`, `Contents`, or `Versions` does not establish that directory. Directory and authenticated-file identity are diff --git a/docs/harmony-analysis.md b/docs/harmony-analysis.md index 3d61cf741..c0963dad5 100644 --- a/docs/harmony-analysis.md +++ b/docs/harmony-analysis.md @@ -45,6 +45,10 @@ The MCP tool `project_harmony_application_graph` takes the same - path-based N-API bridge hypotheses between bytecode and `libs/` native libraries +File and slice occurrences supply component and child-package paths; directory +trees and symlinks retain their identities in the source inventory without +being classified as code files. + Every claim is path-presence only. Manifest semantics (module identity, abilities, entry points, permissions, dependencies), Ark bytecode contents and versions, resource resolution, signing verification, and observed native diff --git a/src/domain/android/androidApplication.ts b/src/domain/android/androidApplication.ts index 6e92fea40..ade60df39 100644 --- a/src/domain/android/androidApplication.ts +++ b/src/domain/android/androidApplication.ts @@ -14,6 +14,7 @@ import { projectedBridgeCandidateSchema, projectedComponentSchema, projectedInventoryEntries, + projectedFileComponents, projectionCoverage, projectionDigest, projectionEvidenceIdSchema, @@ -94,9 +95,7 @@ export const projectAndroidApplication = ( `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 entries = projectedInventoryEntries(inventory); - const all = entries.flatMap(({ component }) => - component === undefined ? [] : [component], - ); + const all = projectedFileComponents(entries); const unavailable = entries.flatMap(({ unavailable }) => unavailable === undefined ? [] : [unavailable], ); diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts index da3168aad..8c013dc7d 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -28,6 +28,7 @@ import { import { projectedComponentSchema, projectedInventoryEntries, + projectedFileComponents, projectionDigest, unavailableComponentSchema, type ProjectedInventoryEntry, @@ -119,9 +120,7 @@ export const projectAppleApplication = ( ); const rootFormat = appleRootFormat(inventory.manifest.root_format); const entries = projectedInventoryEntries(inventory); - const inventoried = entries.flatMap(({ component }) => - component === undefined ? [] : [component], - ); + const inventoried = projectedFileComponents(entries); const { roots, bundles } = projectAppleBundleAnatomy(entries, { name: evidence[0]?.subject?.name ?? "", format: rootFormat, diff --git a/src/domain/harmony/harmonyApplication.ts b/src/domain/harmony/harmonyApplication.ts index f648e9cc5..53585f378 100644 --- a/src/domain/harmony/harmonyApplication.ts +++ b/src/domain/harmony/harmonyApplication.ts @@ -14,6 +14,7 @@ import { projectedBridgeCandidateSchema, projectedComponentSchema, projectedInventoryEntries, + projectedFileComponents, projectionCoverage, projectionDigest, projectionEvidenceIdSchema, @@ -84,9 +85,7 @@ export const projectHarmonyApplication = ( ); const rootFormat = harmonyRootFormat(inventory.manifest.root_format); const entries = projectedInventoryEntries(inventory); - const all = entries.flatMap(({ component }) => - component === undefined ? [] : [component], - ); + const all = projectedFileComponents(entries); const unavailable = entries.flatMap(({ unavailable }) => unavailable === undefined ? [] : [unavailable], ); diff --git a/src/domain/mobileApplicationGraphProjection.test.ts b/src/domain/mobileApplicationGraphProjection.test.ts new file mode 100644 index 000000000..f6fcc8973 --- /dev/null +++ b/src/domain/mobileApplicationGraphProjection.test.ts @@ -0,0 +1,52 @@ +import { expect, it } from "vitest"; + +import { + projectedFileComponents, + type ProjectedInventoryEntry, +} from "./mobileApplicationGraphProjection.js"; + +it("selects authenticated file and slice facts without reordering or deduplicating occurrences", () => { + const file: ProjectedInventoryEntry = { + path: "member", + kind: "file", + unavailable: undefined, + component: { + path: "member", + artifact_id: `art_${"a".repeat(64)}`, + sha256: "b".repeat(64), + format: "file", + }, + }; + const slice: ProjectedInventoryEntry = { + ...file, + kind: "slice", + component: { ...file.component, path: "member/arm64", format: "mach-o" }, + path: "member/arm64", + }; + const unread: ProjectedInventoryEntry = { + path: "unread", + kind: "file", + component: undefined, + unavailable: { + path: "unread", + artifact_id: null, + sha256: null, + format: "file", + encrypted: false, + limitations: ["Content was not read."], + }, + }; + const entries = Object.freeze([ + { ...file, kind: "directory" as const }, + file, + { ...file, kind: "symlink" as const }, + slice, + unread, + file, + ]); + const result = projectedFileComponents(entries); + expect(result).toEqual([file.component, slice.component, file.component]); + expect(result[0]).toBe(file.component); + expect(result[1]).toBe(slice.component); + expect(entries[4]).toBe(unread); +}); diff --git a/src/domain/mobileApplicationGraphProjection.ts b/src/domain/mobileApplicationGraphProjection.ts index 0aef81e81..b2866538a 100644 --- a/src/domain/mobileApplicationGraphProjection.ts +++ b/src/domain/mobileApplicationGraphProjection.ts @@ -130,6 +130,16 @@ export const projectedInventoryEntries = ( }); }; +/** Directory trees and links retain inventory identity but are not file components. */ +export const projectedFileComponents = ( + entries: readonly ProjectedInventoryEntry[], +): ProjectedComponent[] => + entries.flatMap(({ kind, component }) => + (kind === "file" || kind === "slice") && component !== undefined + ? [component] + : [], + ); + export { bridgeCandidateCoverageSchema, projectCartesianCandidates }; /** Digest a canonical JSON projection payload for its stable identifier. */ diff --git a/tests/acceptance/applications/mobileDirectoryComponents.test.ts b/tests/acceptance/applications/mobileDirectoryComponents.test.ts new file mode 100644 index 000000000..b1d20ec2a --- /dev/null +++ b/tests/acceptance/applications/mobileDirectoryComponents.test.ts @@ -0,0 +1,213 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { + TextReader, + Uint8ArrayReader, + Uint8ArrayWriter, + ZipWriter, +} from "@zip.js/zip.js"; +import { expect } from "vitest"; + +import { appleApplicationProjectionResultSchema } from "../../../src/domain/apple/appleApplication.js"; +import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { androidApplicationProjectionResultSchema } from "../../../src/domain/android/androidApplication.js"; +import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; +import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { harmonyApplicationProjectionResultSchema } from "../../../src/domain/harmony/harmonyApplication.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const harmony = { + family: "HarmonyOS", + extension: "hap", + command: "project-harmony-application-graph", + operation: "project_harmony_application_graph", + paths: [ + "module.json", + "ets/modules.abc", + "libs/arm64-v8a/libflutter.so", + "assets/main.js", + "resources.index", + "resources/base", + "META-INF/TEST.p7b", + ], + nestedCode: "ets/modules.abc/actual.abc", + runtimeFamilies: ["ark", "flutter", "javascript", "native"], + nestedRuntime: "ark", + bridgeCount: 1, +}; + +const fixtures = [ + { + family: "Android", + extension: "apk", + command: "project-android-application-graph", + operation: "project_android_application_graph", + paths: [ + "AndroidManifest.xml", + "resources.arsc", + "classes.dex", + "Main.class", + "lib/arm64-v8a/libflutter.so", + "META-INF/TEST.RSA", + ], + nestedCode: "classes.dex/actual.dex", + runtimeFamilies: ["dalvik-art", "flutter", "java-kotlin", "native"], + nestedRuntime: "dalvik-art", + bridgeCount: 2, + }, + { + family: "Apple", + extension: "ipa", + command: "project-apple-application-graph", + operation: "project_apple_application_graph", + paths: [ + "Payload/Fixture.app/Info.plist", + "Payload/Fixture.app/main.js", + "Payload/Fixture.app/libentry.dylib", + ], + nestedCode: "Payload/Fixture.app/main.js/actual.js", + runtimeFamilies: ["javascript", "native"], + nestedRuntime: "javascript", + bridgeCount: 1, + }, + harmony, + { + ...harmony, + family: "HarmonyOS App Pack", + extension: "app", + paths: [...harmony.paths, "entry.hap", "feature.hsp"], + }, +]; + +cliTest.for(fixtures)( + "preserves entry kinds in $family projections through CLI and stdio MCP", + { timeout: 60_000 }, + async (fixture, { cli }) => { + const root = await createTestTempDirectory("rea-mobile-directory-"); + const environment = { HOME: root, USERPROFILE: root }; + const { call } = await connectLocalToolsMcp(environment); + for (const mode of ["directories", "files", "nested"] as const) { + const writer = new ZipWriter(new Uint8ArrayWriter()); + for (const path of fixture.paths) { + if (mode !== "files") + await writer.add(`${path}/`, undefined, { directory: true }); + else + await writer.add( + path, + path.endsWith(".dylib") + ? new Uint8ArrayReader(thinMach(0xfeedfacf, 0x0100000c)) + : path.endsWith(".so") + ? new Uint8ArrayReader( + Uint8Array.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]), + ) + : new TextReader("opaque fixture content"), + ); + } + if (mode === "nested") + await writer.add(fixture.nestedCode, new TextReader("opaque bytecode")); + const bytes = await writer.close(); + const path = join(root, `${mode}.${fixture.extension}`); + await writeFile(path, bytes); + + const inspected = await cli.run({ + arguments: ["inspect-artifact", path, "--json"], + environment, + }); + expect(inspected.exitCode, inspected.stderr).toBe(0); + const inventoryEvidence = parseEvidence(inspected.json); + const inspection = artifactInspectionResultSchema.parse( + inventoryEvidence.normalized_result, + ); + const occurrences = inspection.substeps.flatMap( + ({ evidence }) => + artifactInventoryResultSchema.parse( + parseEvidence(evidence).normalized_result, + ).occurrences, + ); + for (const member of fixture.paths) + expect(occurrences, `${mode}: ${member}`).toContainEqual( + expect.objectContaining({ + logical_path: member, + entry_kind: mode === "files" ? "file" : "directory", + ...(mode === "files" ? {} : { artifact_format: "directory" }), + }), + ); + + const input = { inventory_evidence: [inventoryEvidence] }; + const originalInput = JSON.stringify(input); + const projected = await cli.run({ + arguments: [fixture.command, originalInput, "--json"], + environment, + }); + expect(projected.exitCode, projected.stderr).toBe(0); + const evidence = parseEvidence(projected.json); + const mcp = await call(fixture.operation, input); + expect(mcp.isError, JSON.stringify(mcp)).not.toBe(true); + expect(parseEvidence(mcp.structuredContent)).toEqual(evidence); + expect(JSON.stringify(input)).toBe(originalInput); + + const projection = + fixture.extension === "apk" + ? androidApplicationProjectionResultSchema.parse( + evidence.normalized_result, + ) + : fixture.extension === "ipa" + ? appleApplicationProjectionResultSchema.parse( + evidence.normalized_result, + ) + : harmonyApplicationProjectionResultSchema.parse( + evidence.normalized_result, + ); + const childPackages = fixture.paths.filter((member) => + /\.(?:hap|hsp)$/u.test(member), + ); + const categories = Object.values(projection.components); + for (const category of categories) + expect(new Set(category.map(({ path }) => path)).size).toBe( + category.length, + ); + const componentPaths = [ + ...new Set(categories.flat().map((component) => component.path)), + ].sort(); + expect(componentPaths, mode).toEqual( + mode === "files" + ? fixture.paths + .filter((member) => !childPackages.includes(member)) + .sort() + : mode === "nested" + ? [fixture.nestedCode] + : [], + ); + expect(projection.runtime_families, mode).toEqual( + mode === "files" + ? fixture.runtimeFamilies + : mode === "nested" + ? [fixture.nestedRuntime] + : [], + ); + expect(projection.bridge_candidates, mode).toHaveLength( + mode === "files" ? fixture.bridgeCount : 0, + ); + expect(projection.source_evidence_ids).toEqual([ + inventoryEvidence.evidence_id, + ]); + expect(projection.coverage).toEqual({ + status: "complete-within-inventory", + inventory_complete: true, + }); + if ("packaging_model" in projection) { + expect(projection.packaging_model, mode).toBe( + mode === "files" ? "stage" : "unknown", + ); + expect(projection.app_pack_children.map((child) => child.path)).toEqual( + mode === "files" ? childPackages : [], + ); + } + expect(await readFile(path)).toEqual(Buffer.from(bytes)); + } + }, +);