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    /// Stat a file (jail-resolved).
98    ///
99    /// # Errors
100    /// [`FsError::Path`] if the path escapes the jail; [`FsError::Io`] if the stat fails.
101    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    /// List a directory's immediate entries (jail-resolved), unsorted.
107    ///
108    /// # Errors
109    /// [`FsError::Path`] if the path escapes the jail; [`FsError::Io`] if it is not a
110    /// readable directory.
111    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}