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