Files
NetMesh/electron/plugins/filesystemBroker.cjs

368 lines
13 KiB
JavaScript
Raw Permalink Normal View History

"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,
};