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 <[email protected]>
This commit is contained in:
BaaRai
2026-10-11 20:09:21 +08:00
committed by GitHub
co-authored by morluto
parent b2da21abe1
commit f2150eb9a7
30 changed files with 1290 additions and 111 deletions
+79
View File
@@ -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/<module>/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/<abi>` 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.
+12
View File
@@ -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
+2
View File
@@ -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",
+71
View File
@@ -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 });
}
}
+16
View File
@@ -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."
}
+179
View File
@@ -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 });
}
+5 -1
View File
@@ -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<BinaryTarget, { kind: "archive" }>["format"] =>
["zip", "ipa", "apk", "msix", "appx", "asar", "dmg", "pkg"].includes(format);
isZipPackageFormat(format) || ["asar", "dmg", "pkg"].includes(format);
const sha256Handle = async (
handle: FileHandle,
@@ -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",
@@ -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<Evidence, AnalysisError> => {
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",
});
};
@@ -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,
+11 -9
View File
@@ -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<boolean> => {
const size = (await handle.stat()).size;
if (size < 512) return false;
+7 -12
View File
@@ -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<NodeJS.ProcessEnv>,
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.
+17
View File
@@ -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 = (
+1
View File
@@ -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. */
+24
View File
@@ -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] {
@@ -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",
},
),
],
};
+1
View File
@@ -457,6 +457,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
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,
+3
View File
@@ -53,6 +53,9 @@ const formatSchema = z.enum([
"apk",
"msix",
"appx",
"hap",
"hsp",
"app-pack",
"asar",
"dmg",
"pkg",
+31 -70
View File
@@ -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");
};
+7 -11
View File
@@ -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");
+3
View File
@@ -56,6 +56,9 @@ const artifactFormatSchema = z.enum([
"apk",
"msix",
"appx",
"hap",
"hsp",
"app-pack",
"plist",
"entitlements",
"directory",
+3
View File
@@ -102,6 +102,9 @@ export type BinaryTarget =
| "apk"
| "msix"
| "appx"
| "hap"
| "hsp"
| "app-pack"
| "asar"
| "dmg"
| "pkg";
+3
View File
@@ -48,6 +48,9 @@ const subjectSchema = z.object({
"apk",
"msix",
"appx",
"hap",
"hsp",
"app-pack",
"asar",
"dmg",
"pkg",
+246
View File
@@ -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<typeof componentSchema>;
/** 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";
@@ -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<typeof projectedComponentSchema>;
/** 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");
};
+25 -2
View File
@@ -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;
};
@@ -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: {
@@ -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]) },
});
});
@@ -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"),
);
});
});
+61 -1
View File
@@ -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<string> => {
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<Evidence> =>
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 = <Schema extends z.ZodType>(
project: (input: {
inventory_evidence: readonly Evidence[];
}) => Result<Evidence, AnalysisError>,
inventory: Evidence,
schema: Schema,
): z.output<Schema> => {
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.