1use std::path::Path;
4use std::time::SystemTime;
5
6use crate::Host;
7use crate::path::PathError;
8
9#[derive(Debug, Clone)]
11pub struct FileRead {
12 pub contents: String,
14 pub stat: FileStat,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct FileStat {
21 pub len: u64,
23 pub modified: Option<SystemTime>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct DirEntry {
30 pub name: String,
32 pub is_dir: bool,
34}
35
36#[derive(Debug, thiserror::Error)]
38pub enum FsError {
39 #[error(transparent)]
41 Path(#[from] PathError),
42 #[error("{op} failed for {path}: {source}")]
44 Io {
45 op: &'static str,
47 path: String,
49 source: std::io::Error,
51 },
52}
53
54impl Host {
55 pub async fn read_file(&self, cwd: &Path, path: &Path) -> Result<FileRead, FsError> {
60 let resolved = self.resolve_in_jail(cwd, path).await?;
61 let bytes = tokio::fs::read(&resolved)
62 .await
63 .map_err(|source| FsError::Io {
64 op: "read",
65 path: resolved.display().to_string(),
66 source,
67 })?;
68 let contents = String::from_utf8_lossy(&bytes).into_owned();
69 let stat = self.stat_resolved(&resolved).await?;
70 Ok(FileRead { contents, stat })
71 }
72
73 pub async fn write_file(
81 &self,
82 cwd: &Path,
83 path: &Path,
84 contents: &str,
85 ) -> Result<FileStat, FsError> {
86 let resolved = self.resolve_in_jail(cwd, path).await?;
87 tokio::fs::write(&resolved, contents)
88 .await
89 .map_err(|source| FsError::Io {
90 op: "write",
91 path: resolved.display().to_string(),
92 source,
93 })?;
94 self.stat_resolved(&resolved).await
95 }
96
97 pub async fn create_dir(
113 &self,
114 cwd: &Path,
115 path: &Path,
116 parents: bool,
117 exist_ok: bool,
118 ) -> Result<(), FsError> {
119 let resolved = self.resolve_in_jail(cwd, path).await?;
120 let already_exists = tokio::fs::metadata(&resolved)
121 .await
122 .is_ok_and(|m| m.is_dir());
123 if already_exists {
124 if exist_ok {
125 return Ok(());
126 }
127 return Err(FsError::Io {
128 op: "create_dir",
129 path: resolved.display().to_string(),
130 source: std::io::Error::new(
131 std::io::ErrorKind::AlreadyExists,
132 "directory already exists",
133 ),
134 });
135 }
136 let result = if parents {
137 tokio::fs::create_dir_all(&resolved).await
138 } else {
139 tokio::fs::create_dir(&resolved).await
140 };
141 result.map_err(|source| FsError::Io {
142 op: "create_dir",
143 path: resolved.display().to_string(),
144 source,
145 })
146 }
147
148 pub async fn remove_file(&self, cwd: &Path, path: &Path) -> Result<(), FsError> {
157 let resolved = self.resolve_in_jail(cwd, path).await?;
158 tokio::fs::remove_file(&resolved)
159 .await
160 .map_err(|source| FsError::Io {
161 op: "remove",
162 path: resolved.display().to_string(),
163 source,
164 })
165 }
166
167 pub async fn stat(&self, cwd: &Path, path: &Path) -> Result<FileStat, FsError> {
172 let resolved = self.resolve_in_jail(cwd, path).await?;
173 self.stat_resolved(&resolved).await
174 }
175
176 pub async fn read_dir(&self, cwd: &Path, path: &Path) -> Result<Vec<DirEntry>, FsError> {
182 let resolved = self.resolve_in_jail(cwd, path).await?;
183 let mut reader = tokio::fs::read_dir(&resolved)
184 .await
185 .map_err(|source| FsError::Io {
186 op: "read_dir",
187 path: resolved.display().to_string(),
188 source,
189 })?;
190 let mut entries = Vec::new();
191 while let Some(entry) = reader.next_entry().await.map_err(|source| FsError::Io {
192 op: "read_dir",
193 path: resolved.display().to_string(),
194 source,
195 })? {
196 let is_dir = entry.file_type().await.is_ok_and(|t| t.is_dir());
197 entries.push(DirEntry {
198 name: entry.file_name().to_string_lossy().into_owned(),
199 is_dir,
200 });
201 }
202 Ok(entries)
203 }
204
205 async fn stat_resolved(&self, resolved: &Path) -> Result<FileStat, FsError> {
206 let metadata = tokio::fs::metadata(resolved)
207 .await
208 .map_err(|source| FsError::Io {
209 op: "stat",
210 path: resolved.display().to_string(),
211 source,
212 })?;
213 Ok(FileStat {
214 len: metadata.len(),
215 modified: metadata.modified().ok(),
216 })
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::{PathPolicy, test_host};
224 use tempfile::tempdir;
225
226 #[tokio::test]
227 async fn write_then_read_roundtrip_in_jail() {
228 let dir = tempdir().unwrap();
229 let host = test_host(dir.path(), PathPolicy::Jailed, false);
230 let root = host.workspace_root().to_path_buf();
231
232 let stat = host
233 .write_file(&root, Path::new("a.txt"), "hello")
234 .await
235 .unwrap();
236 assert_eq!(stat.len, 5);
237
238 let read = host.read_file(&root, Path::new("a.txt")).await.unwrap();
239 assert_eq!(read.contents, "hello");
240 assert!(read.stat.modified.is_some());
241 }
242
243 #[tokio::test]
244 async fn read_outside_jail_is_a_path_error() {
245 let dir = tempdir().unwrap();
246 let host = test_host(dir.path(), PathPolicy::Jailed, false);
247 let root = host.workspace_root().to_path_buf();
248
249 let err = host
250 .read_file(&root, Path::new("/etc/passwd"))
251 .await
252 .expect_err("jail rejection");
253 assert!(matches!(err, FsError::Path(_)));
254 }
255
256 #[tokio::test]
257 async fn create_dir_makes_parents_and_is_exist_ok() {
258 let dir = tempdir().unwrap();
259 let host = test_host(dir.path(), PathPolicy::Jailed, false);
260 let root = host.workspace_root().to_path_buf();
261
262 host.create_dir(&root, Path::new("a/b/c"), true, true)
264 .await
265 .unwrap();
266 assert!(root.join("a/b/c").is_dir());
267 host.create_dir(&root, Path::new("a/b/c"), true, true)
269 .await
270 .unwrap();
271 let err = host
273 .create_dir(&root, Path::new("a/b/c"), true, false)
274 .await
275 .expect_err("existing dir with exist_ok=false");
276 assert!(matches!(err, FsError::Io { .. }));
277 let err = host
279 .create_dir(&root, Path::new("x/y/z"), false, true)
280 .await
281 .expect_err("missing parent with parents=false");
282 assert!(matches!(err, FsError::Io { .. }));
283 host.create_dir(&root, Path::new("solo"), false, true)
285 .await
286 .unwrap();
287 assert!(root.join("solo").is_dir());
288 }
289
290 #[tokio::test]
291 async fn create_dir_outside_jail_is_a_path_error() {
292 let dir = tempdir().unwrap();
293 let host = test_host(dir.path(), PathPolicy::Jailed, false);
294 let root = host.workspace_root().to_path_buf();
295 let err = host
296 .create_dir(&root, Path::new("/tmp/escape"), true, true)
297 .await
298 .expect_err("jail rejection");
299 assert!(matches!(err, FsError::Path(_)));
300 }
301}