fix(javascript): preserve artifact failures through cleanup
This commit is contained in:
1 parent
8b7ee48a46
commit
ca3fd7baf9
4 files changed
+211
-30
No files matched your search
@@ -146,14 +146,10 @@ export const reconstructJavaScriptArtifact = async (
|
|||||||
const earlyCleanup = await scope.release(readerOwner);
|
const earlyCleanup = await scope.release(readerOwner);
|
||||||
readerOpen = false;
|
readerOpen = false;
|
||||||
if (earlyCleanup.kind === "failed")
|
if (earlyCleanup.kind === "failed")
|
||||||
throw ArtifactReaderFailure.withCleanup(
|
throw scope.cleanupFailure({ kind: "completed" }, [earlyCleanup], {
|
||||||
earlyCleanup.cause,
|
kind: "artifact-inventory",
|
||||||
ArtifactReaderFailure.cleanupObservation(
|
inventory: snapshot,
|
||||||
earlyCleanup.cause,
|
});
|
||||||
readerOwner.resource,
|
|
||||||
),
|
|
||||||
{ kind: "artifact-inventory", inventory: snapshot },
|
|
||||||
);
|
|
||||||
await reportPhase(
|
await reportPhase(
|
||||||
"parse_javascript_sources",
|
"parse_javascript_sources",
|
||||||
`Processing ${String(files.files.length - (files.source_selection?.unselected_files ?? 0))} selected application files`,
|
`Processing ${String(files.files.length - (files.source_selection?.unselected_files ?? 0))} selected application files`,
|
||||||
@@ -410,19 +406,13 @@ export const reconstructJavaScriptArtifact = async (
|
|||||||
if (readerOpen) {
|
if (readerOpen) {
|
||||||
const cleanupAttempt = await scope.release(readerOwner);
|
const cleanupAttempt = await scope.release(readerOwner);
|
||||||
if (cleanupAttempt.kind === "failed") {
|
if (cleanupAttempt.kind === "failed") {
|
||||||
const cleanup = ArtifactReaderFailure.cleanupObservation(
|
|
||||||
cleanupAttempt.cause,
|
|
||||||
readerOwner.resource,
|
|
||||||
);
|
|
||||||
const partialObservation: ArtifactInventoryPartialObservation = {
|
const partialObservation: ArtifactInventoryPartialObservation = {
|
||||||
kind: "artifact-inventory",
|
kind: "artifact-inventory",
|
||||||
inventory: snapshot,
|
inventory: snapshot,
|
||||||
};
|
};
|
||||||
throw ArtifactReaderFailure.withCleanup(
|
throw scope.cleanupFailure(
|
||||||
reconstructionOutcome.kind === "failed"
|
reconstructionOutcome,
|
||||||
? reconstructionOutcome.cause
|
[cleanupAttempt],
|
||||||
: cleanupAttempt.cause,
|
|
||||||
cleanup,
|
|
||||||
partialObservation,
|
partialObservation,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,25 @@ export const hashReadable = async (
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
integrityAlgorithm?: "sha256" | "md5",
|
integrityAlgorithm?: "sha256" | "md5",
|
||||||
asarHeaderAdmission?: AsarHeaderAdmission,
|
asarHeaderAdmission?: AsarHeaderAdmission,
|
||||||
|
): Promise<HashResult> => {
|
||||||
|
const completed = await hashReadableToCompletion(
|
||||||
|
stream,
|
||||||
|
signal,
|
||||||
|
integrityAlgorithm,
|
||||||
|
asarHeaderAdmission,
|
||||||
|
);
|
||||||
|
abortIfNeeded(signal);
|
||||||
|
return completed;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Keep a fully delivered digest so its owner can validate before late cancellation.
|
||||||
|
* Source stability remains the stream or caller's responsibility.
|
||||||
|
*/
|
||||||
|
export const hashReadableToCompletion = async (
|
||||||
|
stream: Readable,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
integrityAlgorithm?: "sha256" | "md5",
|
||||||
|
asarHeaderAdmission?: AsarHeaderAdmission,
|
||||||
): Promise<HashResult> => {
|
): Promise<HashResult> => {
|
||||||
const hash = createHash("sha256");
|
const hash = createHash("sha256");
|
||||||
const md5 = integrityAlgorithm === "md5" ? createHash("md5") : undefined;
|
const md5 = integrityAlgorithm === "md5" ? createHash("md5") : undefined;
|
||||||
@@ -45,7 +64,6 @@ export const hashReadable = async (
|
|||||||
md5?.update(chunk);
|
md5?.update(chunk);
|
||||||
probe.write(chunk);
|
probe.write(chunk);
|
||||||
}
|
}
|
||||||
abortIfNeeded(signal);
|
|
||||||
return {
|
return {
|
||||||
sha256: hash.digest("hex"),
|
sha256: hash.digest("hex"),
|
||||||
...(md5 === undefined ? {} : { md5: md5.digest("hex") }),
|
...(md5 === undefined ? {} : { md5: md5.digest("hex") }),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
type ArtifactReader,
|
type ArtifactReader,
|
||||||
} from "../ArtifactReader.js";
|
} from "../ArtifactReader.js";
|
||||||
import { streamChunkToBuffer } from "../StreamBytes.js";
|
import { streamChunkToBuffer } from "../StreamBytes.js";
|
||||||
import { hashReadable } from "../ArtifactHash.js";
|
import { hashReadableToCompletion } from "../ArtifactHash.js";
|
||||||
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
|
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
|
||||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||||
@@ -400,17 +400,13 @@ const verifyEntryBytes = async (
|
|||||||
expected: Pick<ExpectedFile, "path" | "sha256" | "bytes">,
|
expected: Pick<ExpectedFile, "path" | "sha256" | "bytes">,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
const digest = await hashReadableToCompletion(stream, signal);
|
||||||
const digest = await hashReadable(stream, signal);
|
if (digest.sha256 !== expected.sha256 || digest.bytes !== expected.bytes)
|
||||||
if (digest.sha256 !== expected.sha256 || digest.bytes !== expected.bytes)
|
throw new ArtifactReaderFailure(
|
||||||
throw new ArtifactReaderFailure(
|
"integrity",
|
||||||
"integrity",
|
`Artifact entry changed after inventory: ${expected.path}`,
|
||||||
`Artifact entry changed after inventory: ${expected.path}`,
|
);
|
||||||
);
|
abortIfNeeded(signal);
|
||||||
} catch (cause: unknown) {
|
|
||||||
abortIfNeeded(signal);
|
|
||||||
throw cause;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const expectedInventory = (
|
const expectedInventory = (
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import type { FileHandle } from "node:fs/promises";
|
||||||
|
import { open, realpath, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { expect, it, onTestFinished, vi } from "vitest";
|
||||||
|
import { analyzeJavaScriptApplication } from "../../src/application/javascript/JavaScriptApplicationService.js";
|
||||||
|
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
|
||||||
|
import { OwnedFileHandle } from "../../src/filesystem/OwnedFileHandle.js";
|
||||||
|
import { DirectoryArtifactReader } from "../../src/artifacts/DirectoryArtifactReader.js";
|
||||||
|
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||||
|
|
||||||
|
const injected = vi.hoisted(() => ({
|
||||||
|
opened: undefined as ((path: unknown, file: FileHandle) => void) | undefined,
|
||||||
|
}));
|
||||||
|
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
open: async (...args: Parameters<typeof actual.open>) => {
|
||||||
|
const file = await actual.open(...args);
|
||||||
|
injected.opened?.(args[0], file);
|
||||||
|
return file;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const causesOf = (error: unknown): unknown[] => {
|
||||||
|
if (!(error instanceof Error)) return [error];
|
||||||
|
return [
|
||||||
|
error,
|
||||||
|
...causesOf(error.cause),
|
||||||
|
...(error instanceof AggregateError ? error.errors.flatMap(causesOf) : []),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each(
|
||||||
|
["addon.node", "unselected.js"].flatMap((filename) =>
|
||||||
|
[
|
||||||
|
"physical failure",
|
||||||
|
"failed cleanup",
|
||||||
|
"completed cleanup",
|
||||||
|
"abort only",
|
||||||
|
"changed bytes",
|
||||||
|
"completed mismatch cancellation",
|
||||||
|
"completed matching cancellation",
|
||||||
|
].map((outcome) => ({ filename, outcome })),
|
||||||
|
),
|
||||||
|
)(
|
||||||
|
"preserves $filename verifier provenance through $outcome",
|
||||||
|
async ({ filename, outcome }) => {
|
||||||
|
const root = await realpath(
|
||||||
|
await createTestTempDirectory("rea-js-verifier-failure-"),
|
||||||
|
);
|
||||||
|
const path = join(root, filename);
|
||||||
|
await writeFile(path, "original payload");
|
||||||
|
await writeFile(join(root, "main.js"), "module.exports = 42;\n");
|
||||||
|
const scope = new ArtifactResourceScope();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const reason = { source: "artifact source acquisition" };
|
||||||
|
const physical = Object.assign(
|
||||||
|
new Error("physical acquisition read denied"),
|
||||||
|
{ code: "EIO", errno: -5, syscall: "read" },
|
||||||
|
);
|
||||||
|
let acquisition = false;
|
||||||
|
let blocked =
|
||||||
|
outcome === "failed cleanup" || outcome === "completed cleanup";
|
||||||
|
let independentBlocked = false;
|
||||||
|
let descriptor: FileHandle | undefined;
|
||||||
|
const openEntry = DirectoryArtifactReader.prototype.open;
|
||||||
|
vi.spyOn(DirectoryArtifactReader.prototype, "open").mockImplementation(
|
||||||
|
async function (this: DirectoryArtifactReader, entry, signal) {
|
||||||
|
const stream = await openEntry.call(this, entry, signal);
|
||||||
|
if (
|
||||||
|
acquisition &&
|
||||||
|
entry.adapterKey === path &&
|
||||||
|
outcome.startsWith("completed ") &&
|
||||||
|
outcome !== "completed cleanup"
|
||||||
|
)
|
||||||
|
stream.once("end", () => controller.abort(reason));
|
||||||
|
return stream;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
injected.opened = (candidate, file) => {
|
||||||
|
if (!acquisition || String(candidate) !== path) return;
|
||||||
|
descriptor = file;
|
||||||
|
const close = file.close.bind(file);
|
||||||
|
vi.spyOn(file, "close").mockImplementation(async () => {
|
||||||
|
if (blocked) throw new Error("acquisition descriptor close denied");
|
||||||
|
await close();
|
||||||
|
});
|
||||||
|
if (outcome === "abort only") controller.abort(reason);
|
||||||
|
if (outcome === "physical failure" || outcome === "failed cleanup")
|
||||||
|
vi.spyOn(file, "read").mockImplementationOnce(async () => {
|
||||||
|
controller.abort(reason);
|
||||||
|
throw physical;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
onTestFinished(async () => {
|
||||||
|
injected.opened = undefined;
|
||||||
|
blocked = false;
|
||||||
|
independentBlocked = false;
|
||||||
|
await scope.close();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
const result = await analyzeJavaScriptApplication(
|
||||||
|
{ input_path: root, source_paths: ["main.js"] },
|
||||||
|
scope,
|
||||||
|
{
|
||||||
|
signal: controller.signal,
|
||||||
|
progress: {
|
||||||
|
report: async (update) => {
|
||||||
|
if (update.phase !== "read_javascript_artifacts") return;
|
||||||
|
acquisition = true;
|
||||||
|
if (
|
||||||
|
outcome === "changed bytes" ||
|
||||||
|
outcome === "completed mismatch cancellation"
|
||||||
|
)
|
||||||
|
await writeFile(path, "modified payload");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.ok) throw new Error("Expected acquisition failure");
|
||||||
|
if (
|
||||||
|
outcome === "abort only" ||
|
||||||
|
outcome === "completed matching cancellation"
|
||||||
|
)
|
||||||
|
expect(result.error).toMatchObject({
|
||||||
|
reason: "cancelled",
|
||||||
|
cause: reason,
|
||||||
|
});
|
||||||
|
else if (
|
||||||
|
outcome === "changed bytes" ||
|
||||||
|
outcome === "completed mismatch cancellation"
|
||||||
|
)
|
||||||
|
expect(result.error).toMatchObject({ reason: "integrity" });
|
||||||
|
else {
|
||||||
|
expect(result.error).toMatchObject({ reason: "io" });
|
||||||
|
if (outcome !== "completed cleanup")
|
||||||
|
expect(causesOf(result.error)).toContain(physical);
|
||||||
|
}
|
||||||
|
expect(descriptor).toBeDefined();
|
||||||
|
if (blocked) {
|
||||||
|
const independentPath = join(root, "independent-owner");
|
||||||
|
await writeFile(independentPath, "separately retained bytes");
|
||||||
|
const independent = await open(independentPath);
|
||||||
|
const closeIndependent = independent.close.bind(independent);
|
||||||
|
independentBlocked = true;
|
||||||
|
vi.spyOn(independent, "close").mockImplementation(async () => {
|
||||||
|
if (independentBlocked)
|
||||||
|
throw new Error("independent owner close denied");
|
||||||
|
await closeIndependent();
|
||||||
|
});
|
||||||
|
scope.retain({
|
||||||
|
kind: "file-handle",
|
||||||
|
resource: independentPath,
|
||||||
|
handle: new OwnedFileHandle(independent),
|
||||||
|
});
|
||||||
|
expect(result.error.cleanupIncomplete).toBe(true);
|
||||||
|
expect(result.error.partialObservation).toMatchObject({
|
||||||
|
kind: "artifact-inventory",
|
||||||
|
});
|
||||||
|
await expect(scope.close()).rejects.toBeDefined();
|
||||||
|
expect(descriptor!.fd).toBeGreaterThanOrEqual(0);
|
||||||
|
blocked = false;
|
||||||
|
await expect(scope.close()).rejects.toBeDefined();
|
||||||
|
expect(descriptor!.fd).toBe(-1);
|
||||||
|
expect(result.error.cleanupIncomplete).toBe(false);
|
||||||
|
expect(independent.fd).toBeGreaterThanOrEqual(0);
|
||||||
|
independentBlocked = false;
|
||||||
|
await scope.close();
|
||||||
|
expect(independent.fd).toBe(-1);
|
||||||
|
}
|
||||||
|
await scope.close();
|
||||||
|
expect(descriptor!.fd).toBe(-1);
|
||||||
|
expect(result.error.cleanupIncomplete).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in new issue
Block a user