Skip to main content

agentmux_srv/backend/remote/
wsl_fs.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! WSL folders in Hangar and the editor, through Windows' own share for a
5//! distro's files, `\\wsl.localhost\<distro>\...` (P1 of
6//! SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md).
7//!
8//! Windows reads and writes those paths with the ordinary file APIs, so the
9//! panes need no new transport, only to know when a path is one: the rules
10//! about where AgentMux may write are written for Windows paths, and a distro
11//! has its own home and system folders, and its `/mnt/<letter>` is a Windows
12//! drive. Everything here is string work on a path, so it is tested on every
13//! platform.
14
15/// The share's host. `wsl$` is the older name for the same share.
16pub const SHARE_HOST: &str = "wsl.localhost";
17const LEGACY_SHARE_HOST: &str = "wsl$";
18
19/// A path inside a distro, as seen through the share.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct SharePath {
22    pub distro: String,
23    /// The path inside the distro: absolute, `/`-separated, no `.` or `..`.
24    pub linux: String,
25}
26
27/// [`split_share`] where the share exists, on Windows; `None` elsewhere, where
28/// `//wsl.localhost/...` is an ordinary path and the Windows rules for it are
29/// the only ones. Every rule that treats a share path differently goes through
30/// this.
31pub fn share_of(path: &std::path::Path) -> Option<SharePath> {
32    if cfg!(windows) {
33        split_share(&path.to_string_lossy())
34    } else {
35        None
36    }
37}
38
39/// The distro and Linux path of a path on the share: `\\wsl.localhost\D\...`,
40/// `\\wsl$\D\...`, the `\\?\UNC\wsl.localhost\D\...` form `canonicalize`
41/// returns, or any of those with `/` separators (the frontend's form). `None`
42/// for any other path.
43pub fn split_share(path: &str) -> Option<SharePath> {
44    let rest = strip_unc_prefix(path)?;
45    let mut parts = rest.split(['\\', '/']).filter(|p| !p.is_empty());
46    let host = parts.next()?;
47    if !host.eq_ignore_ascii_case(SHARE_HOST) && !host.eq_ignore_ascii_case(LEGACY_SHARE_HOST) {
48        return None;
49    }
50    let distro = parts
51        .next()
52        .filter(|d| *d != "." && *d != "..")?
53        .to_string();
54    let mut linux: Vec<&str> = Vec::new();
55    for part in parts {
56        match part {
57            "." => {}
58            ".." => {
59                linux.pop();
60            }
61            p => linux.push(p),
62        }
63    }
64    Some(SharePath {
65        distro,
66        linux: format!("/{}", linux.join("/")),
67    })
68}
69
70fn strip_unc_prefix(path: &str) -> Option<&str> {
71    [r"\\?\UNC\", "//?/UNC/", r"\\", "//"]
72        .into_iter()
73        .find(|prefix| {
74            path.get(..prefix.len())
75                .is_some_and(|p| p.eq_ignore_ascii_case(prefix))
76        })
77        .map(|prefix| &path[prefix.len()..])
78}
79
80/// The share path for `linux` in `distro`: `\\wsl.localhost\<distro>\a\b`.
81pub fn share_path(distro: &str, linux: &str) -> String {
82    let mut out = format!(r"\\{SHARE_HOST}\{distro}\");
83    out.push_str(&linux.trim_start_matches('/').replace('/', "\\"));
84    out
85}
86
87/// A distro's own system folders: the same list AgentMux protects on a Linux
88/// machine, plus `/mnt` (where WSL mounts the Windows drives and its own
89/// helpers), `/init` (WSL's own init) and `/run`. `/` itself counts.
90const SYSTEM_DIRS: &[&str] = &[
91    "/usr", "/bin", "/sbin", "/etc", "/var", "/boot", "/lib", "/lib32", "/lib64", "/proc", "/sys",
92    "/dev", "/mnt", "/init", "/run",
93];
94
95/// Whether `linux` is, or is inside, one of a distro's system folders.
96pub fn is_system_path(linux: &str) -> bool {
97    linux == "/"
98        || SYSTEM_DIRS
99            .iter()
100            .any(|d| linux == *d || linux.starts_with(&format!("{d}/")))
101}
102
103/// The Windows path a distro's `/mnt/<letter>/...` is: WSL mounts each drive
104/// there, so changing it changes the Windows file, and the Windows rules apply.
105pub fn drive_path(linux: &str) -> Option<String> {
106    let rest = linux.strip_prefix("/mnt/")?;
107    let (letter, tail) = rest.split_once('/').unwrap_or((rest, ""));
108    let mut chars = letter.chars();
109    let c = chars.next().filter(|c| c.is_ascii_alphabetic())?;
110    if chars.next().is_some() {
111        return None;
112    }
113    Some(format!(
114        "{}:\\{}",
115        c.to_ascii_uppercase(),
116        tail.replace('/', "\\")
117    ))
118}
119
120/// Whether `linux` is a distro user's home folder: `/home/<user>` or `/root`.
121pub fn is_home(linux: &str) -> bool {
122    linux == "/root"
123        || linux
124            .strip_prefix("/home/")
125            .is_some_and(|u| !u.is_empty() && !u.contains('/'))
126}
127
128/// Whether `linux` is a distro user's home folder or inside one: the WSL side
129/// of the editor's "only under your home folder" rule for writes.
130pub fn in_home(linux: &str) -> bool {
131    is_home(linux)
132        || linux.starts_with("/root/")
133        || linux
134            .strip_prefix("/home/")
135            .is_some_and(|u| u.split_once('/').is_some_and(|(user, _)| !user.is_empty()))
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn sp(distro: &str, linux: &str) -> Option<SharePath> {
143        Some(SharePath {
144            distro: distro.to_string(),
145            linux: linux.to_string(),
146        })
147    }
148
149    #[test]
150    fn every_spelling_of_a_share_path_splits_the_same_way() {
151        for path in [
152            r"\\wsl.localhost\Ubuntu\home\u\proj",
153            r"\\WSL.LOCALHOST\Ubuntu\home\u\proj\",
154            r"\\wsl$\Ubuntu\home\u\proj",
155            r"\\?\UNC\wsl.localhost\Ubuntu\home\u\proj",
156            "//wsl.localhost/Ubuntu/home/u/proj",
157            r"\\wsl.localhost\Ubuntu\home\u\.\x\..\proj",
158        ] {
159            assert_eq!(split_share(path), sp("Ubuntu", "/home/u/proj"), "{path}");
160        }
161        assert_eq!(split_share(r"\\wsl.localhost\Ubuntu"), sp("Ubuntu", "/"));
162        assert_eq!(
163            split_share(r"\\wsl.localhost\Ubuntu\..\..\x"),
164            sp("Ubuntu", "/x")
165        );
166    }
167
168    #[test]
169    fn other_paths_are_not_share_paths() {
170        for path in [
171            r"C:\Users\u",
172            r"\\?\C:\Users\u",
173            r"\\server\share\x",
174            r"\\wsl.localhost",
175            r"\\wsl.localhost\..\x",
176            "/home/u",
177            "",
178        ] {
179            assert_eq!(split_share(path), None, "{path}");
180        }
181    }
182
183    /// Off Windows a share-looking path is an ordinary one: no WSL rule applies.
184    #[test]
185    fn a_share_path_is_one_only_on_windows() {
186        let p = std::path::Path::new("//wsl.localhost/Ubuntu/home/u/x");
187        assert_eq!(share_of(p).is_some(), cfg!(windows));
188    }
189
190    #[test]
191    fn share_paths_round_trip() {
192        assert_eq!(share_path("Ubuntu", "/"), r"\\wsl.localhost\Ubuntu\");
193        let p = share_path("Ubuntu", "/home/u/proj");
194        assert_eq!(p, r"\\wsl.localhost\Ubuntu\home\u\proj");
195        assert_eq!(split_share(&p), sp("Ubuntu", "/home/u/proj"));
196    }
197
198    #[test]
199    fn a_distro_has_system_folders_and_homes() {
200        for p in [
201            "/",
202            "/usr",
203            "/usr/bin/ls",
204            "/etc/passwd",
205            "/mnt",
206            "/mnt/wsl",
207            "/init",
208        ] {
209            assert!(is_system_path(p), "{p}");
210        }
211        for p in [
212            "/home/u",
213            "/root/x",
214            "/srv/app",
215            "/usrlocal",
216            "/tmp/x",
217            "/opt/tool",
218        ] {
219            assert!(!is_system_path(p), "{p}");
220        }
221        assert!(is_home("/home/u") && is_home("/root"));
222        assert!(!is_home("/home") && !is_home("/home/u/x"));
223        assert!(in_home("/home/u") && in_home("/home/u/proj/a.rs") && in_home("/root/x"));
224        assert!(
225            !in_home("/home")
226                && !in_home("/srv/app")
227                && !in_home("/rootx")
228                && !in_home("/mnt/c/Users/u")
229        );
230    }
231
232    #[test]
233    fn a_drive_under_mnt_is_its_windows_path() {
234        assert_eq!(drive_path("/mnt/c"), Some(r"C:\".to_string()));
235        assert_eq!(
236            drive_path("/mnt/c/Windows/System32"),
237            Some(r"C:\Windows\System32".to_string())
238        );
239        assert_eq!(drive_path("/mnt/d/x"), Some(r"D:\x".to_string()));
240        assert_eq!(drive_path("/mnt/wsl"), None);
241        assert_eq!(drive_path("/mnt"), None);
242        assert_eq!(drive_path("/home/u"), None);
243    }
244}