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