diff --git a/docs/backends/bwrap/bubblewrap-backend.md b/docs/backends/bwrap/bubblewrap-backend.md index 04f6242c5..22a4beadb 100644 --- a/docs/backends/bwrap/bubblewrap-backend.md +++ b/docs/backends/bwrap/bubblewrap-backend.md @@ -172,6 +172,15 @@ Common consequences of this default: `readonlyPaths` if the script depends on it. - `working_directory` must live under the baseline or a policy path — a `cwd` of `~/project` without a matching `readonlyPaths` entry will fail. + At launch, Bubblewrap retains a mount-configuration hint for SDK callers, + which `lxc-exec` also prints to stderr, when an explicit absolute cwd has + no apparent overlap with known mount destinations. This compares path + spellings without inspecting the host cwd or policy paths, so symlinks or + runtime mount behavior can make the hint inapplicable. Check + `readonlyPaths` or `readwritePaths` for a containing directory. Ambiguous + paths (including `..` and paths near policy mounts) are left to Bubblewrap; + silence does not prove a cwd is accessible. The hint does not change + execution or replace Bubblewrap's error. - DNS works on systemd-resolved, NetworkManager, and resolvconf hosts because the corresponding `/run/...` directories are bound. The common symlink targets *outside* `/run` are covered too: `/var/run/...`-routed diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs index b5898895b..93b9e3015 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs @@ -284,6 +284,50 @@ fn start_directory(request: &ExecutionRequest) -> Option { .map(crate::mxc_common::models::sandbox_absolute_path) } +/// Find an explicit cwd with no overlapping known mount destination. +/// This is a lexical hint, not proof of visibility inside Bubblewrap. +#[cfg(any(target_os = "linux", test))] +pub(crate) fn cwd_without_matching_mount(request: &ExecutionRequest) -> Option { + let dir = start_directory(request)?; + if dir == "/" + || !request.working_directory.starts_with('/') + || request + .working_directory + .split('/') + .any(|part| part == "." || part == "..") + { + return None; + } + + let mounts = BASELINE_RO_BIND_PATHS + .iter() + .copied() + .chain(["/dev", "/proc", "/tmp", "/var/run"]) + .chain( + request + .policy + .readwrite_paths + .iter() + .chain(&request.policy.readonly_paths) + .chain(&request.policy.denied_paths) + .map(String::as_str), + ); + for mount in mounts { + if !mount.starts_with('/') || mount.split('/').any(|part| part == "." || part == "..") { + return None; + } + if path_is_within(&dir, mount) || path_is_within(mount, &dir) { + return None; + } + } + Some(dir) +} + +#[cfg(any(target_os = "linux", test))] +fn path_is_within(path: &str, parent: &str) -> bool { + std::path::Path::new(path).starts_with(parent) +} + /// The default environment: `PATH`, `TERM`, and — when one resolves — `HOME`. /// /// `HOME` names the directory the child actually runs in, so it is a path the @@ -571,6 +615,85 @@ mod tests { } } + #[test] + fn cwd_hint_requires_a_nonoverlapping_absolute_path() { + let mut request = base_request(); + assert_eq!( + cwd_without_matching_mount(&request).as_deref(), + Some("/home/user") + ); + request.working_directory = "/".into(); + assert_eq!(cwd_without_matching_mount(&request), None); + request.working_directory = "".into(); + assert_eq!(cwd_without_matching_mount(&request), None); + request.working_directory = "home/user".into(); + assert_eq!(cwd_without_matching_mount(&request), None); + request.working_directory = "/home/../home/user".into(); + assert_eq!(cwd_without_matching_mount(&request), None); + } + + #[test] + fn cwd_overlap_is_inconclusive_in_either_direction() { + let mut request = base_request(); + for cwd in ["/etc", "/etc/project", "/usr", "/tmp/work", "/var"] { + request.working_directory = cwd.into(); + assert_eq!(cwd_without_matching_mount(&request), None, "{cwd}"); + } + for mount in [ + "/home", + "/home/user", + "/home/user/", + "/home//user/", + "/home/user/data", + "/home//user/data/", + ] { + request.working_directory = "/home/user".into(); + request.policy.readonly_paths = vec![mount.into()]; + assert_eq!(cwd_without_matching_mount(&request), None, "{mount}"); + } + for mount in ["/home/user2", "/home/user2/"] { + request.policy.readonly_paths = vec![mount.into()]; + assert_eq!( + cwd_without_matching_mount(&request).as_deref(), + Some("/home/user"), + "{mount}" + ); + } + request.policy.readonly_paths = vec!["/alias/../home/user".into()]; + assert_eq!(cwd_without_matching_mount(&request), None); + request.policy.readonly_paths = vec!["/".into()]; + assert_eq!(cwd_without_matching_mount(&request), None); + request.policy.readonly_paths.clear(); + request.policy.denied_paths = vec!["/home/user/hidden".into()]; + assert_eq!(cwd_without_matching_mount(&request), None); + } + + #[test] + fn cwd_proof_uses_the_mounted_baseline_and_virtual_roots() { + let request = base_request(); + let args = build_args(&request, None); + for mount in BASELINE_RO_BIND_PATHS { + assert!( + args.windows(3) + .any(|a| { a == ["--ro-bind-try", *mount, *mount] }), + "{mount}" + ); + } + for (flag, path) in [ + ("--dev", "/dev"), + ("--proc", "/proc"), + ("--tmpfs", "/tmp"), + ("--symlink", "/var/run"), + ] { + assert!( + args.windows(2).any(|a| a[0] == flag && a[1] == path) + || (flag == "--symlink" + && args.windows(3).any(|a| a == ["--symlink", "/run", path])), + "{flag} {path}" + ); + } + } + #[test] fn ambiguous_resolver_paths_and_shadowed_link_do_not_warn() { let mut policy = ContainerPolicy::default(); @@ -594,6 +717,33 @@ mod tests { )); } + #[test] + fn cwd_diagnostic_recognizes_every_emitted_mount_destination() { + let mut request = base_request(); + let args = build_args(&request, None); + for triple in args.windows(3) { + if matches!( + triple[0].as_str(), + "--ro-bind-try" | "--ro-bind" | "--bind" | "--symlink" + ) { + request.working_directory = triple[2].clone(); + assert!( + cwd_without_matching_mount(&request).is_none(), + "unrecognized mount destination: {triple:?}" + ); + } + } + for pair in args.windows(2) { + if matches!(pair[0].as_str(), "--dev" | "--proc" | "--tmpfs") { + request.working_directory = pair[1].clone(); + assert!( + cwd_without_matching_mount(&request).is_none(), + "unrecognized virtual mount destination: {pair:?}" + ); + } + } + } + /// `process.env` resolution, which schema 0.9 gave a default block. mod env { use super::*; diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs index ac262f081..97608ade6 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs @@ -146,6 +146,7 @@ impl SandboxBackend for BubblewrapScriptRunner { return Err(ScriptResponse::error(&msg)); } warn_resolver_mount_hint(request, logger); + warn_cwd_mount_hint(request, logger); let child = self.spawn_bwrap(request, &plan.files, egress_plan, logger, stdio)?; Ok(Box::new(BubblewrapSandboxProcess::new(child))) } @@ -377,6 +378,18 @@ fn warn_resolver_mount_hint_with( )); } +fn warn_cwd_mount_hint(request: &ExecutionRequest, logger: &mut Logger) { + let Some(dir) = bwrap_command::cwd_without_matching_mount(request) else { + return; + }; + logger.warning_line(&format!( + "WARNING: Bubblewrap: process.cwd {dir:?} has no apparent covering mount \ + destination. It may be unavailable inside the sandbox; check \ + filesystem.readonlyPaths or filesystem.readwritePaths for a containing \ + directory. Bubblewrap will decide whether --chdir succeeds." + )); +} + impl BubblewrapScriptRunner { /// Set up networking and spawn `bwrap`, returning a [`BwrapChild`] wrapped /// by the [`SandboxProcess`] handle. With [`StdioMode::Pipes`] the child's @@ -1294,6 +1307,29 @@ mod tests { } } + #[test] + fn cwd_mount_hint_is_retained_for_unmatched_paths() { + use crate::mxc_common::logger::Mode; + + let mut request = base_request(); + request.working_directory = "/home/user".into(); + let mut logger = Logger::new(Mode::Buffer); + warn_cwd_mount_hint(&request, &mut logger); + assert_eq!(logger.warnings().len(), 1); + assert!(logger.warnings()[0].contains("no apparent covering mount")); + assert!(logger.warnings()[0].contains("filesystem.readonlyPaths")); + assert!(logger.get_buffer().is_empty()); + + request.policy.readonly_paths = vec!["/other".into()]; + let mut unrelated_logger = Logger::new(Mode::Buffer); + warn_cwd_mount_hint(&request, &mut unrelated_logger); + assert_eq!(unrelated_logger.warnings().len(), 1); + request.policy.readonly_paths = vec!["/home/user/data".into()]; + let mut covered_logger = Logger::new(Mode::Buffer); + warn_cwd_mount_hint(&request, &mut covered_logger); + assert!(covered_logger.warnings().is_empty()); + } + /// A request carrying `alice:hunter2@` in its proxy URL, built the way a /// programmatic caller would rather than through the JSON parser. fn request_with_a_credential_bearing_proxy() -> ExecutionRequest { diff --git a/tests/scripts/run_bwrap_all_tests.sh b/tests/scripts/run_bwrap_all_tests.sh index 7c1a92385..a275be25d 100644 --- a/tests/scripts/run_bwrap_all_tests.sh +++ b/tests/scripts/run_bwrap_all_tests.sh @@ -79,6 +79,7 @@ run_test() { } run_test "Basic Bubblewrap" "$SCRIPT_DIR/run_bwrap_basic_test.sh" +run_test "Bubblewrap cwd advisory" "$SCRIPT_DIR/run_bwrap_cwd_advisory_test.sh" run_test "Bubblewrap Environment" "$SCRIPT_DIR/run_bwrap_environment_test.sh" run_test "Bubblewrap Filesystem" "$SCRIPT_DIR/run_bwrap_filesystem_test.sh" run_test "Bubblewrap Read-Only Denial" "$SCRIPT_DIR/run_bwrap_readonly_denial_test.sh" diff --git a/tests/scripts/run_bwrap_cwd_advisory_test.sh b/tests/scripts/run_bwrap_cwd_advisory_test.sh new file mode 100644 index 000000000..9e974eaf2 --- /dev/null +++ b/tests/scripts/run_bwrap_cwd_advisory_test.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Check that the CLI reports a cwd mount hint without changing launch results. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +LXC_EXEC="${LXC_EXEC:-$REPO_DIR/src/target/release/lxc-exec}" +if [ ! -f "$LXC_EXEC" ]; then + LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +fi +if [ ! -f "$LXC_EXEC" ]; then + echo "Error: lxc-exec not found. Run build.sh first." + exit 1 +fi + +WORK_DIR=$(mktemp -d "$(cd "$HOME" && pwd -P)/.mxc_bwrap_cwd_XXXXXX") +CWD="$WORK_DIR/work" +UNRELATED="$WORK_DIR/other" +trap 'rm -f "$WORK_DIR/uncovered.json" "$WORK_DIR/unrelated.json" "$WORK_DIR/covered.json" "$WORK_DIR/uncovered.stderr" "$WORK_DIR/unrelated.stderr" "$WORK_DIR/covered.stderr"; rmdir "$CWD" "$UNRELATED" "$WORK_DIR"' EXIT +mkdir "$CWD" "$UNRELATED" + +cat >"$WORK_DIR/uncovered.json" <"$WORK_DIR/covered.json" <"$WORK_DIR/unrelated.json" <"$WORK_DIR/$case.stderr") || rc=$? + if [ "$rc" -eq 0 ] \ + || ! grep -Fq "WARNING: Bubblewrap: process.cwd \"$CWD\"" "$WORK_DIR/$case.stderr" \ + || ! grep -Fq 'filesystem.readonlyPaths' "$WORK_DIR/$case.stderr" \ + || ! grep -Eq '(^|[[:space:]])bwrap:' "$WORK_DIR/$case.stderr"; then + echo "$case cwd did not report the hint and Bubblewrap's original error: $output" + cat "$WORK_DIR/$case.stderr" + exit 1 + fi +done +output=$("$LXC_EXEC" --experimental "$WORK_DIR/covered.json" 2>"$WORK_DIR/covered.stderr") +if ! grep -qFx "$CWD" <<<"$output" \ + || grep -Fq 'WARNING: Bubblewrap: process.cwd ' "$WORK_DIR/covered.stderr"; then + echo "Covered cwd did not run without an advisory: $output" + cat "$WORK_DIR/covered.stderr" + exit 1 +fi