diff --git a/docs/bwrap-support/bubblewrap-backend.md b/docs/bwrap-support/bubblewrap-backend.md index c1851c7c0..73003347e 100644 --- a/docs/bwrap-support/bubblewrap-backend.md +++ b/docs/bwrap-support/bubblewrap-backend.md @@ -26,7 +26,14 @@ requiring root privileges or a container runtime. environment is built with `--clearenv` (bwrap 0.5.0+), so **bwrap 0.5.0 or newer** is required. Platform detection probes `bwrap --version` and reports the backend as unavailable — with the detected version — when the host is - below that floor. + below that floor. The probe has a 5-second deadline and retains at most 64 KB + from each output stream. On timeout, its process group is terminated with + `SIGKILL` so wrappers and descendants cannot keep the probe alive. Successful + Rust-executor advisory results are cached for the process lifetime; Rust + failures are not cached, and execution validation probes again before launch + so a changed PATH target cannot reuse an advisory result. The Node SDK caches + the complete `getPlatformSupport()` result, including failures, for the module + lifetime; restart the Node process after remediating the host. - User namespaces must be enabled: ```bash # Check: should print "1" diff --git a/sdk/node/README.md b/sdk/node/README.md index 47ca684e3..e7a0ab550 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -79,7 +79,7 @@ The default `processcontainer`, `bubblewrap`, `lxc`, and `seatbelt` backends wor > **Hyperlight** is an opt-in build flavor (Linux x64 and Windows x64) gated by the `--with-hyperlight` cargo feature. Default shipped binaries do not include it; build from source with `build.bat --with-hyperlight` (Windows) or the equivalent cargo invocation on Linux. -`getPlatformSupport()` reports backend availability and, when the native probe can determine it, `uiCapabilities`: a platform-neutral view of which UI restrictions the host can enforce. This is currently populated only by the Windows native probe, where it is derived from `JOB_OBJECT_UILIMIT_*` support; Linux and macOS omit the field until their probes expose equivalent data. +`getPlatformSupport()` reports backend availability and, when the native probe can determine it, `uiCapabilities`: a platform-neutral view of which UI restrictions the host can enforce. This is currently populated only by the Windows native probe, where it is derived from `JOB_OBJECT_UILIMIT_*` support; Linux and macOS omit the field until their probes expose equivalent data. On Linux, `unavailableReasons` provides a diagnostic for each unavailable LXC or Bubblewrap backend even when the other backend keeps the platform supported. **Node.js:** ≥ 18. @@ -368,7 +368,7 @@ Setting `cwd` (or the `workingDirectory` argument) does **not** add that path to | Error | Cause | Fix | | --- | --- | --- | -| `MXC is not supported on this platform` | `getPlatformSupport()` returned `isSupported: false`. On Linux: neither LXC nor Bubblewrap on PATH. On macOS: schema version < `0.6.0-alpha`. | Install LXC/Bubblewrap, or switch to schema `0.6.0-alpha` (or `0.7.0-alpha` if you need state-aware lifecycle). | +| `MXC is not supported on this platform` | `getPlatformSupport()` returned `isSupported: false`. On Linux, neither LXC nor a usable Bubblewrap 0.5.0+ installation is available. On macOS, the Seatbelt platform probe could not find `/usr/bin/sandbox-exec`. | Inspect `support.reason`. On Linux, also inspect `support.unavailableReasons` and install LXC or Bubblewrap 0.5.0+. On macOS, verify that `/usr/bin/sandbox-exec` exists; its absence indicates an incomplete or unsupported macOS installation. | | `wxc-exec.exe not found` / `lxc-exec not found` | The SDK couldn't locate the native binary. | Set `MXC_BIN_DIR=` so `//wxc-exec.exe` (or `lxc-exec`) exists, or pass `options.executablePath` explicitly. | | `Invalid containment value ''` | `containment` field doesn't match the parser's accepted values. | Use one of the abstract intents (`process`, `vm`, `microvm`) or a concrete backend listed in [Choosing a Backend](#choosing-a-backend). | | `'' containment requires experimental mode` | A `windows_sandbox` / `wslc` / `microvm` / `isolation_session` / `hyperlight` backend was selected without the flag. | Pass `{ experimental: true }` in `SandboxSpawnOptions`. | diff --git a/sdk/node/src/bwrap-probe-anchor.ts b/sdk/node/src/bwrap-probe-anchor.ts new file mode 100644 index 000000000..ebef77a70 --- /dev/null +++ b/sdk/node/src/bwrap-probe-anchor.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawn } from 'node:child_process'; + +// Strip NODE_OPTIONS so a host's loader/inspector/require flags cannot +// interfere with the helper's minimal, trusted `bwrap --version` probe. +const spawnEnv = { ...process.env }; +delete spawnEnv.NODE_OPTIONS; + +const helperPath = process.argv[2]; +const timeoutMs = Number(process.argv[3]); +const outputLimit = process.argv[4]; +let resultWritten = false; +let resultFlushed = false; +let shuttingDown = false; +let helperClosed = false; +let output = ''; +const hold = setInterval(() => {}, 0x3fffffff); + +function terminateHelper(): void { + if (shuttingDown) return; + shuttingDown = true; + const pid = helperProcess.pid; + if (pid && !helperClosed) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // The helper may already have exited. + } + } +} + +function terminateOwnedGroup(): void { + try { + process.kill(-process.pid, 'SIGKILL'); + } catch { + process.exit(1); + } +} + +function finishIfReady(): void { + if (!helperClosed || !resultFlushed) return; + clearTimeout(watchdog); + clearInterval(hold); + // The anchor is still the unreaped group leader. The helper has already + // been reaped, so terminating the owned group cannot target a recycled ID. + terminateOwnedGroup(); +} + +const watchdog = setTimeout(() => { + terminateHelper(); + setTimeout(terminateOwnedGroup, 500).unref(); +}, timeoutMs + 1000); +watchdog.unref(); + +process.on('SIGTERM', () => { + terminateHelper(); +}); + +function emitFailure(detail: string): void { + if (resultWritten) return; + resultWritten = true; + process.stdout.write(`${JSON.stringify({ kind: 'spawnError', detail })}\n`, () => { + resultFlushed = true; + finishIfReady(); + }); +} + +const helperProcess = spawn( + process.execPath, + [helperPath, String(timeoutMs), outputLimit], + { detached: false, stdio: ['ignore', 'pipe', 'ignore'], env: spawnEnv }, +); +helperProcess.stdout.setEncoding('utf8'); +helperProcess.stdout.on('data', (chunk: string) => { + output += chunk; + const newline = output.indexOf('\n'); + if (!resultWritten && newline !== -1) { + resultWritten = true; + process.stdout.write(output.slice(0, newline + 1), () => { + resultFlushed = true; + finishIfReady(); + }); + terminateHelper(); + } +}); +helperProcess.on('error', (error) => emitFailure(error.message)); +helperProcess.on('close', () => { + helperClosed = true; + emitFailure('probe helper exited without a result'); + finishIfReady(); +}); diff --git a/sdk/node/src/bwrap-probe-helper.ts b/sdk/node/src/bwrap-probe-helper.ts new file mode 100644 index 000000000..c9d5744cd --- /dev/null +++ b/sdk/node/src/bwrap-probe-helper.ts @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawn, ChildProcessByStdio } from 'node:child_process'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { Readable } from 'node:stream'; + +type HelperResult = + | { kind: 'completed'; status: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string } + | { kind: 'notFound' } + | { kind: 'timeout' } + | { kind: 'overflow' } + | { kind: 'spawnError'; detail: string }; + +const timeoutMs = Number(process.argv[2]); +const outputLimit = Number(process.argv[3]); +let child: ChildProcessByStdio | undefined; +let finished = false; +let classifyingSpawnError = false; +let timer: NodeJS.Timeout | undefined; +let stdoutLength = 0; +let stderrLength = 0; +const stdoutChunks: Buffer[] = []; +const stderrChunks: Buffer[] = []; +const hold = setInterval(() => {}, 0x3fffffff); + +setTimeout(() => { + if (child) { + try { + child.kill('SIGKILL'); + } catch { + // The child may already have exited. + } + } + process.exit(1); +}, timeoutMs + 1000).unref(); + +function capture(chunks: Buffer[], chunk: Buffer, currentLength: number): number { + const remaining = Math.max(0, outputLimit - currentLength); + if (remaining > 0) chunks.push(chunk.subarray(0, remaining)); + return currentLength + chunk.length; +} + +function emit(result: HelperResult): void { + if (finished) return; + finished = true; + if (timer) clearTimeout(timer); + process.stdout.write(`${JSON.stringify(result)}\n`); +} + +async function handleSpawnError(error: NodeJS.ErrnoException): Promise { + if (error.code !== 'ENOENT') { + emit({ kind: 'spawnError', detail: error.message }); + return; + } + for (const entry of (process.env.PATH ?? '').split(path.delimiter)) { + const candidate = path.join(entry, 'bwrap'); + try { + if ((await fs.stat(candidate)).isFile()) { + emit({ + kind: 'spawnError', + detail: `${candidate} was found but could not be executed; check for a missing interpreter or loader`, + }); + return; + } + } catch (statError) { + const error = statError as NodeJS.ErrnoException; + if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') { + emit({ kind: 'spawnError', detail: `failed to inspect ${candidate}: ${error.message}` }); + return; + } + } + } + emit({ kind: 'notFound' }); +} + +try { + child = spawn('bwrap', ['--version'], { + detached: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} catch (error) { + void handleSpawnError(error as NodeJS.ErrnoException); +} + +if (child) { + child.stdout.on('data', (chunk: Buffer) => { + stdoutLength = capture(stdoutChunks, chunk, stdoutLength); + if (stdoutLength > outputLimit) emit({ kind: 'overflow' }); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderrLength = capture(stderrChunks, chunk, stderrLength); + if (stderrLength > outputLimit) emit({ kind: 'overflow' }); + }); + child.on('error', (error: NodeJS.ErrnoException) => { + classifyingSpawnError = true; + void handleSpawnError(error); + }); + child.on('close', (status, signal) => { + if (classifyingSpawnError) return; + emit({ + kind: 'completed', + status, + signal, + stdout: Buffer.concat(stdoutChunks).toString('utf8'), + stderr: Buffer.concat(stderrChunks).toString('utf8'), + }); + }); + timer = setTimeout(() => emit({ kind: 'timeout' }), timeoutMs); +} + +void hold; diff --git a/sdk/node/src/bwrap-probe-worker.ts b/sdk/node/src/bwrap-probe-worker.ts new file mode 100644 index 000000000..fcb0e5d37 --- /dev/null +++ b/sdk/node/src/bwrap-probe-worker.ts @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawn, ChildProcessByStdio } from 'node:child_process'; +import { Readable } from 'node:stream'; +import { workerData } from 'node:worker_threads'; + +// Strip NODE_OPTIONS so a host's loader/inspector/require flags cannot +// interfere with the anchor's minimal, trusted probe supervision. +const spawnEnv = { ...process.env }; +delete spawnEnv.NODE_OPTIONS; + +interface ProbeWorkerData { + shared: SharedArrayBuffer; + anchorPath: string; + helperPath: string; + probeTimeoutMs: number; + publishTimeoutMs: number; + outputLimit: number; +} + +const data = workerData as ProbeWorkerData; +const header = new Int32Array(data.shared, 0, 3); +const payload = new Uint8Array(data.shared, 12); +let anchor: ChildProcessByStdio | undefined; +let output = ''; +let finished = false; +let anchorExited = false; +let anchorClosed = false; +let completionRequested = false; +let pendingResult: unknown; +let timeout: NodeJS.Timeout | undefined; + +function stopAnchor(): void { + const pid = anchor?.pid; + if (!pid || anchorExited) return; + try { + process.kill(pid, 'SIGTERM'); + } catch { + // The process may have exited between the ownership check and the signal. + } +} + +function publish(result: unknown): void { + if (finished) return; + finished = true; + if (timeout) clearTimeout(timeout); + let encoded = Buffer.from(JSON.stringify(result)); + if (encoded.length > payload.length) { + encoded = Buffer.from(JSON.stringify({ + kind: 'spawnError', + detail: 'probe helper result exceeded its bound', + })); + } + payload.set(encoded); + Atomics.store(header, 1, encoded.length); + if (Atomics.compareExchange(header, 0, 0, 1) === 0) { + Atomics.notify(header, 0); + } +} + +function completeAfterCleanup(result: unknown, stop = false): void { + if (finished || completionRequested) return; + completionRequested = true; + pendingResult = result; + if (stop) stopAnchor(); + if (anchorClosed) publish(pendingResult); +} + +if (Atomics.load(header, 0) === 0) { + try { + const spawnedAnchor = spawn( + process.execPath, + [ + data.anchorPath, + data.helperPath, + String(data.probeTimeoutMs), + String(data.outputLimit), + ], + { detached: true, stdio: ['ignore', 'pipe', 'ignore'], env: spawnEnv }, + ); + anchor = spawnedAnchor; + const anchorPid = spawnedAnchor.pid; + if (anchorPid === undefined) { + completeAfterCleanup({ + kind: 'spawnError', + detail: 'probe anchor did not receive a process id', + }); + } else { + Atomics.store(header, 2, anchorPid); + } + if (Atomics.load(header, 0) !== 0) { + stopAnchor(); + } + spawnedAnchor.stdout.setEncoding('utf8'); + spawnedAnchor.stdout.on('data', (chunk: string) => { + output += chunk; + const newline = output.indexOf('\n'); + if (newline !== -1) { + try { + completeAfterCleanup(JSON.parse(output.slice(0, newline))); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + completeAfterCleanup( + { kind: 'spawnError', detail: `invalid probe helper result: ${detail}` }, + true, + ); + } + } + }); + spawnedAnchor.on('error', (error) => { + completeAfterCleanup({ kind: 'spawnError', detail: error.message }, true); + }); + spawnedAnchor.on('exit', () => { + anchorExited = true; + }); + spawnedAnchor.on('close', () => { + anchorClosed = true; + publish( + completionRequested + ? pendingResult + : { kind: 'spawnError', detail: 'probe helper exited without a result' }, + ); + }); + // Ask the anchor to stop inside the caller's budget. Publication waits for + // close so the result cannot escape before group teardown and reaping. + timeout = setTimeout( + () => completeAfterCleanup({ kind: 'timeout' }, true), + data.publishTimeoutMs, + ); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + publish({ kind: 'spawnError', detail }); + } +} diff --git a/sdk/node/src/helper.ts b/sdk/node/src/helper.ts index d0d2fbabe..f5d2f3281 100644 --- a/sdk/node/src/helper.ts +++ b/sdk/node/src/helper.ts @@ -260,8 +260,11 @@ export function resolveExecutableAndArgs( effectiveContainment as ContainmentBackend ); if (!isIntent && !isExperimental && !isAvailable) { + const unavailableReason = + platformSupport.unavailableReasons?.[effectiveContainment as ContainmentBackend]; throw new Error( `Containment backend '${rawContainment}' is not available on this platform. ` + + (unavailableReason ? `${unavailableReason} ` : '') + `Available methods: ${platformSupport.availableMethods.join(', ')}` ); } diff --git a/sdk/node/src/index.ts b/sdk/node/src/index.ts index 4432ba7a0..6056cc9c8 100644 --- a/sdk/node/src/index.ts +++ b/sdk/node/src/index.ts @@ -9,6 +9,8 @@ * `processContainer.learningMode: true` to enable deny-and-record learning * mode. Learning-mode capability names are reserved and must not be supplied * directly in `processContainer.capabilities`. + * On Linux, `getPlatformSupport()` reports failures for individual backends + * through `PlatformSupport.unavailableReasons`, including when none is usable. * * @example * ```typescript diff --git a/sdk/node/src/platform.ts b/sdk/node/src/platform.ts index b704e212d..86bd986fc 100644 --- a/sdk/node/src/platform.ts +++ b/sdk/node/src/platform.ts @@ -2,12 +2,18 @@ import * as os from 'os'; import * as fs from 'fs'; import * as path from 'path'; import { execSync, execFileSync } from 'child_process'; +import { performance } from 'node:perf_hooks'; import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { Worker, type WorkerOptions } from 'node:worker_threads'; import { ContainmentBackend, IsolationTier, PlatformSupport, UiCapabilitySupport } from './types.js'; import { diagLog } from './diagnostic.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +// This module is emitted as ESM, so there is no ambient CommonJS `require`; +// synthesize one bound to this file's URL for `require.resolve`. +const require = createRequire(import.meta.url); /** * Resolves the SDK package root directory. @@ -24,6 +30,11 @@ function getSdkPackageRoot(): string { } } +const bwrapProbeScriptDirectory = fs.existsSync( + path.join(__dirname, 'bwrap-probe-worker.js'), +) + ? __dirname + : path.join(getSdkPackageRoot(), 'dist'); let windowsSandboxAvailableCache: boolean | undefined; /** @@ -64,9 +75,10 @@ function isWindowsSandboxAvailable(): boolean { * On Windows, this also invokes `wxc-exec --probe` to populate * `isolationTier`, the `isolationWarnings` array (if any), and portable UI * capability facts. Linux and macOS currently do not expose native probe data, - * so `uiCapabilities` is omitted on those platforms. The result is cached for - * the lifetime of the SDK module — the underlying machine state is not - * expected to change at runtime. + * so `uiCapabilities` is omitted on those platforms. On Linux, + * `unavailableReasons` contains per-backend diagnostics for unavailable LXC + * or Bubblewrap backends. The result is cached for the lifetime of the SDK + * module — the underlying machine state is not expected to change at runtime. * * @returns Platform support details including available sandboxing methods */ @@ -196,16 +208,26 @@ function computeSupport(): PlatformSupport { // LXC and Bubblewrap are both supported on Linux. Report whichever // are installed; callers pick via the containment field. const methods: ContainmentBackend[] = []; - if (isLxcAvailable()) methods.push('lxc'); + if (lxcAvailabilityProbe()) { + methods.push('lxc'); + } else { + support.unavailableReasons = { + lxc: 'LXC is not installed or not available on this system.', + }; + } const bubblewrap = _probeBubblewrap(); if (bubblewrap.available) { methods.push('bubblewrap'); } else { + support.unavailableReasons = { + ...support.unavailableReasons, + bubblewrap: bubblewrap.reason, + }; // Always surface why bwrap is unavailable. When LXC is present the // platform is still supported, so `reason` — documented as why the // platform is *not* supported — must stay unset, and the detail would - // otherwise be dropped with no way to diagnose the missing backend. - diagLog(`getPlatformSupport: bubblewrap unavailable — ${bubblewrap.reason}`); + // otherwise be dropped without the per-backend reason above. + platformDiagnosticLogger(`getPlatformSupport: bubblewrap unavailable — ${bubblewrap.reason}`); if (methods.length === 0) { support.reason = `Neither LXC nor Bubblewrap is available on this system (${bubblewrap.reason})`; } @@ -234,7 +256,7 @@ function computeSupport(): PlatformSupport { /** * Check if LXC is available on the system */ -function isLxcAvailable(): boolean { +function defaultLxcAvailabilityProbe(): boolean { try { execSync('lxc-ls --version', { encoding: 'utf-8', stdio: 'pipe' }); return true; @@ -243,6 +265,20 @@ function isLxcAvailable(): boolean { } } +let lxcAvailabilityProbe = defaultLxcAvailabilityProbe; + +/** @internal Test-only: override the LXC availability probe. */ +export function _setLxcAvailabilityProbe(fn: (() => boolean) | null): void { + lxcAvailabilityProbe = fn ?? defaultLxcAvailabilityProbe; +} + +let platformDiagnosticLogger: (message: string) => void = diagLog; + +/** @internal Test-only: override platform-support diagnostic logging. */ +export function _setPlatformDiagnosticLogger(fn: ((message: string) => void) | null): void { + platformDiagnosticLogger = fn ?? diagLog; +} + /** * Minimum `bwrap` version the Bubblewrap backend supports, as * `[major, minor, patch]`. @@ -256,6 +292,7 @@ function isLxcAvailable(): boolean { * `src/backends/bubblewrap/common/src/bwrap_version.rs` — keep both in sync. */ const MIN_BWRAP_VERSION: readonly [number, number, number] = [0, 5, 0]; +const MIN_BWRAP_VERSION_REASON = 'the sandbox uses `--clearenv`, added in bwrap 0.5.0'; /** Outcome of the Bubblewrap probe: available, or unavailable with a reason. */ type BubblewrapProbe = { available: true } | { available: false; reason: string }; @@ -269,77 +306,181 @@ type BwrapVersionResult = | { kind: 'notFound' } | { kind: 'failed'; status: number | null; detail: string }; -/** - * Whether a `bwrap` candidate exists anywhere on `PATH`. - * - * Linux reports `ENOENT` both for a genuinely absent binary and for one that - * exists but cannot be executed (a missing ELF interpreter or script shebang - * target), so the spawn error alone cannot tell `notFound` from `failed`. A - * candidate on `PATH` means the package is installed and the failure is a - * broken install. - */ -function bwrapExistsOnPath(): boolean { - const pathVar = process.env.PATH; - if (!pathVar) return false; - return pathVar - .split(path.delimiter) - .some((dir) => dir !== '' && fs.existsSync(path.join(dir, 'bwrap'))); -} - /** * How long to wait for `bwrap --version` before giving up. * * `getPlatformSupport()` is synchronous, so without a bound a `bwrap` that * hangs — a wrapper script on PATH, a binary on a stalled network mount — * would block the caller indefinitely. Printing a version string is - * near-instant, so this is generous. + * near-instant, so this is generous. This is the total wall-clock bound the + * caller observes: the supervision layers run *inside* it. */ const BWRAP_VERSION_TIMEOUT_MS = 5000; +const BWRAP_VERSION_MAX_BUFFER_BYTES = 64 * 1024; +const BWRAP_HELPER_RESULT_BYTES = 1024 * 1024; +/** + * Time reserved out of the caller's budget for the supervision layers to stop + * the probe, publish a result, and hand it back. + */ +const BWRAP_PROBE_SUPERVISION_MARGIN_MS = 1500; /** - * Default runner for `bwrap --version`. Uses `execFileSync` rather than a - * shell so a missing binary surfaces as `ENOENT` instead of the shell's - * indistinguishable exit code 127 — that separation is what lets us report - * "not installed" and "installed but broken" differently. + * Split the caller's budget into the probe deadline (how long `bwrap` itself + * may run) and the worker's publish deadline, so the caller-visible wait never + * exceeds `timeoutMs`. Budgets smaller than twice the margin split in half + * rather than starving the probe. * - * Replaceable in unit tests via {@link _setBwrapVersionRunner}. + * @internal Exported for unit tests. */ -function defaultBwrapVersionRunner(): BwrapVersionResult { +export function _bwrapProbeDeadlines(timeoutMs: number): { + probeTimeoutMs: number; + publishTimeoutMs: number; +} { + const probeTimeoutMs = Math.max( + 1, + Math.max(Math.ceil(timeoutMs / 2), timeoutMs - BWRAP_PROBE_SUPERVISION_MARGIN_MS), + ); + const publishTimeoutMs = Math.max( + probeTimeoutMs + 1, + timeoutMs - Math.ceil((timeoutMs - probeTimeoutMs) / 2), + ); + return { probeTimeoutMs, publishTimeoutMs }; +} + +function resolveBwrapProbeScript(fileName: string): string { + return path.join(bwrapProbeScriptDirectory, fileName); +} + +type BwrapProbeWorkerFactory = (fileName: string, options: WorkerOptions) => Worker; + +const defaultBwrapProbeWorkerFactory: BwrapProbeWorkerFactory = (fileName, options) => + new Worker(fileName, options); + +let bwrapProbeWorkerFactory = defaultBwrapProbeWorkerFactory; + +/** @internal Test-only: replace worker construction to exercise setup deadlines. */ +export function _setBwrapProbeWorkerFactory(factory: BwrapProbeWorkerFactory | null): void { + bwrapProbeWorkerFactory = factory ?? defaultBwrapProbeWorkerFactory; +} + +type BwrapHelperResult = + | { kind: 'completed'; status: number | null; signal: string | null; stdout: string; stderr: string } + | { kind: 'notFound' } + | { kind: 'timeout' } + | { kind: 'overflow' } + | { kind: 'spawnError'; detail: string }; + +function parseBwrapHelperResult(output: Buffer | string | undefined): BwrapHelperResult | null { + if (output === undefined || output.length === 0) return null; try { - return { - kind: 'output', - stdout: execFileSync('bwrap', ['--version'], { - encoding: 'utf-8', - stdio: 'pipe', - timeout: BWRAP_VERSION_TIMEOUT_MS, - }), - }; - } catch (err) { - const e = err as NodeJS.ErrnoException & { - status?: number | null; - stderr?: Buffer | string; - killed?: boolean; - }; - // `ENOENT` covers both an absent binary and a present-but-unusable one - // (missing ELF interpreter / shebang target), so confirm the binary is - // really absent before blaming the package manager. - if (e.code === 'ENOENT' && !bwrapExistsOnPath()) { + return JSON.parse(output.toString()) as BwrapHelperResult; + } catch { + return null; + } +} + +/** @internal Pure helper-result normalization for unit tests. */ +export function _mapBwrapHelperResult( + result: BwrapHelperResult, + timeoutMs = BWRAP_VERSION_TIMEOUT_MS, +): BwrapVersionResult { + switch (result.kind) { + case 'notFound': return { kind: 'notFound' }; - } - // Timed out: the child was killed, so there is no meaningful exit status. - if (e.code === 'ETIMEDOUT' || e.killed) { + case 'timeout': + return { kind: 'failed', status: null, detail: `timed out after ${timeoutMs}ms` }; + case 'overflow': return { kind: 'failed', status: null, - detail: `timed out after ${BWRAP_VERSION_TIMEOUT_MS}ms`, + detail: `probe output exceeded the ${BWRAP_VERSION_MAX_BUFFER_BYTES}-byte cap`, }; + case 'spawnError': + return { kind: 'failed', status: null, detail: result.detail }; + case 'completed': + if (result.status !== 0) { + return { + kind: 'failed', + status: result.status, + detail: result.stderr.trim() || (result.signal ? `signal ${result.signal}` : ''), + }; + } + return { kind: 'output', stdout: result.stdout }; + } +} + +/** + * Run `bwrap --version` beneath a detached Node sentinel that anchors the + * process group. A child helper performs asynchronous, bounded I/O; the + * supervising worker holds the helper's bounded result until the anchor closes + * after tearing down the sentinel-owned group. + * + * The probe and the worker run against deadlines derived from `timeoutMs`, so + * this call returns within `timeoutMs` even when every inner layer stalls. + * + * @internal Exported for a real-subprocess regression test. + */ +export function _runBwrapVersionCommand( + timeoutMs = BWRAP_VERSION_TIMEOUT_MS, +): BwrapVersionResult { + const started = performance.now(); + const deadline = started + Math.max(0, timeoutMs); + const shared = new SharedArrayBuffer(12 + BWRAP_HELPER_RESULT_BYTES); + const header = new Int32Array(shared, 0, 3); + const setupRemainingMs = Math.floor(deadline - performance.now()); + if (setupRemainingMs < 2) { + return { kind: 'failed', status: null, detail: `timed out after ${timeoutMs}ms` }; + } + const { probeTimeoutMs, publishTimeoutMs } = _bwrapProbeDeadlines(setupRemainingMs); + let worker: Worker; + try { + worker = bwrapProbeWorkerFactory(resolveBwrapProbeScript('bwrap-probe-worker.js'), { + workerData: { + shared, + anchorPath: resolveBwrapProbeScript('bwrap-probe-anchor.js'), + helperPath: resolveBwrapProbeScript('bwrap-probe-helper.js'), + probeTimeoutMs, + publishTimeoutMs, + outputLimit: BWRAP_VERSION_MAX_BUFFER_BYTES, + }, + }); + } catch (err) { + if (performance.now() >= deadline) { + return { kind: 'failed', status: null, detail: `timed out after ${timeoutMs}ms` }; } return { kind: 'failed', - status: e.status ?? null, - detail: e.stderr?.toString().trim() || e.message, + status: null, + detail: err instanceof Error ? err.message : String(err), }; } + worker.on('error', () => {}); + const waitRemainingMs = Math.max(0, deadline - performance.now()); + const waitResult = Atomics.wait(header, 0, 0, waitRemainingMs); + if (waitResult === 'timed-out') { + if (Atomics.compareExchange(header, 0, 0, -1) === 0) { + // The worker owns process cleanup. It may not have published the anchor + // PID yet, so leave it alive to observe -1 and terminate the group + // without exposing a stale PID to this process. + worker.unref(); + return { kind: 'failed', status: null, detail: `timed out after ${timeoutMs}ms` }; + } + // The worker published just as Atomics.wait timed out. Consume that result + // instead of overwriting it with a timeout or signalling its former PID. + } + const length = Atomics.load(header, 1); + const result = parseBwrapHelperResult(Buffer.from(shared, 12, length)); + // The worker owns the anchor's ChildProcess handle and must remain alive + // long enough for libuv to reap it after process-group cleanup. + worker.unref(); + return result + ? _mapBwrapHelperResult(result, timeoutMs) + : { kind: 'failed', status: null, detail: 'probe helper returned no result' }; +} + +/** Default runner, replaceable in unit tests via {@link _setBwrapVersionRunner}. */ +function defaultBwrapVersionRunner(): BwrapVersionResult { + return _runBwrapVersionCommand(); } let bwrapVersionRunner: () => BwrapVersionResult = defaultBwrapVersionRunner; @@ -420,8 +561,8 @@ function compareVersions( * fails closed — without a version we cannot assert the required flags exist. * * Mirrors `probe_bwrap` in - * `src/backends/bubblewrap/common/src/bwrap_version.rs`, including the - * distinction between a missing binary and a present-but-broken one. + * `src/backends/bubblewrap/common/src/bwrap_version.rs`. A missing command is + * distinct from observed process failures. * * @internal Exported for unit tests. */ @@ -432,20 +573,23 @@ export function _probeBubblewrap(): BubblewrapProbe { if (result.kind === 'notFound') { return { available: false, - reason: `Bubblewrap (bwrap) is not installed or not on PATH; version ${minVersion} or newer is required`, + reason: + `Bubblewrap (bwrap) is not installed or not on PATH. ` + + `Install it via your package manager (e.g., apt install bubblewrap). ` + + `Version ${minVersion} or newer is required.`, }; } if (result.kind === 'failed') { - // Present but broken: do not send the user to their package manager for a - // package they already have. - // Covers both a spawn failure and termination by a signal, neither of - // which yields an exit code. + // This includes failures before PATH lookup completes, so do not claim + // that a Bubblewrap executable was observed. const where = result.status === null ? 'failed without an exit status' : `exited with status ${result.status}`; const detail = result.detail ? `: ${result.detail}` : ''; return { available: false, - reason: `Bubblewrap (bwrap) is present but \`bwrap --version\` ${where}${detail}; version ${minVersion} or newer is required`, + reason: + `The Bubblewrap (bwrap) availability probe \`bwrap --version\` ${where}${detail}. ` + + `Version ${minVersion} or newer is required; check PATH and the installation before using the Bubblewrap backend.`, }; } @@ -453,13 +597,17 @@ export function _probeBubblewrap(): BubblewrapProbe { if (!version) { return { available: false, - reason: `could not determine the Bubblewrap (bwrap) version from ${JSON.stringify(result.stdout.trim())}; version ${minVersion} or newer is required`, + reason: + `Could not determine the Bubblewrap (bwrap) version: \`bwrap --version\` printed ` + + `${JSON.stringify(result.stdout.trim())}. Version ${minVersion} or newer is required.`, }; } if (compareVersions(version, MIN_BWRAP_VERSION) < 0) { return { available: false, - reason: `Bubblewrap (bwrap) ${version.join('.')} is too old; version ${minVersion} or newer is required`, + reason: + `Bubblewrap (bwrap) ${version.join('.')} is too old: version ${minVersion} or newer is required ` + + `(${MIN_BWRAP_VERSION_REASON}). Upgrade the bubblewrap package.`, }; } return { available: true }; diff --git a/sdk/node/src/types.ts b/sdk/node/src/types.ts index de029318b..73bf05f06 100644 --- a/sdk/node/src/types.ts +++ b/sdk/node/src/types.ts @@ -510,6 +510,12 @@ export interface PlatformSupport { reason?: string; /** Available sandboxing methods on this platform */ availableMethods: ContainmentBackend[]; + /** + * Why individual Linux backends are unavailable, whether or not another + * backend keeps Linux supported. Omitted on other platforms and when no + * Linux backend failures were observed. + */ + unavailableReasons?: Partial>; /** * Tier that would be selected for an empty policy on this system. * Omitted on non-Windows platforms or when the probe fails. diff --git a/sdk/node/tests/unit/platform.test.ts b/sdk/node/tests/unit/platform.test.ts index 194641342..2fd2cf555 100644 --- a/sdk/node/tests/unit/platform.test.ts +++ b/sdk/node/tests/unit/platform.test.ts @@ -3,20 +3,77 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert'; +import * as fs from 'node:fs'; import * as os from 'os'; import * as path from 'path'; +import { Worker } from 'node:worker_threads'; import { getPlatformSupport, _resetPlatformSupportCache, _setProbeRunner, _parseBwrapVersion, _probeBubblewrap, + _mapBwrapHelperResult, + _bwrapProbeDeadlines, + _runBwrapVersionCommand, + _setBwrapProbeWorkerFactory, _setBwrapVersionRunner, + _setLxcAvailabilityProbe, + _setPlatformDiagnosticLogger, findWxcExecutable, } from '../../src/platform.js'; const isWindows = os.platform() === 'win32'; +function readPidFileEventually(pidFile: string, timeoutMs = 5000): number { + const pollBuffer = new Int32Array(new SharedArrayBuffer(4)); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + return Number.parseInt(fs.readFileSync(pidFile, 'utf8'), 10); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + Atomics.wait(pollBuffer, 0, 0, 10); + } + } + throw new Error(`probe did not write ${pidFile} within ${timeoutMs}ms`); +} + +function isProcessGoneError(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException).code; + return code === 'ENOENT' || code === 'ESRCH'; +} + +function assertProcessTerminated(pid: number, message: string): void { + try { + const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); + const state = stat.slice(stat.lastIndexOf(') ') + 2, stat.lastIndexOf(') ') + 3); + assert.strictEqual(state, 'Z', message); + } catch (err) { + if (!isProcessGoneError(err)) throw err; + } +} + +function directZombieChildren(): Set { + const zombies = new Set(); + if (os.platform() !== 'linux') return zombies; + for (const entry of fs.readdirSync('/proc')) { + if (!/^\d+$/.test(entry)) continue; + try { + const status = fs.readFileSync(`/proc/${entry}/status`, 'utf8'); + if ( + status.match(/^PPid:\s+(\d+)$/m)?.[1] === String(process.pid) && + status.match(/^State:\s+(\w)/m)?.[1] === 'Z' + ) { + zombies.add(Number(entry)); + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + } + return zombies; +} + const allUiCapabilities = { canBlockClipboardRead: true, canBlockClipboardWrite: true, @@ -448,12 +505,559 @@ describe('bwrap version parsing', () => { }); }); +describe('bwrap subprocess helpers', () => { + it('publishes a worker result only after the anchor closes', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-bwrap-anchor-order-')); + const anchorPath = path.join(dir, 'delayed-anchor.js'); + fs.writeFileSync( + anchorPath, + "process.stdout.write('{\"kind\":\"notFound\"}\\n');\n" + + 'setTimeout(() => process.exit(0), 300);\n', + ); + const shared = new SharedArrayBuffer(12 + 1024); + const header = new Int32Array(shared, 0, 3); + const worker = new Worker( + new URL('../../src/bwrap-probe-worker.js', import.meta.url), + { + workerData: { + shared, + anchorPath, + helperPath: anchorPath, + probeTimeoutMs: 1000, + publishTimeoutMs: 900, + outputLimit: 1024, + }, + }, + ); + worker.on('error', () => {}); + try { + const started = Date.now(); + const waitResult = Atomics.wait(header, 0, 0, 1000); + const elapsed = Date.now() - started; + assert.notStrictEqual(waitResult, 'timed-out'); + assert.ok(elapsed >= 200, `worker published after ${elapsed}ms, before anchor close`); + } finally { + worker.unref(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('ignores anchor stderr so diagnostics cannot stall cleanup', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-bwrap-anchor-stderr-')); + const anchorPath = path.join(dir, 'noisy-anchor.js'); + fs.writeFileSync( + anchorPath, + "const fs = require('node:fs');\n" + + "fs.writeSync(2, Buffer.alloc(2 * 1024 * 1024, 'x'));\n" + + "process.stdout.write('{\"kind\":\"notFound\"}\\n');\n", + ); + const shared = new SharedArrayBuffer(12 + 1024); + const header = new Int32Array(shared, 0, 3); + const worker = new Worker( + new URL('../../src/bwrap-probe-worker.js', import.meta.url), + { + workerData: { + shared, + anchorPath, + helperPath: anchorPath, + probeTimeoutMs: 1000, + publishTimeoutMs: 900, + outputLimit: 1024, + }, + }, + ); + worker.on('error', () => {}); + try { + const waitResult = Atomics.wait(header, 0, 0, 2000); + assert.notStrictEqual(waitResult, 'timed-out'); + const length = Atomics.load(header, 1); + assert.deepStrictEqual( + JSON.parse(Buffer.from(shared, 12, length).toString()), + { kind: 'notFound' }, + ); + } finally { + worker.unref(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('maps bounded helper results', () => { + assert.deepStrictEqual(_mapBwrapHelperResult({ kind: 'timeout' }, 50), { + kind: 'failed', + status: null, + detail: 'timed out after 50ms', + }); + assert.deepStrictEqual(_mapBwrapHelperResult({ kind: 'overflow' }), { + kind: 'failed', + status: null, + detail: 'probe output exceeded the 65536-byte cap', + }); + assert.deepStrictEqual( + _mapBwrapHelperResult({ kind: 'notFound' }), + { kind: 'notFound' }, + ); + assert.deepStrictEqual( + _mapBwrapHelperResult({ + kind: 'completed', + status: 124, + signal: null, + stdout: '', + stderr: 'wrapper failed\n', + }), + { kind: 'failed', status: 124, detail: 'wrapper failed' }, + ); + assert.deepStrictEqual( + _mapBwrapHelperResult({ + kind: 'completed', + status: 126, + signal: null, + stdout: '', + stderr: '', + }), + { kind: 'failed', status: 126, detail: '' }, + ); + }); + + it( + 'reaps the detached anchor after repeated probes', + { skip: os.platform() !== 'linux' }, + () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-bwrap-reap-')); + const originalPath = process.env.PATH; + const existingZombies = directZombieChildren(); + try { + process.env.PATH = dir; + for (let i = 0; i < 5; i += 1) { + assert.deepStrictEqual(_runBwrapVersionCommand(1000), { kind: 'notFound' }); + } + + const pollBuffer = new Int32Array(new SharedArrayBuffer(4)); + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + const newZombies = [...directZombieChildren()].filter( + (pid) => !existingZombies.has(pid), + ); + if (newZombies.length === 0) return; + Atomics.wait(pollBuffer, 0, 0, 10); + } + const newZombies = [...directZombieChildren()].filter( + (pid) => !existingZombies.has(pid), + ); + assert.deepStrictEqual(newZombies, [], 'probe anchors must be reaped'); + } finally { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + fs.rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it( + 'bounds actual subprocess output at 64 KiB', + { skip: os.platform() !== 'linux' }, + () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-bwrap-overflow-')); + const originalPath = process.env.PATH; + try { + const wrapper = path.join(dir, 'bwrap'); + fs.writeFileSync( + wrapper, + '#!/bin/sh\n' + + "dd if=/dev/zero bs=70000 count=1 2>/dev/null | tr '\\000' x\n", + ); + fs.chmodSync(wrapper, 0o755); + process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ''}`; + + assert.deepStrictEqual(_runBwrapVersionCommand(3000), { + kind: 'failed', + status: null, + detail: 'probe output exceeded the 65536-byte cap', + }); + } finally { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + fs.rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it( + 'forwards a multi-chunk helper result without truncating its JSON', + { skip: os.platform() !== 'linux' }, + () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-bwrap-large-result-')); + const originalPath = process.env.PATH; + try { + const wrapper = path.join(dir, 'bwrap'); + fs.writeFileSync( + wrapper, + '#!/bin/sh\n' + + "printf 'bubblewrap 0.5.0 '\n" + + "dd if=/dev/zero bs=60000 count=1 2>/dev/null | tr '\\000' x\n", + ); + fs.chmodSync(wrapper, 0o755); + process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ''}`; + + const result = _runBwrapVersionCommand(3000); + assert.strictEqual(result.kind, 'output'); + if (result.kind === 'output') { + assert.ok(result.stdout.startsWith('bubblewrap 0.5.0 ')); + assert.ok(result.stdout.length > 60000); + } + } finally { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + fs.rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it( + 'bounds a wrapper whose background descendant retains stdout and stderr', + { skip: os.platform() !== 'linux' }, + () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-bwrap-descendant-')); + const originalPath = process.env.PATH; + const originalPidFile = process.env.MXC_TEST_DESCENDANT_PID_FILE; + const pidFile = path.join(dir, 'descendant.pid'); + let descendantPid: number | undefined; + try { + const wrapper = path.join(dir, 'bwrap'); + fs.writeFileSync( + wrapper, + '#!/bin/sh\n' + + '(sleep 30) &\n' + + 'echo "$!" > "$MXC_TEST_DESCENDANT_PID_FILE"\n' + + "exec /bin/echo 'bubblewrap 0.5.0'\n", + ); + fs.chmodSync(wrapper, 0o755); + process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ''}`; + process.env.MXC_TEST_DESCENDANT_PID_FILE = pidFile; + + const result = _runBwrapVersionCommand(1000); + assert.deepStrictEqual(result, { + kind: 'failed', + status: null, + detail: 'timed out after 1000ms', + }); + descendantPid = readPidFileEventually(pidFile); + const pollBuffer = new Int32Array(new SharedArrayBuffer(4)); + const deadline = Date.now() + 1000; + let terminated = false; + while (Date.now() < deadline) { + try { + const stat = fs.readFileSync(`/proc/${descendantPid}/stat`, 'utf8'); + const state = stat.slice(stat.lastIndexOf(') ') + 2, stat.lastIndexOf(') ') + 3); + if (state === 'Z') { + terminated = true; + break; + } + Atomics.wait(pollBuffer, 0, 0, 10); + } catch (err) { + if (isProcessGoneError(err)) { + terminated = true; + break; + } + throw err; + } + } + assert.ok(terminated, 'probe must terminate the background descendant'); + } finally { + if (descendantPid !== undefined) { + try { + process.kill(descendantPid, 'SIGKILL'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ESRCH') throw err; + } + } + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + if (originalPidFile === undefined) { + delete process.env.MXC_TEST_DESCENDANT_PID_FILE; + } else { + process.env.MXC_TEST_DESCENDANT_PID_FILE = originalPidFile; + } + fs.rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it( + 'does not return a successful result before terminating closed-pipe descendants', + { skip: os.platform() !== 'linux' }, + () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-bwrap-closed-descendant-')); + const originalPath = process.env.PATH; + const originalPidFile = process.env.MXC_TEST_DESCENDANT_PID_FILE; + const pidFile = path.join(dir, 'descendant.pid'); + let descendantPid: number | undefined; + try { + const wrapper = path.join(dir, 'bwrap'); + fs.writeFileSync( + wrapper, + '#!/bin/sh\n' + + '(sleep 30) >/dev/null 2>&1 &\n' + + 'echo "$!" > "$MXC_TEST_DESCENDANT_PID_FILE"\n' + + "exec /bin/echo 'bubblewrap 0.5.0'\n", + ); + fs.chmodSync(wrapper, 0o755); + process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ''}`; + process.env.MXC_TEST_DESCENDANT_PID_FILE = pidFile; + + const result = _runBwrapVersionCommand(1000); + assert.deepStrictEqual(result, { + kind: 'output', + stdout: 'bubblewrap 0.5.0\n', + }); + descendantPid = readPidFileEventually(pidFile); + assertProcessTerminated( + descendantPid, + 'probe returned before terminating the background descendant', + ); + } finally { + if (descendantPid !== undefined) { + try { + process.kill(descendantPid, 'SIGKILL'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ESRCH') throw err; + } + } + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + if (originalPidFile === undefined) { + delete process.env.MXC_TEST_DESCENDANT_PID_FILE; + } else { + process.env.MXC_TEST_DESCENDANT_PID_FILE = originalPidFile; + } + fs.rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it('nests the probe and publish deadlines inside the caller budget', () => { + for (const timeoutMs of [5000, 1000, 100, 2]) { + const { probeTimeoutMs, publishTimeoutMs } = _bwrapProbeDeadlines(timeoutMs); + assert.ok(probeTimeoutMs >= 1, `probe deadline must be positive for ${timeoutMs}ms`); + assert.ok( + probeTimeoutMs < publishTimeoutMs, + `probe deadline must precede the publish deadline for ${timeoutMs}ms`, + ); + assert.ok( + publishTimeoutMs <= timeoutMs, + `publish deadline must stay inside the caller budget for ${timeoutMs}ms`, + ); + } + }); + + it('subtracts worker setup time from the caller-visible wait budget', async () => { + const delay = new Int32Array(new SharedArrayBuffer(4)); + let worker: Worker | undefined; + _setBwrapProbeWorkerFactory(() => { + Atomics.wait(delay, 0, 0, 300); + worker = new Worker('setInterval(() => {}, 1000);', { eval: true }); + return worker; + }); + try { + const started = Date.now(); + const result = _runBwrapVersionCommand(400); + const elapsed = Date.now() - started; + assert.deepStrictEqual(result, { + kind: 'failed', + status: null, + detail: 'timed out after 400ms', + }); + assert.ok(elapsed >= 350, `probe returned before its remaining budget: ${elapsed}ms`); + assert.ok(elapsed < 550, `probe added a full wait after setup: ${elapsed}ms`); + } finally { + _setBwrapProbeWorkerFactory(null); + if (worker) await worker.terminate(); + } + }); + + it( + 'returns within the caller-visible timeout when the probe hangs', + { skip: os.platform() !== 'linux' }, + () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-bwrap-bound-')); + const originalPath = process.env.PATH; + try { + const wrapper = path.join(dir, 'bwrap'); + fs.writeFileSync(wrapper, '#!/bin/sh\nexec sleep 30\n'); + fs.chmodSync(wrapper, 0o755); + process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ''}`; + + const started = Date.now(); + const result = _runBwrapVersionCommand(1000); + const elapsed = Date.now() - started; + assert.deepStrictEqual(result, { + kind: 'failed', + status: null, + detail: 'timed out after 1000ms', + }); + // The supervision layers run inside the caller's budget, so the total + // wait must not stack their margins on top of it. + assert.ok(elapsed < 1500, `probe took ${elapsed}ms, expected under 1500ms`); + } finally { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + fs.rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it( + 'reports an existing wrapper with a missing interpreter as broken', + { skip: os.platform() !== 'linux' }, + () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-bwrap-missing-interpreter-')); + const originalPath = process.env.PATH; + try { + const wrapper = path.join(dir, 'bwrap'); + fs.writeFileSync(wrapper, '#!/this/interpreter/does/not/exist\n'); + fs.chmodSync(wrapper, 0o755); + // Keep execvp from falling through to a real system bwrap after the + // synthetic wrapper's missing shebang interpreter returns ENOENT. + process.env.PATH = dir; + + const result = _runBwrapVersionCommand(1000); + assert.strictEqual(result.kind, 'failed'); + if (result.kind === 'failed') { + assert.strictEqual(result.status, null); + assert.match(result.detail, /missing interpreter or loader/i); + } + } finally { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + fs.rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it( + 'reports an actually missing bwrap executable as not found', + { skip: os.platform() !== 'linux' }, + () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-bwrap-missing-')); + const originalPath = process.env.PATH; + try { + process.env.PATH = dir; + assert.deepStrictEqual(_runBwrapVersionCommand(1000), { kind: 'notFound' }); + } finally { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + fs.rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + it( + 'terminates the probe when the helper exits without printing a result', + { skip: os.platform() !== 'linux' }, + () => { + // The wrapper kills the probe helper but keeps running. A separate + // sentinel must retain process-group ownership until the worker kills the + // whole group; using the helper itself as leader orphaned this wrapper. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-bwrap-no-result-')); + const originalPath = process.env.PATH; + const originalPidFile = process.env.MXC_TEST_DESCENDANT_PID_FILE; + const pidFile = path.join(dir, 'probe.pid'); + let probePid: number | undefined; + try { + const wrapper = path.join(dir, 'bwrap'); + fs.writeFileSync( + wrapper, + '#!/bin/sh\n' + + 'echo "$$" > "$MXC_TEST_DESCENDANT_PID_FILE"\n' + + 'kill $PPID\n' + + 'sleep 30\n', + ); + fs.chmodSync(wrapper, 0o755); + process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ''}`; + process.env.MXC_TEST_DESCENDANT_PID_FILE = pidFile; + + const result = _runBwrapVersionCommand(1000); + assert.strictEqual(result.kind, 'failed'); + if (result.kind === 'failed') { + assert.match(result.detail, /exited without a result/i); + } + probePid = readPidFileEventually(pidFile); + const pollBuffer = new Int32Array(new SharedArrayBuffer(4)); + const deadline = Date.now() + 1000; + let terminated = false; + while (Date.now() < deadline) { + try { + const stat = fs.readFileSync(`/proc/${probePid}/stat`, 'utf8'); + const state = stat.slice(stat.lastIndexOf(') ') + 2, stat.lastIndexOf(') ') + 3); + if (state === 'Z') { + terminated = true; + break; + } + Atomics.wait(pollBuffer, 0, 0, 10); + } catch (err) { + if (isProcessGoneError(err)) { + terminated = true; + break; + } + throw err; + } + } + assert.ok(terminated, 'probe must terminate after its helper exits'); + } finally { + if (probePid !== undefined) { + try { + process.kill(probePid, 'SIGKILL'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ESRCH') throw err; + } + } + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + if (originalPidFile === undefined) { + delete process.env.MXC_TEST_DESCENDANT_PID_FILE; + } else { + process.env.MXC_TEST_DESCENDANT_PID_FILE = originalPidFile; + } + fs.rmSync(dir, { recursive: true, force: true }); + } + }, + ); +}); + // The minimum-version comparison itself, driven through the injectable // runner. Without these the SDK gate could drift from the Rust gate in // `src/backends/bubblewrap/common/src/bwrap_version.rs` unnoticed. describe('bwrap minimum-version gate', () => { afterEach(() => { _setBwrapVersionRunner(null); + _setLxcAvailabilityProbe(null); + _setPlatformDiagnosticLogger(null); _resetPlatformSupportCache(); }); @@ -475,8 +1079,11 @@ describe('bwrap minimum-version gate', () => { withVersion('bubblewrap 0.4.1\n'); const probe = _probeBubblewrap(); assert.strictEqual(probe.available, false); - assert.match(probe.reason, /0\.4\.1 is too old/); - assert.match(probe.reason, /0\.5\.0 or newer/); + assert.strictEqual( + probe.reason, + 'Bubblewrap (bwrap) 0.4.1 is too old: version 0.5.0 or newer is required ' + + '(the sandbox uses `--clearenv`, added in bwrap 0.5.0). Upgrade the bubblewrap package.', + ); }); it('rejects the release immediately below the floor', () => { @@ -497,21 +1104,30 @@ describe('bwrap minimum-version gate', () => { withVersion('something else entirely\n'); const probe = _probeBubblewrap(); assert.strictEqual(probe.available, false); - assert.match(probe.reason, /could not determine/); + assert.strictEqual( + probe.reason, + 'Could not determine the Bubblewrap (bwrap) version: `bwrap --version` printed ' + + '"something else entirely". Version 0.5.0 or newer is required.', + ); }); it('fails closed when unrelated output contains a number', () => { withVersion('some other tool 999\n'); const probe = _probeBubblewrap(); assert.strictEqual(probe.available, false); - assert.match(probe.reason, /could not determine/); + assert.match(probe.reason, /could not determine/i); }); it('reports a missing binary as not installed', () => { _setBwrapVersionRunner(() => ({ kind: 'notFound' })); const probe = _probeBubblewrap(); assert.strictEqual(probe.available, false); - assert.match(probe.reason, /not installed or not on PATH/); + assert.strictEqual( + probe.reason, + 'Bubblewrap (bwrap) is not installed or not on PATH. ' + + 'Install it via your package manager (e.g., apt install bubblewrap). ' + + 'Version 0.5.0 or newer is required.', + ); }); it('reports a present but broken binary distinctly from a missing one', () => { @@ -524,10 +1140,12 @@ describe('bwrap minimum-version gate', () => { })); const probe = _probeBubblewrap(); assert.strictEqual(probe.available, false); - assert.match(probe.reason, /is present but/); - assert.match(probe.reason, /126/); - assert.match(probe.reason, /permission denied/); - assert.doesNotMatch(probe.reason, /not installed/); + assert.strictEqual( + probe.reason, + 'The Bubblewrap (bwrap) availability probe `bwrap --version` exited with status 126: ' + + 'bwrap: permission denied. Version 0.5.0 or newer is required; ' + + 'check PATH and the installation before using the Bubblewrap backend.', + ); }); it('reports a timed-out probe as a failure rather than hanging', () => { @@ -541,6 +1159,7 @@ describe('bwrap minimum-version gate', () => { const probe = _probeBubblewrap(); assert.strictEqual(probe.available, false); assert.match(probe.reason, /timed out after 5000ms/); + assert.doesNotMatch(probe.reason, /is present/); }); it('describes a failure that has no exit status without claiming it never ran', () => { @@ -567,4 +1186,71 @@ describe('bwrap minimum-version gate', () => { _resetPlatformSupportCache(); assert.ok(getPlatformSupport().availableMethods.includes('bubblewrap')); }); + + it( + 'keeps Linux supported and logs the bwrap reason when LXC is available', + { skip: os.platform() !== 'linux' }, + () => { + _setLxcAvailabilityProbe(() => true); + withVersion('bubblewrap 0.4.1\n'); + const logs: string[] = []; + _setPlatformDiagnosticLogger((message) => logs.push(message)); + _resetPlatformSupportCache(); + + const support = getPlatformSupport(); + assert.strictEqual(support.isSupported, true); + assert.deepStrictEqual(support.availableMethods, ['lxc']); + assert.strictEqual(support.reason, ''); + assert.deepStrictEqual(support.unavailableReasons, { + bubblewrap: + 'Bubblewrap (bwrap) 0.4.1 is too old: version 0.5.0 or newer is required ' + + '(the sandbox uses `--clearenv`, added in bwrap 0.5.0). Upgrade the bubblewrap package.', + }); + assert.strictEqual(logs.length, 1); + assert.match(logs[0], /0\.4\.1 is too old/i); + }, + ); + + it( + 'reports the bwrap failure reason when neither Linux backend is available', + { skip: os.platform() !== 'linux' }, + () => { + _setLxcAvailabilityProbe(() => false); + withVersion('bubblewrap 0.4.1\n'); + _resetPlatformSupportCache(); + + const support = getPlatformSupport(); + assert.strictEqual(support.isSupported, false); + assert.deepStrictEqual(support.availableMethods, []); + assert.deepStrictEqual(support.unavailableReasons, { + lxc: 'LXC is not installed or not available on this system.', + bubblewrap: + 'Bubblewrap (bwrap) 0.4.1 is too old: version 0.5.0 or newer is required ' + + '(the sandbox uses `--clearenv`, added in bwrap 0.5.0). Upgrade the bubblewrap package.', + }); + assert.strictEqual( + support.reason, + 'Neither LXC nor Bubblewrap is available on this system ' + + '(Bubblewrap (bwrap) 0.4.1 is too old: version 0.5.0 or newer is required ' + + '(the sandbox uses `--clearenv`, added in bwrap 0.5.0). Upgrade the bubblewrap package.)', + ); + }, + ); + + it( + 'reports LXC as unavailable when Bubblewrap keeps Linux supported', + { skip: os.platform() !== 'linux' }, + () => { + _setLxcAvailabilityProbe(() => false); + withVersion('bubblewrap 0.5.0\n'); + _resetPlatformSupportCache(); + + const support = getPlatformSupport(); + assert.strictEqual(support.isSupported, true); + assert.deepStrictEqual(support.availableMethods, ['bubblewrap']); + assert.deepStrictEqual(support.unavailableReasons, { + lxc: 'LXC is not installed or not available on this system.', + }); + }, + ); }); diff --git a/sdk/node/tests/unit/sandbox.test.ts b/sdk/node/tests/unit/sandbox.test.ts index 054346cba..3b3288f1a 100644 --- a/sdk/node/tests/unit/sandbox.test.ts +++ b/sdk/node/tests/unit/sandbox.test.ts @@ -5,6 +5,11 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; import { buildSandboxPayload, createConfigFromPolicy, spawnSandbox, spawnSandboxFromConfig } from '../../src/sandbox.js'; import { resolveExecutableAndArgs } from '../../src/helper.js'; +import { + _resetPlatformSupportCache, + _setBwrapVersionRunner, + _setLxcAvailabilityProbe, +} from '../../src/platform.js'; import { ContainerConfig, SandboxPolicy, SandboxingMethod } from '../../src/types.js'; import { platformSkip } from './test-helpers.js'; @@ -1069,6 +1074,31 @@ describe('resolveExecutableAndArgs (containment validation)', { skip: platformSk ); }); + it('includes the selected Linux backend failure reason', function (this: { skip: (reason?: string) => void }) { + if (process.platform !== 'linux') { + this.skip('per-backend availability reasons are Linux-only'); + return; + } + try { + _setLxcAvailabilityProbe(() => true); + _setBwrapVersionRunner(() => ({ + kind: 'failed', + status: null, + detail: 'timed out after 5000ms', + })); + _resetPlatformSupportCache(); + + assert.throws( + () => resolveExecutableAndArgs(makeConfig('bubblewrap'), { executablePath: fakeExe }), + { message: /timed out after 5000ms/ }, + ); + } finally { + _setLxcAvailabilityProbe(null); + _setBwrapVersionRunner(null); + _resetPlatformSupportCache(); + } + }); + it('should still require experimental mode for experimental backends like wslc', () => { assert.throws( () => resolveExecutableAndArgs(makeConfig('wslc'), { executablePath: fakeExe }), diff --git a/src/backends/bubblewrap/common/src/bwrap_runner.rs b/src/backends/bubblewrap/common/src/bwrap_runner.rs index d2354eb41..5fda99d57 100644 --- a/src/backends/bubblewrap/common/src/bwrap_runner.rs +++ b/src/backends/bubblewrap/common/src/bwrap_runner.rs @@ -55,10 +55,15 @@ impl BubblewrapScriptRunner { pub fn new() -> Self { Self } -} -impl SandboxBackend for BubblewrapScriptRunner { - fn validate(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { + fn validate_with_probe( + &self, + request: &ExecutionRequest, + probe: F, + ) -> Result<(), ScriptResponse> + where + F: FnOnce() -> Result, + { // User-input validation runs before the environmental `bwrap` // probe so config errors are reported deterministically even on // hosts without bwrap installed. @@ -75,12 +80,18 @@ impl SandboxBackend for BubblewrapScriptRunner { // `bwrap` must be present *and* new enough for every flag the argument // builder emits — an old binary would otherwise fail at spawn time with // an opaque "unknown option" error. - if let Err(err) = bwrap_version::probe_bwrap() { + if let Err(err) = probe() { return Err(ScriptResponse::error(&err.to_string())); } Ok(()) } +} + +impl SandboxBackend for BubblewrapScriptRunner { + fn validate(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { + self.validate_with_probe(request, bwrap_version::probe_bwrap_uncached) + } fn spawn( &mut self, @@ -90,6 +101,17 @@ impl SandboxBackend for BubblewrapScriptRunner { ) -> Result, ScriptResponse> { validate_common(request)?; self.validate(request)?; + self.spawn_after_validation(request, logger, stdio) + } +} + +impl BubblewrapScriptRunner { + fn spawn_after_validation( + &mut self, + request: &ExecutionRequest, + logger: &mut Logger, + stdio: StdioMode, + ) -> Result, ScriptResponse> { // Object-based FS-policy normalization (D6): tighten aliases of the same // host object to the strictest intent (deny > ro > rw). Done here, close // to mount — config_parser stays string-only and the TOCTOU window @@ -654,7 +676,9 @@ mod tests { req.testing_features_enabled = false; let runner = BubblewrapScriptRunner::new(); - assert!(runner.validate(&req).is_ok()); + assert!(runner + .validate_with_probe(&req, || Ok(bwrap_version::MIN_BWRAP_VERSION)) + .is_ok()); } #[test] @@ -665,10 +689,36 @@ mod tests { req.script_code = String::new(); let runner = BubblewrapScriptRunner::new(); - let err = runner.validate(&req).unwrap_err(); + let err = runner + .validate_with_probe(&req, || { + panic!("environment probe must not run for invalid input") + }) + .unwrap_err(); assert!(err.error_message.contains("script_code is empty")); } + #[test] + fn validate_surfaces_every_environment_probe_failure() { + let request = base_request(); + let failures = [ + bwrap_version::BwrapUnavailable::NotFound, + bwrap_version::BwrapUnavailable::ProbeFailed { + status: Some(126), + detail: "permission denied".to_string(), + }, + bwrap_version::BwrapUnavailable::UnrecognizedVersion("junk".to_string()), + bwrap_version::BwrapUnavailable::TooOld(bwrap_version::BwrapVersion::new(0, 4, 1)), + ]; + + for failure in failures { + let expected = failure.to_string(); + let error = BubblewrapScriptRunner::new() + .validate_with_probe(&request, || Err(failure)) + .unwrap_err(); + assert_eq!(error.error_message, expected); + } + } + /// A denied symlink pointing at a **directory** is rewritten to its canonical /// target so the mask lands on the real directory (bwrap cannot mount a mask /// over a symlink whose parent is bound). The resolved directory is diff --git a/src/backends/bubblewrap/common/src/bwrap_version.rs b/src/backends/bubblewrap/common/src/bwrap_version.rs index 17a33a2f7..2255f2f93 100644 --- a/src/backends/bubblewrap/common/src/bwrap_version.rs +++ b/src/backends/bubblewrap/common/src/bwrap_version.rs @@ -12,8 +12,21 @@ //! The parsing half is pure (no I/O), so it is unit-tested on every host; only //! [`probe_bwrap`] shells out. +use std::ffi::OsStr; use std::fmt; +use std::io::{self, Read}; +use std::path::Path; +#[cfg(target_os = "linux")] +use std::process::Child; use std::process::{Command, Stdio}; +use std::sync::{mpsc, Condvar, Mutex, OnceLock}; +use std::thread; +use std::time::{Duration, Instant}; + +#[cfg(unix)] +use std::os::fd::AsRawFd; +#[cfg(unix)] +use std::os::unix::process::CommandExt; /// The minimum `bwrap` version the Bubblewrap backend supports. /// @@ -31,6 +44,86 @@ use std::process::{Command, Stdio}; /// builder ever adopts a newer flag, raise this constant in the same change. pub const MIN_BWRAP_VERSION: BwrapVersion = BwrapVersion::new(0, 5, 0); +/// Why [`MIN_BWRAP_VERSION`] is the compatibility floor. +pub const MIN_BWRAP_VERSION_REASON: &str = "the sandbox uses `--clearenv`, added in bwrap 0.5.0"; + +const BWRAP_VERSION_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_BWRAP_VERSION_OUTPUT_BYTES: usize = 64 * 1024; +const INITIAL_WAIT_POLL_INTERVAL: Duration = Duration::from_millis(1); +const MAX_WAIT_POLL_INTERVAL: Duration = Duration::from_millis(10); + +static CACHED_BWRAP_VERSION: OnceLock = OnceLock::new(); +static CACHED_BWRAP_PROBE_GATE: OnceLock = OnceLock::new(); +static BWRAP_PROBE_GATE: OnceLock = OnceLock::new(); + +fn cached_probe_gate() -> &'static ProbeGate { + CACHED_BWRAP_PROBE_GATE.get_or_init(ProbeGate::default) +} + +fn probe_gate() -> &'static ProbeGate { + BWRAP_PROBE_GATE.get_or_init(ProbeGate::default) +} + +#[derive(Debug, Default)] +struct ProbeGate { + in_flight: Mutex, + available: Condvar, +} + +#[derive(Debug)] +struct ProbeGatePermit<'a> { + gate: &'a ProbeGate, +} + +impl Drop for ProbeGatePermit<'_> { + fn drop(&mut self) { + self.gate.release(); + } +} + +impl ProbeGate { + fn acquire_until(&self, deadline: Instant) -> bool { + let mut in_flight = self + .in_flight + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return false; + } + + if !*in_flight { + *in_flight = true; + return true; + } + + let (next, wait_result) = self + .available + .wait_timeout(in_flight, remaining) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + in_flight = next; + if wait_result.timed_out() { + return false; + } + } + } + + fn release(&self) { + let mut in_flight = self + .in_flight + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *in_flight = false; + self.available.notify_all(); + } + + fn acquire_permit_until(&self, deadline: Instant) -> Option> { + self.acquire_until(deadline) + .then_some(ProbeGatePermit { gate: self }) + } +} + /// A `major.minor.patch` Bubblewrap version. /// /// Ordering is the derived field order (major, then minor, then patch), which @@ -65,14 +158,13 @@ impl fmt::Display for BwrapVersion { /// `validate` and the engine's platform probe) share one message source. #[derive(Debug, Clone, PartialEq, Eq)] pub enum BwrapUnavailable { - /// `bwrap` could not be found — the spawn failed with - /// [`std::io::ErrorKind::NotFound`]. + /// No `bwrap` executable was found on `PATH`. NotFound, - /// `bwrap` was found but `bwrap --version` did not complete successfully - /// (e.g. a permissions problem, a dynamic-loader failure, or a non-zero - /// exit). Distinct from [`Self::NotFound`] so the reported remediation is - /// not the misleading "install the package", and so the underlying cause is - /// preserved rather than discarded. + /// The `bwrap --version` probe failed to start or did not complete + /// successfully (e.g. a permissions problem or a non-zero exit). Distinct + /// from [`Self::NotFound`] so observed failures preserve their underlying + /// cause. An executable found on `PATH` with a missing loader or shebang + /// target maps here rather than to [`Self::NotFound`]. ProbeFailed { /// Exit status of `bwrap --version`, when the process ran at all. status: Option, @@ -96,7 +188,10 @@ impl fmt::Display for BwrapUnavailable { Version {MIN_BWRAP_VERSION} or newer is required." ), Self::ProbeFailed { status, detail } => { - write!(f, "Bubblewrap (bwrap) is present but `bwrap --version` ")?; + write!( + f, + "The Bubblewrap (bwrap) availability probe `bwrap --version` " + )?; match status { Some(code) => write!(f, "exited with status {code}")?, // Covers both a spawn failure and termination by a signal, @@ -108,8 +203,8 @@ impl fmt::Display for BwrapUnavailable { } write!( f, - ". Version {MIN_BWRAP_VERSION} or newer is required; fix the \ - installation before using the Bubblewrap backend." + ". Version {MIN_BWRAP_VERSION} or newer is required; check \ + PATH and the installation before using the Bubblewrap backend." ) } Self::UnrecognizedVersion(output) => write!( @@ -121,7 +216,7 @@ impl fmt::Display for BwrapUnavailable { Self::TooOld(found) => write!( f, "Bubblewrap (bwrap) {found} is too old: version {MIN_BWRAP_VERSION} or newer is \ - required (the sandbox uses `--clearenv`, added in bwrap 0.5.0). \ + required ({MIN_BWRAP_VERSION_REASON}). \ Upgrade the bubblewrap package." ), } @@ -133,26 +228,553 @@ impl std::error::Error for BwrapUnavailable {} /// Probe the host for a usable `bwrap`. /// /// Runs `bwrap --version` and validates the reported version against -/// [`MIN_BWRAP_VERSION`]. Returns the detected version on success. +/// [`MIN_BWRAP_VERSION`]. Successful advisory probes are cached. pub fn probe_bwrap() -> Result { - let output = Command::new("bwrap") - .arg("--version") + probe_bwrap_cached( + &CACHED_BWRAP_VERSION, + cached_probe_gate(), + BWRAP_VERSION_TIMEOUT, + probe_bwrap_uncached_until, + ) +} + +fn probe_bwrap_cached( + cache: &OnceLock, + gate: &ProbeGate, + timeout: Duration, + probe: F, +) -> Result +where + F: FnOnce(Instant, Duration) -> Result, +{ + if let Some(version) = cache.get() { + return Ok(*version); + } + + let deadline = Instant::now() + timeout; + let Some(_permit) = gate.acquire_permit_until(deadline) else { + return Err(probe_timeout(timeout)); + }; + if let Some(version) = cache.get() { + return Ok(*version); + } + + let version = probe(deadline, timeout)?; + let _ = cache.set(version); + Ok(version) +} + +/// Probe immediately without consulting the advisory success cache. +/// +/// Execution validation uses this so a prior platform-support query cannot +/// approve a different executable after `PATH` or its contents change. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub(crate) fn probe_bwrap_uncached() -> Result { + probe_bwrap_uncached_until( + Instant::now() + BWRAP_VERSION_TIMEOUT, + BWRAP_VERSION_TIMEOUT, + ) +} + +fn probe_bwrap_uncached_until( + deadline: Instant, + timeout: Duration, +) -> Result { + probe_bwrap_uncached_with(|| run_bwrap_version_until(deadline, timeout)) +} + +fn probe_bwrap_uncached_with(run: F) -> Result +where + F: FnOnce() -> Result, +{ + let output = run()?; + check_probe_output(output) +} + +#[derive(Debug)] +struct ProbeOutput { + success: bool, + status: Option, + stdout: Vec, + stderr: Vec, +} + +#[derive(Debug)] +struct CapturedOutput { + bytes: Vec, + truncated: bool, +} + +fn run_bwrap_version_until( + deadline: Instant, + timeout: Duration, +) -> Result { + let gate = probe_gate(); + let Some(gate_permit) = gate.acquire_permit_until(deadline) else { + return Err(probe_timeout(timeout)); + }; + + let (sender, receiver) = mpsc::sync_channel(1); + let worker = thread::Builder::new() + .name("bwrap-probe".to_string()) + .spawn(move || { + let _gate_permit = gate_permit; + let result = + run_version_command_until(Path::new("bwrap"), &["--version"], deadline, timeout); + let _ = sender.send(result); + }); + if let Err(err) = worker { + return Err(BwrapUnavailable::ProbeFailed { + status: None, + detail: format!("failed to start the `bwrap --version` probe: {err}"), + }); + } + + match receiver.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + Ok(result) => result, + Err(mpsc::RecvTimeoutError::Timeout) => Err(probe_timeout(timeout)), + Err(mpsc::RecvTimeoutError::Disconnected) => Err(probe_internal_error( + "`bwrap --version` probe worker disconnected", + )), + } +} + +fn probe_timeout(timeout: Duration) -> BwrapUnavailable { + BwrapUnavailable::ProbeFailed { + status: None, + detail: format!("timed out after {}ms", timeout.as_millis()), + } +} + +#[cfg(all(test, unix))] +fn run_version_command_with_timeout( + executable: &std::path::Path, + args: &[&str], + timeout: Duration, +) -> Result { + run_version_command_until(executable, args, Instant::now() + timeout, timeout) +} + +fn run_version_command_until( + executable: &Path, + args: &[&str], + deadline: Instant, + timeout: Duration, +) -> Result { + // The public probe runs this entire operation in a deadline-watched worker, + // so PATH lookup and spawn are bounded without assuming a fixed `env` path. + // A dedicated process group handles wrappers and inherited probe pipes. On + // timeout/failure paths this worker waits for child reaping before it + // returns, so the single-flight gate stays held until cleanup completes. + let mut command = Command::new(executable); + command + .args(args) .stdin(Stdio::null()) - .output() - .map_err(|err| match err.kind() { - // `ENOENT` covers both an absent binary and a present-but-unusable - // one (missing ELF interpreter / shebang target), so confirm the - // binary is really absent before blaming the package manager. - std::io::ErrorKind::NotFound if !bwrap_exists_on_path() => BwrapUnavailable::NotFound, - _ => BwrapUnavailable::ProbeFailed { + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(unix)] + command.process_group(0); + let mut child = command + .spawn() + .map_err(|err| classify_spawn_failure(executable, err))?; + let process_group = child.id(); + + let stdout = child + .stdout + .take() + .ok_or_else(|| probe_internal_error("stdout pipe was not captured"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| probe_internal_error("stderr pipe was not captured"))?; + let (stdout_reader, stdout_rx) = match spawn_reader(stdout, deadline) { + Ok(reader) => reader, + Err(err) => { + let _ = terminate_probe_tree(&mut child, process_group); + let _ = child.wait(); + return Err(probe_internal_error(&format!( + "failed to start stdout reader: {err}" + ))); + } + }; + let (stderr_reader, stderr_rx) = match spawn_reader(stderr, deadline) { + Ok(reader) => reader, + Err(err) => { + let _ = terminate_probe_tree(&mut child, process_group); + let _ = child.wait(); + let _ = stdout_reader.join(); + return Err(probe_internal_error(&format!( + "failed to start stderr reader: {err}" + ))); + } + }; + let mut status = None; + let mut stdout = None; + let mut stderr = None; + #[cfg(target_os = "linux")] + let mut group_cleanup_error: Option = None; + #[cfg(target_os = "linux")] + let mut leader_exited = false; + let mut poll_interval = INITIAL_WAIT_POLL_INTERVAL; + loop { + if let Err(err) = receive_reader(&stdout_rx, &mut stdout, timeout) + .and_then(|_| receive_reader(&stderr_rx, &mut stderr, timeout)) + { + let _ = terminate_probe_tree(&mut child, process_group); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err(err); + } + + #[cfg(target_os = "linux")] + if status.is_none() && !leader_exited { + match child_exited_without_reaping(&child) { + Ok(true) => { + // Keep the exited leader unreaped until both readers have + // closed. Its reserved PID keeps the process-group ID from + // being recycled while descendant cleanup is still possible. + group_cleanup_error = terminate_probe_group(process_group).err(); + leader_exited = true; + } + Ok(false) => {} + Err(err) => { + let _ = terminate_probe_tree(&mut child, process_group); + let _ = child.wait(); + drop(stdout_reader); + drop(stderr_reader); + return Err(BwrapUnavailable::ProbeFailed { + status: None, + detail: format!("failed while waiting for `bwrap --version`: {err}"), + }); + } + } + } + + #[cfg(target_os = "linux")] + if leader_exited && stdout.is_some() && stderr.is_some() { + match child.wait() { + Ok(exit_status) => status = Some(exit_status), + Err(err) => { + drop(stdout_reader); + drop(stderr_reader); + return Err(BwrapUnavailable::ProbeFailed { + status: None, + detail: format!("failed while reaping `bwrap --version`: {err}"), + }); + } + } + } + + #[cfg(not(target_os = "linux"))] + if status.is_none() { + match child.try_wait() { + Ok(Some(exit_status)) => status = Some(exit_status), + Ok(None) => {} + Err(err) => { + let _ = terminate_probe_tree(&mut child, process_group); + let _ = child.wait(); + drop(stdout_reader); + drop(stderr_reader); + return Err(BwrapUnavailable::ProbeFailed { + status: None, + detail: format!("failed while waiting for `bwrap --version`: {err}"), + }); + } + } + } + + if status.is_some() && stdout.is_some() && stderr.is_some() { + let status = status.take().expect("checked above"); + let stdout = stdout.take().expect("checked above"); + let stderr = stderr.take().expect("checked above"); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + #[cfg(target_os = "linux")] + let cleanup_error = group_cleanup_error.take(); + #[cfg(not(target_os = "linux"))] + let cleanup_error = None; + return finish_probe(status, stdout, stderr, cleanup_error); + } + + if Instant::now() < deadline { + thread::sleep(poll_interval.min(deadline.saturating_duration_since(Instant::now()))); + poll_interval = poll_interval.saturating_mul(2).min(MAX_WAIT_POLL_INTERVAL); + continue; + } + + let kill_error = terminate_probe_tree(&mut child, process_group).err(); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + #[cfg(target_os = "linux")] + let kill_error = kill_error.or(group_cleanup_error); + let suffix = kill_error + .map(|err| format!("; failed to terminate it: {err}")) + .unwrap_or_default(); + return Err(BwrapUnavailable::ProbeFailed { + status: None, + detail: format!("timed out after {}ms{suffix}", timeout.as_millis()), + }); + } +} + +fn finish_probe( + status: std::process::ExitStatus, + stdout: CapturedOutput, + stderr: CapturedOutput, + _cleanup_error: Option, +) -> Result { + // A completed version command is authoritative. Some setuid bwrap + // installations reject signalling their post-exit process group with + // EPERM; that cleanup diagnostic must not turn a valid version into an + // unavailable backend. + if stdout.truncated || stderr.truncated { + return Err(BwrapUnavailable::ProbeFailed { + status: status.code(), + detail: format!( + "`bwrap --version` output exceeded the {} byte limit", + MAX_BWRAP_VERSION_OUTPUT_BYTES + ), + }); + } + Ok(ProbeOutput { + success: status.success(), + status: status.code(), + stdout: stdout.bytes, + stderr: stderr.bytes, + }) +} + +fn classify_spawn_failure(executable: &Path, err: io::Error) -> BwrapUnavailable { + if err.kind() != io::ErrorKind::NotFound { + return BwrapUnavailable::ProbeFailed { + status: None, + detail: err.to_string(), + }; + } + + if executable_mentions_path(executable) { + return if executable.exists() { + BwrapUnavailable::ProbeFailed { status: None, - detail: err.to_string(), - }, + detail: format!( + "`{}` was found but could not be executed ({err}); check for a missing interpreter or loader", + executable.display() + ), + } + } else { + BwrapUnavailable::NotFound + }; + } + + if command_is_on_path(executable) { + return BwrapUnavailable::ProbeFailed { + status: None, + detail: format!( + "`{}` was found on PATH but could not be executed ({err}); check for a missing interpreter or loader", + executable.display() + ), + }; + } + BwrapUnavailable::NotFound +} + +fn executable_mentions_path(executable: &Path) -> bool { + executable.is_absolute() || executable.components().count() > 1 +} + +fn command_is_on_path(executable: &Path) -> bool { + std::env::var_os("PATH") + .map(|path| path_contains_executable(executable, &path)) + .unwrap_or(false) +} + +fn path_contains_executable(executable: &Path, path: &OsStr) -> bool { + std::env::split_paths(path).any(|entry| entry.join(executable).is_file()) +} + +#[cfg(target_os = "linux")] +fn child_exited_without_reaping(child: &Child) -> io::Result { + use nix::sys::wait::{waitid, Id, WaitPidFlag, WaitStatus}; + use nix::unistd::Pid; + + let flags = WaitPidFlag::WEXITED | WaitPidFlag::WNOHANG | WaitPidFlag::WNOWAIT; + match waitid(Id::Pid(Pid::from_raw(child.id() as i32)), flags) { + Ok(WaitStatus::StillAlive) => Ok(false), + Ok(_) => Ok(true), + Err(err) => Err(io::Error::from_raw_os_error(err as i32)), + } +} + +#[cfg(any(not(unix), test))] +fn read_bounded(mut reader: impl Read) -> io::Result { + let mut bytes = Vec::new(); + let mut truncated = false; + let mut buffer = [0_u8; 4096]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + let remaining = MAX_BWRAP_VERSION_OUTPUT_BYTES.saturating_sub(bytes.len()); + let retained = read.min(remaining); + bytes.extend_from_slice(&buffer[..retained]); + truncated |= retained < read; + } + Ok(CapturedOutput { bytes, truncated }) +} + +#[cfg(unix)] +fn spawn_reader( + reader: impl Read + AsRawFd + Send + 'static, + deadline: Instant, +) -> io::Result<( + thread::JoinHandle<()>, + mpsc::Receiver>, +)> { + let (sender, receiver) = mpsc::channel(); + let handle = thread::Builder::new() + .name("bwrap-probe-reader".to_string()) + .spawn(move || { + let _ = sender.send(read_bounded_until(reader, deadline)); + })?; + Ok((handle, receiver)) +} + +#[cfg(not(unix))] +fn spawn_reader( + reader: impl Read + Send + 'static, + _deadline: Instant, +) -> io::Result<( + thread::JoinHandle<()>, + mpsc::Receiver>, +)> { + let (sender, receiver) = mpsc::channel(); + let handle = thread::Builder::new() + .name("bwrap-probe-reader".to_string()) + .spawn(move || { + let _ = sender.send(read_bounded(reader)); })?; + Ok((handle, receiver)) +} + +#[cfg(unix)] +fn read_bounded_until( + mut reader: impl Read + AsRawFd, + deadline: Instant, +) -> io::Result { + let mut bytes = Vec::new(); + let mut truncated = false; + let mut buffer = [0_u8; 4096]; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "probe output remained open past the deadline", + )); + } + let timeout_ms = remaining.as_millis().min(i32::MAX as u128) as i32; + let mut descriptor = nix::libc::pollfd { + fd: reader.as_raw_fd(), + events: nix::libc::POLLIN | nix::libc::POLLHUP | nix::libc::POLLERR, + revents: 0, + }; + // SAFETY: `descriptor` points to one initialized pollfd for this call. + let result = unsafe { nix::libc::poll(&mut descriptor, 1, timeout_ms) }; + if result == 0 { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "probe output remained open past the deadline", + )); + } + if result < 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(error); + } + + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + let remaining_capacity = MAX_BWRAP_VERSION_OUTPUT_BYTES.saturating_sub(bytes.len()); + let retained = read.min(remaining_capacity); + bytes.extend_from_slice(&buffer[..retained]); + truncated |= retained < read; + } + Ok(CapturedOutput { bytes, truncated }) +} + +fn receive_reader( + receiver: &mpsc::Receiver>, + output: &mut Option, + timeout: Duration, +) -> Result<(), BwrapUnavailable> { + if output.is_none() { + match receiver.try_recv() { + Ok(result) => { + *output = Some(result.map_err(|err| { + if err.kind() == io::ErrorKind::TimedOut { + BwrapUnavailable::ProbeFailed { + status: None, + detail: format!("timed out after {}ms", timeout.as_millis()), + } + } else { + probe_internal_error(&format!("failed reading probe output: {err}")) + } + })?); + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => { + return Err(probe_internal_error("probe output reader disconnected")); + } + } + } + Ok(()) +} + +#[cfg(unix)] +fn terminate_probe_tree(child: &mut std::process::Child, process_group: u32) -> io::Result<()> { + let child_result = child.kill(); + let group_result = terminate_probe_group(process_group); + match (child_result, group_result) { + (_, Err(err)) | (Err(err), Ok(())) => Err(err), + (Ok(()), Ok(())) => Ok(()), + } +} + +#[cfg(unix)] +fn terminate_probe_group(process_group: u32) -> io::Result<()> { + use nix::errno::Errno; + use nix::sys::signal::{killpg, Signal}; + use nix::unistd::Pid; + + match killpg(Pid::from_raw(process_group as i32), Signal::SIGKILL) { + Ok(()) | Err(Errno::ESRCH) => Ok(()), + Err(err) => Err(io::Error::from_raw_os_error(err as i32)), + } +} - if !output.status.success() { +#[cfg(not(unix))] +fn terminate_probe_tree(child: &mut std::process::Child, _process_group: u32) -> io::Result<()> { + child.kill() +} + +fn probe_internal_error(detail: &str) -> BwrapUnavailable { + BwrapUnavailable::ProbeFailed { + status: None, + detail: detail.to_string(), + } +} + +fn check_probe_output(output: ProbeOutput) -> Result { + if !output.success { return Err(BwrapUnavailable::ProbeFailed { - status: output.status.code(), + status: output.status, detail: String::from_utf8_lossy(&output.stderr).trim().to_string(), }); } @@ -225,18 +847,6 @@ fn parse_version(output: &str) -> Option { Some(BwrapVersion::new(major, minor, patch)) } -/// Whether a `bwrap` candidate exists anywhere on `PATH`. -/// -/// Linux returns `ENOENT` both for a genuinely absent binary and for one that -/// exists but cannot be executed (a missing ELF interpreter or script shebang -/// target), so the spawn error alone cannot tell [`BwrapUnavailable::NotFound`] -/// from [`BwrapUnavailable::ProbeFailed`]. A candidate on `PATH` means the -/// package is installed and the failure is a broken install. -fn bwrap_exists_on_path() -> bool { - std::env::var_os("PATH") - .is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join("bwrap").exists())) -} - /// Parse the leading run of ASCII digits of `component`, ignoring any suffix /// (so `"1-1"` yields `1`). Returns `None` when there is no leading digit or /// the number overflows. @@ -458,4 +1068,397 @@ mod tests { "OS error should survive: {message}" ); } + + #[test] + fn bounded_reader_drains_but_does_not_retain_excess_output() { + let input = vec![b'x'; MAX_BWRAP_VERSION_OUTPUT_BYTES + 17]; + let captured = read_bounded(std::io::Cursor::new(input)).unwrap(); + assert_eq!(captured.bytes.len(), MAX_BWRAP_VERSION_OUTPUT_BYTES); + assert!(captured.truncated); + } + + #[test] + fn probe_gate_serializes_callers_within_their_deadline() { + let gate = std::sync::Arc::new(ProbeGate::default()); + assert!(gate.acquire_until(Instant::now() + Duration::from_secs(1))); + + let waiting_gate = std::sync::Arc::clone(&gate); + let (waiting_tx, waiting_rx) = mpsc::channel(); + let waiter = thread::spawn(move || { + waiting_tx.send(()).unwrap(); + let acquired = waiting_gate.acquire_until(Instant::now() + Duration::from_secs(5)); + if acquired { + waiting_gate.release(); + } + acquired + }); + + waiting_rx.recv().unwrap(); + gate.release(); + assert!(waiter.join().unwrap()); + } + + #[test] + fn probe_gate_does_not_create_a_retry_after_the_deadline() { + let gate = ProbeGate::default(); + assert!(gate.acquire_until(Instant::now() + Duration::from_millis(25))); + assert!(!gate.acquire_until(Instant::now() + Duration::from_millis(10))); + gate.release(); + } + + #[test] + fn probe_gate_permit_releases_on_panic() { + let gate = std::sync::Arc::new(ProbeGate::default()); + let panicking_gate = std::sync::Arc::clone(&gate); + let worker = thread::spawn(move || { + let _permit = panicking_gate + .acquire_permit_until(Instant::now() + Duration::from_millis(50)) + .expect("permit should be acquired"); + panic!("simulated panic while probe is in-flight"); + }); + assert!(worker.join().is_err()); + + assert!( + gate.acquire_until(Instant::now() + Duration::from_millis(50)), + "panic should release the gate" + ); + gate.release(); + } + + #[cfg(unix)] + #[test] + fn missing_binary_spawn_failure_is_classified_as_not_found() { + // A spawn failure with ENOENT means the binary is absent from PATH. + // Regression: when the probe used `/usr/bin/env bwrap`, a missing + // `/usr/bin/env` (e.g. NixOS) produced ProbeFailed instead of NotFound. + let error = run_version_command_with_timeout( + std::path::Path::new("this-binary-does-not-exist-on-any-path"), + &["--version"], + Duration::from_secs(1), + ) + .unwrap_err(); + assert_eq!(error, BwrapUnavailable::NotFound); + } + + #[cfg(unix)] + #[test] + fn missing_interpreter_is_classified_as_probe_failure() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("bwrap"); + std::fs::write(&script, "#!/this/interpreter/does/not/exist\nexit 0\n").unwrap(); + let mut permissions = std::fs::metadata(&script).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&script, permissions).unwrap(); + + let error = + run_version_command_with_timeout(&script, &["--version"], Duration::from_secs(1)) + .unwrap_err(); + assert!(matches!( + error, + BwrapUnavailable::ProbeFailed { status: None, detail } + if detail.contains("missing interpreter or loader") + )); + } + + #[cfg(unix)] + #[test] + fn subprocess_probe_preserves_reader_results_until_all_are_ready() { + for _ in 0..50 { + let output = run_version_command_with_timeout( + std::path::Path::new("/bin/sh"), + &["-c", "printf 'bubblewrap 0.5.0\\n'"], + Duration::from_secs(1), + ) + .unwrap(); + assert_eq!(check_probe_output(output), Ok(MIN_BWRAP_VERSION)); + } + } + + #[cfg(unix)] + #[test] + fn subprocess_probe_times_out_and_terminates_the_child() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("bwrap"); + std::fs::write(&script, "#!/bin/sh\nwhile true; do :; done\n").unwrap(); + let mut permissions = std::fs::metadata(&script).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&script, permissions).unwrap(); + + let error = + run_version_command_with_timeout(&script, &["--version"], Duration::from_millis(250)) + .unwrap_err(); + assert!(matches!( + error, + BwrapUnavailable::ProbeFailed { + status: None, + detail, + } if detail.contains("timed out after 250ms") + )); + } + + #[cfg(unix)] + #[test] + fn bounded_reader_times_out_when_the_writer_stays_open() { + use std::os::unix::net::UnixStream; + + let (reader, _writer) = UnixStream::pair().unwrap(); + let error = + read_bounded_until(reader, Instant::now() + Duration::from_millis(100)).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + assert!(error.to_string().contains("remained open")); + } + + #[cfg(target_os = "linux")] + #[test] + fn subprocess_probe_terminates_a_descendant_holding_the_pipes_open() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("bwrap"); + std::fs::write( + &script, + "#!/bin/sh\n(while true; do :; done) &\necho 'bubblewrap 0.5.0'\nexit 0\n", + ) + .unwrap(); + let mut permissions = std::fs::metadata(&script).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&script, permissions).unwrap(); + + let started = Instant::now(); + let output = + run_version_command_with_timeout(&script, &["--version"], Duration::from_millis(30)) + .unwrap(); + assert!(started.elapsed() < Duration::from_secs(1)); + assert_eq!(check_probe_output(output), Ok(MIN_BWRAP_VERSION)); + } + + #[cfg(target_os = "linux")] + #[test] + fn subprocess_probe_terminates_a_descendant_with_closed_pipes() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("bwrap"); + let pid_file = dir.path().join("descendant.pid"); + std::fs::write( + &script, + "#!/bin/sh\n(while true; do :; done) >/dev/null 2>&1 &\necho \"$!\" > \"$1\"\necho 'bubblewrap 0.5.0'\nexit 0\n", + ) + .unwrap(); + let mut permissions = std::fs::metadata(&script).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&script, permissions).unwrap(); + + let pid_file_arg = pid_file.to_string_lossy(); + let output = run_version_command_with_timeout( + &script, + &[pid_file_arg.as_ref()], + Duration::from_secs(1), + ) + .unwrap(); + assert_eq!(check_probe_output(output), Ok(MIN_BWRAP_VERSION)); + + let pid: u32 = std::fs::read_to_string(pid_file) + .unwrap() + .trim() + .parse() + .unwrap(); + let poll_deadline = Instant::now() + Duration::from_secs(1); + let terminated = loop { + let terminated = match std::fs::read_to_string(format!("/proc/{pid}/stat")) { + Ok(stat) => { + stat.rfind(") ") + .and_then(|index| stat[index + 2..].chars().next()) + == Some('Z') + } + Err(err) if err.kind() == io::ErrorKind::NotFound => true, + Err(err) => panic!("failed to inspect probe descendant {pid}: {err}"), + }; + if terminated || Instant::now() >= poll_deadline { + break terminated; + } + thread::sleep(Duration::from_millis(10)); + }; + assert!(terminated, "probe descendant {pid} is still running"); + } + + #[test] + fn uncached_probe_maps_injected_process_outcomes() { + let version = probe_bwrap_uncached_with(|| { + Ok(ProbeOutput { + success: true, + status: Some(0), + stdout: b"bubblewrap 0.5.0\n".to_vec(), + stderr: Vec::new(), + }) + }) + .unwrap(); + assert_eq!(version, MIN_BWRAP_VERSION); + + let failure = BwrapUnavailable::ProbeFailed { + status: Some(126), + detail: "permission denied".to_string(), + }; + assert_eq!( + probe_bwrap_uncached_with(|| Err(failure.clone())), + Err(failure) + ); + } + + #[test] + fn probe_output_preserves_nonzero_status_and_stderr() { + let error = check_probe_output(ProbeOutput { + success: false, + status: Some(126), + stdout: Vec::new(), + stderr: b"permission denied".to_vec(), + }) + .unwrap_err(); + assert!(matches!( + error, + BwrapUnavailable::ProbeFailed { + detail, + .. + } if detail == "permission denied" + )); + } + + #[cfg(unix)] + #[test] + fn successful_probe_ignores_post_exit_group_cleanup_failure() { + use std::os::unix::process::ExitStatusExt; + + let output = finish_probe( + std::process::ExitStatus::from_raw(0), + CapturedOutput { + bytes: b"bubblewrap 0.5.0\n".to_vec(), + truncated: false, + }, + CapturedOutput { + bytes: Vec::new(), + truncated: false, + }, + Some(io::Error::from_raw_os_error(nix::libc::EPERM)), + ) + .unwrap(); + assert_eq!(check_probe_output(output), Ok(MIN_BWRAP_VERSION)); + } + + #[test] + fn successful_probe_result_is_cached() { + let cache = OnceLock::new(); + let gate = ProbeGate::default(); + let first = probe_bwrap_cached(&cache, &gate, Duration::from_secs(1), |_, _| { + Ok(MIN_BWRAP_VERSION) + }) + .unwrap(); + assert_eq!(first, MIN_BWRAP_VERSION); + + let second = probe_bwrap_cached(&cache, &gate, Duration::from_secs(1), |_, _| { + panic!("successful probe should be reused from the cache") + }) + .unwrap(); + assert_eq!(second, MIN_BWRAP_VERSION); + } + + #[test] + fn expired_probe_gate_deadline_does_not_start_probe() { + let gate = ProbeGate::default(); + assert!(gate + .acquire_permit_until(Instant::now() - Duration::from_millis(1)) + .is_none()); + assert!(!*gate + .in_flight + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner())); + } + + #[test] + fn failed_probe_result_is_not_cached() { + let cache = OnceLock::new(); + let gate = ProbeGate::default(); + assert_eq!( + probe_bwrap_cached(&cache, &gate, Duration::from_secs(1), |_, _| { + Err(BwrapUnavailable::NotFound) + }), + Err(BwrapUnavailable::NotFound) + ); + assert_eq!( + probe_bwrap_cached(&cache, &gate, Duration::from_secs(1), |_, _| { + Ok(MIN_BWRAP_VERSION) + }), + Ok(MIN_BWRAP_VERSION) + ); + } + + #[test] + fn concurrent_cached_callers_reuse_the_first_success() { + let cache = std::sync::Arc::new(OnceLock::new()); + let gate = std::sync::Arc::new(ProbeGate::default()); + let (started_tx, started_rx) = mpsc::channel(); + let synchronization = std::sync::Arc::new(std::sync::Barrier::new(2)); + let first_cache = std::sync::Arc::clone(&cache); + let first_gate = std::sync::Arc::clone(&gate); + let first_synchronization = std::sync::Arc::clone(&synchronization); + let first = thread::spawn(move || { + probe_bwrap_cached(&first_cache, &first_gate, Duration::from_secs(1), |_, _| { + started_tx.send(()).unwrap(); + first_synchronization.wait(); + Ok(MIN_BWRAP_VERSION) + }) + }); + + started_rx.recv().unwrap(); + let second_cache = std::sync::Arc::clone(&cache); + let second_gate = std::sync::Arc::clone(&gate); + let second_synchronization = std::sync::Arc::clone(&synchronization); + let second = thread::spawn(move || { + second_synchronization.wait(); + probe_bwrap_cached( + &second_cache, + &second_gate, + Duration::from_secs(1), + |_, _| panic!("queued advisory caller should reuse the first success"), + ) + }); + + assert_eq!(first.join().unwrap(), Ok(MIN_BWRAP_VERSION)); + assert_eq!(second.join().unwrap(), Ok(MIN_BWRAP_VERSION)); + } + + #[test] + fn concurrent_cached_callers_remain_deadline_bounded_after_failure() { + let cache = std::sync::Arc::new(OnceLock::new()); + let gate = std::sync::Arc::new(ProbeGate::default()); + let (started_tx, started_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let first_cache = std::sync::Arc::clone(&cache); + let first_gate = std::sync::Arc::clone(&gate); + let first = thread::spawn(move || { + probe_bwrap_cached(&first_cache, &first_gate, Duration::from_secs(1), |_, _| { + started_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + Err(BwrapUnavailable::NotFound) + }) + }); + + started_rx.recv().unwrap(); + let timeout = Duration::from_millis(50); + let started = Instant::now(); + let second = probe_bwrap_cached(&cache, &gate, timeout, |_, _| { + panic!("caller whose cache wait expired must not start a probe") + }); + assert_eq!(second, Err(probe_timeout(timeout))); + assert!( + started.elapsed() < Duration::from_secs(5), + "cache wait exceeded its deadline by an unreasonable margin" + ); + + release_tx.send(()).unwrap(); + assert_eq!(first.join().unwrap(), Err(BwrapUnavailable::NotFound)); + } } diff --git a/src/core/mxc_engine/src/platform.rs b/src/core/mxc_engine/src/platform.rs index 6fa5ff3e9..e366c950a 100644 --- a/src/core/mxc_engine/src/platform.rs +++ b/src/core/mxc_engine/src/platform.rs @@ -25,6 +25,27 @@ pub struct PlatformSupport { pub available_methods: Vec, } +#[cfg(target_os = "linux")] +fn linux_platform_support_with(probe: F) -> PlatformSupport +where + F: FnOnce() -> Result< + bwrap_common::bwrap_version::BwrapVersion, + bwrap_common::bwrap_version::BwrapUnavailable, + >, +{ + match probe() { + Ok(_) => PlatformSupport { + is_supported: true, + available_methods: vec!["bubblewrap".to_string()], + ..Default::default() + }, + Err(err) => PlatformSupport { + reason: Some(err.to_string()), + ..Default::default() + }, + } +} + /// Detect MXC support on the current host. /// /// Mirrors the SDK's `getPlatformSupport`, restricted to the backends the @@ -61,17 +82,7 @@ pub fn platform_support() -> PlatformSupport { // `bwrap_common::bwrap_version::MIN_BWRAP_VERSION`). `lxc` is a // host-capability backend the SDK can't launch, so it is reported by // `available_backends()` rather than here. - match bwrap_common::bwrap_version::probe_bwrap() { - Ok(_) => PlatformSupport { - is_supported: true, - available_methods: vec!["bubblewrap".to_string()], - ..Default::default() - }, - Err(err) => PlatformSupport { - reason: Some(err.to_string()), - ..Default::default() - }, - } + linux_platform_support_with(bwrap_common::bwrap_version::probe_bwrap) } #[cfg(target_os = "windows")] @@ -137,7 +148,11 @@ pub fn isolation_session_available() -> bool { #[cfg(test)] mod tests { + #[cfg(target_os = "linux")] + use super::linux_platform_support_with; use super::platform_support; + #[cfg(target_os = "linux")] + use bwrap_common::bwrap_version::{BwrapUnavailable, BwrapVersion, MIN_BWRAP_VERSION}; use wxc_common::wire::Containment; fn wire_name(containment: &Containment) -> String { @@ -192,4 +207,24 @@ mod tests { ); } } + + #[cfg(target_os = "linux")] + #[test] + fn linux_support_reports_bubblewrap_when_probe_succeeds() { + let support = linux_platform_support_with(|| Ok(MIN_BWRAP_VERSION)); + assert!(support.is_supported); + assert_eq!(support.reason, None); + assert_eq!(support.available_methods, ["bubblewrap"]); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_support_preserves_probe_failure_reason() { + let failure = BwrapUnavailable::TooOld(BwrapVersion::new(0, 4, 1)); + let expected = failure.to_string(); + let support = linux_platform_support_with(|| Err(failure)); + assert!(!support.is_supported); + assert_eq!(support.reason.as_deref(), Some(expected.as_str())); + assert!(support.available_methods.is_empty()); + } } diff --git a/src/core/wxc_common/src/sandbox_process.rs b/src/core/wxc_common/src/sandbox_process.rs index 058911774..f94efd758 100644 --- a/src/core/wxc_common/src/sandbox_process.rs +++ b/src/core/wxc_common/src/sandbox_process.rs @@ -20,6 +20,7 @@ use std::io::{Read, Write}; use crate::logger::Logger; use crate::models::{ExecutionRequest, FailurePhase, SandboxOutputMetadata, ScriptResponse}; use crate::script_runner::ScriptRunner; +use crate::validator::validate_common; /// A handle to a running sandboxed process. /// @@ -360,6 +361,10 @@ pub trait SandboxBackend { /// Apply this backend's containment and spawn the sandboxed process with /// stdio wired per `stdio`, returning a handle. On a validation or spawn /// failure returns a [`ScriptResponse`] carrying the error. + /// + /// Implementations must apply shared validation and [`Self::validate`] + /// before performing any process-launch side effects. This keeps direct + /// streaming callers and the [`Runner`] bridge on the same safe path. fn spawn( &mut self, request: &ExecutionRequest, @@ -398,16 +403,18 @@ impl Runner { } } -impl ScriptRunner for Runner { - fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { - self.0.validate(request) - } - - fn execute(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { - let mut child = match self.0.spawn(request, logger, StdioMode::Inherit) { +impl Runner { + fn wait_for_child( + &self, + request: &ExecutionRequest, + logger: &mut Logger, + child: Result, ScriptResponse>, + ) -> ScriptResponse { + let mut child = match child { Ok(child) => child, Err(response) => return response, }; + match child.wait() { Ok(exit_code) => { let mut response = ScriptResponse { @@ -448,6 +455,218 @@ impl ScriptRunner for Runner { } } +impl ScriptRunner for Runner { + fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { + self.0.validate(request) + } + + fn execute(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { + // Direct callers have not passed through ScriptRunner::run, so use the + // backend's validation-safe public spawn path. + let child = self.0.spawn(request, logger, StdioMode::Inherit); + self.wait_for_child(request, logger, child) + } + + fn run(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { + // A dry-run has no spawn call to enforce validation. Normal execution + // uses SandboxBackend::spawn, the single validation-safe launch path. + if request.dry_run { + if let Err(response) = validate_common(request) { + return response; + } + if let Err(response) = self.validate_runner(request) { + return response; + } + return ScriptResponse { + exit_code: 0, + ..Default::default() + }; + } + + self.execute(request, logger) + } +} + +#[cfg(test)] +mod runner_tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use crate::logger::Mode; + + use super::*; + + struct CompletedProcess; + + impl SandboxProcess for CompletedProcess { + fn take_stdin(&mut self) -> Option> { + None + } + + fn take_stdout(&mut self) -> Option> { + None + } + + fn take_stderr(&mut self) -> Option> { + None + } + + fn try_wait(&mut self) -> std::io::Result> { + Ok(Some(0)) + } + + fn id(&self) -> u32 { + 0 + } + + fn kill(&mut self) -> std::io::Result<()> { + Ok(()) + } + + fn wait(&mut self) -> std::io::Result { + Ok(0) + } + } + + struct CountingBackend { + validations: Arc, + direct_spawns: Arc, + reject_validation: bool, + } + + impl SandboxBackend for CountingBackend { + fn validate(&self, _request: &ExecutionRequest) -> Result<(), ScriptResponse> { + self.validations.fetch_add(1, Ordering::Relaxed); + if self.reject_validation { + Err(ScriptResponse::error("backend validation failed")) + } else { + Ok(()) + } + } + + fn spawn( + &mut self, + request: &ExecutionRequest, + _logger: &mut Logger, + _stdio: StdioMode, + ) -> Result, ScriptResponse> { + validate_common(request)?; + self.validate(request)?; + self.direct_spawns.fetch_add(1, Ordering::Relaxed); + Ok(Box::new(CompletedProcess)) + } + } + + #[test] + fn runner_execute_validates_direct_callers() { + let validations = Arc::new(AtomicUsize::new(0)); + let direct_spawns = Arc::new(AtomicUsize::new(0)); + let mut runner = Runner::new(CountingBackend { + validations: Arc::clone(&validations), + direct_spawns: Arc::clone(&direct_spawns), + reject_validation: false, + }); + let request = ExecutionRequest { + script_code: "echo hello".to_string(), + ..Default::default() + }; + let mut logger = Logger::new(Mode::Buffer); + + let response = runner.execute(&request, &mut logger); + + assert_eq!(response.exit_code, 0); + assert_eq!(validations.load(Ordering::Relaxed), 1); + assert_eq!(direct_spawns.load(Ordering::Relaxed), 1); + } + + #[test] + fn runner_execute_applies_shared_validation() { + let validations = Arc::new(AtomicUsize::new(0)); + let direct_spawns = Arc::new(AtomicUsize::new(0)); + let mut runner = Runner::new(CountingBackend { + validations: Arc::clone(&validations), + direct_spawns: Arc::clone(&direct_spawns), + reject_validation: false, + }); + let request = ExecutionRequest::default(); + let mut logger = Logger::new(Mode::Buffer); + + let response = runner.execute(&request, &mut logger); + + assert_eq!(response.exit_code, -1); + assert_eq!(validations.load(Ordering::Relaxed), 0); + assert_eq!(direct_spawns.load(Ordering::Relaxed), 0); + } + + #[test] + fn runner_run_validates_once() { + let validations = Arc::new(AtomicUsize::new(0)); + let direct_spawns = Arc::new(AtomicUsize::new(0)); + let mut runner = Runner::new(CountingBackend { + validations: Arc::clone(&validations), + direct_spawns: Arc::clone(&direct_spawns), + reject_validation: false, + }); + let request = ExecutionRequest { + script_code: "echo hello".to_string(), + ..Default::default() + }; + let mut logger = Logger::new(Mode::Buffer); + + let response = runner.run(&request, &mut logger); + + assert_eq!(response.exit_code, 0); + assert_eq!(validations.load(Ordering::Relaxed), 1); + assert_eq!(direct_spawns.load(Ordering::Relaxed), 1); + } + + #[test] + fn runner_run_dry_run_validates_without_spawning() { + let validations = Arc::new(AtomicUsize::new(0)); + let direct_spawns = Arc::new(AtomicUsize::new(0)); + let mut runner = Runner::new(CountingBackend { + validations: Arc::clone(&validations), + direct_spawns: Arc::clone(&direct_spawns), + reject_validation: false, + }); + let request = ExecutionRequest { + script_code: "echo hello".to_string(), + dry_run: true, + ..Default::default() + }; + let mut logger = Logger::new(Mode::Buffer); + + let response = runner.run(&request, &mut logger); + + assert_eq!(response.exit_code, 0); + assert_eq!(validations.load(Ordering::Relaxed), 1); + assert_eq!(direct_spawns.load(Ordering::Relaxed), 0); + } + + #[test] + fn runner_run_backend_validation_failure_does_not_spawn() { + let validations = Arc::new(AtomicUsize::new(0)); + let direct_spawns = Arc::new(AtomicUsize::new(0)); + let mut runner = Runner::new(CountingBackend { + validations: Arc::clone(&validations), + direct_spawns: Arc::clone(&direct_spawns), + reject_validation: true, + }); + let request = ExecutionRequest { + script_code: "echo hello".to_string(), + ..Default::default() + }; + let mut logger = Logger::new(Mode::Buffer); + + let response = runner.run(&request, &mut logger); + + assert_eq!(response.exit_code, -1); + assert_eq!(response.error_message, "backend validation failed"); + assert_eq!(validations.load(Ordering::Relaxed), 1); + assert_eq!(direct_spawns.load(Ordering::Relaxed), 0); + } +} + #[cfg(all(test, unix))] mod tests { use super::{wait_with_timeout, WaitError};