Skip to repository content1976 lines · 75.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:54:42.242Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
windows_wsl.rs
1//! Windows sandbox integration via WSL.
2//!
3//! Sandboxed Windows terminal commands are routed through WSL and then executed
4//! under Bubblewrap inside Linux. Projects may be opened either from native
5//! Windows paths (`C:\Users\...`) or WSL UNC paths
6//! (`\\wsl.localhost\Ubuntu\home\...`). Native drive-letter paths are
7//! translated into the distro's filesystem view with `wslpath` (falling back
8//! to the conventional `/mnt/<drive>/...` mapping if that fails) and use the
9//! user's default WSL distro unless a WSL UNC path in the request pins a
10//! specific distro.
11//!
12//! Errors fall into two classes the agent treats differently:
13//!
14//! * **Environment unavailable** — WSL missing or failing to start, no
15//! usable `bwrap`, or the probe/path-resolution stdout protocol breaking
16//! down. These are returned as a [`WslSandboxUnavailable`] (whose `Display`
17//! carries
18//! [`WSL_SANDBOX_UNAVAILABLE_PREFIX`](crate::WSL_SANDBOX_UNAVAILABLE_PREFIX)),
19//! so the agent recognizes them *by type* and offers the same
20//! retry / run-unsandboxed fallback it offers on Linux, rather than matching
21//! on message text.
22//! * **Bad request** — a specific path that doesn't exist or can't be mapped
23//! into WSL, or a request mixing distros. These are ordinary `anyhow` errors
24//! *without* [`WslSandboxUnavailable`], and are reported back to the model,
25//! which can fix the request and retry.
26
27use std::collections::HashMap;
28use std::path::{Path, PathBuf};
29use std::sync::{Mutex, OnceLock};
30
31use smol::process::{Command, Stdio};
32
33use anyhow::{Context as _, Result, bail, ensure};
34
35use crate::WSL_SANDBOX_UNAVAILABLE_PREFIX;
36
37/// Per-command relaxations of the WSL/Bubblewrap sandbox. Windows can only
38/// toggle network access wholesale (no loopback-proxy confinement yet), so this
39/// is a plain bool rather than the richer cross-platform [`crate::SandboxNetPolicy`].
40#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
41pub(crate) struct SandboxPermissions {
42 pub(crate) allow_network: bool,
43 pub(crate) allow_fs_write: bool,
44}
45
46/// Exit code the environment probe script uses to signal that `bwrap` is not
47/// installed, distinguishing that from WSL itself failing to start a shell.
48/// Chosen to be unlikely to collide with `wsl.exe`'s own failure codes.
49const BWRAP_MISSING_EXIT_CODE: i32 = 41;
50
51/// Exit code the environment probe script uses to signal that `bwrap` is
52/// installed but failed the sandbox smoke test — typically because the
53/// distro restricts unprivileged user namespaces (e.g. Ubuntu 24.04's
54/// default AppArmor policy), which every namespace flag we pass depends on.
55const BWRAP_UNUSABLE_EXIT_CODE: i32 = 42;
56
57/// Prefix of the probe script's single result line, so it can be picked out
58/// of any stdout noise printed by the login shell's profile scripts.
59const PROBE_RESULT_PREFIX: &str = "zed-wsl-probe:";
60
61/// Prefix of the helper-provisioning script's single result line (the absolute
62/// in-WSL path of the Linux `zed` to run as the sandbox helper), picked out of
63/// login-shell stdout noise just like [`PROBE_RESULT_PREFIX`].
64const HELPER_RESULT_PREFIX: &str = "zed-wsl-helper:";
65
66/// Ensures a Linux `zed` matching the running release is available inside WSL to
67/// act as the sandbox helper (`--wsl-sandbox-helper`), and prints its absolute
68/// in-WSL path on a [`HELPER_RESULT_PREFIX`] line. `$1` is the release channel,
69/// `$2` the version (`latest` for dev builds, which have no matching release and
70/// so track the latest nightly); both are passed as argv, never interpolated, so
71/// a version/channel string can't inject shell.
72///
73/// Unlike a normal Linux install, this deliberately does **not** consult the WSL
74/// `PATH`: inside WSL `zed` typically resolves to the *Windows* `zed.exe` via
75/// interop, which is not a Linux binary and so can't be the helper. It also does
76/// not use the public install script (`install.sh`), which puts `zed` on the
77/// user's `PATH` and writes desktop entries we don't want. Instead the Windows
78/// side resolves the exact channel+version (see `wsl_zed_release`) and this
79/// script downloads that release's Linux tarball straight from
80/// `cloud.zed.dev/releases` and unpacks it into a private, off-`PATH` location
81/// (`~/.local/libexec/zed/<channel>`, the conventional spot for executables run
82/// by other programs rather than directly by the user). One managed copy per
83/// channel is kept, tracked by a marker file so an exact channel+version match
84/// is reused rather than re-downloaded. The floating `latest` version (dev
85/// builds) is the exception: it always re-downloads so it tracks the newest
86/// nightly rather than pinning to the first copy fetched.
87///
88/// We ship no `zed` (nor `bwrap`) into WSL ourselves; this downloads `zed` on
89/// demand. A missing `curl`/`wget` (or a failed download) is a hard error the
90/// caller surfaces to the user, exactly like a missing `bwrap`.
91const HELPER_PROVISION_SCRIPT: &str = r#"
92set -eu
93channel="$1"
94version="$2"
95dest="$HOME/.local/libexec/zed/$channel"
96marker="$dest/.zed-wsl-helper-version"
97want="$channel $version"
98
99# Reuse an exact, already-installed channel+version — but never for the floating
100# "latest" tag (dev builds), which must always re-fetch so they track the most
101# recent nightly instead of pinning to whatever was downloaded first.
102if [ "$version" != "latest" ] && [ "$(cat "$marker" 2>/dev/null || true)" = "$want" ]; then
103 helper=$(find "$dest" -type f -path '*/libexec/zed-editor' -print 2>/dev/null | head -n 1 || true)
104 if [ -n "$helper" ] && [ -x "$helper" ]; then
105 printf 'zed-wsl-helper: %s\n' "$helper"
106 exit 0
107 fi
108fi
109
110arch=$(uname -m)
111case "$arch" in
112 x86_64 | amd64) arch="x86_64" ;;
113 aarch64 | arm64) arch="aarch64" ;;
114 *) echo "unsupported WSL architecture for the zed sandbox helper: $arch" >&2; exit 1 ;;
115esac
116url="https://cloud.zed.dev/releases/$channel/$version/download?asset=zed&arch=$arch&os=linux&source=zed-wsl-sandbox"
117
118tmp=$(mktemp -d "${TMPDIR:-/tmp}/zed-wsl-helper-XXXXXX")
119trap 'rm -rf "$tmp"' EXIT
120tarball="$tmp/zed.tar.gz"
121if command -v curl >/dev/null 2>&1; then
122 curl -fL "$url" -o "$tarball"
123elif command -v wget >/dev/null 2>&1; then
124 wget -O "$tarball" "$url"
125else
126 echo 'neither curl nor wget is available in WSL to download zed' >&2
127 exit 1
128fi
129
130mkdir -p "$tmp/unpacked"
131tar -xzf "$tarball" -C "$tmp/unpacked"
132helper_src=$(find "$tmp/unpacked" -type f -path '*/libexec/zed-editor' -print 2>/dev/null | head -n 1 || true)
133if [ -z "$helper_src" ]; then
134 echo 'the downloaded zed tarball did not contain a libexec/zed-editor binary' >&2
135 exit 1
136fi
137app=$(dirname "$(dirname "$helper_src")")
138
139# Install atomically: stage the unpacked app next to the destination on the same
140# filesystem, then swap it into place so a concurrent run never sees (or execs) a
141# partially-written install. The whole app dir is kept so the binary's bundled
142# libraries ($ORIGIN/../lib) remain alongside it.
143mkdir -p "$(dirname "$dest")"
144rm -rf "$dest.new" "$dest.old"
145cp -a "$app" "$dest.new"
146if [ -e "$dest" ]; then mv "$dest" "$dest.old"; fi
147mv "$dest.new" "$dest"
148rm -rf "$dest.old"
149printf '%s' "$want" > "$marker"
150
151helper=$(find "$dest" -type f -path '*/libexec/zed-editor' -print 2>/dev/null | head -n 1 || true)
152if [ -z "$helper" ] || [ ! -x "$helper" ]; then
153 echo "the installed zed sandbox helper is missing or not executable under $dest" >&2
154 exit 1
155fi
156printf 'zed-wsl-helper: %s\n' "$helper"
157exit 0
158"#;
159
160/// Marks a failure of the Windows WSL sandboxing *environment*: WSL is missing
161/// or won't start, there's no usable `bwrap`, or the probe / path-resolution
162/// stdout protocol broke down. Returned as the root of the `anyhow::Error` so
163/// callers classify it by type ([`anyhow::Error::downcast_ref`]) instead of by
164/// matching message text. Per-request failures (a missing writable path, paths
165/// mixing distros) are ordinary `anyhow` errors *without* this type, so they
166/// never match — the agent returns those to the model rather than offering to
167/// run unsandboxed.
168#[derive(Debug, Clone)]
169pub struct WslSandboxUnavailable(String);
170
171impl WslSandboxUnavailable {
172 /// Build an environment-unavailable error from a human-readable reason
173 /// (without the [`WSL_SANDBOX_UNAVAILABLE_PREFIX`], which `Display` adds).
174 pub fn new(message: impl Into<String>) -> Self {
175 Self(message.into())
176 }
177
178 /// The reason, without the leading [`WSL_SANDBOX_UNAVAILABLE_PREFIX`].
179 #[cfg(test)]
180 pub fn message(&self) -> &str {
181 &self.0
182 }
183}
184
185impl std::fmt::Display for WslSandboxUnavailable {
186 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187 write!(f, "{WSL_SANDBOX_UNAVAILABLE_PREFIX}: {}", self.0)
188 }
189}
190
191impl std::error::Error for WslSandboxUnavailable {}
192
193/// Shorthand for an [`anyhow::Error`] wrapping a [`WslSandboxUnavailable`].
194fn unavailable(message: impl Into<String>) -> anyhow::Error {
195 anyhow::Error::new(WslSandboxUnavailable::new(message))
196}
197
198#[derive(Clone, Debug, Eq, Hash, PartialEq)]
199struct WslPath {
200 distro: Option<String>,
201 path: String,
202}
203
204/// A path mapped for use inside WSL.
205///
206/// WSL UNC and WSL-absolute paths can be mapped structurally up front. Native
207/// drive-letter paths depend on the distro's automount configuration
208/// (`/etc/wsl.conf` can move the `/mnt` root), so they are translated with
209/// `wslpath` inside the distro — but a distro can only be chosen after every
210/// path has been parsed (WSL UNC paths pin one), hence this two-stage shape:
211/// parse structurally first, then resolve native paths via [`resolve_paths`]
212/// once the distro is known.
213#[derive(Clone, Debug, Eq, Hash, PartialEq)]
214enum PathMapping {
215 Wsl(WslPath),
216 NativeDrive {
217 /// The `\\?\`-stripped, forward-slashed form that `wslpath -u`
218 /// accepts (`wslpath` is a Linux binary and doesn't understand
219 /// backslash separators).
220 windows_path: String,
221 /// The conventional `/mnt/<drive>/...` mapping, used when `wslpath`
222 /// translation fails.
223 fallback: WslPath,
224 },
225}
226
227impl PathMapping {
228 fn distro(&self) -> Option<&str> {
229 match self {
230 PathMapping::Wsl(path) => path.distro.as_deref(),
231 PathMapping::NativeDrive { .. } => None,
232 }
233 }
234}
235
236/// Wrap a Linux process invocation so it runs under Bubblewrap inside WSL.
237///
238/// `program` and `args` must name a Linux executable and Linux argv, not a
239/// Windows executable. The caller is expected to convert the model's command
240/// into a Linux shell invocation (typically `/bin/sh -c ...`) before calling
241/// this function.
242///
243/// All writable paths, protected paths, and the cwd must be paths that can be
244/// mapped into WSL. The cwd and writable paths must exist; protected paths may
245/// be missing because Bubblewrap cannot bind a missing source. WSL UNC paths
246/// may specify a distro; native drive-letter paths are translated with `wslpath`
247/// inside either that distro or the default distro (falling back to
248/// `/mnt/<drive>/...` if translation fails).
249///
250/// `env` is forwarded into the sandboxed command via `bwrap --setenv` rather
251/// than being set on the `wsl.exe` process. Windows environment variables
252/// don't cross the WSL boundary unless they're listed in `WSLENV`, so without
253/// this the command would lose `PAGER` (used to stop `git` from paging into
254/// the PTY) and the rest of the project environment. Variables whose Windows
255/// values are meaningless or harmful inside Linux are dropped (see
256/// [`is_forwardable_env_var`]).
257///
258/// This function performs up to two `wsl.exe` round-trips (environment probe
259/// and path resolution, each cached) plus filesystem stats of WSL UNC paths,
260/// any of which can take seconds when the WSL VM is cold (and the stats can
261/// stall on a slow `\\wsl.localhost` filesystem). Run it on a background
262/// executor, never on the UI thread, and bound it with a timeout — a wedged
263/// `wsl.exe` (a real failure mode when the WSL service is unhealthy)
264/// otherwise stalls the returned future forever. This crate deliberately has
265/// no timer of its own (timers come from the caller's executor so tests stay
266/// deterministic); instead it guarantees that dropping the future kills any
267/// in-flight `wsl.exe` child, so a caller-side timeout that drops the future
268/// also reaps the process. Parameters are owned so the returned future is
269/// `Send + 'static`.
270pub async fn wrap_invocation<S: std::hash::BuildHasher>(
271 program: String,
272 args: Vec<String>,
273 writable_paths: Vec<PathBuf>,
274 protected_paths: Vec<PathBuf>,
275 permissions: SandboxPermissions,
276 cwd: Option<PathBuf>,
277 env: HashMap<String, String, S>,
278 // `(release channel, version)` of the Linux `zed` to provision inside WSL as
279 // the `--wsl-sandbox-helper` (the version is `latest` for dev builds). When
280 // `None`, no helper is used and bwrap is exec'd directly — the legacy path,
281 // which binds writable paths by string and so carries the bind-source TOCTOU
282 // the helper closes. Callers that can determine the running release should
283 // always pass `Some`.
284 wsl_zed_release: Option<(String, String)>,
285) -> Result<(String, Vec<String>)> {
286 // Mapping failures are bad requests (a path that doesn't exist or has a
287 // shape WSL can't address), not environment problems, so no
288 // `WSL_SANDBOX_UNAVAILABLE_PREFIX` here.
289 let cwd_mapping =
290 match &cwd {
291 Some(cwd) => Some(directory_to_wsl(cwd).with_context(|| {
292 format!("failed to map terminal cwd `{}` into WSL", cwd.display())
293 })?),
294 None => None,
295 };
296
297 let writable_mappings = writable_paths
298 .iter()
299 .map(|path| {
300 path_to_wsl(path).with_context(|| {
301 format!("failed to map writable path `{}` into WSL", path.display())
302 })
303 })
304 .collect::<Result<Vec<_>>>()?;
305 let protected_mappings = protected_paths
306 .iter()
307 .map(|path| {
308 path_to_wsl_allowing_missing(path).with_context(|| {
309 format!("failed to map protected path `{}` into WSL", path.display())
310 })
311 })
312 .collect::<Result<Vec<_>>>()?;
313
314 let distro = select_distro(
315 cwd_mapping.as_ref(),
316 writable_mappings.iter().chain(protected_mappings.iter()),
317 )?;
318 let wsl_exe = wsl_exe_path();
319 if !wsl_exe.is_file() {
320 return Err(unavailable(format!(
321 "WSL (`wsl.exe`) was not found at `{}`",
322 wsl_exe.display()
323 )));
324 }
325 let environment = probe_environment(&wsl_exe, distro.as_deref()).await?;
326
327 // Resolve all paths (translating native drive-letter paths with `wslpath`
328 // now that the distro is known) in a single WSL round-trip. The cwd and
329 // ordinary writable paths must exist; protected paths may be missing because,
330 // as with Linux bwrap, a not-yet-created path can't be overlaid.
331 let has_cwd = cwd_mapping.is_some();
332 let writable_path_count = writable_mappings.len();
333 let mut mappings = Vec::with_capacity(writable_mappings.len() + protected_mappings.len() + 1);
334 if let Some(mapping) = cwd_mapping {
335 mappings.push((mapping, "terminal cwd", true));
336 }
337 mappings.extend(
338 writable_mappings
339 .into_iter()
340 .map(|mapping| (mapping, "writable path", true)),
341 );
342 mappings.extend(
343 protected_mappings
344 .into_iter()
345 .map(|mapping| (mapping, "protected path", false)),
346 );
347 let resolved = resolve_paths(&wsl_exe, distro.as_deref(), &mappings).await?;
348 let (cwd, writable_paths, protected_paths) =
349 split_resolved_paths(has_cwd, writable_path_count, resolved)?;
350
351 let mut wsl_args = Vec::new();
352 if let Some(distro) = distro.as_deref() {
353 wsl_args.extend(["-d".to_string(), distro.to_string()]);
354 }
355 if let Some(cwd) = &cwd {
356 wsl_args.extend(["--cd".to_string(), cwd.clone()]);
357 }
358
359 // The bwrap *options* (everything before the trailing `-- cmd`): root bind,
360 // `/tmp` tmpfs, writable binds, interop blocking, `--setenv`s, `--chdir`,
361 // namespace flags. Identical whether or not we route through the helper.
362 let bwrap_options = build_bwrap_args(
363 &writable_paths,
364 &protected_paths,
365 permissions,
366 cwd.as_deref(),
367 environment.mask_interop_dir,
368 &env,
369 );
370
371 match wsl_zed_release {
372 // Preferred path: run the in-WSL `zed` as the sandbox helper, which
373 // captures the writable binds' inodes WSL-side and validates them after
374 // bwrap's mounts (the same in-sandbox check native Linux performs).
375 Some((channel, version)) => {
376 let helper =
377 ensure_wsl_zed_helper(&wsl_exe, distro.as_deref(), &channel, &version).await?;
378 wsl_args.extend(["--exec".to_string(), helper]);
379 // Protocol (decoded by `linux_bubblewrap::decode_wsl_helper_args`):
380 // <flag> <bwrap_path> <n_base> <base...> <n_writable> <writable...> -- <prog> <args>
381 wsl_args.push(crate::WSL_SANDBOX_HELPER_FLAG.to_string());
382 wsl_args.push(environment.bwrap_path.clone());
383 wsl_args.push(bwrap_options.len().to_string());
384 wsl_args.extend(bwrap_options);
385 wsl_args.push(writable_paths.len().to_string());
386 wsl_args.extend(writable_paths.iter().cloned());
387 wsl_args.push("--".to_string());
388 wsl_args.push(program);
389 wsl_args.extend(args);
390 }
391 // Legacy path: exec bwrap directly (no in-sandbox bind validation). Use
392 // the absolute path the probe validated, since `wsl --exec` searches
393 // only the default WSL PATH.
394 None => {
395 wsl_args.extend(["--exec".to_string(), environment.bwrap_path.clone()]);
396 wsl_args.extend(bwrap_options);
397 wsl_args.push("--".to_string());
398 wsl_args.push(program);
399 wsl_args.extend(args);
400 }
401 }
402
403 Ok((wsl_exe.to_string_lossy().into_owned(), wsl_args))
404}
405
406fn split_resolved_paths(
407 has_cwd: bool,
408 writable_path_count: usize,
409 resolved: Vec<Option<String>>,
410) -> Result<(Option<String>, Vec<String>, Vec<String>)> {
411 let mut resolved = resolved.into_iter();
412 let cwd = if has_cwd {
413 Some(
414 resolved
415 .next()
416 .context("bug: missing resolved terminal cwd")?
417 .context("bug: required terminal cwd resolved as missing")?,
418 )
419 } else {
420 None
421 };
422
423 let mut writable_paths = Vec::with_capacity(writable_path_count);
424 for _ in 0..writable_path_count {
425 writable_paths.push(
426 resolved
427 .next()
428 .context("bug: missing resolved writable path")?
429 .context("bug: required writable path resolved as missing")?,
430 );
431 }
432
433 Ok((cwd, writable_paths, resolved.flatten().collect()))
434}
435
436fn select_distro<'a>(
437 cwd: Option<&PathMapping>,
438 paths: impl IntoIterator<Item = &'a PathMapping>,
439) -> Result<Option<String>> {
440 let mut distro = cwd.and_then(|mapping| mapping.distro().map(str::to_string));
441 for mapping in paths {
442 let Some(path_distro) = mapping.distro() else {
443 continue;
444 };
445 match distro.as_deref() {
446 // A bad request, not an environment problem: the model (or
447 // project layout) asked for paths spanning two distros, which a
448 // single bwrap invocation can't serve.
449 Some(distro) => ensure!(
450 distro == path_distro,
451 "cannot sandbox a command whose paths mix WSL distros `{}` and `{}`",
452 distro,
453 path_distro
454 ),
455 None => distro = Some(path_distro.to_string()),
456 }
457 }
458 Ok(distro)
459}
460
461/// What [`probe_environment`] learned about a WSL distro.
462#[derive(Clone, Debug, Eq, PartialEq)]
463struct EnvironmentProbe {
464 /// Whether the WSL interop socket directory (`/run/WSL`) exists and so
465 /// must (and can) be masked — see [`build_bwrap_args`].
466 mask_interop_dir: bool,
467 /// Absolute path of the `bwrap` binary the smoke test validated. The real
468 /// invocation must exec this exact path: `wsl --exec` searches only the
469 /// default WSL PATH, so a bare `bwrap` could miss (or differ from) the
470 /// binary the probe's login shell found.
471 bwrap_path: String,
472}
473
474/// Shell script run by [`probe_environment`]. Resolves `bwrap` to an absolute
475/// path (exit [`BWRAP_MISSING_EXIT_CODE`] if absent), rejects setuid-root
476/// binaries, then smoke-tests a real minimal sandbox (exit
477/// [`BWRAP_UNUSABLE_EXIT_CODE`] on failure) using the same mount and namespace
478/// flags as [`build_bwrap_args`] — presence isn't
479/// enough, because unprivileged user namespaces can be disabled by the
480/// distro's kernel, sysctl, or AppArmor policy (notably Ubuntu 24.04, the
481/// current default WSL distro), in which case `bwrap` exists but every
482/// sandboxed command would fail. The interop mask is included in the smoke
483/// test when `/run/WSL` exists so the exact mount we later perform is
484/// exercised too. On success, one [`PROBE_RESULT_PREFIX`]-marked result line
485/// reports the interop state and the resolved `bwrap` path.
486fn probe_script() -> String {
487 format!(
488 "bwrap_path=$(command -v bwrap) || exit {BWRAP_MISSING_EXIT_CODE}; \
489 if [ -u \"$bwrap_path\" ] && [ \"$(stat -c %u \"$bwrap_path\" 2>/dev/null)\" = 0 ]; then \
490 echo 'setuid-root bwrap is not supported' >&2; \
491 exit {BWRAP_UNUSABLE_EXIT_CODE}; fi; \
492 if [ -d /run/WSL ]; then interop=interop; mask='--tmpfs /run/WSL'; \
493 else interop=no-interop; mask=''; fi; \
494 \"$bwrap_path\" --ro-bind / / --tmpfs /tmp $mask --dev /dev --proc /proc \
495 --unshare-net --unshare-user --unshare-ipc --unshare-uts --unshare-pid \
496 --unshare-cgroup-try --die-with-parent -- true >/dev/null \
497 || exit {BWRAP_UNUSABLE_EXIT_CODE}; \
498 printf '{PROBE_RESULT_PREFIX} %s %s\\n' \"$interop\" \"$bwrap_path\""
499 )
500}
501
502/// Probe a distro's sandbox environment in one `wsl.exe` round-trip: confirm
503/// a shell starts, confirm `bwrap` is installed *and can actually set up an
504/// unprivileged sandbox* (see [`probe_script`]), and report whether the
505/// interop socket directory exists.
506///
507/// Successful results are cached per distro for the life of the process —
508/// like `linux_bubblewrap::is_available`, the answers can't realistically
509/// change while Zed runs. Failures are deliberately *not* cached so a user
510/// who installs `bwrap` (or lifts a user-namespace restriction) after seeing
511/// the error can retry the command without restarting Zed.
512async fn probe_environment(wsl_exe: &Path, distro: Option<&str>) -> Result<EnvironmentProbe> {
513 static CACHE: OnceLock<Mutex<HashMap<Option<String>, EnvironmentProbe>>> = OnceLock::new();
514 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
515
516 let key = distro.map(str::to_string);
517 if let Some(probe) = cache
518 .lock()
519 .unwrap_or_else(|poisoned| poisoned.into_inner())
520 .get(&key)
521 {
522 return Ok(probe.clone());
523 }
524
525 // A login shell (`-lc`) is used so a `bwrap` reachable only through a
526 // profile-managed PATH is still found; the resolved absolute path is
527 // reported back so the real invocation execs the same binary.
528 let script = probe_script();
529 let output = run_wsl_command(
530 wsl_exe,
531 distro,
532 ["--exec", "sh", "-lc", &script],
533 "probe the sandbox environment",
534 )
535 .await?;
536 if output.status.code() == Some(BWRAP_MISSING_EXIT_CODE) {
537 return Err(unavailable(format!(
538 "Bubblewrap (`bwrap`) is not installed in {}",
539 wsl_distro_label(distro)
540 )));
541 }
542 if output.status.code() == Some(BWRAP_UNUSABLE_EXIT_CODE) {
543 let stderr = String::from_utf8_lossy(&output.stderr);
544 let stderr = stderr.trim();
545 return Err(unavailable(format!(
546 "Bubblewrap (`bwrap`) is installed in {} but could not set up a sandbox — the \
547 distro may restrict unprivileged user namespaces (as Ubuntu 24.04's default \
548 AppArmor policy does){}",
549 wsl_distro_label(distro),
550 if stderr.is_empty() {
551 String::new()
552 } else {
553 format!(": {stderr}")
554 }
555 )));
556 }
557 if !output.status.success() {
558 return Err(unavailable(format!(
559 "failed to start a shell in {}{}",
560 wsl_distro_label(distro),
561 command_failure_details(output.status.code(), &output.stderr)
562 )));
563 }
564
565 let stdout = String::from_utf8_lossy(&output.stdout);
566 let probe = parse_probe_output(&stdout).map_err(|error| {
567 unavailable(format!(
568 "unexpected sandbox probe output from {}: {error:#}",
569 wsl_distro_label(distro)
570 ))
571 })?;
572 cache
573 .lock()
574 .unwrap_or_else(|poisoned| poisoned.into_inner())
575 .insert(key, probe.clone());
576 Ok(probe)
577}
578
579/// Parse [`probe_script`] output: the last [`PROBE_RESULT_PREFIX`]-marked
580/// line wins, so stdout noise from login-shell profile scripts (which runs
581/// before the script body) is ignored.
582fn parse_probe_output(stdout: &str) -> Result<EnvironmentProbe> {
583 let line = stdout
584 .lines()
585 .rev()
586 .find_map(|line| line.strip_prefix(PROBE_RESULT_PREFIX))
587 .with_context(|| format!("no probe result line in: {stdout:?}"))?;
588 let (interop, bwrap_path) = line
589 .trim_start()
590 .split_once(' ')
591 .with_context(|| format!("malformed probe result line: {line:?}"))?;
592 let mask_interop_dir = match interop {
593 "interop" => true,
594 "no-interop" => false,
595 _ => bail!("malformed probe result line: {line:?}"),
596 };
597 ensure!(
598 bwrap_path.starts_with('/'),
599 "`bwrap` resolved to {bwrap_path:?} rather than an absolute path; a shell \
600 alias or function named `bwrap` cannot be run with `wsl --exec`"
601 );
602 Ok(EnvironmentProbe {
603 mask_interop_dir,
604 bwrap_path: bwrap_path.to_string(),
605 })
606}
607
608/// Ensure a Linux `zed` of the given release `channel`/`version` is available
609/// inside WSL and return its absolute in-WSL path, to be `--exec`'d as the
610/// `--wsl-sandbox-helper`. Runs [`HELPER_PROVISION_SCRIPT`] (which downloads the
611/// matching release tarball into an off-`PATH` location on first use).
612///
613/// Successful resolutions are cached per `(distro, channel, version)` for the
614/// life of the process — once provisioned, the path won't change. Failures are
615/// not cached, so a user who installs `curl` (or fixes networking) after an
616/// error can retry without restarting Zed.
617async fn ensure_wsl_zed_helper(
618 wsl_exe: &Path,
619 distro: Option<&str>,
620 channel: &str,
621 version: &str,
622) -> Result<String> {
623 type HelperCache = HashMap<(Option<String>, String, String), String>;
624 static CACHE: OnceLock<Mutex<HelperCache>> = OnceLock::new();
625 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
626
627 let key = (
628 distro.map(str::to_string),
629 channel.to_string(),
630 version.to_string(),
631 );
632 if let Some(path) = cache
633 .lock()
634 .unwrap_or_else(|poisoned| poisoned.into_inner())
635 .get(&key)
636 {
637 return Ok(path.clone());
638 }
639
640 // A login shell (`-lc`) is used so a profile-managed PATH (where `zed` or
641 // `curl` may live) is honored. `channel`/`version` are passed as positional
642 // args (`$1`/`$2`), never interpolated into the script body.
643 let output = run_wsl_command(
644 wsl_exe,
645 distro,
646 [
647 "--exec",
648 "sh",
649 "-lc",
650 HELPER_PROVISION_SCRIPT,
651 "zed-wsl-sandbox-helper",
652 channel,
653 version,
654 ],
655 "provision the Linux `zed` sandbox helper",
656 )
657 .await?;
658 if !output.status.success() {
659 let stderr = String::from_utf8_lossy(&output.stderr);
660 let stderr = stderr.trim();
661 return Err(unavailable(format!(
662 "failed to provision a Linux `zed` sandbox helper in {}{}",
663 wsl_distro_label(distro),
664 if stderr.is_empty() {
665 String::new()
666 } else {
667 format!(": {stderr}")
668 }
669 )));
670 }
671
672 let stdout = String::from_utf8_lossy(&output.stdout);
673 let path = stdout
674 .lines()
675 .rev()
676 .find_map(|line| line.strip_prefix(HELPER_RESULT_PREFIX))
677 .map(|path| path.trim().to_string())
678 .with_context(|| {
679 unavailable(format!(
680 "no helper result line in sandbox-helper provisioning output from {}: {stdout:?}",
681 wsl_distro_label(distro)
682 ))
683 })?;
684 ensure!(
685 path.starts_with('/'),
686 "the WSL `zed` sandbox helper resolved to {path:?} rather than an absolute path"
687 );
688
689 cache
690 .lock()
691 .unwrap_or_else(|poisoned| poisoned.into_inner())
692 .insert(key, path.clone());
693 Ok(path)
694}
695
696/// Shell script that resolves and existence-checks paths in a single WSL
697/// round-trip. Arguments come in triples `(kind, path, fallback)`: kind `W`
698/// is a native Windows path to translate with `wslpath -u` (falling back to
699/// the precomputed `/mnt/<drive>/...` mapping when translation fails), kind
700/// `L` is an already-Linux path with an empty fallback. One result line is
701/// printed per triple: `<ok|fallback> <ok|missing> <resolved path>`.
702const PATH_RESOLUTION_SCRIPT: &str = "\
703 while [ \"$#\" -ge 3 ]; do \
704 kind=$1; path=$2; fallback=$3; shift 3; translate=ok; \
705 if [ \"$kind\" = W ]; then \
706 resolved=$(wslpath -u \"$path\" 2>/dev/null) || { resolved=$fallback; translate=fallback; }; \
707 else resolved=$path; fi; \
708 exists=ok; [ -e \"$resolved\" ] || exists=missing; \
709 printf '%s %s %s\\n' \"$translate\" \"$exists\" \"$resolved\"; \
710 done";
711
712/// A line of [`PATH_RESOLUTION_SCRIPT`] output, parsed.
713#[derive(Debug, Eq, PartialEq)]
714struct ResolvedPath {
715 path: String,
716 used_fallback: bool,
717 exists: bool,
718}
719
720/// Resolve path mappings into final WSL paths and confirm required paths exist.
721/// Native drive-letter paths are translated with `wslpath -u` inside the
722/// chosen distro so its actual automount configuration is honored, falling
723/// back to the structural `/mnt/<drive>` mapping when translation fails
724/// (e.g. a distro without `wslpath`); a wrong fallback is still caught by
725/// the existence check.
726///
727/// Successful resolutions are memoized per `(distro, mapping)` for the life
728/// of the process, so a steady-state command whose paths have all been seen
729/// before resolves with zero `wsl.exe` round-trips; at most one round-trip
730/// handles all cache misses ([`resolve_uncached_paths`]). A hit reuses the
731/// translation — which only changes if the distro's automount configuration
732/// is edited and the distro restarted — and also skips the WSL-side
733/// existence re-check. That staleness is acceptable: if a cached path
734/// disappears mid-session bwrap fails closed on the missing bind source rather
735/// than running the command unsandboxed. Optional missing paths are not cached,
736/// so a protected Git path can be created and then included by a later command.
737///
738/// Each mapping is paired with a human-readable description used in errors and
739/// a flag for whether the path is required to exist. The returned paths are in
740/// the same order as `mappings`; optional missing paths are returned as `None`.
741async fn resolve_paths(
742 wsl_exe: &Path,
743 distro: Option<&str>,
744 mappings: &[(PathMapping, &str, bool)],
745) -> Result<Vec<Option<String>>> {
746 type ResolutionCache = HashMap<Option<String>, HashMap<PathMapping, String>>;
747 static CACHE: OnceLock<Mutex<ResolutionCache>> = OnceLock::new();
748 let cache = CACHE.get_or_init(Default::default);
749
750 let distro_key = distro.map(str::to_string);
751 let mut resolved: Vec<Option<Option<String>>> = {
752 let cache = cache
753 .lock()
754 .unwrap_or_else(|poisoned| poisoned.into_inner());
755 let per_distro = cache.get(&distro_key);
756 mappings
757 .iter()
758 .map(|(mapping, _, _)| {
759 per_distro
760 .and_then(|cached| cached.get(mapping))
761 .cloned()
762 .map(Some)
763 })
764 .collect()
765 };
766
767 let misses: Vec<usize> = (0..mappings.len())
768 .filter(|&index| resolved[index].is_none())
769 .collect();
770 if !misses.is_empty() {
771 let miss_mappings: Vec<&(PathMapping, &str, bool)> =
772 misses.iter().map(|&index| &mappings[index]).collect();
773 let miss_resolved = resolve_uncached_paths(wsl_exe, distro, &miss_mappings).await?;
774 let mut cache = cache
775 .lock()
776 .unwrap_or_else(|poisoned| poisoned.into_inner());
777 let per_distro = cache.entry(distro_key).or_default();
778 for (&index, path) in misses.iter().zip(miss_resolved) {
779 if let Some(path) = &path {
780 per_distro.insert(mappings[index].0.clone(), path.clone());
781 }
782 resolved[index] = Some(path);
783 }
784 }
785
786 let mut paths = Vec::with_capacity(resolved.len());
787 for path in resolved {
788 paths.push(path.context("bug: a path mapping was left unresolved")?);
789 }
790 Ok(paths)
791}
792
793/// Resolve and existence-check mappings that weren't in the cache, in a
794/// single `wsl.exe` round-trip. A non-login shell runs the script so profile
795/// scripts can't pollute the stdout protocol.
796async fn resolve_uncached_paths(
797 wsl_exe: &Path,
798 distro: Option<&str>,
799 mappings: &[&(PathMapping, &str, bool)],
800) -> Result<Vec<Option<String>>> {
801 let mut args = vec![
802 "--exec".to_string(),
803 "sh".to_string(),
804 "-c".to_string(),
805 PATH_RESOLUTION_SCRIPT.to_string(),
806 // argv[0] for the script; the path triples follow as "$@".
807 "zed-resolve-paths".to_string(),
808 ];
809 args.extend(path_resolution_args(
810 mappings.iter().map(|mapping| &mapping.0),
811 ));
812 let output = run_wsl_command(wsl_exe, distro, &args, "resolve sandbox paths").await?;
813 if !output.status.success() {
814 return Err(unavailable(format!(
815 "failed to resolve sandbox paths in {}{}",
816 wsl_distro_label(distro),
817 command_failure_details(output.status.code(), &output.stderr)
818 )));
819 }
820
821 let stdout = String::from_utf8_lossy(&output.stdout);
822 let resolved = parse_path_resolution_output(&stdout, mappings.len()).map_err(|error| {
823 unavailable(format!(
824 "failed to resolve sandbox paths in {}: {error:#}",
825 wsl_distro_label(distro)
826 ))
827 })?;
828
829 mappings
830 .iter()
831 .zip(resolved)
832 .map(|((mapping, description, required), resolved)| {
833 if resolved.used_fallback
834 && let PathMapping::NativeDrive { windows_path, .. } = mapping
835 {
836 log::warn!(
837 "failed to translate `{windows_path}` with wslpath in {}; \
838 falling back to `{}`",
839 wsl_distro_label(distro),
840 resolved.path
841 );
842 }
843 if !resolved.exists {
844 // A bad request (the path simply isn't there), not an
845 // environment problem — the model can create it or fix the path
846 // and retry, so no `WSL_SANDBOX_UNAVAILABLE_PREFIX`. Protected
847 // Git paths are allowed to be absent, matching Linux bwrap's
848 // behavior: a missing path cannot be overlaid, so it is skipped.
849 ensure!(
850 !required,
851 "mapped {description} `{}` does not exist in {}",
852 resolved.path,
853 wsl_distro_label(distro)
854 );
855 return Ok(None);
856 }
857 Ok(Some(resolved.path))
858 })
859 .collect()
860}
861
862/// Flatten path mappings into the `(kind, path, fallback)` argument triples
863/// consumed by [`PATH_RESOLUTION_SCRIPT`].
864fn path_resolution_args<'a>(mappings: impl Iterator<Item = &'a PathMapping>) -> Vec<String> {
865 let mut args = Vec::new();
866 for mapping in mappings {
867 match mapping {
868 PathMapping::Wsl(path) => {
869 args.extend(["L".to_string(), path.path.clone(), String::new()]);
870 }
871 PathMapping::NativeDrive {
872 windows_path,
873 fallback,
874 } => {
875 args.extend(["W".to_string(), windows_path.clone(), fallback.path.clone()]);
876 }
877 }
878 }
879 args
880}
881
882/// Parse [`PATH_RESOLUTION_SCRIPT`] output: one strictly-formatted line per
883/// input triple. Anything else (wrong line count, unknown status words, a
884/// non-absolute path) means the stdout protocol was corrupted and is an error.
885fn parse_path_resolution_output(stdout: &str, expected: usize) -> Result<Vec<ResolvedPath>> {
886 let lines: Vec<&str> = stdout.lines().collect();
887 ensure!(
888 lines.len() == expected,
889 "expected {expected} result lines from the path resolution script, got {}: {stdout:?}",
890 lines.len()
891 );
892 lines
893 .into_iter()
894 .map(|line| {
895 let mut parts = line.splitn(3, ' ');
896 let (Some(translate), Some(exists), Some(path)) =
897 (parts.next(), parts.next(), parts.next())
898 else {
899 bail!("malformed line from the path resolution script: {line:?}");
900 };
901 let used_fallback = match translate {
902 "ok" => false,
903 "fallback" => true,
904 _ => bail!("malformed line from the path resolution script: {line:?}"),
905 };
906 let exists = match exists {
907 "ok" => true,
908 "missing" => false,
909 _ => bail!("malformed line from the path resolution script: {line:?}"),
910 };
911 ensure!(
912 path.starts_with('/'),
913 "unexpected resolved path from the path resolution script: {path:?}"
914 );
915 Ok(ResolvedPath {
916 path: path.to_string(),
917 used_fallback,
918 exists,
919 })
920 })
921 .collect()
922}
923
924/// `CREATE_NO_WINDOW` process creation flag. `wsl.exe` is a console-subsystem
925/// binary, so spawning it from a GUI process without this flag flashes a
926/// console window. Defined locally because this crate doesn't depend on
927/// `util` (whose command helpers normally take care of this).
928const CREATE_NO_WINDOW: u32 = 0x0800_0000;
929
930/// Invoke `wsl.exe` with the given args and return its raw output.
931///
932/// Only spawn failures become errors here; callers interpret the exit status
933/// themselves. stdout, when used, is decoded as UTF-8 (lossily) — that's
934/// only valid for `--exec`'d programs whose output we control, not for
935/// `wsl.exe`'s own diagnostics (which are UTF-16LE).
936///
937/// `output()` spawns the child eagerly and the returned future owns it, so
938/// with `kill_on_drop` the child can't outlive this future: a caller-side
939/// timeout or cancellation that drops us also terminates a wedged `wsl.exe`
940/// instead of leaking it.
941async fn run_wsl_command(
942 wsl_exe: &Path,
943 distro: Option<&str>,
944 args: impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>>,
945 description: &str,
946) -> Result<std::process::Output> {
947 use smol::process::windows::CommandExt as _;
948
949 let mut command = Command::new(wsl_exe);
950 if let Some(distro) = distro {
951 command.args(["-d", distro]);
952 }
953 command
954 .args(args)
955 .stdin(Stdio::null())
956 .kill_on_drop(true)
957 .creation_flags(CREATE_NO_WINDOW);
958
959 command.output().await.map_err(|error| {
960 unavailable(format!(
961 "failed to invoke WSL while trying to {description}: {error:#}"
962 ))
963 })
964}
965
966fn command_failure_details(exit_code: Option<i32>, stderr: &[u8]) -> String {
967 let stderr = String::from_utf8_lossy(stderr);
968 let stderr = stderr.trim();
969 let exit_status = match exit_code {
970 Some(code) => format!("exit code {code}"),
971 None => "terminated by signal".to_string(),
972 };
973 if stderr.is_empty() {
974 format!(" ({exit_status})")
975 } else {
976 format!(" ({exit_status}; stderr: {stderr})")
977 }
978}
979
980fn wsl_distro_label(distro: Option<&str>) -> String {
981 match distro {
982 Some(distro) => format!("WSL distro `{distro}`"),
983 None => "the default WSL distro".to_string(),
984 }
985}
986
987fn wsl_exe_path() -> PathBuf {
988 std::env::var_os("SystemRoot")
989 .map(PathBuf::from)
990 .unwrap_or_else(|| PathBuf::from(r"C:\Windows"))
991 .join("System32")
992 .join("wsl.exe")
993}
994
995fn build_bwrap_args<S: std::hash::BuildHasher>(
996 writable_paths: &[String],
997 protected_paths: &[String],
998 permissions: SandboxPermissions,
999 cwd: Option<&str>,
1000 mask_interop_dir: bool,
1001 env: &HashMap<String, String, S>,
1002) -> Vec<String> {
1003 let mut args = Vec::new();
1004
1005 if permissions.allow_fs_write {
1006 push_bind(&mut args, "--bind", "/", "/");
1007 } else {
1008 push_bind(&mut args, "--ro-bind", "/", "/");
1009 args.extend(["--tmpfs".to_string(), "/tmp".to_string()]);
1010 for path in writable_paths {
1011 push_bind(&mut args, "--bind", path, path);
1012 }
1013 }
1014
1015 // Protect requested paths by re-binding them read-only over any writable
1016 // binds above (order matters: later binds win).
1017 for path in protected_paths {
1018 push_bind(&mut args, "--ro-bind", path, path);
1019 }
1020
1021 // Block WSL's Windows interop, regardless of the requested permissions.
1022 // Without this, a sandboxed process can exec a Windows binary (e.g.
1023 // /mnt/c/Windows/System32/cmd.exe), which the kernel's binfmt handler
1024 // (`/init`) hands off to the Windows host over an AF_UNIX socket — running
1025 // fully outside bwrap and defeating both the filesystem and the network
1026 // restrictions. `/init` locates that socket via the $WSL_INTEROP
1027 // environment variable, so we drop it; and we mask the socket directory
1028 // (when it exists) so the value can't be rediscovered by listing
1029 // /run/WSL and re-exporting it. Both steps are required: unsetting the
1030 // variable alone is bypassable, and masking alone leaves the inherited
1031 // variable usable.
1032 args.extend(["--unsetenv".to_string(), "WSL_INTEROP".to_string()]);
1033 args.extend(["--unsetenv".to_string(), "WSLENV".to_string()]);
1034 if mask_interop_dir {
1035 args.extend(["--tmpfs".to_string(), "/run/WSL".to_string()]);
1036 }
1037
1038 args.extend([
1039 "--dev".to_string(),
1040 "/dev".to_string(),
1041 "--proc".to_string(),
1042 "/proc".to_string(),
1043 ]);
1044
1045 if !permissions.allow_network {
1046 args.push("--unshare-net".to_string());
1047 }
1048
1049 args.extend([
1050 "--unshare-user".to_string(),
1051 "--unshare-ipc".to_string(),
1052 "--unshare-uts".to_string(),
1053 "--unshare-pid".to_string(),
1054 "--unshare-cgroup-try".to_string(),
1055 "--die-with-parent".to_string(),
1056 ]);
1057
1058 // Forward the caller-provided environment into the command. Windows env
1059 // set on the `wsl.exe` process doesn't reach the Linux command, so we
1060 // re-apply it here on the sandbox's child instead.
1061 for (name, value) in env {
1062 if is_forwardable_env_var(name) {
1063 args.extend(["--setenv".to_string(), name.clone(), value.clone()]);
1064 }
1065 }
1066
1067 if let Some(cwd) = cwd {
1068 args.extend(["--chdir".to_string(), cwd.to_string()]);
1069 }
1070
1071 args
1072}
1073
1074/// Whether an environment variable should be forwarded into the Linux sandbox.
1075///
1076/// `bwrap --setenv` calls `setenv(3)`, which rejects names that are empty or
1077/// contain `=`. Windows process environments include such entries — most
1078/// notably the per-drive current-directory pseudo-variables (`=C:`, `=D:`,
1079/// ...) Windows keeps in the environment block — so they must be skipped or
1080/// bwrap aborts with "setenv failed".
1081///
1082/// Beyond that, many variables hold Windows-specific values that would be
1083/// meaningless or actively break the command inside WSL, so they are dropped
1084/// rather than forwarded: `PATH`/`PATHEXT` would shadow WSL's own `PATH` and
1085/// stop the shell from finding Linux executables, the temp-dir variables point
1086/// at Windows paths that don't exist in WSL (bwrap provides a fresh tmpfs
1087/// `/tmp` instead), the WSL interop variables would undermine the explicit
1088/// interop block above, a Windows-set `HOME` would clobber the distro's own
1089/// `$HOME` and break the shell, and the rest are Windows system locations,
1090/// shell settings, host/session identity, and CPU descriptors that are either
1091/// wrong or misleading inside Linux. This is a blocklist rather than an allowlist so
1092/// genuinely portable variables (e.g. `LANG`, `CARGO_TERM_COLOR`, `PAGER`)
1093/// still reach the command; it only needs to cover the variables Windows
1094/// populates by default. Matched case-insensitively because Windows
1095/// environment variable names are.
1096fn is_forwardable_env_var(name: &str) -> bool {
1097 if name.is_empty() || name.contains('=') {
1098 return false;
1099 }
1100 const BLOCKED: &[&str] = &[
1101 // Shadow or break the Linux process environment.
1102 "PATH",
1103 "PATHEXT",
1104 "TMPDIR",
1105 "TMP",
1106 "TEMP",
1107 // Would undermine the explicit WSL interop block above.
1108 "WSL_INTEROP",
1109 "WSLENV",
1110 // Windows system locations and shell, meaningless inside Linux.
1111 "OS",
1112 "COMSPEC",
1113 "WINDIR",
1114 "SYSTEMROOT",
1115 "SYSTEMDRIVE",
1116 // When Windows has `HOME` set (e.g. for git), it's a Windows path that
1117 // would clobber the distro's correct Linux `$HOME` and break the shell.
1118 "HOME",
1119 "HOMEDRIVE",
1120 "HOMEPATH",
1121 "HOMESHARE",
1122 "USERPROFILE",
1123 "PUBLIC",
1124 "ALLUSERSPROFILE",
1125 "APPDATA",
1126 "LOCALAPPDATA",
1127 "PROGRAMDATA",
1128 "PROGRAMFILES",
1129 "PROGRAMFILES(X86)",
1130 "PROGRAMW6432",
1131 "COMMONPROGRAMFILES",
1132 "COMMONPROGRAMFILES(X86)",
1133 "COMMONPROGRAMW6432",
1134 "PSMODULEPATH",
1135 "DRIVERDATA",
1136 "ONEDRIVE",
1137 // Windows host/session identity, misleading inside Linux.
1138 "COMPUTERNAME",
1139 "USERNAME",
1140 "USERDOMAIN",
1141 "USERDOMAIN_ROAMINGPROFILE",
1142 "LOGONSERVER",
1143 "SESSIONNAME",
1144 // Windows CPU descriptors.
1145 "NUMBER_OF_PROCESSORS",
1146 "PROCESSOR_ARCHITECTURE",
1147 "PROCESSOR_ARCHITEW6432",
1148 "PROCESSOR_IDENTIFIER",
1149 "PROCESSOR_LEVEL",
1150 "PROCESSOR_REVISION",
1151 ];
1152 !BLOCKED
1153 .iter()
1154 .any(|blocked| name.eq_ignore_ascii_case(blocked))
1155}
1156
1157fn push_bind(args: &mut Vec<String>, flag: &str, source: &str, destination: &str) {
1158 args.extend([
1159 flag.to_string(),
1160 source.to_string(),
1161 destination.to_string(),
1162 ]);
1163}
1164
1165fn directory_to_wsl(path: &Path) -> Result<PathMapping> {
1166 ensure!(
1167 path.is_dir(),
1168 "Windows sandboxing via WSL can only use an existing directory as cwd: {}",
1169 path.display()
1170 );
1171 map_path_to_wsl(path)
1172}
1173
1174fn path_to_wsl(path: &Path) -> Result<PathMapping> {
1175 let path_string = path.to_string_lossy();
1176 if let Ok(path) = parse_wsl_absolute_path(&path_string) {
1177 return Ok(PathMapping::Wsl(path));
1178 }
1179
1180 ensure!(
1181 path.is_dir() || path.is_file(),
1182 "Windows sandboxing via WSL can only grant existing files or directories: {}",
1183 path.display()
1184 );
1185 map_path_to_wsl(path)
1186}
1187
1188fn path_to_wsl_allowing_missing(path: &Path) -> Result<PathMapping> {
1189 let path_string = path.to_string_lossy();
1190 if let Ok(path) = parse_wsl_absolute_path(&path_string) {
1191 return Ok(PathMapping::Wsl(path));
1192 }
1193 map_path_to_wsl(path)
1194}
1195
1196fn map_path_to_wsl(path: &Path) -> Result<PathMapping> {
1197 let path_string = path.to_string_lossy();
1198 if let Ok(path) = parse_wsl_unc_path(&path_string) {
1199 return Ok(PathMapping::Wsl(path));
1200 }
1201 let fallback = parse_native_drive_path(&path_string)?;
1202 let windows_path = path_string
1203 .strip_prefix(r"\\?\")
1204 .unwrap_or(&path_string)
1205 .replace('\\', "/");
1206 Ok(PathMapping::NativeDrive {
1207 windows_path,
1208 fallback,
1209 })
1210}
1211
1212fn parse_wsl_absolute_path(path: &str) -> Result<WslPath> {
1213 let path = path.replace('\\', "/");
1214 ensure!(
1215 path.starts_with('/') && !path.starts_with("//"),
1216 "path is not a WSL absolute path: {path}"
1217 );
1218 Ok(WslPath { distro: None, path })
1219}
1220
1221fn parse_wsl_unc_path(path: &str) -> Result<WslPath> {
1222 let path = path.replace('/', "\\");
1223 let remainder = path
1224 .strip_prefix("\\\\wsl.localhost\\")
1225 .or_else(|| path.strip_prefix("\\\\wsl$\\"))
1226 .or_else(|| path.strip_prefix("\\\\?\\UNC\\wsl.localhost\\"))
1227 .or_else(|| path.strip_prefix("\\\\?\\UNC\\wsl$\\"))
1228 .with_context(|| format!("path is not a WSL UNC path: {path}"))?;
1229
1230 let (distro, rest) = remainder
1231 .split_once('\\')
1232 .map(|(distro, rest)| (distro, Some(rest)))
1233 .unwrap_or((remainder, None));
1234 ensure!(
1235 !distro.is_empty(),
1236 "WSL UNC path is missing a distro name: {path}"
1237 );
1238
1239 let linux_path = match rest {
1240 Some(rest) if !rest.is_empty() => format!("/{}", rest.replace('\\', "/")),
1241 _ => "/".to_string(),
1242 };
1243
1244 Ok(WslPath {
1245 distro: Some(distro.to_string()),
1246 path: linux_path,
1247 })
1248}
1249
1250fn parse_native_drive_path(path: &str) -> Result<WslPath> {
1251 let path = path
1252 .strip_prefix("\\\\?\\")
1253 .unwrap_or(path)
1254 .replace('\\', "/");
1255 let mut chars = path.chars();
1256 let Some(drive) = chars.next().filter(|drive| drive.is_ascii_alphabetic()) else {
1257 bail!("path is not a drive-letter Windows path: {path}");
1258 };
1259 ensure!(chars.next() == Some(':'), "path is not absolute: {path}");
1260 let rest = chars.as_str().trim_start_matches('/');
1261 let drive = drive.to_ascii_lowercase();
1262 let linux_path = if rest.is_empty() {
1263 format!("/mnt/{drive}")
1264 } else {
1265 format!("/mnt/{drive}/{rest}")
1266 };
1267 Ok(WslPath {
1268 distro: None,
1269 path: linux_path,
1270 })
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275 use super::*;
1276
1277 #[test]
1278 fn wrap_invocation_future_is_send() {
1279 // Callers run `wrap_invocation` via `background_spawn`, which
1280 // requires a `Send` future. This fails to compile if, for example, a
1281 // cache `MutexGuard` is ever held across an await point.
1282 fn assert_send<T: Send>(_: T) {}
1283 assert_send(wrap_invocation(
1284 String::new(),
1285 Vec::new(),
1286 Vec::new(),
1287 Vec::new(),
1288 SandboxPermissions::default(),
1289 None,
1290 HashMap::<String, String>::new(),
1291 None,
1292 ));
1293 }
1294
1295 #[test]
1296 fn parse_wsl_localhost_path() {
1297 let path = parse_wsl_unc_path(r"\\wsl.localhost\Ubuntu\home\me\project").unwrap();
1298 assert_eq!(path.distro.as_deref(), Some("Ubuntu"));
1299 assert_eq!(path.path, "/home/me/project");
1300 }
1301
1302 #[test]
1303 fn parse_wsl_dollar_path() {
1304 let path = parse_wsl_unc_path(r"\\wsl$\Debian\tmp").unwrap();
1305 assert_eq!(path.distro.as_deref(), Some("Debian"));
1306 assert_eq!(path.path, "/tmp");
1307 }
1308
1309 #[test]
1310 fn parse_native_windows_path() {
1311 let path = parse_native_drive_path(r"C:\Users\me\project").unwrap();
1312 assert_eq!(path.distro, None);
1313 assert_eq!(path.path, "/mnt/c/Users/me/project");
1314 }
1315
1316 #[test]
1317 fn parse_wsl_absolute_path_keeps_linux_path() {
1318 let path = parse_wsl_absolute_path("/home/me").unwrap();
1319 assert_eq!(path.distro, None);
1320 assert_eq!(path.path, "/home/me");
1321 }
1322
1323 #[test]
1324 fn parse_wsl_absolute_path_rejects_unc_paths() {
1325 assert!(parse_wsl_absolute_path(r"\\server\share").is_err());
1326 }
1327
1328 #[test]
1329 fn parse_verbatim_native_windows_path() {
1330 let path = parse_native_drive_path(r"\\?\D:\workspace").unwrap();
1331 assert_eq!(path.distro, None);
1332 assert_eq!(path.path, "/mnt/d/workspace");
1333 }
1334
1335 #[test]
1336 fn rejects_unc_non_wsl_path() {
1337 assert!(parse_native_drive_path(r"\\server\share\project").is_err());
1338 }
1339
1340 #[test]
1341 fn probe_output_reports_interop_and_bwrap_path() {
1342 let probe = parse_probe_output("zed-wsl-probe: interop /usr/bin/bwrap\n").unwrap();
1343 assert_eq!(
1344 probe,
1345 EnvironmentProbe {
1346 mask_interop_dir: true,
1347 bwrap_path: "/usr/bin/bwrap".to_string(),
1348 }
1349 );
1350
1351 let probe =
1352 parse_probe_output("zed-wsl-probe: no-interop /home/me/.nix-profile/bin/bwrap\n")
1353 .unwrap();
1354 assert_eq!(
1355 probe,
1356 EnvironmentProbe {
1357 mask_interop_dir: false,
1358 bwrap_path: "/home/me/.nix-profile/bin/bwrap".to_string(),
1359 }
1360 );
1361 }
1362
1363 #[test]
1364 fn probe_output_ignores_profile_noise_even_mentioning_interop() {
1365 // Login-shell profile scripts run before the probe body and may print
1366 // arbitrary text; only the marked result line counts.
1367 let probe = parse_probe_output(
1368 "welcome to my shell, interop fans\nzed-wsl-probe: no-interop /usr/bin/bwrap\n",
1369 )
1370 .unwrap();
1371 assert!(!probe.mask_interop_dir);
1372 }
1373
1374 #[test]
1375 fn probe_output_rejects_missing_or_malformed_result_line() {
1376 assert!(parse_probe_output("").is_err());
1377 assert!(parse_probe_output("profile noise only\n").is_err());
1378 assert!(parse_probe_output("zed-wsl-probe: interop\n").is_err());
1379 assert!(parse_probe_output("zed-wsl-probe: maybe /usr/bin/bwrap\n").is_err());
1380 }
1381
1382 #[test]
1383 fn probe_output_rejects_non_absolute_bwrap_path() {
1384 // `command -v` reports a bare name for shell functions and aliases,
1385 // which `wsl --exec` could never run.
1386 assert!(parse_probe_output("zed-wsl-probe: interop bwrap\n").is_err());
1387 }
1388
1389 #[test]
1390 fn probe_script_smoke_tests_the_namespaces_the_real_invocation_uses() {
1391 // Presence isn't enough: unprivileged user namespaces can be
1392 // restricted (e.g. Ubuntu 24.04's AppArmor policy), so the probe must
1393 // actually exercise the namespace flags `build_bwrap_args` emits.
1394 let script = probe_script();
1395 for flag in [
1396 "--unshare-user",
1397 "--unshare-net",
1398 "--unshare-ipc",
1399 "--unshare-uts",
1400 "--unshare-pid",
1401 "--unshare-cgroup-try",
1402 "--ro-bind / /",
1403 ] {
1404 assert!(script.contains(flag), "probe script must contain {flag}");
1405 }
1406 assert!(script.contains("exit 41"));
1407 assert!(script.contains("exit 42"));
1408 }
1409
1410 #[test]
1411 fn probe_script_rejects_setuid_root_bwrap_before_smoke_test() {
1412 let script = probe_script();
1413 let guard =
1414 "[ -u \"$bwrap_path\" ] && [ \"$(stat -c %u \"$bwrap_path\" 2>/dev/null)\" = 0 ]";
1415 let smoke_test = "\"$bwrap_path\" --ro-bind / /";
1416 let Some(guard_index) = script.find(guard) else {
1417 panic!("probe script must contain setuid-root guard: {script}");
1418 };
1419 let Some(smoke_test_index) = script.find(smoke_test) else {
1420 panic!("probe script must contain bwrap smoke test: {script}");
1421 };
1422
1423 assert!(script.contains("setuid-root bwrap is not supported"));
1424 assert!(script.contains(&format!("exit {BWRAP_UNUSABLE_EXIT_CODE}; fi")));
1425 assert!(guard_index < smoke_test_index);
1426 }
1427
1428 #[test]
1429 fn split_resolved_paths_keeps_existing_protected_paths_and_skips_missing_ones() {
1430 let (cwd, writable_paths, protected_paths) = split_resolved_paths(
1431 true,
1432 1,
1433 vec![
1434 Some("/home/me/project".to_string()),
1435 Some("/home/me/project".to_string()),
1436 None,
1437 Some("/mnt/c/external/.git".to_string()),
1438 ],
1439 )
1440 .unwrap();
1441
1442 assert_eq!(cwd.as_deref(), Some("/home/me/project"));
1443 assert_eq!(writable_paths, vec!["/home/me/project".to_string()]);
1444 assert_eq!(protected_paths, vec!["/mnt/c/external/.git".to_string()]);
1445 }
1446
1447 #[test]
1448 fn split_resolved_paths_rejects_missing_required_writable_paths() {
1449 let error = split_resolved_paths(false, 1, vec![None]).unwrap_err();
1450 assert!(
1451 error
1452 .to_string()
1453 .contains("required writable path resolved as missing"),
1454 "unexpected error: {error:#}"
1455 );
1456 }
1457
1458 #[test]
1459 fn bwrap_denies_network_by_default() {
1460 let args = build_bwrap_args(
1461 &["/home/me/project".to_string()],
1462 &[],
1463 SandboxPermissions::default(),
1464 Some("/home/me/project"),
1465 true,
1466 &HashMap::new(),
1467 );
1468 assert!(args.iter().any(|arg| arg == "--unshare-net"));
1469 assert!(
1470 args.windows(3)
1471 .any(|window| window == ["--bind", "/home/me/project", "/home/me/project"])
1472 );
1473 }
1474
1475 #[test]
1476 fn bwrap_allows_network_when_requested() {
1477 let args = build_bwrap_args(
1478 &[],
1479 &[],
1480 SandboxPermissions {
1481 allow_network: true,
1482 allow_fs_write: false,
1483 },
1484 None,
1485 true,
1486 &HashMap::new(),
1487 );
1488 assert!(!args.iter().any(|arg| arg == "--unshare-net"));
1489 }
1490
1491 #[test]
1492 fn bwrap_binds_explicit_writable_file_paths() {
1493 let args = build_bwrap_args(
1494 &["/mnt/c/Users/me/AppData/Roaming/Zed/AGENTS.md".to_string()],
1495 &[],
1496 SandboxPermissions::default(),
1497 None,
1498 true,
1499 &HashMap::new(),
1500 );
1501 assert!(args.windows(3).any(|window| window
1502 == [
1503 "--bind",
1504 "/mnt/c/Users/me/AppData/Roaming/Zed/AGENTS.md",
1505 "/mnt/c/Users/me/AppData/Roaming/Zed/AGENTS.md"
1506 ]));
1507 }
1508
1509 #[test]
1510 fn bwrap_protects_paths_after_writable_paths() {
1511 let args = build_bwrap_args(
1512 &["/home/me/project".to_string()],
1513 &["/home/me/project/.git".to_string()],
1514 SandboxPermissions::default(),
1515 Some("/home/me/project"),
1516 true,
1517 &HashMap::new(),
1518 );
1519 let writable_index = args
1520 .windows(3)
1521 .position(|window| window == ["--bind", "/home/me/project", "/home/me/project"])
1522 .expect("project should be writable");
1523 let protected_index = args
1524 .windows(3)
1525 .position(|window| {
1526 window
1527 == [
1528 "--ro-bind",
1529 "/home/me/project/.git",
1530 "/home/me/project/.git",
1531 ]
1532 })
1533 .expect("protected path should be bound read-only");
1534 assert!(protected_index > writable_index);
1535 }
1536
1537 #[test]
1538 fn bwrap_protects_paths_when_fs_writes_are_unrestricted() {
1539 let args = build_bwrap_args(
1540 &[],
1541 &["/home/me/project/.git".to_string()],
1542 SandboxPermissions {
1543 allow_network: false,
1544 allow_fs_write: true,
1545 },
1546 None,
1547 true,
1548 &HashMap::new(),
1549 );
1550 let unrestricted_write_index = args
1551 .windows(3)
1552 .position(|window| window == ["--bind", "/", "/"])
1553 .expect("root should be writable");
1554 let protected_index = args
1555 .windows(3)
1556 .position(|window| {
1557 window
1558 == [
1559 "--ro-bind",
1560 "/home/me/project/.git",
1561 "/home/me/project/.git",
1562 ]
1563 })
1564 .expect("protected path should be bound read-only");
1565 assert!(protected_index > unrestricted_write_index);
1566 }
1567
1568 #[test]
1569 fn bwrap_blocks_wsl_interop_by_default() {
1570 let args = build_bwrap_args(
1571 &["/home/me/project".to_string()],
1572 &[],
1573 SandboxPermissions::default(),
1574 Some("/home/me/project"),
1575 true,
1576 &HashMap::new(),
1577 );
1578 assert!(
1579 args.windows(2)
1580 .any(|window| window == ["--unsetenv", "WSL_INTEROP"])
1581 );
1582 assert!(
1583 args.windows(2)
1584 .any(|window| window == ["--tmpfs", "/run/WSL"])
1585 );
1586 }
1587
1588 #[test]
1589 fn bwrap_blocks_wsl_interop_even_with_fs_write() {
1590 let args = build_bwrap_args(
1591 &[],
1592 &[],
1593 SandboxPermissions {
1594 allow_network: true,
1595 allow_fs_write: true,
1596 },
1597 None,
1598 true,
1599 &HashMap::new(),
1600 );
1601 // Interop is host code execution, not just a filesystem write, so it
1602 // stays blocked even when the user has granted unrestricted writes
1603 // and network.
1604 assert!(
1605 args.windows(2)
1606 .any(|window| window == ["--unsetenv", "WSL_INTEROP"])
1607 );
1608 assert!(
1609 args.windows(2)
1610 .any(|window| window == ["--tmpfs", "/run/WSL"])
1611 );
1612 }
1613
1614 #[test]
1615 fn bwrap_skips_interop_dir_mask_when_absent() {
1616 // When the interop socket directory doesn't exist (interop disabled),
1617 // there's nothing to mask and a `--tmpfs /run/WSL` would abort bwrap,
1618 // so the mount must be omitted. Unsetting the variable is harmless and
1619 // stays.
1620 let args = build_bwrap_args(
1621 &[],
1622 &[],
1623 SandboxPermissions::default(),
1624 None,
1625 false,
1626 &HashMap::new(),
1627 );
1628 assert!(
1629 args.windows(2)
1630 .any(|window| window == ["--unsetenv", "WSL_INTEROP"])
1631 );
1632 assert!(!args.iter().any(|arg| arg == "/run/WSL"));
1633 }
1634
1635 #[test]
1636 fn bwrap_forwards_env_via_setenv() {
1637 let env = HashMap::from([
1638 ("PAGER".to_string(), String::new()),
1639 ("CARGO_TERM_COLOR".to_string(), "always".to_string()),
1640 ]);
1641 let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
1642 assert!(
1643 args.windows(3)
1644 .any(|window| window == ["--setenv", "PAGER", ""])
1645 );
1646 assert!(
1647 args.windows(3)
1648 .any(|window| window == ["--setenv", "CARGO_TERM_COLOR", "always"])
1649 );
1650 }
1651
1652 #[test]
1653 fn bwrap_does_not_forward_wsl_interop_env() {
1654 let env = HashMap::from([
1655 (
1656 "WSL_INTEROP".to_string(),
1657 "/run/WSL/123_interop".to_string(),
1658 ),
1659 ("WsLeNv".to_string(), "WSL_INTEROP/u".to_string()),
1660 ("PAGER".to_string(), String::new()),
1661 ]);
1662 let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
1663
1664 assert!(
1665 args.windows(2)
1666 .any(|window| window == ["--unsetenv", "WSL_INTEROP"])
1667 );
1668 assert!(
1669 args.windows(2)
1670 .any(|window| window == ["--unsetenv", "WSLENV"])
1671 );
1672 assert!(
1673 args.windows(3)
1674 .any(|window| window == ["--setenv", "PAGER", ""])
1675 );
1676 assert!(!args.windows(3).any(|window| {
1677 matches!(window, [flag, name, _]
1678 if flag.as_str() == "--setenv"
1679 && name.eq_ignore_ascii_case("WSL_INTEROP"))
1680 }));
1681 assert!(!args.windows(3).any(|window| {
1682 matches!(window, [flag, name, _]
1683 if flag.as_str() == "--setenv"
1684 && name.eq_ignore_ascii_case("WSLENV"))
1685 }));
1686 }
1687
1688 #[test]
1689 fn bwrap_does_not_forward_windows_specific_env() {
1690 // These hold Windows paths/values that would break or be meaningless
1691 // inside WSL, so they must never cross the boundary. Names are matched
1692 // case-insensitively, as Windows env var names are. `(x86)` variants
1693 // contain parentheses but no `=`, so they'd pass the `setenv` filter
1694 // and must be blocked by name.
1695 let env = HashMap::from([
1696 ("Path".to_string(), r"C:\Windows\System32".to_string()),
1697 ("PATHEXT".to_string(), ".COM;.EXE;.BAT".to_string()),
1698 (
1699 "TEMP".to_string(),
1700 r"C:\Users\me\AppData\Local\Temp".to_string(),
1701 ),
1702 (
1703 "Tmp".to_string(),
1704 r"C:\Users\me\AppData\Local\Temp".to_string(),
1705 ),
1706 ("TMPDIR".to_string(), r"C:\tmp".to_string()),
1707 ("OS".to_string(), "Windows_NT".to_string()),
1708 (
1709 "ComSpec".to_string(),
1710 r"C:\Windows\system32\cmd.exe".to_string(),
1711 ),
1712 ("windir".to_string(), r"C:\Windows".to_string()),
1713 ("SystemRoot".to_string(), r"C:\Windows".to_string()),
1714 ("HOME".to_string(), r"C:\Users\me".to_string()),
1715 ("USERPROFILE".to_string(), r"C:\Users\me".to_string()),
1716 (
1717 "APPDATA".to_string(),
1718 r"C:\Users\me\AppData\Roaming".to_string(),
1719 ),
1720 (
1721 "LOCALAPPDATA".to_string(),
1722 r"C:\Users\me\AppData\Local".to_string(),
1723 ),
1724 (
1725 "ProgramFiles(x86)".to_string(),
1726 r"C:\Program Files (x86)".to_string(),
1727 ),
1728 ("USERNAME".to_string(), "me".to_string()),
1729 ("COMPUTERNAME".to_string(), "DESKTOP-ABC".to_string()),
1730 ("PROCESSOR_ARCHITECTURE".to_string(), "AMD64".to_string()),
1731 ("NUMBER_OF_PROCESSORS".to_string(), "16".to_string()),
1732 ]);
1733 let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
1734 assert!(!args.iter().any(|arg| arg == "--setenv"));
1735 }
1736
1737 #[test]
1738 fn bwrap_forwards_portable_env_alongside_windows_specific_env() {
1739 // A blocklist (not an allowlist) means genuinely portable variables
1740 // still reach the command even when Windows-only ones are present.
1741 let env = HashMap::from([
1742 ("USERPROFILE".to_string(), r"C:\Users\me".to_string()),
1743 ("LANG".to_string(), "en_US.UTF-8".to_string()),
1744 ("CARGO_TERM_COLOR".to_string(), "always".to_string()),
1745 ]);
1746 let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
1747 assert!(
1748 args.windows(3)
1749 .any(|window| window == ["--setenv", "LANG", "en_US.UTF-8"])
1750 );
1751 assert!(
1752 args.windows(3)
1753 .any(|window| window == ["--setenv", "CARGO_TERM_COLOR", "always"])
1754 );
1755 assert!(!args.windows(3).any(|window| {
1756 matches!(window, [flag, name, _]
1757 if flag.as_str() == "--setenv"
1758 && name.eq_ignore_ascii_case("USERPROFILE"))
1759 }));
1760 }
1761
1762 #[test]
1763 fn bwrap_skips_env_names_setenv_would_reject() {
1764 // bwrap's `--setenv` calls `setenv(3)`, which rejects empty names and
1765 // names containing `=`. Windows environments include the per-drive
1766 // current-directory pseudo-variables (`=C:`, ...); forwarding them
1767 // would abort bwrap with "setenv failed".
1768 let env = HashMap::from([
1769 ("=C:".to_string(), r"C:\Users\me".to_string()),
1770 (String::new(), "value".to_string()),
1771 ("OK".to_string(), "value".to_string()),
1772 ]);
1773 let args = build_bwrap_args(&[], &[], SandboxPermissions::default(), None, false, &env);
1774 assert!(
1775 args.windows(3)
1776 .any(|window| window == ["--setenv", "OK", "value"])
1777 );
1778 assert_eq!(args.iter().filter(|arg| *arg == "--setenv").count(), 1);
1779 }
1780
1781 #[test]
1782 fn select_distro_uses_wsl_distro_when_present() {
1783 let distro = select_distro(
1784 None,
1785 &[
1786 PathMapping::NativeDrive {
1787 windows_path: "C:/project".to_string(),
1788 fallback: WslPath {
1789 distro: None,
1790 path: "/mnt/c/project".to_string(),
1791 },
1792 },
1793 PathMapping::Wsl(WslPath {
1794 distro: Some("Ubuntu".to_string()),
1795 path: "/home/me/project".to_string(),
1796 }),
1797 ],
1798 )
1799 .unwrap();
1800 assert_eq!(distro.as_deref(), Some("Ubuntu"));
1801 }
1802
1803 #[test]
1804 fn bad_request_errors_do_not_claim_sandboxing_is_unavailable() {
1805 // Mixed distros and missing/unmappable paths are model-fixable bad
1806 // requests. They must not be typed as `WslSandboxUnavailable` (nor
1807 // carry its prefix), since the agent uses that type to offer the
1808 // run-unsandboxed fallback only for genuine environment failures.
1809 let mixed_distros = select_distro(
1810 Some(&PathMapping::Wsl(WslPath {
1811 distro: Some("Ubuntu".to_string()),
1812 path: "/home/me".to_string(),
1813 })),
1814 &[PathMapping::Wsl(WslPath {
1815 distro: Some("Debian".to_string()),
1816 path: "/home/me".to_string(),
1817 })],
1818 )
1819 .unwrap_err();
1820 assert!(
1821 mixed_distros
1822 .downcast_ref::<WslSandboxUnavailable>()
1823 .is_none()
1824 );
1825 assert!(!format!("{mixed_distros:#}").contains(WSL_SANDBOX_UNAVAILABLE_PREFIX));
1826
1827 let missing_path =
1828 path_to_wsl(Path::new(r"C:\zed-test\definitely\does\not\exist-2769")).unwrap_err();
1829 assert!(
1830 missing_path
1831 .downcast_ref::<WslSandboxUnavailable>()
1832 .is_none()
1833 );
1834 assert!(!format!("{missing_path:#}").contains(WSL_SANDBOX_UNAVAILABLE_PREFIX));
1835
1836 let unmappable_cwd = directory_to_wsl(Path::new(r"\\server\share\project")).unwrap_err();
1837 assert!(
1838 unmappable_cwd
1839 .downcast_ref::<WslSandboxUnavailable>()
1840 .is_none()
1841 );
1842 assert!(!format!("{unmappable_cwd:#}").contains(WSL_SANDBOX_UNAVAILABLE_PREFIX));
1843 }
1844
1845 #[test]
1846 fn unavailable_errors_are_typed_and_prefixed() {
1847 // Environment failures are recognizable by type (so the agent doesn't
1848 // depend on message text) and still render with the shared prefix.
1849 let error = unavailable("Bubblewrap (`bwrap`) is not installed in the default WSL distro");
1850 let typed = error
1851 .downcast_ref::<WslSandboxUnavailable>()
1852 .expect("environment failure should downcast to WslSandboxUnavailable");
1853 assert_eq!(
1854 typed.message(),
1855 "Bubblewrap (`bwrap`) is not installed in the default WSL distro"
1856 );
1857 assert!(format!("{error:#}").starts_with(WSL_SANDBOX_UNAVAILABLE_PREFIX));
1858 }
1859
1860 #[test]
1861 fn map_path_to_wsl_keeps_unc_paths_structural() {
1862 let mapping = map_path_to_wsl(Path::new(r"\\wsl.localhost\Ubuntu\home\me")).unwrap();
1863 assert_eq!(
1864 mapping,
1865 PathMapping::Wsl(WslPath {
1866 distro: Some("Ubuntu".to_string()),
1867 path: "/home/me".to_string(),
1868 })
1869 );
1870 }
1871
1872 #[test]
1873 fn map_path_to_wsl_defers_native_paths_to_wslpath() {
1874 let mapping = map_path_to_wsl(Path::new(r"C:\Users\me\project")).unwrap();
1875 assert_eq!(
1876 mapping,
1877 PathMapping::NativeDrive {
1878 windows_path: "C:/Users/me/project".to_string(),
1879 fallback: WslPath {
1880 distro: None,
1881 path: "/mnt/c/Users/me/project".to_string(),
1882 },
1883 }
1884 );
1885 }
1886
1887 #[test]
1888 fn map_path_to_wsl_strips_verbatim_prefix_for_wslpath() {
1889 let mapping = map_path_to_wsl(Path::new(r"\\?\D:\workspace")).unwrap();
1890 assert_eq!(
1891 mapping,
1892 PathMapping::NativeDrive {
1893 windows_path: "D:/workspace".to_string(),
1894 fallback: WslPath {
1895 distro: None,
1896 path: "/mnt/d/workspace".to_string(),
1897 },
1898 }
1899 );
1900 }
1901
1902 #[test]
1903 fn path_resolution_args_flattens_mappings_into_triples() {
1904 let mappings = [
1905 PathMapping::NativeDrive {
1906 windows_path: "C:/Users/me/project".to_string(),
1907 fallback: WslPath {
1908 distro: None,
1909 path: "/mnt/c/Users/me/project".to_string(),
1910 },
1911 },
1912 PathMapping::Wsl(WslPath {
1913 distro: Some("Ubuntu".to_string()),
1914 path: "/home/me/project".to_string(),
1915 }),
1916 ];
1917 assert_eq!(
1918 path_resolution_args(mappings.iter()),
1919 [
1920 "W",
1921 "C:/Users/me/project",
1922 "/mnt/c/Users/me/project",
1923 "L",
1924 "/home/me/project",
1925 "",
1926 ]
1927 );
1928 }
1929
1930 #[test]
1931 fn parse_path_resolution_output_reads_one_line_per_path() {
1932 let resolved = parse_path_resolution_output(
1933 "ok ok /mnt/c/Users/me/project\nfallback missing /mnt/d/workspace\n",
1934 2,
1935 )
1936 .unwrap();
1937 assert_eq!(
1938 resolved,
1939 [
1940 ResolvedPath {
1941 path: "/mnt/c/Users/me/project".to_string(),
1942 used_fallback: false,
1943 exists: true,
1944 },
1945 ResolvedPath {
1946 path: "/mnt/d/workspace".to_string(),
1947 used_fallback: true,
1948 exists: false,
1949 },
1950 ]
1951 );
1952 }
1953
1954 #[test]
1955 fn parse_path_resolution_output_keeps_spaces_in_paths() {
1956 let resolved =
1957 parse_path_resolution_output("ok ok /mnt/c/Users/me/My Documents/project\n", 1)
1958 .unwrap();
1959 assert_eq!(resolved[0].path, "/mnt/c/Users/me/My Documents/project");
1960 }
1961
1962 #[test]
1963 fn parse_path_resolution_output_rejects_wrong_line_count() {
1964 assert!(parse_path_resolution_output("ok ok /a\n", 2).is_err());
1965 assert!(parse_path_resolution_output("ok ok /a\nok ok /b\n", 1).is_err());
1966 }
1967
1968 #[test]
1969 fn parse_path_resolution_output_rejects_corrupted_lines() {
1970 assert!(parse_path_resolution_output("garbage\n", 1).is_err());
1971 assert!(parse_path_resolution_output("weird ok /a\n", 1).is_err());
1972 assert!(parse_path_resolution_output("ok weird /a\n", 1).is_err());
1973 assert!(parse_path_resolution_output("ok ok not-absolute\n", 1).is_err());
1974 }
1975}
1976