Skip to main content

origin_workspace_fs/
lib.rs

1//! The workspace filesystem contract (`WorkspaceFs`) over the local filesystem (B2).
2//!
3//! Read-only by construction: the trait has no write method, so a product that uses
4//! this adapter cannot modify a repository through it. Every access is confined to a
5//! user-confirmed [`WorkspaceRoot`]; a path that escapes it — including via a symlink —
6//! is refused with a permission error.
7
8use async_trait::async_trait;
9use origin_domain::{AppError, Result};
10use origin_platform::{DirEntry, RelPath, WorkspaceFs, WorkspaceRoot};
11
12/// Reads files and lists directories with the standard library.
13///
14/// No Tauri, no plugin: the OS filesystem is reachable from plain Rust, and the
15/// allowlist/containment check in this adapter *is* the security boundary. ADR-0007
16/// still applies to the frontend, which never gets `fs:*` — it goes through the
17/// transport and the application services.
18#[derive(Debug, Clone, Copy, Default)]
19pub struct StdWorkspaceFs;
20
21impl StdWorkspaceFs {
22    pub fn new() -> Self {
23        Self
24    }
25}
26
27/// Resolve `path` under `root`, refusing anything that escapes it.
28///
29/// Canonicalising both sides closes the symlink hole that a pure `..`-filter leaves
30/// open: a symlink inside the root pointing outside it resolves to a path that no
31/// longer starts with the canonicalised root.
32fn resolve(root: &WorkspaceRoot, path: &RelPath) -> Result<std::path::PathBuf> {
33    let canonical_root = std::fs::canonicalize(root.as_path()).map_err(|error| {
34        AppError::storage(format!(
35            "cannot resolve workspace root {}: {error}",
36            root.as_path().display()
37        ))
38    })?;
39
40    let joined = root.as_path().join(path.as_path());
41    let canonical = std::fs::canonicalize(&joined).map_err(|error| {
42        AppError::storage(format!("cannot resolve {}: {error}", joined.display()))
43    })?;
44
45    if !canonical.starts_with(&canonical_root) {
46        return Err(AppError::Permission(format!(
47            "{} escapes the workspace root",
48            path.as_path().display()
49        )));
50    }
51
52    Ok(canonical)
53}
54
55#[async_trait]
56impl WorkspaceFs for StdWorkspaceFs {
57    async fn list_dir(&self, root: &WorkspaceRoot, path: &RelPath) -> Result<Vec<DirEntry>> {
58        let directory = resolve(root, path)?;
59
60        let mut reader = tokio::fs::read_dir(&directory).await.map_err(|error| {
61            AppError::storage(format!("cannot read {}: {error}", directory.display()))
62        })?;
63
64        let mut entries = Vec::new();
65        while let Some(entry) = reader.next_entry().await.map_err(|error| {
66            AppError::storage(format!("cannot read {}: {error}", directory.display()))
67        })? {
68            let file_type = entry.file_type().await.map_err(|error| {
69                AppError::storage(format!("cannot stat {}: {error}", entry.path().display()))
70            })?;
71
72            entries.push(DirEntry {
73                name: entry.file_name().to_string_lossy().into_owned(),
74                is_dir: file_type.is_dir(),
75            });
76        }
77
78        // Stable order: a directory listing that reshuffles between calls makes tests
79        // flaky and diffs noisy.
80        entries.sort_by(|a, b| a.name.cmp(&b.name));
81        Ok(entries)
82    }
83
84    async fn read_file(&self, root: &WorkspaceRoot, path: &RelPath) -> Result<Vec<u8>> {
85        let file = resolve(root, path)?;
86
87        tokio::fs::read(&file)
88            .await
89            .map_err(|error| AppError::storage(format!("cannot read {}: {error}", file.display())))
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use std::path::PathBuf;
97
98    fn temp_root(name: &str) -> PathBuf {
99        let dir =
100            std::env::temp_dir().join(format!("origin-workspace-fs-{}-{name}", std::process::id()));
101        let _ = std::fs::remove_dir_all(&dir);
102        std::fs::create_dir_all(&dir).unwrap();
103        dir
104    }
105
106    #[tokio::test]
107    async fn it_reads_and_lists_within_the_root() {
108        let root_path = temp_root("read");
109        std::fs::write(root_path.join("a.txt"), b"alpha").unwrap();
110        std::fs::create_dir(root_path.join("sub")).unwrap();
111        std::fs::write(root_path.join("sub").join("b.txt"), b"beta").unwrap();
112
113        let root = WorkspaceRoot::new(root_path.clone()).unwrap();
114        let fs = StdWorkspaceFs::new();
115
116        let contents = fs
117            .read_file(&root, &RelPath::new("a.txt").unwrap())
118            .await
119            .unwrap();
120        assert_eq!(contents, b"alpha");
121
122        let entries = fs
123            .list_dir(&root, &RelPath::new(".").unwrap())
124            .await
125            .unwrap();
126        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
127        assert_eq!(names, vec!["a.txt", "sub"]);
128        assert!(entries.iter().any(|e| e.name == "sub" && e.is_dir));
129
130        std::fs::remove_dir_all(&root_path).ok();
131    }
132
133    #[tokio::test]
134    async fn a_symlink_escaping_the_root_is_refused() {
135        let root_path = temp_root("symlink-root");
136        let outside = temp_root("symlink-outside");
137        let secret = outside.join("secret.txt");
138        std::fs::write(&secret, b"do not read").unwrap();
139
140        // A file inside the root that is actually a symlink to the outside.
141        let link = root_path.join("escape.txt");
142        #[cfg(unix)]
143        std::os::unix::fs::symlink(&secret, &link).unwrap();
144        #[cfg(not(unix))]
145        {
146            // Symlink creation needs privileges on Windows; skip the assertion there.
147            std::fs::remove_dir_all(&root_path).ok();
148            std::fs::remove_dir_all(&outside).ok();
149            return;
150        }
151
152        let root = WorkspaceRoot::new(root_path.clone()).unwrap();
153        let fs = StdWorkspaceFs::new();
154
155        let error = fs
156            .read_file(&root, &RelPath::new("escape.txt").unwrap())
157            .await
158            .unwrap_err();
159
160        assert_eq!(
161            error.kind(),
162            origin_domain::ErrorKind::Permission,
163            "a symlink out of the root must be a permission error, got: {error}"
164        );
165
166        std::fs::remove_dir_all(&root_path).ok();
167        std::fs::remove_dir_all(&outside).ok();
168    }
169}