diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d9f6644b..be813b67f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -473,6 +473,21 @@ jobs: tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts - run: npm run verify:windows-native - run: npm run verify:package:windows + - name: Verify native directory identity on Node 24 + run: >- + npx vitest run --coverage.enabled=false + tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts + tests/boundary/filesystem/javascriptPackagePrecedence.test.ts + tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts + - uses: actions/setup-node@v4 + with: + node-version: 22.19.0 + - name: Verify native directory identity on Node 22 + run: >- + npx vitest run --coverage.enabled=false + tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts + tests/boundary/filesystem/javascriptPackagePrecedence.test.ts + tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts - uses: actions/upload-artifact@v4 if: always() with: diff --git a/tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts b/tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts new file mode 100644 index 000000000..387eced40 --- /dev/null +++ b/tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts @@ -0,0 +1,126 @@ +import { lstat, open, rename, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { buffer } from "node:stream/consumers"; + +import { describe, expect, it } from "vitest"; + +import { type ArtifactEntry } from "../../../src/artifacts/ArtifactReader.js"; +import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const sampleEntry = async ( + reader: DirectoryArtifactReader, +): Promise => { + for await (const entry of reader.entries()) { + if (entry.path === "sample.txt") return entry; + } + throw new Error("Fixture omitted sample.txt"); +}; + +const fixture = async () => { + const root = await createTestTempDirectory("rea-dir-reader-open-"); + const path = join(root, "sample.txt"); + await writeFile(path, "test payload\n"); + return { path, reader: new DirectoryArtifactReader(root) }; +}; + +describe("DirectoryArtifactReader.open", () => { + it("preserves native path and handle identity while streaming an unchanged file", async () => { + const { path, reader } = await fixture(); + try { + const entry = await sampleEntry(reader); + const metadata = await lstat(path); + const handle = await open(path, "r"); + try { + const observed = await handle.stat(); + expect(entry.sourceIdentity).toEqual({ + device: metadata.dev, + inode: metadata.ino, + }); + expect({ device: observed.dev, inode: observed.ino }).toEqual( + entry.sourceIdentity, + ); + console.info( + JSON.stringify({ + platform: process.platform, + node: process.version, + pathDevice: metadata.dev, + handleDevice: observed.dev, + pathInode: metadata.ino, + handleInode: observed.ino, + }), + ); + } finally { + await handle.close(); + } + expect((await buffer(await reader.open(entry))).toString("utf8")).toBe( + "test payload\n", + ); + } finally { + await reader.close(); + } + }); + + it("rejects an actual file replacement after enumeration", async () => { + const { path, reader } = await fixture(); + try { + const entry = await sampleEntry(reader); + await rename(path, `${path}.original`); + await writeFile(path, "replacement payload\n"); + await expect(reader.open(entry)).rejects.toMatchObject({ + reason: "integrity", + }); + } finally { + await reader.close(); + } + }); + + it.each(["zero", "another"] as const)( + "requires the enumerated %s device to match the opened file", + async (kind) => { + const { reader } = await fixture(); + try { + const entry = await sampleEntry(reader); + const identity = entry.sourceIdentity; + if (identity === undefined) + throw new Error("Fixture omitted source identity"); + if (kind === "zero" && identity.device === 0) { + expect( + (await buffer(await reader.open(entry))).toString("utf8"), + ).toBe("test payload\n"); + return; + } + const device = kind === "zero" ? 0 : identity.device === 1 ? 2 : 1; + await expect( + reader.open({ ...entry, sourceIdentity: { ...identity, device } }), + ).rejects.toMatchObject({ reason: "integrity" }); + } finally { + await reader.close(); + } + }, + ); + + it("rejects a file without enumerated source identity", async () => { + const { reader } = await fixture(); + try { + const { sourceIdentity: _identity, ...entry } = await sampleEntry(reader); + await expect(reader.open(entry)).rejects.toMatchObject({ + reason: "integrity", + }); + } finally { + await reader.close(); + } + }); + + it("rejects entries that are not files", async () => { + const { reader } = await fixture(); + try { + const entry = await sampleEntry(reader); + await expect( + reader.open({ ...entry, kind: "directory" }), + ).rejects.toMatchObject({ reason: "format" }); + } finally { + await reader.close(); + } + }); +});