193 lines
6.1 KiB
TypeScript
193 lines
6.1 KiB
TypeScript
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
|
import { afterAll, afterEach, beforeAll, expect, it } from "vitest";
|
|
import { vi } from "vitest";
|
|
|
|
const fault = vi.hoisted(() => ({
|
|
path: "",
|
|
failClose: "never" as "never" | "once" | "always",
|
|
abortAtStat: undefined as
|
|
| { controller: AbortController; reason: unknown }
|
|
| undefined,
|
|
pauseAtStat: undefined as
|
|
| { started: () => void; resume: Promise<void> }
|
|
| undefined,
|
|
}));
|
|
|
|
vi.mock("node:fs/promises", async (importOriginal) => {
|
|
const fs = await importOriginal<typeof import("node:fs/promises")>();
|
|
return {
|
|
...fs,
|
|
open: async (...args: Parameters<typeof fs.open>) => {
|
|
const handle = await fs.open(...args);
|
|
if (args[0] !== fault.path) return handle;
|
|
const close = handle.close.bind(handle);
|
|
const stat = handle.stat.bind(handle);
|
|
handle.stat = (async (...statArgs: Parameters<typeof stat>) => {
|
|
const abort = fault.abortAtStat;
|
|
fault.abortAtStat = undefined;
|
|
abort?.controller.abort(abort.reason);
|
|
const pause = fault.pauseAtStat;
|
|
fault.pauseAtStat = undefined;
|
|
if (pause !== undefined) {
|
|
pause.started();
|
|
await pause.resume;
|
|
}
|
|
return stat(...statArgs);
|
|
}) as typeof handle.stat;
|
|
handle.close = async () => {
|
|
if (fault.failClose !== "never") {
|
|
if (fault.failClose === "once") fault.failClose = "never";
|
|
throw new Error("controlled descriptor close failure");
|
|
}
|
|
return close();
|
|
};
|
|
return handle;
|
|
},
|
|
};
|
|
});
|
|
|
|
import { ApkSignatureProvider } from "../../src/apkSignature/ApkSignatureProvider.js";
|
|
import { createCli } from "../../src/cli.js";
|
|
import { CLI_COMMANDS } from "../../src/cliCommandNames.js";
|
|
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
|
|
|
|
let root = "";
|
|
let path = "";
|
|
const exitCode = process.exitCode;
|
|
const deferred = () => {
|
|
let complete = () => {};
|
|
const promise = new Promise<void>((resolve) => {
|
|
complete = resolve;
|
|
});
|
|
return { promise, resolve: () => complete() };
|
|
};
|
|
beforeAll(async () => {
|
|
root = await mkdtemp(join(tmpdir(), "rea-apk-lifecycle-"));
|
|
path = join(root, "unsigned.apk");
|
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
await writer.add("classes.dex", new TextReader("x"));
|
|
await writeFile(path, await writer.close());
|
|
});
|
|
afterEach(() => {
|
|
fault.path = "";
|
|
fault.failClose = "never";
|
|
fault.abortAtStat = undefined;
|
|
fault.pauseAtStat = undefined;
|
|
process.exitCode = exitCode;
|
|
});
|
|
afterAll(async () => {
|
|
await rm(root, { recursive: true, force: true });
|
|
});
|
|
|
|
it("serializes reads across providers, preserves queued aborts and drains admitted work on close", async () => {
|
|
const first = new ApkSignatureProvider();
|
|
const second = new ApkSignatureProvider();
|
|
const started = deferred();
|
|
const resume = deferred();
|
|
fault.path = path;
|
|
fault.pauseAtStat = {
|
|
started: () => started.resolve(),
|
|
resume: resume.promise,
|
|
};
|
|
const reading = first.inspect({ path });
|
|
await started.promise;
|
|
const queued = second.inspect({ path });
|
|
const controller = new AbortController();
|
|
const reason = new Error("cancel while queued");
|
|
const cancelled = second.inspect({ path }, { signal: controller.signal });
|
|
let closed = false;
|
|
const closing = second.close().then(() => {
|
|
closed = true;
|
|
});
|
|
try {
|
|
controller.abort(reason);
|
|
expect(await cancelled).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisCancelledError", cause: reason },
|
|
});
|
|
expect(closed).toBe(false);
|
|
resume.resolve();
|
|
expect((await reading).ok).toBe(true);
|
|
expect((await queued).ok).toBe(true);
|
|
await closing;
|
|
expect(closed).toBe(true);
|
|
} finally {
|
|
resume.resolve();
|
|
await Promise.all([reading, queued, cancelled, closing]);
|
|
await first.close();
|
|
await second.close();
|
|
}
|
|
});
|
|
|
|
it("keeps the abort reason itself as the cancellation cause", async () => {
|
|
const provider = new ApkSignatureProvider();
|
|
const controller = new AbortController();
|
|
const reason = new Error("caller abort reason");
|
|
fault.path = path;
|
|
fault.abortAtStat = { controller, reason };
|
|
try {
|
|
const result = await provider.inspect(
|
|
{ path },
|
|
{ signal: controller.signal },
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
expect(result.error.cause).toBe(reason);
|
|
} finally {
|
|
await provider.close();
|
|
}
|
|
});
|
|
|
|
it("reports a completed read whose close failed, and a closed scope, as execution failures", async () => {
|
|
const provider = new ApkSignatureProvider();
|
|
fault.path = path;
|
|
fault.failClose = "once";
|
|
const first = await provider.inspect({ path });
|
|
expect(first.ok).toBe(false);
|
|
if (first.ok) return;
|
|
expect(projectAnalysisError(first.error)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
diagnostics: { primary_error: { code: "execution_failure" } },
|
|
},
|
|
});
|
|
expect(first.error.cause).toBeInstanceOf(AggregateError);
|
|
if (!(first.error.cause instanceof AggregateError)) return;
|
|
expect(first.error.cause.errors[1]).toMatchObject({
|
|
cleanupResources: [path],
|
|
});
|
|
|
|
await provider.close();
|
|
expect(first.error.cleanupIncomplete).toBe(false);
|
|
const closed = await provider.inspect({ path });
|
|
expect(closed.ok).toBe(false);
|
|
if (!closed.ok)
|
|
expect(projectAnalysisError(closed.error).code).toBe("execution_failure");
|
|
});
|
|
|
|
it("projects a provider close that still fails through the public CLI", async () => {
|
|
fault.path = path;
|
|
fault.failClose = "always";
|
|
let stdout = "";
|
|
await createCli({}).serve(
|
|
[CLI_COMMANDS.inspectApkSignature, path, "--json"],
|
|
{
|
|
env: {},
|
|
stdout: (text) => {
|
|
stdout += text;
|
|
},
|
|
exit: () => undefined,
|
|
},
|
|
);
|
|
fault.failClose = "never";
|
|
expect(JSON.parse(stdout)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: { cleanup: "incomplete", resources: [path] },
|
|
});
|
|
expect(process.exitCode).toBe(1);
|
|
});
|