Skip to main content

locode_host/
fs.rs

1//! Jailed filesystem helpers: read / write / stat, all resolving through the jail first.
2
3use std::path::Path;
4use std::time::SystemTime;
5
6use crate::Host;
7use crate::path::PathError;
8
9/// A file's contents plus its stat (the stat is the freshness token edits compare).
10#[derive(Debug, Clone)]
11pub struct FileRead {
12    /// The file contents, lossy UTF-8 (binary files degrade rather than error).
13    pub contents: String,
14    /// The file's size + mtime at read time.
15    pub stat: FileStat,
16}
17
18/// A file's size and last-modified time.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct FileStat {
21    /// Length in bytes.
22    pub len: u64,
23    /// Last-modified time, if the platform reports it (the edit-freshness token).
24    pub modified: Option<SystemTime>,
25}
26
27/// One entry in a directory listing.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct DirEntry {
30    /// The entry's file name (not a full path).
31    pub name: String,
32    /// Whether the entry is a directory.
33    pub is_dir: bool,
34}
35
36/// A filesystem operation failure.
37#[derive(Debug, thiserror::Error)]
38pub enum FsError {
39    /// The path was rejected by the jail.
40    #[error(transparent)]
41    Path(#[from] PathError),
42    /// An IO error performing `op`.
43    #[error("{op} failed for {path}: {source}")]
44    Io {
45        /// The operation that failed (`read`/`write`/`stat`).
46        op: &'static str,
47        /// The path involved.
48        path: String,
49        /// The underlying IO error.
50        source: std::io::Error,
51    },
52}
53
54impl Host {
55    /// Read a file (jail-resolved), returning its lossy-UTF-8 contents + stat.
56    ///
57    /// # Errors
58    /// [`FsError::Path`] if the path escapes the jail; [`FsError::Io`] if the read fails.
59    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    /// Create-or-overwrite a file (jail-resolved), returning its post-write stat.
74    ///
75    /// Does **not** auto-create parent directories (a mistyped nested path silently
76    /// creating dirs is a footgun); a missing parent surfaces as an IO error.
77    ///
78    /// # Errors
79    /// [`FsError::Path`] if the path escapes the jail; [`FsError::Io`] if the write fails.
80    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    /// Create a directory (jail-resolved) — an explicit, opt-in `mkdir`, separate
98    /// from [`Host::write_file`], which deliberately never auto-creates dirs. A
99    /// pack calls this when it wants a harness's real "create the parent dir on
100    /// write" behavior (e.g. Claude Code's `Edit`/`Write`), without changing the
101    /// footgun-avoidance default for everyone else.
102    ///
103    /// - `parents` — create missing intermediate dirs (`mkdir -p`); when `false`,
104    ///   a missing parent is an error.
105    /// - `exist_ok` — succeed if the target dir already exists; when `false`, an
106    ///   existing target is an error (`mkdir` without `-p`'s tolerance).
107    ///
108    /// # Errors
109    /// [`FsError::Path`] if the path escapes the jail; [`FsError::Io`] on a missing
110    /// parent (when `!parents`), an existing target (when `!exist_ok`), or any other
111    /// IO failure.
112    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    /// Remove a single file (jail-resolved). Never recursive — a directory target
149    /// surfaces as an IO error (codex's `apply_patch` refuses to delete a directory).
150    /// The explicit delete seam for a pack that ports a real "delete file" behavior
151    /// (codex `apply_patch`'s `*** Delete File:` and the source side of `*** Move to:`).
152    ///
153    /// # Errors
154    /// [`FsError::Path`] if the path escapes the jail; [`FsError::Io`] if the removal
155    /// fails (missing file, a directory target, or any other IO failure).
156    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    /// Stat a file (jail-resolved).
168    ///
169    /// # Errors
170    /// [`FsError::Path`] if the path escapes the jail; [`FsError::Io`] if the stat fails.
171    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    /// List a directory's immediate entries (jail-resolved), unsorted.
177    ///
178    /// # Errors
179    /// [`FsError::Path`] if the path escapes the jail; [`FsError::Io`] if it is not a
180    /// readable directory.
181    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        // mkdir -p: intermediate dirs created.
263        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        // exist_ok = true: creating it again succeeds.
268        host.create_dir(&root, Path::new("a/b/c"), true, true)
269            .await
270            .unwrap();
271        // exist_ok = false on an existing dir errors.
272        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        // parents = false with a missing parent errors.
278        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        // A new dir directly under root, no parents needed.
284        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}