Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
790ecf825a |
No files matched your search
@@ -9,6 +9,7 @@ import {
|
||||
ArtifactReaderFailure,
|
||||
type ArtifactEntry,
|
||||
type ArtifactReader,
|
||||
type ArtifactReaderTraversal,
|
||||
type ArtifactTraversalOptions,
|
||||
} from "./ArtifactReader.js";
|
||||
import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js";
|
||||
@@ -149,6 +150,7 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
#attachOutputReturned = false;
|
||||
#attachMayHaveMounted = false;
|
||||
#ownershipUncertainty: string | undefined;
|
||||
#preexistingAttachment: ArtifactReaderTraversal | undefined;
|
||||
#closed = false;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
readonly #activeDirectoryOperations = new Set<Promise<unknown>>();
|
||||
@@ -172,6 +174,10 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
options?: ArtifactTraversalOptions,
|
||||
): AsyncIterable<ArtifactEntry> {
|
||||
await this.#ensureAttached(signal);
|
||||
if (this.#preexistingAttachment !== undefined) {
|
||||
options?.onCoverage?.(this.#preexistingAttachment);
|
||||
return;
|
||||
}
|
||||
const directory = this.#directory;
|
||||
if (directory === undefined)
|
||||
throw new ArtifactReaderFailure("unavailable", "DMG is not attached");
|
||||
@@ -413,22 +419,20 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
);
|
||||
const mountRoot = await realpath(await mkdtemp(join(tmpdir(), "rea-dmg-")));
|
||||
this.#mountRoot = mountRoot;
|
||||
const attachArguments = [
|
||||
"attach",
|
||||
"-readonly",
|
||||
"-nobrowse",
|
||||
"-plist",
|
||||
"-mountroot",
|
||||
mountRoot,
|
||||
this.path,
|
||||
];
|
||||
try {
|
||||
this.#attachMayHaveMounted = true;
|
||||
const attached = await runChecked(
|
||||
this.host,
|
||||
[
|
||||
"attach",
|
||||
"-readonly",
|
||||
"-nobrowse",
|
||||
"-plist",
|
||||
"-mountroot",
|
||||
mountRoot,
|
||||
this.path,
|
||||
],
|
||||
signal,
|
||||
{ timeoutMs: HDIUTIL_COMMAND_TIMEOUT_MS },
|
||||
);
|
||||
const attached = await runChecked(this.host, attachArguments, signal, {
|
||||
timeoutMs: HDIUTIL_COMMAND_TIMEOUT_MS,
|
||||
});
|
||||
this.#attachOutputReturned = true;
|
||||
let parsed: NativeDmgAttachOutput;
|
||||
try {
|
||||
@@ -454,20 +458,7 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
? `DMG attachment ownership is uncertain: ${this.#ownershipUncertainty ?? "no unique mount under the owned root"}`
|
||||
: "hdiutil did not mount a new image beneath the owned root",
|
||||
);
|
||||
this.#provenance.push(
|
||||
command(
|
||||
[
|
||||
"attach",
|
||||
"-readonly",
|
||||
"-nobrowse",
|
||||
"-plist",
|
||||
"-mountroot",
|
||||
mountRoot,
|
||||
this.path,
|
||||
],
|
||||
["read", "mount"],
|
||||
),
|
||||
);
|
||||
this.#provenance.push(command(attachArguments, ["read", "mount"]));
|
||||
this.#directory = new DirectoryArtifactReader(mountRoot);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof NativeDmgCommandFailure && cause.stdout !== "") {
|
||||
@@ -482,10 +473,59 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
this.#attachMayHaveMounted = false;
|
||||
this.#ownershipUncertainty = undefined;
|
||||
}
|
||||
throw cause;
|
||||
this.#preexistingAttachment = await this.#preexistingAttachmentCoverage(
|
||||
cause,
|
||||
baseline,
|
||||
attachArguments,
|
||||
);
|
||||
if (this.#preexistingAttachment === undefined) throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* hdiutil refuses a private attachment while another owner holds this image.
|
||||
* The root remains observable; child coverage stays unknown, and the
|
||||
* existing attachment is never traversed, borrowed or detached.
|
||||
*/
|
||||
async #preexistingAttachmentCoverage(
|
||||
cause: unknown,
|
||||
baseline: NativeDmgInfo,
|
||||
attachArguments: readonly string[],
|
||||
): Promise<ArtifactReaderTraversal | undefined> {
|
||||
if (
|
||||
!(cause instanceof NativeDmgCommandFailure) ||
|
||||
cause.exitCode === null ||
|
||||
cause.reason !== "unavailable" ||
|
||||
!(await this.#baselineAttachesTarget(baseline))
|
||||
)
|
||||
return undefined;
|
||||
try {
|
||||
if ((await this.#resolveAttachOwnership()) !== "none") return undefined;
|
||||
} catch {
|
||||
// The attach failure remains primary; close() reconciles ownership again.
|
||||
return undefined;
|
||||
}
|
||||
this.#provenance.push(command(attachArguments, ["read"], cause.exitCode));
|
||||
return {
|
||||
status: "unknown",
|
||||
limitations: [
|
||||
`DMG child inventory is unknown: hdiutil refused a private read-only attachment (exit ${String(cause.exitCode)}) while it already listed an attachment of this image path. REA did not traverse, borrow or detach that attachment; release it and retry to inventory children.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async #baselineAttachesTarget(baseline: NativeDmgInfo): Promise<boolean> {
|
||||
const target = await realpath(this.path).catch(() => undefined);
|
||||
if (target === undefined) return false;
|
||||
for (const image of baseline.images) {
|
||||
const imagePath = image["image-path"];
|
||||
if (imagePath === undefined) continue;
|
||||
if ((await realpath(imagePath).catch(() => undefined)) === target)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async #verify(signal?: AbortSignal): Promise<void> {
|
||||
const arguments_ = ["verify", this.path];
|
||||
const deadline = performance.now() + HDIUTIL_COMMAND_TIMEOUT_MS;
|
||||
|
||||
@@ -15,6 +15,7 @@ const attachOutputSchema = z.object({
|
||||
const infoOutputSchema = z.object({
|
||||
images: z.array(
|
||||
z.object({
|
||||
"image-path": z.string().optional(),
|
||||
"system-entities": z.array(systemEntitySchema),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1193,44 +1193,3 @@ describe("malformed Objective-C protocol method lists", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Objective-C method lists cut by the record budget", () => {
|
||||
it("never reports a declaration decoded while the budget omitted its methods", () => {
|
||||
const complete = decodeAppleDispatchMetadata(fixture(), 100, provenance);
|
||||
const classKey = (item: { name: string; is_meta_class: boolean | null }) =>
|
||||
`${item.name}:${String(item.is_meta_class)}`;
|
||||
const completeProtocols = new Map(
|
||||
complete.objc_protocol_records.map((item) => [item.name, item]),
|
||||
);
|
||||
const completeClasses = new Map(
|
||||
complete.objc_classes.map((item) => [classKey(item), item]),
|
||||
);
|
||||
const cutReasons = new Set<string>();
|
||||
for (let budget = 1; budget < 40; budget++) {
|
||||
const result = decodeAppleDispatchMetadata(fixture(), budget, provenance);
|
||||
for (const record of result.objc_protocol_records) {
|
||||
const full = completeProtocols.get(record.name);
|
||||
if (record.decode.status === "decoded") {
|
||||
expect(record.methods).toEqual(full?.methods);
|
||||
expect(record.optional_methods).toEqual(full?.optional_methods);
|
||||
} else if (record.decode.reason !== null)
|
||||
cutReasons.add(`protocol:${record.decode.reason}`);
|
||||
}
|
||||
for (const record of result.objc_classes) {
|
||||
const full = completeClasses.get(classKey(record));
|
||||
if (record.decode?.status === "decoded")
|
||||
expect(record.methods).toEqual(full?.methods);
|
||||
else if (record.decode?.reason != null)
|
||||
cutReasons.add(`class:${record.decode.reason}`);
|
||||
}
|
||||
}
|
||||
// The fixture has methods on both declaration kinds, so some budget must
|
||||
// admit each declaration but stop inside its method list.
|
||||
expect([...cutReasons]).toEqual(
|
||||
expect.arrayContaining([
|
||||
"protocol:method_list_record_budget_exhausted",
|
||||
"class:method_list_record_budget_exhausted",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ export const decodeObjcCategories = (input: {
|
||||
className: string,
|
||||
methodType: "instance" | "class",
|
||||
category: string,
|
||||
) => { selectors: string[]; budgetExhausted: boolean };
|
||||
) => string[];
|
||||
readonly protocols: (list: bigint) => string[];
|
||||
/** Name of a class defined in this image, from its class_ro_t. */
|
||||
readonly localClassName: (address: bigint) => string;
|
||||
@@ -71,18 +71,6 @@ export const decodeObjcCategories = (input: {
|
||||
);
|
||||
}
|
||||
const owner = className ?? `(${name})`;
|
||||
const instanceMethods = input.methods(
|
||||
read.pointer(category + 16n),
|
||||
owner,
|
||||
"instance",
|
||||
name,
|
||||
);
|
||||
const classMethods = input.methods(
|
||||
read.pointer(category + 24n),
|
||||
owner,
|
||||
"class",
|
||||
name,
|
||||
);
|
||||
result.objc_categories.push({
|
||||
name,
|
||||
class_name: className,
|
||||
@@ -92,8 +80,18 @@ export const decodeObjcCategories = (input: {
|
||||
: className === null
|
||||
? "unresolved"
|
||||
: "local",
|
||||
instance_methods: instanceMethods.selectors,
|
||||
class_methods: classMethods.selectors,
|
||||
instance_methods: input.methods(
|
||||
read.pointer(category + 16n),
|
||||
owner,
|
||||
"instance",
|
||||
name,
|
||||
),
|
||||
class_methods: input.methods(
|
||||
read.pointer(category + 24n),
|
||||
owner,
|
||||
"class",
|
||||
name,
|
||||
),
|
||||
protocols: input.protocols(read.pointer(category + 32n)),
|
||||
properties: readObjcPropertiesOf(read, read.pointer(category + 40n), {
|
||||
owner: `category ${name}`,
|
||||
@@ -103,14 +101,9 @@ export const decodeObjcCategories = (input: {
|
||||
}),
|
||||
location: read.location(category),
|
||||
decode:
|
||||
instanceMethods.budgetExhausted || classMethods.budgetExhausted
|
||||
? {
|
||||
status: "partial",
|
||||
reason: "method_list_record_budget_exhausted",
|
||||
}
|
||||
: className === null
|
||||
? { status: "partial", reason: "category_class_unresolved" }
|
||||
: { status: "decoded", reason: null },
|
||||
className === null
|
||||
? { status: "partial", reason: "category_class_unresolved" }
|
||||
: { status: "decoded", reason: null },
|
||||
evidence: read.evidence(category, "Objective-C category_t record"),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
|
||||
@@ -103,9 +103,9 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
className: string,
|
||||
methodType: "instance" | "class",
|
||||
category?: string,
|
||||
): { selectors: string[]; budgetExhausted: boolean } => {
|
||||
): string[] => {
|
||||
const selectors: string[] = [];
|
||||
if (list === 0n) return { selectors, budgetExhausted: false };
|
||||
if (list === 0n) return selectors;
|
||||
const flags = read.u32(list),
|
||||
count = read.u32(list + 4n),
|
||||
relative = (flags & 0x80000000) !== 0;
|
||||
@@ -113,7 +113,7 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
if (stride !== (relative ? 12 : 24))
|
||||
throw new TypeError(`Unsupported method stride/flags at ${hex(list)}`);
|
||||
for (let index = 0; index < count; index++) {
|
||||
if (!budget.admit()) return { selectors, budgetExhausted: true };
|
||||
if (!budget.admit()) break;
|
||||
const entry = list + 8n + BigInt(index * stride);
|
||||
const rel = (field: bigint) => {
|
||||
const displacement = bytes.readInt32LE(read.offset(field, 4));
|
||||
@@ -196,7 +196,7 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
),
|
||||
});
|
||||
}
|
||||
return { selectors, budgetExhausted: false };
|
||||
return selectors;
|
||||
};
|
||||
const protocolReader = createObjcProtocolReader({
|
||||
readers: {
|
||||
@@ -326,11 +326,7 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
});
|
||||
}
|
||||
}
|
||||
const { budgetExhausted } = methods(
|
||||
read.pointer(ro + 32n),
|
||||
name,
|
||||
meta ? "class" : "instance",
|
||||
);
|
||||
methods(read.pointer(ro + 32n), name, meta ? "class" : "instance");
|
||||
result.objc_classes.push({
|
||||
name,
|
||||
super_class: superclass.name,
|
||||
@@ -366,9 +362,8 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
location: read.location(address),
|
||||
superclass_address: superclass.address,
|
||||
metaclass_address: meta ? null : hex(read.pointer(address)),
|
||||
decode: budgetExhausted
|
||||
? { status: "partial", reason: "method_list_record_budget_exhausted" }
|
||||
: superclass.name === null && !root
|
||||
decode:
|
||||
superclass.name === null && !root
|
||||
? { status: "partial", reason: "superclass_binding_unresolved" }
|
||||
: decoded,
|
||||
evidence: read.evidence(
|
||||
|
||||
@@ -39,8 +39,6 @@ export const createObjcProtocolReader = (input: {
|
||||
instance: boolean,
|
||||
/** Entries skipped for an empty selector, reported by the declaring protocol. */
|
||||
emptySelectors: string[],
|
||||
/** Set when the record budget stops the list before its last entry. */
|
||||
budget: { exhausted: boolean },
|
||||
) => {
|
||||
const output: ObjcSwiftMetadata["objc_protocols"][number]["methods"] = [];
|
||||
if (address === 0n) return output;
|
||||
@@ -51,10 +49,7 @@ export const createObjcProtocolReader = (input: {
|
||||
if (stride !== (relative ? 12 : 24))
|
||||
throw new TypeError("Unsupported Objective-C protocol method entry size");
|
||||
for (let index = 0; index < count; index++) {
|
||||
if (!read.admit()) {
|
||||
budget.exhausted = true;
|
||||
break;
|
||||
}
|
||||
if (!read.admit()) break;
|
||||
const entry = address + 8n + BigInt(index * stride);
|
||||
let selector = relative
|
||||
? entry + BigInt(read.i32(entry))
|
||||
@@ -93,14 +88,13 @@ export const createObjcProtocolReader = (input: {
|
||||
if (protocolName.length === 0)
|
||||
throw new TypeError("Objective-C protocol has an empty name");
|
||||
const skipped: string[] = [];
|
||||
const budget = { exhausted: false };
|
||||
const required = [
|
||||
...methods(read.pointer(address + 24n), true, true, skipped, budget),
|
||||
...methods(read.pointer(address + 32n), true, false, skipped, budget),
|
||||
...methods(read.pointer(address + 24n), true, true, skipped),
|
||||
...methods(read.pointer(address + 32n), true, false, skipped),
|
||||
];
|
||||
const optional = [
|
||||
...methods(read.pointer(address + 40n), false, true, skipped, budget),
|
||||
...methods(read.pointer(address + 48n), false, false, skipped, budget),
|
||||
...methods(read.pointer(address + 40n), false, true, skipped),
|
||||
...methods(read.pointer(address + 48n), false, false, skipped),
|
||||
];
|
||||
for (const message of skipped)
|
||||
failures.push(
|
||||
@@ -118,11 +112,8 @@ export const createObjcProtocolReader = (input: {
|
||||
methods: required,
|
||||
optional_methods: optional,
|
||||
location: read.location(address),
|
||||
// A budget-cut list is recoverable with a larger budget, so that
|
||||
// cause is reported first; an empty list is otherwise complete.
|
||||
decode: budget.exhausted
|
||||
? { status: "partial", reason: "method_list_record_budget_exhausted" }
|
||||
: skipped.length === 0
|
||||
decode:
|
||||
skipped.length === 0
|
||||
? { status: "decoded", reason: null }
|
||||
: { status: "partial", reason: "protocol_method_selector_empty" },
|
||||
evidence: read.evidence(
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { parse } from "plist";
|
||||
import { describe, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js";
|
||||
import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js";
|
||||
import { parseEvidence } from "../../src/domain/evidence.js";
|
||||
import { execFileOutput } from "../../src/process/ExecFileOutput.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
|
||||
const HDIUTIL = "/usr/bin/hdiutil";
|
||||
|
||||
const attachedDevices = (stdout: string): string[] =>
|
||||
z
|
||||
.object({
|
||||
"system-entities": z.array(z.object({ "dev-entry": z.string() })),
|
||||
})
|
||||
.parse(parse(stdout))
|
||||
["system-entities"].map(({ "dev-entry": device }) => device);
|
||||
|
||||
const imageAttached = async (imagePath: string): Promise<boolean> =>
|
||||
z
|
||||
.object({
|
||||
images: z.array(z.object({ "image-path": z.string().optional() })),
|
||||
})
|
||||
.parse(parse((await execFileOutput(HDIUTIL, ["info", "-plist"])).stdout))
|
||||
.images.some((image) => image["image-path"] === imagePath);
|
||||
|
||||
describe.skipIf(process.platform !== "darwin")(
|
||||
"DMG inspection with a preexisting attachment",
|
||||
() => {
|
||||
cliTest(
|
||||
"retains root observations and leaves another owner's attachment alone",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-dmg-preexisting-");
|
||||
const source = join(root, "source");
|
||||
const mountRoot = join(root, "foreign-mounts");
|
||||
const imagePath = join(root, "fixture.dmg");
|
||||
await mkdir(source);
|
||||
await mkdir(mountRoot);
|
||||
await writeFile(join(source, "file.txt"), "fixture\n");
|
||||
await execFileOutput(HDIUTIL, [
|
||||
"create",
|
||||
"-quiet",
|
||||
"-srcfolder",
|
||||
source,
|
||||
"-volname",
|
||||
"PreexistingFixture",
|
||||
"-format",
|
||||
"UDZO",
|
||||
imagePath,
|
||||
]);
|
||||
const devices = attachedDevices(
|
||||
(
|
||||
await execFileOutput(HDIUTIL, [
|
||||
"attach",
|
||||
"-readonly",
|
||||
"-nobrowse",
|
||||
"-plist",
|
||||
"-mountroot",
|
||||
mountRoot,
|
||||
imagePath,
|
||||
])
|
||||
).stdout,
|
||||
);
|
||||
try {
|
||||
const inspected = await cli.run({
|
||||
arguments: ["inspect-artifact", imagePath, "--json"],
|
||||
});
|
||||
expect(inspected.exitCode, inspected.stdout + inspected.stderr).toBe(
|
||||
0,
|
||||
);
|
||||
const inspection = artifactInspectionResultSchema.parse(
|
||||
parseEvidence(inspected.json).normalized_result,
|
||||
);
|
||||
const inventoryEvidence = inspection.substeps[0]?.evidence;
|
||||
if (inventoryEvidence === undefined)
|
||||
throw new Error("CLI inspection did not retain DMG inventory");
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
inventoryEvidence.normalized_result,
|
||||
);
|
||||
expect(inventory.manifest).toMatchObject({
|
||||
root_format: "dmg",
|
||||
occurrence_count: 1,
|
||||
});
|
||||
expect(inventory.manifest.root_sha256).toBe(
|
||||
createHash("sha256")
|
||||
.update(await readFile(imagePath))
|
||||
.digest("hex"),
|
||||
);
|
||||
expect(inventory.coverage).toEqual({
|
||||
scope: "container-entries",
|
||||
status: "unknown",
|
||||
});
|
||||
expect(inventory.limitations).toEqual([
|
||||
expect.stringMatching(
|
||||
/^DMG child inventory is unknown: hdiutil refused a private read-only attachment .*release it and retry/u,
|
||||
),
|
||||
]);
|
||||
expect(inventory.provenance).toContainEqual(
|
||||
expect.objectContaining({
|
||||
arguments: expect.arrayContaining(["attach", imagePath]),
|
||||
exit_code: expect.any(Number),
|
||||
effects: ["read"],
|
||||
}),
|
||||
);
|
||||
expect(inspection.limitations).toEqual(
|
||||
expect.arrayContaining(inventory.limitations),
|
||||
);
|
||||
expect(await imageAttached(imagePath)).toBe(true);
|
||||
} finally {
|
||||
const wholeDisk =
|
||||
devices.find((device) => /^\/dev\/disk\d+$/u.test(device)) ??
|
||||
devices[0];
|
||||
if (wholeDisk !== undefined)
|
||||
await execFileOutput(HDIUTIL, ["detach", wholeDisk]);
|
||||
}
|
||||
expect(await imageAttached(imagePath)).toBe(false);
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1102,6 +1102,82 @@ describe("native DMG command failure diagnostics", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("native DMG preexisting attachment", () => {
|
||||
it.each([
|
||||
{ listed: "target", outcome: "unknown coverage" },
|
||||
{ listed: "other", outcome: "failure" },
|
||||
] as const)(
|
||||
"reports $outcome when attach fails while hdiutil lists the $listed image",
|
||||
async ({ listed }) => {
|
||||
const parent = await createTestTempDirectory("rea-dmg-preexisting-");
|
||||
const path = join(parent, "image.dmg");
|
||||
const image = Buffer.alloc(512);
|
||||
image.write("koly", 0, "ascii");
|
||||
await writeFile(path, image);
|
||||
const calls: string[][] = [];
|
||||
const host: NativeDmgHost = {
|
||||
run(arguments_) {
|
||||
calls.push([...arguments_]);
|
||||
if (arguments_[0] === "info")
|
||||
return Promise.resolve({
|
||||
stdout: infoPlist(
|
||||
listed === "target" ? path : join(parent, "other.dmg"),
|
||||
[{ "dev-entry": "/dev/disk51", "mount-point": "/Volumes/X" }],
|
||||
),
|
||||
exitCode: 0,
|
||||
});
|
||||
if (arguments_[0] === "attach")
|
||||
return Promise.resolve({
|
||||
stdout: "",
|
||||
stderr: "hdiutil: attach failed - Resource busy\n",
|
||||
exitCode: 1,
|
||||
});
|
||||
return Promise.resolve({ stdout: "", exitCode: 0 });
|
||||
},
|
||||
};
|
||||
const resourceScope = new ArtifactResourceScope();
|
||||
onTestFinished(() => resourceScope.close());
|
||||
const inventory = scanCanonicalArtifactInventory(
|
||||
path,
|
||||
{ resourceScope },
|
||||
(inputPath, _format, environment) =>
|
||||
new NativeDmgArtifactReader(inputPath, environment, host),
|
||||
);
|
||||
|
||||
if (listed === "other")
|
||||
await expect(inventory).rejects.toMatchObject({
|
||||
reason: "unavailable",
|
||||
message: expect.stringContaining("Resource busy"),
|
||||
});
|
||||
else {
|
||||
const snapshot = await inventory;
|
||||
expect(snapshot.manifest).toMatchObject({
|
||||
root_format: "dmg",
|
||||
occurrence_count: 1,
|
||||
});
|
||||
expect(snapshot.coverage).toEqual({
|
||||
scope: "container-entries",
|
||||
status: "unknown",
|
||||
});
|
||||
expect(snapshot.limitations).toEqual([
|
||||
expect.stringMatching(
|
||||
/^DMG child inventory is unknown: hdiutil refused a private read-only attachment \(exit 1\)/u,
|
||||
),
|
||||
]);
|
||||
expect(snapshot.provenance.map((entry) => entry.exit_code)).toEqual([
|
||||
0, 1,
|
||||
]);
|
||||
}
|
||||
expect(calls.map(([operation]) => operation)).toEqual([
|
||||
"verify",
|
||||
"info",
|
||||
"attach",
|
||||
"info",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("native DMG command diagnostics", () => {
|
||||
it("keeps unknown attach failures unavailable with command evidence", async () => {
|
||||
await expect(
|
||||
|
||||
Reference in new issue
Block a user