From f2150eb9a7ed76748238009ca6666efa52de5271 Mon Sep 17 00:00:00 2001 From: BaaRai <130491490+BaaRai@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:09:21 +0200 Subject: [PATCH] feat(harmony): project HarmonyOS package inventory evidence (#1447) * feat(harmony): project HarmonyOS package inventory evidence First HarmonyOS increment from #1431: HAP, HSP, and App Pack roots are classified through the hardened ZIP reader and projected into an execution-free application graph with packaging-model hints, exact component paths and hashes, App Pack children by path, runtime-family hints, and path-based N-API bridge hypotheses. HarmonyOS .har stays unclassified by suffix because .har also names HTTP Archive JSON. Adds the project_harmony_application_graph tool (CLI and MCP), synthetic composition coverage, a pinned real-package verify lane, and docs. * refactor(domain): share application graph projection scaffolding The HarmonyOS projection mirrors the Android and Apple projections by design, which pushed jscpd duplication over its 2.5 percent budget. Extract the identical pieces (inventory input schema, projected component schema and occurrence flattening, bridge candidate schema factory, coverage assembly, comparator, canonical digest) into domain/mobileApplicationGraphProjection.ts and route all three projections plus the composition fixtures through it. Duplication drops from 2.52 to 2.49 percent with no behavior change: projection ids, classifications, and limitations are byte-identical. * fix(harmony): address review, stable app-pack roots, and rotated fixture - rebased onto main; the shared projection scaffolding now keeps main's nativeRuntimeConvention loop and the harmony runtime families adopt the same react-native/flutter/unity path conventions - the .app App Pack suffix is now a root-input rule only (verified ZIP magic in classifyRootFormat and BinaryTargetResolver); nested .app ZIP members inside other packages keep the generic zip classification, with a regression test, so existing inventories do not change - the signing limitation now states that an empty signing list does not mean unsigned because packaging-tool signatures live in the ZIP signing block, and the app-pack root rule is disclosed as suffix-based - repinned the real-package fixture to the current VHome 2.6.14-beta asset after the upstream deleted v5.0.111-beta and replaced the v2.6.14-beta asset within hours; the pin records both observed digests --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/harmony-analysis.md | 79 +++++ docs/testing.md | 12 + package.json | 2 + scripts/fixtures/harmony/fetch.mjs | 71 +++++ scripts/fixtures/harmony/pipeline.json | 16 + scripts/verify/harmony/analysis.mjs | 179 ++++++++++++ src/application/BinaryTargetResolver.ts | 6 +- src/application/InvestigationProviders.ts | 7 + .../harmony/HarmonyApplicationService.ts | 28 ++ .../inventory/ArtifactGraphConstruction.ts | 6 +- src/artifacts/inventory/classify.ts | 20 +- src/artifacts/inventory/reader.ts | 19 +- src/cli/applicationCommands.ts | 17 ++ src/cliCommandNames.ts | 1 + src/contracts/applicationToolContracts.ts | 24 ++ .../mobileApplicationGraphExamples.ts | 22 +- src/contracts/toolEffects.ts | 1 + src/domain/analysisSnapshot.ts | 3 + src/domain/android/androidApplication.ts | 101 ++----- src/domain/apple/appleApplication.ts | 18 +- src/domain/artifactGraph.ts | 3 + src/domain/binaryTargetTypes.ts | 3 + src/domain/evidence.ts | 3 + src/domain/harmony/harmonyApplication.ts | 246 ++++++++++++++++ .../mobileApplicationGraphProjection.ts | 102 +++++++ src/domain/zipPackageFormat.ts | 27 +- .../projectMobileApplicationGraph.ts | 9 +- .../mcp/mobileApplicationGraphMcp.test.ts | 39 +++ tests/composition/harmony/application.test.ts | 275 ++++++++++++++++++ tests/support/applicationSessionFixture.ts | 62 +++- 30 files changed, 1290 insertions(+), 111 deletions(-) create mode 100644 docs/harmony-analysis.md create mode 100644 scripts/fixtures/harmony/fetch.mjs create mode 100644 scripts/fixtures/harmony/pipeline.json create mode 100644 scripts/verify/harmony/analysis.mjs create mode 100644 src/application/harmony/HarmonyApplicationService.ts create mode 100644 src/domain/harmony/harmonyApplication.ts create mode 100644 src/domain/mobileApplicationGraphProjection.ts create mode 100644 tests/composition/harmony/application.test.ts diff --git a/docs/harmony-analysis.md b/docs/harmony-analysis.md new file mode 100644 index 000000000..3d61cf741 --- /dev/null +++ b/docs/harmony-analysis.md @@ -0,0 +1,79 @@ +# Static HarmonyOS package inventory + +REA inventories HarmonyOS application packages through the shared hardened +ZIP artifact reader in `src/artifacts/`, then projects the authenticated +inventory into an execution-free application graph. This is the first +increment of the HarmonyOS roadmap (#1431): package identification and +component inventory only. + +Recognized root formats: + +- `.hap` — installable application module +- `.hsp` — runtime shared package +- `.app` — distribution App Pack (HAP/HSP children are listed by path). The + App Pack classification applies to verified-ZIP-magic root inputs only; a + nested ZIP member ending in `.app` inside another package, such as an IPA, + keeps the generic `zip` classification. + +HarmonyOS `.har` shared libraries are intentionally not classified by suffix: +`.har` also names HTTP Archive JSON, so a Harmony shared archive requires +verified ZIP bytes and is deferred with an explicit limitation. + +## Usage + +No external SDK, toolchain, or engine is required. Inventory the package and +project the graph; CLI and MCP use the same workflow and return +artifact-bound Evidence inline: + +```sh +rea inspect-artifact /path/to/Example.hap --format json > inspection.json +node --input-type=module -e 'import { readFileSync } from "node:fs"; console.log(JSON.stringify({ inventory_evidence: [JSON.parse(readFileSync("inspection.json", "utf8"))] }));' > projection-input.json +rea project-harmony-application-graph projection-input.json --format json +``` + +The MCP tool `project_harmony_application_graph` takes the same +`inventory_evidence` input. The projection reports: + +- exact component paths, artifact IDs, and hashes (manifests, Ark bytecode, + resources, native libraries, JavaScript, signing) +- a packaging-model hint: Stage is claimed only from a `module.json` entry, + FA only from `config.json`, otherwise `unknown` +- FA resources under `assets//resources/` as well as Stage `resources/` +- unavailable component paths with unknown IDs and hashes, including encrypted entries; these make projection coverage partial +- App Pack child package paths without recursive inventory +- runtime-family hints (`ark`, `native`, `javascript`) from paths and formats +- path-based N-API bridge hypotheses between bytecode and `libs/` native + libraries + +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 +boundaries are out of scope for this increment and stay in the projection's +`limitations` array until a dedicated HarmonyOS provider exists. REA fails +closed on packages whose inventory cannot be authenticated. + +## Verification lane + +`npm run fixtures:harmony` downloads the SHA-256-pinned 1.0.1 HAP release +from the MIT-licensed [harmony-next-pipeline source repository](https://github.com/ohosvscode/harmony-next-pipeline) +into ignored `_reference/harmony-integration/`. The pin records the source +revision, release asset ID, size, and digest. The downloaded artifact is not +redistributed; bundled component licenses have not been independently audited. + +```sh +npm run fixtures:harmony +REA_HARMONY_TEST_HAP=_reference/harmony-integration/entry-default-unsigned.hap \ + npm run verify:harmony +``` + +The lane exercises the compiled CLI and actual SDK-connected stdio MCP: +`inspect_artifact` authenticity, projection determinism, Stage-model path hints, +exact manifest/bytecode/resource component sets, empty native and bridge sets, +complete-inventory coverage, and CLI/MCP parity. It checks that this fixture has +no META-INF signing entries; that does not establish whether the package is signed. + +The release artifact's compiler/SDK versions and reproducible source build have +not been independently established. This lane does not complete the pinned +toolchain acceptance in #1431 and does not verify Ark decoding or signatures. +Set `REA_HARMONY_ENTRYPOINT` to an installed package's `scripts/rea.mjs` to run +the same checks against that package; the default lane uses the repository build. diff --git a/docs/testing.md b/docs/testing.md index fca45ae66..0b90c687f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -626,6 +626,18 @@ remain GUI-only surfaces in JEB 5.48.0 headless. Authenticated IPA and macOS application inventory projection is documented in [Apple application analysis](apple-application-analysis.md). +`npm run verify:harmony` requires no SDK or engine. It projects the pinned +harmony-next-pipeline 1.0.1 release HAP from an MIT-licensed source repository, +fetched by `npm run fixtures:harmony` into ignored +`_reference/harmony-integration/`, set as `REA_HARMONY_TEST_HAP`. +The lane exercises `inspect_artifact`, projection determinism, Stage-model +path hints, exact component sets, signing-entry limitations, and CLI/MCP parity. +The release artifact has no independently established compiler/SDK or +reproducible source build; the default lane does not verify an installed package. +See [HarmonyOS package inventory](harmony-analysis.md) for those proof boundaries. +HarmonyOS `.har` libraries are deliberately not suffix-classified because +`.har` also names HTTP Archive JSON. + Synthetic producer regressions run independently: ```sh diff --git a/package.json b/package.json index 06cda8c08..553970a12 100644 --- a/package.json +++ b/package.json @@ -185,6 +185,8 @@ "verify:android": "npm run build:cached && node scripts/verify/android/analysis.mjs", "verify:jeb": "npm run build:cached && node scripts/verify-jeb.mjs", "fixtures:android": "node scripts/fixtures/android/fetch.mjs", + "verify:harmony": "npm run build:cached && node scripts/verify/harmony/analysis.mjs", + "fixtures:harmony": "node scripts/fixtures/harmony/fetch.mjs", "verify:agent": "npm run build:cached && node scripts/verify-agent-experience.mjs", "verify:hopper:linux": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper-linux.mjs", "verify:setup:linux": "npm run build:cached && node scripts/verify-linux-setup-e2e.mjs", diff --git a/scripts/fixtures/harmony/fetch.mjs b/scripts/fixtures/harmony/fetch.mjs new file mode 100644 index 000000000..efc752185 --- /dev/null +++ b/scripts/fixtures/harmony/fetch.mjs @@ -0,0 +1,71 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { link, mkdir, open, rm } from "node:fs/promises"; +import { resolve, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import fixture from "./pipeline.json" with { type: "json" }; + +const args = process.argv.slice(2); +if (args.length > 1) + throw new Error("Usage: npm run fixtures:harmony -- [DIRECTORY]"); +const directory = resolve( + args[0] ?? + fileURLToPath( + new URL("../../../_reference/harmony-integration/", import.meta.url), + ), +); +await mkdir(directory, { recursive: true }); +const hashFile = async (path) => { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +}; +const path = join(directory, fixture.filename); +try { + const digest = await hashFile(path); + if (digest !== fixture.sha256) + throw new Error( + `Existing ${path} has a different SHA-256; it was not overwritten`, + ); + console.log(`Verified existing ${path}: ${digest}`); +} catch (cause) { + if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT")) + throw cause; + const temporary = `${path}.${randomUUID()}.partial`; + try { + const response = await fetch(fixture.url, { + signal: AbortSignal.timeout(300_000), + }); + if (!response.ok || response.body === null) + throw new Error( + `Download failed: ${fixture.url}: HTTP ${response.status}`, + ); + const handle = await open(temporary, "wx", 0o600); + const hash = createHash("sha256"); + let bytes = 0; + try { + for await (const chunk of response.body) { + bytes += chunk.length; + if (bytes > fixture.bytes) + throw new Error(`Download exceeds the pinned ${fixture.bytes} bytes`); + hash.update(chunk); + await handle.writeFile(chunk); + } + } finally { + await handle.close(); + } + if (bytes !== fixture.bytes) + throw new Error( + `Download size mismatch: expected ${fixture.bytes}, received ${bytes}`, + ); + const digest = hash.digest("hex"); + if (digest !== fixture.sha256) + throw new Error( + `SHA-256 mismatch for ${fixture.filename}: expected ${fixture.sha256}, received ${digest}`, + ); + await link(temporary, path); + console.log(`Downloaded and verified ${path}: ${digest}`); + } finally { + await rm(temporary, { force: true }); + } +} diff --git a/scripts/fixtures/harmony/pipeline.json b/scripts/fixtures/harmony/pipeline.json new file mode 100644 index 000000000..0a83aa3f5 --- /dev/null +++ b/scripts/fixtures/harmony/pipeline.json @@ -0,0 +1,16 @@ +{ + "name": "harmony-next-pipeline", + "version": "1.0.1", + "source": "https://github.com/ohosvscode/harmony-next-pipeline", + "source_revision": "196956a1c73e7f800887ef699eeff68b07129e98", + "license": "MIT", + "license_url": "https://github.com/ohosvscode/harmony-next-pipeline/blob/196956a1c73e7f800887ef699eeff68b07129e98/LICENSE", + "license_note": "The source repository is MIT licensed. The downloaded release is used locally, not redistributed; bundled component licenses were not independently audited.", + "url": "https://github.com/ohosvscode/harmony-next-pipeline/releases/download/v1.0.1/entry-default-unsigned.hap", + "asset_id": 223070058, + "filename": "entry-default-unsigned.hap", + "bytes": 112885, + "sha256": "928a4c6718125328695d2c32297ef5dfba2e06d16fd501d507d4aa32423cbad6", + "toolchain": null, + "provenance_note": "This is an upstream release artifact. Compiler and SDK versions and a reproducible source-to-artifact build have not been independently established." +} diff --git a/scripts/verify/harmony/analysis.mjs b/scripts/verify/harmony/analysis.mjs new file mode 100644 index 000000000..3aa90c9f0 --- /dev/null +++ b/scripts/verify/harmony/analysis.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { access, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +import { parseEvidence } from "../../../dist/domain/evidence.js"; +import { harmonyApplicationProjectionResultSchema } from "../../../dist/domain/harmony/harmonyApplication.js"; +import fixture from "../../fixtures/harmony/pipeline.json" with { type: "json" }; + +const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const hap = process.env.REA_HARMONY_TEST_HAP; +if (hap === undefined) + throw new Error( + "verify:harmony requires REA_HARMONY_TEST_HAP. Run npm run fixtures:harmony and set it to the downloaded file; see docs/harmony-analysis.md.", + ); +await access(hap); +const hashFile = async (path) => { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +}; +assert.equal( + await hashFile(hap), + fixture.sha256, + "Real HarmonyOS lane requires the pinned Harmony pipeline release fixture", +); +const entrypoint = + process.env.REA_HARMONY_ENTRYPOINT ?? resolve(repository, "scripts/rea.mjs"); +const execute = promisify(execFile); +const path = resolve(hap); +const workspace = await mkdtemp(join(tmpdir(), "rea-harmony-verify-")); + +try { + const inspectResponse = await execute( + process.execPath, + [entrypoint, "inspect-artifact", path, "--format", "json"], + { cwd: repository, timeout: 150_000, maxBuffer: 64 * 1024 * 1024 }, + ); + const inspection = parseEvidence(JSON.parse(inspectResponse.stdout)); + assert.equal(inspection.operation, "inspect_artifact"); + assert.equal(inspection.subject?.format, "hap"); + assert.equal(inspection.subject?.digest.sha256, fixture.sha256); + console.log("PASS CLI inspect_artifact"); + + const projectionInput = JSON.stringify({ + inventory_evidence: [inspection], + }); + const projectionFile = resolve(workspace, "projection-input.json"); + await writeFile(projectionFile, projectionInput); + const projectionResponse = await execute( + process.execPath, + [ + entrypoint, + "project-harmony-application-graph", + projectionFile, + "--format", + "json", + ], + { cwd: repository, timeout: 150_000, maxBuffer: 64 * 1024 * 1024 }, + ); + const projectionEvidence = parseEvidence( + JSON.parse(projectionResponse.stdout), + ); + assert.equal( + projectionEvidence.operation, + "project_harmony_application_graph", + ); + const projection = harmonyApplicationProjectionResultSchema.parse( + projectionEvidence.normalized_result, + ); + const repeatResponse = await execute( + process.execPath, + [ + entrypoint, + "project-harmony-application-graph", + projectionFile, + "--format", + "json", + ], + { cwd: repository, timeout: 150_000, maxBuffer: 64 * 1024 * 1024 }, + ); + assert.equal( + JSON.parse(repeatResponse.stdout).normalized_result.projection_id, + projection.projection_id, + "projection must be deterministic for the same inventory Evidence", + ); + + assert.equal(projection.root_format, "hap"); + assert.equal(projection.packaging_model, "stage"); + assert.deepEqual( + projection.components.manifests.map(({ path }) => path).sort(), + ["module.json", "pack.info"], + ); + assert.deepEqual( + projection.components.bytecode.map(({ path }) => path), + ["ets/modules.abc"], + ); + assert.deepEqual(projection.components.native_libraries, []); + assert.equal( + projection.components.resources.length, + 8, + "resources.index plus every resources/ entry", + ); + assert.ok( + projection.components.resources.some( + ({ path }) => path === "resources.index", + ), + ); + assert.equal(projection.components.javascript.length, 0); + assert.equal( + projection.components.signing.length, + 0, + "this fixture has no META-INF certificate entries; signing status is unverified", + ); + assert.deepEqual(projection.runtime_families, ["ark"]); + assert.deepEqual(projection.bridge_candidates, []); + assert.deepEqual(projection.unavailable_components, []); + assert.deepEqual(projection.coverage, { + status: "complete-within-inventory", + inventory_complete: true, + }); + assert.ok( + projection.limitations.some((limitation) => + limitation.startsWith( + "Manifest, module, resource, and signing semantics require a dedicated HarmonyOS provider", + ), + ), + ); + console.log("PASS CLI project_harmony_application_graph"); + + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + cwd: repository, + env: process.env, + stderr: "pipe", + }); + let stderr = ""; + transport.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + const client = new Client({ + name: "rea-real-harmony-verifier", + version: "1", + }); + try { + await client.connect(transport, { timeout: 30_000 }); + const response = await client.callTool( + { + name: "project_harmony_application_graph", + arguments: { inventory_evidence: [inspection] }, + }, + { timeout: 150_000 }, + ); + assert.notEqual(response.isError, true, JSON.stringify(response)); + assert.ok(response.structuredContent !== undefined); + const mcpEvidence = parseEvidence(response.structuredContent); + assert.deepEqual( + mcpEvidence.normalized_result, + projectionEvidence.normalized_result, + ); + console.log("PASS MCP/CLI parity project_harmony_application_graph"); + } catch (cause) { + process.stderr.write(stderr); + throw cause; + } finally { + await client.close(); + } +} finally { + await rm(workspace, { recursive: true, force: true }); +} diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 49e5c5dab..10f310731 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -29,6 +29,7 @@ import { import { mzWindowsHeaderOffset } from "../domain/dosMz.js"; import { hasZipSignature, + isZipPackageFormat, zipPackageFormatForPath, } from "../domain/zipPackageFormat.js"; @@ -203,6 +204,9 @@ const detectArtifactFormat = async ( const observed = await handle.read(magic, 0, magic.length, 0); throwIfTargetResolutionCancelled(signal); if (hasZipSignature(magic.subarray(0, observed.bytesRead))) { + // The HarmonyOS App Pack suffix `.app` is a root-input concept; a nested + // ZIP member ending in `.app` keeps the generic zip classification. + if (lower.endsWith(".app")) return "app-pack"; return zipPackageFormatForPath(lower) ?? "zip"; } const named = namedArtifactFormat(lower); @@ -243,7 +247,7 @@ const isArchiveFormat = ( "analysis-database" | "mach-o" | "elf" | "pe" | "dos-mz" | "dos-com" >, ): format is Extract["format"] => - ["zip", "ipa", "apk", "msix", "appx", "asar", "dmg", "pkg"].includes(format); + isZipPackageFormat(format) || ["asar", "dmg", "pkg"].includes(format); const sha256Handle = async ( handle: FileHandle, diff --git a/src/application/InvestigationProviders.ts b/src/application/InvestigationProviders.ts index 4e333ec27..d1da371f5 100644 --- a/src/application/InvestigationProviders.ts +++ b/src/application/InvestigationProviders.ts @@ -65,6 +65,13 @@ export const ANDROID_APPLICATION_PROVIDER = { version: "1", } as const; +/** Provider identity for deterministic HarmonyOS application projections. */ +export const HARMONY_APPLICATION_PROVIDER = { + id: "rea-harmony-application", + name: "REA HarmonyOS application workflows", + version: "1", +} as const; + /** Provider identity for verified publication of retained website scripts. */ export const WEB_SCRIPT_EXPORT_PROVIDER = { id: "rea-web-script-export", diff --git a/src/application/harmony/HarmonyApplicationService.ts b/src/application/harmony/HarmonyApplicationService.ts new file mode 100644 index 000000000..dcdc8489b --- /dev/null +++ b/src/application/harmony/HarmonyApplicationService.ts @@ -0,0 +1,28 @@ +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { Evidence } from "../../domain/evidence.js"; +import { + harmonyApplicationProjectionInputSchema, + projectHarmonyApplication, +} from "../../domain/harmony/harmonyApplication.js"; +import type { Result } from "../../domain/result.js"; +import { HARMONY_APPLICATION_PROVIDER } from "../InvestigationProviders.js"; +import { projectInventoryEvidence } from "../InventoryProjectionEvidence.js"; + +const OPERATION = "project_harmony_application_graph" as const; + +/** Project authenticated HarmonyOS package inventory Evidence into application evidence. */ +export const projectHarmonyApplicationEvidence = ( + rawInput: unknown, +): Result => { + return projectInventoryEvidence({ + rawInput, + schema: harmonyApplicationProjectionInputSchema, + project: projectHarmonyApplication, + operation: OPERATION, + predicateType: "rea.harmony-application-graph", + provider: HARMONY_APPLICATION_PROVIDER, + subjectFormat: (first) => first.subject?.format ?? "unknown", + protocolError: + "HarmonyOS application projection produced an invalid result", + }); +}; diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index 99d76e9f1..8466d7cd1 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -14,6 +14,7 @@ import type { } from "../../domain/artifactGraph.js"; import { hasZipSignature, + isZipPackageFormat, zipPackageFormatForPath, type ZipPackageFormat, } from "../../domain/zipPackageFormat.js"; @@ -217,9 +218,8 @@ export const rootOccurrenceFor = ( entry_kind: metadata.format === "directory" ? "directory" : "file", artifact_kind: metadata.format === "directory" || - ["zip", "ipa", "apk", "msix", "appx", "asar", "dmg", "pkg"].includes( - metadata.format, - ) + isZipPackageFormat(metadata.format) || + ["asar", "dmg", "pkg"].includes(metadata.format) ? "container" : artifactRoleForFormat(metadata.path, metadata.format).kind, artifact_format: metadata.format, diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index c6c3fb353..9dd050c8e 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -9,7 +9,9 @@ import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../ArtifactHash.js"; import type { ArtifactOccurrence } from "../../domain/artifactGraph.js"; import { hasZipSignature, + isZipPackageFormat, zipPackageFormatForPath, + type ZipPackageFormat, } from "../../domain/zipPackageFormat.js"; import { ArtifactReaderFailure } from "../ArtifactReader.js"; import type { ArtifactResourceOwner } from "../ArtifactResourceScope.js"; @@ -109,7 +111,8 @@ export const classifyAndHashRootForInventory = async ( initial, options.signal, ); - const retainSource = isZipFormat(format) || format === "mach-o-universal"; + const retainSource = + isZipPackageFormat(format) || format === "mach-o-universal"; outcome = { kind: "completed", value: { @@ -138,13 +141,6 @@ export const classifyAndHashRootForInventory = async ( return outcome.value; }; -const isZipFormat = (format: ArtifactOccurrence["artifact_format"]): boolean => - format === "zip" || - format === "ipa" || - format === "apk" || - format === "msix" || - format === "appx"; - const classifyRootFormat = async ( path: string, handle: FileHandle, @@ -153,7 +149,7 @@ const classifyRootFormat = async ( const observed = await handle.read(magic, 0, magic.length, 0); const prefix = magic.subarray(0, observed.bytesRead); const lower = path.toLowerCase(); - if (hasZipSignature(prefix)) return zipPackageFormatForPath(lower) ?? "zip"; + if (hasZipSignature(prefix)) return zipRootPackageFormat(lower); if ( lower.endsWith(".pkg") && prefix.subarray(0, 4).toString("ascii") === "xar!" @@ -165,6 +161,12 @@ const classifyRootFormat = async ( .format; }; +/** App Pack is a verified ZIP root role; nested .app members retain their observed format. */ +const zipRootPackageFormat = (lower: string): ZipPackageFormat => + lower.endsWith(".app") + ? "app-pack" + : (zipPackageFormatForPath(lower) ?? "zip"); + const hasKolyTrailer = async (handle: FileHandle): Promise => { const size = (await handle.stat()).size; if (size < 512) return false; diff --git a/src/artifacts/inventory/reader.ts b/src/artifacts/inventory/reader.ts index 471ca1ca7..8a171b566 100644 --- a/src/artifacts/inventory/reader.ts +++ b/src/artifacts/inventory/reader.ts @@ -5,7 +5,10 @@ import { MachOSliceArtifactReader } from "../MachOSliceArtifactReader.js"; import { NativeDmgArtifactReader } from "../NativeDmgArtifactReader.js"; import { ZipArtifactReader } from "../ZipArtifactReader.js"; import type { ArtifactOccurrence } from "../../domain/artifactGraph.js"; -import { ZIP_NON_ENTRY_TAIL_LIMITATION } from "../../domain/zipPackageFormat.js"; +import { + isZipPackageFormat, + ZIP_NON_ENTRY_TAIL_LIMITATION, +} from "../../domain/zipPackageFormat.js"; import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.js"; export const createReader = ( @@ -14,15 +17,11 @@ export const createReader = ( environment: Readonly, rootSource?: StableRegularFileDescriptor, ): ArtifactReader | undefined => { + if (isZipPackageFormat(format)) + return new ZipArtifactReader(path, format, undefined, rootSource); switch (format) { case "directory": return new DirectoryArtifactReader(path); - case "zip": - case "ipa": - case "apk": - case "msix": - case "appx": - return new ZipArtifactReader(path, format, undefined, rootSource); case "asar": return new AsarArtifactReader(path); case "mach-o-universal": @@ -42,11 +41,7 @@ export const inventoryLimitations = ( reader: ArtifactReader | undefined, ): string[] => { if (reader !== undefined) - return reader.format === "zip" || - reader.format === "ipa" || - reader.format === "apk" || - reader.format === "msix" || - reader.format === "appx" + return isZipPackageFormat(reader.format) ? [ZIP_NON_ENTRY_TAIL_LIMITATION] : []; // dmg and pkg reach here only after classifyRootFormat matched their bytes. diff --git a/src/cli/applicationCommands.ts b/src/cli/applicationCommands.ts index 5125814b1..75c63e09e 100644 --- a/src/cli/applicationCommands.ts +++ b/src/cli/applicationCommands.ts @@ -8,6 +8,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { androidApplicationProjectionInputSchema } from "../domain/android/androidApplication.js"; import { appleApplicationProjectionInputSchema } from "../domain/apple/appleApplication.js"; +import { harmonyApplicationProjectionInputSchema } from "../domain/harmony/harmonyApplication.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import type { Logger } from "pino"; import { traceApplicationFeatureInputSchema } from "../domain/javascript/javascriptFeatureTraceSchemas.js"; @@ -124,6 +125,22 @@ export const registerApplicationCommands = ( : result; }, }); + registerJsonCommand({ + cli, + logger, + name: CLI_COMMANDS.projectHarmonyApplicationGraph, + description: + "Project authenticated HarmonyOS HAP, HSP, or App Pack inventory Evidence into an application graph", + inputSchema: harmonyApplicationProjectionInputSchema, + workflow: async (input) => { + const { projectHarmonyApplicationEvidence } = + await import("../application/harmony/HarmonyApplicationService.js"); + const result = projectHarmonyApplicationEvidence(input); + return result.ok + ? { ok: true, value: jsonValueSchema.parse(result.value) } + : result; + }, + }); }; const registerObligationLedgerCommand = ( diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 8a8cc2b66..c2415fccf 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -128,6 +128,7 @@ export const CLI_COMMANDS = Object.freeze({ evaluateReconstructionCoverage: "evaluate-reconstruction-coverage", projectAndroidApplicationGraph: "project-android-application-graph", projectAppleApplicationGraph: "project-apple-application-graph", + projectHarmonyApplicationGraph: "project-harmony-application-graph", }); /** Ordered primary CLI inventory; aliases are intentionally excluded. */ diff --git a/src/contracts/applicationToolContracts.ts b/src/contracts/applicationToolContracts.ts index 379294e72..9a592b536 100644 --- a/src/contracts/applicationToolContracts.ts +++ b/src/contracts/applicationToolContracts.ts @@ -24,6 +24,10 @@ import { appleApplicationProjectionInputSchema, appleApplicationProjectionResultSchema, } from "../domain/apple/appleApplication.js"; +import { + harmonyApplicationProjectionInputSchema, + harmonyApplicationProjectionResultSchema, +} from "../domain/harmony/harmonyApplication.js"; import type { ToolContract } from "./toolContractTypes.js"; import { toolContractMetadata } from "./toolEffects.js"; import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; @@ -36,6 +40,7 @@ import { JAVASCRIPT_EXPORT_SHAPE_COMPARISON_EXAMPLE } from "./javascript/javascr import { ANDROID_APPLICATION_GRAPH_EXAMPLE, APPLE_APPLICATION_GRAPH_EXAMPLE, + HARMONY_APPLICATION_GRAPH_EXAMPLE, MACOS_APPLICATION_GRAPH_EXAMPLE, } from "./mobileApplicationGraphExamples.js"; @@ -296,6 +301,22 @@ export const APPLICATION_TOOL_CONTRACTS = [ }, ], }, + { + name: "project_harmony_application_graph", + ...toolContractMetadata("project_harmony_application_graph"), + description: + "Project authenticated HarmonyOS HAP, HSP, or App Pack inventory Evidence into an execution-free application inventory. Reports packaging-model hints (Stage module.json versus FA config.json), exact component paths and hashes (manifests, Ark bytecode, resources, native libraries, JavaScript, signing), App Pack child packages, runtime-family hints, and path-based N-API bridge hypotheses without decoding Ark bytecode, parsing manifest semantics, or claiming observed runtime calls.", + kind: "application", + inputSchema: harmonyApplicationProjectionInputSchema, + outputSchema: evidenceResultOf(harmonyApplicationProjectionResultSchema), + examples: [ + { + title: + "Project HAP inventory Evidence into a HarmonyOS application graph", + input: HARMONY_APPLICATION_GRAPH_EXAMPLE, + }, + ], + }, ] as const satisfies readonly ToolContract[]; /** Resolve one named application contract without relying on array position. */ @@ -326,6 +347,9 @@ export function applicationToolContract( export function applicationToolContract( name: "project_apple_application_graph", ): (typeof APPLICATION_TOOL_CONTRACTS)[8]; +export function applicationToolContract( + name: "project_harmony_application_graph", +): (typeof APPLICATION_TOOL_CONTRACTS)[9]; export function applicationToolContract( name: (typeof APPLICATION_TOOL_CONTRACTS)[number]["name"], ): (typeof APPLICATION_TOOL_CONTRACTS)[number] { diff --git a/src/contracts/mobileApplicationGraphExamples.ts b/src/contracts/mobileApplicationGraphExamples.ts index cb946d495..10b27e169 100644 --- a/src/contracts/mobileApplicationGraphExamples.ts +++ b/src/contracts/mobileApplicationGraphExamples.ts @@ -2,7 +2,10 @@ import { digestCanonicalValue } from "../domain/canonicalDigest.js"; import { createEvidence } from "../domain/evidence.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; -const inventory = (format: "apk" | "ipa" | "directory", digit: string) => { +const inventory = ( + format: "apk" | "ipa" | "directory" | "hap", + digit: string, +) => { const sha = digit.repeat(64); const artifactId = `art_${digestCanonicalValue({ sha256: sha }, "Artifact inventory")}`; const occurrenceId = `occ_${digestCanonicalValue( @@ -129,3 +132,20 @@ export const MACOS_APPLICATION_GRAPH_EXAMPLE = { ), ], }; + +/** Catalog example for HarmonyOS application inventory projection. */ +export const HARMONY_APPLICATION_GRAPH_EXAMPLE = { + inventory_evidence: [ + createEvidence( + { path: "Fixture.hap", sha256: "d".repeat(64), format: "hap" }, + provider, + { + operation: "inventory_artifact", + parameters: {}, + result: inventory("hap", "d"), + confidence: "observed", + authority: "shipped-artifact", + }, + ), + ], +}; diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 186b7bae7..4910e3c90 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -457,6 +457,7 @@ export const TOOL_EFFECTS: Readonly> = { evaluate_reconstruction_coverage: effects(), project_android_application_graph: evidence, project_apple_application_graph: evidence, + project_harmony_application_graph: evidence, open_binary: effects({ mutatesSession: true, launchesProcess: true }), close_binary: effects({ mutatesSession: true, diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index 98960ba55..a60b70524 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -53,6 +53,9 @@ const formatSchema = z.enum([ "apk", "msix", "appx", + "hap", + "hsp", + "app-pack", "asar", "dmg", "pkg", diff --git a/src/domain/android/androidApplication.ts b/src/domain/android/androidApplication.ts index 915873cfb..eb6c671e7 100644 --- a/src/domain/android/androidApplication.ts +++ b/src/domain/android/androidApplication.ts @@ -1,31 +1,28 @@ -import { createHash } from "node:crypto"; - -import canonicalize from "canonicalize"; import { z } from "zod"; import { parseArtifactInventoryEvidence } from "../artifactInventoryEvidence.js"; -import { evidenceSchema } from "../evidence.js"; import { digestSchema } from "../digests.js"; import { prefixedDigestSchema } from "../digests.js"; -import { - bridgeCandidateCoverageSchema, - projectCartesianCandidates, -} from "../bridgeCandidateProjection.js"; import { nativeRuntimeConvention } from "../nativeConvention.js"; +import { + applicationInventoryProjectionInputSchema, + bridgeCandidateCoverageSchema, + compareProjectionStrings, + projectCartesianCandidates, + projectedBridgeCandidateSchema, + projectedComponentSchema, + projectedComponents, + projectionCoverage, + projectionDigest, + projectionEvidenceIdSchema, +} from "../mobileApplicationGraphProjection.js"; -const evidenceIdSchema = prefixedDigestSchema("ev"); -const pathSchema = z.string().min(1); -const componentSchema = z.strictObject({ - path: pathSchema, - artifact_id: prefixedDigestSchema("art"), - sha256: digestSchema, - format: z.string().min(1), -}); +const evidenceIdSchema = projectionEvidenceIdSchema; +const componentSchema = projectedComponentSchema; /** Authenticated APK inventory pages projected as one Android application. */ -export const androidApplicationProjectionInputSchema = z.strictObject({ - inventory_evidence: z.array(evidenceSchema).min(1), -}); +export const androidApplicationProjectionInputSchema = + applicationInventoryProjectionInputSchema; /** Deterministic, execution-free Android application inventory projection. */ export const androidApplicationProjectionResultSchema = z.strictObject({ @@ -54,17 +51,13 @@ export const androidApplicationProjectionResultSchema = z.strictObject({ ]), ), bridge_candidates: z.array( - z.strictObject({ - managed_path: pathSchema, - native_path: pathSchema, - basis: z.enum([ - "managed-and-native-content", - "jni-library-convention", - "react-native-convention", - "flutter-convention", - "unity-convention", - ]), - }), + projectedBridgeCandidateSchema([ + "managed-and-native-content", + "jni-library-convention", + "react-native-convention", + "flutter-convention", + "unity-convention", + ] as const), ), bridge_candidate_coverage: bridgeCandidateCoverageSchema, coverage: z.strictObject({ @@ -94,27 +87,7 @@ 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 nodes = new Map( - inventory.nodes.map((node) => [node.artifact_id, node]), - ); - const all = inventory.occurrences - .filter( - (occurrence) => - occurrence.artifact_id !== null && occurrence.logical_path !== ".", - ) - .map((occurrence) => { - const node = nodes.get(occurrence.artifact_id ?? ""); - if (node === undefined) - throw new TypeError( - "Android application occurrence has no artifact node", - ); - return { - path: occurrence.logical_path, - artifact_id: node.artifact_id, - sha256: node.sha256, - format: occurrence.artifact_format, - } satisfies Component; - }); + const all = projectedComponents(inventory); const classified = classify(all); const bridgeProjection = bridgeCandidates( [...classified.dex, ...classified.jvm_classes], @@ -146,23 +119,20 @@ export const projectAndroidApplication = ( root_format: "apk" as const, source_evidence_ids: evidence .map(({ evidence_id: id }) => id) - .sort(compare), + .sort(compareProjectionStrings), components, runtime_families: runtimeFamilies(all), bridge_candidates: bridgeProjection.candidates, bridge_candidate_coverage: bridgeProjection.coverage, - coverage: { - status: - inventory.complete && bridgeProjection.coverage.status === "complete" - ? ("complete-within-inventory" as const) - : ("partial" as const), - inventory_complete: inventory.complete, - }, + coverage: projectionCoverage( + inventory, + bridgeProjection.coverage.status === "complete", + ), limitations, }; return androidApplicationProjectionResultSchema.parse({ ...withoutId, - projection_id: `adp_${digest(withoutId)}`, + projection_id: `adp_${projectionDigest(withoutId, "Android application projection")}`, }); }; @@ -198,7 +168,7 @@ const runtimeFamilies = (all: readonly Component[]) => { const convention = nativeRuntimeConvention(path); if (convention !== null) families.add(convention); } - return [...families].sort(compare); + return [...families].sort(compareProjectionStrings); }; const bridgeCandidates = ( @@ -226,12 +196,3 @@ const bridgeBasis = ( return "jni-library-convention"; return "managed-and-native-content"; }; - -const compare = (left: string, right: string): number => - left < right ? -1 : left > right ? 1 : 0; -const digest = (value: unknown): string => { - const encoded = canonicalize(value); - if (encoded === undefined) - throw new TypeError("Android application projection is not canonical JSON"); - return createHash("sha256").update(encoded).digest("hex"); -}; diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts index fae33f78d..bbf6519ee 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -1,6 +1,3 @@ -import { createHash } from "node:crypto"; - -import canonicalize from "canonicalize"; import { z } from "zod"; import { @@ -28,6 +25,10 @@ import { bridgeCandidateCoverageSchema, projectCartesianCandidates, } from "../bridgeCandidateProjection.js"; +import { + compareProjectionStrings, + projectionDigest, +} from "../mobileApplicationGraphProjection.js"; const evidenceIdSchema = prefixedDigestSchema("ev"); const pathSchema = z.string().min(1); @@ -437,11 +438,6 @@ const projectionLimitations = (facts: { "A bridge basis is inferred from the native path and is repeated for every managed component.", ]; -const compare = (left: string, right: string): number => - left < right ? -1 : left > right ? 1 : 0; -const digest = (value: unknown): string => { - const encoded = canonicalize(value); - if (encoded === undefined) - throw new TypeError("Apple application projection is not canonical JSON"); - return createHash("sha256").update(encoded).digest("hex"); -}; +const compare = compareProjectionStrings; +const digest = (value: unknown): string => + projectionDigest(value, "Apple application projection"); diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index f585658c9..8b5d763eb 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -56,6 +56,9 @@ const artifactFormatSchema = z.enum([ "apk", "msix", "appx", + "hap", + "hsp", + "app-pack", "plist", "entitlements", "directory", diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index e7d3db050..9296109cd 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -102,6 +102,9 @@ export type BinaryTarget = | "apk" | "msix" | "appx" + | "hap" + | "hsp" + | "app-pack" | "asar" | "dmg" | "pkg"; diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 6c1ef36c1..8aa6d4c65 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -48,6 +48,9 @@ const subjectSchema = z.object({ "apk", "msix", "appx", + "hap", + "hsp", + "app-pack", "asar", "dmg", "pkg", diff --git a/src/domain/harmony/harmonyApplication.ts b/src/domain/harmony/harmonyApplication.ts new file mode 100644 index 000000000..51dacd148 --- /dev/null +++ b/src/domain/harmony/harmonyApplication.ts @@ -0,0 +1,246 @@ +import { z } from "zod"; + +import { parseArtifactInventoryEvidence } from "../artifactInventoryEvidence.js"; +import { digestSchema } from "../digests.js"; +import { prefixedDigestSchema } from "../digests.js"; +import { nativeRuntimeConvention } from "../nativeConvention.js"; +import { + applicationInventoryProjectionInputSchema, + bridgeCandidateCoverageSchema, + compareProjectionStrings, + projectCartesianCandidates, + projectedBridgeCandidateSchema, + projectedComponentSchema, + projectedComponents, + projectionCoverage, + projectionDigest, + projectionEvidenceIdSchema, + projectionPathSchema, +} from "../mobileApplicationGraphProjection.js"; + +const evidenceIdSchema = projectionEvidenceIdSchema; +const componentSchema = projectedComponentSchema; + +const HARMONY_ROOT_FORMATS = ["hap", "hsp", "app-pack"] as const; +type RootFormat = (typeof HARMONY_ROOT_FORMATS)[number]; + +/** Authenticated HarmonyOS inventory pages projected as one application. */ +export const harmonyApplicationProjectionInputSchema = + applicationInventoryProjectionInputSchema; + +/** Deterministic, execution-free HarmonyOS application inventory projection. */ +export const harmonyApplicationProjectionResultSchema = z.strictObject({ + projection_id: prefixedDigestSchema("hmp"), + root_sha256: digestSchema, + root_format: z.enum(HARMONY_ROOT_FORMATS), + source_evidence_ids: z.array(evidenceIdSchema).min(1), + packaging_model: z.enum(["stage", "fa", "unknown"]), + components: z.strictObject({ + manifests: z.array(componentSchema), + bytecode: z.array(componentSchema), + resources: z.array(componentSchema), + native_libraries: z.array(componentSchema), + javascript: z.array(componentSchema), + signing: z.array(componentSchema), + }), + unavailable_components: z.array( + z.strictObject({ + path: projectionPathSchema, + artifact_id: z.null(), + sha256: z.null(), + format: z.string().min(1), + encrypted: z.boolean(), + limitations: z.array(z.string()), + }), + ), + app_pack_children: z.array(componentSchema), + runtime_families: z.array( + z.enum(["ark", "native", "javascript", "react-native", "flutter", "unity"]), + ), + bridge_candidates: z.array( + projectedBridgeCandidateSchema([ + "managed-and-native-content", + "napi-library-convention", + ] as const), + ), + bridge_candidate_coverage: bridgeCandidateCoverageSchema, + coverage: z.strictObject({ + status: z.enum(["complete-within-inventory", "partial"]), + inventory_complete: z.boolean(), + }), + limitations: z.array(z.string().min(1)), +}); + +export type HarmonyApplicationProjectionInput = z.infer< + typeof harmonyApplicationProjectionInputSchema +>; +export type HarmonyApplicationProjectionResult = z.infer< + typeof harmonyApplicationProjectionResultSchema +>; +type Component = z.infer; + +/** Project exact HarmonyOS package paths and hashes without decoding or executing target code. */ +export const projectHarmonyApplication = ( + input: HarmonyApplicationProjectionInput, +): HarmonyApplicationProjectionResult => { + const parsed = harmonyApplicationProjectionInputSchema.parse(input); + const { evidence, inventory } = parseArtifactInventoryEvidence( + parsed.inventory_evidence, + ); + const rootFormat = harmonyRootFormat(inventory.manifest.root_format); + const all = projectedComponents(inventory); + const unavailable = 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, + })); + const classified = classify(all); + const bridgeProjection = bridgeCandidates( + classified.bytecode, + classified.native_libraries, + ); + const limitations = [ + ...(!inventory.complete + ? ["Source inventory pages are incomplete; absence is unknown."] + : []), + ...(unavailable.length > 0 + ? [ + "Some inventoried occurrences have no authenticated content identity. Their paths and limitations are retained in unavailable_components; component and runtime absence is unknown.", + ] + : []), + "Manifest, module, resource, and signing semantics require a dedicated HarmonyOS provider; this projection reports exact inventory paths and hashes only.", + "The packaging model is a path-presence heuristic: a Stage model is claimed only from module.json and a FA model only from config.json; both can coexist with unusual layouts.", + "Runtime families are inferred from inventory formats and paths; filename suffixes do not establish valid Ark bytecode.", + "The signing component lists META-INF certificate-entry suffixes only; signatures applied by the packaging tool live in the ZIP signing block rather than archive entries, so an empty signing list does not mean the package is unsigned.", + "Bridge candidates are path-based hypotheses, not decoded N-API declarations or observed runtime calls.", + ...(rootFormat === "app-pack" + ? [ + "App Pack child packages are listed by inventory path only; their contents are not recursively inventoried by this projection.", + "The app-pack root classification is a verified-ZIP-magic plus .app suffix rule; the projection does not require pack.info or child packages before reporting the root format.", + ] + : []), + "HarmonyOS .har shared libraries are not identified by this projection; the .har suffix also names HTTP Archive JSON, so that classification requires verified ZIP bytes.", + ...(bridgeProjection.coverage.status === "partial" + ? [ + `Bridge candidate pairs exceeded the projection safety budget; ${bridgeProjection.coverage.omitted_candidates} hypotheses are omitted. Component arrays still include every component from the supplied inventory pages.`, + ] + : []), + ]; + const withoutId = { + root_sha256: inventory.manifest.root_sha256, + root_format: rootFormat, + source_evidence_ids: evidence + .map(({ evidence_id: id }) => id) + .sort(compareProjectionStrings), + packaging_model: packagingModel(classified.manifests), + components: classified, + unavailable_components: unavailable, + app_pack_children: + rootFormat === "app-pack" + ? all.filter(({ path }) => /\.(?:hap|hsp)$/iu.test(path)) + : [], + runtime_families: runtimeFamilies(all), + bridge_candidates: bridgeProjection.candidates, + bridge_candidate_coverage: bridgeProjection.coverage, + coverage: projectionCoverage( + inventory, + bridgeProjection.coverage.status === "complete" && + unavailable.length === 0, + ), + limitations, + }; + return harmonyApplicationProjectionResultSchema.parse({ + ...withoutId, + projection_id: `hmp_${projectionDigest(withoutId, "HarmonyOS application projection")}`, + }); +}; + +const harmonyRootFormat = (format: string): RootFormat => { + const root = HARMONY_ROOT_FORMATS.find((candidate) => candidate === format); + if (root === undefined) + throw new TypeError( + `HarmonyOS application projection requires HAP, HSP, or App Pack inventory Evidence (got ${format})`, + ); + return root; +}; + +const packagingModel = ( + manifests: readonly Component[], +): HarmonyApplicationProjectionResult["packaging_model"] => { + const stage = manifests.some(({ path }) => + /(?:^|\/)module\.json$/iu.test(path), + ); + const fa = manifests.some(({ path }) => /(?:^|\/)config\.json$/iu.test(path)); + if (stage) return "stage"; + if (fa) return "fa"; + return "unknown"; +}; + +const classify = (all: readonly Component[]) => ({ + manifests: all.filter(({ path }) => + /(?:^|\/)(?:module\.json|config\.json|pack\.info)$/iu.test(path), + ), + bytecode: all.filter(({ path }) => /\.(?:abc|pa)$/iu.test(path)), + resources: all.filter( + ({ path }) => + /(?:^|\/)resources\.index$/iu.test(path) || + /^(?:resources\/|assets\/[^/]+\/resources\/)/iu.test(path), + ), + native_libraries: all.filter( + ({ path, format }) => + format === "elf" || /(?:^|\/)libs\/[^/]+\/[^/]+\.so$/iu.test(path), + ), + javascript: all.filter( + ({ path, format }) => + format === "javascript-bundle" || /\.(?:m?js|cjs)$/iu.test(path), + ), + signing: all.filter(({ path }) => + /^META-INF\/[^/]+\.(?:p7b|sig|cer|crt|pem|provision)$/iu.test(path), + ), +}); + +const runtimeFamilies = (all: readonly Component[]) => { + const paths = all.map(({ path }) => path.toLowerCase()); + const families = new Set< + HarmonyApplicationProjectionResult["runtime_families"][number] + >(); + if (paths.some((path) => path.endsWith(".abc") || path.endsWith(".pa"))) + families.add("ark"); + if (all.some(({ format }) => format === "elf")) families.add("native"); + if (paths.some((path) => /\.(?:m?js|cjs)$/.test(path))) + families.add("javascript"); + for (const { path } of all) { + const convention = nativeRuntimeConvention(path); + if (convention !== null) families.add(convention); + } + return [...families].sort(compareProjectionStrings); +}; + +const bridgeCandidates = ( + managed: readonly Component[], + native: readonly Component[], +) => { + return projectCartesianCandidates({ + groups: [{ left: managed, right: native }], + createCandidate: (managed_path, target) => ({ + managed_path, + native_path: target.path, + basis: napiBasis(target.path), + }), + }); +}; + +const napiBasis = ( + path: string, +): HarmonyApplicationProjectionResult["bridge_candidates"][number]["basis"] => + /(?:^|\/)libs\/[^/]+\/lib[^/]+\.so$/iu.test(path) + ? "napi-library-convention" + : "managed-and-native-content"; diff --git a/src/domain/mobileApplicationGraphProjection.ts b/src/domain/mobileApplicationGraphProjection.ts new file mode 100644 index 000000000..328388ebe --- /dev/null +++ b/src/domain/mobileApplicationGraphProjection.ts @@ -0,0 +1,102 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; +import { z } from "zod"; + +import type { ParsedInventorySet } from "./artifactInventoryEvidence.js"; +import { evidenceSchema } from "./evidence.js"; +import { digestSchema } from "./digests.js"; +import { prefixedDigestSchema } from "./digests.js"; +import { + bridgeCandidateCoverageSchema, + projectCartesianCandidates, +} from "./bridgeCandidateProjection.js"; + +/** Evidence-scoped identifier schemas shared by application graph projections. */ +export const projectionEvidenceIdSchema = prefixedDigestSchema("ev"); +export const projectionPathSchema = z.string().min(1); + +/** Inventory pages accepted by every application graph projection. */ +export const applicationInventoryProjectionInputSchema = z.strictObject({ + inventory_evidence: z.array(evidenceSchema).min(1), +}); + +/** One authenticated inventory entry retained by application graph projections. */ +export const projectedComponentSchema = z.strictObject({ + path: projectionPathSchema, + artifact_id: prefixedDigestSchema("art"), + sha256: digestSchema, + format: z.string().min(1), +}); + +export type ProjectedComponent = z.infer; + +/** Path-pair hypotheses connecting managed content to a native library. */ +export const projectedBridgeCandidateSchema = < + Basis extends readonly [string, ...string[]], +>( + basis: Basis, +) => + z.strictObject({ + managed_path: projectionPathSchema, + native_path: projectionPathSchema, + basis: z.enum(basis), + }); + +/** Coverage status combining inventory completeness with a projection budget. */ +export const projectionCoverage = ( + inventory: ParsedInventorySet["inventory"], + budgetComplete: boolean, +): { + status: "complete-within-inventory" | "partial"; + inventory_complete: boolean; +} => ({ + status: + inventory.complete && budgetComplete + ? ("complete-within-inventory" as const) + : ("partial" as const), + inventory_complete: inventory.complete, +}); + +/** Flatten every inventoried occurrence into authenticated path components. */ +export const projectedComponents = ( + inventory: ParsedInventorySet["inventory"], +): ProjectedComponent[] => { + 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 ?? ""); + 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; + }); +}; + +export { bridgeCandidateCoverageSchema, projectCartesianCandidates }; + +/** Unicode code point ordering so projection arrays sort deterministically. */ +export const compareProjectionStrings = ( + left: string, + right: string, +): number => (left < right ? -1 : left > right ? 1 : 0); + +/** Digest a canonical JSON projection payload for its stable identifier. */ +export const projectionDigest = (value: unknown, label: string): string => { + const encoded = canonicalize(value); + if (encoded === undefined) + throw new TypeError(`${label} is not canonical JSON`); + return createHash("sha256").update(encoded).digest("hex"); +}; diff --git a/src/domain/zipPackageFormat.ts b/src/domain/zipPackageFormat.ts index 1544084e9..04a73065c 100644 --- a/src/domain/zipPackageFormat.ts +++ b/src/domain/zipPackageFormat.ts @@ -1,5 +1,21 @@ /** ZIP-backed application package families that share the hardened archive reader. */ -export type ZipPackageFormat = "zip" | "ipa" | "apk" | "msix" | "appx"; +export const ZIP_PACKAGE_FORMATS = [ + "zip", + "ipa", + "apk", + "msix", + "appx", + "hap", + "hsp", + "app-pack", +] as const; +export type ZipPackageFormat = (typeof ZIP_PACKAGE_FORMATS)[number]; + +/** All ZIP package families share archive ownership and inventory coverage. */ +export const isZipPackageFormat = ( + format: string, +): format is ZipPackageFormat => + ZIP_PACKAGE_FORMATS.some((candidate) => candidate === format); /** Recognize local-header, empty-archive, or split-archive ZIP signatures, not archive validity. */ export const hasZipSignature = (bytes: Uint8Array): boolean => @@ -14,7 +30,12 @@ export const hasZipSignature = (bytes: Uint8Array): boolean => * Classify a ZIP-backed package by its complete lower-cased path suffix. * * This is deliberately extension-only. Callers must separately verify ZIP - * magic before trusting the classification for a root input. + * magic before trusting the classification for a root input. HarmonyOS `.har` + * is intentionally absent: the suffix also names HTTP Archive JSON, so a + * Harmony shared archive is only established from verified ZIP bytes. The + * HarmonyOS App Pack suffix `.app` is also absent here: it applies only to + * root inputs (see `classifyAndHashRoot`), because a nested ZIP entry ending + * in `.app` inside another package, such as an IPA, is not an App Pack. */ export const zipPackageFormatForPath = ( path: string, @@ -26,6 +47,8 @@ export const zipPackageFormatForPath = ( if (lower.endsWith(".apk")) return "apk"; if (lower.endsWith(".msix") || lower.endsWith(".msixbundle")) return "msix"; if (lower.endsWith(".appx") || lower.endsWith(".appxbundle")) return "appx"; + if (lower.endsWith(".hap")) return "hap"; + if (lower.endsWith(".hsp")) return "hsp"; return undefined; }; diff --git a/src/server/registerApplicationTools/projectMobileApplicationGraph.ts b/src/server/registerApplicationTools/projectMobileApplicationGraph.ts index f82ff6405..195fcdd9f 100644 --- a/src/server/registerApplicationTools/projectMobileApplicationGraph.ts +++ b/src/server/registerApplicationTools/projectMobileApplicationGraph.ts @@ -4,6 +4,7 @@ import { runAdmittedToolOperation } from "../admittedToolOperation.js"; import { projectAndroidApplicationEvidence } from "../../application/android/AndroidApplicationService.js"; import { projectAppleApplicationEvidence } from "../../application/apple/AppleApplicationService.js"; +import { projectHarmonyApplicationEvidence } from "../../application/harmony/HarmonyApplicationService.js"; import { APPLICATION_TOOL_CONTRACTS, applicationToolContract, @@ -17,7 +18,7 @@ import { toolRegistrationOptions } from "../toolRegistrationOptions.js"; import { recordResult } from "./helpers.js"; import type { ApplicationToolRegistration } from "./types.js"; -/** Register execution-free Android and Apple inventory projection tools. */ +/** Register execution-free mobile application inventory projection tools. */ export const registerProjectMobileApplicationGraphTools = ( server: EvidenceMcpServer, options: ApplicationToolRegistration, @@ -34,6 +35,12 @@ export const registerProjectMobileApplicationGraphTools = ( contract: applicationToolContract("project_apple_application_graph"), project: projectAppleApplicationEvidence, }); + registerProjection({ + server, + options, + contract: applicationToolContract("project_harmony_application_graph"), + project: projectHarmonyApplicationEvidence, + }); }; const registerProjection = (context: { diff --git a/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts b/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts index 80173eba8..fc092d466 100644 --- a/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts +++ b/tests/boundary/mcp/mobileApplicationGraphMcp.test.ts @@ -108,3 +108,42 @@ it("advertises and executes Android and Apple inventory projections", async () = provider: { id: "rea-apple-application" }, }); }); + +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]) }, + }); +}); diff --git a/tests/composition/harmony/application.test.ts b/tests/composition/harmony/application.test.ts new file mode 100644 index 000000000..9665eb66c --- /dev/null +++ b/tests/composition/harmony/application.test.ts @@ -0,0 +1,275 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; + +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + createNonApplicationZipInventory, + inventoryApplicationPackage, + requireDeterministicProjection, + requireSuccessfulProjection, + writeApplicationZip, +} from "../../support/applicationSessionFixture.js"; + +import { projectHarmonyApplicationEvidence } from "../../../src/application/harmony/HarmonyApplicationService.js"; +import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js"; +import { harmonyApplicationProjectionResultSchema } from "../../../src/domain/harmony/harmonyApplication.js"; +import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; +import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; +import { + ZIP_PACKAGE_FORMATS, + ZIP_NON_ENTRY_TAIL_LIMITATION, +} from "../../../src/domain/zipPackageFormat.js"; + +const { runProviderAnalysis } = createDirectAnalysis({}); +const elf = Uint8Array.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]); + +const stageHapEntries = [ + { path: "module.json", content: '{"module":{"name":"entry"}}' }, + { path: "ets/modules.abc", content: "panda bytecode" }, + { path: "libs/arm64-v8a/libentry.so", content: elf }, + { path: "resources.index", content: "resource index" }, + { path: "resources/base/profile/main_pages.json", content: "{}" }, + { path: "assets/web/index.js", content: "bridge();" }, + { path: "META-INF/FIXTURE.p7b", content: "opaque signing" }, +] as const; + +describe("HarmonyOS application projection", () => { + it("projects deterministic Stage HAP components and explicit bridge hypotheses", async () => { + const path = await writeApplicationZip( + "rea-harmony-", + "Fixture.hap", + stageHapEntries, + ); + const inventory = await inventoryApplicationPackage(path); + expect(inventory.subject?.format).toBe("hap"); + const left = requireDeterministicProjection( + projectHarmonyApplicationEvidence, + inventory, + harmonyApplicationProjectionResultSchema, + ); + expect(left).toMatchObject({ + root_format: "hap", + packaging_model: "stage", + coverage: { + status: "complete-within-inventory", + inventory_complete: true, + }, + }); + expect(left.components.manifests.map(({ path }) => path)).toEqual([ + "module.json", + ]); + expect(left.components.bytecode.map(({ path }) => path)).toEqual([ + "ets/modules.abc", + ]); + expect(left.components.resources.map(({ path }) => path).sort()).toEqual([ + "resources.index", + "resources/base/profile/main_pages.json", + ]); + expect(left.components.native_libraries).toHaveLength(1); + expect(left.components.javascript).toHaveLength(1); + expect(left.components.signing).toHaveLength(1); + expect(left.runtime_families).toEqual(["ark", "javascript", "native"]); + expect(left.bridge_candidates).toEqual([ + expect.objectContaining({ + managed_path: "ets/modules.abc", + native_path: "libs/arm64-v8a/libentry.so", + basis: "napi-library-convention", + }), + ]); + expect(left.app_pack_children).toEqual([]); + expect(JSON.stringify(left)).not.toContain("opaque signing"); + expect(left.limitations).toContain( + "Manifest, module, resource, and signing semantics require a dedicated HarmonyOS provider; this projection reports exact inventory paths and hashes only.", + ); + }); + + it("projects App Pack children by path without recursive inventory", async () => { + const entryHap = await writeApplicationZip( + "rea-harmony-entry-", + "entry.hap", + [ + { path: "module.json", content: '{"module":{}}' }, + { path: "ets/modules.abc", content: "panda" }, + ], + ); + const featureHsp = await writeApplicationZip( + "rea-harmony-feature-", + "feature.hsp", + [{ path: "module.json", content: '{"module":{}}' }], + ); + const path = await writeApplicationZip("rea-harmony-app-", "Bundle.app", [ + { path: "pack.info", content: '{"packages":[]}' }, + { path: "entry.hap", content: new Uint8Array(await readFile(entryHap)) }, + { + path: "feature.hsp", + content: new Uint8Array(await readFile(featureHsp)), + }, + ]); + const inventory = await inventoryApplicationPackage(path); + expect(inventory.subject?.format).toBe("app-pack"); + const projection = requireDeterministicProjection( + projectHarmonyApplicationEvidence, + inventory, + harmonyApplicationProjectionResultSchema, + ); + expect(projection.root_format).toBe("app-pack"); + expect(projection.components.manifests.map(({ path }) => path)).toEqual([ + "pack.info", + ]); + expect(projection.app_pack_children.map(({ path }) => path).sort()).toEqual( + ["entry.hap", "feature.hsp"], + ); + expect(projection.packaging_model).toBe("unknown"); + expect(projection.limitations).toContain( + "App Pack child packages are listed by inventory path only; their contents are not recursively inventoried by this projection.", + ); + }); +}); + +describe("HarmonyOS packaging boundaries", () => { + it("keeps a nested .app ZIP member inside an IPA out of the App Pack classification", async () => { + const nested = await writeApplicationZip( + "rea-harmony-nested-", + "Nested.app", + [{ path: "inner.txt", content: "not an app pack" }], + ); + const path = await writeApplicationZip("rea-harmony-ipa-", "Fixture.ipa", [ + { path: "Payload/App.app/Info.plist", content: "plist" }, + { + path: "Payload/Nested.app", + content: new Uint8Array(await readFile(nested)), + }, + ]); + const inventory = await inventoryApplicationPackage(path); + expect(inventory.subject?.format).toBe("ipa"); + const occurrences = ( + inventory.normalized_result as { + occurrences: Array<{ + logical_path: string; + artifact_format: string; + }>; + } + ).occurrences; + const nestedOccurrence = occurrences.find( + ({ logical_path }) => logical_path === "Payload/Nested.app", + ); + expect(nestedOccurrence?.artifact_format).toBe("zip"); + }); + + it("claims the FA model only from config.json", async () => { + const path = await writeApplicationZip("rea-harmony-fa-", "Legacy.hap", [ + { path: "config.json", content: '{"app":{}}' }, + { path: "assets/js/default.js", content: "legacy();" }, + { path: "assets/entry/resources.index", content: "resource index" }, + { + path: "assets/entry/resources/base/element/strings.json", + content: "{}", + }, + { path: "assets/feature/resources/rawfile/icon.png", content: "image" }, + ]); + const inventory = await inventoryApplicationPackage(path); + const result = projectHarmonyApplicationEvidence({ + inventory_evidence: [inventory], + }); + const projection = harmonyApplicationProjectionResultSchema.parse( + requireSuccessfulProjection(result).normalized_result, + ); + expect(projection.packaging_model).toBe("fa"); + expect(projection.runtime_families).toEqual(["javascript"]); + expect(projection.components.resources.map(({ path }) => path)).toEqual([ + "assets/entry/resources.index", + "assets/entry/resources/base/element/strings.json", + "assets/feature/resources/rawfile/icon.png", + ]); + }); + + it("rejects non-HarmonyOS inventory Evidence", async () => { + const inventory = await createNonApplicationZipInventory( + "rea-harmony-invalid-", + ); + expect( + projectHarmonyApplicationEvidence({ inventory_evidence: [inventory] }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + }); + + it("keeps HTTP Archive .har files out of the HarmonyOS classification", async () => { + const root = await createTestTempDirectory("rea-harmony-http-har-"); + const path = join(root, "capture.har"); + await writeFile( + path, + JSON.stringify({ log: { version: "1.2", creator: {}, entries: [] } }), + ); + const result = await runProviderAnalysis(path, "inventory_artifact", {}); + expect(result).toMatchObject({ + error: "Analysis failed", + code: "target_unavailable", + }); + }); +}); + +describe("HarmonyOS inventory coverage", () => { + it.each(ZIP_PACKAGE_FORMATS)( + "retains archive identity and ZIP coverage for %s", + async (format) => { + const suffix = format === "app-pack" ? "app" : format; + const path = await writeApplicationZip( + "rea-package-family-", + `Fixture.${suffix}`, + [{ path: "module.json", content: "{}" }], + ); + const target = await parseBinaryTarget(path); + if (!target.ok) throw target.error; + expect(target.value).toMatchObject({ kind: "archive", format }); + const evidence = await inventoryApplicationPackage(path); + const inventory = artifactInventoryResultSchema.parse( + evidence.normalized_result, + ); + expect( + inventory.occurrences.find(({ logical_path }) => logical_path === "."), + ).toMatchObject({ + artifact_kind: "container", + artifact_format: format, + }); + expect(inventory.limitations).toContain(ZIP_NON_ENTRY_TAIL_LIMITATION); + }, + ); + + it("preserves unavailable encrypted component paths and qualifies absence", async () => { + const root = await createTestTempDirectory("rea-harmony-encrypted-"); + 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 evidence = await inventoryApplicationPackage(path); + const projection = requireDeterministicProjection( + projectHarmonyApplicationEvidence, + evidence, + harmonyApplicationProjectionResultSchema, + ); + expect(projection.components.bytecode).toEqual([]); + expect(projection.unavailable_components).toEqual([ + expect.objectContaining({ + path: "ets/modules.abc", + artifact_id: null, + sha256: null, + encrypted: true, + }), + ]); + expect(projection.coverage).toEqual({ + status: "partial", + inventory_complete: true, + }); + expect(projection.limitations).toContainEqual( + expect.stringContaining("component and runtime absence is unknown"), + ); + }); +}); diff --git a/tests/support/applicationSessionFixture.ts b/tests/support/applicationSessionFixture.ts index f0f08ab4b..c1db91743 100644 --- a/tests/support/applicationSessionFixture.ts +++ b/tests/support/applicationSessionFixture.ts @@ -1,7 +1,13 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { + TextReader, + Uint8ArrayReader, + Uint8ArrayWriter, + ZipWriter, +} from "@zip.js/zip.js"; +import { z } from "zod"; import { createDirectAnalysis } from "../../src/composition/directAnalysis.js"; import type { AnalysisError } from "../../src/domain/analysisErrorBase.js"; @@ -20,6 +26,60 @@ export const requireSuccessfulProjection = ( return result.value; }; +/** Write one synthetic ZIP application package from literal entries. */ +export const writeApplicationZip = async ( + prefix: string, + filename: string, + entries: ReadonlyArray<{ + readonly path: string; + readonly content: string | Uint8Array; + }>, +): Promise => { + const root = await createTestTempDirectory(prefix); + const path = join(root, filename); + const writer = new ZipWriter(new Uint8ArrayWriter()); + for (const entry of entries) + await writer.add( + entry.path, + typeof entry.content === "string" + ? new TextReader(entry.content) + : new Uint8ArrayReader(entry.content), + ); + await writeFile(path, await writer.close()); + return path; +}; + +/** Build inventory Evidence for a written package through the real pipeline. */ +export const inventoryApplicationPackage = async ( + path: string, +): Promise => + parseEvidence(await runProviderAnalysis(path, "inventory_artifact", {})); + +/** + * Project the same inventory Evidence twice and return the schema-parsed + * result, failing when the two projections are not byte-identical. + */ +export const requireDeterministicProjection = ( + project: (input: { + inventory_evidence: readonly Evidence[]; + }) => Result, + inventory: Evidence, + schema: Schema, +): z.output => { + const left = requireSuccessfulProjection( + project({ inventory_evidence: [inventory] }), + ); + const right = requireSuccessfulProjection( + project({ inventory_evidence: [inventory] }), + ); + if ( + JSON.stringify(left.normalized_result) !== + JSON.stringify(right.normalized_result) + ) + throw new TypeError("Expected repeated projection to be identical"); + return schema.parse(left.normalized_result); +}; + /** * Build inventory Evidence from a ZIP that is neither an APK nor an IPA. * Both mobile projections must reject it with an AnalysisInputError.