Compare executable fingerprints before and after the owned version probe, and capture the script prefix from the final hash stream. Preserve partial facts and failed-close ownership without claiming process interruption from a snapshot. Closes #5611
655 lines
24 KiB
TypeScript
655 lines
24 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { open as openFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { expect, it, vi } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
|
|
import * as regularFile from "../../../src/filesystem/RegularFile.js";
|
|
import { RegularFileAdmissionFailure } from "../../../src/filesystem/RegularFile.js";
|
|
import {
|
|
hashStableFile,
|
|
observeStableFile,
|
|
} from "../../../src/filesystem/StableFileHash.js";
|
|
import { LinuxHopperPublisher } from "../../../src/application/LinuxHopperPublisher.js";
|
|
import {
|
|
RegularFileCancelled,
|
|
RegularFileCleanupFailure,
|
|
} from "../../../src/filesystem/OwnedRegularFile.js";
|
|
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
|
|
import type { VerifiedLinuxHopperPackage } from "../../../src/application/LinuxHopperPublisher.js";
|
|
|
|
it("captures a two-byte prefix across short reads and buffer reuse", async () => {
|
|
const root = await createTestTempDirectory("rea-stable-observation-prefix-");
|
|
const path = join(root, "script");
|
|
const bytes = Buffer.from("#!rest-of-file");
|
|
await writeFile(path, bytes);
|
|
const retained: OwnedFileHandle[] = [];
|
|
const originalOpen = regularFile.openRegularFile;
|
|
const open = vi
|
|
.spyOn(regularFile, "openRegularFile")
|
|
.mockImplementation(async (...args) => {
|
|
const handle = await originalOpen(...args);
|
|
return {
|
|
get fd() {
|
|
return handle.fd;
|
|
},
|
|
stat: (...statArgs: Parameters<typeof handle.stat>) =>
|
|
handle.stat(...statArgs),
|
|
read: (
|
|
buffer: Buffer,
|
|
offset: number,
|
|
length: number,
|
|
position: number,
|
|
) =>
|
|
handle.read(
|
|
buffer,
|
|
offset,
|
|
position === 0 ? Math.min(length, 1) : length,
|
|
position,
|
|
),
|
|
close: () => handle.close(),
|
|
} as unknown as Awaited<ReturnType<typeof originalOpen>>;
|
|
});
|
|
try {
|
|
const observation = await observeStableFile(path, {
|
|
retainFailedClose: (owner) => retained.push(owner),
|
|
});
|
|
expect(observation).toMatchObject({
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
bytes: bytes.length,
|
|
prefix: Buffer.from("#!"),
|
|
});
|
|
} finally {
|
|
open.mockRestore();
|
|
for (const owner of retained) if (owner.handle.fd >= 0) await owner.close();
|
|
}
|
|
});
|
|
|
|
it("adopts and retries the descriptor when hashing succeeded but close failed", async () => {
|
|
const root = await createTestTempDirectory("rea-stable-hash-close-");
|
|
const path = join(root, "input.bin");
|
|
await writeFile(path, "owned bytes");
|
|
const closeCause = new Error("injected descriptor close failure");
|
|
const originalClose = OwnedFileHandle.prototype.close;
|
|
const close = vi
|
|
.spyOn(OwnedFileHandle.prototype, "close")
|
|
.mockImplementationOnce(async () => {
|
|
throw closeCause;
|
|
});
|
|
const retained: OwnedFileHandle[] = [];
|
|
try {
|
|
let failure: unknown;
|
|
try {
|
|
await hashStableFile(path, {
|
|
retainFailedClose: (owner) => retained.push(owner),
|
|
});
|
|
} catch (cause: unknown) {
|
|
failure = cause;
|
|
}
|
|
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
|
|
expect(retained).toHaveLength(1);
|
|
if (!(failure instanceof RegularFileCleanupFailure))
|
|
throw new Error("Expected a typed hash cleanup failure");
|
|
expect(failure.outcome).toMatchObject({
|
|
kind: "completed",
|
|
value: { bytes: 11 },
|
|
});
|
|
expect(failure.cleanupCause).toBe(closeCause);
|
|
expect(failure.retention).toEqual({ kind: "callback_returned" });
|
|
expect(failure.owner).toBe(retained[0]);
|
|
expect(retained[0]!.handle.fd).toBeGreaterThanOrEqual(0);
|
|
close.mockRestore();
|
|
await retained[0]!.close();
|
|
expect(retained[0]!.handle.fd).toBe(-1);
|
|
} finally {
|
|
close.mockRestore();
|
|
if (retained[0] !== undefined && retained[0]!.handle.fd >= 0)
|
|
await originalClose.call(retained[0]);
|
|
}
|
|
});
|
|
|
|
it("preserves a read cancellation cause when close also fails", async () => {
|
|
const root = await createTestTempDirectory("rea-stable-hash-primary-");
|
|
const path = join(root, "input.bin");
|
|
await writeFile(path, "owned bytes");
|
|
const primary = new Error("injected cancellation after read");
|
|
const closeCause = new Error("injected close failure after cancellation");
|
|
const controller = new AbortController();
|
|
const nativeOpen = regularFile.openRegularFile;
|
|
const open = vi
|
|
.spyOn(regularFile, "openRegularFile")
|
|
.mockImplementation(async (selectedPath, options) => {
|
|
const handle = await nativeOpen(selectedPath, options);
|
|
vi.spyOn(handle, "stat").mockImplementationOnce(async () => {
|
|
controller.abort(primary);
|
|
throw primary;
|
|
});
|
|
return handle;
|
|
});
|
|
const originalClose = OwnedFileHandle.prototype.close;
|
|
const close = vi
|
|
.spyOn(OwnedFileHandle.prototype, "close")
|
|
.mockImplementationOnce(async () => {
|
|
throw closeCause;
|
|
});
|
|
const retained: OwnedFileHandle[] = [];
|
|
try {
|
|
let failure: unknown;
|
|
try {
|
|
await hashStableFile(path, {
|
|
signal: controller.signal,
|
|
retainFailedClose: (owner) => retained.push(owner),
|
|
});
|
|
} catch (cause: unknown) {
|
|
failure = cause;
|
|
}
|
|
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
|
|
if (!(failure instanceof RegularFileCleanupFailure))
|
|
throw new Error("Expected a typed hash cleanup failure");
|
|
expect(failure.outcome).toEqual({ kind: "failed", cause: primary });
|
|
expect(failure.cleanupCause).toBe(closeCause);
|
|
expect(failure.cause).toBeInstanceOf(AggregateError);
|
|
expect((failure.cause as AggregateError).errors).toEqual([
|
|
primary,
|
|
closeCause,
|
|
]);
|
|
expect(retained).toEqual([failure.owner]);
|
|
close.mockRestore();
|
|
await failure.owner.close();
|
|
expect(failure.owner.handle.fd).toBe(-1);
|
|
} finally {
|
|
close.mockRestore();
|
|
open.mockRestore();
|
|
if (retained[0] !== undefined && retained[0]!.handle.fd >= 0)
|
|
await originalClose.call(retained[0]);
|
|
}
|
|
});
|
|
|
|
it("closes a normally hashed file without adopting an owner", async () => {
|
|
const root = await createTestTempDirectory("rea-stable-hash-control-");
|
|
const path = join(root, "input.bin");
|
|
await writeFile(path, "owned bytes");
|
|
const retainFailedClose = vi.fn();
|
|
await expect(
|
|
hashStableFile(path, { retainFailedClose }),
|
|
).resolves.toMatchObject({ bytes: 11 });
|
|
expect(retainFailedClose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("retains a completed digest when cancellation follows successful close", async () => {
|
|
const root = await createTestTempDirectory("rea-stable-hash-cancel-");
|
|
const path = join(root, "input.bin");
|
|
const bytes = Buffer.from("owned bytes");
|
|
await writeFile(path, bytes);
|
|
const controller = new AbortController();
|
|
const reason = new Error("caller cancelled during close");
|
|
const retainFailedClose = vi.fn();
|
|
const nativeClose = OwnedFileHandle.prototype.close;
|
|
let observed: OwnedFileHandle | undefined;
|
|
const close = vi
|
|
.spyOn(OwnedFileHandle.prototype, "close")
|
|
.mockImplementationOnce(async function (this: OwnedFileHandle) {
|
|
observed = this;
|
|
await nativeClose.call(this);
|
|
controller.abort(reason);
|
|
});
|
|
try {
|
|
const failure: unknown = await hashStableFile(path, {
|
|
signal: controller.signal,
|
|
retainFailedClose,
|
|
}).catch((cause: unknown) => cause);
|
|
expect(failure).toBeInstanceOf(RegularFileCancelled);
|
|
if (!(failure instanceof RegularFileCancelled))
|
|
throw new Error(
|
|
"Expected completed file observations under cancellation",
|
|
);
|
|
expect(failure.path).toBe(path);
|
|
expect(failure.reason).toBe(reason);
|
|
expect(failure.owner).toBe(observed);
|
|
expect(failure.outcome).toEqual({
|
|
kind: "completed",
|
|
value: {
|
|
bytes: bytes.length,
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
},
|
|
});
|
|
expect(observed?.handle.fd).toBe(-1);
|
|
expect(retainFailedClose).not.toHaveBeenCalled();
|
|
} finally {
|
|
close.mockRestore();
|
|
await observed?.close();
|
|
}
|
|
});
|
|
|
|
it("adopts a descriptor from an admission failure and preserves both causes", async () => {
|
|
const root = await createTestTempDirectory("rea-stable-hash-admission-");
|
|
const path = join(root, "input.bin");
|
|
await writeFile(path, "owned bytes");
|
|
const owner = new OwnedFileHandle(await openFile(path, "r"));
|
|
const primary = new Error("injected file admission failure");
|
|
const cleanupCause = new Error("injected admission descriptor close failure");
|
|
const open = vi
|
|
.spyOn(regularFile, "openRegularFile")
|
|
.mockRejectedValueOnce(
|
|
new RegularFileAdmissionFailure(path, primary, cleanupCause, owner),
|
|
);
|
|
const retained: OwnedFileHandle[] = [];
|
|
try {
|
|
let failure: unknown;
|
|
try {
|
|
await hashStableFile(path, {
|
|
retainFailedClose: (retainedOwner) => retained.push(retainedOwner),
|
|
});
|
|
} catch (cause: unknown) {
|
|
failure = cause;
|
|
}
|
|
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
|
|
if (!(failure instanceof RegularFileCleanupFailure))
|
|
throw new Error("Expected a typed admission cleanup failure");
|
|
expect(failure.outcome).toEqual({ kind: "failed", cause: primary });
|
|
expect(failure.cleanupCause).toBe(cleanupCause);
|
|
expect(failure.retention).toEqual({ kind: "callback_returned" });
|
|
expect((failure.cause as AggregateError).errors).toEqual([
|
|
primary,
|
|
cleanupCause,
|
|
]);
|
|
expect(retained).toEqual([owner]);
|
|
} finally {
|
|
open.mockRestore();
|
|
await owner.close();
|
|
}
|
|
});
|
|
|
|
it("distinguishes a thrown undefined primary failure from a clean hash", async () => {
|
|
const root = await createTestTempDirectory("rea-stable-hash-undefined-");
|
|
const path = join(root, "input.bin");
|
|
await writeFile(path, "owned bytes");
|
|
const openFileAtBoundary = regularFile.openRegularFile;
|
|
const open = vi
|
|
.spyOn(regularFile, "openRegularFile")
|
|
.mockImplementation(async (selectedPath, options) => {
|
|
const handle = await openFileAtBoundary(selectedPath, options);
|
|
const originalStat = handle.stat.bind(handle);
|
|
let calls = 0;
|
|
vi.spyOn(handle, "stat").mockImplementation(async () => {
|
|
calls += 1;
|
|
if (calls === 2) throw undefined;
|
|
return originalStat();
|
|
});
|
|
return handle;
|
|
});
|
|
const closeCause = new Error("injected close failure after undefined");
|
|
const close = vi
|
|
.spyOn(OwnedFileHandle.prototype, "close")
|
|
.mockRejectedValueOnce(closeCause);
|
|
const retained: OwnedFileHandle[] = [];
|
|
try {
|
|
let failure: unknown;
|
|
try {
|
|
await hashStableFile(path, {
|
|
retainFailedClose: (owner) => retained.push(owner),
|
|
});
|
|
} catch (cause: unknown) {
|
|
failure = cause;
|
|
}
|
|
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
|
|
if (!(failure instanceof RegularFileCleanupFailure))
|
|
throw new Error("Expected a typed hash cleanup failure");
|
|
expect(failure.outcome).toEqual({ kind: "failed", cause: undefined });
|
|
expect(failure.cleanupCause).toBe(closeCause);
|
|
expect((failure.cause as AggregateError).errors).toEqual([
|
|
undefined,
|
|
closeCause,
|
|
]);
|
|
expect(retained).toEqual([failure.owner]);
|
|
} finally {
|
|
close.mockRestore();
|
|
open.mockRestore();
|
|
if (retained[0] !== undefined) await retained[0].close();
|
|
}
|
|
});
|
|
|
|
it("retries a publisher-owned launcher hash close before returning its digest", async () => {
|
|
const root = await createTestTempDirectory("rea-publisher-hash-owner-");
|
|
const path = join(root, "launcher");
|
|
await writeFile(path, "fixture launcher bytes");
|
|
const publisher = new LinuxHopperPublisher({}, async () => undefined);
|
|
const close = vi
|
|
.spyOn(OwnedFileHandle.prototype, "close")
|
|
.mockRejectedValueOnce(new Error("fixture launcher close failure"));
|
|
try {
|
|
await expect(publisher.hashLauncher(path)).resolves.toMatch(
|
|
/^[a-f0-9]{64}$/u,
|
|
);
|
|
expect(close).toHaveBeenCalledTimes(2);
|
|
close.mockRestore();
|
|
await publisher.close();
|
|
} finally {
|
|
close.mockRestore();
|
|
await publisher.close();
|
|
}
|
|
});
|
|
|
|
it("retries pending cleanup before acquiring a publisher temporary root", async () => {
|
|
const root = await createTestTempDirectory("rea-publisher-admission-");
|
|
const path = join(root, "launcher");
|
|
const launcherBytes = "fixture launcher bytes";
|
|
await writeFile(path, launcherBytes);
|
|
const publisher = new LinuxHopperPublisher(
|
|
{ ...process.env, XDG_CACHE_HOME: join(root, "cache") },
|
|
async () => undefined,
|
|
);
|
|
const originalClose = OwnedFileHandle.prototype.close;
|
|
const closeOwners: OwnedFileHandle[] = [];
|
|
let allowClose = false;
|
|
const close = vi
|
|
.spyOn(OwnedFileHandle.prototype, "close")
|
|
.mockImplementation(async function (this: OwnedFileHandle) {
|
|
closeOwners.push(this);
|
|
if (!allowClose) throw new Error("fixture close remains unavailable");
|
|
return originalClose.call(this);
|
|
});
|
|
const createRoot = vi.spyOn(PrivateRuntimeRoot, "create");
|
|
try {
|
|
await expect(publisher.hashLauncher(path)).rejects.toMatchObject({
|
|
reason: "cleanup_failed",
|
|
});
|
|
const retainedOwner = closeOwners[0];
|
|
if (retainedOwner === undefined)
|
|
throw new Error("Expected a retained launcher descriptor");
|
|
const attemptsBeforeAdmission = closeOwners.length;
|
|
await expect(
|
|
publisher.retainVerifiedArchive({} as VerifiedLinuxHopperPackage, path),
|
|
).resolves.toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
reason: "cleanup_failed",
|
|
cause: { cleanup: { resources: [path] } },
|
|
},
|
|
});
|
|
expect(createRoot).not.toHaveBeenCalled();
|
|
expect(closeOwners.length).toBeGreaterThan(attemptsBeforeAdmission);
|
|
expect(closeOwners.every((owner) => owner === retainedOwner)).toBe(true);
|
|
|
|
const attemptsBeforeRecovery = closeOwners.length;
|
|
allowClose = true;
|
|
await expect(publisher.hashLauncher(path)).resolves.toBe(
|
|
createHash("sha256").update(launcherBytes).digest("hex"),
|
|
);
|
|
const recoveryOwners = closeOwners.slice(attemptsBeforeRecovery);
|
|
expect(recoveryOwners[0]).toBe(retainedOwner);
|
|
expect(recoveryOwners.some((owner) => owner !== retainedOwner)).toBe(true);
|
|
expect(closeOwners.every((owner) => owner.handle.fd === -1)).toBe(true);
|
|
expect(createRoot).not.toHaveBeenCalled();
|
|
const attemptsAfterRecovery = closeOwners.length;
|
|
await publisher.close();
|
|
expect(closeOwners).toHaveLength(attemptsAfterRecovery);
|
|
} finally {
|
|
close.mockRestore();
|
|
createRoot.mockRestore();
|
|
await publisher.close();
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ stage: "close", reason: null },
|
|
{ stage: "close", reason: "caller stopped" },
|
|
{ stage: "close", reason: new Error("caller stopped") },
|
|
{ stage: "retention", reason: "after the failed close" },
|
|
] as const)(
|
|
"records completion cancellation at $stage before owner handoff",
|
|
async ({ stage, reason }) => {
|
|
const root = await createTestTempDirectory("rea-hash-close-provenance-");
|
|
const path = join(root, "input.bin");
|
|
await writeFile(path, "owned bytes");
|
|
const controller = new AbortController();
|
|
const nativeClose = OwnedFileHandle.prototype.close;
|
|
const close = vi
|
|
.spyOn(OwnedFileHandle.prototype, "close")
|
|
.mockImplementationOnce(async () => {
|
|
if (stage === "close") controller.abort(reason);
|
|
throw new Error("injected failed close");
|
|
});
|
|
let retained: OwnedFileHandle | undefined;
|
|
try {
|
|
const failure: unknown = await hashStableFile(path, {
|
|
signal: controller.signal,
|
|
retainFailedClose: (owner) => {
|
|
retained = owner;
|
|
if (stage === "retention") controller.abort(reason);
|
|
},
|
|
}).catch((cause: unknown) => cause);
|
|
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
|
|
expect(failure).toMatchObject({
|
|
outcome: { kind: "completed", value: { bytes: 11 } },
|
|
});
|
|
if (stage === "close") {
|
|
expect(failure).toMatchObject({ completionCancellation: { reason } });
|
|
expect(
|
|
Reflect.get(failure as object, "completionCancellation").reason,
|
|
).toBe(reason);
|
|
} else
|
|
expect(failure).not.toHaveProperty(
|
|
"completionCancellation",
|
|
expect.anything(),
|
|
);
|
|
} finally {
|
|
close.mockRestore();
|
|
if (retained !== undefined) await nativeClose.call(retained);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("tags a physical hash failure without inferring cancellation from a later signal", async () => {
|
|
const root = await createTestTempDirectory("rea-hash-physical-provenance-");
|
|
const path = join(root, "input.bin");
|
|
await writeFile(path, "owned bytes");
|
|
const controller = new AbortController();
|
|
const primary = Object.assign(new Error("injected digest read failure"), {
|
|
code: "EIO",
|
|
});
|
|
const nativeOpen = regularFile.openRegularFile;
|
|
const open = vi
|
|
.spyOn(regularFile, "openRegularFile")
|
|
.mockImplementation(async (selected, options) => {
|
|
const handle = await nativeOpen(selected, options);
|
|
vi.spyOn(handle, "read").mockRejectedValueOnce(primary);
|
|
return handle;
|
|
});
|
|
const nativeClose = OwnedFileHandle.prototype.close;
|
|
const close = vi
|
|
.spyOn(OwnedFileHandle.prototype, "close")
|
|
.mockImplementationOnce(async function (this: OwnedFileHandle) {
|
|
await nativeClose.call(this);
|
|
controller.abort(primary);
|
|
});
|
|
try {
|
|
const failure: unknown = await hashStableFile(path, {
|
|
signal: controller.signal,
|
|
retainFailedClose: () => {
|
|
throw new Error("Close succeeded");
|
|
},
|
|
}).catch((cause: unknown) => cause);
|
|
expect(failure).toMatchObject({
|
|
_tag: "StableFileHashFailure",
|
|
cause: primary,
|
|
});
|
|
if (!(failure instanceof Error))
|
|
throw new Error("Expected producer-owned physical failure");
|
|
expect(failure.cause).toBe(primary);
|
|
} finally {
|
|
open.mockRestore();
|
|
close.mockRestore();
|
|
}
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"preserves premature EOF with same-read cancellation and failed close=%s",
|
|
async (failClose) => {
|
|
const root = await createTestTempDirectory("rea-hash-eof-cancel-");
|
|
const path = join(root, "input.bin");
|
|
await writeFile(path, "admitted extent");
|
|
const controller = new AbortController();
|
|
const nativeOpen = regularFile.openRegularFile;
|
|
let retained: OwnedFileHandle | undefined;
|
|
const open = vi
|
|
.spyOn(regularFile, "openRegularFile")
|
|
.mockImplementation(async (selected, options) => {
|
|
const handle = await nativeOpen(selected, options);
|
|
vi.spyOn(handle, "read").mockImplementationOnce(async () => {
|
|
controller.abort("cancelled at EOF");
|
|
return { bytesRead: 0, buffer: Buffer.alloc(0) };
|
|
});
|
|
return handle;
|
|
});
|
|
const nativeClose = OwnedFileHandle.prototype.close;
|
|
const close = vi.spyOn(OwnedFileHandle.prototype, "close");
|
|
if (failClose) close.mockRejectedValueOnce(new Error("close unavailable"));
|
|
try {
|
|
const failure: unknown = await hashStableFile(path, {
|
|
signal: controller.signal,
|
|
retainFailedClose: (owner) => {
|
|
retained = owner;
|
|
},
|
|
}).catch((cause: unknown) => cause);
|
|
const physical =
|
|
failure instanceof RegularFileCleanupFailure
|
|
? failure.outcome.kind === "failed"
|
|
? failure.outcome.cause
|
|
: undefined
|
|
: failure instanceof Error
|
|
? failure.cause
|
|
: undefined;
|
|
expect(physical).toBeInstanceOf(regularFile.RegularFileChangedError);
|
|
if (failClose) {
|
|
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
|
|
expect(retained?.handle.fd).toBeGreaterThanOrEqual(0);
|
|
} else expect(failure).toMatchObject({ _tag: "StableFileHashFailure" });
|
|
} finally {
|
|
open.mockRestore();
|
|
close.mockRestore();
|
|
if (retained !== undefined) await nativeClose.call(retained);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(
|
|
[null, "caller stopped", new Error("caller stopped")].flatMap((reason) =>
|
|
[false, true].map((failClose) => ({ reason, failClose })),
|
|
),
|
|
)(
|
|
"retains the exact read checkpoint reason with failed close=$failClose ($reason)",
|
|
async ({ reason, failClose }) => {
|
|
const root = await createTestTempDirectory("rea-hash-read-checkpoint-");
|
|
const path = join(root, "input.bin");
|
|
await writeFile(path, "admitted bytes");
|
|
const controller = new AbortController();
|
|
const nativeOpen = regularFile.openRegularFile;
|
|
let retained: OwnedFileHandle | undefined;
|
|
let handleSeen: Awaited<ReturnType<typeof nativeOpen>> | undefined;
|
|
const open = vi
|
|
.spyOn(regularFile, "openRegularFile")
|
|
.mockImplementation(async (selected, options) => {
|
|
const handle = await nativeOpen(selected, options);
|
|
handleSeen = handle;
|
|
const read = handle.read.bind(handle);
|
|
vi.spyOn(handle, "read").mockImplementationOnce(async (...args) => {
|
|
const result = await read(...args);
|
|
controller.abort(reason);
|
|
return result;
|
|
});
|
|
return handle;
|
|
});
|
|
const nativeClose = OwnedFileHandle.prototype.close;
|
|
const close = vi.spyOn(OwnedFileHandle.prototype, "close");
|
|
if (failClose) close.mockRejectedValueOnce(new Error("close unavailable"));
|
|
try {
|
|
const failure: unknown = await hashStableFile(path, {
|
|
signal: controller.signal,
|
|
retainFailedClose: (owner) => {
|
|
retained = owner;
|
|
},
|
|
}).catch((cause: unknown) => cause);
|
|
const primary =
|
|
failure instanceof RegularFileCleanupFailure &&
|
|
failure.outcome.kind === "failed"
|
|
? failure.outcome.cause
|
|
: failure;
|
|
expect(primary).toBeInstanceOf(regularFile.RegularFileOperationCancelled);
|
|
expect(
|
|
(primary as regularFile.RegularFileOperationCancelled).reason,
|
|
).toBe(reason);
|
|
expect(handleSeen?.fd).toBe(failClose ? retained?.handle.fd : -1);
|
|
} finally {
|
|
open.mockRestore();
|
|
close.mockRestore();
|
|
if (retained !== undefined) await nativeClose.call(retained);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(["physical", "completed"] as const)(
|
|
"preserves the publisher's %s hash outcome after cancellation and a successful close retry",
|
|
async (outcome) => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-publisher-hash-retry-cancel-",
|
|
);
|
|
const path = join(root, "launcher");
|
|
await writeFile(path, "launcher bytes");
|
|
const controller = new AbortController();
|
|
const primary = new Error("injected publisher read failure");
|
|
const reason = null;
|
|
const nativeOpen = regularFile.openRegularFile;
|
|
const open = vi
|
|
.spyOn(regularFile, "openRegularFile")
|
|
.mockImplementation(async (selected, options) => {
|
|
const handle = await nativeOpen(selected, options);
|
|
if (outcome === "physical")
|
|
vi.spyOn(handle, "read").mockRejectedValueOnce(primary);
|
|
return handle;
|
|
});
|
|
const nativeClose = OwnedFileHandle.prototype.close;
|
|
let ownerSeen: OwnedFileHandle | undefined;
|
|
let attempts = 0;
|
|
const close = vi
|
|
.spyOn(OwnedFileHandle.prototype, "close")
|
|
.mockImplementation(async function (this: OwnedFileHandle) {
|
|
ownerSeen = this;
|
|
attempts++;
|
|
if (attempts === 1) {
|
|
controller.abort(reason);
|
|
throw new Error("first close unavailable");
|
|
}
|
|
await nativeClose.call(this);
|
|
});
|
|
const publisher = new LinuxHopperPublisher(
|
|
{ XDG_CACHE_HOME: join(root, "cache") },
|
|
async () => undefined,
|
|
);
|
|
try {
|
|
const result = await publisher.inspectLauncher(path, controller.signal);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("Injected hash outcome passed");
|
|
expect(result.error.reason).toBe(
|
|
outcome === "physical" ? "verification_unavailable" : "cancelled",
|
|
);
|
|
if (outcome === "physical")
|
|
expect(result.error.cause).toMatchObject({
|
|
_tag: "StableFileHashFailure",
|
|
cause: primary,
|
|
});
|
|
else expect(result.error.cause).toBe(reason);
|
|
expect(attempts).toBe(2);
|
|
expect(ownerSeen?.handle.fd).toBe(-1);
|
|
} finally {
|
|
open.mockRestore();
|
|
close.mockRestore();
|
|
await publisher.close();
|
|
if (ownerSeen !== undefined && ownerSeen.handle.fd >= 0)
|
|
await nativeClose.call(ownerSeen);
|
|
}
|
|
},
|
|
);
|