xtask_todo_lib/devshell/vfs/
mod.rs1use std::path::Path;
4
5#[derive(Debug)]
7pub enum VfsError {
8 InvalidPath,
9 Io(std::io::Error),
10}
11
12impl std::fmt::Display for VfsError {
13 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14 match self {
15 Self::InvalidPath => f.write_str("invalid path"),
16 Self::Io(e) => write!(f, "io: {e}"),
17 }
18 }
19}
20
21impl std::error::Error for VfsError {
22 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
23 match self {
24 Self::Io(e) => Some(e),
25 Self::InvalidPath => None,
26 }
27 }
28}
29
30#[derive(Debug, Clone, PartialEq)]
31pub enum Node {
32 Dir { name: String, children: Vec<Self> },
33 File { name: String, content: Vec<u8> },
34}
35
36impl Node {
37 #[must_use]
38 pub fn name(&self) -> &str {
39 match self {
40 Self::Dir { name, .. } | Self::File { name, .. } => name,
41 }
42 }
43 #[must_use]
44 pub const fn is_dir(&self) -> bool {
45 matches!(self, Self::Dir { .. })
46 }
47 #[must_use]
48 pub const fn is_file(&self) -> bool {
49 matches!(self, Self::File { .. })
50 }
51
52 #[must_use]
54 pub fn child(&self, name: &str) -> Option<&Self> {
55 match self {
56 Self::Dir { children, .. } => children.iter().find(|c| c.name() == name),
57 Self::File { .. } => None,
58 }
59 }
60}
61
62pub struct Vfs {
63 root: Node,
64 cwd: String,
65}
66
67impl Default for Vfs {
68 fn default() -> Self {
69 Self::new()
70 }
71}
72
73impl Vfs {
74 #[must_use]
75 pub fn new() -> Self {
76 Self {
77 root: Node::Dir {
78 name: String::new(),
79 children: vec![],
80 },
81 cwd: "/".to_string(),
82 }
83 }
84
85 #[must_use]
87 pub const fn from_parts(root: Node, cwd: String) -> Self {
88 Self { root, cwd }
89 }
90 #[must_use]
91 pub fn cwd(&self) -> &str {
92 &self.cwd
93 }
94 #[must_use]
95 pub const fn root(&self) -> &Node {
96 &self.root
97 }
98
99 pub fn resolve_absolute(&self, path: &str) -> Result<Node, VfsError> {
104 let path = path.trim_end_matches('/');
105 if path.is_empty() || path == "/" {
106 return Ok(self.root.clone());
107 }
108 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
109 let mut current = &self.root;
110 for segment in segments {
111 current = current.child(segment).ok_or(VfsError::InvalidPath)?;
112 }
113 Ok(current.clone())
114 }
115
116 #[must_use]
119 pub fn resolve_to_absolute(&self, path: &str) -> String {
120 let path = path.trim();
121 let path_normalized = normalize_path(path);
122 if path_normalized.starts_with('/') {
124 return path_normalized;
125 }
126 if path_normalized == "/" {
127 return self.cwd.clone();
128 }
129 let base = self.cwd.trim_end_matches('/');
131 let p = path.trim_start_matches('/');
132 let combined = if base.is_empty() {
133 format!("/{p}")
134 } else {
135 format!("{base}/{p}")
136 };
137 let result = normalize_path(&combined);
138 if result.is_empty() || result == "." {
139 "/".to_string()
140 } else if result.starts_with('/') {
141 result
142 } else {
143 format!("/{result}")
144 }
145 }
146
147 pub fn mkdir(&mut self, path: &str) -> Result<(), VfsError> {
152 let abs = self.resolve_to_absolute(path);
153 let segments: Vec<&str> = abs.split('/').filter(|s| !s.is_empty()).collect();
154 if segments.is_empty() {
155 return Ok(());
156 }
157 let mut indices: Vec<usize> = vec![];
158 for segment in segments {
159 let current = Self::get_mut_at(&mut self.root, &indices);
160 match current {
161 Node::Dir { children, .. } => {
162 let pos = children.iter().position(|c| c.name() == segment);
163 if let Some(i) = pos {
164 if !children[i].is_dir() {
165 return Err(VfsError::InvalidPath);
166 }
167 indices.push(i);
168 } else {
169 children.push(Node::Dir {
170 name: segment.to_string(),
171 children: vec![],
172 });
173 indices.push(children.len() - 1);
174 }
175 }
176 Node::File { .. } => return Err(VfsError::InvalidPath),
177 }
178 }
179 Ok(())
180 }
181
182 pub fn write_file(&mut self, path: &str, content: &[u8]) -> Result<(), VfsError> {
187 let abs = self.resolve_to_absolute(path);
188 let segments: Vec<&str> = abs.split('/').filter(|s| !s.is_empty()).collect();
189 let (parent_segments, file_name) = match segments.split_last() {
190 Some((last, rest)) => (rest, *last),
191 None => return Err(VfsError::InvalidPath), };
193 let mut indices: Vec<usize> = vec![];
194 for segment in parent_segments {
195 let current = Self::get_mut_at(&mut self.root, &indices);
196 match current {
197 Node::Dir { children, .. } => {
198 let pos = children.iter().position(|c| c.name() == *segment);
199 match pos {
200 Some(i) => {
201 if !children[i].is_dir() {
202 return Err(VfsError::InvalidPath);
203 }
204 indices.push(i);
205 }
206 None => return Err(VfsError::InvalidPath), }
208 }
209 Node::File { .. } => return Err(VfsError::InvalidPath),
210 }
211 }
212 let parent = Self::get_mut_at(&mut self.root, &indices);
213 match parent {
214 Node::Dir { children, .. } => {
215 let pos = children.iter().position(|c| c.name() == file_name);
216 let node = Node::File {
217 name: file_name.to_string(),
218 content: content.to_vec(),
219 };
220 match pos {
221 Some(i) => children[i] = node,
222 None => children.push(node),
223 }
224 Ok(())
225 }
226 Node::File { .. } => Err(VfsError::InvalidPath),
227 }
228 }
229
230 pub fn touch(&mut self, path: &str) -> Result<(), VfsError> {
235 self.write_file(path, &[])
236 }
237
238 pub fn read_file(&self, path: &str) -> Result<Vec<u8>, VfsError> {
243 let abs = self.resolve_to_absolute(path);
244 let n = self.resolve_absolute(&abs)?;
245 match n {
246 Node::File { content, .. } => Ok(content),
247 Node::Dir { .. } => Err(VfsError::InvalidPath),
248 }
249 }
250
251 pub fn list_dir(&self, path: &str) -> Result<Vec<String>, VfsError> {
256 let abs = self.resolve_to_absolute(path);
257 let n = self.resolve_absolute(&abs)?;
258 match n {
259 Node::Dir { children, .. } => {
260 Ok(children.iter().map(|c| c.name().to_string()).collect())
261 }
262 Node::File { .. } => Err(VfsError::InvalidPath),
263 }
264 }
265
266 pub fn set_cwd(&mut self, path: &str) -> Result<(), VfsError> {
271 let abs = self.resolve_to_absolute(path);
272 let n = self.resolve_absolute(&abs)?;
273 if !n.is_dir() {
274 return Err(VfsError::InvalidPath);
275 }
276 self.cwd = if abs == "/" { "/".to_string() } else { abs };
277 Ok(())
278 }
279
280 fn get_mut_at<'a>(node: &'a mut Node, path: &[usize]) -> &'a mut Node {
282 if path.is_empty() {
283 return node;
284 }
285 match node {
286 Node::Dir { children, .. } => {
287 let i = path[0];
288 Self::get_mut_at(&mut children[i], &path[1..])
289 }
290 Node::File { .. } => unreachable!("path must follow dirs only"),
291 }
292 }
293
294 pub fn copy_tree_to_host(&self, vfs_path: &str, host_dir: &Path) -> Result<(), VfsError> {
300 let abs = self.resolve_to_absolute(vfs_path);
301 let node = self.resolve_absolute(&abs)?;
302 copy_node_to_host(&node, host_dir)
303 }
304}
305
306fn is_safe_component(name: &str) -> bool {
308 !name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')
309}
310
311fn copy_node_to_host(node: &Node, host_path: &Path) -> Result<(), VfsError> {
313 match node {
314 Node::Dir { name, children } => {
315 let dir_path = if name.is_empty() {
316 host_path.to_path_buf()
317 } else {
318 if !is_safe_component(name) {
319 return Err(VfsError::InvalidPath);
320 }
321 host_path.join(name)
322 };
323 std::fs::create_dir_all(&dir_path).map_err(VfsError::Io)?;
324 for child in children {
325 copy_node_to_host(child, &dir_path)?;
326 }
327 Ok(())
328 }
329 Node::File { name, content } => {
330 if !is_safe_component(name) {
331 return Err(VfsError::InvalidPath);
332 }
333 let file_path = host_path.join(name);
334 std::fs::write(&file_path, content).map_err(VfsError::Io)?;
335 Ok(())
336 }
337 }
338}
339
340#[must_use]
343pub fn normalize_path(input: &str) -> String {
344 let s = input.replace('\\', "/");
345
346 let (rest, absolute) = if s.len() >= 2
348 && s.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
349 && s.chars().nth(1) == Some(':')
350 {
351 (&s[2..], true)
352 } else {
353 (s.as_str(), s.starts_with('/'))
354 };
355
356 let rest = rest.trim_start_matches('/');
357 let mut out: Vec<&str> = Vec::new();
358 for p in rest.split('/') {
359 match p {
360 "" | "." => {}
361 ".." => {
362 out.pop();
363 }
364 _ => out.push(p),
365 }
366 }
367
368 if absolute {
369 "/".to_string() + &out.join("/")
370 } else if out.is_empty() {
371 ".".to_string()
372 } else {
373 out.join("/")
374 }
375}
376
377#[cfg(test)]
378mod tests;