Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/Build.Linux.Job.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,27 @@ jobs:
run: cargo test --locked --release --target ${{ matrix.target }}
--no-default-features --features hyperlight

# Bubblewrap is required to run the executor characterization tests in
# wxc_e2e_tests (they skip via has_bwrap() when it is absent). lxc-exec
# always includes the Bubblewrap backend (bwrap_common is a non-optional
# dependency), so the binary built above can drive it.
- name: Install Bubblewrap
working-directory: ${{ github.workspace }}
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap
# Ubuntu 24.04 runners restrict unprivileged user namespaces via
# AppArmor, which blocks `bwrap --unshare-user`. Relax it so the
# sandbox can start (no-op on kernels without this knob).
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true

# Runs the Bubblewrap executor characterization tests. lxc-exec was built
# into src/target/<triple>/release above, where find_binary() locates it.
- name: Test executor characterization (wxc_e2e_tests)
working-directory: src
run: cargo test --locked --release --target ${{ matrix.target }}
-p wxc_e2e_tests

# linux_test_proxy is a separate workspace member, not a dep of lxc.
- name: Build linux-test-proxy
working-directory: src
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/Build.MacOS.Job.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,15 @@ jobs:
# workspace has Windows-only crates that won't compile here.
- name: Build
run: cargo build --locked --release --target aarch64-apple-darwin
-p mxc_darwin -p seatbelt_common -p wxc_common
-p mxc_darwin -p seatbelt_common -p wxc_common -p wxc_e2e_tests

# wxc_e2e_tests includes the Seatbelt executor characterization tests.
# mxc-exec-mac (built above via -p mxc_darwin) is what they drive; the
# tests skip via has_platform_exec() if it is missing. sandbox-exec needs
# no elevation, so they run in this standard macOS job.
- name: Test
run: cargo test --locked --release --target aarch64-apple-darwin
-p mxc_darwin -p seatbelt_common -p wxc_common
-p mxc_darwin -p seatbelt_common -p wxc_common -p wxc_e2e_tests

- name: Upload binaries
uses: actions/upload-artifact@v4
Expand Down
111 changes: 111 additions & 0 deletions src/testing/wxc_e2e_tests/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ fn current_triple() -> &'static str {
"x86_64-unknown-linux-gnu"
} else if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
"aarch64-unknown-linux-gnu"
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
"aarch64-apple-darwin"
} else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
"x86_64-apple-darwin"
} else {
""
}
Expand Down Expand Up @@ -482,6 +486,113 @@ pub fn run_wxc_config_value(
run_executable(label, &exe, args)
}

// ---------------------------------------------------------------------------
// Cross-platform executor characterization helpers
//
// These drive the *native* one-shot executor binary for the current OS
// (`mxc-exec-mac` on macOS, `lxc-exec` on Linux, `wxc-exec.exe` on Windows)
// with an in-memory config, optionally setting the child process's environment
// and working directory. They exist to lock in the current run-to-completion
// behavior (exit code, stdout, env/cwd inheritance, timeout) before the
// unified `SandboxBackend`/`Runner` refactor lands.
// ---------------------------------------------------------------------------

/// The native one-shot executor binary name for the current platform.
pub fn platform_exec_binary_name() -> &'static str {
if cfg!(target_os = "windows") {
"wxc-exec.exe"
} else if cfg!(target_os = "macos") {
"mxc-exec-mac"
} else {
"lxc-exec"
}
}

/// Locate the native one-shot executor binary for the current platform.
pub fn find_platform_exec() -> Option<PathBuf> {
find_binary(platform_exec_binary_name())
}

/// Whether the native executor binary for this platform is available.
pub fn has_platform_exec() -> bool {
match find_platform_exec() {
Some(p) => {
println!("Using {} at {}", platform_exec_binary_name(), p.display());
true
}
None => {
println!(
"SKIPPED: {} not found — build the native executor first",
platform_exec_binary_name()
);
false
}
}
}

/// Whether `bwrap` (Bubblewrap) is installed and runnable on this Linux host.
/// Bubblewrap characterization tests skip cleanly when it is absent (e.g. a CI
/// runner without `bubblewrap` installed).
pub fn has_bwrap() -> bool {
let available = Command::new("bwrap")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !available {
println!("SKIPPED: bwrap not found on PATH — install `bubblewrap` to run these tests");
}
available
}

/// Opt-in switch for the Windows ProcessContainer characterization tests.
///
/// AppContainer/BaseContainer execution requires an elevated, host-prepped
/// Windows host (see `docs/host-prep.md`). Standard CI runners are NOT capable,
/// so these tests are skipped unless a host-prepped lane explicitly sets
/// `MXC_E2E_HOST_PREPPED=1`. This keeps them from ever red-failing on incapable
/// CI while still being runnable on a prepared box.
pub fn host_prepped_optin() -> bool {
let enabled = std::env::var("MXC_E2E_HOST_PREPPED").as_deref() == Ok("1");
if !enabled {
println!(
"SKIPPED: ProcessContainer characterization requires a host-prepped Windows host; \
set MXC_E2E_HOST_PREPPED=1 on a prepared lane to enable"
);
}
enabled
}

/// Run the current platform's native executor binary with an in-memory config
/// value (serialised + base64-encoded via `--config-base64`), optionally
/// setting environment variables and a working directory on the *executor*
/// process. `extra_env`/`cwd` are how the inheritance characterization tests
/// observe whether the sandboxed child picks up the launcher's env/cwd.
pub fn run_platform_config_value(
label: &str,
config: &serde_json::Value,
extra_env: &[(&str, &str)],
cwd: Option<&Path>,
) -> CommandResult {
let exe = find_platform_exec().expect("native executor binary should be available");
let encoded = STANDARD.encode(config.to_string().as_bytes());

let start = Instant::now();
let mut cmd = Command::new(&exe);
cmd.arg("--config-base64").arg(encoded);
for (key, value) in extra_env {
cmd.env(key, value);
}
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
let output = cmd
.output()
.unwrap_or_else(|error| panic!("failed to execute {label}: {error}"));

command_result(label, output, start.elapsed().as_millis())
}

/// Run `wxc-test-driver.exe` against a directory or a single config file.
pub fn run_test_driver(target: &Path, extra_args: &[&str]) -> CommandResult {
let exe = find_binary("wxc-test-driver.exe").expect("wxc-test-driver.exe should be available");
Expand Down
170 changes: 170 additions & 0 deletions src/testing/wxc_e2e_tests/tests/e2e_bubblewrap_characterization.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

//! Bubblewrap (Linux) executor **characterization** tests.
//!
//! These lock in the *current* run-to-completion behavior of the `lxc-exec`
//! Bubblewrap path before the unified `SandboxBackend`/`Runner` refactor lands.
//! They assert what the code does **today**.
//!
//! Unlike Seatbelt, Bubblewrap already `--clearenv`s unconditionally and runs
//! the child with `stdin` closed, so the env/stdin contracts pinned here are
//! ones the refactor should *preserve*. (The stdin/`SIGTTIN` regression that the
//! refactor introduces is only observable under a real PTY, which the
//! `.output()`-based harness cannot provide — that needs a separate PTY harness
//! and is tracked as a follow-up.)
//!
//! They run in the existing Linux CI job (`cargo test`) **only when `bwrap` is
//! installed** — `has_bwrap()` skips them cleanly otherwise. Each test also
//! skips if `lxc-exec` has not been built.
#![cfg(target_os = "linux")]

use serde_json::json;
use wxc_e2e_tests::{has_bwrap, has_platform_exec, run_platform_config_value};

const SCHEMA_VERSION: &str = "0.7.0-alpha";

/// Whether the Bubblewrap characterization prerequisites are present.
fn ready() -> bool {
has_platform_exec() && has_bwrap()
}

/// Build a one-shot config that omits `containment` so the binary selects its
/// OS-native backend (Bubblewrap on Linux).
fn config(label: &str, command_line: &str) -> serde_json::Value {
json!({
"version": SCHEMA_VERSION,
"containerId": format!("char-bwrap-{label}"),
"process": { "commandLine": command_line }
})
}

#[test]
fn bubblewrap_propagates_exit_code() {
if !ready() {
return;
}
let result =
run_platform_config_value("bwrap exit code", &config("exit-code", "exit 7"), &[], None);
assert_eq!(
result.code,
Some(7),
"expected exit 7, got {:?}\n--- stderr ---\n{}",
result.code,
result.stderr
);
}

#[test]
fn bubblewrap_streams_stdout() {
if !ready() {
return;
}
let result = run_platform_config_value(
"bwrap stdout",
&config("stdout", "echo CHAR_BWRAP_STDOUT_71c4d"),
&[],
None,
);
assert_eq!(result.code, Some(0), "stderr: {}", result.stderr);
assert!(
result.combined_output().contains("CHAR_BWRAP_STDOUT_71c4d"),
"stdout missing sentinel:\n{}",
result.combined_output()
);
}

/// CHARACTERIZES CURRENT BEHAVIOR.
///
/// Bubblewrap runs with `--clearenv`, so the sandboxed child does *not* inherit
/// the launcher's environment even when `process.env` is empty. The refactor
/// should preserve this; if it ever turns RED the env model has drifted.
#[test]
fn bubblewrap_clears_host_env_by_default() {
if !ready() {
return;
}
let marker = "CHAR_BWRAP_SHOULD_NOT_APPEAR_8a02f";
let result = run_platform_config_value(
"bwrap env clear",
&config("env-clear", "printf 'MARKER=[%s]\\n' \"$MXC_CHAR_MARKER\""),
&[("MXC_CHAR_MARKER", marker)],
None,
);
assert_eq!(result.code, Some(0), "stderr: {}", result.stderr);
let out = result.combined_output();
assert!(
out.contains("MARKER=[]"),
"expected cleared env (MARKER=[]); current Bubblewrap --clearenv behavior. Output:\n{out}"
);
assert!(
!out.contains(marker),
"host env marker leaked into the sandbox. Output:\n{out}"
);
}

/// Locks in that an explicitly requested `process.env` reaches the child.
#[test]
fn bubblewrap_applies_requested_env() {
if !ready() {
return;
}
let mut cfg = config("env-set", "printf 'SET=[%s]\\n' \"$MXC_CHAR_SET\"");
cfg["process"]["env"] = json!(["MXC_CHAR_SET=from_config_c93b"]);
let result = run_platform_config_value("bwrap env set", &cfg, &[], None);
assert_eq!(result.code, Some(0), "stderr: {}", result.stderr);
assert!(
result.combined_output().contains("SET=[from_config_c93b]"),
"expected requested env var to reach the child. Output:\n{}",
result.combined_output()
);
}

/// Locks in that an explicit `process.cwd` is honored (Bubblewrap emits
/// `--chdir` for a non-empty working directory). `/` always exists inside the
/// sandbox, so it is a stable target.
#[test]
fn bubblewrap_honors_explicit_process_cwd() {
if !ready() {
return;
}
let mut cfg = config("cwd-explicit", "pwd -P");
cfg["process"]["cwd"] = json!("/");
let result = run_platform_config_value("bwrap cwd explicit", &cfg, &[], None);
assert_eq!(result.code, Some(0), "stderr: {}", result.stderr);
assert_eq!(
result.stdout.trim(),
"/",
"expected child cwd to honor explicit process.cwd=/"
);
}

/// Characterizes that a `process.timeout` shorter than the workload is
/// enforced and surfaces as a non-zero exit.
///
/// NOTE: on current `main`, the Bubblewrap run-to-completion timeout kills only
/// the `bwrap` parent (`child.kill()`), so a forked descendant can survive,
/// keep the stdout pipe open, and have its post-timeout output captured (the
/// call can even block until the descendant exits). That tree-kill behavior is
/// something the unified `Runner` refactor changes, so this test deliberately
/// does NOT assert the absence of post-timeout output or a wall-clock bound —
/// only that the timeout fires and fails the run.
#[test]
fn bubblewrap_timeout_is_enforced() {
if !ready() {
return;
}
let mut cfg = config("timeout", "echo CHAR_BEFORE; /bin/sleep 5; echo CHAR_AFTER");
cfg["process"]["timeout"] = json!(1500);
let result = run_platform_config_value("bwrap timeout", &cfg, &[], None);
let out = result.combined_output();
assert!(
out.contains("CHAR_BEFORE"),
"expected pre-timeout output. Output:\n{out}"
);
assert_ne!(
result.code,
Some(0),
"a timed-out run should exit non-zero. Output:\n{out}"
);
}
Loading
Loading