mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
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:
6 files changed
+215
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in new issue
Block a user