xtask_todo_lib/devshell/vm/
guest_fs_ops.rs1use 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#[derive(Debug)]
16pub enum GuestFsError {
17 InvalidPath(String),
19 NotFound(String),
21 NotADirectory(String),
23 IsADirectory(String),
25 GuestCommand { status: Option<i32>, stderr: String },
27 #[cfg(any(unix, feature = "beta-vm"))]
29 Vm(VmError),
30 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
66pub trait GuestFsOps {
68 fn list_dir(&mut self, guest_path: &str) -> Result<Vec<String>, GuestFsError>;
73
74 fn read_file(&mut self, guest_path: &str) -> Result<Vec<u8>, GuestFsError>;
79
80 fn write_file(&mut self, guest_path: &str, data: &[u8]) -> Result<(), GuestFsError>;
85
86 fn mkdir(&mut self, guest_path: &str) -> Result<(), GuestFsError>;
91
92 fn remove(&mut self, guest_path: &str) -> Result<(), GuestFsError>;
97}
98
99#[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#[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#[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#[derive(Debug, Clone)]
151enum MockNode {
152 File(Vec<u8>),
153 Dir,
154}
155
156#[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
289pub 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#[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#[cfg(unix)]
417pub struct LimaGuestFsOps {
418 session: GammaSession,
419}
420
421#[cfg(unix)]
422impl LimaGuestFsOps {
423 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}