test: strengthen archive and resource cleanup assertions

Replace duplicate path-helper checks with real ZIP inventory coverage and replace global memory sampling with bounded-chunk assertions while retaining 64 MiB digest coverage. Await artifact client cleanup, preserve Hopper lease ownership during teardown, and expose typed application failures.
This commit is contained in:
morluto
2026-10-12 04:53:27 +08:00
parent 804c287e44
commit 5ba1642a7d
8 changed files with 137 additions and 129 deletions
@@ -73,11 +73,11 @@ describe("JebAnalysisService admission", () => {
const requests: JebRequest[] = [];
const service = new JebAnalysisService(fakePort(requests));
const result = await service.execute("list_jeb_units", {});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(requests[0]?.operation).toBe("list_jeb_units");
if (!result.ok) throw result.error;
expect(requests).toHaveLength(1);
const request = requests[0];
if (request?.operation !== "list_jeb_units") return;
if (request?.operation !== "list_jeb_units")
throw new Error("Expected the list_jeb_units provider request");
expect(request.input.index).toBe(0);
expect(request.input.count).toBe(100);
expect(result.value.normalized_result).toEqual({ observed: true });
@@ -129,6 +129,7 @@ describe("entry-order validation and reader controls", () => {
["pkg/data.txt", "pkg/data.txt/"],
["pkg/data.txt/", "pkg/data.txt"],
["pkg/data.txt", "pkg"],
["pkg", "pkg/data.txt"],
["../escape.txt"],
])("keeps invalid/colliding paths rejected for %j", async (...entries) => {
const root = await createTestTempDirectory("rea-entry-order-invalid-");
@@ -158,6 +159,27 @@ describe("entry-order validation and reader controls", () => {
}
});
it("retains case-distinct file prefixes without inventing a directory", async () => {
const root = await createTestTempDirectory("rea-entry-order-case-prefix-");
const archive = join(root, "case-prefix.zip");
for (const entries of [
["Foo", "foo/bar"],
["foo/bar", "Foo"],
]) {
await writeOrderedZip(archive, entries);
const inventory = await inventoryArtifact(archive);
expect(artifactParentPaths(inventory)).toEqual({
".": null,
Foo: ".",
"foo/bar": ".",
});
for (const path of entries)
expect(artifactOccurrenceAt(inventory, path).hash_status).toBe(
"verified",
);
}
});
it("retains high-cardinality case-folded directories and their descendants", async () => {
const root = await createTestTempDirectory(
"rea-entry-order-case-capacity-",
@@ -8,8 +8,8 @@ import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
import { ZipArtifactReader } from "../../../src/artifacts/ZipArtifactReader.js";
import { withArtifactClient } from "../../support/artifactClientScope.js";
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
it("bounds a requested metadata read without rejecting the underlying valid ZIP", async () => {
@@ -139,15 +139,12 @@ it("retains duplicate ZIP members without replacing their source identities", as
const target = await parseBinaryTarget(path);
if (!target.ok) throw target.error;
const client = new ArtifactProvider(process.env).createClient(target.value);
try {
await withArtifactClient(target.value, async (client) => {
expect(await client.execute("inventory_artifact", {})).toMatchObject({
ok: false,
error: { _tag: "ArtifactOperationError", reason: "path" },
});
} finally {
await client.close();
}
});
});
it("can enumerate case-distinct ZIP members again without losing entry identity", async () => {
@@ -200,9 +197,9 @@ it("rejects overlapping ZIP member data ranges through artifact inventory", asyn
const target = await parseBinaryTarget(path);
if (!target.ok) throw target.error;
const result = await new ArtifactProvider(process.env)
.createClient(target.value)
.execute("inventory_artifact", {});
const result = await withArtifactClient(target.value, (client) =>
client.execute("inventory_artifact", {}),
);
expect(result).toMatchObject({
ok: false,
@@ -221,9 +218,10 @@ it("preserves encrypted-member and corrupt-CRC observations", async () => {
const encryptedTarget = await parseBinaryTarget(encryptedPath);
if (!encryptedTarget.ok) throw encryptedTarget.error;
const encryptedResult = await new ArtifactProvider(process.env)
.createClient(encryptedTarget.value)
.execute("inventory_artifact", {});
const encryptedResult = await withArtifactClient(
encryptedTarget.value,
(client) => client.execute("inventory_artifact", {}),
);
if (!encryptedResult.ok) throw encryptedResult.error;
const encryptedInventory = artifactInventoryResultSchema.parse(
encryptedResult.value.result,
@@ -237,11 +235,10 @@ it("preserves encrypted-member and corrupt-CRC observations", async () => {
);
// REA does not decrypt entries, so complete extraction is unsupported.
const extractionRoot = join(root, "encrypted-output");
expect(
await new ArtifactProvider(process.env)
.createClient(encryptedTarget.value)
.execute("extract_artifact", { output_root: extractionRoot }),
).toMatchObject({
const extraction = await withArtifactClient(encryptedTarget.value, (client) =>
client.execute("extract_artifact", { output_root: extractionRoot }),
);
expect(extraction).toMatchObject({
ok: false,
error: {
_tag: "AnalysisUnsupportedTargetError",
@@ -272,9 +269,10 @@ it("preserves encrypted-member and corrupt-CRC observations", async () => {
const corruptTarget = await parseBinaryTarget(corruptPath);
if (!corruptTarget.ok) throw corruptTarget.error;
const corruptResult = await new ArtifactProvider(process.env)
.createClient(corruptTarget.value)
.execute("inventory_artifact", {});
const corruptResult = await withArtifactClient(
corruptTarget.value,
(client) => client.execute("inventory_artifact", {}),
);
expect(corruptResult).toMatchObject({
ok: false,
error: { _tag: "ArtifactOperationError", reason: "io" },
@@ -288,9 +286,9 @@ it("rejects a truncated ZIP through artifact inventory", async () => {
const target = await parseBinaryTarget(path);
if (!target.ok) throw target.error;
const result = await new ArtifactProvider(process.env)
.createClient(target.value)
.execute("inventory_artifact", {});
const result = await withArtifactClient(target.value, (client) =>
client.execute("inventory_artifact", {}),
);
expect(result).toMatchObject({
ok: false,
@@ -24,6 +24,7 @@ import { hashReadable } from "../../../src/artifacts/ArtifactHash.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const memberBytes = 64 * 1024 * 1024;
const maximumObservedChunkBytes = 1024 * 1024;
const expectedSha256 = (): string => {
const hash = createHash("sha256");
const zeros = Buffer.alloc(64 * 1024);
@@ -727,7 +728,7 @@ describe("ASAR entry streaming", () => {
["packed", {}],
["unpacked", { unpack: "large.bin" }],
] as const)(
"opens a %s member without allocating its full contents",
"streams a %s member in bounded chunks with its complete digest",
async (_kind, options) => {
const root = await createTestTempDirectory("rea-asar-streaming-");
const source = join(root, "source");
@@ -745,13 +746,21 @@ describe("ASAR entry streaming", () => {
const entry = entries.find(({ path }) => path === "large.bin");
if (entry === undefined) throw new Error("Expected large ASAR member");
const before = process.memoryUsage().arrayBuffers;
const stream = await reader.open(entry);
const afterOpen = process.memoryUsage().arrayBuffers;
const observed = await hashReadable(stream);
const allocation = afterOpen - before;
let largestChunkBytes = 0;
const measuredStream = Readable.from(
(async function* () {
for await (const chunk of stream) {
largestChunkBytes = Math.max(largestChunkBytes, chunk.byteLength);
yield chunk;
}
})(),
);
const observed = await hashReadable(measuredStream);
expect(allocation).toBeLessThan(memberBytes / 2);
expect(largestChunkBytes).toBeLessThanOrEqual(
maximumObservedChunkBytes,
);
expect(observed).toMatchObject({
sha256: expectedSha256(),
bytes: memberBytes,
@@ -1,4 +1,4 @@
import { access, mkdtemp, rm } from "node:fs/promises";
import { access, mkdtemp, rmdir } from "node:fs/promises";
import { expect, it, onTestFinished } from "vitest";
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
@@ -9,10 +9,7 @@ import type {
BridgeSession,
} from "../../../src/hopper/BridgeLauncher.js";
import { HopperClient } from "../../../src/hopper/HopperClient.js";
import {
acquireHopperTargetLease,
type HopperTargetLease,
} from "../../../src/hopper/HopperTargetLease.js";
import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js";
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
import { hopperFixturePath } from "../providers/hopper/hopperClient.fixture.js";
@@ -21,11 +18,22 @@ import {
createTestBinarySession,
} from "../../fixtures/binarySession.js";
const createShortLeaseDirectory = async (): Promise<string> => {
// A redirected TMPDIR can exceed macOS's Unix socket path limit once
// Hopper appends its lease key. Remove only an empty directory after the
// owning client has closed; recursive removal could hide a retained lease.
const directory = await mkdtemp("/tmp/rea-hl-");
onTestFinished(async () => {
await rmdir(directory);
await expect(access(directory)).rejects.toMatchObject({ code: "ENOENT" });
});
return directory;
};
class RetryingHopperLauncher implements BridgeLauncher {
cleanupAllowed = false;
cleanupAttempts = 0;
directory: string | undefined;
lease: HopperTargetLease | undefined;
constructor(
readonly leaseDirectory: string,
@@ -49,7 +57,6 @@ class RetryingHopperLauncher implements BridgeLauncher {
return err(
new HopperStartError({ userMessage: "fixture lease is held" }),
);
this.lease = lease.lease;
this.directory = session.directory;
const started = await spawnOwnedProviderProcess({
command: process.execPath,
@@ -83,8 +90,7 @@ class RetryingHopperLauncher implements BridgeLauncher {
}
it("retains Hopper process handles, private files, and lease until an actual session cleanup retry succeeds", async () => {
const leaseDirectory = await mkdtemp("/tmp/rea-hl-");
onTestFinished(() => rm(leaseDirectory, { recursive: true, force: true }));
const leaseDirectory = await createShortLeaseDirectory();
const [first, second] = await createBinarySessionTargets();
const launcher = new RetryingHopperLauncher(leaseDirectory, first);
const hopper = new HopperClient({ launcher, startupTimeoutMs: 10_000 });
@@ -109,9 +115,10 @@ it("retains Hopper process handles, private files, and lease until an actual ses
});
onTestFinished(async () => {
launcher.cleanupAllowed = true;
await session.close();
await hopper.close();
await launcher.lease?.release();
const sessionClosed = await session.close();
if (!sessionClosed.ok) throw sessionClosed.error;
const hopperClosed = await hopper.close();
if (!hopperClosed.ok) throw hopperClosed.error;
});
expect(await session.open(first)).toMatchObject({ ok: true });
expect(await session.close()).toMatchObject({
@@ -144,15 +151,14 @@ it("retains Hopper process handles, private files, and lease until an actual ses
});
it("reports a direct Hopper close failure and retries the retained owned process", async () => {
const leaseDirectory = await mkdtemp("/tmp/rea-hl-");
onTestFinished(() => rm(leaseDirectory, { recursive: true, force: true }));
const leaseDirectory = await createShortLeaseDirectory();
const [target] = await createBinarySessionTargets();
const launcher = new RetryingHopperLauncher(leaseDirectory, target, true);
const hopper = new HopperClient({ launcher, startupTimeoutMs: 10_000 });
onTestFinished(async () => {
launcher.cleanupAllowed = true;
await hopper.close();
await launcher.lease?.release();
const closed = await hopper.close();
if (!closed.ok) throw closed.error;
});
expect(await hopper.start()).toMatchObject({ ok: true });
expect(await hopper.close()).toMatchObject({
@@ -7,20 +7,14 @@ import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import { createDirectAnalysis } from "../../../../src/composition/directAnalysis.js";
import {
ArtifactPathRegistry,
destinationCaseCollisionMessage,
ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION,
normalizeArtifactPath,
} from "../../../../src/artifacts/ArtifactPaths.js";
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js";
import { ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION } from "../../../../src/artifacts/ArtifactPaths.js";
import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js";
import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js";
import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js";
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
import { parseEvidence } from "../../../../src/domain/evidence.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { withArtifactClient } from "../../../support/artifactClientScope.js";
const { runProviderAnalysis } = createDirectAnalysis({});
@@ -61,15 +55,15 @@ describe("artifact archive safety", () => {
),
).toBe(true);
const output = join(root, "output");
const extracted = await new ArtifactProvider(process.env)
.createClient(parsed.value)
.execute(
const extracted = await withArtifactClient(parsed.value, (client) =>
client.execute(
"extract_artifact",
artifactExtractionExecutionSchema.parse({
output_root: output,
}),
);
expect(extracted.ok).toBe(true);
),
);
if (!extracted.ok) throw extracted.error;
expect(await readFile(join(output, "Assets", "main.js"), "utf8")).toBe(
"export default 'windows';",
);
@@ -83,60 +77,6 @@ describe("artifact archive safety", () => {
});
},
);
it("rejects unsafe paths and collisions", async () => {
let unsafePathError: unknown;
try {
normalizeArtifactPath("../escape");
} catch (error: unknown) {
unsafePathError = error;
}
expect(unsafePathError).toBeInstanceOf(ArtifactReaderFailure);
expect(unsafePathError).toMatchObject({
reason: "path",
message: 'Artifact path is not normalized: "../escape"',
});
const registry = new ArtifactPathRegistry();
registry.add("A.js", "file");
expect(() => registry.add("a.js", "file")).not.toThrow();
expect(() => registry.add("A.js", "file")).toThrow(ArtifactReaderFailure);
for (const [filePath, childPath] of [
["Foo", "foo/bar"],
["root/Foo", "root/foo/bar"],
] as const) {
const casePrefixRegistry = new ArtifactPathRegistry();
casePrefixRegistry.add(filePath, "file");
expect(() => casePrefixRegistry.add(childPath, "file")).not.toThrow();
}
const exactPrefixRegistry = new ArtifactPathRegistry();
exactPrefixRegistry.add("Foo", "file");
expect(() => exactPrefixRegistry.add("Foo/bar", "file")).toThrow(
ArtifactReaderFailure,
);
const sameDirectoryRegistry = new ArtifactPathRegistry();
sameDirectoryRegistry.add("Foo/one.js", "file");
expect(() => sameDirectoryRegistry.add("Foo/two.js", "file")).not.toThrow();
const caseVariantDirectoryRegistry = new ArtifactPathRegistry();
caseVariantDirectoryRegistry.add("Foo/one.js", "file");
expect(() =>
caseVariantDirectoryRegistry.add("foo/two.js", "file"),
).not.toThrow();
});
it("names a destination case collision from the directory listing", () => {
expect(
destinationCaseCollisionMessage("res/2f.xml", ["2F.xml", "other.xml"]),
).toBe(
"Destination filesystem cannot store both res/2f.xml and res/2F.xml; inventory retains both logical names.",
);
expect(
destinationCaseCollisionMessage("res/2f.xml", ["2f.xml", "2F.xml"]),
).toBeUndefined();
});
});
it("extracts case-distinct names only when the destination can preserve them", async () => {
@@ -163,12 +103,12 @@ it("extracts case-distinct names only when the destination can preserve them", a
.sort(),
).toEqual(["Main.js", "main.js"]);
const output = join(root, "output");
const extracted = await new ArtifactProvider(process.env)
.createClient(parsed.value)
.execute(
const extracted = await withArtifactClient(parsed.value, (client) =>
client.execute(
"extract_artifact",
artifactExtractionExecutionSchema.parse({ output_root: output }),
);
),
);
if (!caseSensitive) {
if (extracted.ok) throw new Error("expected a destination case collision");
expect(projectAnalysisError(extracted.error)).toMatchObject({
@@ -199,12 +139,12 @@ it("preserves colon names in inventory and refuses Windows stream materializatio
);
const output = join(root, "output");
const extracted = await new ArtifactProvider(process.env)
.createClient(parsed.value)
.execute(
const extracted = await withArtifactClient(parsed.value, (client) =>
client.execute(
"extract_artifact",
artifactExtractionExecutionSchema.parse({ output_root: output }),
);
),
);
if (process.platform === "win32") {
if (extracted.ok) throw new Error("expected a Windows stream path refusal");
const detail =
@@ -232,9 +172,9 @@ it("preserves colon names in inventory and refuses Windows stream materializatio
});
const inventory = async (targetValue: BinaryTarget) => {
const result = await new ArtifactProvider(process.env)
.createClient(targetValue)
.execute("inventory_artifact", {});
const result = await withArtifactClient(targetValue, (client) =>
client.execute("inventory_artifact", {}),
);
if (!result.ok) throw result.error;
return artifactInventoryResultSchema.parse(result.value.result);
};
+1 -2
View File
@@ -21,8 +21,7 @@ const { runProviderAnalysis } = createDirectAnalysis({});
export const requireSuccessfulProjection = (
result: Result<Evidence, AnalysisError>,
): Evidence => {
if (!result.ok)
throw new TypeError("Expected application projection to succeed");
if (!result.ok) throw result.error;
return result.value;
};
+34
View File
@@ -0,0 +1,34 @@
import { onTestFinished } from "vitest";
import type { AnalysisClient } from "../../src/application/AnalysisProvider.js";
import { ArtifactProvider } from "../../src/artifacts/ArtifactProvider.js";
import type { BinaryTarget } from "../../src/domain/binaryTargetTypes.js";
export const withArtifactClient = async <T>(
target: BinaryTarget,
action: (client: AnalysisClient) => Promise<T>,
): Promise<T> => {
const client = new ArtifactProvider(process.env).createClient(target);
onTestFinished(async () => {
const closed = await client.close();
if (!closed.ok) throw closed.error;
});
let outcome:
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: unknown };
try {
outcome = { ok: true, value: await action(client) };
} catch (error) {
outcome = { ok: false, error };
}
const closed = await client.close();
if (!closed.ok) {
if (!outcome.ok)
throw new AggregateError(
[outcome.error, closed.error],
"Artifact operation and client cleanup failed",
);
throw closed.error;
}
if (!outcome.ok) throw outcome.error;
return outcome.value;
};