feat(harmony): disclose unclassified inventory files (#4968)

* feat(harmony): disclose unclassified inventory files

* test(harmony): let createEvidence own JSON admission in the partial-inventory case

* test(harmony): extract the projection assertion from the archive journey

* docs(harmony): qualify the unclassified disclosure rules and pin exact-case manifests

* test(harmony): replace limitation prose with structured coverage facts

The partial-inventory control froze the sentence "Source inventory coverage is
partial; absence is unknown." verbatim. docs/testing.md asserts structured
limit facts instead of freezing explanatory sentences and puts documentation
wording in review, so the sentence is no longer substring-matched.

`coverage.status` is a disjunction of independent causes in this projection:
incomplete inventory scope, unavailable content, and an exceeded bridge budget.
Asserting only `status: "partial"` left that attribution unstated, so the two
other causes are now pinned as well. `unavailable_components` being empty and
`bridge_candidate_coverage` being complete with zero candidates make the partial
result attributable to the missing occurrence alone.

Also drops an `artifact_id` comparison that could not fail: the fixture's four
files carry the same bytes, the preceding assertion already pins that shared
digest, and `artifact_id` is derived from the digest. Distinct paths sharing
content stay covered by the path array and the independent digest expectations.

Narrows a comment that claimed exact-case matching for every name in the
control; only the manifest names are case-sensitive, because the App Pack child
suffix match is case-insensitive.

* docs(harmony): name the disclosure location for a nested child package

The unclassified-disclosure paragraph used "there" to refer back to a clause
that had just named `app_pack_children`, so a reader could take a nested `.hap`
or `.hsp` member of a HAP or HSP root to be reported as an App Pack child. It is
reported in `unclassified_components`: the child list is populated only for an
App Pack root. Names the location explicitly.
This commit is contained in:
Ace authored and GitHub committed 2026-11-04 19:44:18 +08:00
1 parent b668cea2af
commit 848f165417
6 files changed
+215 -17

No files matched your search

+20
View File
@@ -36,6 +36,8 @@ The MCP tool `project_harmony_application_graph` takes the same
- exact component paths, artifact IDs, and hashes (manifests, Ark bytecode,
resources, native libraries, JavaScript, signing)
- `unclassified_components` with the same paths and content identities for
authenticated files outside those categories and the App Pack child list
- a packaging-model hint: Stage is claimed only from an exactly cased `module.json` entry
outside a classified resource path, FA only from `config.json` outside a
classified resource path, otherwise `unknown`
@@ -55,6 +57,24 @@ being classified as code files. Ark and JavaScript runtime hints summarize the
classified bytecode and JavaScript lists; supported source extensions share the
same classification rule as their component rows.
`unclassified_components` preserves files such as `pkgContextInfo.json`,
`.pages.info`, a HarmonyOS `.har` shared library, and a non-certificate
`META-INF` entry without interpreting their contents. The `.har` suffix also
names HTTP Archive JSON, so identifying one needs verified ZIP bytes rather than
a path rule. Different paths sharing the same bytes remain separate entries. App
Pack children of an App Pack root are already reported in `app_pack_children`,
so they are not repeated as unclassified files. A nested `.hap` or `.hsp` member
of a HAP or HSP root is disclosed in `unclassified_components` instead, because
child-package inventory is not recursive. Unreadable or encrypted content remains
in `unavailable_components` with unknown identity instead.
`coverage.status: "complete-within-inventory"` describes the authenticated
source inventory and bounded bridge projection, not semantic understanding of
every file. Unclassified files do not make a complete inventory partial; missing
inventory pages, source coverage limits, unavailable content and bridge limits
still qualify coverage independently. An empty unclassified list on a partial
inventory does not establish that the whole package has no unclassified files.
Every claim is path-presence only. Manifest semantics (module identity,
abilities, entry points, permissions, dependencies), Ark bytecode contents and
versions, resource resolution, signing verification, and observed native
+1 -1
View File
@@ -372,7 +372,7 @@ 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.",
"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), authenticated files outside those categories in unclassified_components, 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: applicationInventoryProjectionInputSchema,
outputSchema: evidenceResultOf(harmonyApplicationProjectionResultSchema),
+8
View File
@@ -43,6 +43,7 @@ export const harmonyApplicationProjectionResultSchema = z.strictObject({
javascript: z.array(projectedComponentSchema),
signing: z.array(projectedComponentSchema),
}),
unclassified_components: z.array(projectedComponentSchema),
unavailable_components: z.array(unavailableComponentSchema),
app_pack_children: z.array(
z.union([projectedComponentSchema, unavailableComponentSchema]),
@@ -97,6 +98,10 @@ export const projectHarmonyInventory = (
}
const manifests = classifyManifests(fileEntries);
const classified = classify(all, manifests.components);
const classifiedFiles = new Set([
...Object.values(classified).flat(),
...children,
]);
const bridgeProjection = bridgeCandidates(
classified.bytecode,
classified.native_libraries,
@@ -138,6 +143,9 @@ export const projectHarmonyInventory = (
.sort(compareUnicodeCodePoints),
packaging_model: manifests.packagingModel,
components: classified,
unclassified_components: all.filter(
(component) => !classifiedFiles.has(component),
),
unavailable_components: unavailable,
app_pack_children: children,
runtime_families: runtimeFamilies(all, classified),
@@ -260,6 +260,7 @@ cliTest.for(fixtures)(
inventory_complete: true,
});
if ("packaging_model" in projection) {
expect(projection.unclassified_components, mode).toEqual([]);
expect(projection.packaging_model, mode).toBe(
mode === "files" ? "stage" : "unknown",
);
+40 -16
View File
@@ -59,6 +59,8 @@ const harmonyFixture = async () => {
await writer.add("ets/current", new TextReader("modules.abc"), {
unixMode: 0o120777,
});
await writer.add("pkgContextInfo.json", new TextReader("{}"));
await writer.add(".pages.info", new TextReader("page metadata fixture"));
const javascriptPaths = ["cts", "jsx", "mts", "ts", "tsx"].map(
(extension) => `assets/js/index.${extension}`,
);
@@ -67,6 +69,43 @@ const harmonyFixture = async () => {
return { bytes: await writer.close(), javascriptPaths };
};
/** Assert the projected Harmony graph for one archive format over the MCP adapter. */
const expectHarmonyProjection = (
graph: HarmonyApplicationProjectionResult,
format: string,
sha256: string,
javascriptPaths: readonly string[],
): void => {
expect(graph).toMatchObject({
root_format: format,
root_sha256: sha256,
coverage: { status: "complete-within-inventory" },
unavailable_components: [],
unclassified_components: [
expect.objectContaining({
path: ".pages.info",
sha256: createHash("sha256")
.update("page metadata fixture")
.digest("hex"),
}),
expect.objectContaining({
path: "pkgContextInfo.json",
sha256: createHash("sha256").update("{}").digest("hex"),
}),
],
});
expect(graph.components.manifests.map(({ path: member }) => member)).toEqual([
"module.json",
]);
expect(graph.components.bytecode.map(({ path: member }) => member)).toEqual([
"ets/modules.abc",
]);
expect(graph.components.javascript.map(({ path: member }) => member)).toEqual(
javascriptPaths,
);
expect(graph.runtime_families).toEqual(["ark", "javascript"]);
};
test("preserves Harmony archive formats through the public CLI/MCP lifecycle", async ({
cli,
workspace,
@@ -157,22 +196,7 @@ test("preserves Harmony archive formats through the public CLI/MCP lifecycle", a
const graph = harmonyApplicationProjectionResultSchema.parse(
parseEvidence(projected.structuredContent).normalized_result,
);
expect(graph).toMatchObject({
root_format: format,
root_sha256: sha256,
coverage: { status: "complete-within-inventory" },
unavailable_components: [],
});
expect(
graph.components.manifests.map(({ path: member }) => member),
).toEqual(["module.json"]);
expect(
graph.components.bytecode.map(({ path: member }) => member),
).toEqual(["ets/modules.abc"]);
expect(
graph.components.javascript.map(({ path: member }) => member),
).toEqual(javascriptPaths);
expect(graph.runtime_families).toEqual(["ark", "javascript"]);
expectHarmonyProjection(graph, format, sha256, javascriptPaths);
const projectionInput = workspace.path("projection-input.json");
await writeFile(
projectionInput,
@@ -6,6 +6,7 @@ import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import { fragmentInventoryEvidence } from "../../../fixtures/artifactEvidence.js";
import {
createNonApplicationZipInventory,
inventoryApplicationPackage,
@@ -18,6 +19,7 @@ import { projectHarmonyApplicationEvidence } from "../../../../src/application/h
import { createDirectAnalysis } from "../../../../src/composition/directAnalysis.js";
import { harmonyApplicationProjectionResultSchema } from "../../../../src/domain/harmony/harmonyApplication.js";
import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js";
import { createEvidence } from "../../../../src/domain/evidence.js";
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
import {
ZIP_PACKAGE_FORMATS,
@@ -37,6 +39,62 @@ const stageHapEntries = [
{ path: "META-INF/FIXTURE.p7b", content: "opaque signing" },
] as const;
describe("HarmonyOS unclassified inventory", () => {
it("retains distinct unclassified paths sharing content with a classified file", async () => {
const path = await writeApplicationZip(
"rea-harmony-unclassified-",
"Fixture.hap",
[
{ path: "module.json", content: "{}" },
{ path: "pkgContextInfo.json", content: "{}" },
{ path: ".pages.info", content: "{}" },
{ path: "metadata-copy.json", content: "{}" },
{ path: "resources/base/bridge.js", content: "bridge();" },
],
);
const inventory = await inventoryApplicationPackage(path);
const original = JSON.stringify(inventory);
const projection = requireDeterministicProjection(
projectHarmonyApplicationEvidence,
inventory,
harmonyApplicationProjectionResultSchema,
);
expect(projection.unclassified_components).toEqual([
expect.objectContaining({
path: ".pages.info",
sha256:
"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
}),
expect.objectContaining({ path: "metadata-copy.json" }),
expect.objectContaining({ path: "pkgContextInfo.json" }),
]);
expect(projection.components.resources.map(({ path }) => path)).toEqual([
"resources/base/bridge.js",
]);
expect(projection.components.javascript.map(({ path }) => path)).toEqual([
"resources/base/bridge.js",
]);
expect(projection.coverage).toEqual({
status: "complete-within-inventory",
inventory_complete: true,
});
const pages = fragmentInventoryEvidence(inventory, 3);
const assembled = requireSuccessfulProjection(
projectHarmonyApplicationEvidence({
inventory_evidence: [...pages].reverse(),
}),
);
const fromPages = harmonyApplicationProjectionResultSchema.parse(
assembled.normalized_result,
);
expect(fromPages.unclassified_components).toEqual(
projection.unclassified_components,
);
expect(fromPages.coverage).toEqual(projection.coverage);
expect(JSON.stringify(inventory)).toBe(original);
});
});
it("retains inventoried certificate-block suffixes without claiming verified signatures", async () => {
const names = ["CERT.RSA", "CERT.DSA", "CERT.EC", "FIXTURE.p7b"];
const path = await writeApplicationZip(
@@ -147,6 +205,7 @@ describe("HarmonyOS application projection", () => {
}),
]);
expect(left.app_pack_children).toEqual([]);
expect(left.unclassified_components).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.",
@@ -189,6 +248,7 @@ describe("HarmonyOS application projection", () => {
expect(projection.app_pack_children.map(({ path }) => path).sort()).toEqual(
["entry.hap", "feature.hsp"],
);
expect(projection.unclassified_components).toEqual([]);
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.",
@@ -312,6 +372,76 @@ describe("HarmonyOS packaging boundaries", () => {
});
});
describe("HarmonyOS partial inventory classification", () => {
it("does not claim complete classification from a missing inventory occurrence", async () => {
const path = await writeApplicationZip(
"rea-harmony-partial-",
"Fixture.hap",
[
{ path: "module.json", content: "{}" },
{ path: ".pages.info", content: "page metadata" },
],
);
const inventory = await inventoryApplicationPackage(path);
if (inventory.subject === null) throw new Error("Missing fixture subject");
const result = artifactInventoryResultSchema.parse(
inventory.normalized_result,
);
const partial = createEvidence(
{
path: inventory.subject.local_path,
sha256: inventory.subject.digest.sha256,
format: inventory.subject.format,
},
inventory.provider,
{
operation: inventory.operation,
parameters: inventory.parameters,
result: {
...result,
occurrences: result.occurrences.filter(
({ logical_path }) => logical_path !== ".pages.info",
),
},
confidence: "derived",
authority: inventory.authority,
},
);
const projection = requireDeterministicProjection(
projectHarmonyApplicationEvidence,
partial,
harmonyApplicationProjectionResultSchema,
);
expect(projection.unclassified_components).toEqual([]);
// Coverage is the structured fact a partial inventory reports, and the two
// independent causes of a partial projection stay empty, so `partial` is
// attributable to the missing occurrence alone. The prose limitation that
// accompanies it is explanatory wording, so it stays in review rather than
// in a substring assertion.
expect(projection.coverage).toEqual({
status: "partial",
inventory_complete: false,
});
expect(projection.unavailable_components).toEqual([]);
expect(projection.bridge_candidate_coverage).toEqual({
status: "complete",
total_candidates: 0,
emitted_candidates: 0,
omitted_candidates: 0,
});
const tampered = {
...partial,
normalized_result: inventory.normalized_result,
};
expect(
projectHarmonyApplicationEvidence({ inventory_evidence: [tampered] }),
).toMatchObject({
ok: false,
error: { _tag: "EvidenceIntegrityError" },
});
});
});
describe("HarmonyOS inventory coverage", () => {
it("retains complete traversal and case-collision limits in the application projection", async () => {
const path = await writeApplicationZip(
@@ -375,6 +505,9 @@ describe("HarmonyOS inventory coverage", () => {
await writer.add("ets/modules.abc", new TextReader("opaque bytecode"), {
password: "fixture-only",
});
await writer.add("pkgContextInfo.json", new TextReader("{}"), {
password: "fixture-only",
});
await writeFile(path, await writer.close());
const evidence = await inventoryApplicationPackage(path);
const projection = requireDeterministicProjection(
@@ -383,6 +516,7 @@ describe("HarmonyOS inventory coverage", () => {
harmonyApplicationProjectionResultSchema,
);
expect(projection.components.bytecode).toEqual([]);
expect(projection.unclassified_components).toEqual([]);
expect(projection.unavailable_components).toEqual([
expect.objectContaining({
path: "ets/modules.abc",
@@ -390,6 +524,12 @@ describe("HarmonyOS inventory coverage", () => {
sha256: null,
encrypted: true,
}),
expect.objectContaining({
path: "pkgContextInfo.json",
artifact_id: null,
sha256: null,
encrypted: true,
}),
]);
expect(projection.coverage).toEqual({
status: "partial",
@@ -421,6 +561,11 @@ describe("HarmonyOS manifest path rules", () => {
);
expect(projection.packaging_model).toBe("unknown");
expect(projection.components.manifests).toEqual([]);
// Exact-case manifest names only, so these members belong to no manifest
// category and are disclosed as unclassified rather than silently dropped.
expect(
projection.unclassified_components.map(({ path }) => path).sort(),
).toEqual(["CONFIG.JSON", "MODULE.JSON", "PACK.INFO"]);
});
it.each([