From 5e4ec1557330654dd9805e839dbc69f5cfa54369 Mon Sep 17 00:00:00 2001 From: Gudge Date: Mon, 3 Aug 2026 14:56:40 -0700 Subject: [PATCH] Harden Bubblewrap version probing This PR hardens Bubblewrap availability probing so hung, noisy, broken, or instrumented launchers remain bounded and produce actionable diagnostics in the Rust executor and Node SDK. Details * Apply total wall-clock deadlines and output caps across PATH resolution, process launch, worker setup, supervision, and cleanup. * Retain process-group ownership through descendant termination and reaping, with Node result publication gated on completed anchor cleanup. * Bound concurrent Rust probes, cache successful advisory results, and preserve uncached validation for execution and direct runner calls. * Package Node supervision as compiled helper modules with bundled-ESM asset resolution and sanitized NODE_OPTIONS. * Add lifecycle, failure-classification, concurrency, deadline, cleanup-ordering, and diagnostic coverage. Tests * cargo fmt --all -- --check. * cargo check --workspace --all-targets. * cargo clippy --workspace --all-targets -- -D warnings. * cargo test --workspace. * cargo check -p bwrap_common --tests --target x86_64-unknown-linux-gnu. * cargo clippy -p bwrap_common --all-targets --target x86_64-unknown-linux-gnu -- -D warnings. * npm run build; npm test; npm pack --dry-run. Generated-with: gpt-5.6-sol Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aac97903-f49a-48de-81ac-ae56d7d2eb17 --- docs/bwrap-support/bubblewrap-backend.md | 9 +- sdk/node/README.md | 4 +- sdk/node/src/bwrap-probe-anchor.ts | 93 ++ sdk/node/src/bwrap-probe-helper.ts | 113 ++ sdk/node/src/bwrap-probe-worker.ts | 135 +++ sdk/node/src/helper.ts | 3 + sdk/node/src/index.ts | 2 + sdk/node/src/platform.ts | 279 ++++- sdk/node/src/types.ts | 6 + sdk/node/tests/unit/platform.test.ts | 665 +++++++++- sdk/node/tests/unit/sandbox.test.ts | 30 + .../bubblewrap/common/src/bwrap_runner.rs | 60 +- .../bubblewrap/common/src/bwrap_version.rs | 1077 ++++++++++++++++- src/core/mxc_engine/src/platform.rs | 57 +- src/core/wxc_common/src/sandbox_process.rs | 216 +++- 15 files changed, 2613 insertions(+), 136 deletions(-) create mode 100644 sdk/node/src/bwrap-probe-anchor.ts create mode 100644 sdk/node/src/bwrap-probe-helper.ts create mode 100644 sdk/node/src/bwrap-probe-worker.ts diff --git a/docs/bwrap-support/bubblewrap-backend.md b/docs/bwrap-support/bubblewrap-backend.md index 0e9e5684f..3ee2ad857 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 4ec617dbe..1516836be 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. @@ -340,7 +340,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..4162edb5d --- /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', 'pipe'], 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 54df8e634..f224fb14d 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 4c3c0789b..f61d01e34 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,12 @@ function getSdkPackageRoot(): string { } } +const bwrapProbeScriptDirectory = fs.existsSync( + path.join(__dirname, 'bwrap-probe-worker.js'), +) + ? __dirname + : path.join(getSdkPackageRoot(), 'dist'); + /** * Query Windows Registry for a value * @param key - Registry key path (e.g., "HKLM\\Software\\...") @@ -145,9 +157,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 */ @@ -273,16 +286,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})`; } @@ -314,7 +337,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; @@ -323,6 +346,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]`. @@ -336,6 +373,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 }; @@ -349,77 +387,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; @@ -500,8 +642,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. */ @@ -512,20 +654,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.`, }; } @@ -533,13 +678,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 7dd199b7d..d809d01c0 100644 --- a/sdk/node/tests/unit/platform.test.ts +++ b/sdk/node/tests/unit/platform.test.ts @@ -3,8 +3,10 @@ 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, @@ -12,12 +14,67 @@ import { _setWindowsBuildQuery, _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, @@ -444,12 +501,520 @@ 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('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(); }); @@ -471,8 +1036,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', () => { @@ -493,21 +1061,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', () => { @@ -520,10 +1097,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', () => { @@ -537,6 +1116,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', () => { @@ -563,4 +1143,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 d01f34ac0..37857e0b9 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,15 @@ impl SandboxBackend for BubblewrapScriptRunner { ) -> Result, ScriptResponse> { validate_common(request)?; self.validate(request)?; + self.spawn_validated(request, logger, stdio) + } + + fn spawn_validated( + &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 @@ -651,7 +671,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] @@ -662,10 +684,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 b054bfbfa..9f690ca28 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 @@ -55,17 +76,7 @@ pub fn platform_support() -> PlatformSupport { // Presence alone is not enough: `bwrap` must also be new enough for // every flag the argument builder emits (see // `bwrap_common::bwrap_version::MIN_BWRAP_VERSION`). - 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")] @@ -85,3 +96,27 @@ pub fn platform_support() -> PlatformSupport { } } } + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use bwrap_common::bwrap_version::{BwrapUnavailable, BwrapVersion, MIN_BWRAP_VERSION}; + + #[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"]); + } + + #[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..9a822434b 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. /// @@ -367,6 +368,20 @@ pub trait SandboxBackend { stdio: StdioMode, ) -> Result, ScriptResponse>; + /// Spawn after [`validate`](SandboxBackend::validate) and shared validation + /// have already succeeded. The run-to-completion bridge uses this hook to + /// avoid repeating expensive validation. Backends whose `spawn` does not + /// revalidate can use the default implementation. + #[doc(hidden)] + fn spawn_validated( + &mut self, + request: &ExecutionRequest, + logger: &mut Logger, + stdio: StdioMode, + ) -> Result, ScriptResponse> { + self.spawn(request, logger, stdio) + } + /// Optional post-exit diagnostics for the run-to-completion (binary) path: /// when the child exits non-zero, return a more actionable error message /// (e.g. a known AppContainer filesystem-permission failure). Default: none. @@ -398,16 +413,27 @@ impl Runner { } } -impl ScriptRunner for Runner { - fn validate_runner(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { - self.0.validate(request) +impl Runner { + fn execute_validated( + &mut self, + request: &ExecutionRequest, + logger: &mut Logger, + ) -> ScriptResponse { + let child = self.0.spawn_validated(request, logger, StdioMode::Inherit); + self.wait_for_child(request, logger, child) } - fn execute(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse { - let mut child = match self.0.spawn(request, logger, StdioMode::Inherit) { + 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 +474,186 @@ 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 { + // Keep this validation and dry-run sequence aligned with the trait + // default; only the final dispatch differs to avoid revalidation. + if let Err(response) = validate_common(request) { + return response; + } + + if let Err(response) = self.validate_runner(request) { + return response; + } + + if request.dry_run { + return ScriptResponse { + exit_code: 0, + ..Default::default() + }; + } + + self.execute_validated(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, + validated_spawns: Arc, + } + + impl SandboxBackend for CountingBackend { + fn validate(&self, _request: &ExecutionRequest) -> Result<(), ScriptResponse> { + self.validations.fetch_add(1, Ordering::Relaxed); + 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)) + } + + fn spawn_validated( + &mut self, + _request: &ExecutionRequest, + _logger: &mut Logger, + _stdio: StdioMode, + ) -> Result, ScriptResponse> { + self.validated_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 validated_spawns = Arc::new(AtomicUsize::new(0)); + let mut runner = Runner::new(CountingBackend { + validations: Arc::clone(&validations), + direct_spawns: Arc::clone(&direct_spawns), + validated_spawns: Arc::clone(&validated_spawns), + }); + 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); + assert_eq!(validated_spawns.load(Ordering::Relaxed), 0); + } + + #[test] + fn runner_execute_applies_shared_validation() { + let validations = Arc::new(AtomicUsize::new(0)); + let direct_spawns = Arc::new(AtomicUsize::new(0)); + let validated_spawns = Arc::new(AtomicUsize::new(0)); + let mut runner = Runner::new(CountingBackend { + validations: Arc::clone(&validations), + direct_spawns: Arc::clone(&direct_spawns), + validated_spawns: Arc::clone(&validated_spawns), + }); + 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); + assert_eq!(validated_spawns.load(Ordering::Relaxed), 0); + } + + #[test] + fn runner_run_does_not_repeat_backend_validation() { + let validations = Arc::new(AtomicUsize::new(0)); + let direct_spawns = Arc::new(AtomicUsize::new(0)); + let validated_spawns = Arc::new(AtomicUsize::new(0)); + let mut runner = Runner::new(CountingBackend { + validations: Arc::clone(&validations), + direct_spawns: Arc::clone(&direct_spawns), + validated_spawns: Arc::clone(&validated_spawns), + }); + 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), 0); + assert_eq!(validated_spawns.load(Ordering::Relaxed), 1); + } +} + #[cfg(all(test, unix))] mod tests { use super::{wait_with_timeout, WaitError};