From 95845a9970f42ca5ca3c385040eb65009f3b8dcc Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 03:46:00 -0400 Subject: [PATCH 1/4] fix: align native MariaDB FUSE ownership (#2238) AI assistance: OpenAI GPT-5.6 Sol via OpenCode implemented the caller-ownership alignment, readiness lifecycle probe, and deterministic tests. Chris Huber remains responsible for every line. --- docs/native-mariadb-runtime-service.md | 4 +- packages/cli/src/runtime-services.ts | 41 ++++++++++---------- tests/native-mariadb-runtime-service.test.ts | 6 ++- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/docs/native-mariadb-runtime-service.md b/docs/native-mariadb-runtime-service.md index bcb5378ea..3d944c698 100644 --- a/docs/native-mariadb-runtime-service.md +++ b/docs/native-mariadb-runtime-service.md @@ -7,14 +7,14 @@ - The provider rejects UID 0 and resolves and validates `mariadbd`, `mariadb-install-db`, `mariadb`, util-linux `prlimit`, `truncate`, `mkfs.ext4`, `fuse2fs`, and `fusermount3` identities before allocation. - Default executable discovery ignores the caller's `PATH`, searches fixed system directories, resolves symlinks, and requires every executable and ancestor to be UID-0-owned and not group/other writable. Child processes receive a fixed minimal `PATH`. - Every run gets a mode-`0700` `mkdtemp` root. MariaDB's data directory, temporary directory, socket, PID file, error log, plugin directory, secure-file directory, home, and working directory are all inside the bounded image. -- Initialization, daemon, and administrative clients run through fixed hard rlimits; each database command itself begins with `--no-defaults`. No shell or default socket is used. +- Initialization and the daemon run as the verified unprivileged FUSE mount owner through fixed hard rlimits; administrative clients retain the provider identity and each database command itself begins with `--no-defaults`. No shell or default socket is used. - Administration uses only the private Unix socket. Workloads receive a generated least-privilege `runtime` account over loopback TCP through the existing ephemeral connector-secret channel. - Initialization, FUSE, and daemon commands each run in a new owned process group. Cleanup addresses the retained group, not a PID file; it waits for the complete group to disappear after graceful shutdown, then applies group-wide `SIGTERM` and `SIGKILL` as needed. Linux captures the leader start-time token and revalidates it while the leader is alive. Root device/inode identity and a symlink-free tree are revalidated before recursive removal. - Failures, aborts, timeouts, startup crashes, and partial initialization all enter the same graceful-shutdown, forced-shutdown, wait, and verified-removal state machine. Cleanup failure is terminal and retained in bounded lifecycle evidence. - Address space is capped at 2 GiB, CPU at 300 seconds, individual daemon files at 128 MiB, open files at 512, and processes/threads at 512. Core files and locked memory are disabled. The datadir is a provider-owned 256 MiB ext4 image formatted with 4,096 inodes and mounted through unprivileged FUSE; device, byte, and inode geometry must be proven before initialization. Hosts without this containment fail closed. - Recipes may declare at most two native services, bounding the aggregate native ceiling to two 2-GiB address spaces, two 256-MiB images, and the corresponding process/file limits. - The daemon uses an empty bounded plugin directory and a bounded `secure-file-priv` directory. Startup fails unless every enabled storage engine is on the fixed local-only allowlist; FEDERATED, CONNECT, SPIDER, S3, and unknown enabled engines are rejected. The runtime account has privileges only on its generated database and cannot install plugins. -- Runtime descriptor discovery distinguishes package support from host availability. It advertises the active native capability only after trusted tools and a real create, mount, geometry, write, unmount, process-group-exit, and removal probe succeed; unavailable reasons are stable codes without private paths. +- Runtime descriptor discovery distinguishes package support from host availability. It advertises the active native capability only after trusted tools and a full disposable native-service provision, initialization, readiness, teardown, process-group exit, and removal lifecycle succeeds; unavailable reasons are stable codes without private paths. - Cleanup is single-flight for concurrent callers. A failed attempt may be retried, and evidence transitions from `teardown: failed` to a consistent released/completed state only after the retry proves cleanup. - Evidence contains service ID, engine/provider version, lifecycle state, and memory measurements only. It never contains credentials or private absolute paths. diff --git a/packages/cli/src/runtime-services.ts b/packages/cli/src/runtime-services.ts index ac4157deb..9533dac0b 100644 --- a/packages/cli/src/runtime-services.ts +++ b/packages/cli/src/runtime-services.ts @@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto" import { access, chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, statfs, writeFile } from "node:fs/promises" import { constants as fsConstants } from "node:fs" import { createConnection, createServer } from "node:net" -import { tmpdir } from "node:os" +import { tmpdir, userInfo } from "node:os" import { dirname, join, resolve } from "node:path" import type { RuntimePolicy, WorkspaceRecipeExternalServiceBoundary, WorkspaceRecipeRuntimeService } from "@automattic/wp-codebox-core" @@ -418,7 +418,9 @@ async function provisionMysqlNativeService(service: WorkspaceRecipeRuntimeServic const storageRoot = ownedNativePath(root, "storage") await mkdir(storageRoot, { mode: 0o700 }) const storageEnvironment = nativeMariaDbEnvironment(root.path) - const userArgument: string[] = [] + // MariaDB otherwise selects its package service account, which cannot write + // through a FUSE filesystem owned by the unprivileged provider caller. + const userArgument = [`--user=${nativeMariaDbCallerUser()}`] storage = await provisionNativeMariaDbStorage(binaries, root, storageRoot, dependencies, storageEnvironment, signal) const datadir = join(storageRoot, "database") const runtimeDirectory = join(storageRoot, "runtime") @@ -545,36 +547,35 @@ export async function nativeMariaDbHostReadiness(dependencies: RuntimeServiceDep } catch { return { status: "unavailable", reason: "unprivileged-host-required" } } - let binaries: NativeMariaDbBinaries try { - binaries = await resolveNativeMariaDbBinaries(dependencies) + await resolveNativeMariaDbBinaries(dependencies) } catch { return { status: "unavailable", reason: "trusted-containment-tools-unavailable" } } - let root: OwnedNativeRoot | undefined - let storage: NativeMariaDbStorage | undefined try { - root = await createOwnedNativeRoot() - const mountpoint = ownedNativePath(root, "storage") - await mkdir(mountpoint, { mode: 0o700 }) - storage = await provisionNativeMariaDbStorage(binaries, root, mountpoint, dependencies, nativeMariaDbEnvironment(root.path)) - await writeFile(join(mountpoint, ".readiness"), "ready", { mode: 0o600 }) - await stopNativeMariaDbStorage(storage, binaries, dependencies, root) - storage = undefined - await removeOwnedNativeRoot(root, dependencies) - root = undefined + const evidence: RuntimeServiceEvidence[] = [] + const managed = await provisionMysqlNativeService({ + id: "native-mariadb-readiness", + kind: "mysql", + configuration: { provider: "native", engine: "mariadb" }, + outputs: {}, + }, dependencies, { externalServices: [], externalServiceWritesApproved: false }, evidence) + await managed.release() return { status: "ready" } - } catch { - try { - if (storage && root) await stopNativeMariaDbStorage(storage, binaries, dependencies, root) - if (root) await removeOwnedNativeRoot(root, dependencies) - } catch { + } catch (error) { + if (runtimeServiceEvidenceFromError(error)?.some((entry) => entry.teardown === "failed")) { return { status: "unavailable", reason: "containment-probe-cleanup-failed" } } return { status: "unavailable", reason: "bounded-filesystem-unavailable" } } } +function nativeMariaDbCallerUser(): string { + const user = userInfo().username + if (!user || user.includes("\0")) throw new Error("Native MariaDB caller identity cannot be proven") + return user +} + function assertNativeMariaDbConfiguration(service: WorkspaceRecipeRuntimeService): void { const configuration = service.configuration if (configuration?.provider !== "native" || configuration.engine !== "mariadb") throw new Error("Native MySQL-compatible services require engine=mariadb") diff --git a/tests/native-mariadb-runtime-service.test.ts b/tests/native-mariadb-runtime-service.test.ts index 75a00cfab..a3a3b29bc 100644 --- a/tests/native-mariadb-runtime-service.test.ts +++ b/tests/native-mariadb-runtime-service.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { chmod, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises" import { createServer } from "node:net" -import { tmpdir } from "node:os" +import { tmpdir, userInfo } from "node:os" import { basename, dirname, join } from "node:path" import { assertNativeMariaDbEngines, assertNativeMariaDbFilesystemGeometry, assertNativeMariaDbUnprivilegedHost, nativeMariaDbHostReadiness, provisionRuntimeServices, RuntimeServiceProvisionError, runtimeServicePlan, type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts" import { validateWorkspaceRecipeSemantics } from "../packages/cli/src/recipe-validation.ts" @@ -100,6 +100,8 @@ try { } assert.deepEqual(await nativeMariaDbHostReadiness(dependencies), { status: "ready" }) + assert.ok(calls.some((call) => call.stdin?.includes("CREATE DATABASE")), "readiness provisions a disposable database instead of only writing a mount marker") + assert.ok(calls.some((call) => call.stdin === "SHOW ENGINES;\n"), "readiness proves the daemon storage-engine policy") assert.equal(calls.every((call) => call.env?.PATH === "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"), true, "native commands ignore the caller PATH") const before = new Set((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-"))) @@ -121,6 +123,7 @@ try { const initializerArgs = (await readFile(join(dirname(datadir), "tmp", "initializer-args"), "utf8")).trim().split("\n") assert.ok(initializerArgs.includes("--no-defaults")) assert.ok(initializerArgs.some((arg) => arg === `--datadir=${datadir}`)) + assert.ok(initializerArgs.includes(`--user=${userInfo().username}`), "the initializer runs as the unprivileged FUSE mount owner") assert.ok(calls.some((call) => call.args.some((arg) => arg.endsWith("truncate")) && call.args.includes("268435456")), "the provider creates a fixed 256 MiB backing image") assert.ok(calls.some((call) => call.args.some((arg) => arg.endsWith("mkfs.ext4")) && call.args.includes("-N") && call.args.includes("4096")), "the provider creates a fixed 4096-inode filesystem") assert.ok(daemonArgs.includes("--as=2147483648")) @@ -130,6 +133,7 @@ try { assert.ok(daemonArgs.includes("--nproc=512")) assert.ok(daemonArgs.includes(`--plugin-dir=${join(dirname(datadir), "plugins")}`)) assert.ok(daemonArgs.includes(`--secure-file-priv=${join(dirname(datadir), "files")}`)) + assert.ok(daemonArgs.includes(`--user=${userInfo().username}`), "the daemon runs as the unprivileged FUSE mount owner") assert.ok(daemonArgs.every((arg) => !arg.startsWith("--socket=") || arg.startsWith(`--socket=${dirname(datadir)}/runtime/`))) assert.equal(daemonArgs.includes(password), false) const createUser = calls.find((call) => call.stdin?.includes("CREATE USER")) From 585dac2d0aded337ccc1beaf8c15dd5240f1f37c Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:29:33 +0000 Subject: [PATCH 2/4] fix: harden native MariaDB readiness lifecycle Keep descriptor discovery aligned with real allocation while proving caller ownership, FUSE mount identity, bounded cleanup, and single-flight probing. --- docs/native-mariadb-runtime-service.md | 6 +- packages/cli/src/runtime-services.ts | 57 ++++++++++++++++--- ...ariadb-runtime-service.integration.test.ts | 5 +- tests/native-mariadb-runtime-service.test.ts | 30 +++++++++- 4 files changed, 83 insertions(+), 15 deletions(-) diff --git a/docs/native-mariadb-runtime-service.md b/docs/native-mariadb-runtime-service.md index 3d944c698..20aca92df 100644 --- a/docs/native-mariadb-runtime-service.md +++ b/docs/native-mariadb-runtime-service.md @@ -4,17 +4,17 @@ ## Isolation Contract -- The provider rejects UID 0 and resolves and validates `mariadbd`, `mariadb-install-db`, `mariadb`, util-linux `prlimit`, `truncate`, `mkfs.ext4`, `fuse2fs`, and `fusermount3` identities before allocation. +- The provider rejects UID 0 and resolves and validates `mariadbd`, `mariadb-install-db`, `mariadb`, util-linux `prlimit`, `truncate`, `mkfs.ext4`, `fuse2fs`, and `fusermount3` identities before allocation. The MariaDB username must resolve back to the caller's exact UID and GID. - Default executable discovery ignores the caller's `PATH`, searches fixed system directories, resolves symlinks, and requires every executable and ancestor to be UID-0-owned and not group/other writable. Child processes receive a fixed minimal `PATH`. - Every run gets a mode-`0700` `mkdtemp` root. MariaDB's data directory, temporary directory, socket, PID file, error log, plugin directory, secure-file directory, home, and working directory are all inside the bounded image. - Initialization and the daemon run as the verified unprivileged FUSE mount owner through fixed hard rlimits; administrative clients retain the provider identity and each database command itself begins with `--no-defaults`. No shell or default socket is used. - Administration uses only the private Unix socket. Workloads receive a generated least-privilege `runtime` account over loopback TCP through the existing ephemeral connector-secret channel. - Initialization, FUSE, and daemon commands each run in a new owned process group. Cleanup addresses the retained group, not a PID file; it waits for the complete group to disappear after graceful shutdown, then applies group-wide `SIGTERM` and `SIGKILL` as needed. Linux captures the leader start-time token and revalidates it while the leader is alive. Root device/inode identity and a symlink-free tree are revalidated before recursive removal. - Failures, aborts, timeouts, startup crashes, and partial initialization all enter the same graceful-shutdown, forced-shutdown, wait, and verified-removal state machine. Cleanup failure is terminal and retained in bounded lifecycle evidence. -- Address space is capped at 2 GiB, CPU at 300 seconds, individual daemon files at 128 MiB, open files at 512, and processes/threads at 512. Core files and locked memory are disabled. The datadir is a provider-owned 256 MiB ext4 image formatted with 4,096 inodes and mounted through unprivileged FUSE; device, byte, and inode geometry must be proven before initialization. Hosts without this containment fail closed. +- Address space is capped at 2 GiB, CPU at 300 seconds, individual daemon files at 128 MiB, open files at 512, and processes/threads at 512. Core files and locked memory are disabled. The datadir is a provider-owned 256 MiB ext4 image formatted with 4,096 inodes and mounted through unprivileged FUSE; device, byte, inode, UID/GID, FUSE identity, and `rw,nosuid,nodev,noexec` mount options must be proven before initialization. Hosts without this containment fail closed. - Recipes may declare at most two native services, bounding the aggregate native ceiling to two 2-GiB address spaces, two 256-MiB images, and the corresponding process/file limits. - The daemon uses an empty bounded plugin directory and a bounded `secure-file-priv` directory. Startup fails unless every enabled storage engine is on the fixed local-only allowlist; FEDERATED, CONNECT, SPIDER, S3, and unknown enabled engines are rejected. The runtime account has privileges only on its generated database and cannot install plugins. -- Runtime descriptor discovery distinguishes package support from host availability. It advertises the active native capability only after trusted tools and a full disposable native-service provision, initialization, readiness, teardown, process-group exit, and removal lifecycle succeeds; unavailable reasons are stable codes without private paths. +- Runtime descriptor discovery distinguishes package support from host availability. It advertises the active native capability only after trusted tools and a full disposable native-service provision, initialization, readiness, query, teardown, process-group exit, unmount, and removal lifecycle succeeds; unavailable reasons are stable codes without private paths. Repeated and concurrent discovery in one CLI process shares the same bounded probe; cleanup failures remain retryable. - Cleanup is single-flight for concurrent callers. A failed attempt may be retried, and evidence transitions from `teardown: failed` to a consistent released/completed state only after the retry proves cleanup. - Evidence contains service ID, engine/provider version, lifecycle state, and memory measurements only. It never contains credentials or private absolute paths. diff --git a/packages/cli/src/runtime-services.ts b/packages/cli/src/runtime-services.ts index e1f2d6b07..817e7e460 100644 --- a/packages/cli/src/runtime-services.ts +++ b/packages/cli/src/runtime-services.ts @@ -380,6 +380,9 @@ const NATIVE_MARIADB_CPU_SECONDS = 300 const NATIVE_MARIADB_OPEN_FILES = 512 const NATIVE_MARIADB_PROCESSES = 512 const nativeOwnedProcesses = new Set() +const nativeMariaDbReadinessProbes = new WeakMap>() + +type NativeMariaDbHostReadiness = { status: "ready" | "unavailable"; reason?: string } interface NativeMariaDbBinaries { server: string @@ -587,7 +590,17 @@ export function assertNativeMariaDbUnprivilegedHost(uid = typeof process.getuid if (uid === undefined || uid === 0) throw new Error("Native MariaDB requires a provably unprivileged host identity") } -export async function nativeMariaDbHostReadiness(dependencies: RuntimeServiceDependencies = defaultDependencies): Promise<{ status: "ready" | "unavailable"; reason?: string }> { +export async function nativeMariaDbHostReadiness(dependencies: RuntimeServiceDependencies = defaultDependencies): Promise { + const active = nativeMariaDbReadinessProbes.get(dependencies) + if (active) return await active + const probe = probeNativeMariaDbHostReadiness(dependencies) + nativeMariaDbReadinessProbes.set(dependencies, probe) + const result = await probe + if (result.reason === "containment-probe-cleanup-failed") nativeMariaDbReadinessProbes.delete(dependencies) + return result +} + +async function probeNativeMariaDbHostReadiness(dependencies: RuntimeServiceDependencies): Promise { try { assertNativeMariaDbUnprivilegedHost() } catch { @@ -598,18 +611,18 @@ export async function nativeMariaDbHostReadiness(dependencies: RuntimeServiceDep } catch { return { status: "unavailable", reason: "trusted-containment-tools-unavailable" } } + const evidence: RuntimeServiceEvidence[] = [] try { - const evidence: RuntimeServiceEvidence[] = [] const managed = await provisionMysqlNativeService({ id: "native-mariadb-readiness", kind: "mysql", configuration: { provider: "native", engine: "mariadb" }, outputs: {}, - }, dependencies, { externalServices: [], externalServiceWritesApproved: false }, evidence) + }, dependencies, { externalServices: [], externalServiceWritesApproved: false, nextSmtpServiceOrdinal: () => 1 }, evidence) await managed.release() return { status: "ready" } } catch (error) { - if (runtimeServiceEvidenceFromError(error)?.some((entry) => entry.teardown === "failed")) { + if (evidence.some((entry) => entry.teardown === "failed") || runtimeServiceEvidenceFromError(error)?.some((entry) => entry.teardown === "failed")) { return { status: "unavailable", reason: "containment-probe-cleanup-failed" } } return { status: "unavailable", reason: "bounded-filesystem-unavailable" } @@ -617,9 +630,11 @@ export async function nativeMariaDbHostReadiness(dependencies: RuntimeServiceDep } function nativeMariaDbCallerUser(): string { - const user = userInfo().username - if (!user || user.includes("\0")) throw new Error("Native MariaDB caller identity cannot be proven") - return user + const caller = userInfo() + const uid = process.getuid?.() + const gid = process.getgid?.() + if (!caller.username || caller.username.includes("\0") || caller.uid !== uid || caller.gid !== gid) throw new Error("Native MariaDB caller identity cannot be proven") + return caller.username } function assertNativeMariaDbConfiguration(service: WorkspaceRecipeRuntimeService): void { @@ -746,7 +761,7 @@ async function provisionNativeMariaDbStorage(binaries: NativeMariaDbBinaries, ro const processState = spawnOwnedNativeMariaDb(binaries.limiter, [...storageLimits, "--", binaries.fuse, "-f", "-o", "rw,nosuid,nodev,noexec", image, datadir], environment) try { if (dependencies.verifyNativeFilesystem) await dependencies.verifyNativeFilesystem(root.path, datadir) - else await waitForNativeFilesystemContainment(root, datadir, processState, signal) + else await waitForNativeFilesystemContainment(root, datadir, image, processState, signal) return { process: processState, datadir } } catch (error) { try { @@ -762,7 +777,7 @@ async function provisionNativeMariaDbStorage(binaries: NativeMariaDbBinaries, ro } } -async function waitForNativeFilesystemContainment(root: OwnedNativeRoot, datadir: string, state: OwnedNativeProcess, signal?: AbortSignal): Promise { +async function waitForNativeFilesystemContainment(root: OwnedNativeRoot, datadir: string, image: string, state: OwnedNativeProcess, signal?: AbortSignal): Promise { const deadline = Date.now() + 10_000 while (Date.now() < deadline) { throwIfAborted(signal) @@ -771,6 +786,9 @@ async function waitForNativeFilesystemContainment(root: OwnedNativeRoot, datadir const [directory, filesystem] = await Promise.all([stat(datadir), statfs(datadir)]) const bytes = filesystem.blocks * filesystem.bsize assertNativeMariaDbFilesystemGeometry(directory.dev, root.device, bytes, filesystem.files) + assertNativeMariaDbFilesystemOwnership(directory.uid, directory.gid) + if (process.platform !== "linux") throw new Error("Native MariaDB FUSE mount identity cannot be proven") + assertNativeMariaDbMountInfo(await readFile("/proc/self/mountinfo", "utf8"), datadir, image) return } catch (error) { if (signal?.aborted) throw error @@ -786,6 +804,27 @@ export function assertNativeMariaDbFilesystemGeometry(device: number, rootDevice } } +export function assertNativeMariaDbFilesystemOwnership(uid: number, gid: number, expectedUid = process.getuid?.(), expectedGid = process.getgid?.()): void { + if (expectedUid === undefined || expectedGid === undefined || expectedUid === 0 || uid !== expectedUid || gid !== expectedGid) { + throw new Error("Native MariaDB bounded filesystem ownership is unavailable") + } +} + +export function assertNativeMariaDbMountInfo(mountInfo: string, mountpoint: string, source?: string): void { + const decode = (value: string): string => value.replace(/\\(040|011|012|134)/g, (_match, code: string) => ({ "040": " ", "011": "\t", "012": "\n", "134": "\\" })[code] ?? "") + for (const line of mountInfo.split("\n")) { + const [mountFields, filesystemFields] = line.split(" - ") + if (!mountFields || !filesystemFields) continue + const mount = mountFields.split(" ") + const filesystem = filesystemFields.split(" ") + if (decode(mount[4] ?? "") !== mountpoint) continue + const options = new Set([...(mount[5] ?? "").split(","), ...(filesystem[2] ?? "").split(",")]) + if (!/^fuse(?:\.|$)/.test(filesystem[0] ?? "") || (source !== undefined && decode(filesystem[1] ?? "") !== source) || !["rw", "nosuid", "nodev", "noexec"].every((option) => options.has(option))) break + return + } + throw new Error("Native MariaDB FUSE mount identity or options cannot be proven") +} + async function stopNativeMariaDbStorage(storage: NativeMariaDbStorage, binaries: NativeMariaDbBinaries, dependencies: RuntimeServiceDependencies, root: OwnedNativeRoot | undefined): Promise { if (!storage.process.exited) { if (!root) throw new Error("Native MariaDB storage root is unavailable") diff --git a/tests/native-mariadb-runtime-service.integration.test.ts b/tests/native-mariadb-runtime-service.integration.test.ts index 648aca01c..594f69a9b 100644 --- a/tests/native-mariadb-runtime-service.integration.test.ts +++ b/tests/native-mariadb-runtime-service.integration.test.ts @@ -13,11 +13,14 @@ const service = (id: string, prefix = "DB"): WorkspaceRecipeRuntimeService => ({ configuration: { provider: "native", engine: "mariadb" }, outputs: { host: `${prefix}_HOST`, port: `${prefix}_PORT`, username: `${prefix}_USER`, password: `${prefix}_SECRET`, database: `${prefix}_NAME` }, }) +const rootsBefore = new Set((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-"))) const readiness = await nativeMariaDbHostReadiness() if (readiness.status !== "ready") { + assert.ok(["unprivileged-host-required", "trusted-containment-tools-unavailable", "bounded-filesystem-unavailable", "containment-probe-cleanup-failed"].includes(readiness.reason ?? ""), "native discovery must return a stable fail-closed reason") + const leakedRoots = (await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-") && !rootsBefore.has(name)) + assert.deepEqual(leakedRoots, [], "failed native discovery must not leak private roots") console.log(`native MariaDB runtime service integration skipped: ${readiness.reason ?? "host-unavailable"}`) } else { -const rootsBefore = new Set((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-"))) const concurrentProvisioning = await Promise.allSettled([provisionRuntimeServices([service("native-one")]), provisionRuntimeServices([service("native-two", "SECOND_DB")])]) const successfulPeers = concurrentProvisioning.filter((result): result is PromiseFulfilledResult>> => result.status === "fulfilled").map((result) => result.value) const rejectedPeer = concurrentProvisioning.find((result): result is PromiseRejectedResult => result.status === "rejected") diff --git a/tests/native-mariadb-runtime-service.test.ts b/tests/native-mariadb-runtime-service.test.ts index b1f1baf25..5b6f06955 100644 --- a/tests/native-mariadb-runtime-service.test.ts +++ b/tests/native-mariadb-runtime-service.test.ts @@ -3,7 +3,7 @@ import { chmod, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node: import { createServer } from "node:net" import { tmpdir, userInfo } from "node:os" import { basename, dirname, join } from "node:path" -import { assertNativeMariaDbEngines, assertNativeMariaDbFilesystemGeometry, assertNativeMariaDbUnprivilegedHost, nativeMariaDbHostReadiness, provisionRuntimeServices, RuntimeServiceProvisionError, runtimeServicePlan, type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts" +import { assertNativeMariaDbEngines, assertNativeMariaDbFilesystemGeometry, assertNativeMariaDbFilesystemOwnership, assertNativeMariaDbMountInfo, assertNativeMariaDbUnprivilegedHost, nativeMariaDbHostReadiness, provisionRuntimeServices, RuntimeServiceProvisionError, runtimeServicePlan, type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts" import { validateWorkspaceRecipeSemantics } from "../packages/cli/src/recipe-validation.ts" import { validateWorkspaceRecipeJsonSchema, type WorkspaceRecipeRuntimeService } from "../packages/runtime-core/src/index.ts" @@ -41,6 +41,12 @@ assert.throws(() => assertNativeMariaDbFilesystemGeometry(1, 1, 256 * 1024 * 102 assert.throws(() => assertNativeMariaDbFilesystemGeometry(2, 1, 257 * 1024 * 1024, 4_096), /geometry/) assert.throws(() => assertNativeMariaDbFilesystemGeometry(2, 1, 256 * 1024 * 1024, 4_097), /geometry/) assert.doesNotThrow(() => assertNativeMariaDbFilesystemGeometry(2, 1, 256 * 1024 * 1024, 4_096)) +assert.throws(() => assertNativeMariaDbFilesystemOwnership(1001, 1000, 1000, 1000), /ownership/) +assert.doesNotThrow(() => assertNativeMariaDbFilesystemOwnership(1000, 1000, 1000, 1000)) +assert.doesNotThrow(() => assertNativeMariaDbMountInfo("31 22 0:45 / /tmp/native\\040db rw,nosuid,nodev,noexec - fuse.fuse2fs /tmp/datadir.ext4 rw", "/tmp/native db", "/tmp/datadir.ext4")) +assert.throws(() => assertNativeMariaDbMountInfo("31 22 0:45 / /tmp/native rw,nosuid,nodev,noexec - fuse.fuse2fs /tmp/wrong.ext4 rw", "/tmp/native", "/tmp/datadir.ext4"), /identity/) +assert.throws(() => assertNativeMariaDbMountInfo("31 22 0:45 / /tmp/native rw,nosuid,nodev - fuse.fuse2fs image rw", "/tmp/native"), /options/) +assert.throws(() => assertNativeMariaDbMountInfo("31 22 8:1 / /tmp/native rw,nosuid,nodev,noexec - ext4 image rw", "/tmp/native"), /identity/) assert.doesNotThrow(() => assertNativeMariaDbEngines("InnoDB\tDEFAULT\tTransactional\nFEDERATED\tNO\tOutbound\nMEMORY\tYES\tMemory\n")) assert.throws(() => assertNativeMariaDbEngines("InnoDB\tDEFAULT\tTransactional\nFEDERATED\tYES\tOutbound\n"), /outbound-capable/) assert.throws(() => assertNativeMariaDbEngines("InnoDB\tNO\tTransactional\n"), /cannot be proven/) @@ -114,11 +120,31 @@ try { }, } - assert.deepEqual(await nativeMariaDbHostReadiness(dependencies), { status: "ready" }) + const [firstReadiness, concurrentReadiness] = await Promise.all([nativeMariaDbHostReadiness(dependencies), nativeMariaDbHostReadiness(dependencies)]) + assert.deepEqual(firstReadiness, { status: "ready" }) + assert.deepEqual(concurrentReadiness, { status: "ready" }) assert.ok(calls.some((call) => call.stdin?.includes("CREATE DATABASE")), "readiness provisions a disposable database instead of only writing a mount marker") assert.ok(calls.some((call) => call.stdin === "SHOW ENGINES;\n"), "readiness proves the daemon storage-engine policy") + const readinessAllocations = calls.filter((call) => call.stdin?.includes("CREATE DATABASE")).length + assert.deepEqual(await nativeMariaDbHostReadiness(dependencies), { status: "ready" }) + assert.equal(calls.filter((call) => call.stdin?.includes("CREATE DATABASE")).length, readinessAllocations, "repeated and concurrent descriptor probes share one full lifecycle") assert.equal(calls.every((call) => call.env?.PATH === "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"), true, "native commands ignore the caller PATH") + const failedProbeRoots = new Set((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-"))) + assert.deepEqual(await nativeMariaDbHostReadiness({ ...dependencies, async verifyNativeFilesystem() { throw new Error("mount is not writable by caller") } }), { status: "unavailable", reason: "bounded-filesystem-unavailable" }) + assert.deepEqual((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-") && !failedProbeRoots.has(name)), [], "an incompatible mount cannot produce false-ready or leak its root") + + const failedInitializerScript = `${nodeShebang} +if (process.argv.includes('--help')) { console.log('Usage: mariadb-install-db --auth-root-authentication-method --skip-test-db'); process.exit(0); } +process.exit(1); +` + await writeFile(join(fixture, "mariadb-install-db"), failedInitializerScript) + await chmod(join(fixture, "mariadb-install-db"), 0o700) + assert.deepEqual(await nativeMariaDbHostReadiness({ ...dependencies }), { status: "unavailable", reason: "bounded-filesystem-unavailable" }) + assert.deepEqual((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-") && !failedProbeRoots.has(name)), [], "initializer failure cannot produce false-ready or leak its root") + await writeFile(join(fixture, "mariadb-install-db"), initializerScript) + await chmod(join(fixture, "mariadb-install-db"), 0o700) + const before = new Set((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-"))) const provisioned = await provisionRuntimeServices([service], { dependencies }) const password = Buffer.alloc(24, 0x5a).toString("base64url") From 4c2c26dfb947b4b283bf59957ee75be3ed5f5fd2 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:19:10 +0000 Subject: [PATCH 3/4] fix: secure native MariaDB descriptor cleanup Prove stable real and effective identities before host access, retain failed readiness cleanup handles, and abort descriptor probes without leaking native resources. --- docs/native-mariadb-runtime-service.md | 5 +- packages/cli/src/commands/discovery.ts | 28 +++- packages/cli/src/runtime-services.ts | 121 +++++++++++++----- ...ariadb-runtime-service.integration.test.ts | 2 +- tests/native-mariadb-runtime-service.test.ts | 70 +++++++++- 5 files changed, 182 insertions(+), 44 deletions(-) diff --git a/docs/native-mariadb-runtime-service.md b/docs/native-mariadb-runtime-service.md index 20aca92df..ba7da6577 100644 --- a/docs/native-mariadb-runtime-service.md +++ b/docs/native-mariadb-runtime-service.md @@ -4,7 +4,7 @@ ## Isolation Contract -- The provider rejects UID 0 and resolves and validates `mariadbd`, `mariadb-install-db`, `mariadb`, util-linux `prlimit`, `truncate`, `mkfs.ext4`, `fuse2fs`, and `fusermount3` identities before allocation. The MariaDB username must resolve back to the caller's exact UID and GID. +- Before resolving or executing any host tool, the provider requires available real/effective UID and GID APIs, rejects every root-valued identity, and requires real/effective IDs to match. It then resolves and validates `mariadbd`, `mariadb-install-db`, `mariadb`, util-linux `prlimit`, `truncate`, `mkfs.ext4`, `fuse2fs`, and `fusermount3` identities before allocation. The MariaDB username must resolve back to the same proven UID and GID. - Default executable discovery ignores the caller's `PATH`, searches fixed system directories, resolves symlinks, and requires every executable and ancestor to be UID-0-owned and not group/other writable. Child processes receive a fixed minimal `PATH`. - Every run gets a mode-`0700` `mkdtemp` root. MariaDB's data directory, temporary directory, socket, PID file, error log, plugin directory, secure-file directory, home, and working directory are all inside the bounded image. - Initialization and the daemon run as the verified unprivileged FUSE mount owner through fixed hard rlimits; administrative clients retain the provider identity and each database command itself begins with `--no-defaults`. No shell or default socket is used. @@ -14,7 +14,8 @@ - Address space is capped at 2 GiB, CPU at 300 seconds, individual daemon files at 128 MiB, open files at 512, and processes/threads at 512. Core files and locked memory are disabled. The datadir is a provider-owned 256 MiB ext4 image formatted with 4,096 inodes and mounted through unprivileged FUSE; device, byte, inode, UID/GID, FUSE identity, and `rw,nosuid,nodev,noexec` mount options must be proven before initialization. Hosts without this containment fail closed. - Recipes may declare at most two native services, bounding the aggregate native ceiling to two 2-GiB address spaces, two 256-MiB images, and the corresponding process/file limits. - The daemon uses an empty bounded plugin directory and a bounded `secure-file-priv` directory. Startup fails unless every enabled storage engine is on the fixed local-only allowlist; FEDERATED, CONNECT, SPIDER, S3, and unknown enabled engines are rejected. The runtime account has privileges only on its generated database and cannot install plugins. -- Runtime descriptor discovery distinguishes package support from host availability. It advertises the active native capability only after trusted tools and a full disposable native-service provision, initialization, readiness, query, teardown, process-group exit, unmount, and removal lifecycle succeeds; unavailable reasons are stable codes without private paths. Repeated and concurrent discovery in one CLI process shares the same bounded probe; cleanup failures remain retryable. +- Runtime descriptor discovery distinguishes package support from host availability. It advertises the active native capability only after trusted tools and a full disposable native-service provision, initialization, readiness, query, teardown, process-group exit, unmount, and removal lifecycle succeeds; unavailable reasons are stable codes without private paths. Concurrent discovery shares only the active bounded probe; settled results are immediately invalidated so host capability changes are observed. A failed teardown retains its exact cleanup handle and must be retried to completion before another allocation can begin. +- Descriptor execution applies a bounded timeout and scoped `SIGINT`, `SIGTERM`, and `SIGHUP` abort handlers. Handlers are removed after execution, and interruption follows the same retained cleanup path before descriptor discovery settles. - Cleanup is single-flight for concurrent callers. A failed attempt may be retried, and evidence transitions from `teardown: failed` to a consistent released/completed state only after the retry proves cleanup. - Evidence contains service ID, engine/provider version, lifecycle state, and memory measurements only. It never contains credentials or private absolute paths. diff --git a/packages/cli/src/commands/discovery.ts b/packages/cli/src/commands/discovery.ts index 4c9871e85..bd71248a2 100644 --- a/packages/cli/src/commands/discovery.ts +++ b/packages/cli/src/commands/discovery.ts @@ -5,6 +5,14 @@ import { cliRuntimeBackendRecipePolicy, listCliRecipeCommandDefinitions, listCli import { nativeMariaDbHostReadiness } from "../runtime-services.js" import { playwrightBrowserReadiness } from "@automattic/wp-codebox-playground" +const RUNTIME_DESCRIPTOR_TIMEOUT_MS = 120_000 +const RUNTIME_DESCRIPTOR_SIGNALS: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"] + +interface RuntimeDescriptorSignalTarget { + on(signal: NodeJS.Signals, handler: (signal: NodeJS.Signals) => void): unknown + off(signal: NodeJS.Signals, handler: (signal: NodeJS.Signals) => void): unknown +} + interface CommandCatalogOutput { schema: "wp-codebox/command-catalog/v1" commands: Array> @@ -42,7 +50,7 @@ export async function runRecipeSchemaCommand(args: string[]): Promise { export async function runRuntimeDescriptorCommand(args: string[]): Promise { const json = parseDiscoveryJsonOption(args) - const output = await runtimeDescriptorOutput() + const output = await withRuntimeDescriptorInterruption((signal) => runtimeDescriptorOutput(signal)) if (!json) { printRuntimeDescriptorHumanOutput(output) return 0 @@ -52,6 +60,20 @@ export async function runRuntimeDescriptorCommand(args: string[]): Promise(run: (signal: AbortSignal) => Promise, target: RuntimeDescriptorSignalTarget = process, timeoutMs = RUNTIME_DESCRIPTOR_TIMEOUT_MS): Promise { + const controller = new AbortController() + const interrupt = () => controller.abort() + for (const signal of RUNTIME_DESCRIPTOR_SIGNALS) target.on(signal, interrupt) + const timer = setTimeout(interrupt, timeoutMs) + timer.unref() + try { + return await run(controller.signal) + } finally { + clearTimeout(timer) + for (const signal of RUNTIME_DESCRIPTOR_SIGNALS) target.off(signal, interrupt) + } +} + function parseDiscoveryJsonOption(args: string[]): boolean { let json = false for (const arg of args) { @@ -111,6 +133,6 @@ function recipeSchemaOutput(): RecipeSchemaOutput { } } -async function runtimeDescriptorOutput(): Promise { - return runtimeDescriptor({ nativeMariaDb: await nativeMariaDbHostReadiness(), browserRuntime: await playwrightBrowserReadiness() }) +async function runtimeDescriptorOutput(signal: AbortSignal): Promise { + return runtimeDescriptor({ nativeMariaDb: await nativeMariaDbHostReadiness(undefined, signal), browserRuntime: await playwrightBrowserReadiness() }) } diff --git a/packages/cli/src/runtime-services.ts b/packages/cli/src/runtime-services.ts index 817e7e460..3d59cf9b7 100644 --- a/packages/cli/src/runtime-services.ts +++ b/packages/cli/src/runtime-services.ts @@ -110,6 +110,7 @@ export interface RuntimeServiceDependencies { signalNativeProcess?: (child: ChildProcess, signal: NodeJS.Signals) => boolean verifyNativeFilesystem?: (root: string, datadir: string) => Promise request?: (url: string, options: { method: "GET" | "DELETE"; signal?: AbortSignal }) => Promise<{ status: number; body?: string }> + nativeIdentity?: () => NativeMariaDbHostIdentity } export interface RuntimeServiceProvider { @@ -126,6 +127,8 @@ interface RuntimeServiceProvisionContext { externalServices: WorkspaceRecipeExternalServiceBoundary[] externalServiceWritesApproved: boolean nextSmtpServiceOrdinal(): number + nativeIdentity?: NativeMariaDbHostIdentity + retainNativeCleanup?: (release: () => Promise, evidence: RuntimeServiceEvidence) => void } export interface ProvisionRuntimeServicesOptions { @@ -380,10 +383,22 @@ const NATIVE_MARIADB_CPU_SECONDS = 300 const NATIVE_MARIADB_OPEN_FILES = 512 const NATIVE_MARIADB_PROCESSES = 512 const nativeOwnedProcesses = new Set() -const nativeMariaDbReadinessProbes = new WeakMap>() +const nativeMariaDbReadinessStates = new WeakMap() type NativeMariaDbHostReadiness = { status: "ready" | "unavailable"; reason?: string } +export interface NativeMariaDbHostIdentity { + uid: number + euid: number + gid: number + egid: number +} + +interface NativeMariaDbReadinessState { + active?: Promise + retained?: { release: () => Promise; evidence: RuntimeServiceEvidence } +} + interface NativeMariaDbBinaries { server: string initialize: string @@ -455,10 +470,11 @@ async function provisionMysqlNativeService(service: WorkspaceRecipeRuntimeServic }) return cleanupPromise } + context.retainNativeCleanup?.(cleanup, evidence) try { assertNativeMariaDbConfiguration(service) - assertNativeMariaDbUnprivilegedHost() + const identity = context.nativeIdentity ?? nativeMariaDbHostIdentity(dependencies) throwIfAborted(signal) const binaries = await resolveNativeMariaDbBinaries(dependencies, signal) binariesForCleanup = binaries @@ -469,8 +485,8 @@ async function provisionMysqlNativeService(service: WorkspaceRecipeRuntimeServic const storageEnvironment = nativeMariaDbEnvironment(root.path) // MariaDB otherwise selects its package service account, which cannot write // through a FUSE filesystem owned by the unprivileged provider caller. - const userArgument = [`--user=${nativeMariaDbCallerUser()}`] - storage = await provisionNativeMariaDbStorage(binaries, root, storageRoot, dependencies, storageEnvironment, signal) + const userArgument = [`--user=${nativeMariaDbCallerUser(identity)}`] + storage = await provisionNativeMariaDbStorage(binaries, root, storageRoot, identity, dependencies, storageEnvironment, signal) const datadir = join(storageRoot, "database") const runtimeDirectory = join(storageRoot, "runtime") const temporaryDirectory = join(storageRoot, "tmp") @@ -586,29 +602,53 @@ export function assertNativeMariaDbEngines(output: string): void { if (!enabled.has("innodb")) throw new Error("Native MariaDB engine isolation cannot be proven") } -export function assertNativeMariaDbUnprivilegedHost(uid = typeof process.getuid === "function" ? process.getuid() : undefined): void { - if (uid === undefined || uid === 0) throw new Error("Native MariaDB requires a provably unprivileged host identity") +export function assertNativeMariaDbUnprivilegedHost(identity: Partial): asserts identity is NativeMariaDbHostIdentity { + const values = [identity.uid, identity.euid, identity.gid, identity.egid] + if (values.some((value) => value === undefined || !Number.isSafeInteger(value) || value < 0) || values.some((value) => value === 0) || identity.uid !== identity.euid || identity.gid !== identity.egid) { + throw new Error("Native MariaDB requires a stable unprivileged host identity") + } } -export async function nativeMariaDbHostReadiness(dependencies: RuntimeServiceDependencies = defaultDependencies): Promise { - const active = nativeMariaDbReadinessProbes.get(dependencies) - if (active) return await active - const probe = probeNativeMariaDbHostReadiness(dependencies) - nativeMariaDbReadinessProbes.set(dependencies, probe) - const result = await probe - if (result.reason === "containment-probe-cleanup-failed") nativeMariaDbReadinessProbes.delete(dependencies) - return result +export async function nativeMariaDbHostReadiness(dependencies: RuntimeServiceDependencies = defaultDependencies, signal?: AbortSignal): Promise { + let state = nativeMariaDbReadinessStates.get(dependencies) + if (!state) { + state = {} + nativeMariaDbReadinessStates.set(dependencies, state) + } + if (state.active) return await state.active + const probe = probeNativeMariaDbHostReadiness(dependencies, state, signal) + state.active = probe + try { + return await probe + } finally { + if (state.active === probe) state.active = undefined + if (!state.retained) nativeMariaDbReadinessStates.delete(dependencies) + } } -async function probeNativeMariaDbHostReadiness(dependencies: RuntimeServiceDependencies): Promise { +async function probeNativeMariaDbHostReadiness(dependencies: RuntimeServiceDependencies, state: NativeMariaDbReadinessState, signal?: AbortSignal): Promise { + if (state.retained) { + const retained = state.retained + try { + await retained.release() + state.retained = undefined + if (signal?.aborted) return { status: "unavailable", reason: "containment-probe-interrupted" } + return retained.evidence.readiness === "ready" ? { status: "ready" } : { status: "unavailable", reason: "bounded-filesystem-unavailable" } + } catch { + return { status: "unavailable", reason: "containment-probe-cleanup-failed" } + } + } + if (signal?.aborted) return { status: "unavailable", reason: "containment-probe-interrupted" } + let identity: NativeMariaDbHostIdentity try { - assertNativeMariaDbUnprivilegedHost() + identity = nativeMariaDbHostIdentity(dependencies) } catch { return { status: "unavailable", reason: "unprivileged-host-required" } } try { - await resolveNativeMariaDbBinaries(dependencies) + await resolveNativeMariaDbBinaries(dependencies, signal) } catch { + if (signal?.aborted) return { status: "unavailable", reason: "containment-probe-interrupted" } return { status: "unavailable", reason: "trusted-containment-tools-unavailable" } } const evidence: RuntimeServiceEvidence[] = [] @@ -618,22 +658,42 @@ async function probeNativeMariaDbHostReadiness(dependencies: RuntimeServiceDepen kind: "mysql", configuration: { provider: "native", engine: "mariadb" }, outputs: {}, - }, dependencies, { externalServices: [], externalServiceWritesApproved: false, nextSmtpServiceOrdinal: () => 1 }, evidence) + }, dependencies, { + externalServices: [], + externalServiceWritesApproved: false, + nextSmtpServiceOrdinal: () => 1, + nativeIdentity: identity, + retainNativeCleanup: (release, retainedEvidence) => { state.retained = { release, evidence: retainedEvidence } }, + signal, + }, evidence) await managed.release() + state.retained = undefined return { status: "ready" } } catch (error) { + if (signal?.aborted && !state.retained) return { status: "unavailable", reason: "containment-probe-interrupted" } if (evidence.some((entry) => entry.teardown === "failed") || runtimeServiceEvidenceFromError(error)?.some((entry) => entry.teardown === "failed")) { return { status: "unavailable", reason: "containment-probe-cleanup-failed" } } + state.retained = undefined + if (signal?.aborted) return { status: "unavailable", reason: "containment-probe-interrupted" } return { status: "unavailable", reason: "bounded-filesystem-unavailable" } } } -function nativeMariaDbCallerUser(): string { +function nativeMariaDbHostIdentity(dependencies: RuntimeServiceDependencies): NativeMariaDbHostIdentity { + const identity = dependencies.nativeIdentity?.() ?? { + uid: typeof process.getuid === "function" ? process.getuid() : undefined, + euid: typeof process.geteuid === "function" ? process.geteuid() : undefined, + gid: typeof process.getgid === "function" ? process.getgid() : undefined, + egid: typeof process.getegid === "function" ? process.getegid() : undefined, + } + assertNativeMariaDbUnprivilegedHost(identity) + return identity +} + +function nativeMariaDbCallerUser(identity: NativeMariaDbHostIdentity): string { const caller = userInfo() - const uid = process.getuid?.() - const gid = process.getgid?.() - if (!caller.username || caller.username.includes("\0") || caller.uid !== uid || caller.gid !== gid) throw new Error("Native MariaDB caller identity cannot be proven") + if (!caller.username || caller.username.includes("\0") || caller.uid !== identity.uid || caller.gid !== identity.gid) throw new Error("Native MariaDB caller identity cannot be proven") return caller.username } @@ -750,18 +810,15 @@ async function executeOwnedNativeLimited(dependencies: RuntimeServiceDependencie } } -async function provisionNativeMariaDbStorage(binaries: NativeMariaDbBinaries, root: OwnedNativeRoot, datadir: string, dependencies: RuntimeServiceDependencies, environment: NodeJS.ProcessEnv, signal?: AbortSignal): Promise { +async function provisionNativeMariaDbStorage(binaries: NativeMariaDbBinaries, root: OwnedNativeRoot, datadir: string, identity: NativeMariaDbHostIdentity, dependencies: RuntimeServiceDependencies, environment: NodeJS.ProcessEnv, signal?: AbortSignal): Promise { const image = ownedNativePath(root, "datadir.ext4") const storageLimits = nativeMariaDbLimitArguments(NATIVE_MARIADB_DATADIR_BYTES) - const uid = process.getuid?.() - const gid = process.getgid?.() - if (uid === undefined || gid === undefined || uid === 0) throw new Error("Native MariaDB bounded filesystem requires an unprivileged POSIX identity") await dependencies.execute(binaries.limiter, [...storageLimits, "--", binaries.truncate, "--size", String(NATIVE_MARIADB_DATADIR_BYTES), image], { env: environment, signal, timeout: 10_000, maxOutputBytes: NATIVE_MARIADB_PROCESS_OUTPUT_LIMIT_BYTES }) - await dependencies.execute(binaries.limiter, [...storageLimits, "--", binaries.mkfs, "-q", "-F", "-N", String(NATIVE_MARIADB_DATADIR_INODES), "-E", `root_owner=${uid}:${gid}`, image], { env: environment, signal, timeout: 30_000, maxOutputBytes: NATIVE_MARIADB_PROCESS_OUTPUT_LIMIT_BYTES }) + await dependencies.execute(binaries.limiter, [...storageLimits, "--", binaries.mkfs, "-q", "-F", "-N", String(NATIVE_MARIADB_DATADIR_INODES), "-E", `root_owner=${identity.uid}:${identity.gid}`, image], { env: environment, signal, timeout: 30_000, maxOutputBytes: NATIVE_MARIADB_PROCESS_OUTPUT_LIMIT_BYTES }) const processState = spawnOwnedNativeMariaDb(binaries.limiter, [...storageLimits, "--", binaries.fuse, "-f", "-o", "rw,nosuid,nodev,noexec", image, datadir], environment) try { if (dependencies.verifyNativeFilesystem) await dependencies.verifyNativeFilesystem(root.path, datadir) - else await waitForNativeFilesystemContainment(root, datadir, image, processState, signal) + else await waitForNativeFilesystemContainment(root, datadir, image, identity, processState, signal) return { process: processState, datadir } } catch (error) { try { @@ -777,7 +834,7 @@ async function provisionNativeMariaDbStorage(binaries: NativeMariaDbBinaries, ro } } -async function waitForNativeFilesystemContainment(root: OwnedNativeRoot, datadir: string, image: string, state: OwnedNativeProcess, signal?: AbortSignal): Promise { +async function waitForNativeFilesystemContainment(root: OwnedNativeRoot, datadir: string, image: string, identity: NativeMariaDbHostIdentity, state: OwnedNativeProcess, signal?: AbortSignal): Promise { const deadline = Date.now() + 10_000 while (Date.now() < deadline) { throwIfAborted(signal) @@ -786,7 +843,7 @@ async function waitForNativeFilesystemContainment(root: OwnedNativeRoot, datadir const [directory, filesystem] = await Promise.all([stat(datadir), statfs(datadir)]) const bytes = filesystem.blocks * filesystem.bsize assertNativeMariaDbFilesystemGeometry(directory.dev, root.device, bytes, filesystem.files) - assertNativeMariaDbFilesystemOwnership(directory.uid, directory.gid) + assertNativeMariaDbFilesystemOwnership(directory.uid, directory.gid, identity) if (process.platform !== "linux") throw new Error("Native MariaDB FUSE mount identity cannot be proven") assertNativeMariaDbMountInfo(await readFile("/proc/self/mountinfo", "utf8"), datadir, image) return @@ -804,8 +861,8 @@ export function assertNativeMariaDbFilesystemGeometry(device: number, rootDevice } } -export function assertNativeMariaDbFilesystemOwnership(uid: number, gid: number, expectedUid = process.getuid?.(), expectedGid = process.getgid?.()): void { - if (expectedUid === undefined || expectedGid === undefined || expectedUid === 0 || uid !== expectedUid || gid !== expectedGid) { +export function assertNativeMariaDbFilesystemOwnership(uid: number, gid: number, identity: NativeMariaDbHostIdentity): void { + if (uid !== identity.uid || gid !== identity.gid) { throw new Error("Native MariaDB bounded filesystem ownership is unavailable") } } diff --git a/tests/native-mariadb-runtime-service.integration.test.ts b/tests/native-mariadb-runtime-service.integration.test.ts index 594f69a9b..62fcd8759 100644 --- a/tests/native-mariadb-runtime-service.integration.test.ts +++ b/tests/native-mariadb-runtime-service.integration.test.ts @@ -16,7 +16,7 @@ const service = (id: string, prefix = "DB"): WorkspaceRecipeRuntimeService => ({ const rootsBefore = new Set((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-"))) const readiness = await nativeMariaDbHostReadiness() if (readiness.status !== "ready") { - assert.ok(["unprivileged-host-required", "trusted-containment-tools-unavailable", "bounded-filesystem-unavailable", "containment-probe-cleanup-failed"].includes(readiness.reason ?? ""), "native discovery must return a stable fail-closed reason") + assert.ok(["unprivileged-host-required", "trusted-containment-tools-unavailable", "bounded-filesystem-unavailable", "containment-probe-cleanup-failed", "containment-probe-interrupted"].includes(readiness.reason ?? ""), "native discovery must return a stable fail-closed reason") const leakedRoots = (await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-") && !rootsBefore.has(name)) assert.deepEqual(leakedRoots, [], "failed native discovery must not leak private roots") console.log(`native MariaDB runtime service integration skipped: ${readiness.reason ?? "host-unavailable"}`) diff --git a/tests/native-mariadb-runtime-service.test.ts b/tests/native-mariadb-runtime-service.test.ts index 5b6f06955..26ca67cce 100644 --- a/tests/native-mariadb-runtime-service.test.ts +++ b/tests/native-mariadb-runtime-service.test.ts @@ -1,9 +1,11 @@ import assert from "node:assert/strict" +import { EventEmitter } from "node:events" import { chmod, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises" import { createServer } from "node:net" import { tmpdir, userInfo } from "node:os" import { basename, dirname, join } from "node:path" -import { assertNativeMariaDbEngines, assertNativeMariaDbFilesystemGeometry, assertNativeMariaDbFilesystemOwnership, assertNativeMariaDbMountInfo, assertNativeMariaDbUnprivilegedHost, nativeMariaDbHostReadiness, provisionRuntimeServices, RuntimeServiceProvisionError, runtimeServicePlan, type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts" +import { withRuntimeDescriptorInterruption } from "../packages/cli/src/commands/discovery.ts" +import { assertNativeMariaDbEngines, assertNativeMariaDbFilesystemGeometry, assertNativeMariaDbFilesystemOwnership, assertNativeMariaDbMountInfo, assertNativeMariaDbUnprivilegedHost, nativeMariaDbHostReadiness, provisionRuntimeServices, RuntimeServiceProvisionError, runtimeServicePlan, type NativeMariaDbHostIdentity, type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts" import { validateWorkspaceRecipeSemantics } from "../packages/cli/src/recipe-validation.ts" import { validateWorkspaceRecipeJsonSchema, type WorkspaceRecipeRuntimeService } from "../packages/runtime-core/src/index.ts" @@ -35,14 +37,18 @@ assert.equal(validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-r const forbiddenConfiguration = { ...service, configuration: { ...service.configuration, hostEnv: "PRODUCTION_DB_HOST", image: "mariadb:latest" } } const forbiddenIssues = await validateWorkspaceRecipeSemantics({ schema: "wp-codebox/workspace-recipe/v1", inputs: { services: [forbiddenConfiguration] }, workflow: { steps: [{ command: "wordpress.run-php" }] } }, "recipe.json") assert.equal(forbiddenIssues.filter((issue) => issue.code === "unsupported-native-runtime-service-option").length, 2) -assert.throws(() => assertNativeMariaDbUnprivilegedHost(0), /unprivileged/) -assert.doesNotThrow(() => assertNativeMariaDbUnprivilegedHost(1000)) +const hostIdentity: NativeMariaDbHostIdentity = { uid: process.getuid!(), euid: process.geteuid!(), gid: process.getgid!(), egid: process.getegid!() } +assert.throws(() => assertNativeMariaDbUnprivilegedHost({ uid: 1000, euid: 0, gid: 1000, egid: 1000 }), /unprivileged/) +assert.throws(() => assertNativeMariaDbUnprivilegedHost({ uid: 1000, euid: 1001, gid: 1000, egid: 1000 }), /stable/) +assert.throws(() => assertNativeMariaDbUnprivilegedHost({ uid: 1000, euid: 1000, gid: 1000, egid: 1001 }), /stable/) +assert.throws(() => assertNativeMariaDbUnprivilegedHost({ uid: 1000, euid: 1000, gid: 1000 }), /stable/) +assert.doesNotThrow(() => assertNativeMariaDbUnprivilegedHost(hostIdentity)) assert.throws(() => assertNativeMariaDbFilesystemGeometry(1, 1, 256 * 1024 * 1024, 4_096), /geometry/) assert.throws(() => assertNativeMariaDbFilesystemGeometry(2, 1, 257 * 1024 * 1024, 4_096), /geometry/) assert.throws(() => assertNativeMariaDbFilesystemGeometry(2, 1, 256 * 1024 * 1024, 4_097), /geometry/) assert.doesNotThrow(() => assertNativeMariaDbFilesystemGeometry(2, 1, 256 * 1024 * 1024, 4_096)) -assert.throws(() => assertNativeMariaDbFilesystemOwnership(1001, 1000, 1000, 1000), /ownership/) -assert.doesNotThrow(() => assertNativeMariaDbFilesystemOwnership(1000, 1000, 1000, 1000)) +assert.throws(() => assertNativeMariaDbFilesystemOwnership(hostIdentity.uid + 1, hostIdentity.gid, hostIdentity), /ownership/) +assert.doesNotThrow(() => assertNativeMariaDbFilesystemOwnership(hostIdentity.uid, hostIdentity.gid, hostIdentity)) assert.doesNotThrow(() => assertNativeMariaDbMountInfo("31 22 0:45 / /tmp/native\\040db rw,nosuid,nodev,noexec - fuse.fuse2fs /tmp/datadir.ext4 rw", "/tmp/native db", "/tmp/datadir.ext4")) assert.throws(() => assertNativeMariaDbMountInfo("31 22 0:45 / /tmp/native rw,nosuid,nodev,noexec - fuse.fuse2fs /tmp/wrong.ext4 rw", "/tmp/native", "/tmp/datadir.ext4"), /identity/) assert.throws(() => assertNativeMariaDbMountInfo("31 22 0:45 / /tmp/native rw,nosuid,nodev - fuse.fuse2fs image rw", "/tmp/native"), /options/) @@ -120,14 +126,28 @@ try { }, } + const identityProbeRoots = new Set((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-"))) + for (const identity of [ + { ...hostIdentity, euid: 0 }, + { ...hostIdentity, egid: 0 }, + { ...hostIdentity, euid: hostIdentity.uid + 1 }, + { ...hostIdentity, egid: hostIdentity.gid + 1 }, + ]) { + const callsBeforeIdentityProbe = calls.length + assert.deepEqual(await nativeMariaDbHostReadiness({ ...dependencies, nativeIdentity: () => identity }), { status: "unavailable", reason: "unprivileged-host-required" }) + assert.equal(calls.length, callsBeforeIdentityProbe, "invalid effective identity must execute zero host commands") + } + assert.deepEqual((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-") && !identityProbeRoots.has(name)), [], "invalid effective identity must allocate zero private roots") + const [firstReadiness, concurrentReadiness] = await Promise.all([nativeMariaDbHostReadiness(dependencies), nativeMariaDbHostReadiness(dependencies)]) assert.deepEqual(firstReadiness, { status: "ready" }) assert.deepEqual(concurrentReadiness, { status: "ready" }) assert.ok(calls.some((call) => call.stdin?.includes("CREATE DATABASE")), "readiness provisions a disposable database instead of only writing a mount marker") assert.ok(calls.some((call) => call.stdin === "SHOW ENGINES;\n"), "readiness proves the daemon storage-engine policy") const readinessAllocations = calls.filter((call) => call.stdin?.includes("CREATE DATABASE")).length + assert.equal(readinessAllocations, 1, "concurrent descriptor probes share one in-flight allocation") assert.deepEqual(await nativeMariaDbHostReadiness(dependencies), { status: "ready" }) - assert.equal(calls.filter((call) => call.stdin?.includes("CREATE DATABASE")).length, readinessAllocations, "repeated and concurrent descriptor probes share one full lifecycle") + assert.equal(calls.filter((call) => call.stdin?.includes("CREATE DATABASE")).length, readinessAllocations + 1, "settled descriptor readiness is not cached") assert.equal(calls.every((call) => call.env?.PATH === "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"), true, "native commands ignore the caller PATH") const failedProbeRoots = new Set((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-"))) @@ -145,6 +165,44 @@ process.exit(1); await writeFile(join(fixture, "mariadb-install-db"), initializerScript) await chmod(join(fixture, "mariadb-install-db"), 0o700) + let transientMountFailure = true + const transientDependencies: RuntimeServiceDependencies = { ...dependencies, async verifyNativeFilesystem() { + if (transientMountFailure) throw new Error("temporary mount policy failure") + } } + assert.deepEqual(await nativeMariaDbHostReadiness(transientDependencies), { status: "unavailable", reason: "bounded-filesystem-unavailable" }) + transientMountFailure = false + assert.deepEqual(await nativeMariaDbHostReadiness(transientDependencies), { status: "ready" }, "temporary unavailable readiness must recover on the next probe") + + const cleanupRetryRoots = new Set((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-"))) + let readinessRemovalAttempts = 0 + const cleanupRetryDependencies: RuntimeServiceDependencies = { ...dependencies, async removeNativeRoot(root) { + readinessRemovalAttempts += 1 + if (readinessRemovalAttempts === 1) throw new Error("temporary descriptor cleanup failure") + await rm(root, { recursive: true, force: false }) + } } + const allocationsBeforeCleanupRetry = calls.filter((call) => call.stdin?.includes("CREATE DATABASE")).length + assert.deepEqual(await nativeMariaDbHostReadiness(cleanupRetryDependencies), { status: "unavailable", reason: "containment-probe-cleanup-failed" }) + assert.equal(calls.filter((call) => call.stdin?.includes("CREATE DATABASE")).length, allocationsBeforeCleanupRetry + 1) + assert.deepEqual(await nativeMariaDbHostReadiness(cleanupRetryDependencies), { status: "ready" }) + assert.equal(readinessRemovalAttempts, 2, "readiness retries the retained cleanup closure") + assert.equal(calls.filter((call) => call.stdin?.includes("CREATE DATABASE")).length, allocationsBeforeCleanupRetry + 1, "cleanup retry must not create a second readiness allocation") + assert.deepEqual((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-") && !cleanupRetryRoots.has(name)), [], "retained readiness cleanup eventually removes its exact root") + + const interruptedRoots = new Set((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-"))) + const descriptorAbort = new AbortController() + const interruptedDependencies: RuntimeServiceDependencies = { ...dependencies, async verifyNativeFilesystem(root, datadir) { + await dependencies.verifyNativeFilesystem?.(root, datadir) + descriptorAbort.abort() + } } + assert.deepEqual(await nativeMariaDbHostReadiness(interruptedDependencies, descriptorAbort.signal), { status: "unavailable", reason: "containment-probe-interrupted" }) + assert.deepEqual((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-") && !interruptedRoots.has(name)), [], "interrupted descriptor discovery releases all native roots") + + const signalTarget = new EventEmitter() + const interruptedDescriptor = withRuntimeDescriptorInterruption(async (signal) => await new Promise((resolve) => signal.addEventListener("abort", () => resolve(signal.aborted), { once: true })), signalTarget, 10_000) + signalTarget.emit("SIGTERM", "SIGTERM") + assert.equal(await interruptedDescriptor, true) + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) assert.equal(signalTarget.listenerCount(signal), 0, `descriptor removes its ${signal} cleanup handler`) + const before = new Set((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-"))) const provisioned = await provisionRuntimeServices([service], { dependencies }) const password = Buffer.alloc(24, 0x5a).toString("base64url") From ac67b02fb87e9875e85dc03529c7c13feb963b93 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:11:34 +0000 Subject: [PATCH 4/4] fix: isolate descriptor cancellation waiters Keep shared native probes independent from caller signals while making descriptor interruption abort browser/native phases, suppress output, and return deterministic nonzero statuses after owned cleanup. --- docs/native-mariadb-runtime-service.md | 3 +- packages/cli/src/commands/discovery.ts | 58 +++++++-- packages/cli/src/runtime-services.ts | 69 +++++++++-- .../src/playwright-browser-provenance.ts | 22 +++- tests/native-mariadb-runtime-service.test.ts | 112 +++++++++++++++++- tests/playwright-browser-provenance.test.ts | 11 ++ 6 files changed, 243 insertions(+), 32 deletions(-) diff --git a/docs/native-mariadb-runtime-service.md b/docs/native-mariadb-runtime-service.md index ba7da6577..099da51bc 100644 --- a/docs/native-mariadb-runtime-service.md +++ b/docs/native-mariadb-runtime-service.md @@ -15,7 +15,8 @@ - Recipes may declare at most two native services, bounding the aggregate native ceiling to two 2-GiB address spaces, two 256-MiB images, and the corresponding process/file limits. - The daemon uses an empty bounded plugin directory and a bounded `secure-file-priv` directory. Startup fails unless every enabled storage engine is on the fixed local-only allowlist; FEDERATED, CONNECT, SPIDER, S3, and unknown enabled engines are rejected. The runtime account has privileges only on its generated database and cannot install plugins. - Runtime descriptor discovery distinguishes package support from host availability. It advertises the active native capability only after trusted tools and a full disposable native-service provision, initialization, readiness, query, teardown, process-group exit, unmount, and removal lifecycle succeeds; unavailable reasons are stable codes without private paths. Concurrent discovery shares only the active bounded probe; settled results are immediately invalidated so host capability changes are observed. A failed teardown retains its exact cleanup handle and must be retried to completion before another allocation can begin. -- Descriptor execution applies a bounded timeout and scoped `SIGINT`, `SIGTERM`, and `SIGHUP` abort handlers. Handlers are removed after execution, and interruption follows the same retained cleanup path before descriptor discovery settles. +- Descriptor execution applies a bounded timeout and scoped `SIGINT`, `SIGTERM`, and `SIGHUP` abort handlers across native teardown and browser readiness. Handlers are removed after execution, normal output is suppressed after interruption, and the CLI returns `124` for timeout or the deterministic `128 + signal` status after native cleanup settles. +- Concurrent native probes use one internal abort controller with independent caller waiters. One caller's cancellation cannot cancel other live callers; the underlying probe is aborted only after its final waiter leaves, and a descriptor caller separately waits for that cleanup before terminating. - Cleanup is single-flight for concurrent callers. A failed attempt may be retried, and evidence transitions from `teardown: failed` to a consistent released/completed state only after the retry proves cleanup. - Evidence contains service ID, engine/provider version, lifecycle state, and memory measurements only. It never contains credentials or private absolute paths. diff --git a/packages/cli/src/commands/discovery.ts b/packages/cli/src/commands/discovery.ts index bd71248a2..e18099d0b 100644 --- a/packages/cli/src/commands/discovery.ts +++ b/packages/cli/src/commands/discovery.ts @@ -2,15 +2,24 @@ import { createWorkspaceRecipeJsonSchema, runtimeDescriptor, type RuntimeDescrip import { commandRegistry, type CommandDefinition } from "@automattic/wp-codebox-core/contracts" import { printCommandCatalogHumanOutput, printRecipeSchemaHumanOutput, printRuntimeDescriptorHumanOutput } from "../output.js" import { cliRuntimeBackendRecipePolicy, listCliRecipeCommandDefinitions, listCliRuntimeBackendKinds } from "../runtime-backends.js" -import { nativeMariaDbHostReadiness } from "../runtime-services.js" +import { nativeMariaDbHostReadiness, settleNativeMariaDbHostReadiness } from "../runtime-services.js" import { playwrightBrowserReadiness } from "@automattic/wp-codebox-playground" const RUNTIME_DESCRIPTOR_TIMEOUT_MS = 120_000 const RUNTIME_DESCRIPTOR_SIGNALS: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"] interface RuntimeDescriptorSignalTarget { - on(signal: NodeJS.Signals, handler: (signal: NodeJS.Signals) => void): unknown - off(signal: NodeJS.Signals, handler: (signal: NodeJS.Signals) => void): unknown + on(signal: NodeJS.Signals, handler: () => void): unknown + off(signal: NodeJS.Signals, handler: () => void): unknown +} + +type RuntimeDescriptorInterruption = NodeJS.Signals | "timeout" + +export class RuntimeDescriptorInterruptedError extends Error { + constructor(readonly interruption: RuntimeDescriptorInterruption, readonly exitCode: number) { + super(interruption === "timeout" ? "Runtime descriptor discovery timed out" : `Runtime descriptor discovery interrupted by ${interruption}`) + this.name = "RuntimeDescriptorInterruptedError" + } } interface CommandCatalogOutput { @@ -48,9 +57,15 @@ export async function runRecipeSchemaCommand(args: string[]): Promise { return 0 } -export async function runRuntimeDescriptorCommand(args: string[]): Promise { +export async function runRuntimeDescriptorCommand(args: string[], options: { descriptorOutput?: (signal: AbortSignal) => Promise; signalTarget?: RuntimeDescriptorSignalTarget; timeoutMs?: number } = {}): Promise { const json = parseDiscoveryJsonOption(args) - const output = await withRuntimeDescriptorInterruption((signal) => runtimeDescriptorOutput(signal)) + let output: RuntimeDescriptor + try { + output = await withRuntimeDescriptorInterruption(options.descriptorOutput ?? runtimeDescriptorOutput, options.signalTarget, options.timeoutMs) + } catch (error) { + if (error instanceof RuntimeDescriptorInterruptedError) return error.exitCode + throw error + } if (!json) { printRuntimeDescriptorHumanOutput(output) return 0 @@ -62,18 +77,30 @@ export async function runRuntimeDescriptorCommand(args: string[]): Promise(run: (signal: AbortSignal) => Promise, target: RuntimeDescriptorSignalTarget = process, timeoutMs = RUNTIME_DESCRIPTOR_TIMEOUT_MS): Promise { const controller = new AbortController() - const interrupt = () => controller.abort() - for (const signal of RUNTIME_DESCRIPTOR_SIGNALS) target.on(signal, interrupt) - const timer = setTimeout(interrupt, timeoutMs) - timer.unref() + let interruption: RuntimeDescriptorInterruption | undefined + const interrupt = (signal: NodeJS.Signals) => { interruption ??= signal; controller.abort() } + const timeout = () => { interruption ??= "timeout"; controller.abort() } + const handlers = new Map(RUNTIME_DESCRIPTOR_SIGNALS.map((signal) => [signal, () => interrupt(signal)] as const)) + for (const [signal, handler] of handlers) target.on(signal, handler) + const timer = setTimeout(timeout, timeoutMs) try { - return await run(controller.signal) + const result = await run(controller.signal) + if (interruption) throw runtimeDescriptorInterruptedError(interruption) + return result + } catch (error) { + if (interruption) throw runtimeDescriptorInterruptedError(interruption) + throw error } finally { clearTimeout(timer) - for (const signal of RUNTIME_DESCRIPTOR_SIGNALS) target.off(signal, interrupt) + for (const [signal, handler] of handlers) target.off(signal, handler) } } +function runtimeDescriptorInterruptedError(interruption: RuntimeDescriptorInterruption): RuntimeDescriptorInterruptedError { + const signalExitCodes: Record = { SIGHUP: 129, SIGINT: 130, SIGTERM: 143 } as Record + return new RuntimeDescriptorInterruptedError(interruption, interruption === "timeout" ? 124 : signalExitCodes[interruption]) +} + function parseDiscoveryJsonOption(args: string[]): boolean { let json = false for (const arg of args) { @@ -134,5 +161,12 @@ function recipeSchemaOutput(): RecipeSchemaOutput { } async function runtimeDescriptorOutput(signal: AbortSignal): Promise { - return runtimeDescriptor({ nativeMariaDb: await nativeMariaDbHostReadiness(undefined, signal), browserRuntime: await playwrightBrowserReadiness() }) + const nativeMariaDb = await nativeMariaDbHostReadiness(undefined, signal) + if (signal.aborted) { + await settleNativeMariaDbHostReadiness() + throw new Error("Runtime descriptor discovery interrupted") + } + const browserRuntime = await playwrightBrowserReadiness({ signal }) + if (signal.aborted) throw new Error("Runtime descriptor discovery interrupted") + return runtimeDescriptor({ nativeMariaDb, browserRuntime }) } diff --git a/packages/cli/src/runtime-services.ts b/packages/cli/src/runtime-services.ts index 3d59cf9b7..6cacb4f70 100644 --- a/packages/cli/src/runtime-services.ts +++ b/packages/cli/src/runtime-services.ts @@ -395,10 +395,17 @@ export interface NativeMariaDbHostIdentity { } interface NativeMariaDbReadinessState { - active?: Promise + active?: NativeMariaDbActiveProbe retained?: { release: () => Promise; evidence: RuntimeServiceEvidence } } +interface NativeMariaDbActiveProbe { + controller: AbortController + promise: Promise + waiters: Set + settled: boolean +} + interface NativeMariaDbBinaries { server: string initialize: string @@ -615,15 +622,18 @@ export async function nativeMariaDbHostReadiness(dependencies: RuntimeServiceDep state = {} nativeMariaDbReadinessStates.set(dependencies, state) } - if (state.active) return await state.active - const probe = probeNativeMariaDbHostReadiness(dependencies, state, signal) - state.active = probe - try { - return await probe - } finally { - if (state.active === probe) state.active = undefined - if (!state.retained) nativeMariaDbReadinessStates.delete(dependencies) + let active = state.active + if (!active) { + const controller = new AbortController() + active = { controller, promise: Promise.resolve({ status: "unavailable" }), waiters: new Set(), settled: false } + state.active = active + active.promise = probeNativeMariaDbHostReadiness(dependencies, state, controller.signal).finally(() => { + active!.settled = true + if (state.active === active) state.active = undefined + if (!state.retained) nativeMariaDbReadinessStates.delete(dependencies) + }) } + return await waitForNativeMariaDbProbe(active, signal) } async function probeNativeMariaDbHostReadiness(dependencies: RuntimeServiceDependencies, state: NativeMariaDbReadinessState, signal?: AbortSignal): Promise { @@ -668,6 +678,7 @@ async function probeNativeMariaDbHostReadiness(dependencies: RuntimeServiceDepen }, evidence) await managed.release() state.retained = undefined + if (signal?.aborted) return { status: "unavailable", reason: "containment-probe-interrupted" } return { status: "ready" } } catch (error) { if (signal?.aborted && !state.retained) return { status: "unavailable", reason: "containment-probe-interrupted" } @@ -680,6 +691,38 @@ async function probeNativeMariaDbHostReadiness(dependencies: RuntimeServiceDepen } } +async function waitForNativeMariaDbProbe(active: NativeMariaDbActiveProbe, signal?: AbortSignal): Promise { + const waiter = Symbol("native-mariadb-readiness-waiter") + active.waiters.add(waiter) + let abort: (() => void) | undefined + try { + if (signal?.aborted) return { status: "unavailable", reason: "containment-probe-interrupted" } + if (!signal) return await active.promise + return await Promise.race([ + active.promise, + new Promise((resolve) => { + abort = () => resolve({ status: "unavailable", reason: "containment-probe-interrupted" }) + signal.addEventListener("abort", abort, { once: true }) + }), + ]) + } finally { + if (abort) signal?.removeEventListener("abort", abort) + active.waiters.delete(waiter) + if (!active.settled && active.waiters.size === 0) active.controller.abort() + } +} + +export async function settleNativeMariaDbHostReadiness(dependencies: RuntimeServiceDependencies = defaultDependencies): Promise { + const state = nativeMariaDbReadinessStates.get(dependencies) + if (!state) return + if (state.active && !state.active.controller.signal.aborted) return + await state.active?.promise + if (!state.retained) return + await state.retained.release() + state.retained = undefined + nativeMariaDbReadinessStates.delete(dependencies) +} + function nativeMariaDbHostIdentity(dependencies: RuntimeServiceDependencies): NativeMariaDbHostIdentity { const identity = dependencies.nativeIdentity?.() ?? { uid: typeof process.getuid === "function" ? process.getuid() : undefined, @@ -1901,10 +1944,12 @@ function throwIfAborted(signal: AbortSignal | undefined): void { if (signal?.aborted) throw new Error("Managed runtime service provisioning interrupted") } -function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise { +export function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(new Error("Managed runtime service provisioning interrupted")) return new Promise((resolve, reject) => { - const timer = setTimeout(resolve, milliseconds) - signal?.addEventListener("abort", () => { clearTimeout(timer); reject(new Error("Managed runtime service provisioning interrupted")) }, { once: true }) + const abort = () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); reject(new Error("Managed runtime service provisioning interrupted")) } + const timer = setTimeout(() => { signal?.removeEventListener("abort", abort); resolve() }, milliseconds) + signal?.addEventListener("abort", abort, { once: true }) }) } diff --git a/packages/runtime-playground/src/playwright-browser-provenance.ts b/packages/runtime-playground/src/playwright-browser-provenance.ts index 78a5e7861..04bfee1e6 100644 --- a/packages/runtime-playground/src/playwright-browser-provenance.ts +++ b/packages/runtime-playground/src/playwright-browser-provenance.ts @@ -48,12 +48,30 @@ export async function assertPlaywrightBrowserReady(): Promise boolean } = {}): Promise { - const provenance = await playwrightBrowserProvenance() +export async function playwrightBrowserReadiness(options: { executableExists?: (path: string) => boolean; signal?: AbortSignal; provenance?: () => Promise } = {}): Promise { + throwIfAborted(options.signal) + const provenance = await abortable(options.provenance?.() ?? playwrightBrowserProvenance(), options.signal) + throwIfAborted(options.signal) if ((options.executableExists ?? existsSync)(provenance.executablePath)) return { status: "ready" } return { status: "unavailable", reason: playwrightBrowserUnavailableReason(provenance) } } +function abortable(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise + return new Promise((resolve, reject) => { + const abort = () => { signal.removeEventListener("abort", abort); reject(new Error("Playwright browser readiness interrupted")) } + signal.addEventListener("abort", abort, { once: true }) + void promise.then( + (value) => { signal.removeEventListener("abort", abort); resolve(value) }, + (error) => { signal.removeEventListener("abort", abort); reject(error) }, + ) + }) +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw new Error("Playwright browser readiness interrupted") +} + function playwrightBrowserUnavailableReason(provenance: PlaywrightBrowserProvenance): string { return `Required Playwright Chromium ${provenance.chromiumVersion} (revision ${provenance.chromiumRevision}) is unavailable. Install the browser owned by this WP Codebox package with: node ./node_modules/playwright/cli.js install chromium` } diff --git a/tests/native-mariadb-runtime-service.test.ts b/tests/native-mariadb-runtime-service.test.ts index 26ca67cce..e2243161a 100644 --- a/tests/native-mariadb-runtime-service.test.ts +++ b/tests/native-mariadb-runtime-service.test.ts @@ -1,11 +1,12 @@ import assert from "node:assert/strict" -import { EventEmitter } from "node:events" +import { EventEmitter, getEventListeners } from "node:events" import { chmod, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises" import { createServer } from "node:net" import { tmpdir, userInfo } from "node:os" import { basename, dirname, join } from "node:path" -import { withRuntimeDescriptorInterruption } from "../packages/cli/src/commands/discovery.ts" -import { assertNativeMariaDbEngines, assertNativeMariaDbFilesystemGeometry, assertNativeMariaDbFilesystemOwnership, assertNativeMariaDbMountInfo, assertNativeMariaDbUnprivilegedHost, nativeMariaDbHostReadiness, provisionRuntimeServices, RuntimeServiceProvisionError, runtimeServicePlan, type NativeMariaDbHostIdentity, type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts" +import { runRuntimeDescriptorCommand, RuntimeDescriptorInterruptedError, withRuntimeDescriptorInterruption } from "../packages/cli/src/commands/discovery.ts" +import { abortableDelay, assertNativeMariaDbEngines, assertNativeMariaDbFilesystemGeometry, assertNativeMariaDbFilesystemOwnership, assertNativeMariaDbMountInfo, assertNativeMariaDbUnprivilegedHost, nativeMariaDbHostReadiness, provisionRuntimeServices, RuntimeServiceProvisionError, runtimeServicePlan, settleNativeMariaDbHostReadiness, type NativeMariaDbHostIdentity, type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts" +import { playwrightBrowserReadiness } from "../packages/runtime-playground/src/playwright-browser-provenance.ts" import { validateWorkspaceRecipeSemantics } from "../packages/cli/src/recipe-validation.ts" import { validateWorkspaceRecipeJsonSchema, type WorkspaceRecipeRuntimeService } from "../packages/runtime-core/src/index.ts" @@ -195,14 +196,115 @@ process.exit(1); descriptorAbort.abort() } } assert.deepEqual(await nativeMariaDbHostReadiness(interruptedDependencies, descriptorAbort.signal), { status: "unavailable", reason: "containment-probe-interrupted" }) + await settleNativeMariaDbHostReadiness(interruptedDependencies) assert.deepEqual((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-") && !interruptedRoots.has(name)), [], "interrupted descriptor discovery releases all native roots") const signalTarget = new EventEmitter() const interruptedDescriptor = withRuntimeDescriptorInterruption(async (signal) => await new Promise((resolve) => signal.addEventListener("abort", () => resolve(signal.aborted), { once: true })), signalTarget, 10_000) - signalTarget.emit("SIGTERM", "SIGTERM") - assert.equal(await interruptedDescriptor, true) + signalTarget.emit("SIGTERM") + await assert.rejects(interruptedDescriptor, (error: unknown) => error instanceof RuntimeDescriptorInterruptedError && error.exitCode === 143) for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) assert.equal(signalTarget.listenerCount(signal), 0, `descriptor removes its ${signal} cleanup handler`) + const timeoutTarget = new EventEmitter() + await assert.rejects(withRuntimeDescriptorInterruption(async (signal) => await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true })), timeoutTarget, 1), (error: unknown) => error instanceof RuntimeDescriptorInterruptedError && error.exitCode === 124) + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) assert.equal(timeoutTarget.listenerCount(signal), 0, `timed-out descriptor removes its ${signal} handler`) + + let descriptorOutput = "" + const originalStdoutWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string | Uint8Array) => { descriptorOutput += chunk.toString(); return true }) as typeof process.stdout.write + try { + const commandSignalTarget = new EventEmitter() + const command = runRuntimeDescriptorCommand(["--json"], { + signalTarget: commandSignalTarget, + descriptorOutput: async (signal) => await new Promise((resolve) => signal.addEventListener("abort", () => resolve({} as never), { once: true })), + }) + commandSignalTarget.emit("SIGINT") + assert.equal(await command, 130) + assert.equal(descriptorOutput, "", "interrupted descriptor command suppresses normal output") + + const timeoutCommand = runRuntimeDescriptorCommand(["--json"], { + signalTarget: new EventEmitter(), + timeoutMs: 1, + descriptorOutput: async (signal) => await new Promise((resolve) => signal.addEventListener("abort", () => resolve({} as never), { once: true })), + }) + assert.equal(await timeoutCommand, 124) + assert.equal(descriptorOutput, "", "timed-out descriptor command suppresses normal output") + + const browserSignalTarget = new EventEmitter() + const browserCommand = runRuntimeDescriptorCommand(["--json"], { + signalTarget: browserSignalTarget, + descriptorOutput: async (signal) => { + await playwrightBrowserReadiness({ signal, provenance: async () => await new Promise(() => undefined) }) + return {} as never + }, + }) + browserSignalTarget.emit("SIGHUP") + assert.equal(await browserCommand, 129) + assert.equal(descriptorOutput, "", "browser-readiness interruption suppresses normal descriptor output") + } finally { + process.stdout.write = originalStdoutWrite + } + + const delayController = new AbortController() + for (let iteration = 0; iteration < 20; iteration += 1) await abortableDelay(0, delayController.signal) + assert.equal(getEventListeners(delayController.signal, "abort").length, 0, "resolved abortable delays remove abort listeners") + + const singleFlightAllocations = () => calls.filter((call) => call.stdin?.includes("CREATE DATABASE")).length + const secondAbortFirst = new AbortController() + const secondAbortSecond = new AbortController() + const beforeSecondAbort = singleFlightAllocations() + const secondAbortFirstResult = nativeMariaDbHostReadiness(dependencies, secondAbortFirst.signal) + const secondAbortSecondResult = nativeMariaDbHostReadiness(dependencies, secondAbortSecond.signal) + secondAbortSecond.abort() + assert.deepEqual(await secondAbortSecondResult, { status: "unavailable", reason: "containment-probe-interrupted" }) + assert.equal(await Promise.race([settleNativeMariaDbHostReadiness(dependencies).then(() => "settled"), abortableDelay(50).then(() => "blocked")]), "settled", "an interrupted descriptor does not wait behind another live caller") + assert.deepEqual(await secondAbortFirstResult, { status: "ready" }) + assert.equal(singleFlightAllocations(), beforeSecondAbort + 1, "aborting the second waiter does not cancel the first caller's allocation") + assert.equal(getEventListeners(secondAbortFirst.signal, "abort").length + getEventListeners(secondAbortSecond.signal, "abort").length, 0) + + const firstAbortFirst = new AbortController() + const firstAbortSecond = new AbortController() + const beforeFirstAbort = singleFlightAllocations() + const firstAbortFirstResult = nativeMariaDbHostReadiness(dependencies, firstAbortFirst.signal) + const firstAbortSecondResult = nativeMariaDbHostReadiness(dependencies, firstAbortSecond.signal) + firstAbortFirst.abort() + assert.deepEqual(await firstAbortFirstResult, { status: "unavailable", reason: "containment-probe-interrupted" }) + assert.deepEqual(await firstAbortSecondResult, { status: "ready" }) + assert.equal(singleFlightAllocations(), beforeFirstAbort + 1, "aborting the first waiter does not cancel the second caller's allocation") + + const allAbortRoots = new Set((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-"))) + const allAbortFirst = new AbortController() + const allAbortSecond = new AbortController() + const beforeAllAbort = singleFlightAllocations() + const allAbortFirstResult = nativeMariaDbHostReadiness(dependencies, allAbortFirst.signal) + const allAbortSecondResult = nativeMariaDbHostReadiness(dependencies, allAbortSecond.signal) + while (singleFlightAllocations() === beforeAllAbort) await abortableDelay(10) + allAbortFirst.abort() + allAbortSecond.abort() + assert.deepEqual(await allAbortFirstResult, { status: "unavailable", reason: "containment-probe-interrupted" }) + assert.deepEqual(await allAbortSecondResult, { status: "unavailable", reason: "containment-probe-interrupted" }) + await settleNativeMariaDbHostReadiness(dependencies) + assert.equal(singleFlightAllocations(), beforeAllAbort + 1, "all aborted waiters cancel only one underlying allocation") + assert.deepEqual((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-") && !allAbortRoots.has(name)), [], "all-waiter abort cleans the underlying allocation") + assert.equal(getEventListeners(allAbortFirst.signal, "abort").length + getEventListeners(allAbortSecond.signal, "abort").length, 0) + + let releaseRaceEntered!: () => void + let releaseRaceContinue!: () => void + const releaseEntered = new Promise((resolve) => { releaseRaceEntered = resolve }) + const releaseContinue = new Promise((resolve) => { releaseRaceContinue = resolve }) + const teardownRaceDependencies: RuntimeServiceDependencies = { ...dependencies, async removeNativeRoot(root) { + releaseRaceEntered() + await releaseContinue + await rm(root, { recursive: true, force: false }) + } } + const teardownRaceAbort = new AbortController() + const teardownRaceResult = nativeMariaDbHostReadiness(teardownRaceDependencies, teardownRaceAbort.signal) + await releaseEntered + teardownRaceAbort.abort() + assert.deepEqual(await teardownRaceResult, { status: "unavailable", reason: "containment-probe-interrupted" }) + releaseRaceContinue() + await settleNativeMariaDbHostReadiness(teardownRaceDependencies) + const before = new Set((await readdir(tmpdir())).filter((name) => name.startsWith("wp-codebox-mariadb-"))) const provisioned = await provisionRuntimeServices([service], { dependencies }) const password = Buffer.alloc(24, 0x5a).toString("base64url") diff --git a/tests/playwright-browser-provenance.test.ts b/tests/playwright-browser-provenance.test.ts index 6a113c161..677c5e551 100644 --- a/tests/playwright-browser-provenance.test.ts +++ b/tests/playwright-browser-provenance.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict" import { createHash } from "node:crypto" import { readFile } from "node:fs/promises" import { resolve } from "node:path" +import { getEventListeners } from "node:events" import { test } from "node:test" import { playwrightBrowserProvenance, playwrightBrowserReadiness } from "../packages/runtime-playground/src/playwright-browser-provenance.js" @@ -29,3 +30,13 @@ test("reports missing and present browser revisions without using the ambient ca const present = await playwrightBrowserReadiness({ executableExists: () => true }) assert.deepEqual(present, { status: "ready" }) }) + +test("browser readiness aborts a pending provenance lookup and removes its listener", async () => { + const controller = new AbortController() + const pending = new Promise(() => undefined) + const readiness = playwrightBrowserReadiness({ signal: controller.signal, provenance: () => pending }) + assert.equal(getEventListeners(controller.signal, "abort").length, 1) + controller.abort() + await assert.rejects(readiness, /interrupted/) + assert.equal(getEventListeners(controller.signal, "abort").length, 0) +})