diff --git a/docs/harmony-analysis.md b/docs/harmony-analysis.md new file mode 100644 index 0000000000..3d61cf7412 --- /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 fca45ae660..0b90c687f3 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 06cda8c086..553970a120 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 0000000000..efc752185d --- /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 0000000000..0a83aa3f54 --- /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 0000000000..3aa90c9f06 --- /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 49e5c5dab2..10f310731f 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 4e333ec27a..d1da371f5d 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 0000000000..dcdc8489b9 --- /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 99d76e9f14..8466d7cd14 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 c6c3fb3536..9dd050c8e4 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 471ca1ca7d..8a171b5666 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 5125814b1f..75c63e09ee 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 8a8cc2b669..c2415fccf6 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 379294e721..9a592b5364 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 cb946d4953..10b27e1693 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 186b7bae7a..4910e3c905 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 98960ba55c..a60b705247 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 915873cfb1..eb6c671e7d 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 fae33f78da..bbf6519ee6 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 f585658c91..8b5d763eb3 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 e7d3db050b..9296109cd6 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 6c1ef36c12..8aa6d4c654 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 0000000000..51dacd1489 --- /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 0000000000..328388ebe2 --- /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 1544084e9f..04a73065cc 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 f82ff6405e..195fcdd9f2 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 80173eba87..fc092d466b 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 0000000000..9665eb66c0 --- /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 f0f08ab4be..c1db917439 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.