Skip to main content

speccoder_speckit/
workspace.rs

1//! Read-only access confined to a project root.
2//!
3//! Every path is resolved against the canonical root; absolute paths, `..`
4//! components and anything that canonicalizes outside the root (symlinks
5//! included) are refused before the file is opened.
6
7use std::fs;
8use std::path::{Component, Path, PathBuf};
9
10/// Largest artifact that is read and parsed.
11pub const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
12
13/// The Spec Kit marker directory.
14pub const MARKER: &str = ".specify";
15
16/// Why a workspace path could not be used.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum ReadError {
19    /// Nothing exists at the path.
20    Missing,
21    /// The path is absolute, uses `..`, or resolves outside the root.
22    Escape,
23    /// The file is larger than [`MAX_FILE_BYTES`].
24    TooLarge(u64),
25    /// The file is not UTF-8.
26    NotUtf8,
27    /// Any other IO failure.
28    Io(String),
29}
30
31/// A project root that all reads are confined to.
32#[derive(Debug, Clone)]
33pub struct Workspace {
34    root: PathBuf,
35}
36
37/// Finds the nearest ancestor of `start` (inclusive) that contains `.specify/`.
38#[must_use]
39pub fn find_root(start: &Path) -> Option<Workspace> {
40    let start = fs::canonicalize(start).ok()?;
41    start
42        .ancestors()
43        .find(|dir| dir.join(MARKER).is_dir())
44        .map(|root| Workspace {
45            root: root.to_path_buf(),
46        })
47}
48
49impl Workspace {
50    /// A workspace rooted at `root` (which must exist).
51    #[must_use]
52    pub fn at(root: &Path) -> Option<Self> {
53        let root = fs::canonicalize(root).ok()?;
54        root.is_dir().then_some(Self { root })
55    }
56
57    /// The canonical root.
58    #[must_use]
59    pub fn root(&self) -> &Path {
60        &self.root
61    }
62
63    /// Resolves a workspace-relative path to an existing path inside the root.
64    pub fn resolve(&self, rel: &str) -> Result<PathBuf, ReadError> {
65        let rel_path = Path::new(rel);
66        if rel_path
67            .components()
68            .any(|c| !matches!(c, Component::Normal(_) | Component::CurDir))
69        {
70            return Err(ReadError::Escape);
71        }
72        let joined = self.root.join(rel_path);
73        if fs::symlink_metadata(&joined).is_err() {
74            return Err(ReadError::Missing);
75        }
76        let canonical = fs::canonicalize(&joined).map_err(|_| ReadError::Escape)?;
77        if canonical.starts_with(&self.root) {
78            Ok(canonical)
79        } else {
80            Err(ReadError::Escape)
81        }
82    }
83
84    /// Whether `rel` is a file inside the root.
85    #[must_use]
86    pub fn is_file(&self, rel: &str) -> bool {
87        self.resolve(rel).is_ok_and(|p| p.is_file())
88    }
89
90    /// Whether `rel` is a directory inside the root.
91    #[must_use]
92    pub fn is_dir(&self, rel: &str) -> bool {
93        self.resolve(rel).is_ok_and(|p| p.is_dir())
94    }
95
96    /// Reads a UTF-8 file inside the root, refusing files above the size limit.
97    pub fn read_text(&self, rel: &str) -> Result<String, ReadError> {
98        self.read_text_limited(rel, MAX_FILE_BYTES)
99    }
100
101    /// [`Self::read_text`] with an explicit size limit (e.g. large test reports).
102    pub fn read_text_limited(&self, rel: &str, limit: u64) -> Result<String, ReadError> {
103        let path = self.resolve(rel)?;
104        let meta = fs::metadata(&path).map_err(|e| ReadError::Io(e.to_string()))?;
105        if !meta.is_file() {
106            return Err(ReadError::Missing);
107        }
108        if meta.len() > limit {
109            return Err(ReadError::TooLarge(meta.len()));
110        }
111        let bytes = fs::read(&path).map_err(|e| ReadError::Io(e.to_string()))?;
112        String::from_utf8(bytes).map_err(|_| ReadError::NotUtf8)
113    }
114
115    /// Names of the directory entries of `rel` that are directories, split into
116    /// those inside the root and those resolving outside it.
117    pub fn list_dirs(&self, rel: &str) -> Result<DirListing, ReadError> {
118        let dir = self.resolve(rel)?;
119        let entries = fs::read_dir(&dir).map_err(|e| ReadError::Io(e.to_string()))?;
120        let mut listing = DirListing::default();
121        for entry in entries.flatten() {
122            let Ok(name) = entry.file_name().into_string() else {
123                continue;
124            };
125            match self.resolve(&format!("{rel}/{name}")) {
126                Ok(p) if p.is_dir() => listing.inside.push(name),
127                Ok(_) | Err(ReadError::Missing) => {}
128                Err(_) => listing.escaping.push(name),
129            }
130        }
131        listing.inside.sort();
132        listing.escaping.sort();
133        Ok(listing)
134    }
135}
136
137/// Result of [`Workspace::list_dirs`].
138#[derive(Debug, Default)]
139pub struct DirListing {
140    /// Subdirectories inside the root.
141    pub inside: Vec<String>,
142    /// Entries that resolve outside the root.
143    pub escaping: Vec<String>,
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    fn workspace() -> (tempfile::TempDir, Workspace) {
151        let dir = tempfile::tempdir().unwrap();
152        fs::create_dir(dir.path().join(MARKER)).unwrap();
153        fs::write(dir.path().join("a.md"), "hello").unwrap();
154        let ws = find_root(dir.path()).unwrap();
155        (dir, ws)
156    }
157
158    #[test]
159    fn finds_root_from_a_subdirectory() {
160        let (dir, ws) = workspace();
161        fs::create_dir_all(dir.path().join("x/y")).unwrap();
162        let found = find_root(&dir.path().join("x/y")).unwrap();
163        assert_eq!(found.root(), ws.root());
164    }
165
166    #[test]
167    fn refuses_parent_and_absolute_paths() {
168        let (_dir, ws) = workspace();
169        assert_eq!(ws.resolve("../a.md"), Err(ReadError::Escape));
170        assert_eq!(ws.resolve("/etc/passwd"), Err(ReadError::Escape));
171        assert_eq!(ws.read_text("a.md").as_deref(), Ok("hello"));
172        assert_eq!(ws.resolve("missing.md"), Err(ReadError::Missing));
173    }
174}