Files
zed/crates/sandbox/src/windows_wsl.rs
T
yenru0 fe62a99bf5 gwen: Purge remaining Zed branding from user-facing surfaces
Sweep every user-visible product string to Gwen: app menus (Quit,
Hide, Gwen Repository), feedback links (repo/issues now point at
github.com/kk376/zed, email to yenru@yrkaierx.com), move-to-Applications
and update dialogs, provider and settings copy, CLI help, installer and
desktop/flatpak/snap/plist metadata.

Rename user-facing settings values with back-compat aliases: base_keymap
"Gwen" (accepts "Zed"), fonts ".GwenMono"/".GwenSans" (accept
".ZedMono"/".ZedSans"), icon theme "Gwen (Default)". Fix F10
app-menu keymap arguments to "Gwen" and align the Windows installer
AppMutex with release_channel::app_identifier(). Internal identifiers,
wire protocol names, upstream service URLs and legal attribution are
intentionally kept.
2026-09-29 09:24:34 +09:00

2280 lines
87 KiB
Rust

//! Windows sandbox integration via WSL.
//!
//! Sandboxed Windows terminal commands are routed through WSL and then executed
//! under Bubblewrap inside Linux. Projects may be opened either from native
//! Windows paths (`C:\Users\...`) or WSL UNC paths
//! (`\\wsl.localhost\Ubuntu\home\...`). Native drive-letter paths are
//! translated into the distro's filesystem view with `wslpath` (falling back
//! to the conventional `/mnt/<drive>/...` mapping if that fails) and use the
//! user's default WSL distro unless a WSL UNC path in the request pins a
//! specific distro.
//!
//! Errors fall into two classes the agent treats differently:
//!
//! * **Environment unavailable** — WSL missing or failing to start, no
//! usable `bwrap`, or the probe/path-resolution stdout protocol breaking
//! down. These are returned as a [`WslSandboxUnavailable`] (whose `Display`
//! carries
//! [`WSL_SANDBOX_UNAVAILABLE_PREFIX`](crate::WSL_SANDBOX_UNAVAILABLE_PREFIX)),
//! so the agent recognizes them *by type* and offers the same
//! retry / run-unsandboxed fallback it offers on Linux, rather than matching
//! on message text.
//! * **Bad request** — a specific path that doesn't exist or can't be mapped
//! into WSL, or a request mixing distros. These are ordinary `anyhow` errors
//! *without* [`WslSandboxUnavailable`], and are reported back to the model,
//! which can fix the request and retry.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use smol::process::{Command, Stdio};
use anyhow::{Context as _, Result, bail, ensure};
use crate::WSL_SANDBOX_UNAVAILABLE_PREFIX;
/// Per-command relaxations of the WSL/Bubblewrap sandbox. Windows can only
/// toggle network access wholesale (no loopback-proxy confinement yet), so this
/// is a plain bool rather than the richer cross-platform [`crate::SandboxNetPolicy`].
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) struct SandboxPermissions {
pub(crate) allow_network: bool,
pub(crate) allow_fs_write: bool,
}
pub(crate) struct WritablePath {
pub(crate) requested: PathBuf,
pub(crate) canonical: PathBuf,
}
/// Exit code the environment probe script uses to signal that `bwrap` is not
/// installed, distinguishing that from WSL itself failing to start a shell.
/// Chosen to be unlikely to collide with `wsl.exe`'s own failure codes.
const BWRAP_MISSING_EXIT_CODE: i32 = 41;
/// Exit code the environment probe script uses to signal that `bwrap` is
/// installed but failed the sandbox smoke test — typically because the
/// distro restricts unprivileged user namespaces (e.g. Ubuntu 24.04's
/// default AppArmor policy), which every namespace flag we pass depends on.
const BWRAP_UNUSABLE_EXIT_CODE: i32 = 42;
/// Prefix of the probe script's single result line, so it can be picked out
/// of any stdout noise printed by the login shell's profile scripts.
const PROBE_RESULT_PREFIX: &str = "zed-wsl-probe:";
/// Prefix of the helper-provisioning script's single result line (the absolute
/// in-WSL path of the Linux `zed` to run as the sandbox helper), picked out of
/// login-shell stdout noise just like [`PROBE_RESULT_PREFIX`].
const HELPER_RESULT_PREFIX: &str = "zed-wsl-helper:";
/// Ensures a Linux `zed` matching the running release is available inside WSL to
/// act as the sandbox helper (`--wsl-sandbox-helper`), and prints its absolute
/// in-WSL path on a [`HELPER_RESULT_PREFIX`] line. `$1` is the release channel,
/// `$2` the version (`latest` for dev builds, which have no matching release and
/// so track the latest nightly); both are passed as argv, never interpolated, so
/// a version/channel string can't inject shell.
///
/// Unlike a normal Linux install, this deliberately does **not** consult the WSL
/// `PATH`: inside WSL `zed` typically resolves to the *Windows* `zed.exe` via
/// interop, which is not a Linux binary and so can't be the helper. It also does
/// not use the public install script (`install.sh`), which puts `zed` on the
/// user's `PATH` and writes desktop entries we don't want. Instead the Windows
/// side resolves the exact channel+version (see `wsl_zed_release`) and this
/// script downloads that release's Linux tarball straight from
/// `cloud.zed.dev/releases` and unpacks it into a private, off-`PATH` location
/// (`~/.local/libexec/zed/<channel>`, the conventional spot for executables run
/// by other programs rather than directly by the user). One managed copy per
/// channel is kept, tracked by a marker file so an exact channel+version match
/// is reused rather than re-downloaded. The floating `latest` version (dev
/// builds) is the exception: it always re-downloads so it tracks the newest
/// nightly rather than pinning to the first copy fetched.
///
/// We ship no `zed` (nor `bwrap`) into WSL ourselves; this downloads `zed` on
/// demand. A missing `curl`/`wget` (or a failed download) is a hard error the
/// caller surfaces to the user, exactly like a missing `bwrap`.
const HELPER_PROVISION_SCRIPT: &str = r#"
set -eu
channel="$1"
version="$2"
dest="$HOME/.local/libexec/zed/$channel"
marker="$dest/.zed-wsl-helper-version"
want="$channel $version"
# Reuse an exact, already-installed channel+version — but never for the floating
# "latest" tag (dev builds), which must always re-fetch so they track the most
# recent nightly instead of pinning to whatever was downloaded first.
if [ "$version" != "latest" ] && [ "$(cat "$marker" 2>/dev/null || true)" = "$want" ]; then
helper=$(find "$dest" -type f -path '*/libexec/zed-editor' -print 2>/dev/null | head -n 1 || true)
if [ -n "$helper" ] && [ -x "$helper" ]; then
printf 'zed-wsl-helper: %s\n' "$helper"
exit 0
fi
fi
arch=$(uname -m)
case "$arch" in
x86_64 | amd64) arch="x86_64" ;;
aarch64 | arm64) arch="aarch64" ;;
*) echo "unsupported WSL architecture for the zed sandbox helper: $arch" >&2; exit 1 ;;
esac
url="https://cloud.zed.dev/releases/$channel/$version/download?asset=zed&arch=$arch&os=linux&source=zed-wsl-sandbox"
tmp=$(mktemp -d "${TMPDIR:-/tmp}/zed-wsl-helper-XXXXXX")
trap 'rm -rf "$tmp"' EXIT
tarball="$tmp/zed.tar.gz"
if command -v curl >/dev/null 2>&1; then
curl -fL "$url" -o "$tarball"
elif command -v wget >/dev/null 2>&1; then
wget -O "$tarball" "$url"
else
echo 'neither curl nor wget is available in WSL to download zed' >&2
exit 1
fi
mkdir -p "$tmp/unpacked"
tar -xzf "$tarball" -C "$tmp/unpacked"
helper_src=$(find "$tmp/unpacked" -type f -path '*/libexec/zed-editor' -print 2>/dev/null | head -n 1 || true)
if [ -z "$helper_src" ]; then
echo 'the downloaded zed tarball did not contain a libexec/zed-editor binary' >&2
exit 1
fi
app=$(dirname "$(dirname "$helper_src")")
# Install atomically: stage the unpacked app next to the destination on the same
# filesystem, then swap it into place so a concurrent run never sees (or execs) a
# partially-written install. The whole app dir is kept so the binary's bundled
# libraries ($ORIGIN/../lib) remain alongside it.
mkdir -p "$(dirname "$dest")"
rm -rf "$dest.new" "$dest.old"
cp -a "$app" "$dest.new"
if [ -e "$dest" ]; then mv "$dest" "$dest.old"; fi
mv "$dest.new" "$dest"
rm -rf "$dest.old"
printf '%s' "$want" > "$marker"
helper=$(find "$dest" -type f -path '*/libexec/zed-editor' -print 2>/dev/null | head -n 1 || true)
if [ -z "$helper" ] || [ ! -x "$helper" ]; then
echo "the installed zed sandbox helper is missing or not executable under $dest" >&2
exit 1
fi
printf 'zed-wsl-helper: %s\n' "$helper"
exit 0
"#;
/// Marks a failure of the Windows WSL sandboxing *environment*: WSL is missing
/// or won't start, there's no usable `bwrap`, or the probe / path-resolution
/// stdout protocol broke down. Returned as the root of the `anyhow::Error` so
/// callers classify it by type ([`anyhow::Error::downcast_ref`]) instead of by
/// matching message text. Per-request failures (a missing writable path, paths
/// mixing distros) are ordinary `anyhow` errors *without* this type, so they
/// never match — the agent returns those to the model rather than offering to
/// run unsandboxed.
#[derive(Debug, Clone)]
pub struct WslSandboxUnavailable(String);
impl WslSandboxUnavailable {
/// Build an environment-unavailable error from a human-readable reason
/// (without the [`WSL_SANDBOX_UNAVAILABLE_PREFIX`], which `Display` adds).
pub fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
/// The reason, without the leading [`WSL_SANDBOX_UNAVAILABLE_PREFIX`].
#[cfg(test)]
pub fn message(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for WslSandboxUnavailable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{WSL_SANDBOX_UNAVAILABLE_PREFIX}: {}", self.0)
}
}
impl std::error::Error for WslSandboxUnavailable {}
/// Shorthand for an [`anyhow::Error`] wrapping a [`WslSandboxUnavailable`].
fn unavailable(message: impl Into<String>) -> anyhow::Error {
anyhow::Error::new(WslSandboxUnavailable::new(message))
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct WslPath {
distro: Option<String>,
path: String,
}
/// A path mapped for use inside WSL.
///
/// WSL UNC and WSL-absolute paths can be mapped structurally up front. Native
/// drive-letter paths depend on the distro's automount configuration
/// (`/etc/wsl.conf` can move the `/mnt` root), so they are translated with
/// `wslpath` inside the distro — but a distro can only be chosen after every
/// path has been parsed (WSL UNC paths pin one), hence this two-stage shape:
/// parse structurally first, then resolve native paths via [`resolve_paths`]
/// once the distro is known.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
enum PathMapping {
Wsl(WslPath),
NativeDrive {
/// The `\\?\`-stripped, forward-slashed form that `wslpath -u`
/// accepts (`wslpath` is a Linux binary and doesn't understand
/// backslash separators).
windows_path: String,
/// The conventional `/mnt/<drive>/...` mapping, used when `wslpath`
/// translation fails.
fallback: WslPath,
},
}
impl PathMapping {
fn distro(&self) -> Option<&str> {
match self {
PathMapping::Wsl(path) => path.distro.as_deref(),
PathMapping::NativeDrive { .. } => None,
}
}
}
/// The result of resolving a write grant inside WSL: its canonical target, plus
/// whether that target lives on a Windows-hosted (DrvFs) filesystem whose
/// sandbox-integrity guarantees are weaker than the distro's native filesystem.
pub struct ResolvedGrant {
pub canonical: PathBuf,
pub on_windows_fs: bool,
}
/// Whether `path` names a location on a Windows drive (reached inside WSL via
/// DrvFs), as opposed to a WSL-native (ext4) path. This is a cheap *structural*
/// classification of the path shape (a drive-letter/verbatim path maps to a
/// `NativeDrive`; WSL UNC and Linux-absolute paths are distro-native). It is
/// used to decide whether to warn about the weaker DrvFs sandbox guarantees for
/// default project paths without a WSL round-trip. For explicit grants the
/// authoritative filesystem class comes from the in-WSL resolver instead (see
/// [`resolve_canonical_for_grant`]).
pub fn path_is_on_windows_drive(path: &Path) -> bool {
matches!(
path_to_wsl_allowing_missing(path),
Ok(PathMapping::NativeDrive { .. })
)
}
/// Whether WSL is structurally present: at least one distro is registered
/// under `HKCU\Software\Microsoft\Windows\CurrentVersion\Lxss`. A cheap
/// registry read (no `wsl.exe` spawn), suitable for UI gating decisions like
/// suppressing sandbox-integrity warnings on machines where no WSL sandbox
/// could ever be constructed. It is *not* an availability guarantee: a
/// registered distro can still fail the real environment probe (no `bwrap`,
/// unhealthy WSL service, …), so enforcement paths must keep relying on
/// [`probe_environment`]'s in-WSL round-trip.
pub fn wsl_distro_registered() -> bool {
windows_registry::CURRENT_USER
.open("Software\\Microsoft\\Windows\\CurrentVersion\\Lxss")
.and_then(|lxss_key| {
Ok(lxss_key.keys()?.any(|key| {
lxss_key
.open(&key)
.and_then(|distro| distro.get_string("DistributionName"))
.is_ok()
}))
})
.unwrap_or(false)
}
/// Resolve a requested write grant to the path we persist for it, in
/// **platform-native** form: a Windows drive path stays `C:\...` (NTFS) and a
/// WSL path stays a Linux-absolute `/...`. In both cases the path is resolved to
/// its symlink/junction-free canonical target (so a granted junction or symlink
/// is followed and shown/stored as its real target), and its backing filesystem
/// is classified.
///
/// Crucially, *each side canonicalizes what it is authoritative about*:
///
/// * A **Windows drive path** is canonicalized on the **Windows host**. A
/// junction (or symlink) on a Windows drive is a Windows filesystem object;
/// WSL's DrvFs surfaces it as a plain symlink but cannot reliably resolve it
/// (the `/proc/self/fd` readback that follows an ext4 symlink to its real
/// target does not follow a DrvFs junction). Resolving it WSL-side would
/// therefore store the junction unresolved and then reject it at enforcement,
/// where [`crate::util::CanonicalPathBuf::from_canonical`] refuses a symlink
/// leaf. `std::fs::canonicalize` follows the junction/symlink chain and
/// confirms the target exists.
/// * A **WSL path** is canonicalized inside **WSL** via the in-distro helper —
/// Windows can't follow ext4 symlinks.
///
/// Either way the persisted canonical is re-verified in the enforcement
/// namespace by `from_canonical` (which opens it `O_NOFOLLOW` and pins the
/// inode), so a component swapped for a symlink/junction after approval still
/// fails closed.
///
/// A local project (the only kind that is sandboxed) has NTFS files, so grants
/// are either those `C:\...` paths or WSL-native scratch locations named with
/// `/...`; `\\wsl.localhost\...` and other shapes are rejected. There is no
/// distro to pin — a sandboxed command runs in exactly one distro (the project's
/// or WSL's default), so a bare Linux path is unambiguous.
pub async fn resolve_canonical_for_grant(
requested: PathBuf,
wsl_zed_release: (String, String),
) -> Result<ResolvedGrant> {
let path_string = requested.to_string_lossy();
// A Windows drive grant (NTFS) is resolved on the Windows host, which is
// authoritative about junctions and symlinks (see the doc comment above).
if parse_native_drive_path(&path_string).is_ok() {
let canonical = smol::fs::canonicalize(&requested).await.with_context(|| {
format!(
"failed to resolve writable path `{}` on the Windows host",
requested.display()
)
})?;
// `canonicalize` yields a `\\?\` verbatim path; store the plain native
// spelling so a non-junction grant compares equal to what was requested.
// A junction resolving off the local drives (onto a UNC/WSL path) is
// rejected — only a Windows drive path maps to a DrvFs bind.
let canonical = strip_windows_verbatim_prefix(canonical);
ensure!(
parse_native_drive_path(&canonical.to_string_lossy()).is_ok(),
"cannot grant sandbox write access to `{}`: it resolves to `{}`, \
which is not a Windows drive path",
requested.display(),
canonical.display()
);
// A Windows drive is reached inside WSL via DrvFs (weaker guarantees), so
// a native-drive grant is on a Windows filesystem by definition.
return Ok(ResolvedGrant {
canonical,
on_windows_fs: true,
});
}
// Otherwise the grant must be a Linux-absolute WSL path, which only WSL can
// canonicalize. `\\wsl.localhost\...` and other shapes are rejected.
let wsl_path = parse_wsl_absolute_path(&path_string).map_err(|_| {
anyhow::anyhow!(
"cannot grant sandbox write access to `{}`: only Windows drive paths \
(`C:\\...`) and WSL absolute paths (`/...`) are supported",
requested.display()
)
})?;
let mapping = PathMapping::Wsl(wsl_path);
let wsl_exe = wsl_exe_path();
if !wsl_exe.is_file() {
return Err(unavailable(format!(
"WSL (`wsl.exe`) was not found at `{}`",
wsl_exe.display()
)));
}
// Translate + existence-check the path inside WSL, then resolve its
// symlink-free canonical (and classify its filesystem) via the helper. No
// distro is pinned — a sandboxed command runs in one distro.
let translated = resolve_paths(&wsl_exe, None, &[(mapping, "writable path", true)])
.await?
.into_iter()
.next()
.context("bug: missing resolved writable path")?
.context("bug: required writable path resolved as missing")?;
let (channel, version) = wsl_zed_release;
let helper = ensure_wsl_zed_helper(&wsl_exe, None, &channel, &version).await?;
let output = run_wsl_command(
&wsl_exe,
None,
[
"--exec",
helper.as_str(),
crate::WSL_SANDBOX_RESOLVE_FLAG,
translated.as_str(),
],
"resolve a sandbox write grant",
)
.await?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"failed to resolve writable path `{}`{}",
requested.display(),
command_failure_details(output.status.code(), &output.stderr)
));
}
let stdout = String::from_utf8(output.stdout)
.context("WSL sandbox helper returned a non-UTF-8 canonical path")?;
parse_canonical_grant_output(&stdout)
}
/// Strip the `\\?\` verbatim prefix that `std::fs::canonicalize` prepends to a
/// Windows drive path, leaving `\\?\UNC\...` untouched (it is not a drive path,
/// and is rejected by the caller). We persist and display the plain `C:\...`
/// spelling so a non-junction grant compares equal to the requested path.
fn strip_windows_verbatim_prefix(path: PathBuf) -> PathBuf {
let text = path.to_string_lossy();
match text.strip_prefix(r"\\?\") {
Some(rest) if !rest.starts_with("UNC\\") => PathBuf::from(rest),
_ => path,
}
}
/// `on_windows_fs` word emitted by the resolver for a Windows-hosted (DrvFs)
/// canonical target.
const RESOLVE_WINDOWS_FS: &str = "windows-fs";
/// `on_windows_fs` word emitted by the resolver for a distro-native canonical
/// target.
const RESOLVE_NATIVE_FS: &str = "native-fs";
/// Wrap a Linux process invocation so it runs under Bubblewrap inside WSL.
///
/// `program` and `args` must name a Linux executable and Linux argv, not a
/// Windows executable. The caller is expected to convert the model's command
/// into a Linux shell invocation (typically `/bin/sh -c ...`) before calling
/// this function.
///
/// All writable paths, protected paths, and the cwd must be paths that can be
/// mapped into WSL. The cwd and writable paths must exist; protected paths may
/// be missing because Bubblewrap cannot bind a missing source. WSL UNC paths
/// may specify a distro; native drive-letter paths are translated with `wslpath`
/// inside either that distro or the default distro (falling back to
/// `/mnt/<drive>/...` if translation fails).
///
/// `env` is forwarded into the sandboxed command via `bwrap --setenv` rather
/// than being set on the `wsl.exe` process. Windows environment variables
/// don't cross the WSL boundary unless they're listed in `WSLENV`, so without
/// this the command would lose `PAGER` (used to stop `git` from paging into
/// the PTY) and the rest of the project environment. Variables whose Windows
/// values are meaningless or harmful inside Linux are dropped (see
/// [`is_forwardable_env_var`]).
///
/// This function performs up to two `wsl.exe` round-trips (environment probe
/// and path resolution, each cached) plus filesystem stats of WSL UNC paths,
/// any of which can take seconds when the WSL VM is cold (and the stats can
/// stall on a slow `\\wsl.localhost` filesystem). Run it on a background
/// executor, never on the UI thread, and bound it with a timeout — a wedged
/// `wsl.exe` (a real failure mode when the WSL service is unhealthy)
/// otherwise stalls the returned future forever. This crate deliberately has
/// no timer of its own (timers come from the caller's executor so tests stay
/// deterministic); instead it guarantees that dropping the future kills any
/// in-flight `wsl.exe` child, so a caller-side timeout that drops the future
/// also reaps the process. Parameters are owned so the returned future is
/// `Send + 'static`.
pub async fn wrap_invocation<S: std::hash::BuildHasher>(
program: String,
args: Vec<String>,
writable_paths: Vec<WritablePath>,
protected_paths: Vec<PathBuf>,
permissions: SandboxPermissions,
cwd: Option<PathBuf>,
env: HashMap<String, String, S>,
// `(release channel, version)` of the Linux `zed` to provision inside WSL as
// the `--wsl-sandbox-helper` (the version is `latest` for dev builds). When
// `None`, no helper is used and bwrap is exec'd directly — the legacy path,
// which binds writable paths by string and so carries the bind-source TOCTOU
// the helper closes. Callers that can determine the running release should
// always pass `Some`.
wsl_zed_release: Option<(String, String)>,
) -> Result<(String, Vec<String>)> {
// Mapping failures are bad requests (a path that doesn't exist or has a
// shape WSL can't address), not environment problems, so no
// `WSL_SANDBOX_UNAVAILABLE_PREFIX` here.
let cwd_mapping =
match &cwd {
Some(cwd) => Some(directory_to_wsl(cwd).with_context(|| {
format!("failed to map terminal cwd `{}` into WSL", cwd.display())
})?),
None => None,
};
let writable_requested_mappings = writable_paths
.iter()
.map(|path| {
path_to_wsl(&path.requested).with_context(|| {
format!(
"failed to map writable path `{}` into WSL",
path.requested.display()
)
})
})
.collect::<Result<Vec<_>>>()?;
let writable_mappings = writable_paths
.iter()
.map(|path| {
path_to_wsl(&path.canonical).with_context(|| {
format!(
"failed to map canonical writable path `{}` into WSL",
path.canonical.display()
)
})
})
.collect::<Result<Vec<_>>>()?;
let protected_mappings = protected_paths
.iter()
.map(|path| {
path_to_wsl_allowing_missing(path).with_context(|| {
format!("failed to map protected path `{}` into WSL", path.display())
})
})
.collect::<Result<Vec<_>>>()?;
let distro = select_distro(
cwd_mapping.as_ref(),
writable_requested_mappings
.iter()
.chain(writable_mappings.iter())
.chain(protected_mappings.iter()),
)?;
let wsl_exe = wsl_exe_path();
if !wsl_exe.is_file() {
return Err(unavailable(format!(
"WSL (`wsl.exe`) was not found at `{}`",
wsl_exe.display()
)));
}
let environment = probe_environment(&wsl_exe, distro.as_deref()).await?;
// Resolve all paths (translating native drive-letter paths with `wslpath`
// now that the distro is known) in a single WSL round-trip. The cwd and
// ordinary writable paths must exist; protected paths may be missing because,
// as with Linux bwrap, a not-yet-created path can't be overlaid.
let has_cwd = cwd_mapping.is_some();
let writable_path_count = writable_mappings.len();
let mut mappings = Vec::with_capacity(writable_mappings.len() + protected_mappings.len() + 1);
if let Some(mapping) = cwd_mapping {
mappings.push((mapping, "terminal cwd", true));
}
mappings.extend(
writable_mappings
.into_iter()
.map(|mapping| (mapping, "writable path", true)),
);
mappings.extend(
protected_mappings
.into_iter()
.map(|mapping| (mapping, "protected path", false)),
);
let resolved = resolve_paths(&wsl_exe, distro.as_deref(), &mappings).await?;
let (cwd, writable_paths, protected_paths) =
split_resolved_paths(has_cwd, writable_path_count, resolved)?;
let mut wsl_args = Vec::new();
if let Some(distro) = distro.as_deref() {
wsl_args.extend(["-d".to_string(), distro.to_string()]);
}
if let Some(cwd) = &cwd {
wsl_args.extend(["--cd".to_string(), cwd.clone()]);
}
// The bwrap *options* (everything before the trailing `-- cmd`): root bind,
// `/tmp` tmpfs, writable binds, interop blocking, `--setenv`s, `--chdir`,
// namespace flags. Identical whether or not we route through the helper.
let bwrap_options = build_bwrap_args(
&writable_paths,
&protected_paths,
permissions,
cwd.as_deref(),
environment.mask_interop_dir,
&env,
);
match wsl_zed_release {
// Preferred path: run the in-WSL `zed` as the sandbox helper, which
// verifies and pins the persisted canonical writable paths WSL-side,
// then validates them again after bwrap's mounts.
Some((channel, version)) => {
let helper =
ensure_wsl_zed_helper(&wsl_exe, distro.as_deref(), &channel, &version).await?;
wsl_args.extend(["--exec".to_string(), helper]);
// Protocol (decoded by `linux_bubblewrap::decode_wsl_helper_args`):
// <flag> <bwrap_path> <n_base> <base...> <n_writable> <writable...> -- <prog> <args>
wsl_args.push(crate::WSL_SANDBOX_HELPER_FLAG.to_string());
wsl_args.push(environment.bwrap_path.clone());
wsl_args.push(bwrap_options.len().to_string());
wsl_args.extend(bwrap_options);
wsl_args.push(writable_paths.len().to_string());
wsl_args.extend(writable_paths.iter().cloned());
wsl_args.push("--".to_string());
wsl_args.push(program);
wsl_args.extend(args);
}
// Legacy path: exec bwrap directly (no in-sandbox bind validation). Use
// the absolute path the probe validated, since `wsl --exec` searches
// only the default WSL PATH.
None => {
wsl_args.extend(["--exec".to_string(), environment.bwrap_path.clone()]);
wsl_args.extend(bwrap_options);
wsl_args.push("--".to_string());
wsl_args.push(program);
wsl_args.extend(args);
}
}
Ok((wsl_exe.to_string_lossy().into_owned(), wsl_args))
}
fn parse_canonical_grant_output(stdout: &str) -> Result<ResolvedGrant> {
let mut lines = stdout.lines();
let distro = lines
.next()
.context("WSL sandbox helper omitted the distro name")?;
let canonical = lines
.next()
.context("WSL sandbox helper omitted the canonical path")?;
let fs_class = lines
.next()
.context("WSL sandbox helper omitted the filesystem class")?;
let on_windows_fs = match fs_class {
RESOLVE_WINDOWS_FS => true,
RESOLVE_NATIVE_FS => false,
_ => bail!("WSL sandbox helper returned an invalid filesystem class: {fs_class:?}"),
};
ensure!(
lines.next().is_none()
&& !distro.is_empty()
&& !distro.contains(['/', '\\'])
&& canonical.starts_with('/'),
"WSL sandbox helper returned invalid canonical-path output: {stdout:?}"
);
// Store the canonical as the Linux path bwrap actually binds. The distro is
// pinned at enforcement from the *requested* path (see `wrap_invocation`),
// so it is not re-encoded here: spelling the canonical as
// `\\wsl.localhost\<distro>\...` would resurface the Windows-style path we
// avoid and, because it names the same object as the request, show as a
// spurious redirect in the approval UI.
Ok(ResolvedGrant {
canonical: PathBuf::from(canonical),
on_windows_fs,
})
}
fn split_resolved_paths(
has_cwd: bool,
writable_path_count: usize,
resolved: Vec<Option<String>>,
) -> Result<(Option<String>, Vec<String>, Vec<String>)> {
let mut resolved = resolved.into_iter();
let cwd = if has_cwd {
Some(
resolved
.next()
.context("bug: missing resolved terminal cwd")?
.context("bug: required terminal cwd resolved as missing")?,
)
} else {
None
};
let mut writable_paths = Vec::with_capacity(writable_path_count);
for _ in 0..writable_path_count {
writable_paths.push(
resolved
.next()
.context("bug: missing resolved writable path")?
.context("bug: required writable path resolved as missing")?,
);
}
Ok((cwd, writable_paths, resolved.flatten().collect()))
}
fn select_distro<'a>(
cwd: Option<&PathMapping>,
paths: impl IntoIterator<Item = &'a PathMapping>,
) -> Result<Option<String>> {
let mut distro = cwd.and_then(|mapping| mapping.distro().map(str::to_string));
for mapping in paths {
let Some(path_distro) = mapping.distro() else {
continue;
};
match distro.as_deref() {
// A bad request, not an environment problem: the model (or
// project layout) asked for paths spanning two distros, which a
// single bwrap invocation can't serve.
Some(distro) => ensure!(
distro == path_distro,
"cannot sandbox a command whose paths mix WSL distros `{}` and `{}`",
distro,
path_distro
),
None => distro = Some(path_distro.to_string()),
}
}
Ok(distro)
}
/// What [`probe_environment`] learned about a WSL distro.
#[derive(Clone, Debug, Eq, PartialEq)]
struct EnvironmentProbe {
/// Whether the WSL interop socket directory (`/run/WSL`) exists and so
/// must (and can) be masked — see [`build_bwrap_args`].
mask_interop_dir: bool,
/// Absolute path of the `bwrap` binary the smoke test validated. The real
/// invocation must exec this exact path: `wsl --exec` searches only the
/// default WSL PATH, so a bare `bwrap` could miss (or differ from) the
/// binary the probe's login shell found.
bwrap_path: String,
}
/// Shell script run by [`probe_environment`]. Resolves `bwrap` to an absolute
/// path (exit [`BWRAP_MISSING_EXIT_CODE`] if absent), rejects setuid-root
/// binaries, then smoke-tests a real minimal sandbox (exit
/// [`BWRAP_UNUSABLE_EXIT_CODE`] on failure) using the same mount and namespace
/// flags as [`build_bwrap_args`] — presence isn't
/// enough, because unprivileged user namespaces can be disabled by the
/// distro's kernel, sysctl, or AppArmor policy (notably Ubuntu 24.04, the
/// current default WSL distro), in which case `bwrap` exists but every
/// sandboxed command would fail. The interop mask is included in the smoke
/// test when `/run/WSL` exists so the exact mount we later perform is
/// exercised too. On success, one [`PROBE_RESULT_PREFIX`]-marked result line
/// reports the interop state and the resolved `bwrap` path.
fn probe_script() -> String {
format!(
"bwrap_path=$(command -v bwrap) || exit {BWRAP_MISSING_EXIT_CODE}; \
if [ -u \"$bwrap_path\" ] && [ \"$(stat -c %u \"$bwrap_path\" 2>/dev/null)\" = 0 ]; then \
echo 'setuid-root bwrap is not supported' >&2; \
exit {BWRAP_UNUSABLE_EXIT_CODE}; fi; \
if [ -d /run/WSL ]; then interop=interop; mask='--tmpfs /run/WSL'; \
else interop=no-interop; mask=''; fi; \
\"$bwrap_path\" --ro-bind / / --tmpfs /tmp $mask --dev /dev --proc /proc \
--unshare-net --unshare-user --unshare-ipc --unshare-uts --unshare-pid \
--unshare-cgroup-try --die-with-parent -- true >/dev/null \
|| exit {BWRAP_UNUSABLE_EXIT_CODE}; \
printf '{PROBE_RESULT_PREFIX} %s %s\\n' \"$interop\" \"$bwrap_path\""
)
}
/// Probe a distro's sandbox environment in one `wsl.exe` round-trip: confirm
/// a shell starts, confirm `bwrap` is installed *and can actually set up an
/// unprivileged sandbox* (see [`probe_script`]), and report whether the
/// interop socket directory exists.
///
/// Successful results are cached per distro for the life of the process —
/// like `linux_bubblewrap::is_available`, the answers can't realistically
/// change while Gwen runs. Failures are deliberately *not* cached so a user
/// who installs `bwrap` (or lifts a user-namespace restriction) after seeing
/// the error can retry the command without restarting Gwen.
async fn probe_environment(wsl_exe: &Path, distro: Option<&str>) -> Result<EnvironmentProbe> {
static CACHE: OnceLock<Mutex<HashMap<Option<String>, EnvironmentProbe>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let key = distro.map(str::to_string);
if let Some(probe) = cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.get(&key)
{
return Ok(probe.clone());
}
// A login shell (`-lc`) is used so a `bwrap` reachable only through a
// profile-managed PATH is still found; the resolved absolute path is
// reported back so the real invocation execs the same binary.
let script = probe_script();
let output = run_wsl_command(
wsl_exe,
distro,
["--exec", "sh", "-lc", &script],
"probe the sandbox environment",
)
.await?;
if output.status.code() == Some(BWRAP_MISSING_EXIT_CODE) {
return Err(unavailable(format!(
"Bubblewrap (`bwrap`) is not installed in {}",
wsl_distro_label(distro)
)));
}
if output.status.code() == Some(BWRAP_UNUSABLE_EXIT_CODE) {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = stderr.trim();
return Err(unavailable(format!(
"Bubblewrap (`bwrap`) is installed in {} but could not set up a sandbox — the \
distro may restrict unprivileged user namespaces (as Ubuntu 24.04's default \
AppArmor policy does){}",
wsl_distro_label(distro),
if stderr.is_empty() {
String::new()
} else {
format!(": {stderr}")
}
)));
}
if !output.status.success() {
return Err(unavailable(format!(
"failed to start a shell in {}{}",
wsl_distro_label(distro),
command_failure_details(output.status.code(), &output.stderr)
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let probe = parse_probe_output(&stdout).map_err(|error| {
unavailable(format!(
"unexpected sandbox probe output from {}: {error:#}",
wsl_distro_label(distro)
))
})?;
cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(key, probe.clone());
Ok(probe)
}
/// Parse [`probe_script`] output: the last [`PROBE_RESULT_PREFIX`]-marked
/// line wins, so stdout noise from login-shell profile scripts (which runs
/// before the script body) is ignored.
fn parse_probe_output(stdout: &str) -> Result<EnvironmentProbe> {
let line = stdout
.lines()
.rev()
.find_map(|line| line.strip_prefix(PROBE_RESULT_PREFIX))
.with_context(|| format!("no probe result line in: {stdout:?}"))?;
let (interop, bwrap_path) = line
.trim_start()
.split_once(' ')
.with_context(|| format!("malformed probe result line: {line:?}"))?;
let mask_interop_dir = match interop {
"interop" => true,
"no-interop" => false,
_ => bail!("malformed probe result line: {line:?}"),
};
ensure!(
bwrap_path.starts_with('/'),
"`bwrap` resolved to {bwrap_path:?} rather than an absolute path; a shell \
alias or function named `bwrap` cannot be run with `wsl --exec`"
);
Ok(EnvironmentProbe {
mask_interop_dir,
bwrap_path: bwrap_path.to_string(),
})
}
/// Ensure a Linux `gwen` of the given release `channel`/`version` is available
/// inside WSL and return its absolute in-WSL path, to be `--exec`'d as the
/// `--wsl-sandbox-helper`. Runs [`HELPER_PROVISION_SCRIPT`] (which downloads the
/// matching release tarball into an off-`PATH` location on first use).
///
/// Successful resolutions are cached per `(distro, channel, version)` for the
/// life of the process — once provisioned, the path won't change. Failures are
/// not cached, so a user who installs `curl` (or fixes networking) after an
/// error can retry without restarting Gwen.
async fn ensure_wsl_zed_helper(
wsl_exe: &Path,
distro: Option<&str>,
channel: &str,
version: &str,
) -> Result<String> {
// TODO: Remove this development override once WSL canonical-path handling is released.
if let Some(helper) = std::env::var_os("ZED_WSL_SANDBOX_HELPER") {
let helper = helper
.into_string()
.map_err(|_| anyhow::anyhow!("ZED_WSL_SANDBOX_HELPER is not valid UTF-8"))?;
ensure!(
helper.starts_with('/'),
"ZED_WSL_SANDBOX_HELPER must be an absolute path inside WSL"
);
return Ok(helper);
}
type HelperCache = HashMap<(Option<String>, String, String), String>;
static CACHE: OnceLock<Mutex<HelperCache>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let key = (
distro.map(str::to_string),
channel.to_string(),
version.to_string(),
);
if let Some(path) = cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.get(&key)
{
return Ok(path.clone());
}
// A login shell (`-lc`) is used so a profile-managed PATH (where `zed` or
// `curl` may live) is honored. `channel`/`version` are passed as positional
// args (`$1`/`$2`), never interpolated into the script body.
let output = run_wsl_command(
wsl_exe,
distro,
[
"--exec",
"sh",
"-lc",
HELPER_PROVISION_SCRIPT,
"zed-wsl-sandbox-helper",
channel,
version,
],
"provision the Linux `zed` sandbox helper",
)
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = stderr.trim();
return Err(unavailable(format!(
"failed to provision a Linux `zed` sandbox helper in {}{}",
wsl_distro_label(distro),
if stderr.is_empty() {
String::new()
} else {
format!(": {stderr}")
}
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let path = stdout
.lines()
.rev()
.find_map(|line| line.strip_prefix(HELPER_RESULT_PREFIX))
.map(|path| path.trim().to_string())
.with_context(|| {
unavailable(format!(
"no helper result line in sandbox-helper provisioning output from {}: {stdout:?}",
wsl_distro_label(distro)
))
})?;
ensure!(
path.starts_with('/'),
"the WSL `zed` sandbox helper resolved to {path:?} rather than an absolute path"
);
cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(key, path.clone());
Ok(path)
}
/// Shell script that resolves and existence-checks paths in a single WSL
/// round-trip. Arguments come in triples `(kind, path, fallback)`: kind `W`
/// is a native Windows path to translate with `wslpath -u` (falling back to
/// the precomputed `/mnt/<drive>/...` mapping when translation fails), kind
/// `L` is an already-Linux path with an empty fallback. One result line is
/// printed per triple: `<ok|fallback> <ok|missing> <resolved path>`.
const PATH_RESOLUTION_SCRIPT: &str = "\
while [ \"$#\" -ge 3 ]; do \
kind=$1; path=$2; fallback=$3; shift 3; translate=ok; \
if [ \"$kind\" = W ]; then \
resolved=$(wslpath -u \"$path\" 2>/dev/null) || { resolved=$fallback; translate=fallback; }; \
else resolved=$path; fi; \
exists=ok; [ -e \"$resolved\" ] || exists=missing; \
printf '%s %s %s\\n' \"$translate\" \"$exists\" \"$resolved\"; \
done";
/// A line of [`PATH_RESOLUTION_SCRIPT`] output, parsed.
#[derive(Debug, Eq, PartialEq)]
struct ResolvedPath {
path: String,
used_fallback: bool,
exists: bool,
}
/// Resolve path mappings into final WSL paths and confirm required paths exist.
/// Native drive-letter paths are translated with `wslpath -u` inside the
/// chosen distro so its actual automount configuration is honored, falling
/// back to the structural `/mnt/<drive>` mapping when translation fails
/// (e.g. a distro without `wslpath`); a wrong fallback is still caught by
/// the existence check.
///
/// Successful resolutions are memoized per `(distro, mapping)` for the life
/// of the process, so a steady-state command whose paths have all been seen
/// before resolves with zero `wsl.exe` round-trips; at most one round-trip
/// handles all cache misses ([`resolve_uncached_paths`]). A hit reuses the
/// translation — which only changes if the distro's automount configuration
/// is edited and the distro restarted — and also skips the WSL-side
/// existence re-check. That staleness is acceptable: if a cached path
/// disappears mid-session bwrap fails closed on the missing bind source rather
/// than running the command unsandboxed. Optional missing paths are not cached,
/// so a protected Git path can be created and then included by a later command.
///
/// Each mapping is paired with a human-readable description used in errors and
/// a flag for whether the path is required to exist. The returned paths are in
/// the same order as `mappings`; optional missing paths are returned as `None`.
async fn resolve_paths(
wsl_exe: &Path,
distro: Option<&str>,
mappings: &[(PathMapping, &str, bool)],
) -> Result<Vec<Option<String>>> {
type ResolutionCache = HashMap<Option<String>, HashMap<PathMapping, String>>;
static CACHE: OnceLock<Mutex<ResolutionCache>> = OnceLock::new();
let cache = CACHE.get_or_init(Default::default);
let distro_key = distro.map(str::to_string);
let mut resolved: Vec<Option<Option<String>>> = {
let cache = cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let per_distro = cache.get(&distro_key);
mappings
.iter()
.map(|(mapping, _, _)| {
per_distro
.and_then(|cached| cached.get(mapping))
.cloned()
.map(Some)
})
.collect()
};
let misses: Vec<usize> = (0..mappings.len())
.filter(|&index| resolved[index].is_none())
.collect();
if !misses.is_empty() {
let miss_mappings: Vec<&(PathMapping, &str, bool)> =
misses.iter().map(|&index| &mappings[index]).collect();
let miss_resolved = resolve_uncached_paths(wsl_exe, distro, &miss_mappings).await?;
let mut cache = cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let per_distro = cache.entry(distro_key).or_default();
for (&index, path) in misses.iter().zip(miss_resolved) {
if let Some(path) = &path {
per_distro.insert(mappings[index].0.clone(), path.clone());
}
resolved[index] = Some(path);
}
}
let mut paths = Vec::with_capacity(resolved.len());
for path in resolved {
paths.push(path.context("bug: a path mapping was left unresolved")?);
}
Ok(paths)
}
/// Resolve and existence-check mappings that weren't in the cache, in a
/// single `wsl.exe` round-trip. A non-login shell runs the script so profile
/// scripts can't pollute the stdout protocol.
async fn resolve_uncached_paths(
wsl_exe: &Path,
distro: Option<&str>,
mappings: &[&(PathMapping, &str, bool)],
) -> Result<Vec<Option<String>>> {
let mut args = vec![
"--exec".to_string(),
"sh".to_string(),
"-c".to_string(),
PATH_RESOLUTION_SCRIPT.to_string(),
// argv[0] for the script; the path triples follow as "$@".
"zed-resolve-paths".to_string(),
];
args.extend(path_resolution_args(
mappings.iter().map(|mapping| &mapping.0),
));
let output = run_wsl_command(wsl_exe, distro, &args, "resolve sandbox paths").await?;
if !output.status.success() {
return Err(unavailable(format!(
"failed to resolve sandbox paths in {}{}",
wsl_distro_label(distro),
command_failure_details(output.status.code(), &output.stderr)
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let resolved = parse_path_resolution_output(&stdout, mappings.len()).map_err(|error| {
unavailable(format!(
"failed to resolve sandbox paths in {}: {error:#}",
wsl_distro_label(distro)
))
})?;
mappings
.iter()
.zip(resolved)
.map(|((mapping, description, required), resolved)| {
if resolved.used_fallback
&& let PathMapping::NativeDrive { windows_path, .. } = mapping
{
log::warn!(
"failed to translate `{windows_path}` with wslpath in {}; \
falling back to `{}`",
wsl_distro_label(distro),
resolved.path
);
}
if !resolved.exists {
// A bad request (the path simply isn't there), not an
// environment problem — the model can create it or fix the path
// and retry, so no `WSL_SANDBOX_UNAVAILABLE_PREFIX`. Protected
// Git paths are allowed to be absent, matching Linux bwrap's
// behavior: a missing path cannot be overlaid, so it is skipped.
ensure!(
!required,
"mapped {description} `{}` does not exist in {}",
resolved.path,
wsl_distro_label(distro)
);
return Ok(None);
}
Ok(Some(resolved.path))
})
.collect()
}
/// Flatten path mappings into the `(kind, path, fallback)` argument triples
/// consumed by [`PATH_RESOLUTION_SCRIPT`].
fn path_resolution_args<'a>(mappings: impl Iterator<Item = &'a PathMapping>) -> Vec<String> {
let mut args = Vec::new();
for mapping in mappings {
match mapping {
PathMapping::Wsl(path) => {
args.extend(["L".to_string(), path.path.clone(), String::new()]);
}
PathMapping::NativeDrive {
windows_path,
fallback,
} => {
args.extend(["W".to_string(), windows_path.clone(), fallback.path.clone()]);
}
}
}
args
}
/// Parse [`PATH_RESOLUTION_SCRIPT`] output: one strictly-formatted line per
/// input triple. Anything else (wrong line count, unknown status words, a
/// non-absolute path) means the stdout protocol was corrupted and is an error.
fn parse_path_resolution_output(stdout: &str, expected: usize) -> Result<Vec<ResolvedPath>> {
let lines: Vec<&str> = stdout.lines().collect();
ensure!(
lines.len() == expected,
"expected {expected} result lines from the path resolution script, got {}: {stdout:?}",
lines.len()
);
lines
.into_iter()
.map(|line| {
let mut parts = line.splitn(3, ' ');
let (Some(translate), Some(exists), Some(path)) =
(parts.next(), parts.next(), parts.next())
else {
bail!("malformed line from the path resolution script: {line:?}");
};
let used_fallback = match translate {
"ok" => false,
"fallback" => true,
_ => bail!("malformed line from the path resolution script: {line:?}"),
};
let exists = match exists {
"ok" => true,
"missing" => false,
_ => bail!("malformed line from the path resolution script: {line:?}"),
};
ensure!(
path.starts_with('/'),
"unexpected resolved path from the path resolution script: {path:?}"
);
Ok(ResolvedPath {
path: path.to_string(),
used_fallback,
exists,
})
})
.collect()
}
/// `CREATE_NO_WINDOW` process creation flag. `wsl.exe` is a console-subsystem
/// binary, so spawning it from a GUI process without this flag flashes a
/// console window. Defined locally because this crate doesn't depend on
/// `util` (whose command helpers normally take care of this).
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// Invoke `wsl.exe` with the given args and return its raw output.
///
/// Only spawn failures become errors here; callers interpret the exit status
/// themselves. stdout, when used, is decoded as UTF-8 (lossily) — that's
/// only valid for `--exec`'d programs whose output we control, not for
/// `wsl.exe`'s own diagnostics (which are UTF-16LE).
///
/// `output()` spawns the child eagerly and the returned future owns it, so
/// with `kill_on_drop` the child can't outlive this future: a caller-side
/// timeout or cancellation that drops us also terminates a wedged `wsl.exe`
/// instead of leaking it.
async fn run_wsl_command(
wsl_exe: &Path,
distro: Option<&str>,
args: impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>>,
description: &str,
) -> Result<std::process::Output> {
use smol::process::windows::CommandExt as _;
let mut command = Command::new(wsl_exe);
if let Some(distro) = distro {
command.args(["-d", distro]);
}
command
.args(args)
.stdin(Stdio::null())
.kill_on_drop(true)
.creation_flags(CREATE_NO_WINDOW);
command.output().await.map_err(|error| {
unavailable(format!(
"failed to invoke WSL while trying to {description}: {error:#}"
))
})
}
fn command_failure_details(exit_code: Option<i32>, stderr: &[u8]) -> String {
let stderr = String::from_utf8_lossy(stderr);
let stderr = stderr.trim();
let exit_status = match exit_code {
Some(code) => format!("exit code {code}"),
None => "terminated by signal".to_string(),
};
if stderr.is_empty() {
format!(" ({exit_status})")
} else {
format!(" ({exit_status}; stderr: {stderr})")
}
}
fn wsl_distro_label(distro: Option<&str>) -> String {
match distro {
Some(distro) => format!("WSL distro `{distro}`"),
None => "the default WSL distro".to_string(),
}
}
fn wsl_exe_path() -> PathBuf {
std::env::var_os("SystemRoot")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\Windows"))
.join("System32")
.join("wsl.exe")
}
fn build_bwrap_args<S: std::hash::BuildHasher>(
writable_paths: &[String],
protected_paths: &[String],
permissions: SandboxPermissions,
cwd: Option<&str>,
mask_interop_dir: bool,
env: &HashMap<String, String, S>,
) -> Vec<String> {
let mut args = Vec::new();
if permissions.allow_fs_write {
push_bind(&mut args, "--bind", "/", "/");
} else {
push_bind(&mut args, "--ro-bind", "/", "/");
args.extend(["--tmpfs".to_string(), "/tmp".to_string()]);
for path in writable_paths {
push_bind(&mut args, "--bind", path, path);
}
}
// Protect requested paths by re-binding them read-only over any writable
// binds above (order matters: later binds win).
for path in protected_paths {
push_bind(&mut args, "--ro-bind", path, path);
}
// Block WSL's Windows interop, regardless of the requested permissions.
// Without this, a sandboxed process can exec a Windows binary (e.g.
// /mnt/c/Windows/System32/cmd.exe), which the kernel's binfmt handler
// (`/init`) hands off to the Windows host over an AF_UNIX socket — running
// fully outside bwrap and defeating both the filesystem and the network
// restrictions. `/init` locates that socket via the $WSL_INTEROP
// environment variable, so we drop it; and we mask the socket directory
// (when it exists) so the value can't be rediscovered by listing
// /run/WSL and re-exporting it. Both steps are required: unsetting the
// variable alone is bypassable, and masking alone leaves the inherited
// variable usable.
args.extend(["--unsetenv".to_string(), "WSL_INTEROP".to_string()]);
args.extend(["--unsetenv".to_string(), "WSLENV".to_string()]);
if mask_interop_dir {
args.extend(["--tmpfs".to_string(), "/run/WSL".to_string()]);
}
args.extend([
"--dev".to_string(),
"/dev".to_string(),
"--proc".to_string(),
"/proc".to_string(),
]);
if !permissions.allow_network {
args.push("--unshare-net".to_string());
}
args.extend([
"--unshare-user".to_string(),
"--unshare-ipc".to_string(),
"--unshare-uts".to_string(),
"--unshare-pid".to_string(),
"--unshare-cgroup-try".to_string(),
"--die-with-parent".to_string(),
]);
// Forward the caller-provided environment into the command. Windows env
// set on the `wsl.exe` process doesn't reach the Linux command, so we
// re-apply it here on the sandbox's child instead.
for (name, value) in env {
if is_forwardable_env_var(name) {
args.extend(["--setenv".to_string(), name.clone(), value.clone()]);
}
}
if let Some(cwd) = cwd {
args.extend(["--chdir".to_string(), cwd.to_string()]);
}
args
}
/// Whether an environment variable should be forwarded into the Linux sandbox.
///
/// `bwrap --setenv` calls `setenv(3)`, which rejects names that are empty or
/// contain `=`. Windows process environments include such entries — most
/// notably the per-drive current-directory pseudo-variables (`=C:`, `=D:`,
/// ...) Windows keeps in the environment block — so they must be skipped or
/// bwrap aborts with "setenv failed".
///
/// Beyond that, many variables hold Windows-specific values that would be
/// meaningless or actively break the command inside WSL, so they are dropped
/// rather than forwarded: `PATH`/`PATHEXT` would shadow WSL's own `PATH` and
/// stop the shell from finding Linux executables, the temp-dir variables point
/// at Windows paths that don't exist in WSL (bwrap provides a fresh tmpfs
/// `/tmp` instead), the WSL interop variables would undermine the explicit
/// interop block above, a Windows-set `HOME` would clobber the distro's own
/// `$HOME` and break the shell, and the rest are Windows system locations,
/// shell settings, host/session identity, and CPU descriptors that are either
/// wrong or misleading inside Linux. This is a blocklist rather than an allowlist so
/// genuinely portable variables (e.g. `LANG`, `CARGO_TERM_COLOR`, `PAGER`)
/// still reach the command; it only needs to cover the variables Windows
/// populates by default. Matched case-insensitively because Windows
/// environment variable names are.
fn is_forwardable_env_var(name: &str) -> bool {
if name.is_empty() || name.contains('=') {
return false;
}
const BLOCKED: &[&str] = &[
// Shadow or break the Linux process environment.
"PATH",
"PATHEXT",
"TMPDIR",
"TMP",
"TEMP",
// Would undermine the explicit WSL interop block above.
"WSL_INTEROP",
"WSLENV",
// Windows system locations and shell, meaningless inside Linux.
"OS",
"COMSPEC",
"WINDIR",
"SYSTEMROOT",
"SYSTEMDRIVE",
// When Windows has `HOME` set (e.g. for git), it's a Windows path that
// would clobber the distro's correct Linux `$HOME` and break the shell.
"HOME",
"HOMEDRIVE",
"HOMEPATH",
"HOMESHARE",
"USERPROFILE",
"PUBLIC",
"ALLUSERSPROFILE",
"APPDATA",
"LOCALAPPDATA",
"PROGRAMDATA",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"PROGRAMW6432",
"COMMONPROGRAMFILES",
"COMMONPROGRAMFILES(X86)",
"COMMONPROGRAMW6432",
"PSMODULEPATH",
"DRIVERDATA",
"ONEDRIVE",
// Windows host/session identity, misleading inside Linux.
"COMPUTERNAME",
"USERNAME",
"USERDOMAIN",
"USERDOMAIN_ROAMINGPROFILE",
"LOGONSERVER",
"SESSIONNAME",
// Windows CPU descriptors.
"NUMBER_OF_PROCESSORS",
"PROCESSOR_ARCHITECTURE",
"PROCESSOR_ARCHITEW6432",
"PROCESSOR_IDENTIFIER",
"PROCESSOR_LEVEL",
"PROCESSOR_REVISION",
];
!BLOCKED
.iter()
.any(|blocked| name.eq_ignore_ascii_case(blocked))
}
fn push_bind(args: &mut Vec<String>, flag: &str, source: &str, destination: &str) {
args.extend([
flag.to_string(),
source.to_string(),
destination.to_string(),
]);
}
fn directory_to_wsl(path: &Path) -> Result<PathMapping> {
ensure!(
path.is_dir(),
"Windows sandboxing via WSL can only use an existing directory as cwd: {}",
path.display()
);
map_path_to_wsl(path)
}
fn path_to_wsl(path: &Path) -> Result<PathMapping> {
let path_string = path.to_string_lossy();
if let Ok(path) = parse_wsl_absolute_path(&path_string) {
return Ok(PathMapping::Wsl(path));
}
ensure!(
path.is_dir() || path.is_file(),
"Windows sandboxing via WSL can only grant existing files or directories: {}",
path.display()
);
map_path_to_wsl(path)
}
fn path_to_wsl_allowing_missing(path: &Path) -> Result<PathMapping> {
let path_string = path.to_string_lossy();
if let Ok(path) = parse_wsl_absolute_path(&path_string) {
return Ok(PathMapping::Wsl(path));
}
map_path_to_wsl(path)
}
fn map_path_to_wsl(path: &Path) -> Result<PathMapping> {
let path_string = path.to_string_lossy();
if let Ok(path) = parse_wsl_unc_path(&path_string) {
return Ok(PathMapping::Wsl(path));
}
let fallback = parse_native_drive_path(&path_string)?;
let windows_path = path_string
.strip_prefix(r"\\?\")
.unwrap_or(&path_string)
.replace('\\', "/");
Ok(PathMapping::NativeDrive {
windows_path,
fallback,
})
}
fn parse_wsl_absolute_path(path: &str) -> Result<WslPath> {
let path = path.replace('\\', "/");
ensure!(
path.starts_with('/') && !path.starts_with("//"),
"path is not a WSL absolute path: {path}"
);
Ok(WslPath { distro: None, path })
}
fn parse_wsl_unc_path(path: &str) -> Result<WslPath> {
let path = path.replace('/', "\\");
let remainder = path
.strip_prefix("\\\\wsl.localhost\\")
.or_else(|| path.strip_prefix("\\\\wsl$\\"))
.or_else(|| path.strip_prefix("\\\\?\\UNC\\wsl.localhost\\"))
.or_else(|| path.strip_prefix("\\\\?\\UNC\\wsl$\\"))
.with_context(|| format!("path is not a WSL UNC path: {path}"))?;
let (distro, rest) = remainder
.split_once('\\')
.map(|(distro, rest)| (distro, Some(rest)))
.unwrap_or((remainder, None));
ensure!(
!distro.is_empty(),
"WSL UNC path is missing a distro name: {path}"
);
let linux_path = match rest {
Some(rest) if !rest.is_empty() => format!("/{}", rest.replace('\\', "/")),
_ => "/".to_string(),
};
Ok(WslPath {
distro: Some(distro.to_string()),
path: linux_path,
})
}
fn parse_native_drive_path(path: &str) -> Result<WslPath> {
let path = path
.strip_prefix("\\\\?\\")
.unwrap_or(path)
.replace('\\', "/");
let mut chars = path.chars();
let Some(drive) = chars.next().filter(|drive| drive.is_ascii_alphabetic()) else {
bail!("path is not a drive-letter Windows path: {path}");
};
ensure!(chars.next() == Some(':'), "path is not absolute: {path}");
let rest = chars.as_str().trim_start_matches('/');
let drive = drive.to_ascii_lowercase();
let linux_path = if rest.is_empty() {
format!("/mnt/{drive}")
} else {
format!("/mnt/{drive}/{rest}")
};
Ok(WslPath {
distro: None,
path: linux_path,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrap_invocation_future_is_send() {
// Callers run `wrap_invocation` via `background_spawn`, which
// requires a `Send` future. This fails to compile if, for example, a
// cache `MutexGuard` is ever held across an await point.
fn assert_send<T: Send>(_: T) {}
assert_send(wrap_invocation(
String::new(),
Vec::new(),
Vec::new(),
Vec::new(),
SandboxPermissions::default(),
None,
HashMap::<String, String>::new(),
None,
));
}
#[test]
fn parse_wsl_localhost_path() {
let path = parse_wsl_unc_path(r"\\wsl.localhost\Ubuntu\home\me\project").unwrap();
assert_eq!(path.distro.as_deref(), Some("Ubuntu"));
assert_eq!(path.path, "/home/me/project");
}
#[test]
fn parse_wsl_dollar_path() {
let path = parse_wsl_unc_path(r"\\wsl$\Debian\tmp").unwrap();
assert_eq!(path.distro.as_deref(), Some("Debian"));
assert_eq!(path.path, "/tmp");
}
#[test]
fn parse_native_windows_path() {
let path = parse_native_drive_path(r"C:\Users\me\project").unwrap();
assert_eq!(path.distro, None);
assert_eq!(path.path, "/mnt/c/Users/me/project");
}
#[test]
fn parse_wsl_absolute_path_keeps_linux_path() {
let path = parse_wsl_absolute_path("/home/me").unwrap();
assert_eq!(path.distro, None);
assert_eq!(path.path, "/home/me");
}
#[test]
fn parse_wsl_absolute_path_rejects_unc_paths() {
assert!(parse_wsl_absolute_path(r"\\server\share").is_err());
}
#[test]
fn parse_verbatim_native_windows_path() {
let path = parse_native_drive_path(r"\\?\D:\workspace").unwrap();
assert_eq!(path.distro, None);
assert_eq!(path.path, "/mnt/d/workspace");
}
#[test]
fn rejects_unc_non_wsl_path() {
assert!(parse_native_drive_path(r"\\server\share\project").is_err());
}
#[test]
fn probe_output_reports_interop_and_bwrap_path() {
let probe = parse_probe_output("zed-wsl-probe: interop /usr/bin/bwrap\n").unwrap();
assert_eq!(
probe,
EnvironmentProbe {
mask_interop_dir: true,
bwrap_path: "/usr/bin/bwrap".to_string(),
}
);
let probe =
parse_probe_output("zed-wsl-probe: no-interop /home/me/.nix-profile/bin/bwrap\n")
.unwrap();
assert_eq!(
probe,
EnvironmentProbe {
mask_interop_dir: false,
bwrap_path: "/home/me/.nix-profile/bin/bwrap".to_string(),
}
);
}
#[test]
fn probe_output_ignores_profile_noise_even_mentioning_interop() {
// Login-shell profile scripts run before the probe body and may print
// arbitrary text; only the marked result line counts.
let probe = parse_probe_output(
"welcome to my shell, interop fans\nzed-wsl-probe: no-interop /usr/bin/bwrap\n",
)
.unwrap();
assert!(!probe.mask_interop_dir);
}
#[test]
fn probe_output_rejects_missing_or_malformed_result_line() {
assert!(parse_probe_output("").is_err());
assert!(parse_probe_output("profile noise only\n").is_err());
assert!(parse_probe_output("zed-wsl-probe: interop\n").is_err());
assert!(parse_probe_output("zed-wsl-probe: maybe /usr/bin/bwrap\n").is_err());
}
#[test]
fn probe_output_rejects_non_absolute_bwrap_path() {
// `command -v` reports a bare name for shell functions and aliases,
// which `wsl --exec` could never run.
assert!(parse_probe_output("zed-wsl-probe: interop bwrap\n").is_err());
}
#[test]
fn probe_script_smoke_tests_the_namespaces_the_real_invocation_uses() {
// Presence isn't enough: unprivileged user namespaces can be
// restricted (e.g. Ubuntu 24.04's AppArmor policy), so the probe must
// actually exercise the namespace flags `build_bwrap_args` emits.
let script = probe_script();
for flag in [
"--unshare-user",
"--unshare-net",
"--unshare-ipc",
"--unshare-uts",
"--unshare-pid",
"--unshare-cgroup-try",
"--ro-bind / /",
] {
assert!(script.contains(flag), "probe script must contain {flag}");
}
assert!(script.contains("exit 41"));
assert!(script.contains("exit 42"));
}
#[test]
fn probe_script_rejects_setuid_root_bwrap_before_smoke_test() {
let script = probe_script();
let guard =
"[ -u \"$bwrap_path\" ] && [ \"$(stat -c %u \"$bwrap_path\" 2>/dev/null)\" = 0 ]";
let smoke_test = "\"$bwrap_path\" --ro-bind / /";
let Some(guard_index) = script.find(guard) else {
panic!("probe script must contain setuid-root guard: {script}");
};
let Some(smoke_test_index) = script.find(smoke_test) else {
panic!("probe script must contain bwrap smoke test: {script}");
};
assert!(script.contains("setuid-root bwrap is not supported"));
assert!(script.contains(&format!("exit {BWRAP_UNUSABLE_EXIT_CODE}; fi")));
assert!(guard_index < smoke_test_index);
}
#[test]
fn canonical_grant_output_returns_the_linux_canonical_path() {
let resolved =
parse_canonical_grant_output("Ubuntu-24.04\n/home/me/real target\nnative-fs\n")
.expect("valid helper output");
// The bare Linux path, not a `\\wsl.localhost\...` spelling.
assert_eq!(resolved.canonical, PathBuf::from("/home/me/real target"));
assert!(!resolved.on_windows_fs);
let resolved = parse_canonical_grant_output("Ubuntu\n/mnt/c/Users/me/proj\nwindows-fs\n")
.expect("valid helper output");
assert_eq!(resolved.canonical, PathBuf::from("/mnt/c/Users/me/proj"));
assert!(resolved.on_windows_fs);
}
#[test]
fn canonical_grant_output_rejects_malformed_protocol() {
// Missing filesystem-class line.
assert!(parse_canonical_grant_output("Ubuntu\n/home/me/project\n").is_err());
// Non-absolute canonical path.
assert!(parse_canonical_grant_output("Ubuntu\nrelative\nnative-fs\n").is_err());
// Distro name containing a separator.
assert!(parse_canonical_grant_output("Ubuntu/bad\n/home/me/project\nnative-fs\n").is_err());
// Unknown filesystem class.
assert!(parse_canonical_grant_output("Ubuntu\n/home/me/project\nmaybe-fs\n").is_err());
// Trailing junk after the filesystem class.
assert!(
parse_canonical_grant_output("Ubuntu\n/home/me/project\nnative-fs\nnoise\n").is_err()
);
}
#[test]
fn split_resolved_paths_keeps_existing_protected_paths_and_skips_missing_ones() {
let (cwd, writable_paths, protected_paths) = split_resolved_paths(
true,
1,
vec![
Some("/home/me/project".to_string()),
Some("/home/me/project".to_string()),
None,
Some("/mnt/c/external/.git".to_string()),
],
)
.unwrap();
assert_eq!(cwd.as_deref(), Some("/home/me/project"));
assert_eq!(writable_paths, vec!["/home/me/project".to_string()]);
assert_eq!(protected_paths, vec!["/mnt/c/external/.git".to_string()]);
}
#[test]
fn split_resolved_paths_rejects_missing_required_writable_paths() {
let error = split_resolved_paths(false, 1, vec![None]).unwrap_err();
assert!(
error
.to_string()
.contains("required writable path resolved as missing"),
"unexpected error: {error:#}"
);
}
#[test]
fn bwrap_denies_network_by_default() {
let args = build_bwrap_args(
&["/home/me/project".to_string()],
&[],
SandboxPermissions::default(),
Some("/home/me/project"),
true,
&HashMap::new(),
);
assert!(args.iter().any(|arg| arg == "--unshare-net"));
assert!(
args.windows(3)
.any(|window| window == ["--bind", "/home/me/project", "/home/me/project"])
);
}
#[test]
fn bwrap_allows_network_when_requested() {
let args = build_bwrap_args(
&[],
&[],
SandboxPermissions {
allow_network: true,
allow_fs_write: false,
},
None,
true,
&HashMap::new(),
);
assert!(!args.iter().any(|arg| arg == "--unshare-net"));
}
#[test]
fn bwrap_binds_explicit_writable_file_paths() {
let args = build_bwrap_args(
&["/mnt/c/Users/me/AppData/Roaming/Zed/AGENTS.md".to_string()],
&[],
SandboxPermissions::default(),
None,
true,
&HashMap::new(),
);
assert!(args.windows(3).any(|window| window
== [
"--bind",
"/mnt/c/Users/me/AppData/Roaming/Zed/AGENTS.md",
"/mnt/c/Users/me/AppData/Roaming/Zed/AGENTS.md"
]));
}
#[test]
fn bwrap_protects_paths_after_writable_paths() {
let args = build_bwrap_args(
&["/home/me/project".to_string()],
&["/home/me/project/.git".to_string()],
SandboxPermissions::default(),
Some("/home/me/project"),
true,
&HashMap::new(),
);
let writable_index = args
.windows(3)
.position(|window| window == ["--bind", "/home/me/project", "/home/me/project"])
.expect("project should be writable");
let protected_index = args
.windows(3)
.position(|window| {
window
== [
"--ro-bind",
"/home/me/project/.git",
"/home/me/project/.git",
]
})
.expect("protected path should be bound read-only");
assert!(protected_index > writable_index);
}
#[test]
fn bwrap_protects_paths_when_fs_writes_are_unrestricted() {
let args = build_bwrap_args(
&[],
&["/home/me/project/.git".to_string()],
SandboxPermissions {
allow_network: false,
allow_fs_write: true,
},
None,
true,
&HashMap::new(),
);
let unrestricted_write_index = args
.windows(3)
.position(|window| window == ["--bind", "/", "/"])
.expect("root should be writable");
let protected_index = args
.windows(3)
.position(|window| {
window
== [
"--ro-bind",
"/home/me/project/.git",
"/home/me/project/.git",
]
})
.expect("protected path should be bound read-only");
assert!(protected_index > unrestricted_write_index);
}
#[test]
fn bwrap_blocks_wsl_interop_by_default() {
let args = build_bwrap_args(
&["/home/me/project".to_string()],
&[],
SandboxPermissions::default(),
Some("/home/me/project"),
true,
&HashMap::new(),
);
assert!(
args.windows(2)
.any(|window| window == ["--unsetenv", "WSL_INTEROP"])
);
assert!(
args.windows(2)
.any(|window| window == ["--tmpfs", "/run/WSL"])
);
}
#[test]
fn bwrap_blocks_wsl_interop_even_with_fs_write() {
let args = build_bwrap_args(
&[],
&[],
SandboxPermissions {
allow_network: true,
allow_fs_write: true,
},
None,
true,
&HashMap::new(),
);
// Interop is host code execution, not just a filesystem write, so it
// stays blocked even when the user has granted unrestricted writes
// and network.
assert!(
args.windows(2)
.any(|window| window == ["--unsetenv", "WSL_INTEROP"])
);
assert!(
args.windows(2)
.any(|window| window == ["--tmpfs", "/run/WSL"])
);
}
#[test]
fn bwrap_skips_interop_dir_mask_when_absent() {
// When the interop socket directory doesn't exist (interop disabled),
// there's nothing to mask and a `--tmpfs /run/WSL` would abort bwrap,
// so the mount must be omitted. Unsetting the variable is harmless and
// stays.
let args = build_bwrap_args(
&[],
&[],
SandboxPermissions::default(),
None,
false,
&HashMap::new(),
);
assert!(
args.windows(2)
.any(|window| window == ["--unsetenv", "WSL_INTEROP"])
);
assert!(!args.iter().any(|arg| arg == "/run/WSL"));
}
#[test]
fn bwrap_forwards_env_via_setenv() {
let env = HashMap::from([
("PAGER".to_string(), String::new()),
("CARGO_TERM_COLOR".to_string(), "always".to_string()),
]);
let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
assert!(
args.windows(3)
.any(|window| window == ["--setenv", "PAGER", ""])
);
assert!(
args.windows(3)
.any(|window| window == ["--setenv", "CARGO_TERM_COLOR", "always"])
);
}
#[test]
fn bwrap_does_not_forward_wsl_interop_env() {
let env = HashMap::from([
(
"WSL_INTEROP".to_string(),
"/run/WSL/123_interop".to_string(),
),
("WsLeNv".to_string(), "WSL_INTEROP/u".to_string()),
("PAGER".to_string(), String::new()),
]);
let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
assert!(
args.windows(2)
.any(|window| window == ["--unsetenv", "WSL_INTEROP"])
);
assert!(
args.windows(2)
.any(|window| window == ["--unsetenv", "WSLENV"])
);
assert!(
args.windows(3)
.any(|window| window == ["--setenv", "PAGER", ""])
);
assert!(!args.windows(3).any(|window| {
matches!(window, [flag, name, _]
if flag.as_str() == "--setenv"
&& name.eq_ignore_ascii_case("WSL_INTEROP"))
}));
assert!(!args.windows(3).any(|window| {
matches!(window, [flag, name, _]
if flag.as_str() == "--setenv"
&& name.eq_ignore_ascii_case("WSLENV"))
}));
}
#[test]
fn bwrap_does_not_forward_windows_specific_env() {
// These hold Windows paths/values that would break or be meaningless
// inside WSL, so they must never cross the boundary. Names are matched
// case-insensitively, as Windows env var names are. `(x86)` variants
// contain parentheses but no `=`, so they'd pass the `setenv` filter
// and must be blocked by name.
let env = HashMap::from([
("Path".to_string(), r"C:\Windows\System32".to_string()),
("PATHEXT".to_string(), ".COM;.EXE;.BAT".to_string()),
(
"TEMP".to_string(),
r"C:\Users\me\AppData\Local\Temp".to_string(),
),
(
"Tmp".to_string(),
r"C:\Users\me\AppData\Local\Temp".to_string(),
),
("TMPDIR".to_string(), r"C:\tmp".to_string()),
("OS".to_string(), "Windows_NT".to_string()),
(
"ComSpec".to_string(),
r"C:\Windows\system32\cmd.exe".to_string(),
),
("windir".to_string(), r"C:\Windows".to_string()),
("SystemRoot".to_string(), r"C:\Windows".to_string()),
("HOME".to_string(), r"C:\Users\me".to_string()),
("USERPROFILE".to_string(), r"C:\Users\me".to_string()),
(
"APPDATA".to_string(),
r"C:\Users\me\AppData\Roaming".to_string(),
),
(
"LOCALAPPDATA".to_string(),
r"C:\Users\me\AppData\Local".to_string(),
),
(
"ProgramFiles(x86)".to_string(),
r"C:\Program Files (x86)".to_string(),
),
("USERNAME".to_string(), "me".to_string()),
("COMPUTERNAME".to_string(), "DESKTOP-ABC".to_string()),
("PROCESSOR_ARCHITECTURE".to_string(), "AMD64".to_string()),
("NUMBER_OF_PROCESSORS".to_string(), "16".to_string()),
]);
let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
assert!(!args.iter().any(|arg| arg == "--setenv"));
}
#[test]
fn bwrap_forwards_portable_env_alongside_windows_specific_env() {
// A blocklist (not an allowlist) means genuinely portable variables
// still reach the command even when Windows-only ones are present.
let env = HashMap::from([
("USERPROFILE".to_string(), r"C:\Users\me".to_string()),
("LANG".to_string(), "en_US.UTF-8".to_string()),
("CARGO_TERM_COLOR".to_string(), "always".to_string()),
]);
let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
assert!(
args.windows(3)
.any(|window| window == ["--setenv", "LANG", "en_US.UTF-8"])
);
assert!(
args.windows(3)
.any(|window| window == ["--setenv", "CARGO_TERM_COLOR", "always"])
);
assert!(!args.windows(3).any(|window| {
matches!(window, [flag, name, _]
if flag.as_str() == "--setenv"
&& name.eq_ignore_ascii_case("USERPROFILE"))
}));
}
#[test]
fn bwrap_skips_env_names_setenv_would_reject() {
// bwrap's `--setenv` calls `setenv(3)`, which rejects empty names and
// names containing `=`. Windows environments include the per-drive
// current-directory pseudo-variables (`=C:`, ...); forwarding them
// would abort bwrap with "setenv failed".
let env = HashMap::from([
("=C:".to_string(), r"C:\Users\me".to_string()),
(String::new(), "value".to_string()),
("OK".to_string(), "value".to_string()),
]);
let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
assert!(
args.windows(3)
.any(|window| window == ["--setenv", "OK", "value"])
);
assert_eq!(args.iter().filter(|arg| *arg == "--setenv").count(), 1);
}
#[test]
fn select_distro_uses_wsl_distro_when_present() {
let distro = select_distro(
None,
&[
PathMapping::NativeDrive {
windows_path: "C:/project".to_string(),
fallback: WslPath {
distro: None,
path: "/mnt/c/project".to_string(),
},
},
PathMapping::Wsl(WslPath {
distro: Some("Ubuntu".to_string()),
path: "/home/me/project".to_string(),
}),
],
)
.unwrap();
assert_eq!(distro.as_deref(), Some("Ubuntu"));
}
#[test]
fn bad_request_errors_do_not_claim_sandboxing_is_unavailable() {
// Mixed distros and missing/unmappable paths are model-fixable bad
// requests. They must not be typed as `WslSandboxUnavailable` (nor
// carry its prefix), since the agent uses that type to offer the
// run-unsandboxed fallback only for genuine environment failures.
let mixed_distros = select_distro(
Some(&PathMapping::Wsl(WslPath {
distro: Some("Ubuntu".to_string()),
path: "/home/me".to_string(),
})),
&[PathMapping::Wsl(WslPath {
distro: Some("Debian".to_string()),
path: "/home/me".to_string(),
})],
)
.unwrap_err();
assert!(
mixed_distros
.downcast_ref::<WslSandboxUnavailable>()
.is_none()
);
assert!(!format!("{mixed_distros:#}").contains(WSL_SANDBOX_UNAVAILABLE_PREFIX));
let missing_path =
path_to_wsl(Path::new(r"C:\zed-test\definitely\does\not\exist-2769")).unwrap_err();
assert!(
missing_path
.downcast_ref::<WslSandboxUnavailable>()
.is_none()
);
assert!(!format!("{missing_path:#}").contains(WSL_SANDBOX_UNAVAILABLE_PREFIX));
let unmappable_cwd = directory_to_wsl(Path::new(r"\\server\share\project")).unwrap_err();
assert!(
unmappable_cwd
.downcast_ref::<WslSandboxUnavailable>()
.is_none()
);
assert!(!format!("{unmappable_cwd:#}").contains(WSL_SANDBOX_UNAVAILABLE_PREFIX));
}
#[test]
fn unavailable_errors_are_typed_and_prefixed() {
// Environment failures are recognizable by type (so the agent doesn't
// depend on message text) and still render with the shared prefix.
let error = unavailable("Bubblewrap (`bwrap`) is not installed in the default WSL distro");
let typed = error
.downcast_ref::<WslSandboxUnavailable>()
.expect("environment failure should downcast to WslSandboxUnavailable");
assert_eq!(
typed.message(),
"Bubblewrap (`bwrap`) is not installed in the default WSL distro"
);
assert!(format!("{error:#}").starts_with(WSL_SANDBOX_UNAVAILABLE_PREFIX));
}
#[test]
fn map_path_to_wsl_keeps_unc_paths_structural() {
let mapping = map_path_to_wsl(Path::new(r"\\wsl.localhost\Ubuntu\home\me")).unwrap();
assert_eq!(
mapping,
PathMapping::Wsl(WslPath {
distro: Some("Ubuntu".to_string()),
path: "/home/me".to_string(),
})
);
}
#[test]
fn map_path_to_wsl_defers_native_paths_to_wslpath() {
let mapping = map_path_to_wsl(Path::new(r"C:\Users\me\project")).unwrap();
assert_eq!(
mapping,
PathMapping::NativeDrive {
windows_path: "C:/Users/me/project".to_string(),
fallback: WslPath {
distro: None,
path: "/mnt/c/Users/me/project".to_string(),
},
}
);
}
#[test]
fn strip_windows_verbatim_prefix_only_strips_drive_paths() {
// `canonicalize` yields `\\?\C:\...`; the plain native spelling is stored.
assert_eq!(
strip_windows_verbatim_prefix(PathBuf::from(r"\\?\C:\Users\me\dir")),
PathBuf::from(r"C:\Users\me\dir")
);
// A `\\?\UNC\...` path is not a drive path and is left intact (the caller
// rejects it).
assert_eq!(
strip_windows_verbatim_prefix(PathBuf::from(r"\\?\UNC\wsl.localhost\Ubuntu\home")),
PathBuf::from(r"\\?\UNC\wsl.localhost\Ubuntu\home")
);
// A path without the prefix is unchanged.
assert_eq!(
strip_windows_verbatim_prefix(PathBuf::from(r"C:\already\plain")),
PathBuf::from(r"C:\already\plain")
);
}
#[test]
fn map_path_to_wsl_strips_verbatim_prefix_for_wslpath() {
let mapping = map_path_to_wsl(Path::new(r"\\?\D:\workspace")).unwrap();
assert_eq!(
mapping,
PathMapping::NativeDrive {
windows_path: "D:/workspace".to_string(),
fallback: WslPath {
distro: None,
path: "/mnt/d/workspace".to_string(),
},
}
);
}
#[test]
fn path_resolution_args_flattens_mappings_into_triples() {
let mappings = [
PathMapping::NativeDrive {
windows_path: "C:/Users/me/project".to_string(),
fallback: WslPath {
distro: None,
path: "/mnt/c/Users/me/project".to_string(),
},
},
PathMapping::Wsl(WslPath {
distro: Some("Ubuntu".to_string()),
path: "/home/me/project".to_string(),
}),
];
assert_eq!(
path_resolution_args(mappings.iter()),
[
"W",
"C:/Users/me/project",
"/mnt/c/Users/me/project",
"L",
"/home/me/project",
"",
]
);
}
#[test]
fn parse_path_resolution_output_reads_one_line_per_path() {
let resolved = parse_path_resolution_output(
"ok ok /mnt/c/Users/me/project\nfallback missing /mnt/d/workspace\n",
2,
)
.unwrap();
assert_eq!(
resolved,
[
ResolvedPath {
path: "/mnt/c/Users/me/project".to_string(),
used_fallback: false,
exists: true,
},
ResolvedPath {
path: "/mnt/d/workspace".to_string(),
used_fallback: true,
exists: false,
},
]
);
}
#[test]
fn parse_path_resolution_output_keeps_spaces_in_paths() {
let resolved =
parse_path_resolution_output("ok ok /mnt/c/Users/me/My Documents/project\n", 1)
.unwrap();
assert_eq!(resolved[0].path, "/mnt/c/Users/me/My Documents/project");
}
#[test]
fn parse_path_resolution_output_rejects_wrong_line_count() {
assert!(parse_path_resolution_output("ok ok /a\n", 2).is_err());
assert!(parse_path_resolution_output("ok ok /a\nok ok /b\n", 1).is_err());
}
#[test]
fn parse_path_resolution_output_rejects_corrupted_lines() {
assert!(parse_path_resolution_output("garbage\n", 1).is_err());
assert!(parse_path_resolution_output("weird ok /a\n", 1).is_err());
assert!(parse_path_resolution_output("ok weird /a\n", 1).is_err());
assert!(parse_path_resolution_output("ok ok not-absolute\n", 1).is_err());
}
}