speccoder_speckit/
workspace.rs1use std::fs;
8use std::path::{Component, Path, PathBuf};
9
10pub const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
12
13pub const MARKER: &str = ".specify";
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum ReadError {
19 Missing,
21 Escape,
23 TooLarge(u64),
25 NotUtf8,
27 Io(String),
29}
30
31#[derive(Debug, Clone)]
33pub struct Workspace {
34 root: PathBuf,
35}
36
37#[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 #[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 #[must_use]
59 pub fn root(&self) -> &Path {
60 &self.root
61 }
62
63 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 #[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 #[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 pub fn read_text(&self, rel: &str) -> Result<String, ReadError> {
98 self.read_text_limited(rel, MAX_FILE_BYTES)
99 }
100
101 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 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#[derive(Debug, Default)]
139pub struct DirListing {
140 pub inside: Vec<String>,
142 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}