mirror of
https://github.com/morluto/rea.git
synced 2026-11-02 21:06:04 +00:00
* fix(hopper): normalize string-backed addresses and update verification * feat(native): add inspection primitives, dispatch traces and approved UI observation * test: prioritize real artifact E2E and captured golden regressions * fix(process): queue observed output before reactive rendering actions * test: prune redundant cases and strengthen boundary assertions * fix(cli): route native value tracing through shared workflows * test: replace synthetic success with real workflows and captured data * fix(catalog): separate portable metadata from native host availability
268 lines
8.2 KiB
TypeScript
268 lines
8.2 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { lstat } from "node:fs/promises";
|
|
import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
|
|
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
|
import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js";
|
|
import {
|
|
appleAssetCatalogInputSchema,
|
|
parseAppleAssetCatalogRecords,
|
|
projectAppleAssetCatalogPage,
|
|
} from "../domain/appleAssetCatalog.js";
|
|
import { execFileOutput } from "../process/ExecFileOutput.js";
|
|
|
|
const ASSETUTIL = "/usr/bin/assetutil";
|
|
const MAX_CATALOG_BYTES = 64 * 1024 * 1024;
|
|
const MAX_TOTAL_METADATA_BYTES = 64 * 1024 * 1024;
|
|
const RESOURCE_KEY_FIELDS = new Set([
|
|
"image",
|
|
"imageName",
|
|
"image_name",
|
|
"resourceName",
|
|
"resource_name",
|
|
"assetName",
|
|
"asset_name",
|
|
"UIImageName",
|
|
"NSImageName",
|
|
]);
|
|
|
|
/** Inspect compiled asset catalog metadata without extracting rendition bytes. */
|
|
export const analyzeAppleAssetCatalogs = async (input: {
|
|
readonly bundlePath: string;
|
|
readonly targetSha256: string;
|
|
readonly page?: unknown;
|
|
readonly signal?: AbortSignal;
|
|
readonly runAssetUtil?: (
|
|
path: string,
|
|
signal?: AbortSignal,
|
|
) => Promise<string>;
|
|
}) => {
|
|
if (process.platform !== "darwin" && input.runAssetUtil === undefined)
|
|
throw new ArtifactReaderFailure(
|
|
"unavailable",
|
|
"Apple asset catalog inspection requires macOS assetutil",
|
|
);
|
|
const page = appleAssetCatalogInputSchema.parse(input.page ?? {});
|
|
const reader = new DirectoryArtifactReader(input.bundlePath);
|
|
const catalogs: {
|
|
path: string;
|
|
sha256: string;
|
|
records: ReturnType<typeof parseAppleAssetCatalogRecords>;
|
|
}[] = [];
|
|
let totalMetadataBytes = 0;
|
|
try {
|
|
for await (const entry of reader.entries(input.signal)) {
|
|
if (entry.kind !== "file" || !entry.path.endsWith("Assets.car")) continue;
|
|
const inspected = await inspectAssetCatalogEntry({
|
|
reader,
|
|
entry,
|
|
runAssetUtil: input.runAssetUtil ?? runAssetUtil,
|
|
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
});
|
|
totalMetadataBytes += inspected.metadataBytes;
|
|
if (totalMetadataBytes > MAX_TOTAL_METADATA_BYTES)
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
`Combined asset catalog metadata exceeds ${MAX_TOTAL_METADATA_BYTES} bytes`,
|
|
);
|
|
catalogs.push(inspected.catalog);
|
|
}
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
if (catalogs.length === 0)
|
|
throw new ArtifactReaderFailure(
|
|
"unavailable",
|
|
"No compiled Assets.car catalogs were found in the app bundle",
|
|
);
|
|
const interfaceBuilder = await analyzeInterfaceBuilderBundle({
|
|
bundlePath: input.bundlePath,
|
|
targetSha256: input.targetSha256,
|
|
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
});
|
|
const resourceReferences = collectInterfaceBuilderResourceKeys(
|
|
interfaceBuilder.graph.nodes,
|
|
);
|
|
const result = projectAppleAssetCatalogPage({
|
|
targetSha256: input.targetSha256,
|
|
catalogs,
|
|
offset: page.offset,
|
|
limit: page.limit,
|
|
resourceReferences,
|
|
});
|
|
return {
|
|
...result,
|
|
limitations: [
|
|
...result.limitations,
|
|
"Exact resource-key joins use only recognized image or asset key fields on decoded Interface Builder resource objects; other archive resource references are not inferred.",
|
|
...(interfaceBuilder.graph.truncated
|
|
? [
|
|
"Interface Builder resource-key joins may be incomplete because the decoded UI graph was truncated.",
|
|
]
|
|
: []),
|
|
],
|
|
};
|
|
};
|
|
|
|
const inspectAssetCatalogEntry = async (input: {
|
|
readonly reader: DirectoryArtifactReader;
|
|
readonly entry: import("../artifacts/ArtifactReader.js").ArtifactEntry;
|
|
readonly runAssetUtil: (
|
|
path: string,
|
|
signal?: AbortSignal,
|
|
) => Promise<string>;
|
|
readonly signal?: AbortSignal;
|
|
}) => {
|
|
const { entry, reader, signal } = input;
|
|
const sha256 = await digestBounded(reader, entry, signal);
|
|
const before = await lstat(entry.adapterKey);
|
|
if (
|
|
entry.sourceIdentity === undefined ||
|
|
before.dev !== entry.sourceIdentity.device ||
|
|
before.ino !== entry.sourceIdentity.inode
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`Asset catalog changed before inspection: ${entry.path}`,
|
|
);
|
|
const output = await input.runAssetUtil(entry.adapterKey, signal);
|
|
const after = await lstat(entry.adapterKey);
|
|
if (
|
|
before.dev !== after.dev ||
|
|
before.ino !== after.ino ||
|
|
before.size !== after.size ||
|
|
(await digestBounded(reader, entry, signal)) !== sha256
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`Asset catalog changed during inspection: ${entry.path}`,
|
|
);
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(output);
|
|
} catch (cause: unknown) {
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
`assetutil returned invalid JSON for ${entry.path}`,
|
|
{ cause },
|
|
);
|
|
}
|
|
try {
|
|
return {
|
|
catalog: {
|
|
path: entry.path,
|
|
sha256,
|
|
records: parseAppleAssetCatalogRecords(parsed),
|
|
},
|
|
metadataBytes: Buffer.byteLength(output, "utf8"),
|
|
};
|
|
} catch (cause: unknown) {
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
`assetutil returned an unsupported record for ${entry.path}`,
|
|
{ cause },
|
|
);
|
|
}
|
|
};
|
|
|
|
/** Return only explicit image or asset fields from decoded resource objects. */
|
|
export const collectInterfaceBuilderResourceKeys = (
|
|
nodes: readonly {
|
|
readonly id: string;
|
|
readonly kind: string;
|
|
readonly name?: string;
|
|
readonly attributes: Readonly<Record<string, unknown>>;
|
|
readonly evidence: readonly {
|
|
readonly artifact_path: string | null;
|
|
readonly artifact_sha256: string | null;
|
|
}[];
|
|
}[],
|
|
) =>
|
|
nodes.flatMap((node) => {
|
|
const source = node.evidence.find(
|
|
(item) => item.artifact_path !== null && item.artifact_sha256 !== null,
|
|
);
|
|
const sourcePath = source?.artifact_path;
|
|
const sourceArchiveSha256 = source?.artifact_sha256;
|
|
if (sourcePath == null || sourceArchiveSha256 == null) return [];
|
|
return Object.entries(node.attributes).flatMap(([field, value]) =>
|
|
RESOURCE_KEY_FIELDS.has(field) &&
|
|
typeof value === "string" &&
|
|
value.length > 0
|
|
? [
|
|
{
|
|
sourceNodeId: node.id,
|
|
sourcePath,
|
|
sourceArchiveSha256,
|
|
field,
|
|
key: value,
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
});
|
|
|
|
const runAssetUtil = async (
|
|
path: string,
|
|
signal?: AbortSignal,
|
|
): Promise<string> => {
|
|
try {
|
|
return (
|
|
await execFileOutput(ASSETUTIL, ["--info", path], {
|
|
timeout: 60_000,
|
|
maxBuffer: MAX_CATALOG_BYTES,
|
|
...(signal === undefined ? {} : { signal }),
|
|
})
|
|
).stdout;
|
|
} catch (cause: unknown) {
|
|
if (signal?.aborted === true)
|
|
throw new ArtifactReaderFailure(
|
|
"cancelled",
|
|
"Asset catalog inspection cancelled",
|
|
);
|
|
const code =
|
|
typeof cause === "object" && cause !== null && "code" in cause
|
|
? cause.code
|
|
: undefined;
|
|
throw new ArtifactReaderFailure(
|
|
code === "ENOENT"
|
|
? "unavailable"
|
|
: code === "ETIMEDOUT" || code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
|
|
? "limit"
|
|
: "io",
|
|
`assetutil failed${code === undefined ? "" : ` (${String(code)})`}`,
|
|
{ cause },
|
|
);
|
|
}
|
|
};
|
|
|
|
const digestBounded = async (
|
|
reader: DirectoryArtifactReader,
|
|
entry: import("../artifacts/ArtifactReader.js").ArtifactEntry,
|
|
signal?: AbortSignal,
|
|
): Promise<string> => {
|
|
if (entry.declaredSize === null || entry.declaredSize > MAX_CATALOG_BYTES)
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
`Asset catalog exceeds ${MAX_CATALOG_BYTES} bytes: ${entry.path}`,
|
|
);
|
|
const stream = await reader.open(entry, signal);
|
|
const hash = createHash("sha256");
|
|
let length = 0;
|
|
for await (const chunk of stream) {
|
|
if (signal?.aborted === true)
|
|
throw new ArtifactReaderFailure(
|
|
"cancelled",
|
|
"Asset catalog inspection cancelled",
|
|
);
|
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
length += bytes.length;
|
|
if (length > MAX_CATALOG_BYTES)
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
`Asset catalog exceeds byte limit: ${entry.path}`,
|
|
);
|
|
hash.update(bytes);
|
|
}
|
|
return hash.digest("hex");
|
|
};
|