fix(artifacts): retain output ownership through publication failures
This commit is contained in:
1 parent
e0e49e9dd5
commit
3f8c51e9b5
12 files changed
+725
-175
No files matched your search
+238
-144
@@ -5,11 +5,11 @@ import {
|
||||
open,
|
||||
readdir,
|
||||
realpath,
|
||||
rmdir,
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import type { Readable } from "node:stream";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { streamChunkToBuffer } from "./StreamBytes.js";
|
||||
|
||||
import {
|
||||
@@ -18,13 +18,14 @@ import {
|
||||
normalizeArtifactPath,
|
||||
} from "./ArtifactPaths.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import { SafeOutputTreeCreationFailure } from "./SafeOutputTreeCreationFailure.js";
|
||||
import { hashOutputFile } from "./SafeOutputTreeReadback.js";
|
||||
import { removeOwnedTree } from "./SafeOutputTreeCleanup.js";
|
||||
import {
|
||||
assertHandleIdentity,
|
||||
assertFilePathIdentity,
|
||||
assertPathIdentity,
|
||||
isAbsent,
|
||||
pathHasIdentity,
|
||||
readDirectoryIdentity,
|
||||
readFileIdentity,
|
||||
replacedDirectory,
|
||||
@@ -57,9 +58,12 @@ export type SafeOutputCleanup =
|
||||
export class SafeOutputTree {
|
||||
readonly #registry = new ArtifactPathRegistry();
|
||||
readonly #outputRoot: string;
|
||||
readonly #rootIdentity: DirectoryIdentity;
|
||||
#rootIdentity: DirectoryIdentity | undefined;
|
||||
readonly #nestedDirectories = new Map<string, DirectoryIdentity>();
|
||||
readonly #unknownDirectories = new Set<string>();
|
||||
readonly #ownedFiles = new Map<string, FileIdentity>();
|
||||
readonly #openFiles = new Map<string, OwnedFileHandle>();
|
||||
readonly #openHandles = new Set<OwnedFileHandle>();
|
||||
#published = false;
|
||||
#cleanup: SafeOutputCleanup = {
|
||||
status: "not-required",
|
||||
@@ -67,11 +71,9 @@ export class SafeOutputTree {
|
||||
|
||||
private constructor(
|
||||
outputRoot: string,
|
||||
rootIdentity: DirectoryIdentity,
|
||||
private readonly platform: NodeJS.Platform,
|
||||
) {
|
||||
this.#outputRoot = outputRoot;
|
||||
this.#rootIdentity = rootIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,57 +115,52 @@ export class SafeOutputTree {
|
||||
);
|
||||
throw cause;
|
||||
});
|
||||
let rootIdentity: DirectoryIdentity | undefined;
|
||||
const tree = new SafeOutputTree(canonicalOutput, platform);
|
||||
try {
|
||||
rootIdentity = await readDirectoryIdentity(canonicalOutput);
|
||||
const rootIdentity = await readDirectoryIdentity(canonicalOutput);
|
||||
tree.#rootIdentity = rootIdentity;
|
||||
if (platform !== "win32") {
|
||||
const stagingHandle = await open(
|
||||
canonicalOutput,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY,
|
||||
const stagingHandle = tree.#trackHandle(
|
||||
await open(
|
||||
canonicalOutput,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY,
|
||||
),
|
||||
);
|
||||
let setup:
|
||||
| { readonly kind: "ready" }
|
||||
| {
|
||||
readonly kind: "failed";
|
||||
readonly cause: unknown;
|
||||
} = { kind: "ready" };
|
||||
try {
|
||||
await assertHandleIdentity(
|
||||
stagingHandle,
|
||||
stagingHandle.handle,
|
||||
rootIdentity,
|
||||
canonicalOutput,
|
||||
);
|
||||
await stagingHandle.chmod(0o700);
|
||||
} finally {
|
||||
await stagingHandle.close();
|
||||
await stagingHandle.handle.chmod(0o700);
|
||||
} catch (cause: unknown) {
|
||||
setup = { kind: "failed", cause };
|
||||
}
|
||||
try {
|
||||
await tree.#closeHandle(stagingHandle);
|
||||
} catch (closeCause: unknown) {
|
||||
if (setup.kind === "failed")
|
||||
throw ArtifactReaderFailure.withCleanup(
|
||||
setup.cause,
|
||||
ArtifactReaderFailure.cleanupObservation(
|
||||
closeCause,
|
||||
canonicalOutput,
|
||||
),
|
||||
);
|
||||
throw closeCause;
|
||||
}
|
||||
if (setup.kind === "failed") throw setup.cause;
|
||||
}
|
||||
await assertPathIdentity(canonicalOutput, rootIdentity);
|
||||
return new SafeOutputTree(canonicalOutput, rootIdentity, platform);
|
||||
return tree;
|
||||
} catch (cause: unknown) {
|
||||
let removalFailure: unknown;
|
||||
let ownedRootAtPath = false;
|
||||
try {
|
||||
ownedRootAtPath =
|
||||
rootIdentity !== undefined &&
|
||||
(await pathHasIdentity(canonicalOutput, rootIdentity));
|
||||
if (ownedRootAtPath) await rmdir(canonicalOutput);
|
||||
else
|
||||
removalFailure = new Error(
|
||||
"Extraction output root identity changed during setup",
|
||||
);
|
||||
} catch (cleanupCause: unknown) {
|
||||
removalFailure = cleanupCause;
|
||||
}
|
||||
let absent = false;
|
||||
try {
|
||||
absent = ownedRootAtPath && (await isAbsent(canonicalOutput));
|
||||
} catch (cleanupCause: unknown) {
|
||||
removalFailure ??= cleanupCause;
|
||||
}
|
||||
if (!absent)
|
||||
throw ArtifactReaderFailure.withCleanup(cause, {
|
||||
reason:
|
||||
removalFailure instanceof Error
|
||||
? removalFailure.message
|
||||
: "Extraction output root ownership could not be verified after setup failure",
|
||||
resources: [canonicalOutput],
|
||||
});
|
||||
throw cause;
|
||||
throw new SafeOutputTreeCreationFailure(cause, tree);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +168,11 @@ export class SafeOutputTree {
|
||||
return this.#outputRoot;
|
||||
}
|
||||
|
||||
/** The output was durably committed, even if descriptor cleanup later failed. */
|
||||
get published(): boolean {
|
||||
return this.#published;
|
||||
}
|
||||
|
||||
get cleanup(): SafeOutputCleanup {
|
||||
return structuredClone(this.#cleanup);
|
||||
}
|
||||
@@ -211,58 +213,62 @@ export class SafeOutputTree {
|
||||
{ cause },
|
||||
);
|
||||
});
|
||||
const fileIdentity = await readFileIdentity(handle, destination).catch(
|
||||
async (cause: unknown) => {
|
||||
await handle.close().catch(() => undefined);
|
||||
throw cause;
|
||||
},
|
||||
const ownedHandle = this.#trackHandle(handle);
|
||||
this.#openFiles.set(destination, ownedHandle);
|
||||
const fileIdentity = await readFileIdentity(
|
||||
ownedHandle.handle,
|
||||
destination,
|
||||
);
|
||||
await assertFilePathIdentity(destination, fileIdentity);
|
||||
this.#ownedFiles.set(destination, fileIdentity);
|
||||
const hash = createHash("sha256");
|
||||
let bytes = 0;
|
||||
try {
|
||||
await this.#assertLineage(lineage);
|
||||
for await (const raw of source) {
|
||||
abortIfNeeded(signal);
|
||||
const chunk = streamChunkToBuffer(raw);
|
||||
if (chunk.length > expected.bytes - bytes)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Extracted content exceeds the inventoried size: ${path}`,
|
||||
);
|
||||
bytes += chunk.length;
|
||||
hash.update(chunk);
|
||||
await writeAll(handle, chunk);
|
||||
}
|
||||
if (bytes !== expected.bytes)
|
||||
await this.#assertLineage(lineage);
|
||||
for await (const raw of source) {
|
||||
abortIfNeeded(signal);
|
||||
const chunk = streamChunkToBuffer(raw);
|
||||
if (chunk.length > expected.bytes - bytes)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Extracted content size disagrees with inventory: ${path}`,
|
||||
`Extracted content exceeds the inventoried size: ${path}`,
|
||||
);
|
||||
const sha256 = hash.digest("hex");
|
||||
if (sha256 !== expected.sha256)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Extracted content disagrees with inventory: ${path}`,
|
||||
);
|
||||
await this.#assertLineage(lineage);
|
||||
await assertFilePathIdentity(destination, fileIdentity);
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
const readback = await hashFile(destination, bytes, signal);
|
||||
await this.#assertLineage(lineage);
|
||||
await assertFilePathIdentity(destination, fileIdentity);
|
||||
if (readback.sha256 !== sha256 || readback.bytes !== bytes)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Durable readback verification failed: ${path}`,
|
||||
);
|
||||
return { relativePath: path, sha256, bytesWritten: bytes };
|
||||
} catch (cause: unknown) {
|
||||
// best-effort cleanup: file-handle close must not mask the write failure.
|
||||
await handle.close().catch(() => undefined);
|
||||
throw cause;
|
||||
bytes += chunk.length;
|
||||
hash.update(chunk);
|
||||
await writeAll(ownedHandle.handle, chunk);
|
||||
}
|
||||
if (bytes !== expected.bytes)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Extracted content size disagrees with inventory: ${path}`,
|
||||
);
|
||||
const sha256 = hash.digest("hex");
|
||||
if (sha256 !== expected.sha256)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Extracted content disagrees with inventory: ${path}`,
|
||||
);
|
||||
await this.#assertLineage(lineage);
|
||||
await assertFilePathIdentity(destination, fileIdentity);
|
||||
await ownedHandle.handle.sync();
|
||||
await this.#closeFileHandle(destination, ownedHandle);
|
||||
const readbackHandle = this.#trackHandle(
|
||||
await open(destination, constants.O_RDONLY | constants.O_NOFOLLOW),
|
||||
);
|
||||
const readback = await hashOutputFile(
|
||||
readbackHandle,
|
||||
destination,
|
||||
fileIdentity,
|
||||
{ maximum: bytes, signal },
|
||||
);
|
||||
await this.#closeHandle(readbackHandle);
|
||||
await this.#assertLineage(lineage);
|
||||
await assertFilePathIdentity(destination, fileIdentity);
|
||||
if (readback.sha256 !== sha256 || readback.bytes !== bytes)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Durable readback verification failed: ${path}`,
|
||||
);
|
||||
return { relativePath: path, sha256, bytesWritten: bytes };
|
||||
} catch (cause: unknown) {
|
||||
try {
|
||||
source.destroy();
|
||||
@@ -282,34 +288,80 @@ export class SafeOutputTree {
|
||||
this.#published = true;
|
||||
return;
|
||||
}
|
||||
const parent = await open(
|
||||
dirname(this.#outputRoot),
|
||||
constants.O_RDONLY | constants.O_DIRECTORY,
|
||||
);
|
||||
let output: FileHandle | undefined;
|
||||
try {
|
||||
output = await open(
|
||||
this.#outputRoot,
|
||||
const parent = this.#trackHandle(
|
||||
await open(
|
||||
dirname(this.#outputRoot),
|
||||
constants.O_RDONLY | constants.O_DIRECTORY,
|
||||
),
|
||||
);
|
||||
let output: OwnedFileHandle | undefined;
|
||||
let sync:
|
||||
| { readonly kind: "synced" }
|
||||
| {
|
||||
readonly kind: "failed";
|
||||
readonly cause: unknown;
|
||||
} = { kind: "synced" };
|
||||
try {
|
||||
output = this.#trackHandle(
|
||||
await open(
|
||||
this.#outputRoot,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY,
|
||||
),
|
||||
);
|
||||
await assertHandleIdentity(output, this.#rootIdentity, this.#outputRoot);
|
||||
await output.sync();
|
||||
await parent.sync();
|
||||
await assertHandleIdentity(
|
||||
output.handle,
|
||||
this.#requireRootIdentity(),
|
||||
this.#outputRoot,
|
||||
);
|
||||
await output.handle.sync();
|
||||
await parent.handle.sync();
|
||||
this.#published = true;
|
||||
} catch (cause: unknown) {
|
||||
sync = { kind: "failed", cause };
|
||||
}
|
||||
const closeResults = await Promise.allSettled(
|
||||
[parent, output]
|
||||
.filter(isDefined)
|
||||
.map((handle) => this.#closeHandle(handle)),
|
||||
);
|
||||
const closeFailures = closeResults
|
||||
.filter(isRejected)
|
||||
.map(({ reason }) => reason);
|
||||
if (closeFailures.length > 0) {
|
||||
const closeFailure = new AggregateError(
|
||||
closeFailures,
|
||||
"Extraction output directory handles could not be closed",
|
||||
);
|
||||
const cleanup = ArtifactReaderFailure.cleanupObservation(
|
||||
closeFailure,
|
||||
this.#outputRoot,
|
||||
);
|
||||
if (sync.kind === "failed")
|
||||
throw ArtifactReaderFailure.withCleanup(
|
||||
new ArtifactReaderFailure(
|
||||
"path",
|
||||
"Could not durably sync extraction output",
|
||||
{ cause: sync.cause },
|
||||
),
|
||||
cleanup,
|
||||
);
|
||||
throw ArtifactReaderFailure.withCleanup(closeFailure, cleanup);
|
||||
}
|
||||
if (sync.kind === "failed")
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
"Could not durably sync extraction output",
|
||||
{ cause },
|
||||
{ cause: sync.cause },
|
||||
);
|
||||
} finally {
|
||||
await Promise.allSettled([parent.close(), output?.close()]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove only this operation's unsealed tree and verify absence. */
|
||||
async rollback(): Promise<SafeOutputCleanup> {
|
||||
if (this.#published || this.#cleanup.status === "complete")
|
||||
if (this.#published) {
|
||||
await this.#closePendingHandles();
|
||||
return structuredClone(this.#cleanup);
|
||||
}
|
||||
if (this.#cleanup.status === "complete")
|
||||
return structuredClone(this.#cleanup);
|
||||
this.#cleanup = {
|
||||
status: "incomplete",
|
||||
@@ -317,10 +369,12 @@ export class SafeOutputTree {
|
||||
};
|
||||
let removalFailure: unknown;
|
||||
try {
|
||||
await this.#admitPendingFiles();
|
||||
await this.#closePendingHandles();
|
||||
await this.#assertOwnedDirectories();
|
||||
await removeOwnedTree({
|
||||
outputRoot: this.#outputRoot,
|
||||
rootIdentity: this.#rootIdentity,
|
||||
rootIdentity: this.#requireRootIdentity(),
|
||||
directories: this.#nestedDirectories,
|
||||
files: this.#ownedFiles,
|
||||
});
|
||||
@@ -362,10 +416,10 @@ export class SafeOutputTree {
|
||||
let current = this.#outputRoot;
|
||||
let logicalParent = "";
|
||||
const lineage: DirectoryLineageEntry[] = [
|
||||
{ path: this.#outputRoot, identity: this.#rootIdentity },
|
||||
{ path: this.#outputRoot, identity: this.#requireRootIdentity() },
|
||||
];
|
||||
if (parts.length === 0)
|
||||
await assertPathIdentity(this.#outputRoot, this.#rootIdentity);
|
||||
await assertPathIdentity(this.#outputRoot, this.#requireRootIdentity());
|
||||
for (const part of parts) {
|
||||
const parent = lineage.at(-1);
|
||||
if (parent === undefined) throw replacedDirectory(this.#outputRoot);
|
||||
@@ -384,8 +438,13 @@ export class SafeOutputTree {
|
||||
if (identity === undefined) throw replacedDirectory(current);
|
||||
await assertPathIdentity(current, identity);
|
||||
} else {
|
||||
const identity = await readDirectoryIdentity(current);
|
||||
this.#nestedDirectories.set(current, identity);
|
||||
try {
|
||||
const identity = await readDirectoryIdentity(current);
|
||||
this.#nestedDirectories.set(current, identity);
|
||||
} catch (cause: unknown) {
|
||||
this.#unknownDirectories.add(current);
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
await assertPathIdentity(parent.path, parent.identity);
|
||||
const identity = this.#nestedDirectories.get(current);
|
||||
@@ -405,9 +464,24 @@ export class SafeOutputTree {
|
||||
}
|
||||
|
||||
async #assertOwnedDirectories(): Promise<void> {
|
||||
await assertPathIdentity(this.#outputRoot, this.#rootIdentity);
|
||||
const rootIdentity = this.#requireRootIdentity();
|
||||
await assertPathIdentity(this.#outputRoot, rootIdentity);
|
||||
for (const [path, identity] of this.#nestedDirectories)
|
||||
await assertPathIdentity(path, identity);
|
||||
if (this.#unknownDirectories.size > 0)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Extraction directory ownership is unknown: ${[...this.#unknownDirectories].join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
#requireRootIdentity(): DirectoryIdentity {
|
||||
if (this.#rootIdentity === undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Extraction output root identity is unknown: ${this.#outputRoot}`,
|
||||
);
|
||||
return this.#rootIdentity;
|
||||
}
|
||||
|
||||
#assertWritable(): void {
|
||||
@@ -422,39 +496,59 @@ export class SafeOutputTree {
|
||||
"Extraction tree cleanup has already started",
|
||||
);
|
||||
}
|
||||
|
||||
#trackHandle(handle: FileHandle): OwnedFileHandle {
|
||||
const owner = new OwnedFileHandle(handle);
|
||||
this.#openHandles.add(owner);
|
||||
return owner;
|
||||
}
|
||||
|
||||
async #closeHandle(owner: OwnedFileHandle): Promise<void> {
|
||||
await owner.close();
|
||||
this.#openHandles.delete(owner);
|
||||
}
|
||||
|
||||
async #closePendingHandles(): Promise<void> {
|
||||
const failures: unknown[] = [];
|
||||
for (const owner of this.#openHandles) {
|
||||
try {
|
||||
await this.#closeHandle(owner);
|
||||
} catch (cause: unknown) {
|
||||
failures.push(cause);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0)
|
||||
throw new AggregateError(failures, "Output tree handles remain open");
|
||||
}
|
||||
|
||||
async #closeFileHandle(path: string, owner: OwnedFileHandle): Promise<void> {
|
||||
await this.#closeHandle(owner);
|
||||
this.#openFiles.delete(path);
|
||||
}
|
||||
|
||||
async #admitPendingFiles(): Promise<void> {
|
||||
for (const [path, owner] of this.#openFiles) {
|
||||
let identity = this.#ownedFiles.get(path);
|
||||
if (identity === undefined) {
|
||||
identity = await readFileIdentity(owner.handle, path);
|
||||
await assertFilePathIdentity(path, identity);
|
||||
this.#ownedFiles.set(path, identity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isDefined = <T>(value: T | undefined): value is T => value !== undefined;
|
||||
|
||||
const isRejected = (
|
||||
result: PromiseSettledResult<void>,
|
||||
): result is PromiseRejectedResult => result.status === "rejected";
|
||||
|
||||
type DirectoryLineageEntry = {
|
||||
readonly path: string;
|
||||
readonly identity: DirectoryIdentity;
|
||||
};
|
||||
|
||||
const hashFile = async (
|
||||
path: string,
|
||||
maximum: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ readonly sha256: string; readonly bytes: number }> => {
|
||||
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
const hash = createHash("sha256");
|
||||
let bytes = 0;
|
||||
try {
|
||||
for await (const raw of handle.createReadStream({ autoClose: false })) {
|
||||
abortIfNeeded(signal);
|
||||
const chunk = streamChunkToBuffer(raw);
|
||||
bytes += chunk.length;
|
||||
if (bytes > maximum)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"Readback exceeded the bytes written",
|
||||
);
|
||||
hash.update(chunk);
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return { sha256: hash.digest("hex"), bytes };
|
||||
};
|
||||
|
||||
const writeAll = async (
|
||||
handle: Awaited<ReturnType<typeof open>>,
|
||||
chunk: Buffer,
|
||||
@@ -475,6 +569,14 @@ const writeAll = async (
|
||||
}
|
||||
};
|
||||
|
||||
const abortIfNeeded = (signal?: AbortSignal): void => {
|
||||
if (signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"Artifact extraction cancelled",
|
||||
);
|
||||
};
|
||||
|
||||
const isAlreadyExists = (cause: unknown): boolean =>
|
||||
cause instanceof Error && "code" in cause && cause.code === "EEXIST";
|
||||
|
||||
@@ -494,11 +596,3 @@ const throwIfDestinationCaseCollision = async (
|
||||
cause === undefined ? undefined : { cause },
|
||||
);
|
||||
};
|
||||
|
||||
const abortIfNeeded = (signal?: AbortSignal): void => {
|
||||
if (signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"Artifact extraction cancelled",
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import type { SafeOutputTree } from "./SafeOutputTree.js";
|
||||
|
||||
/** Setup failed after this tree was acquired; callers still own this exact tree. */
|
||||
export class SafeOutputTreeCreationFailure extends ArtifactReaderFailure {
|
||||
readonly tree: SafeOutputTree;
|
||||
|
||||
constructor(cause: unknown, tree: SafeOutputTree) {
|
||||
const primary =
|
||||
cause instanceof ArtifactReaderFailure
|
||||
? cause
|
||||
: new ArtifactReaderFailure("io", errorMessage(cause), { cause });
|
||||
super(
|
||||
primary.reason,
|
||||
primary.message,
|
||||
{
|
||||
cause: primary,
|
||||
...(primary.cleanup === undefined ? {} : { cleanup: primary.cleanup }),
|
||||
...(primary.partialObservation === undefined
|
||||
? {}
|
||||
: { partialObservation: primary.partialObservation }),
|
||||
},
|
||||
primary.details,
|
||||
);
|
||||
this.name = "SafeOutputTreeCreationFailure";
|
||||
this.tree = tree;
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
@@ -71,20 +71,6 @@ export const assertFilePathIdentity = async (
|
||||
);
|
||||
};
|
||||
|
||||
export const pathHasIdentity = async (
|
||||
path: string,
|
||||
expected: DirectoryIdentity,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await assertPathIdentity(path, expected);
|
||||
return true;
|
||||
} catch (cause: unknown) {
|
||||
if (isNotFound(cause) || cause instanceof ArtifactReaderFailure)
|
||||
return false;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
export const isAbsent = async (path: string): Promise<boolean> =>
|
||||
lstat(path).then(
|
||||
() => false,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
|
||||
import { streamChunkToBuffer } from "./StreamBytes.js";
|
||||
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import {
|
||||
assertFilePathIdentity,
|
||||
readFileIdentity,
|
||||
type FileIdentity,
|
||||
} from "./SafeOutputTreeIdentity.js";
|
||||
|
||||
/** Hash one retained readback descriptor and confirm it still names the output file. */
|
||||
export const hashOutputFile = async (
|
||||
owner: OwnedFileHandle,
|
||||
path: string,
|
||||
identity: FileIdentity,
|
||||
options: {
|
||||
readonly maximum: number;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
},
|
||||
): Promise<{ readonly sha256: string; readonly bytes: number }> => {
|
||||
const admittedIdentity = await readFileIdentity(owner.handle, path);
|
||||
await assertFilePathIdentity(path, identity);
|
||||
if (
|
||||
admittedIdentity.dev !== identity.dev ||
|
||||
admittedIdentity.ino !== identity.ino
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Extraction file identity changed: ${path}`,
|
||||
);
|
||||
const hash = createHash("sha256");
|
||||
let bytes = 0;
|
||||
try {
|
||||
for await (const raw of readFileHandleChunks(owner.handle, options)) {
|
||||
if (options.signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"Artifact extraction cancelled",
|
||||
);
|
||||
const chunk = streamChunkToBuffer(raw);
|
||||
bytes += chunk.length;
|
||||
if (bytes > options.maximum)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"Readback exceeded the bytes written",
|
||||
);
|
||||
hash.update(chunk);
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
if (options.signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"Artifact extraction cancelled",
|
||||
{ cause },
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
await assertFilePathIdentity(path, identity);
|
||||
return { sha256: hash.digest("hex"), bytes };
|
||||
};
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { abortIfNeeded } from "../ArtifactHash.js";
|
||||
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
||||
import { SafeOutputTree } from "../SafeOutputTree.js";
|
||||
import { SafeOutputTreeCreationFailure } from "../SafeOutputTreeCreationFailure.js";
|
||||
import { ZipArtifactReader } from "../ZipArtifactReader.js";
|
||||
import { MachOSliceArtifactReader } from "../MachOSliceArtifactReader.js";
|
||||
import {
|
||||
@@ -220,9 +221,15 @@ const materializeSelection = async ({
|
||||
};
|
||||
let localReaderOwner: ArtifactResourceOwner | undefined = readerOwner;
|
||||
let output: SafeOutputTree | undefined;
|
||||
let completed: ArtifactExtractionResult | undefined;
|
||||
const extracted: ExtractedOccurrence[] = [];
|
||||
try {
|
||||
output = await SafeOutputTree.create(input.outputRoot);
|
||||
try {
|
||||
output = await SafeOutputTree.create(input.outputRoot);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof SafeOutputTreeCreationFailure) output = cause.tree;
|
||||
throw cause;
|
||||
}
|
||||
await writeSelectedEntries({ reader, output, byPath, extracted, signal });
|
||||
localReaderOwner = undefined;
|
||||
const closeAttempt = await input.resourceScope.release(readerOwner);
|
||||
@@ -237,14 +244,9 @@ const materializeSelection = async ({
|
||||
extracted.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.relative_path, right.relative_path),
|
||||
);
|
||||
const result = createExtractionResult(
|
||||
input,
|
||||
inventory,
|
||||
selected,
|
||||
extracted,
|
||||
);
|
||||
completed = createExtractionResult(input, inventory, selected, extracted);
|
||||
await output.commit();
|
||||
return result;
|
||||
return completed;
|
||||
} catch (cause: unknown) {
|
||||
const cleanupFailures = await releaseExtractionOwners(
|
||||
input.resourceScope,
|
||||
@@ -257,13 +259,20 @@ const materializeSelection = async ({
|
||||
({ cause: cleanupCause, resource }) =>
|
||||
ArtifactReaderFailure.cleanupObservation(cleanupCause, resource),
|
||||
);
|
||||
throw ArtifactReaderFailure.withCleanup(cause, {
|
||||
reason: observations.map(({ reason }) => reason).join("; "),
|
||||
resources: [
|
||||
...new Set(observations.flatMap(({ resources }) => resources)),
|
||||
],
|
||||
});
|
||||
throw ArtifactReaderFailure.withCleanup(
|
||||
cause,
|
||||
{
|
||||
reason: observations.map(({ reason }) => reason).join("; "),
|
||||
resources: [
|
||||
...new Set(observations.flatMap(({ resources }) => resources)),
|
||||
],
|
||||
},
|
||||
output?.published === true && completed !== undefined
|
||||
? { kind: "artifact-extraction", extraction: completed }
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
if (output?.published === true && completed !== undefined) return completed;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,7 +94,12 @@ import type { FileOffsetPartialObservation } from "./native/fileOffsetPartialObs
|
||||
import type { NativeCallPartialObservation } from "./native/nativeCallPartialObservation.js";
|
||||
import type { ElectronActivePartialObservation } from "./javascript/electronActiveObservation.js";
|
||||
import type { JavaScriptRuntimeObservation } from "./javascript/javascriptRuntimeObservation.js";
|
||||
import type { ArtifactInventoryPartialObservation } from "./artifactPartialObservation.js";
|
||||
import type {
|
||||
ArtifactInventoryPartialObservation,
|
||||
ArtifactExtractionPartialObservation,
|
||||
} from "./artifactPartialObservation.js";
|
||||
import type { WebScriptExportPartialObservation } from "./webScriptExport.js";
|
||||
import type { JavaScriptRecoveryPartialObservation } from "./javascript/javascriptRecovery.js";
|
||||
import type { BrowserScenarioPartialObservation } from "./browserScenarioCapture.js";
|
||||
import type { Evidence } from "./evidence.js";
|
||||
import type { AndroidPartialObservation } from "./android/androidPartialObservation.js";
|
||||
@@ -114,6 +119,9 @@ export type AnalysisPartialObservation =
|
||||
| BrowserScenarioPartialObservation
|
||||
| JavaScriptRuntimeObservation
|
||||
| ArtifactInventoryPartialObservation
|
||||
| ArtifactExtractionPartialObservation
|
||||
| WebScriptExportPartialObservation
|
||||
| JavaScriptRecoveryPartialObservation
|
||||
| Evidence
|
||||
| AndroidPartialObservation
|
||||
| FirmwarePartialObservation
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { ArtifactInventorySnapshot } from "./artifactInventorySnapshot.js";
|
||||
import type { ArtifactExtractionResult } from "./artifactGraph.js";
|
||||
|
||||
/** A complete inventory retained when releasing its provider resources fails. */
|
||||
export interface ArtifactInventoryPartialObservation {
|
||||
readonly kind: "artifact-inventory";
|
||||
readonly inventory: ArtifactInventorySnapshot;
|
||||
}
|
||||
|
||||
/** Durable extracted files retained when their descriptor cleanup is unresolved. */
|
||||
export interface ArtifactExtractionPartialObservation {
|
||||
readonly kind: "artifact-extraction";
|
||||
readonly extraction: ArtifactExtractionResult;
|
||||
}
|
||||
@@ -106,3 +106,9 @@ export type JavaScriptRecoveryInput = z.output<
|
||||
export type JavaScriptRecoveryResult = z.output<
|
||||
typeof javascriptRecoveryResultSchema
|
||||
>;
|
||||
|
||||
/** Completed publication retained when cleanup fails after durable commit. */
|
||||
export interface JavaScriptRecoveryPartialObservation {
|
||||
readonly kind: "javascript-recovery";
|
||||
readonly result: JavaScriptRecoveryResult;
|
||||
}
|
||||
@@ -104,6 +104,12 @@ export type WebScriptExportResult = z.output<
|
||||
typeof webScriptExportResultSchema
|
||||
>;
|
||||
|
||||
/** Completed publication retained when cleanup fails after durable commit. */
|
||||
export interface WebScriptExportPartialObservation {
|
||||
readonly kind: "web-script-export";
|
||||
readonly result: WebScriptExportResult;
|
||||
}
|
||||
|
||||
/** Adapter input to the provider-neutral path planner and publisher. */
|
||||
export interface CapturedWebScript {
|
||||
readonly source: ExportedWebScript["source"];
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
access,
|
||||
chmod,
|
||||
mkdir,
|
||||
open,
|
||||
readFile,
|
||||
readdir,
|
||||
readlink,
|
||||
@@ -14,11 +15,12 @@ import {
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
|
||||
import { SafeOutputTreeCreationFailure } from "../../../src/artifacts/SafeOutputTreeCreationFailure.js";
|
||||
import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js";
|
||||
|
||||
describe("safe artifact output cleanup", () => {
|
||||
@@ -48,6 +50,109 @@ describe("safe artifact output cleanup", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("safe artifact output file identity admission", () => {
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"retains an O_EXCL file handle until its identity can be retried",
|
||||
async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-admit-");
|
||||
const output = join(parent, "published");
|
||||
const destination = join(output, "payload.txt");
|
||||
const displaced = join(parent, "owned-payload.txt");
|
||||
const outside = join(parent, "outside.txt");
|
||||
await writeFile(outside, "outside bytes");
|
||||
const prototypeHandle = await open(join(parent, "handle"), "w");
|
||||
const handlePrototype = Object.getPrototypeOf(prototypeHandle) as Pick<
|
||||
typeof prototypeHandle,
|
||||
"stat"
|
||||
>;
|
||||
await prototypeHandle.close();
|
||||
await rm(join(parent, "handle"));
|
||||
const tree = await SafeOutputTree.create(output);
|
||||
const originalStat = handlePrototype.stat;
|
||||
const stat = vi.spyOn(handlePrototype, "stat");
|
||||
stat
|
||||
.mockImplementationOnce(function (
|
||||
this: typeof prototypeHandle,
|
||||
...args
|
||||
) {
|
||||
return originalStat.apply(this, args);
|
||||
})
|
||||
.mockRejectedValueOnce(new Error("injected file identity failure"));
|
||||
|
||||
await expect(
|
||||
tree.write("payload.txt", Readable.from(Buffer.from("owned")), {
|
||||
sha256: createHash("sha256").update("owned").digest("hex"),
|
||||
bytes: Buffer.byteLength("owned"),
|
||||
}),
|
||||
).rejects.toThrow("injected file identity failure");
|
||||
stat.mockRestore();
|
||||
await rename(destination, displaced);
|
||||
await symlink(outside, destination, "file");
|
||||
|
||||
const failure = await tree.rollback().catch((cause: unknown) => cause);
|
||||
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
expect(await readlink(destination)).toBe(outside);
|
||||
expect(await readFile(outside, "utf8")).toBe("outside bytes");
|
||||
|
||||
await rm(destination);
|
||||
await rename(displaced, destination);
|
||||
expect(await tree.rollback()).toEqual({
|
||||
status: "complete",
|
||||
residualPaths: [],
|
||||
});
|
||||
expect(await readdir(parent)).toEqual(["outside.txt"]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("safe artifact output setup ownership", () => {
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"returns its exact tree owner when setup fails after root identity capture",
|
||||
async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-setup-");
|
||||
const output = join(parent, "published");
|
||||
const blocker = join(output, "unowned.txt");
|
||||
const prototypeHandle = await open(join(parent, "handle"), "w");
|
||||
const handlePrototype = Object.getPrototypeOf(prototypeHandle) as Pick<
|
||||
typeof prototypeHandle,
|
||||
"chmod"
|
||||
>;
|
||||
await prototypeHandle.close();
|
||||
await rm(join(parent, "handle"));
|
||||
const chmodSpy = vi
|
||||
.spyOn(handlePrototype, "chmod")
|
||||
.mockImplementationOnce(async function (this: typeof prototypeHandle) {
|
||||
await writeFile(blocker, "leave until cleanup retry");
|
||||
throw new Error("injected setup failure");
|
||||
});
|
||||
|
||||
const failure = await SafeOutputTree.create(output).catch(
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
chmodSpy.mockRestore();
|
||||
expect(failure).toBeInstanceOf(SafeOutputTreeCreationFailure);
|
||||
if (!(failure instanceof SafeOutputTreeCreationFailure))
|
||||
throw new Error("expected setup failure to retain its tree owner");
|
||||
expect(failure.cause).toMatchObject({
|
||||
message: "injected setup failure",
|
||||
});
|
||||
expect(failure.tree.outputRoot).toBe(output);
|
||||
|
||||
const cleanupFailure = await failure.tree
|
||||
.rollback()
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(cleanupFailure).toBeInstanceOf(ArtifactReaderFailure);
|
||||
expect(await readFile(blocker, "utf8")).toBe("leave until cleanup retry");
|
||||
await rm(blocker);
|
||||
expect(await failure.tree.rollback()).toEqual({
|
||||
status: "complete",
|
||||
residualPaths: [],
|
||||
});
|
||||
expect(await readdir(parent)).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("safe artifact output tree identity", () => {
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"refuses a replaced root and retries cleanup when the owned root is restored",
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
|
||||
const injectedFilesystemFailures = vi.hoisted(() => ({
|
||||
closeNext: false,
|
||||
blockCloseRetries: false,
|
||||
chmodNext: false,
|
||||
rootIdentityPath: undefined as string | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const filesystem = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...filesystem,
|
||||
lstat: async (...args: Parameters<typeof filesystem.lstat>) => {
|
||||
if (args[0] === injectedFilesystemFailures.rootIdentityPath) {
|
||||
injectedFilesystemFailures.rootIdentityPath = undefined;
|
||||
throw new Error("injected root identity failure");
|
||||
}
|
||||
return filesystem.lstat(...args);
|
||||
},
|
||||
open: async (...args: Parameters<typeof filesystem.open>) => {
|
||||
const handle = await filesystem.open(...args);
|
||||
if (injectedFilesystemFailures.chmodNext) {
|
||||
injectedFilesystemFailures.chmodNext = false;
|
||||
handle.chmod = async () => {
|
||||
throw new Error("primary setup chmod failure");
|
||||
};
|
||||
}
|
||||
if (injectedFilesystemFailures.closeNext) {
|
||||
injectedFilesystemFailures.closeNext = false;
|
||||
const close = handle.close.bind(handle);
|
||||
let attempted = false;
|
||||
handle.close = async () => {
|
||||
if (!attempted || injectedFilesystemFailures.blockCloseRetries) {
|
||||
attempted = true;
|
||||
throw new Error("injected close failure");
|
||||
}
|
||||
await close();
|
||||
};
|
||||
}
|
||||
return handle;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
|
||||
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
|
||||
import { SafeOutputTreeCreationFailure } from "../../../src/artifacts/SafeOutputTreeCreationFailure.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
describe("safe artifact output close failures", () => {
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"retains a failed commit handle and does not remove published output",
|
||||
async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-commit-");
|
||||
const output = join(parent, "published");
|
||||
const tree = await SafeOutputTree.create(output);
|
||||
const bytes = Buffer.from("durable output");
|
||||
await tree.write("file.txt", Readable.from(bytes), {
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.byteLength,
|
||||
});
|
||||
injectedFilesystemFailures.closeNext = true;
|
||||
|
||||
await expect(tree.commit()).rejects.toMatchObject({
|
||||
cleanup: { resources: [output] },
|
||||
});
|
||||
expect(await readFile(join(output, "file.txt"), "utf8")).toBe(
|
||||
"durable output",
|
||||
);
|
||||
expect(await tree.rollback()).toEqual({ status: "not-required" });
|
||||
expect(await readFile(join(output, "file.txt"), "utf8")).toBe(
|
||||
"durable output",
|
||||
);
|
||||
await rm(parent, { recursive: true, force: true });
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"preserves setup failure when closing the staging handle also fails",
|
||||
async () => {
|
||||
const parent = await createTestTempDirectory(
|
||||
"rea-safe-output-setup-close-",
|
||||
);
|
||||
const output = join(parent, "published");
|
||||
injectedFilesystemFailures.chmodNext = true;
|
||||
injectedFilesystemFailures.closeNext = true;
|
||||
const failure = await SafeOutputTree.create(output).catch(
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
expect(failure).toMatchObject({
|
||||
name: "SafeOutputTreeCreationFailure",
|
||||
message: "primary setup chmod failure",
|
||||
cleanup: { reason: "injected close failure", resources: [output] },
|
||||
});
|
||||
if (!(failure instanceof SafeOutputTreeCreationFailure))
|
||||
throw new Error("Expected setup failure to retain the tree owner");
|
||||
expect(await failure.tree.rollback()).toMatchObject({
|
||||
status: "complete",
|
||||
});
|
||||
await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
it("retains an unknown root owner without inferring identity on retry", async () => {
|
||||
const parent = await createTestTempDirectory("rea-safe-output-unknown-");
|
||||
const output = join(parent, "published");
|
||||
injectedFilesystemFailures.rootIdentityPath = output;
|
||||
|
||||
const failure = await SafeOutputTree.create(output).catch(
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
expect(failure).toMatchObject({
|
||||
name: "SafeOutputTreeCreationFailure",
|
||||
cause: { message: "injected root identity failure" },
|
||||
});
|
||||
if (!(failure instanceof SafeOutputTreeCreationFailure))
|
||||
throw new Error("expected setup error to retain its tree owner");
|
||||
await access(output);
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const cleanup = await failure.tree
|
||||
.rollback()
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(cleanup).toMatchObject({
|
||||
cleanup: { resources: [output] },
|
||||
message: expect.stringContaining("identity is unknown"),
|
||||
});
|
||||
await access(output);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32").each([false, true])(
|
||||
"preserves extracted output when commit close fails (persistent: %s)",
|
||||
async (persistent) => {
|
||||
const root = await createTestTempDirectory("rea-extraction-commit-close-");
|
||||
const source = join(root, "source");
|
||||
const output = join(root, "published");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "payload.txt"), "committed bytes");
|
||||
const client = new ArtifactProvider(process.env).createClient({
|
||||
path: source,
|
||||
sourcePath: source,
|
||||
sha256: "0".repeat(64),
|
||||
kind: "archive",
|
||||
format: "asar",
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
injectedFilesystemFailures.blockCloseRetries = false;
|
||||
await client.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
const commit = SafeOutputTree.prototype.commit;
|
||||
vi.spyOn(SafeOutputTree.prototype, "commit").mockImplementation(
|
||||
async function (this: SafeOutputTree) {
|
||||
injectedFilesystemFailures.closeNext = true;
|
||||
injectedFilesystemFailures.blockCloseRetries = persistent;
|
||||
await commit.call(this);
|
||||
},
|
||||
);
|
||||
|
||||
const result = await client.execute("extract_artifact", {
|
||||
output_root: output,
|
||||
});
|
||||
const extraction = {
|
||||
output_root: output,
|
||||
artifacts: [expect.objectContaining({ relative_path: "payload.txt" })],
|
||||
};
|
||||
expect(result).toMatchObject(
|
||||
persistent
|
||||
? {
|
||||
ok: false,
|
||||
error: {
|
||||
cleanupIncomplete: true,
|
||||
partialObservation: { kind: "artifact-extraction", extraction },
|
||||
},
|
||||
}
|
||||
: { ok: true, value: { result: extraction } },
|
||||
);
|
||||
expect(await readFile(join(output, "payload.txt"), "utf8")).toBe(
|
||||
"committed bytes",
|
||||
);
|
||||
injectedFilesystemFailures.blockCloseRetries = false;
|
||||
expect(await client.close()).toMatchObject({ ok: true });
|
||||
expect(await readFile(join(output, "payload.txt"), "utf8")).toBe(
|
||||
"committed bytes",
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { access, mkdir, open, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createPackage } from "@electron/asar";
|
||||
@@ -167,6 +167,47 @@ it("retains a failed rollback owner and preserves the materialization failure",
|
||||
await expect(access(output)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("retains a tree whose setup failed after root identity was captured", async () => {
|
||||
const { root, archive } = await createArchive();
|
||||
const output = join(root, "output");
|
||||
const blocker = join(output, "unowned.txt");
|
||||
const client = createClient(archive);
|
||||
const prototypeHandle = await open(join(root, "prototype-handle"), "w");
|
||||
const handlePrototype = Object.getPrototypeOf(prototypeHandle) as Pick<
|
||||
typeof prototypeHandle,
|
||||
"chmod"
|
||||
>;
|
||||
await prototypeHandle.close();
|
||||
await rm(join(root, "prototype-handle"));
|
||||
onTestFinished(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(blocker, { force: true });
|
||||
await client.close();
|
||||
});
|
||||
vi.spyOn(handlePrototype, "chmod").mockImplementationOnce(async function (
|
||||
this: typeof prototypeHandle,
|
||||
) {
|
||||
await writeFile(blocker, "keep until owner retry");
|
||||
throw new Error("injected output setup failure");
|
||||
});
|
||||
|
||||
const extraction = await client.execute("extract_artifact", {
|
||||
output_root: output,
|
||||
});
|
||||
expect(extraction).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ArtifactOperationError",
|
||||
detail: expect.stringContaining("injected output setup failure"),
|
||||
cleanup: { resources: [output] },
|
||||
},
|
||||
});
|
||||
expect(await readFile(blocker, "utf8")).toBe("keep until owner retry");
|
||||
await rm(blocker);
|
||||
expect(await client.close()).toMatchObject({ ok: true });
|
||||
await expect(access(output)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("waits for admitted extraction before close and rejects later work", async () => {
|
||||
const { root, archive } = await createArchive();
|
||||
const output = join(root, "output");
|
||||
|
||||
Reference in new issue
Block a user