diff --git a/src/application/jeb/JebAnalysisService.test.ts b/src/application/jeb/JebAnalysisService.test.ts index 3c136aeba..4f08f61ab 100644 --- a/src/application/jeb/JebAnalysisService.test.ts +++ b/src/application/jeb/JebAnalysisService.test.ts @@ -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 }); diff --git a/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts b/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts index ee56bacf4..294bd4d3c 100644 --- a/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts +++ b/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts @@ -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-", diff --git a/tests/boundary/filesystem/artifactZipIntegrity.test.ts b/tests/boundary/filesystem/artifactZipIntegrity.test.ts index 946db4a86..9dd776ce9 100644 --- a/tests/boundary/filesystem/artifactZipIntegrity.test.ts +++ b/tests/boundary/filesystem/artifactZipIntegrity.test.ts @@ -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, diff --git a/tests/boundary/filesystem/asarStreamingAllocation.test.ts b/tests/boundary/filesystem/asarStreamingAllocation.test.ts index 6bff18329..0b740d484 100644 --- a/tests/boundary/filesystem/asarStreamingAllocation.test.ts +++ b/tests/boundary/filesystem/asarStreamingAllocation.test.ts @@ -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, diff --git a/tests/boundary/process/binarySessionCleanupRetry.test.ts b/tests/boundary/process/binarySessionCleanupRetry.test.ts index ff00563fc..5d58f9420 100644 --- a/tests/boundary/process/binarySessionCleanupRetry.test.ts +++ b/tests/boundary/process/binarySessionCleanupRetry.test.ts @@ -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 => { + // 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({ diff --git a/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts b/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts index e0978e379..95ea672de 100644 --- a/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts +++ b/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts @@ -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); }; diff --git a/tests/support/applicationSessionFixture.ts b/tests/support/applicationSessionFixture.ts index c1db91743..879df0b5d 100644 --- a/tests/support/applicationSessionFixture.ts +++ b/tests/support/applicationSessionFixture.ts @@ -21,8 +21,7 @@ const { runProviderAnalysis } = createDirectAnalysis({}); export const requireSuccessfulProjection = ( result: Result, ): Evidence => { - if (!result.ok) - throw new TypeError("Expected application projection to succeed"); + if (!result.ok) throw result.error; return result.value; }; diff --git a/tests/support/artifactClientScope.ts b/tests/support/artifactClientScope.ts new file mode 100644 index 000000000..ed5333ac5 --- /dev/null +++ b/tests/support/artifactClientScope.ts @@ -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 ( + target: BinaryTarget, + action: (client: AnalysisClient) => Promise, +): Promise => { + 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; +};