mirror of
https://github.com/morluto/rea.git
synced 2026-10-18 20:10:13 -05:00
fix(mobile): classify authenticated file occurrences as components
Select file and slice identity once before Android, Apple and Harmony classification. Keep directory/link facts in source inventory and Apple anatomy rather than deriving code roles, runtime hints or bridges from them. Preserve occurrence order, multiplicity and existing unavailable facts. Adapt @flyl1u's proposal and public CLI/stdio MCP controls from PR #3830, extending the controls and structural fix to Apple and slice/link semantics. Validation: 80 focused checks, build, check:fast and docs:check pass. Four package controls fail before the fix. 72 tiny completed source cost tasks preserve all regular-file control replies and projection IDs byte for byte. No heavy profiling, target or real deep-provider execution; full agent/public-task costs and published npm package unverified. Fixes #3349
This commit is contained in:
1 parent
149a0fc06c
commit
a425b3e528
9 files changed
+292
-11
No files matched your search
@@ -233,8 +233,10 @@ rea project-android-application-graph '{"inventory_evidence":[<inventory_artifac
|
||||
```
|
||||
|
||||
The projection reports exact component paths and hashes, runtime-family hints,
|
||||
and path-based bridge hypotheses. It does not decode DEX or claim observed
|
||||
runtime calls. Framework hints match complete native library names, such as
|
||||
and path-based bridge hypotheses. File and slice occurrences supply component
|
||||
endpoints; directory trees and symlinks retain their identities in the source
|
||||
inventory without being classified as code files. It does not decode DEX or
|
||||
claim observed runtime calls. Framework hints match complete native library names, such as
|
||||
`libreactnativejni.so`, `libreactnative.so`, `libhermes.so`,
|
||||
`libhermes_executor.so`, `libflutter.so`, and `libunity.so`; unrelated names
|
||||
such as `libcommunity.so` and `libreactivity.so` do not identify those families.
|
||||
|
||||
@@ -77,6 +77,9 @@ including when empty, so Apple projection identifiers change.
|
||||
| `.app` directory | `.`, the inventoried bundle itself |
|
||||
| Directory, ZIP, or DMG containing apps | Each outermost `<Name>.app` followed by `Contents/` |
|
||||
|
||||
File and slice occurrences supply component endpoints. Directory tree identities
|
||||
remain in source inventory and bundle anatomy, and links remain in `symlinks`.
|
||||
|
||||
Root and layout discovery use known directories and ancestors implied by deeper
|
||||
inventory paths. A leaf file or symlink named `*.app`, `Contents`, or `Versions`
|
||||
does not establish that directory. Directory and authenticated-file identity are
|
||||
|
||||
@@ -45,6 +45,10 @@ The MCP tool `project_harmony_application_graph` takes the same
|
||||
- path-based N-API bridge hypotheses between bytecode and `libs/<abi>` native
|
||||
libraries
|
||||
|
||||
File and slice occurrences supply component and child-package paths; directory
|
||||
trees and symlinks retain their identities in the source inventory without
|
||||
being classified as code 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
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
projectedBridgeCandidateSchema,
|
||||
projectedComponentSchema,
|
||||
projectedInventoryEntries,
|
||||
projectedFileComponents,
|
||||
projectionCoverage,
|
||||
projectionDigest,
|
||||
projectionEvidenceIdSchema,
|
||||
@@ -94,9 +95,7 @@ export const projectAndroidApplication = (
|
||||
`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 entries = projectedInventoryEntries(inventory);
|
||||
const all = entries.flatMap(({ component }) =>
|
||||
component === undefined ? [] : [component],
|
||||
);
|
||||
const all = projectedFileComponents(entries);
|
||||
const unavailable = entries.flatMap(({ unavailable }) =>
|
||||
unavailable === undefined ? [] : [unavailable],
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import {
|
||||
projectedComponentSchema,
|
||||
projectedInventoryEntries,
|
||||
projectedFileComponents,
|
||||
projectionDigest,
|
||||
unavailableComponentSchema,
|
||||
type ProjectedInventoryEntry,
|
||||
@@ -119,9 +120,7 @@ export const projectAppleApplication = (
|
||||
);
|
||||
const rootFormat = appleRootFormat(inventory.manifest.root_format);
|
||||
const entries = projectedInventoryEntries(inventory);
|
||||
const inventoried = entries.flatMap(({ component }) =>
|
||||
component === undefined ? [] : [component],
|
||||
);
|
||||
const inventoried = projectedFileComponents(entries);
|
||||
const { roots, bundles } = projectAppleBundleAnatomy(entries, {
|
||||
name: evidence[0]?.subject?.name ?? "",
|
||||
format: rootFormat,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
projectedBridgeCandidateSchema,
|
||||
projectedComponentSchema,
|
||||
projectedInventoryEntries,
|
||||
projectedFileComponents,
|
||||
projectionCoverage,
|
||||
projectionDigest,
|
||||
projectionEvidenceIdSchema,
|
||||
@@ -84,9 +85,7 @@ export const projectHarmonyApplication = (
|
||||
);
|
||||
const rootFormat = harmonyRootFormat(inventory.manifest.root_format);
|
||||
const entries = projectedInventoryEntries(inventory);
|
||||
const all = entries.flatMap(({ component }) =>
|
||||
component === undefined ? [] : [component],
|
||||
);
|
||||
const all = projectedFileComponents(entries);
|
||||
const unavailable = entries.flatMap(({ unavailable }) =>
|
||||
unavailable === undefined ? [] : [unavailable],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
projectedFileComponents,
|
||||
type ProjectedInventoryEntry,
|
||||
} from "./mobileApplicationGraphProjection.js";
|
||||
|
||||
it("selects authenticated file and slice facts without reordering or deduplicating occurrences", () => {
|
||||
const file: ProjectedInventoryEntry = {
|
||||
path: "member",
|
||||
kind: "file",
|
||||
unavailable: undefined,
|
||||
component: {
|
||||
path: "member",
|
||||
artifact_id: `art_${"a".repeat(64)}`,
|
||||
sha256: "b".repeat(64),
|
||||
format: "file",
|
||||
},
|
||||
};
|
||||
const slice: ProjectedInventoryEntry = {
|
||||
...file,
|
||||
kind: "slice",
|
||||
component: { ...file.component, path: "member/arm64", format: "mach-o" },
|
||||
path: "member/arm64",
|
||||
};
|
||||
const unread: ProjectedInventoryEntry = {
|
||||
path: "unread",
|
||||
kind: "file",
|
||||
component: undefined,
|
||||
unavailable: {
|
||||
path: "unread",
|
||||
artifact_id: null,
|
||||
sha256: null,
|
||||
format: "file",
|
||||
encrypted: false,
|
||||
limitations: ["Content was not read."],
|
||||
},
|
||||
};
|
||||
const entries = Object.freeze([
|
||||
{ ...file, kind: "directory" as const },
|
||||
file,
|
||||
{ ...file, kind: "symlink" as const },
|
||||
slice,
|
||||
unread,
|
||||
file,
|
||||
]);
|
||||
const result = projectedFileComponents(entries);
|
||||
expect(result).toEqual([file.component, slice.component, file.component]);
|
||||
expect(result[0]).toBe(file.component);
|
||||
expect(result[1]).toBe(slice.component);
|
||||
expect(entries[4]).toBe(unread);
|
||||
});
|
||||
@@ -130,6 +130,16 @@ export const projectedInventoryEntries = (
|
||||
});
|
||||
};
|
||||
|
||||
/** Directory trees and links retain inventory identity but are not file components. */
|
||||
export const projectedFileComponents = (
|
||||
entries: readonly ProjectedInventoryEntry[],
|
||||
): ProjectedComponent[] =>
|
||||
entries.flatMap(({ kind, component }) =>
|
||||
(kind === "file" || kind === "slice") && component !== undefined
|
||||
? [component]
|
||||
: [],
|
||||
);
|
||||
|
||||
export { bridgeCandidateCoverageSchema, projectCartesianCandidates };
|
||||
|
||||
/** Digest a canonical JSON projection payload for its stable identifier. */
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
TextReader,
|
||||
Uint8ArrayReader,
|
||||
Uint8ArrayWriter,
|
||||
ZipWriter,
|
||||
} from "@zip.js/zip.js";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { appleApplicationProjectionResultSchema } from "../../../src/domain/apple/appleApplication.js";
|
||||
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
import { androidApplicationProjectionResultSchema } from "../../../src/domain/android/androidApplication.js";
|
||||
import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js";
|
||||
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { harmonyApplicationProjectionResultSchema } from "../../../src/domain/harmony/harmonyApplication.js";
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const harmony = {
|
||||
family: "HarmonyOS",
|
||||
extension: "hap",
|
||||
command: "project-harmony-application-graph",
|
||||
operation: "project_harmony_application_graph",
|
||||
paths: [
|
||||
"module.json",
|
||||
"ets/modules.abc",
|
||||
"libs/arm64-v8a/libflutter.so",
|
||||
"assets/main.js",
|
||||
"resources.index",
|
||||
"resources/base",
|
||||
"META-INF/TEST.p7b",
|
||||
],
|
||||
nestedCode: "ets/modules.abc/actual.abc",
|
||||
runtimeFamilies: ["ark", "flutter", "javascript", "native"],
|
||||
nestedRuntime: "ark",
|
||||
bridgeCount: 1,
|
||||
};
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
family: "Android",
|
||||
extension: "apk",
|
||||
command: "project-android-application-graph",
|
||||
operation: "project_android_application_graph",
|
||||
paths: [
|
||||
"AndroidManifest.xml",
|
||||
"resources.arsc",
|
||||
"classes.dex",
|
||||
"Main.class",
|
||||
"lib/arm64-v8a/libflutter.so",
|
||||
"META-INF/TEST.RSA",
|
||||
],
|
||||
nestedCode: "classes.dex/actual.dex",
|
||||
runtimeFamilies: ["dalvik-art", "flutter", "java-kotlin", "native"],
|
||||
nestedRuntime: "dalvik-art",
|
||||
bridgeCount: 2,
|
||||
},
|
||||
{
|
||||
family: "Apple",
|
||||
extension: "ipa",
|
||||
command: "project-apple-application-graph",
|
||||
operation: "project_apple_application_graph",
|
||||
paths: [
|
||||
"Payload/Fixture.app/Info.plist",
|
||||
"Payload/Fixture.app/main.js",
|
||||
"Payload/Fixture.app/libentry.dylib",
|
||||
],
|
||||
nestedCode: "Payload/Fixture.app/main.js/actual.js",
|
||||
runtimeFamilies: ["javascript", "native"],
|
||||
nestedRuntime: "javascript",
|
||||
bridgeCount: 1,
|
||||
},
|
||||
harmony,
|
||||
{
|
||||
...harmony,
|
||||
family: "HarmonyOS App Pack",
|
||||
extension: "app",
|
||||
paths: [...harmony.paths, "entry.hap", "feature.hsp"],
|
||||
},
|
||||
];
|
||||
|
||||
cliTest.for(fixtures)(
|
||||
"preserves entry kinds in $family projections through CLI and stdio MCP",
|
||||
{ timeout: 60_000 },
|
||||
async (fixture, { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-mobile-directory-");
|
||||
const environment = { HOME: root, USERPROFILE: root };
|
||||
const { call } = await connectLocalToolsMcp(environment);
|
||||
for (const mode of ["directories", "files", "nested"] as const) {
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
for (const path of fixture.paths) {
|
||||
if (mode !== "files")
|
||||
await writer.add(`${path}/`, undefined, { directory: true });
|
||||
else
|
||||
await writer.add(
|
||||
path,
|
||||
path.endsWith(".dylib")
|
||||
? new Uint8ArrayReader(thinMach(0xfeedfacf, 0x0100000c))
|
||||
: path.endsWith(".so")
|
||||
? new Uint8ArrayReader(
|
||||
Uint8Array.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]),
|
||||
)
|
||||
: new TextReader("opaque fixture content"),
|
||||
);
|
||||
}
|
||||
if (mode === "nested")
|
||||
await writer.add(fixture.nestedCode, new TextReader("opaque bytecode"));
|
||||
const bytes = await writer.close();
|
||||
const path = join(root, `${mode}.${fixture.extension}`);
|
||||
await writeFile(path, bytes);
|
||||
|
||||
const inspected = await cli.run({
|
||||
arguments: ["inspect-artifact", path, "--json"],
|
||||
environment,
|
||||
});
|
||||
expect(inspected.exitCode, inspected.stderr).toBe(0);
|
||||
const inventoryEvidence = parseEvidence(inspected.json);
|
||||
const inspection = artifactInspectionResultSchema.parse(
|
||||
inventoryEvidence.normalized_result,
|
||||
);
|
||||
const occurrences = inspection.substeps.flatMap(
|
||||
({ evidence }) =>
|
||||
artifactInventoryResultSchema.parse(
|
||||
parseEvidence(evidence).normalized_result,
|
||||
).occurrences,
|
||||
);
|
||||
for (const member of fixture.paths)
|
||||
expect(occurrences, `${mode}: ${member}`).toContainEqual(
|
||||
expect.objectContaining({
|
||||
logical_path: member,
|
||||
entry_kind: mode === "files" ? "file" : "directory",
|
||||
...(mode === "files" ? {} : { artifact_format: "directory" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const input = { inventory_evidence: [inventoryEvidence] };
|
||||
const originalInput = JSON.stringify(input);
|
||||
const projected = await cli.run({
|
||||
arguments: [fixture.command, originalInput, "--json"],
|
||||
environment,
|
||||
});
|
||||
expect(projected.exitCode, projected.stderr).toBe(0);
|
||||
const evidence = parseEvidence(projected.json);
|
||||
const mcp = await call(fixture.operation, input);
|
||||
expect(mcp.isError, JSON.stringify(mcp)).not.toBe(true);
|
||||
expect(parseEvidence(mcp.structuredContent)).toEqual(evidence);
|
||||
expect(JSON.stringify(input)).toBe(originalInput);
|
||||
|
||||
const projection =
|
||||
fixture.extension === "apk"
|
||||
? androidApplicationProjectionResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
)
|
||||
: fixture.extension === "ipa"
|
||||
? appleApplicationProjectionResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
)
|
||||
: harmonyApplicationProjectionResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
const childPackages = fixture.paths.filter((member) =>
|
||||
/\.(?:hap|hsp)$/u.test(member),
|
||||
);
|
||||
const categories = Object.values(projection.components);
|
||||
for (const category of categories)
|
||||
expect(new Set(category.map(({ path }) => path)).size).toBe(
|
||||
category.length,
|
||||
);
|
||||
const componentPaths = [
|
||||
...new Set(categories.flat().map((component) => component.path)),
|
||||
].sort();
|
||||
expect(componentPaths, mode).toEqual(
|
||||
mode === "files"
|
||||
? fixture.paths
|
||||
.filter((member) => !childPackages.includes(member))
|
||||
.sort()
|
||||
: mode === "nested"
|
||||
? [fixture.nestedCode]
|
||||
: [],
|
||||
);
|
||||
expect(projection.runtime_families, mode).toEqual(
|
||||
mode === "files"
|
||||
? fixture.runtimeFamilies
|
||||
: mode === "nested"
|
||||
? [fixture.nestedRuntime]
|
||||
: [],
|
||||
);
|
||||
expect(projection.bridge_candidates, mode).toHaveLength(
|
||||
mode === "files" ? fixture.bridgeCount : 0,
|
||||
);
|
||||
expect(projection.source_evidence_ids).toEqual([
|
||||
inventoryEvidence.evidence_id,
|
||||
]);
|
||||
expect(projection.coverage).toEqual({
|
||||
status: "complete-within-inventory",
|
||||
inventory_complete: true,
|
||||
});
|
||||
if ("packaging_model" in projection) {
|
||||
expect(projection.packaging_model, mode).toBe(
|
||||
mode === "files" ? "stage" : "unknown",
|
||||
);
|
||||
expect(projection.app_pack_children.map((child) => child.path)).toEqual(
|
||||
mode === "files" ? childPackages : [],
|
||||
);
|
||||
}
|
||||
expect(await readFile(path)).toEqual(Buffer.from(bytes));
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user