use std::path::Path;
use std::time::SystemTime;
use crate::Host;
use crate::path::PathError;
#[derive(Debug, Clone)]
pub struct FileRead {
pub contents: String,
pub stat: FileStat,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileStat {
pub len: u64,
pub modified: Option<SystemTime>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirEntry {
pub name: String,
pub is_dir: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum FsError {
#[error(transparent)]
Path(#[from] PathError),
#[error("{op} failed for {path}: {source}")]
Io {
op: &'static str,
path: String,
source: std::io::Error,
},
}
impl Host {
pub async fn read_file(&self, cwd: &Path, path: &Path) -> Result<FileRead, FsError> {
let resolved = self.resolve_in_jail(cwd, path).await?;
let bytes = tokio::fs::read(&resolved)
.await
.map_err(|source| FsError::Io {
op: "read",
path: resolved.display().to_string(),
source,
})?;
let contents = String::from_utf8_lossy(&bytes).into_owned();
let stat = self.stat_resolved(&resolved).await?;
Ok(FileRead { contents, stat })
}
pub async fn write_file(
&self,
cwd: &Path,
path: &Path,
contents: &str,
) -> Result<FileStat, FsError> {
let resolved = self.resolve_in_jail(cwd, path).await?;
tokio::fs::write(&resolved, contents)
.await
.map_err(|source| FsError::Io {
op: "write",
path: resolved.display().to_string(),
source,
})?;
self.stat_resolved(&resolved).await
}
pub async fn stat(&self, cwd: &Path, path: &Path) -> Result<FileStat, FsError> {
let resolved = self.resolve_in_jail(cwd, path).await?;
self.stat_resolved(&resolved).await
}
pub async fn read_dir(&self, cwd: &Path, path: &Path) -> Result<Vec<DirEntry>, FsError> {
let resolved = self.resolve_in_jail(cwd, path).await?;
let mut reader = tokio::fs::read_dir(&resolved)
.await
.map_err(|source| FsError::Io {
op: "read_dir",
path: resolved.display().to_string(),
source,
})?;
let mut entries = Vec::new();
while let Some(entry) = reader.next_entry().await.map_err(|source| FsError::Io {
op: "read_dir",
path: resolved.display().to_string(),
source,
})? {
let is_dir = entry.file_type().await.is_ok_and(|t| t.is_dir());
entries.push(DirEntry {
name: entry.file_name().to_string_lossy().into_owned(),
is_dir,
});
}
Ok(entries)
}
async fn stat_resolved(&self, resolved: &Path) -> Result<FileStat, FsError> {
let metadata = tokio::fs::metadata(resolved)
.await
.map_err(|source| FsError::Io {
op: "stat",
path: resolved.display().to_string(),
source,
})?;
Ok(FileStat {
len: metadata.len(),
modified: metadata.modified().ok(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{PathPolicy, test_host};
use tempfile::tempdir;
#[tokio::test]
async fn write_then_read_roundtrip_in_jail() {
let dir = tempdir().unwrap();
let host = test_host(dir.path(), PathPolicy::Jailed, false);
let root = host.workspace_root().to_path_buf();
let stat = host
.write_file(&root, Path::new("a.txt"), "hello")
.await
.unwrap();
assert_eq!(stat.len, 5);
let read = host.read_file(&root, Path::new("a.txt")).await.unwrap();
assert_eq!(read.contents, "hello");
assert!(read.stat.modified.is_some());
}
#[tokio::test]
async fn read_outside_jail_is_a_path_error() {
let dir = tempdir().unwrap();
let host = test_host(dir.path(), PathPolicy::Jailed, false);
let root = host.workspace_root().to_path_buf();
let err = host
.read_file(&root, Path::new("/etc/passwd"))
.await
.expect_err("jail rejection");
assert!(matches!(err, FsError::Path(_)));
}
}