Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
790ecf825a |
No files matched your search
@@ -9,6 +9,7 @@ import {
|
|||||||
ArtifactReaderFailure,
|
ArtifactReaderFailure,
|
||||||
type ArtifactEntry,
|
type ArtifactEntry,
|
||||||
type ArtifactReader,
|
type ArtifactReader,
|
||||||
|
type ArtifactReaderTraversal,
|
||||||
type ArtifactTraversalOptions,
|
type ArtifactTraversalOptions,
|
||||||
} from "./ArtifactReader.js";
|
} from "./ArtifactReader.js";
|
||||||
import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js";
|
import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js";
|
||||||
@@ -149,6 +150,7 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
|||||||
#attachOutputReturned = false;
|
#attachOutputReturned = false;
|
||||||
#attachMayHaveMounted = false;
|
#attachMayHaveMounted = false;
|
||||||
#ownershipUncertainty: string | undefined;
|
#ownershipUncertainty: string | undefined;
|
||||||
|
#preexistingAttachment: ArtifactReaderTraversal | undefined;
|
||||||
#closed = false;
|
#closed = false;
|
||||||
#closePromise: Promise<void> | undefined;
|
#closePromise: Promise<void> | undefined;
|
||||||
readonly #activeDirectoryOperations = new Set<Promise<unknown>>();
|
readonly #activeDirectoryOperations = new Set<Promise<unknown>>();
|
||||||
@@ -172,6 +174,10 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
|||||||
options?: ArtifactTraversalOptions,
|
options?: ArtifactTraversalOptions,
|
||||||
): AsyncIterable<ArtifactEntry> {
|
): AsyncIterable<ArtifactEntry> {
|
||||||
await this.#ensureAttached(signal);
|
await this.#ensureAttached(signal);
|
||||||
|
if (this.#preexistingAttachment !== undefined) {
|
||||||
|
options?.onCoverage?.(this.#preexistingAttachment);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const directory = this.#directory;
|
const directory = this.#directory;
|
||||||
if (directory === undefined)
|
if (directory === undefined)
|
||||||
throw new ArtifactReaderFailure("unavailable", "DMG is not attached");
|
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-")));
|
const mountRoot = await realpath(await mkdtemp(join(tmpdir(), "rea-dmg-")));
|
||||||
this.#mountRoot = mountRoot;
|
this.#mountRoot = mountRoot;
|
||||||
|
const attachArguments = [
|
||||||
|
"attach",
|
||||||
|
"-readonly",
|
||||||
|
"-nobrowse",
|
||||||
|
"-plist",
|
||||||
|
"-mountroot",
|
||||||
|
mountRoot,
|
||||||
|
this.path,
|
||||||
|
];
|
||||||
try {
|
try {
|
||||||
this.#attachMayHaveMounted = true;
|
this.#attachMayHaveMounted = true;
|
||||||
const attached = await runChecked(
|
const attached = await runChecked(this.host, attachArguments, signal, {
|
||||||
this.host,
|
timeoutMs: HDIUTIL_COMMAND_TIMEOUT_MS,
|
||||||
[
|
});
|
||||||
"attach",
|
|
||||||
"-readonly",
|
|
||||||
"-nobrowse",
|
|
||||||
"-plist",
|
|
||||||
"-mountroot",
|
|
||||||
mountRoot,
|
|
||||||
this.path,
|
|
||||||
],
|
|
||||||
signal,
|
|
||||||
{ timeoutMs: HDIUTIL_COMMAND_TIMEOUT_MS },
|
|
||||||
);
|
|
||||||
this.#attachOutputReturned = true;
|
this.#attachOutputReturned = true;
|
||||||
let parsed: NativeDmgAttachOutput;
|
let parsed: NativeDmgAttachOutput;
|
||||||
try {
|
try {
|
||||||
@@ -454,20 +458,7 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
|||||||
? `DMG attachment ownership is uncertain: ${this.#ownershipUncertainty ?? "no unique mount under the owned root"}`
|
? `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",
|
: "hdiutil did not mount a new image beneath the owned root",
|
||||||
);
|
);
|
||||||
this.#provenance.push(
|
this.#provenance.push(command(attachArguments, ["read", "mount"]));
|
||||||
command(
|
|
||||||
[
|
|
||||||
"attach",
|
|
||||||
"-readonly",
|
|
||||||
"-nobrowse",
|
|
||||||
"-plist",
|
|
||||||
"-mountroot",
|
|
||||||
mountRoot,
|
|
||||||
this.path,
|
|
||||||
],
|
|
||||||
["read", "mount"],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
this.#directory = new DirectoryArtifactReader(mountRoot);
|
this.#directory = new DirectoryArtifactReader(mountRoot);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
if (cause instanceof NativeDmgCommandFailure && cause.stdout !== "") {
|
if (cause instanceof NativeDmgCommandFailure && cause.stdout !== "") {
|
||||||
@@ -482,10 +473,59 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
|||||||
this.#attachMayHaveMounted = false;
|
this.#attachMayHaveMounted = false;
|
||||||
this.#ownershipUncertainty = undefined;
|
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> {
|
async #verify(signal?: AbortSignal): Promise<void> {
|
||||||
const arguments_ = ["verify", this.path];
|
const arguments_ = ["verify", this.path];
|
||||||
const deadline = performance.now() + HDIUTIL_COMMAND_TIMEOUT_MS;
|
const deadline = performance.now() + HDIUTIL_COMMAND_TIMEOUT_MS;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const attachOutputSchema = z.object({
|
|||||||
const infoOutputSchema = z.object({
|
const infoOutputSchema = z.object({
|
||||||
images: z.array(
|
images: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
"image-path": z.string().optional(),
|
||||||
"system-entities": z.array(systemEntitySchema),
|
"system-entities": z.array(systemEntitySchema),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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", () => {
|
describe("native DMG command diagnostics", () => {
|
||||||
it("keeps unknown attach failures unavailable with command evidence", async () => {
|
it("keeps unknown attach failures unavailable with command evidence", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
Reference in new issue
Block a user