use async_trait::async_trait;
use origin_domain::{AppError, Result};
use origin_platform::{DirEntry, RelPath, WorkspaceFs, WorkspaceRoot};
#[derive(Debug, Clone, Copy, Default)]
pub struct StdWorkspaceFs;
impl StdWorkspaceFs {
pub fn new() -> Self {
Self
}
}
fn resolve(root: &WorkspaceRoot, path: &RelPath) -> Result<std::path::PathBuf> {
let canonical_root = std::fs::canonicalize(root.as_path()).map_err(|error| {
AppError::storage(format!(
"cannot resolve workspace root {}: {error}",
root.as_path().display()
))
})?;
let joined = root.as_path().join(path.as_path());
let canonical = std::fs::canonicalize(&joined).map_err(|error| {
AppError::storage(format!("cannot resolve {}: {error}", joined.display()))
})?;
if !canonical.starts_with(&canonical_root) {
return Err(AppError::Permission(format!(
"{} escapes the workspace root",
path.as_path().display()
)));
}
Ok(canonical)
}
#[async_trait]
impl WorkspaceFs for StdWorkspaceFs {
async fn list_dir(&self, root: &WorkspaceRoot, path: &RelPath) -> Result<Vec<DirEntry>> {
let directory = resolve(root, path)?;
let mut reader = tokio::fs::read_dir(&directory).await.map_err(|error| {
AppError::storage(format!("cannot read {}: {error}", directory.display()))
})?;
let mut entries = Vec::new();
while let Some(entry) = reader.next_entry().await.map_err(|error| {
AppError::storage(format!("cannot read {}: {error}", directory.display()))
})? {
let file_type = entry.file_type().await.map_err(|error| {
AppError::storage(format!("cannot stat {}: {error}", entry.path().display()))
})?;
entries.push(DirEntry {
name: entry.file_name().to_string_lossy().into_owned(),
is_dir: file_type.is_dir(),
});
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
}
async fn read_file(&self, root: &WorkspaceRoot, path: &RelPath) -> Result<Vec<u8>> {
let file = resolve(root, path)?;
tokio::fs::read(&file)
.await
.map_err(|error| AppError::storage(format!("cannot read {}: {error}", file.display())))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn temp_root(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("origin-workspace-fs-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[tokio::test]
async fn it_reads_and_lists_within_the_root() {
let root_path = temp_root("read");
std::fs::write(root_path.join("a.txt"), b"alpha").unwrap();
std::fs::create_dir(root_path.join("sub")).unwrap();
std::fs::write(root_path.join("sub").join("b.txt"), b"beta").unwrap();
let root = WorkspaceRoot::new(root_path.clone()).unwrap();
let fs = StdWorkspaceFs::new();
let contents = fs
.read_file(&root, &RelPath::new("a.txt").unwrap())
.await
.unwrap();
assert_eq!(contents, b"alpha");
let entries = fs
.list_dir(&root, &RelPath::new(".").unwrap())
.await
.unwrap();
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["a.txt", "sub"]);
assert!(entries.iter().any(|e| e.name == "sub" && e.is_dir));
std::fs::remove_dir_all(&root_path).ok();
}
#[tokio::test]
async fn a_symlink_escaping_the_root_is_refused() {
let root_path = temp_root("symlink-root");
let outside = temp_root("symlink-outside");
let secret = outside.join("secret.txt");
std::fs::write(&secret, b"do not read").unwrap();
let link = root_path.join("escape.txt");
#[cfg(unix)]
std::os::unix::fs::symlink(&secret, &link).unwrap();
#[cfg(not(unix))]
{
std::fs::remove_dir_all(&root_path).ok();
std::fs::remove_dir_all(&outside).ok();
return;
}
let root = WorkspaceRoot::new(root_path.clone()).unwrap();
let fs = StdWorkspaceFs::new();
let error = fs
.read_file(&root, &RelPath::new("escape.txt").unwrap())
.await
.unwrap_err();
assert_eq!(
error.kind(),
origin_domain::ErrorKind::Permission,
"a symlink out of the root must be a permission error, got: {error}"
);
std::fs::remove_dir_all(&root_path).ok();
std::fs::remove_dir_all(&outside).ok();
}
}