Skip to main content

xtask_todo_lib/devshell/vm/
guest_fs_ops.rs

1//! Guest filesystem operations for Mode P ([`GuestFsOps`]).
2//!
3//! See `docs/superpowers/specs/2026-03-20-devshell-guest-primary-design.md` §4.
4
5#![allow(clippy::pedantic, clippy::nursery)]
6
7use std::collections::HashMap;
8use std::fmt;
9use std::path::Path;
10
11#[cfg(any(unix, feature = "beta-vm"))]
12use super::VmError;
13#[cfg(unix)]
14use super::{GammaSession, VmConfig};
15
16/// Errors from [`GuestFsOps`] (guest path / remote command).
17#[derive(Debug)]
18pub enum GuestFsError {
19    /// Path escapes the workspace mount or is not absolute.
20    InvalidPath(String),
21    /// No such file or directory.
22    NotFound(String),
23    /// Expected a directory.
24    NotADirectory(String),
25    /// Expected a regular file.
26    IsADirectory(String),
27    /// Guest command failed (non-zero exit).
28    GuestCommand { status: Option<i32>, stderr: String },
29    /// VM / `limactl` (Unix γ) or β IPC failure.
30    #[cfg(any(unix, feature = "beta-vm"))]
31    Vm(VmError),
32    /// I/O or UTF-8 issues in the mock implementation.
33    Internal(String),
34}
35
36impl fmt::Display for GuestFsError {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            Self::InvalidPath(s) => write!(f, "invalid guest path: {s}"),
40            Self::NotFound(s) => write!(f, "not found: {s}"),
41            Self::NotADirectory(s) => write!(f, "not a directory: {s}"),
42            Self::IsADirectory(s) => write!(f, "is a directory: {s}"),
43            Self::GuestCommand { stderr, .. } => write!(f, "guest command failed: {stderr}"),
44            #[cfg(any(unix, feature = "beta-vm"))]
45            Self::Vm(e) => write!(f, "{e}"),
46            Self::Internal(s) => write!(f, "{s}"),
47        }
48    }
49}
50
51impl std::error::Error for GuestFsError {
52    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
53        match self {
54            #[cfg(any(unix, feature = "beta-vm"))]
55            Self::Vm(e) => Some(e),
56            _ => None,
57        }
58    }
59}
60
61#[cfg(any(unix, feature = "beta-vm"))]
62impl From<VmError> for GuestFsError {
63    fn from(e: VmError) -> Self {
64        Self::Vm(e)
65    }
66}
67
68/// Operations on the **guest** filesystem (Mode P). Paths are **guest** absolute paths (e.g. `/workspace/foo`).
69pub trait GuestFsOps {
70    /// List **non-hidden** names in `guest_path` (like `ls -1A`).
71    fn list_dir(&mut self, guest_path: &str) -> Result<Vec<String>, GuestFsError>;
72
73    /// Read a file by guest path.
74    fn read_file(&mut self, guest_path: &str) -> Result<Vec<u8>, GuestFsError>;
75
76    /// Write or replace a file.
77    fn write_file(&mut self, guest_path: &str, data: &[u8]) -> Result<(), GuestFsError>;
78
79    /// Create a directory (and parents), like `mkdir -p`.
80    fn mkdir(&mut self, guest_path: &str) -> Result<(), GuestFsError>;
81
82    /// Remove a file or directory tree (`rm -rf` semantics on Lima; mock removes subtree).
83    fn remove(&mut self, guest_path: &str) -> Result<(), GuestFsError>;
84}
85
86// --- path helpers (shared) -------------------------------------------------
87
88/// Lexically normalize an absolute Unix-style path (no symlink resolution).
89#[must_use]
90pub fn normalize_guest_path(path: &str) -> Option<String> {
91    let mut stack: Vec<&str> = Vec::new();
92    for part in path.trim().split('/').filter(|s| !s.is_empty()) {
93        match part {
94            "." => {}
95            ".." => {
96                stack.pop();
97            }
98            p => stack.push(p),
99        }
100    }
101    if stack.is_empty() {
102        Some("/".to_string())
103    } else {
104        Some(format!("/{}", stack.join("/")))
105    }
106}
107
108/// Guest directory for the current logical cwd (γ layout: `guest_mount` + last segment of `logical_cwd`).
109/// Same rule as `guest_dir_for_cwd_inner` in `session_gamma` / push layout.
110#[must_use]
111pub fn guest_project_dir_on_guest(guest_mount: &str, logical_cwd: &str) -> String {
112    let trimmed = logical_cwd.trim_matches('/');
113    let base = guest_mount.trim_end_matches('/');
114    if trimmed.is_empty() {
115        base.to_string()
116    } else {
117        let last = trimmed.split('/').next_back().unwrap_or(".");
118        format!("{base}/{last}")
119    }
120}
121
122/// True if `path` is `mount` or a strict descendant (after normalization). `mount` is e.g. `/workspace`.
123#[must_use]
124pub fn guest_path_is_under_mount(mount: &str, path: &str) -> bool {
125    let Some(m) = normalize_guest_path(mount) else {
126        return false;
127    };
128    let Some(p) = normalize_guest_path(path) else {
129        return false;
130    };
131    let m = m.trim_end_matches('/').to_string();
132    p == m || p.starts_with(&format!("{m}/"))
133}
134
135// --- Mock (tests + future harness) -----------------------------------------
136
137#[derive(Debug, Clone)]
138enum MockNode {
139    File(Vec<u8>),
140    Dir,
141}
142
143/// In-memory [`GuestFsOps`] for unit tests (no VM).
144#[derive(Debug, Default)]
145pub struct MockGuestFsOps {
146    nodes: HashMap<String, MockNode>,
147}
148
149impl MockGuestFsOps {
150    #[must_use]
151    pub fn new() -> Self {
152        Self::default()
153    }
154
155    fn norm_key(path: &str) -> Result<String, GuestFsError> {
156        normalize_guest_path(path).ok_or_else(|| GuestFsError::InvalidPath(path.to_string()))
157    }
158
159    fn ensure_parent_dirs(&mut self, path: &str) -> Result<(), GuestFsError> {
160        let p = Path::new(path);
161        if let Some(parent) = p.parent() {
162            let parent_s = parent.to_string_lossy();
163            if parent_s.is_empty() || parent_s == "/" {
164                return Ok(());
165            }
166            let pk = Self::norm_key(&parent_s)?;
167            if !self.nodes.contains_key(&pk) {
168                self.mkdir(&pk)?;
169            }
170        }
171        Ok(())
172    }
173
174    fn direct_child_names(&self, dir: &str) -> Result<Vec<String>, GuestFsError> {
175        let d = Self::norm_key(dir)?;
176        if !matches!(self.nodes.get(&d), Some(MockNode::Dir)) {
177            return Err(GuestFsError::NotADirectory(d));
178        }
179        let prefix = if d == "/" {
180            "/".to_string()
181        } else {
182            format!("{d}/")
183        };
184        let mut names = std::collections::HashSet::new();
185        for key in self.nodes.keys() {
186            if key == &d {
187                continue;
188            }
189            if !key.starts_with(&prefix) {
190                continue;
191            }
192            let rest = &key[prefix.len()..];
193            if let Some(first) = rest.split('/').next() {
194                if !first.is_empty() {
195                    names.insert(first.to_string());
196                }
197            }
198        }
199        let mut v: Vec<String> = names.into_iter().collect();
200        v.sort();
201        Ok(v)
202    }
203}
204
205impl GuestFsOps for MockGuestFsOps {
206    fn list_dir(&mut self, guest_path: &str) -> Result<Vec<String>, GuestFsError> {
207        self.direct_child_names(guest_path)
208    }
209
210    fn read_file(&mut self, guest_path: &str) -> Result<Vec<u8>, GuestFsError> {
211        let k = Self::norm_key(guest_path)?;
212        match self.nodes.get(&k) {
213            Some(MockNode::File(b)) => Ok(b.clone()),
214            Some(MockNode::Dir) => Err(GuestFsError::IsADirectory(k)),
215            None => Err(GuestFsError::NotFound(k)),
216        }
217    }
218
219    fn write_file(&mut self, guest_path: &str, data: &[u8]) -> Result<(), GuestFsError> {
220        let k = Self::norm_key(guest_path)?;
221        self.ensure_parent_dirs(&k)?;
222        if matches!(self.nodes.get(&k), Some(MockNode::Dir)) {
223            return Err(GuestFsError::IsADirectory(k));
224        }
225        self.nodes.insert(k, MockNode::File(data.to_vec()));
226        Ok(())
227    }
228
229    fn mkdir(&mut self, guest_path: &str) -> Result<(), GuestFsError> {
230        let k = Self::norm_key(guest_path)?;
231        if matches!(self.nodes.get(&k), Some(MockNode::File(_))) {
232            return Err(GuestFsError::InvalidPath(format!(
233                "mkdir: file exists at {k}"
234            )));
235        }
236        if k == "/" {
237            self.nodes.entry("/".to_string()).or_insert(MockNode::Dir);
238            return Ok(());
239        }
240        let chunks: Vec<&str> = k.split('/').filter(|s| !s.is_empty()).collect();
241        let mut cur = String::new();
242        for (i, seg) in chunks.iter().enumerate() {
243            cur = if i == 0 {
244                format!("/{seg}")
245            } else {
246                format!("{cur}/{seg}")
247            };
248            if matches!(self.nodes.get(&cur), Some(MockNode::File(_))) {
249                return Err(GuestFsError::InvalidPath(format!(
250                    "mkdir: file in the way: {cur}"
251                )));
252            }
253            self.nodes.entry(cur.clone()).or_insert(MockNode::Dir);
254        }
255        Ok(())
256    }
257
258    fn remove(&mut self, guest_path: &str) -> Result<(), GuestFsError> {
259        let k = Self::norm_key(guest_path)?;
260        if !self.nodes.contains_key(&k) {
261            return Err(GuestFsError::NotFound(k));
262        }
263        let to_remove: Vec<String> = self
264            .nodes
265            .keys()
266            .filter(|key| *key == &k || key.starts_with(&format!("{k}/")))
267            .cloned()
268            .collect();
269        for key in to_remove {
270            self.nodes.remove(&key);
271        }
272        Ok(())
273    }
274}
275
276// --- Lima (Unix): γ [`GammaSession`] + `limactl shell` -----------------------
277
278/// Validate `guest_path` is absolute and under `mount` (γ/β shared).
279pub(crate) fn validate_guest_path_under_mount(
280    mount: &str,
281    guest_path: &str,
282) -> Result<String, GuestFsError> {
283    let Some(p) = normalize_guest_path(guest_path) else {
284        return Err(GuestFsError::InvalidPath(guest_path.to_string()));
285    };
286    if !guest_path_is_under_mount(mount, &p) {
287        return Err(GuestFsError::InvalidPath(format!(
288            "path not under guest mount {mount}: {p}"
289        )));
290    }
291    Ok(p)
292}
293
294#[cfg(unix)]
295fn gamma_validate_guest_path(g: &GammaSession, guest_path: &str) -> Result<String, GuestFsError> {
296    validate_guest_path_under_mount(g.guest_mount(), guest_path)
297}
298
299#[cfg(unix)]
300fn map_shell_output(out: std::process::Output) -> Result<Vec<u8>, GuestFsError> {
301    if out.status.success() {
302        return Ok(out.stdout);
303    }
304    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
305    Err(GuestFsError::GuestCommand {
306        status: out.status.code(),
307        stderr,
308    })
309}
310
311/// [`GuestFsOps`] on the live γ session (used by REPL dispatch in guest-primary mode).
312#[cfg(unix)]
313impl GuestFsOps for GammaSession {
314    fn list_dir(&mut self, guest_path: &str) -> Result<Vec<String>, GuestFsError> {
315        let p = gamma_validate_guest_path(self, guest_path)?;
316        let out = self.limactl_shell_output(&p, "ls", &["-1A".to_string()])?;
317        if !out.status.success() {
318            let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
319            return Err(GuestFsError::GuestCommand {
320                status: out.status.code(),
321                stderr,
322            });
323        }
324        let s = String::from_utf8_lossy(&out.stdout);
325        let names: Vec<String> = s
326            .lines()
327            .map(str::trim)
328            .filter(|line| !line.is_empty())
329            .map(std::string::ToString::to_string)
330            .collect();
331        Ok(names)
332    }
333
334    fn read_file(&mut self, guest_path: &str) -> Result<Vec<u8>, GuestFsError> {
335        let p = gamma_validate_guest_path(self, guest_path)?;
336        let path = Path::new(&p);
337        let parent = path
338            .parent()
339            .and_then(|x| x.to_str())
340            .filter(|s| !s.is_empty())
341            .unwrap_or("/");
342        let name = path
343            .file_name()
344            .and_then(|n| n.to_str())
345            .ok_or_else(|| GuestFsError::InvalidPath(p.clone()))?;
346        let out = self.limactl_shell_output(parent, "cat", &[name.to_string()])?;
347        map_shell_output(out)
348    }
349
350    fn write_file(&mut self, guest_path: &str, data: &[u8]) -> Result<(), GuestFsError> {
351        let p = gamma_validate_guest_path(self, guest_path)?;
352        let out = self.limactl_shell_stdin(
353            "/",
354            "dd",
355            &[
356                "if=/dev/stdin".to_string(),
357                "status=none".to_string(),
358                format!("of={p}"),
359            ],
360            data,
361        )?;
362        if out.status.success() {
363            Ok(())
364        } else {
365            let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
366            Err(GuestFsError::GuestCommand {
367                status: out.status.code(),
368                stderr,
369            })
370        }
371    }
372
373    fn mkdir(&mut self, guest_path: &str) -> Result<(), GuestFsError> {
374        let p = gamma_validate_guest_path(self, guest_path)?;
375        let out = self.limactl_shell_output("/", "mkdir", &["-p".to_string(), p])?;
376        if out.status.success() {
377            Ok(())
378        } else {
379            let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
380            Err(GuestFsError::GuestCommand {
381                status: out.status.code(),
382                stderr,
383            })
384        }
385    }
386
387    fn remove(&mut self, guest_path: &str) -> Result<(), GuestFsError> {
388        let p = gamma_validate_guest_path(self, guest_path)?;
389        let out = self.limactl_shell_output("/", "rm", &["-rf".to_string(), p])?;
390        if out.status.success() {
391            Ok(())
392        } else {
393            let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
394            Err(GuestFsError::GuestCommand {
395                status: out.status.code(),
396                stderr,
397            })
398        }
399    }
400}
401
402/// Owns a γ [`GammaSession`] for [`GuestFsOps`] tests and harnesses (delegates to [`GuestFsOps`] for [`GammaSession`]).
403#[cfg(unix)]
404pub struct LimaGuestFsOps {
405    session: GammaSession,
406}
407
408#[cfg(unix)]
409impl LimaGuestFsOps {
410    /// Build from VM config (does not start the VM until first operation).
411    ///
412    /// # Errors
413    /// Same as [`GammaSession::new`] (e.g. `limactl` missing).
414    pub fn new(config: &VmConfig) -> Result<Self, VmError> {
415        Ok(Self {
416            session: GammaSession::new(config)?,
417        })
418    }
419}
420
421#[cfg(unix)]
422impl GuestFsOps for LimaGuestFsOps {
423    fn list_dir(&mut self, guest_path: &str) -> Result<Vec<String>, GuestFsError> {
424        GuestFsOps::list_dir(&mut self.session, guest_path)
425    }
426
427    fn read_file(&mut self, guest_path: &str) -> Result<Vec<u8>, GuestFsError> {
428        GuestFsOps::read_file(&mut self.session, guest_path)
429    }
430
431    fn write_file(&mut self, guest_path: &str, data: &[u8]) -> Result<(), GuestFsError> {
432        GuestFsOps::write_file(&mut self.session, guest_path, data)
433    }
434
435    fn mkdir(&mut self, guest_path: &str) -> Result<(), GuestFsError> {
436        GuestFsOps::mkdir(&mut self.session, guest_path)
437    }
438
439    fn remove(&mut self, guest_path: &str) -> Result<(), GuestFsError> {
440        GuestFsOps::remove(&mut self.session, guest_path)
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn normalize_guest_path_dotdot() {
450        assert_eq!(normalize_guest_path("/a/b/../c").as_deref(), Some("/a/c"));
451        assert_eq!(normalize_guest_path("/").as_deref(), Some("/"));
452    }
453
454    #[test]
455    fn under_mount() {
456        assert!(guest_path_is_under_mount("/workspace", "/workspace/foo"));
457        assert!(!guest_path_is_under_mount("/workspace", "/etc/passwd"));
458        assert!(!guest_path_is_under_mount(
459            "/workspace",
460            "/workspace/../etc/passwd"
461        ));
462    }
463
464    #[test]
465    fn mock_mkdir_write_list_read_remove() {
466        let mut m = MockGuestFsOps::new();
467        m.mkdir("/workspace/p").unwrap();
468        m.write_file("/workspace/p/a.txt", b"hi").unwrap();
469        let names = m.list_dir("/workspace/p").unwrap();
470        assert!(names.contains(&"a.txt".to_string()));
471        assert_eq!(m.read_file("/workspace/p/a.txt").unwrap(), b"hi");
472        m.remove("/workspace/p").unwrap();
473        assert!(m.read_file("/workspace/p/a.txt").is_err());
474    }
475}