From e94be95c66515fa6fba2a46e4b29850d34141fef Mon Sep 17 00:00:00 2001 From: Adarsh Kumar Date: Thu, 8 Oct 2026 21:41:28 +0530 Subject: [PATCH] test(artifacts): verify native directory identity on supported Windows runtimes (#1069) * fix(artifacts): guard Windows device 0 in directory open integrity check On Windows, Node.js fs.lstat reports dev as 0 without opening a handle. When DirectoryArtifactReader.open inspects the opened handle using handle.stat(), stat.dev is populated with the volume serial number. Consequently, the TOCTOU check observed.dev !== entry.sourceIdentity.device evaluated to true on Windows for every regular file, throwing an ArtifactReaderFailure integrity error. Relax the device comparison when running on Windows or when the inventoried device is 0, while maintaining strict inode verification to ensure the target has not been replaced. * test(artifacts): verify native directory identity without device bypasses --------- Co-authored-by: N0zoM1z0 <1705004398@qq.com> --- .github/workflows/ci.yml | 15 +++ .../directoryArtifactReaderOpen.test.ts | 126 ++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d9f6644b3..be813b67fd 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 0000000000..387eced40a --- /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(); + } + }); +});