Skip to main content

origin_platform/
workspace.rs

1//! Workspace filesystem contract (B2 of the Gitbit platform requirements).
2//!
3//! Read-only access to files under user-confirmed roots. Unlike a blanket
4//! `fs:allow-read-*` Tauri capability, every access is scoped to an explicitly
5//! chosen directory — `WorkspaceRoot` is confirmed via a native dialog, not a
6//! free-form path the caller can pick.
7
8use async_trait::async_trait;
9use origin_domain::{AppError, Result};
10use std::fmt::Debug;
11use std::path::{Component, Path, PathBuf};
12
13/// A directory explicitly chosen by the user (e.g. via a native file dialog).
14///
15/// Distinct from a bare `PathBuf` so a free path cannot be passed by accident.
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub struct WorkspaceRoot(PathBuf);
18
19impl WorkspaceRoot {
20    pub fn new(path: PathBuf) -> Result<Self> {
21        if !path.is_absolute() {
22            return Err(AppError::validation(
23                "workspace root must be an absolute path",
24            ));
25        }
26        Ok(Self(path))
27    }
28
29    pub fn as_path(&self) -> &Path {
30        &self.0
31    }
32
33    pub fn into_inner(self) -> PathBuf {
34        self.0
35    }
36}
37
38/// A relative path within a [`WorkspaceRoot`].
39///
40/// Construction rejects `.` and `..` components, so a resolved `root / rel`
41/// can never escape the root directory. The guard is checked at construction
42/// time and again in the memory double, so a filesystem adapter can trust the
43/// type.
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
45pub struct RelPath(PathBuf);
46
47impl RelPath {
48    pub fn new(path: impl AsRef<Path>) -> Result<Self> {
49        let path = path.as_ref();
50        if path.is_absolute() {
51            return Err(AppError::validation("a relative path must not be absolute"));
52        }
53
54        for component in path.components() {
55            match component {
56                Component::Normal(_) | Component::CurDir => {}
57                _ => {
58                    return Err(AppError::validation(format!(
59                        "a relative path must not contain `..`: `{}`",
60                        path.display()
61                    )));
62                }
63            }
64        }
65
66        Ok(Self(path.to_path_buf()))
67    }
68
69    pub fn as_path(&self) -> &Path {
70        &self.0
71    }
72}
73
74/// A directory entry returned by [`WorkspaceFs::list_dir`].
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct DirEntry {
77    pub name: String,
78    pub is_dir: bool,
79}
80
81impl DirEntry {
82    pub fn file(name: impl Into<String>) -> Self {
83        Self {
84            name: name.into(),
85            is_dir: false,
86        }
87    }
88
89    pub fn directory(name: impl Into<String>) -> Self {
90        Self {
91            name: name.into(),
92            is_dir: true,
93        }
94    }
95}
96
97/// Read-only workspace filesystem.
98///
99/// Every access is scoped to a [`WorkspaceRoot`]. Write access is deliberately
100/// excluded from this contract — destructive operations need their own port and
101/// their own permission (ADR-0007).
102#[async_trait]
103pub trait WorkspaceFs: Debug + Send + Sync + 'static {
104    /// List the contents of a directory within `root`.
105    async fn list_dir(&self, root: &WorkspaceRoot, path: &RelPath) -> Result<Vec<DirEntry>>;
106
107    /// Read the contents of a file within `root`.
108    async fn read_file(&self, root: &WorkspaceRoot, path: &RelPath) -> Result<Vec<u8>>;
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn workspace_root_rejects_relative_paths() {
117        let result = WorkspaceRoot::new(PathBuf::from("relative/path"));
118        match result {
119            Err(AppError::Validation(_)) => {} // expected
120            other => panic!("expected Validation error for relative path, got {other:?}"),
121        }
122    }
123
124    #[test]
125    fn workspace_root_accepts_absolute_paths() {
126        WorkspaceRoot::new(PathBuf::from("/Users/test/repo")).expect("absolute path must be ok");
127    }
128
129    #[test]
130    fn rel_path_rejects_absolute_paths() {
131        let result = RelPath::new("/absolute/file");
132        match result {
133            Err(AppError::Validation(_)) => {} // expected
134            other => panic!("expected Validation error for absolute rel path, got {other:?}"),
135        }
136    }
137
138    #[test]
139    fn rel_path_rejects_parent_traversal() {
140        let result = RelPath::new("../escape");
141        match result {
142            Err(AppError::Validation(_)) => {} // expected
143            other => panic!("expected Validation error for parent traversal, got {other:?}"),
144        }
145    }
146
147    #[test]
148    fn rel_path_accepts_normal_relative_paths() {
149        let rel = RelPath::new("src/main.rs").expect("normal relative path must be ok");
150        assert_eq!(rel.as_path(), Path::new("src/main.rs"));
151    }
152
153    #[test]
154    fn rel_path_accepts_current_dir_prefix() {
155        let rel = RelPath::new("./file.txt").expect("./ prefix must be ok");
156        assert_eq!(rel.as_path(), Path::new("./file.txt"));
157    }
158}