origin_workspace_fs/
lib.rs1use async_trait::async_trait;
9use origin_domain::{AppError, Result};
10use origin_platform::{DirEntry, RelPath, WorkspaceFs, WorkspaceRoot};
11
12#[derive(Debug, Clone, Copy, Default)]
19pub struct StdWorkspaceFs;
20
21impl StdWorkspaceFs {
22 pub fn new() -> Self {
23 Self
24 }
25}
26
27fn resolve(root: &WorkspaceRoot, path: &RelPath) -> Result<std::path::PathBuf> {
33 let canonical_root = std::fs::canonicalize(root.as_path()).map_err(|error| {
34 AppError::storage(format!(
35 "cannot resolve workspace root {}: {error}",
36 root.as_path().display()
37 ))
38 })?;
39
40 let joined = root.as_path().join(path.as_path());
41 let canonical = std::fs::canonicalize(&joined).map_err(|error| {
42 AppError::storage(format!("cannot resolve {}: {error}", joined.display()))
43 })?;
44
45 if !canonical.starts_with(&canonical_root) {
46 return Err(AppError::Permission(format!(
47 "{} escapes the workspace root",
48 path.as_path().display()
49 )));
50 }
51
52 Ok(canonical)
53}
54
55#[async_trait]
56impl WorkspaceFs for StdWorkspaceFs {
57 async fn list_dir(&self, root: &WorkspaceRoot, path: &RelPath) -> Result<Vec<DirEntry>> {
58 let directory = resolve(root, path)?;
59
60 let mut reader = tokio::fs::read_dir(&directory).await.map_err(|error| {
61 AppError::storage(format!("cannot read {}: {error}", directory.display()))
62 })?;
63
64 let mut entries = Vec::new();
65 while let Some(entry) = reader.next_entry().await.map_err(|error| {
66 AppError::storage(format!("cannot read {}: {error}", directory.display()))
67 })? {
68 let file_type = entry.file_type().await.map_err(|error| {
69 AppError::storage(format!("cannot stat {}: {error}", entry.path().display()))
70 })?;
71
72 entries.push(DirEntry {
73 name: entry.file_name().to_string_lossy().into_owned(),
74 is_dir: file_type.is_dir(),
75 });
76 }
77
78 entries.sort_by(|a, b| a.name.cmp(&b.name));
81 Ok(entries)
82 }
83
84 async fn read_file(&self, root: &WorkspaceRoot, path: &RelPath) -> Result<Vec<u8>> {
85 let file = resolve(root, path)?;
86
87 tokio::fs::read(&file)
88 .await
89 .map_err(|error| AppError::storage(format!("cannot read {}: {error}", file.display())))
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96 use std::path::PathBuf;
97
98 fn temp_root(name: &str) -> PathBuf {
99 let dir =
100 std::env::temp_dir().join(format!("origin-workspace-fs-{}-{name}", std::process::id()));
101 let _ = std::fs::remove_dir_all(&dir);
102 std::fs::create_dir_all(&dir).unwrap();
103 dir
104 }
105
106 #[tokio::test]
107 async fn it_reads_and_lists_within_the_root() {
108 let root_path = temp_root("read");
109 std::fs::write(root_path.join("a.txt"), b"alpha").unwrap();
110 std::fs::create_dir(root_path.join("sub")).unwrap();
111 std::fs::write(root_path.join("sub").join("b.txt"), b"beta").unwrap();
112
113 let root = WorkspaceRoot::new(root_path.clone()).unwrap();
114 let fs = StdWorkspaceFs::new();
115
116 let contents = fs
117 .read_file(&root, &RelPath::new("a.txt").unwrap())
118 .await
119 .unwrap();
120 assert_eq!(contents, b"alpha");
121
122 let entries = fs
123 .list_dir(&root, &RelPath::new(".").unwrap())
124 .await
125 .unwrap();
126 let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
127 assert_eq!(names, vec!["a.txt", "sub"]);
128 assert!(entries.iter().any(|e| e.name == "sub" && e.is_dir));
129
130 std::fs::remove_dir_all(&root_path).ok();
131 }
132
133 #[tokio::test]
134 async fn a_symlink_escaping_the_root_is_refused() {
135 let root_path = temp_root("symlink-root");
136 let outside = temp_root("symlink-outside");
137 let secret = outside.join("secret.txt");
138 std::fs::write(&secret, b"do not read").unwrap();
139
140 let link = root_path.join("escape.txt");
142 #[cfg(unix)]
143 std::os::unix::fs::symlink(&secret, &link).unwrap();
144 #[cfg(not(unix))]
145 {
146 std::fs::remove_dir_all(&root_path).ok();
148 std::fs::remove_dir_all(&outside).ok();
149 return;
150 }
151
152 let root = WorkspaceRoot::new(root_path.clone()).unwrap();
153 let fs = StdWorkspaceFs::new();
154
155 let error = fs
156 .read_file(&root, &RelPath::new("escape.txt").unwrap())
157 .await
158 .unwrap_err();
159
160 assert_eq!(
161 error.kind(),
162 origin_domain::ErrorKind::Permission,
163 "a symlink out of the root must be a permission error, got: {error}"
164 );
165
166 std::fs::remove_dir_all(&root_path).ok();
167 std::fs::remove_dir_all(&outside).ok();
168 }
169}