"use strict"; const fs = require("node:fs"); const fsp = require("node:fs/promises"); const path = require("node:path"); const { PLUGIN_RPC_MAX_RAW_BYTES } = require("./constants.cjs"); const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs"); const MAX_FILESYSTEM_BYTES = PLUGIN_RPC_MAX_RAW_BYTES; const MAX_DIRECTORY_ENTRIES = 1_000; const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0; function invalidArgument(message) { return new PluginRpcError(RPC_ERRORS.invalidArgument, message); } function assertAbsolutePath(value) { if ( typeof value !== "string" || value.length < 1 || value.length > 8_192 || value.includes("\0") || !path.isAbsolute(value) ) { throw invalidArgument("Plugin filesystem path must be absolute"); } // Resolve absolute paths lexically so permission resources have one stable // spelling. In particular, realpath() drops trailing separators, and the // post-permission equality checks must compare against the same spelling. return path.resolve(value); } function assertReadParams(params) { if (!params || typeof params !== "object" || Array.isArray(params)) { throw invalidArgument("Plugin filesystem read parameters are invalid"); } const encoding = params.encoding ?? "utf8"; if (encoding !== "utf8" && encoding !== "base64") { throw invalidArgument("Plugin filesystem read encoding is invalid"); } const maxBytes = params.maxBytes ?? MAX_FILESYSTEM_BYTES; if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_FILESYSTEM_BYTES) { throw invalidArgument("Plugin filesystem read limit is invalid"); } return { path: assertAbsolutePath(params.path), encoding, maxBytes }; } function decodeWriteData(params) { const encoding = params.encoding ?? "utf8"; if (encoding !== "utf8" && encoding !== "base64") { throw invalidArgument("Plugin filesystem write encoding is invalid"); } if (typeof params.data !== "string") throw invalidArgument("Plugin filesystem write data is invalid"); const bytes = Buffer.from(params.data, encoding); if (encoding === "base64" && bytes.toString("base64") !== params.data) { throw invalidArgument("Plugin filesystem base64 data is not canonical"); } if (bytes.byteLength > MAX_FILESYSTEM_BYTES) { throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin filesystem write is too large"); } return { encoding, bytes }; } function assertWriteParams(params) { if (!params || typeof params !== "object" || Array.isArray(params)) { throw invalidArgument("Plugin filesystem write parameters are invalid"); } const { encoding, bytes } = decodeWriteData(params); if (params.overwrite !== undefined && typeof params.overwrite !== "boolean") { throw invalidArgument("Plugin filesystem overwrite flag is invalid"); } return { path: assertAbsolutePath(params.path), encoding, bytes, overwrite: params.overwrite === true, }; } function assertSecureWriteMode(value) { if (!value.overwrite) { throw new PluginRpcError( RPC_ERRORS.failedPrecondition, "Plugin filesystem writes require explicit overwrite of an existing file", ); } return value; } function assertPathParams(params) { if (!params || typeof params !== "object" || Array.isArray(params)) { throw invalidArgument("Plugin filesystem parameters are invalid"); } return { path: assertAbsolutePath(params.path) }; } async function resolveExistingPath(requestedPath, fileSystem = fsp) { const canonical = await fileSystem.realpath(requestedPath); const stats = await fileSystem.lstat(canonical); if (stats.isSymbolicLink()) throw invalidArgument("Plugin filesystem path cannot resolve to a symbolic link"); return { canonical, stats }; } async function resolveWritePath(requestedPath, fileSystem = fsp) { try { const existing = await resolveExistingPath(requestedPath, fileSystem); if (!existing.stats.isFile()) throw invalidArgument("Plugin filesystem write target is not a file"); return existing; } catch (error) { if (error?.code === "ENOENT") { throw new PluginRpcError( RPC_ERRORS.failedPrecondition, "Plugin filesystem writes require an existing regular file", ); } throw error; } } function entryKind(entry) { if (entry.isFile()) return "file"; if (entry.isDirectory()) return "directory"; return "other"; } async function readBoundedFileHandle(handle, maxBytes) { const chunks = []; let total = 0; while (total <= maxBytes) { const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, (maxBytes + 1) - total)); const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, null); if (bytesRead === 0) break; chunks.push(buffer.subarray(0, bytesRead)); total += bytesRead; } if (total > maxBytes) { throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin filesystem file changed or is too large"); } return Buffer.concat(chunks, total); } function assertAuthorizedPath(context, permission, canonical) { const authorization = context?.authorization; if ( authorization?.permission !== permission || !Array.isArray(authorization.resources) || authorization.resources.length !== 1 || authorization.resources[0] !== canonical ) { throw new PluginRpcError( RPC_ERRORS.permissionDenied, "Plugin filesystem path changed after authorization", ); } return canonical; } function assertSameOpenedFile(openedStats, pathStats) { if ( !openedStats.isFile() || !pathStats.isFile() || openedStats.dev !== pathStats.dev || openedStats.ino !== pathStats.ino ) { throw new PluginRpcError( RPC_ERRORS.permissionDenied, "Plugin filesystem target changed after it was opened", ); } } function assertSameOpenedDirectory(expectedStats, pathStats) { if ( !expectedStats.isDirectory() || !pathStats.isDirectory() || expectedStats.dev !== pathStats.dev || expectedStats.ino !== pathStats.ino ) { throw new PluginRpcError( RPC_ERRORS.permissionDenied, "Plugin filesystem directory changed after authorization", ); } } class PluginFilesystemBroker { constructor(options = {}) { this.quotaManager = options.quotaManager ?? null; this.fileSystem = options.fileSystem ?? fsp; this.openDirectoryHandle = options.openDirectoryHandle ?? null; if (this.openDirectoryHandle != null && typeof this.openDirectoryHandle !== "function") { throw new TypeError("Secure plugin directory handle adapter must be a function"); } } validateRead(params) { return assertReadParams(params); } validateWrite(params) { const value = assertWriteParams(params); return { path: value.path, encoding: value.encoding, data: value.bytes.toString(value.encoding), overwrite: value.overwrite, }; } validatePath(params) { return assertPathParams(params); } describeReadAuthorization(params, resourceKind = "exact") { const value = assertPathParams(params); if (resourceKind !== "exact" && resourceKind !== "directory") { throw invalidArgument("Plugin filesystem authorization kind is invalid"); } return { permission: "filesystem.read", resources: [value.path], resourceKinds: [resourceKind], reason: `Read ${value.path}`, operationId: `filesystem.read:${value.path}`, }; } describeWriteAuthorization(params) { const value = assertSecureWriteMode(assertWriteParams(params)); return { permission: "filesystem.write", resources: [value.path], resourceKinds: ["exact"], reason: `Write ${value.path}`, operationId: `filesystem.write:${value.path}`, }; } async readFile(params, context) { const value = assertReadParams(params); assertAuthorizedPath(context, "filesystem.read", value.path); const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem); assertAuthorizedPath(context, "filesystem.read", canonical); if (!stats.isFile()) throw invalidArgument("Plugin filesystem read target is not a file"); if (stats.size > value.maxBytes) { throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin filesystem file is too large"); } const handle = await this.fileSystem.open(canonical, fs.constants.O_RDONLY | NOFOLLOW); try { const [openedStats, pathStats] = await Promise.all([ handle.stat(), this.fileSystem.stat(canonical), ]); assertSameOpenedFile(openedStats, stats); assertSameOpenedFile(openedStats, pathStats); if (openedStats.size > value.maxBytes) { throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin filesystem file changed or is too large"); } assertAuthorizedPath(context, "filesystem.read", await this.fileSystem.realpath(canonical)); const bytes = await readBoundedFileHandle(handle, value.maxBytes); this.quotaManager?.chargeBytes(context.runtimeId, "filesystem", bytes.byteLength); await context.assertActive(); return { data: bytes.toString(value.encoding) }; } finally { await handle.close(); } } async writeFile(params, context) { const value = assertSecureWriteMode(assertWriteParams(params)); assertAuthorizedPath(context, "filesystem.write", value.path); const { canonical, stats } = await resolveWritePath(value.path, this.fileSystem); assertAuthorizedPath(context, "filesystem.write", canonical); this.quotaManager?.chargeBytes(context.runtimeId, "filesystem", value.bytes.byteLength); await context.assertActive(); const flags = fs.constants.O_WRONLY | NOFOLLOW; const handle = await this.fileSystem.open(canonical, flags, 0o600); try { assertAuthorizedPath(context, "filesystem.write", await this.fileSystem.realpath(canonical)); const [openedStats, pathStats] = await Promise.all([ handle.stat(), this.fileSystem.stat(canonical), ]); assertSameOpenedFile(openedStats, stats); assertSameOpenedFile(openedStats, pathStats); await context.assertActive(); if (value.overwrite) await handle.truncate(0); await handle.writeFile(value.bytes); await handle.sync(); await context.assertActive(); } finally { await handle.close(); } return null; } async stat(params, context) { const value = assertPathParams(params); assertAuthorizedPath(context, "filesystem.read", value.path); const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem); assertAuthorizedPath(context, "filesystem.read", canonical); await context.assertActive(); return { kind: stats.isFile() ? "file" : stats.isDirectory() ? "directory" : "other", size: Math.min(Number.MAX_SAFE_INTEGER, Number(stats.size)), modifiedAt: Math.max(0, Math.trunc(stats.mtimeMs)), }; } async readDirectory(params, context) { const value = assertPathParams(params); assertAuthorizedPath(context, "filesystem.read", value.path); const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem); assertAuthorizedPath(context, "filesystem.read", canonical); if (!stats.isDirectory()) throw invalidArgument("Plugin filesystem directory target is not a directory"); if (!this.openDirectoryHandle) { throw new PluginRpcError( RPC_ERRORS.unsupported, "Secure plugin directory enumeration is unavailable on this runtime", ); } const directory = await this.openDirectoryHandle(canonical, { signal: context.signal }); if ( !directory || typeof directory.stat !== "function" || typeof directory.read !== "function" || typeof directory.close !== "function" ) throw new TypeError("Secure plugin directory handle adapter returned an invalid handle"); try { assertSameOpenedDirectory(stats, await directory.stat()); const entries = []; for (;;) { const entry = await directory.read(); if (entry === null) break; entries.push(entry); if (entries.length > MAX_DIRECTORY_ENTRIES) { throw new PluginRpcError( RPC_ERRORS.resourceExhausted, "Plugin filesystem directory has too many entries", ); } } assertSameOpenedDirectory(stats, await directory.stat()); assertSameOpenedDirectory(stats, await this.fileSystem.stat(canonical)); await context.assertActive(); return { entries: entries .map((entry) => ({ name: entry.name, kind: entryKind(entry) })) .sort((left, right) => left.name.localeCompare(right.name, "en")), }; } finally { await directory.close(); } } } module.exports = { MAX_DIRECTORY_ENTRIES, MAX_FILESYSTEM_BYTES, PluginFilesystemBroker, assertAbsolutePath, assertAuthorizedPath, assertSameOpenedFile, assertSameOpenedDirectory, assertPathParams, assertReadParams, assertSecureWriteMode, assertWriteParams, resolveExistingPath, resolveWritePath, readBoundedFileHandle, };