"use strict"; const assert = require("node:assert/strict"); const fs = require("node:fs"); const fsp = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path"); const test = require("node:test"); const { PluginFilesystemBroker, MAX_FILESYSTEM_BYTES } = require("./filesystemBroker.cjs"); const { RPC_ERRORS } = require("./rpcRouter.cjs"); function createRoot(context) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-filesystem-")); context.after(() => fs.rmSync(root, { recursive: true, force: true })); return fs.realpathSync(root); } function runtimeContext(authorization) { return { runtimeId: "runtime-1", authorization, assertActive: async () => {}, }; } async function openTestDirectoryHandle(directoryPath) { const stats = await fsp.stat(directoryPath); const directory = await fsp.opendir(directoryPath); return { stat: async () => stats, read: () => directory.read(), close: async () => { try { await directory.close(); } catch (error) { if (error?.code !== "ERR_DIR_CLOSED") throw error; } }, }; } test("filesystem broker uses canonical authorization for bounded read, write, stat, and list", async (context) => { const root = createRoot(context); const source = path.join(root, "source.txt"); const target = path.join(root, "target.txt"); await Promise.all([ fsp.writeFile(source, "hello"), fsp.writeFile(target, "prior"), ]); const charges = []; const broker = new PluginFilesystemBroker({ quotaManager: { chargeBytes: (...args) => charges.push(args) }, openDirectoryHandle: openTestDirectoryHandle, }); const readAuthorization = await broker.describeReadAuthorization({ path: source }); assert.deepEqual(readAuthorization.resourceKinds, ["exact"]); assert.deepEqual( await broker.readFile({ path: source }, runtimeContext(readAuthorization)), { data: "hello" }, ); assert.equal((await broker.stat({ path: source }, runtimeContext(readAuthorization))).kind, "file"); const writeAuthorization = await broker.describeWriteAuthorization({ path: target, data: "world", overwrite: true, }); assert.deepEqual(writeAuthorization.resourceKinds, ["exact"]); await broker.writeFile({ path: target, data: "world", overwrite: true, }, runtimeContext(writeAuthorization)); assert.equal(await fsp.readFile(target, "utf8"), "world"); const overwriteAuthorization = await broker.describeWriteAuthorization({ path: target, data: "short", overwrite: true, }); await broker.writeFile( { path: target, data: "short", overwrite: true }, runtimeContext(overwriteAuthorization), ); assert.equal(await fsp.readFile(target, "utf8"), "short"); const listAuthorization = await broker.describeReadAuthorization({ path: root }, "directory"); assert.deepEqual(listAuthorization.resourceKinds, ["directory"]); const list = await broker.readDirectory({ path: root }, runtimeContext(listAuthorization)); assert.deepEqual(list.entries.map(({ name }) => name), ["source.txt", "target.txt"]); assert.deepEqual(charges, [ ["runtime-1", "filesystem", 5], ["runtime-1", "filesystem", 5], ["runtime-1", "filesystem", 5], ]); }); test("filesystem authorization removes trailing directory separators before permission", async (context) => { const root = createRoot(context); const requestedPath = `${root}${path.sep}`; await fsp.writeFile(path.join(root, "entry.txt"), "entry"); const broker = new PluginFilesystemBroker({ openDirectoryHandle: openTestDirectoryHandle }); const statAuthorization = broker.describeReadAuthorization({ path: requestedPath }, "exact"); const listAuthorization = broker.describeReadAuthorization({ path: requestedPath }, "directory"); assert.deepEqual(statAuthorization.resources, [root]); assert.deepEqual(listAuthorization.resources, [root]); assert.equal( (await broker.stat({ path: requestedPath }, runtimeContext(statAuthorization))).kind, "directory", ); assert.deepEqual( (await broker.readDirectory({ path: requestedPath }, runtimeContext(listAuthorization))).entries, [{ name: "entry.txt", kind: "file" }], ); }); test("filesystem write quota is enforced before mutating the target", async (context) => { const root = createRoot(context); const target = path.join(root, "target.txt"); await fsp.writeFile(target, "original"); const broker = new PluginFilesystemBroker({ quotaManager: { chargeBytes: () => { throw new Error("quota denied"); } }, }); const authorization = await broker.describeWriteAuthorization({ path: target, data: "blocked", overwrite: true, }); await assert.rejects( broker.writeFile({ path: target, data: "blocked", overwrite: true, }, runtimeContext(authorization)), /quota denied/, ); assert.equal(await fsp.readFile(target, "utf8"), "original"); }); test("filesystem authorization descriptors never probe path existence before permission", async (context) => { const root = createRoot(context); const missing = path.join(root, "missing.txt"); let filesystemCalls = 0; const broker = new PluginFilesystemBroker({ fileSystem: new Proxy({}, { get() { return async () => { filesystemCalls += 1; throw new Error("filesystem must not be queried while describing authorization"); }; }, }), }); assert.deepEqual(broker.describeReadAuthorization({ path: missing }), { permission: "filesystem.read", resources: [missing], resourceKinds: ["exact"], reason: `Read ${missing}`, operationId: `filesystem.read:${missing}`, }); assert.deepEqual(broker.describeReadAuthorization({ path: root }, "directory").resourceKinds, ["directory"]); assert.deepEqual(broker.describeWriteAuthorization({ path: missing, data: "blocked", overwrite: true, }).resources, [missing]); await assert.rejects( broker.readFile({ path: missing }, runtimeContext(null)), (error) => error.code === RPC_ERRORS.permissionDenied, ); await assert.rejects( broker.writeFile( { path: missing, data: "blocked", overwrite: true }, runtimeContext(null), ), (error) => error.code === RPC_ERRORS.permissionDenied, ); assert.equal(filesystemCalls, 0); }); test("filesystem writes reject missing targets without opening or creating them", async (context) => { const root = createRoot(context); const target = path.join(root, "missing.txt"); let openCalls = 0; const broker = new PluginFilesystemBroker({ fileSystem: { ...fsp, async open(...args) { openCalls += 1; return fsp.open(...args); }, }, }); const authorization = broker.describeWriteAuthorization({ path: target, data: "blocked", overwrite: true, }); await assert.rejects( broker.writeFile( { path: target, data: "blocked", overwrite: true }, runtimeContext(authorization), ), (error) => error.code === RPC_ERRORS.failedPrecondition, ); assert.equal(openCalls, 0); await assert.rejects(fsp.stat(target), (error) => error.code === "ENOENT"); }); test("filesystem path swaps after authorization fail closed", async (context) => { const root = createRoot(context); const first = path.join(root, "first.txt"); const second = path.join(root, "second.txt"); const link = path.join(root, "selected.txt"); await Promise.all([fsp.writeFile(first, "first"), fsp.writeFile(second, "second")]); await fsp.symlink(first, link); const broker = new PluginFilesystemBroker(); const authorization = await broker.describeReadAuthorization({ path: link }); await fsp.unlink(link); await fsp.symlink(second, link); await assert.rejects( broker.readFile({ path: link }, runtimeContext(authorization)), (error) => error.code === RPC_ERRORS.permissionDenied, ); }); test("filesystem directory replacement between authorization and open fails closed", async (context) => { const root = createRoot(context); const selected = path.join(root, "selected"); const replacement = path.join(root, "replacement"); const original = path.join(root, "original"); await Promise.all([ fsp.mkdir(selected), fsp.mkdir(replacement), ]); await Promise.all([ fsp.writeFile(path.join(selected, "allowed.txt"), "allowed"), fsp.writeFile(path.join(replacement, "secret.txt"), "secret"), ]); let swapped = false; const broker = new PluginFilesystemBroker({ openDirectoryHandle: async (directoryPath) => { if (!swapped) { swapped = true; await fsp.rename(selected, original); await fsp.rename(replacement, selected); } return openTestDirectoryHandle(directoryPath); }, }); const authorization = await broker.describeReadAuthorization({ path: selected }, "directory"); await assert.rejects( broker.readDirectory({ path: selected }, runtimeContext(authorization)), (error) => error.code === RPC_ERRORS.permissionDenied, ); }); test("filesystem directory listing fails closed without a handle-bound adapter", async (context) => { const root = createRoot(context); await fsp.writeFile(path.join(root, "entry.txt"), "entry"); const broker = new PluginFilesystemBroker(); const authorization = broker.describeReadAuthorization({ path: root }, "directory"); await assert.rejects( broker.readDirectory({ path: root }, runtimeContext(authorization)), (error) => error.code === RPC_ERRORS.unsupported, ); }); test("filesystem writes recheck runtime activity immediately before mutation", async (context) => { const root = createRoot(context); const target = path.join(root, "target.txt"); await fsp.writeFile(target, "original"); let active = true; let armed = false; const broker = new PluginFilesystemBroker({ fileSystem: { ...fsp, async stat(filePath, options) { const stats = await fsp.stat(filePath, options); if (armed) active = false; return stats; }, }, }); const authorization = await broker.describeWriteAuthorization({ path: target, data: "replacement", overwrite: true, }); armed = true; await assert.rejects(broker.writeFile({ path: target, data: "replacement", overwrite: true, }, { ...runtimeContext(authorization), assertActive: async () => { if (!active) throw new Error("runtime stopped"); }, }), /runtime stopped/); assert.equal(await fsp.readFile(target, "utf8"), "original"); }); test("filesystem reads reject a replacement inode opened after authorization", async (context) => { const root = createRoot(context); const target = path.join(root, "selected.txt"); const original = path.join(root, "original.txt"); const replacement = path.join(root, "replacement.txt"); await Promise.all([ fsp.writeFile(target, "allowed"), fsp.writeFile(replacement, "secret"), ]); let swapped = false; const broker = new PluginFilesystemBroker({ fileSystem: { ...fsp, async open(filePath, flags, mode) { if (!swapped) { swapped = true; await fsp.rename(target, original); await fsp.rename(replacement, target); } return fsp.open(filePath, flags, mode); }, }, }); const authorization = broker.describeReadAuthorization({ path: target }); await assert.rejects( broker.readFile({ path: target }, runtimeContext(authorization)), (error) => error.code === RPC_ERRORS.permissionDenied, ); }); test("filesystem writes reject a replacement inode opened after authorization", async (context) => { const root = createRoot(context); const target = path.join(root, "selected.txt"); const original = path.join(root, "original.txt"); const replacement = path.join(root, "replacement.txt"); await Promise.all([ fsp.writeFile(target, "allowed"), fsp.writeFile(replacement, "secret"), ]); let swapped = false; const broker = new PluginFilesystemBroker({ fileSystem: { ...fsp, async open(filePath, flags, mode) { if (!swapped) { swapped = true; await fsp.rename(target, original); await fsp.rename(replacement, target); } return fsp.open(filePath, flags, mode); }, }, }); const authorization = broker.describeWriteAuthorization({ path: target, data: "overwritten", overwrite: true, }); await assert.rejects( broker.writeFile({ path: target, data: "overwritten", overwrite: true, }, runtimeContext(authorization)), (error) => error.code === RPC_ERRORS.permissionDenied, ); assert.equal(await fsp.readFile(original, "utf8"), "allowed"); assert.equal(await fsp.readFile(target, "utf8"), "secret"); }); test("filesystem broker rejects oversized and non-canonical payloads", async (context) => { const root = createRoot(context); const target = path.join(root, "target.bin"); const broker = new PluginFilesystemBroker(); assert.throws(() => broker.validateWrite({ path: target, data: "YQ", encoding: "base64", }), /not canonical/); assert.throws(() => broker.validateWrite({ path: target, data: "a".repeat(MAX_FILESYSTEM_BYTES + 1), }), (error) => error.code === RPC_ERRORS.resourceExhausted); assert.throws(() => broker.validateRead({ path: `${target}\0suffix` }), /absolute/); await fsp.writeFile(target, "12345"); const authorization = await broker.describeReadAuthorization({ path: target, maxBytes: 4 }); await assert.rejects( broker.readFile({ path: target, maxBytes: 4 }, runtimeContext(authorization)), (error) => error.code === RPC_ERRORS.resourceExhausted, ); });