diff --git a/restricted-execution/service.mjs b/restricted-execution/service.mjs index a4140b9..bde930c 100644 --- a/restricted-execution/service.mjs +++ b/restricted-execution/service.mjs @@ -1,12 +1,17 @@ import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { createServer, request as httpRequest } from "node:http"; -import { mkdtemp, readFile, writeFile, rm, open } from "node:fs/promises"; +import { mkdtemp, readFile, writeFile, rm, open, lstat, readdir } from "node:fs/promises"; import { constants } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; -import { checkedSocketPath } from "../socket-address.mjs"; +import { + checkedSocketPath, + socketDirectory, + prepareSocketDirectory, + removeEmptySocketDirectory, +} from "../socket-address.mjs"; const abi = "quixos-transaction-ports-v1"; /** Reserved supervisor faults are not authored domain rejections. A graph @@ -329,15 +334,57 @@ export async function serveRestrictedRegistry(config, io = process) { } await engine.exited; } + const sameDirectory = async (directory, owner) => { + const current = await lstat(directory); + if ( + !owner || + !current.isDirectory() || + current.dev !== owner.dev || + current.ino !== owner.ino || + current.uid !== owner.uid || + current.mode & 0o077 + ) + throw fixedError("RESTRICTED_START_FAILED"); + }; + // A bounded ordinary-engine parent is private to this admitted lifetime. + // Never close/unlink through a replacement directory or release its lease + // while the original namespace's ownership remains uncertain. + if (engine.socketParent) { + await sameDirectory(engine.socketParent, engine.socketParentOwner); + if (engine.directory) await sameDirectory(engine.directory, engine.directoryOwner); + if (engine.broker) { + const current = await lstat(engine.broker).catch((error) => { + if (error.code !== "ENOENT") throw error; + return null; + }); + if ( + current && + (!engine.brokerOwner || + !current.isSocket() || + current.dev !== engine.brokerOwner.dev || + current.ino !== engine.brokerOwner.ino) + ) + throw fixedError("RESTRICTED_START_FAILED"); + } + } engine.server?.closeAllConnections(); if (engine.server) await new Promise((resolve) => engine.server.close(resolve)); - try { - if (engine.directory) await rm(engine.directory, { recursive: true, force: true }); - } finally { - engines.delete(engine); - await engine.release?.(); - changed(); + if (engine.socketParent) { + await sameDirectory(engine.socketParent, engine.socketParentOwner); + if (engine.directory) await sameDirectory(engine.directory, engine.directoryOwner); } + if (engine.directory) await rm(engine.directory, { recursive: true, force: true }); + if (engine.socketParent) { + await removeEmptySocketDirectory(engine.socketIdentity); + const remaining = await lstat(engine.socketParent).catch((error) => { + if (error.code !== "ENOENT") throw error; + return null; + }); + if (remaining) throw fixedError("RESTRICTED_START_FAILED"); + } + engines.delete(engine); + await engine.release?.(); + changed(); })(); return engine.destroying; }; @@ -361,13 +408,24 @@ export async function serveRestrictedRegistry(config, io = process) { () => destroyEngine(engine), ); if (!call.open) return; + let directoryRoot; if (config.sandboxIdentity) { // The privileged helper owns a fixed explicit /run root and requires // this exact basename. Keep its cross-UID ancestry and identity rules. - for (const filename of ["broker.sock", "engine.sock"]) - checkedSocketPath(tmpdir() + "/qx-engine-XXXXXX/" + filename); + directoryRoot = tmpdir(); + } else { + engine.socketIdentity = { kind: "restricted-engine", process: process.pid, lifetime: randomUUID() }; + directoryRoot = socketDirectory(engine.socketIdentity); } - engine.directory = await mkdtemp(join(tmpdir(), "qx-engine-")); + for (const filename of ["broker.sock", "engine.sock"]) + checkedSocketPath(directoryRoot + "/qx-engine-XXXXXX/" + filename); + if (!config.sandboxIdentity) { + engine.socketParent = await prepareSocketDirectory(engine.socketIdentity); + engine.socketParentOwner = await lstat(engine.socketParent); + if ((await readdir(engine.socketParent)).length) throw fixedError("RESTRICTED_START_FAILED"); + } + engine.directory = await mkdtemp(join(directoryRoot, "qx-engine-")); + if (engine.socketParent) engine.directoryOwner = await lstat(engine.directory); if (!call.open) return; const broker = checkedSocketPath(join(engine.directory, "broker.sock")); engine.socket = checkedSocketPath(join(engine.directory, "engine.sock")); @@ -416,10 +474,14 @@ export async function serveRestrictedRegistry(config, io = process) { } }); + if (engine.socketParent) engine.broker = broker; await new Promise((resolve, reject) => { engine.server.once("error", reject); engine.server.listen(broker, resolve); }); + if (engine.socketParent) { + engine.brokerOwner = await lstat(broker); + } const modules = { "entry.mjs": { js: workerSource }, "package.mjs": { js: packageSource } }; for (const module of sdk) modules[module.name] = { js: module.source }; const setting = { registryExport: config.registryExport, exports: config.exports, maxBytes: limits.maxBytes }; diff --git a/restricted-execution/service.test.mjs b/restricted-execution/service.test.mjs index 5b6dc26..7ff2425 100644 --- a/restricted-execution/service.test.mjs +++ b/restricted-execution/service.test.mjs @@ -1,11 +1,12 @@ import test from "node:test"; import assert from "node:assert/strict"; import { PassThrough } from "node:stream"; -import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { mkdtemp, writeFile, rm, mkdir, readdir, lstat } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { serveRestrictedRegistry, RestrictedEngineBudget } from "./service.mjs"; +import { socketDirectory, checkedSocketPath } from "../socket-address.mjs"; const workerd = process.env.QUIXOS_TEST_WORKERD; if (!workerd) throw Error("Set QUIXOS_TEST_WORKERD to the pinned engine"); @@ -95,6 +96,50 @@ async function fixture(source, options = {}) { } const registry = (code) => `export const registry={exports:['test'],async invoke(request,channel){${code}}};`; +test("real engine sockets and retirement are independent of deep Unicode TMPDIR", async () => { + const root = await mkdtemp(join(tmpdir(), "restricted-deep-")); + const deep = join(root, "保留された状態".repeat(12)); + await mkdir(deep); + const oldTmp = process.env.TMPDIR; + const socketRoot = dirname(socketDirectory({ kind: "qualification-probe" })); + const names = async () => + readdir(socketRoot).catch((error) => { + if (error.code !== "ENOENT") throw error; + return []; + }); + const before = new Set(await names()); + let f, namespace; + process.env.TMPDIR = deep; + try { + assert.ok(Buffer.byteLength(join(deep, "qx-engine-XXXXXX", "engine.sock")) > 100); + f = await fixture(registry("return request.input;")); + f.start("deep runtime"); + const result = await f.receive(); + assert.equal(result.status, "returned"); + assert.equal(result.output, "deep runtime"); + assert.equal(f.stats().starts, 1); + const created = (await names()).filter((name) => !before.has(name)); + assert.equal(created.length, 1); + namespace = join(socketRoot, created[0]); + const engines = await readdir(namespace); + assert.equal(engines.length, 1); + for (const name of ["broker.sock", "engine.sock"]) { + const endpoint = checkedSocketPath(join(namespace, engines[0], name)); + assert.ok((await lstat(endpoint)).isSocket()); + } + } finally { + try { + await f?.close(); + } finally { + if (oldTmp === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = oldTmp; + } + } + await assert.rejects(lstat(namespace), { code: "ENOENT" }); + assert.ok((await lstat(deep)).isDirectory()); + await rm(root, { recursive: true, force: true }); +}); + test("uncertain OS cleanup cannot release engine capacity", async () => { const budget = new RestrictedEngineBudget(1, async () => ({ async release() {