diff --git a/packages/safe-fs/src/fs/devices/index.ts b/packages/safe-fs/src/fs/devices/index.ts index 684966b33..db43ef106 100644 --- a/packages/safe-fs/src/fs/devices/index.ts +++ b/packages/safe-fs/src/fs/devices/index.ts @@ -6,7 +6,7 @@ import type { } from "../../contracts/filesystem.js"; import type { ByteSource } from "../../contracts/io.js"; import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admission.js"; -import { compareEntries, registerEntryView } from "../mount/comparison.js"; +import { compareEntries, registerEntryAuthority, registerEntryView } from "../mount/comparison.js"; import { deviceDirectory, lexicalDevicePath, nullPath, resolveDevicePath } from "./path.js"; import { deviceReadStream, drainDeviceFile, drainDeviceInput } from "./stream.js"; import { openRetainedReadFile, openRetainedResizeFile, retainedResizeCapabilities } from "../capabilities.js"; @@ -85,6 +85,8 @@ export class DeviceFileSystem implements FileSystem { stat: resolved === nullPath ? this.#nullStat : this.#directoryStat, readOnly: false }; return { filesystem, path }; }); + // This internal view has no identity proof beyond its synthetic stats. + registerEntryAuthority(this, async () => "unknown"); return view; } diff --git a/packages/safe-fs/tests/devices-comparison-portable.test.ts b/packages/safe-fs/tests/devices-comparison-portable.test.ts new file mode 100644 index 000000000..29aa89966 --- /dev/null +++ b/packages/safe-fs/tests/devices-comparison-portable.test.ts @@ -0,0 +1,35 @@ +import { webcrypto } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { createContext, runInContext } from "node:vm"; +import { build } from "esbuild"; +import { expect, it } from "vitest"; +import { deviceComparisonChecks } from "./helpers/device-comparison-checks.js"; + +const expected = ["unknown", "unknown", "unknown", "unknown", "same", "same", "distinct"]; + +it("compares devices with identity-less backends in the native graph", async () => { + expect(await deviceComparisonChecks()).toEqual(expected); +}); + +for (const condition of ["browser", "workerd"]) { + it(`compares devices without Node callback authority under ${condition} selection`, async () => { + const output = await build({ + entryPoints: [fileURLToPath(new URL("./helpers/device-comparison-checks.ts", import.meta.url))], + bundle: true, + platform: "browser", + conditions: [condition], + format: "iife", + globalName: "deviceChecks", + target: "es2022", + write: false, + metafile: true, + logLevel: "silent", + }); + expect(Object.values(output.metafile!.inputs).flatMap(input => input.imports).filter(input => input.external)).toEqual([]); + expect(Object.keys(output.metafile!.inputs).some(input => input.endsWith("platform/browser.ts"))).toBe(true); + expect(Object.keys(output.metafile!.inputs).some(input => input.endsWith("platform/node.ts"))).toBe(false); + const context = createContext({ AbortController, AbortSignal, TextEncoder, TextDecoder, Uint8Array, crypto: webcrypto }); + runInContext(output.outputFiles[0]!.text, context); + expect(await runInContext("deviceChecks.deviceComparisonChecks()", context)).toEqual(expected); + }); +} diff --git a/packages/safe-fs/tests/helpers/device-comparison-checks.ts b/packages/safe-fs/tests/helpers/device-comparison-checks.ts new file mode 100644 index 000000000..703b10288 --- /dev/null +++ b/packages/safe-fs/tests/helpers/device-comparison-checks.ts @@ -0,0 +1,44 @@ +import type { FileSystem } from "../../src/contracts/filesystem.js"; +import { createDeviceFileSystem } from "../../src/fs/devices/index.js"; +import { MemoryFileSystem } from "../../src/fs/memory/index.js"; +import { compareEntries } from "../../src/fs/mount/comparison.js"; + +export async function deviceComparisonChecks() { + const memory = new MemoryFileSystem(); + await memory.symlink("/dev", "/devices"); + await memory.symlink("/dev/null", "/null-alias"); + // Injected backends need not provide optional identity metadata or authorities. + const backing: FileSystem = new Proxy(memory, { + get(target, key) { + if (key === "compareEntry") return undefined; + if (key === "stat" || key === "lstat") return async (...args: Parameters) => { + const stat = { ...await target[key](...args) }; + delete stat.identityScope; + delete stat.ino; + delete stat.dev; + return stat; + }; + const value = Reflect.get(target, key, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + const devices = createDeviceFileSystem(backing); + const results = []; + for (const path of ["/dev", "/dev/null"]) { + results.push(await compareEntries(devices, path, devices, "/")); + results.push(await compareEntries(devices, "/", devices, path)); + } + results.push(await compareEntries(devices, "/devices", devices, "/dev")); + results.push(await compareEntries(devices, "/null-alias", devices, "/dev/null")); + results.push(await compareEntries(devices, "/dev", devices, "/dev/null")); + const controller = new AbortController(); + const reason = new Error("cancel comparison"); + controller.abort(reason); + try { + await compareEntries(devices, "/dev", devices, "/", { signal: controller.signal }); + throw new Error("comparison ignored cancellation"); + } catch (error) { + if (error !== reason) throw error; + } + return results; +}