xtask_todo_lib/devshell/vm/
guest_fs_ops.rs1#![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#[derive(Debug)]
18pub enum GuestFsError {
19 InvalidPath(String),
21 NotFound(String),
23 NotADirectory(String),
25 IsADirectory(String),
27 GuestCommand { status: Option<i32>, stderr: String },
29 #[cfg(any(unix, feature = "beta-vm"))]
31 Vm(VmError),
32 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
68pub trait GuestFsOps {
70 fn list_dir(&mut self, guest_path: &str) -> Result<Vec<String>, GuestFsError>;
72
73 fn read_file(&mut self, guest_path: &str) -> Result<Vec<u8>, GuestFsError>;
75
76 fn write_file(&mut self, guest_path: &str, data: &[u8]) -> Result<(), GuestFsError>;
78
79 fn mkdir(&mut self, guest_path: &str) -> Result<(), GuestFsError>;
81
82 fn remove(&mut self, guest_path: &str) -> Result<(), GuestFsError>;
84}
85
86#[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#[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#[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#[derive(Debug, Clone)]
138enum MockNode {
139 File(Vec<u8>),
140 Dir,
141}
142
143#[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
276pub(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#[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#[cfg(unix)]
404pub struct LimaGuestFsOps {
405 session: GammaSession,
406}
407
408#[cfg(unix)]
409impl LimaGuestFsOps {
410 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}