mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(apple): preserve bounded archive diagnostics (#7890)
* fix(apple): preserve bounded archive diagnostics * test(apple): assert diagnostic facts without freezing prose
This commit is contained in:
1 parent
16cfe30dab
commit
fd297b1b17
6 files changed
+119
-10
No files matched your search
@@ -517,7 +517,7 @@ it.each([
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["reported carutil", "observed assetutil"])(
|
||||
it.each(["reported carutil", "tab-indented carutil", "observed assetutil"])(
|
||||
"attributes %s rejection of a readable nested catalog as invalid bytes",
|
||||
async (variant) => {
|
||||
const bundle = await createTestTempDirectory("rea-asset-rejected-");
|
||||
@@ -535,9 +535,9 @@ it.each(["reported carutil", "observed assetutil"])(
|
||||
// catalog. This test injects those diagnoses, without running Apple tools.
|
||||
const stderr =
|
||||
"BOMStreamWithFileAndSys: read: Operation not permitted\n" +
|
||||
(variant === "reported carutil"
|
||||
(variant !== "observed assetutil"
|
||||
? `assetutil Error: Unable to read file at '${path}'\n` +
|
||||
`carutil: error:\n ${path} is not a valid AssetCatalog\n`
|
||||
`carutil: error:\n${variant === "tab-indented carutil" ? "\t" : " "}${path} is not a valid AssetCatalog\n`
|
||||
: `assetutil: file error error exiting: 'can't read from: '${path}' Undefined error: 0' invalid file\n`);
|
||||
const cause = Object.assign(new Error("assetutil exited 1"), {
|
||||
code: 1,
|
||||
@@ -580,6 +580,7 @@ it.each(["reported carutil", "observed assetutil"])(
|
||||
it.each([
|
||||
"unrecognized text",
|
||||
"another catalog",
|
||||
"tab-indented another catalog",
|
||||
"signal",
|
||||
"truncated capture",
|
||||
"failed capture",
|
||||
@@ -589,13 +590,15 @@ it.each([
|
||||
const bundle = await createTestTempDirectory("rea-asset-unknown-rejection-");
|
||||
const path = join(bundle, "Assets.car");
|
||||
await writeFile(path, "readable catalog");
|
||||
const rejectedPath = scenario === "another catalog" ? `${path}.other` : path;
|
||||
const rejectedPath = scenario.includes("another catalog")
|
||||
? `${path}.other`
|
||||
: path;
|
||||
const stderr =
|
||||
scenario === "unrecognized text"
|
||||
? "Operation not permitted; catalog may be invalid"
|
||||
: scenario.startsWith("different file-error")
|
||||
? `assetutil: file error error exiting: 'can't read from: '${path}' Undefined error: ${scenario === "different file-error code" ? "13" : "0"}' ${scenario === "different file-error text" ? "access denied" : "invalid file"}\n`
|
||||
: `carutil: error:\n ${rejectedPath} is not a valid AssetCatalog\n`;
|
||||
: `carutil: error:\n${scenario.startsWith("tab-indented") ? "\t" : " "}${rejectedPath} is not a valid AssetCatalog\n`;
|
||||
const cause = Object.assign(new Error("assetutil failed"), {
|
||||
code: 1,
|
||||
stdout: "",
|
||||
|
||||
@@ -465,6 +465,7 @@ const assetUtilFailure = (cause: unknown, entry: ArtifactEntry): unknown => {
|
||||
// other text, interrupted runs and incomplete captures establish no cause.
|
||||
const rejectsCatalog = [
|
||||
`carutil: error:\n ${entry.adapterKey} is not a valid AssetCatalog`,
|
||||
`carutil: error:\n\t${entry.adapterKey} is not a valid AssetCatalog`,
|
||||
`assetutil: file error error exiting: 'can't read from: '${entry.adapterKey}' Undefined error: 0' invalid file`,
|
||||
].some((rejection) => {
|
||||
const offset = output.stderr.indexOf(rejection);
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
const MAX_AGGREGATE_INPUT_BYTES = 32 * 1024 * 1024;
|
||||
const MAX_AGGREGATE_DECODE_BYTES = 256 * 1024 * 1024;
|
||||
const MAX_REPORTED_OMITTED_ARCHIVES = 64;
|
||||
const MAX_REPORTED_INVALID_ARCHIVES = 16;
|
||||
|
||||
interface OmittedArchive {
|
||||
readonly path: string;
|
||||
@@ -100,6 +101,7 @@ const decodeInterfaceBuilderBundle = async (
|
||||
const reader = new DirectoryArtifactReader(input.bundlePath);
|
||||
const documents: InterfaceBuilderDocumentInput[] = [];
|
||||
const invalid: string[] = [];
|
||||
let unreportedInvalidArchives = 0;
|
||||
const omittedArchives: OmittedArchive[] = [];
|
||||
const omissionCounts = new Map<string, number>();
|
||||
const recordOmission = (reason: string, count = 1): void => {
|
||||
@@ -280,9 +282,11 @@ const decodeInterfaceBuilderBundle = async (
|
||||
recordOmittedArchive(entry.path, "unsupported_uid_exact_range");
|
||||
continue;
|
||||
}
|
||||
invalid.push(
|
||||
`${entry.path}: ${cause instanceof Error ? cause.message : "archive decode failed"}`,
|
||||
);
|
||||
if (invalid.length < MAX_REPORTED_INVALID_ARCHIVES)
|
||||
invalid.push(
|
||||
`${entry.path}: ${cause instanceof Error ? cause.message : "archive decode failed"}`,
|
||||
);
|
||||
else unreportedInvalidArchives += 1;
|
||||
}
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
@@ -315,6 +319,7 @@ const decodeInterfaceBuilderBundle = async (
|
||||
nonFiniteReals,
|
||||
negativeZeroReals,
|
||||
invalid,
|
||||
unreportedInvalidArchives,
|
||||
omittedArchives,
|
||||
unreportedOmittedArchives,
|
||||
omissionCounts,
|
||||
@@ -421,6 +426,7 @@ const finalizeHierarchyCoverage = (
|
||||
nonFiniteReals: ReadonlyMap<string, number>;
|
||||
negativeZeroReals: ReadonlyMap<string, number>;
|
||||
invalid: readonly string[];
|
||||
unreportedInvalidArchives: number;
|
||||
omittedArchives: readonly OmittedArchive[];
|
||||
unreportedOmittedArchives: number;
|
||||
omissionCounts: ReadonlyMap<string, number>;
|
||||
@@ -439,6 +445,7 @@ const finalizeHierarchyCoverage = (
|
||||
nonFiniteReals,
|
||||
negativeZeroReals,
|
||||
invalid,
|
||||
unreportedInvalidArchives,
|
||||
omittedArchives,
|
||||
unreportedOmittedArchives,
|
||||
} = counts;
|
||||
@@ -563,7 +570,12 @@ const finalizeHierarchyCoverage = (
|
||||
...(invalid.length === 0
|
||||
? []
|
||||
: [
|
||||
`Some Interface Builder archives could not be decoded: ${invalid.slice(0, 16).join("; ")}`,
|
||||
`Some Interface Builder archives could not be decoded: ${invalid.join("; ")}`,
|
||||
]),
|
||||
...(unreportedInvalidArchives === 0
|
||||
? []
|
||||
: [
|
||||
`${unreportedInvalidArchives} additional archive decode failure diagnostic(s) were not retained; reporting is bounded to ${MAX_REPORTED_INVALID_ARCHIVES} paths.`,
|
||||
]),
|
||||
...[...unknownDates].map(
|
||||
([path, count]) => `${path}: ${unknownDateLimitation(count)}`,
|
||||
|
||||
@@ -53,6 +53,47 @@ const interfaceBuilderReports = async (cli: TestCli, app: string) => {
|
||||
}));
|
||||
};
|
||||
|
||||
cliTest(
|
||||
"discloses omitted malformed-archive diagnostics through CLI and MCP",
|
||||
async ({ cli }) => {
|
||||
const fixture = await bundle();
|
||||
for (let index = 0; index < 17; index += 1)
|
||||
await writeFile(
|
||||
join(fixture.resources, `bad-${String(index).padStart(2, "0")}.nib`),
|
||||
"not a plist",
|
||||
);
|
||||
for (const { evidence, report } of await interfaceBuilderReports(
|
||||
cli,
|
||||
fixture.app,
|
||||
)) {
|
||||
expect(report.documents).toHaveLength(1);
|
||||
expect(report.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "archive_decode",
|
||||
status: "partial",
|
||||
reason: "one_or_more_archives_invalid",
|
||||
examined: 18,
|
||||
omitted: 0,
|
||||
}),
|
||||
);
|
||||
const omission = report.limitations.find((value) =>
|
||||
value.includes("additional archive decode failure diagnostic(s)"),
|
||||
);
|
||||
expect(omission).toMatch(/\b1 additional\b/u);
|
||||
expect(omission).toMatch(/\b16 paths\b/u);
|
||||
expect(evidence.limitations).toContain(omission);
|
||||
for (const limitations of [report.limitations, evidence.limitations]) {
|
||||
const failures = limitations.find((value) =>
|
||||
value.startsWith(
|
||||
"Some Interface Builder archives could not be decoded:",
|
||||
),
|
||||
);
|
||||
expect(failures?.match(/bad-\d{2}\.nib/gu)).toHaveLength(16);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cliTest.skipIf(process.platform === "win32")(
|
||||
"reports symlinked nibs as unexamined while preserving healthy UI documents through CLI and MCP",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -78,7 +78,20 @@ cliTest.skipIf(process.platform !== "darwin")(
|
||||
const graph = interfaceBuilderAnalysisSchema.parse(
|
||||
parseEvidence(decoded.json).normalized_result,
|
||||
).graph;
|
||||
expect(graph.truncated).toBe(false);
|
||||
// The fixture need not describe a complete view hierarchy to establish
|
||||
// decoded object facts used by asset-key matching.
|
||||
expect(graph.coverage).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
facet: "archive_decode",
|
||||
status: "complete",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
facet: "archive_object_decode",
|
||||
status: "complete",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const resource = graph.nodes.find(
|
||||
(node) =>
|
||||
node.kind === "resource" && node.attributes.class_name === "NSImage",
|
||||
@@ -143,11 +156,14 @@ cliTest.skipIf(process.platform !== "darwin")(
|
||||
expect(binaryResult).toEqual({
|
||||
target_sha256: executableSha256,
|
||||
catalogs: result.catalogs,
|
||||
catalog_coverage: result.catalog_coverage,
|
||||
inventory_coverage: result.inventory_coverage,
|
||||
total_records: result.total_records,
|
||||
offset: result.offset,
|
||||
limit: result.limit,
|
||||
records: result.records,
|
||||
resource_key_matches: result.resource_key_matches,
|
||||
resource_key_coverage: result.resource_key_coverage,
|
||||
next_offset: result.next_offset,
|
||||
truncated: result.truncated,
|
||||
limitations: result.limitations,
|
||||
|
||||
@@ -191,6 +191,42 @@ const encodeNibArchiveWithSharedDataValue = (input: {
|
||||
return Buffer.concat([header, objects, keys, values, classes]);
|
||||
};
|
||||
|
||||
it.each([16, 17])(
|
||||
"reports the bounded sample of %i malformed archive diagnostics",
|
||||
async (count) => {
|
||||
const bundle = await createTestTempDirectory("rea-ib-invalid-bound-");
|
||||
for (let index = 0; index < count; index += 1)
|
||||
await writeFile(
|
||||
join(bundle, `bad-${String(index).padStart(2, "0")}.nib`),
|
||||
"not a plist",
|
||||
);
|
||||
const result = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "a".repeat(64),
|
||||
});
|
||||
const diagnostic = result.limitations.find((value) =>
|
||||
value.startsWith("Some Interface Builder archives could not be decoded:"),
|
||||
);
|
||||
expect(diagnostic?.match(/bad-\d{2}\.nib/gu)).toHaveLength(16);
|
||||
const omission = result.limitations.filter((value) =>
|
||||
value.includes("additional archive decode failure diagnostic(s)"),
|
||||
);
|
||||
expect(omission).toHaveLength(count === 16 ? 0 : 1);
|
||||
if (count === 17) {
|
||||
expect(omission[0]).toMatch(/\b1 additional\b/u);
|
||||
expect(omission[0]).toMatch(/\b16 paths\b/u);
|
||||
}
|
||||
expect(result.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "archive_decode",
|
||||
status: "partial",
|
||||
examined: count,
|
||||
omitted: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe("compiled Interface Builder traversal coverage", () => {
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"shares the 64-path omission budget between regular-document capacity and symlink source notices",
|
||||
|
||||
Reference in new issue
Block a user