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 stat(&self, cwd: &Path, path: &Path) -> Result<FileStat, FsError> {
102 let resolved = self.resolve_in_jail(cwd, path).await?;
103 self.stat_resolved(&resolved).await
104 }
105
106 pub async fn read_dir(&self, cwd: &Path, path: &Path) -> Result<Vec<DirEntry>, FsError> {
112 let resolved = self.resolve_in_jail(cwd, path).await?;
113 let mut reader = tokio::fs::read_dir(&resolved)
114 .await
115 .map_err(|source| FsError::Io {
116 op: "read_dir",
117 path: resolved.display().to_string(),
118 source,
119 })?;
120 let mut entries = Vec::new();
121 while let Some(entry) = reader.next_entry().await.map_err(|source| FsError::Io {
122 op: "read_dir",
123 path: resolved.display().to_string(),
124 source,
125 })? {
126 let is_dir = entry.file_type().await.is_ok_and(|t| t.is_dir());
127 entries.push(DirEntry {
128 name: entry.file_name().to_string_lossy().into_owned(),
129 is_dir,
130 });
131 }
132 Ok(entries)
133 }
134
135 async fn stat_resolved(&self, resolved: &Path) -> Result<FileStat, FsError> {
136 let metadata = tokio::fs::metadata(resolved)
137 .await
138 .map_err(|source| FsError::Io {
139 op: "stat",
140 path: resolved.display().to_string(),
141 source,
142 })?;
143 Ok(FileStat {
144 len: metadata.len(),
145 modified: metadata.modified().ok(),
146 })
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use crate::{PathPolicy, test_host};
154 use tempfile::tempdir;
155
156 #[tokio::test]
157 async fn write_then_read_roundtrip_in_jail() {
158 let dir = tempdir().unwrap();
159 let host = test_host(dir.path(), PathPolicy::Jailed, false);
160 let root = host.workspace_root().to_path_buf();
161
162 let stat = host
163 .write_file(&root, Path::new("a.txt"), "hello")
164 .await
165 .unwrap();
166 assert_eq!(stat.len, 5);
167
168 let read = host.read_file(&root, Path::new("a.txt")).await.unwrap();
169 assert_eq!(read.contents, "hello");
170 assert!(read.stat.modified.is_some());
171 }
172
173 #[tokio::test]
174 async fn read_outside_jail_is_a_path_error() {
175 let dir = tempdir().unwrap();
176 let host = test_host(dir.path(), PathPolicy::Jailed, false);
177 let root = host.workspace_root().to_path_buf();
178
179 let err = host
180 .read_file(&root, Path::new("/etc/passwd"))
181 .await
182 .expect_err("jail rejection");
183 assert!(matches!(err, FsError::Path(_)));
184 }
185}