heartbit_core/
workspace.rs1use std::path::{Component, Path, PathBuf};
4
5use crate::error::Error;
6
7#[derive(Debug, Clone)]
14pub struct Workspace {
15 root: PathBuf,
16}
17
18impl Workspace {
19 pub fn open(root: impl Into<PathBuf>) -> Result<Self, Error> {
23 let root = root.into();
24 if !root.exists() {
25 std::fs::create_dir_all(&root).map_err(|e| {
26 Error::Config(format!(
27 "failed to create workspace at {}: {e}",
28 root.display()
29 ))
30 })?;
31 }
32 let root = root.canonicalize().map_err(|e| {
34 Error::Config(format!(
35 "failed to canonicalize workspace path {}: {e}",
36 root.display()
37 ))
38 })?;
39 Ok(Self { root })
40 }
41
42 pub fn root(&self) -> &Path {
44 &self.root
45 }
46
47 pub fn resolve(&self, path: &str) -> Result<PathBuf, Error> {
52 let p = Path::new(path);
53
54 if p.is_absolute() {
56 return Ok(p.to_path_buf());
57 }
58
59 let candidate = self.root.join(p);
61 let normalized = normalize_path(&candidate);
62
63 if !normalized.starts_with(&self.root) {
64 return Err(Error::Agent(format!(
65 "path '{}' escapes workspace root ({})",
66 path,
67 self.root.display()
68 )));
69 }
70
71 Ok(normalized)
72 }
73}
74
75pub fn normalize_path(path: &Path) -> PathBuf {
79 let mut components = Vec::new();
80 for component in path.components() {
81 match component {
82 Component::ParentDir => {
83 match components.last() {
85 Some(Component::Normal(_)) => {
86 components.pop();
87 }
88 _ => {
89 components.push(component);
91 }
92 }
93 }
94 Component::CurDir => {} _ => components.push(component),
96 }
97 }
98 components.iter().collect()
99}
100
101#[derive(Debug, Clone)]
110pub enum EnvPolicy {
111 Inherit,
114 Allowlist(Vec<String>),
117}
118
119impl Default for EnvPolicy {
120 fn default() -> Self {
121 Self::Allowlist(
122 DAEMON_ENV_ALLOWLIST
123 .iter()
124 .map(|s| (*s).to_string())
125 .collect(),
126 )
127 }
128}
129
130pub const DAEMON_ENV_ALLOWLIST: &[&str] = &[
132 "PATH", "HOME", "USER", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TERM", "SHELL", "TMPDIR",
133];
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn open_creates_directory() {
141 let dir = tempfile::tempdir().unwrap();
142 let ws_path = dir.path().join("new_workspace");
143 assert!(!ws_path.exists());
144
145 let ws = Workspace::open(&ws_path).unwrap();
146 assert!(ws_path.exists());
147 assert!(ws.root().is_absolute());
148 }
149
150 #[test]
151 fn open_existing_directory() {
152 let dir = tempfile::tempdir().unwrap();
153 let ws = Workspace::open(dir.path()).unwrap();
154 assert_eq!(ws.root(), dir.path().canonicalize().unwrap());
155 }
156
157 #[test]
158 fn resolve_relative_path() {
159 let dir = tempfile::tempdir().unwrap();
160 let ws = Workspace::open(dir.path()).unwrap();
161
162 let resolved = ws.resolve("notes.md").unwrap();
163 assert_eq!(resolved, ws.root().join("notes.md"));
164 }
165
166 #[test]
167 fn resolve_nested_relative_path() {
168 let dir = tempfile::tempdir().unwrap();
169 let ws = Workspace::open(dir.path()).unwrap();
170
171 let resolved = ws.resolve("sub/dir/file.txt").unwrap();
172 assert_eq!(resolved, ws.root().join("sub/dir/file.txt"));
173 }
174
175 #[test]
176 fn resolve_absolute_path_passthrough() {
177 let dir = tempfile::tempdir().unwrap();
178 let ws = Workspace::open(dir.path()).unwrap();
179
180 let resolved = ws.resolve("/etc/hosts").unwrap();
181 assert_eq!(resolved, PathBuf::from("/etc/hosts"));
182 }
183
184 #[test]
185 fn resolve_rejects_escape() {
186 let dir = tempfile::tempdir().unwrap();
187 let ws = Workspace::open(dir.path()).unwrap();
188
189 let result = ws.resolve("../../etc/passwd");
190 assert!(result.is_err());
191 let err = result.unwrap_err().to_string();
192 assert!(err.contains("escapes workspace root"), "got: {err}");
193 }
194
195 #[test]
196 fn resolve_allows_internal_dotdot() {
197 let dir = tempfile::tempdir().unwrap();
198 let ws = Workspace::open(dir.path()).unwrap();
199
200 let resolved = ws.resolve("sub/../file.txt").unwrap();
202 assert_eq!(resolved, ws.root().join("file.txt"));
203 }
204
205 #[test]
206 fn resolve_dot_path() {
207 let dir = tempfile::tempdir().unwrap();
208 let ws = Workspace::open(dir.path()).unwrap();
209
210 let resolved = ws.resolve(".").unwrap();
211 assert_eq!(resolved, ws.root().to_path_buf());
212 }
213
214 #[test]
215 fn normalize_path_basic() {
216 let path = Path::new("/a/b/../c/./d");
217 assert_eq!(normalize_path(path), PathBuf::from("/a/c/d"));
218 }
219
220 #[test]
221 fn normalize_path_no_escape_root() {
222 let path = Path::new("/a/../../b");
223 let normalized = normalize_path(path);
224 assert!(normalized.starts_with("/"));
226 }
227
228 #[test]
232 fn env_policy_default_is_safe_allowlist() {
233 match EnvPolicy::default() {
234 EnvPolicy::Allowlist(list) => {
235 assert!(list.contains(&"PATH".to_string()));
236 let suspicious: Vec<&String> = list
238 .iter()
239 .filter(|n| {
240 let u = n.to_ascii_uppercase();
241 u.contains("KEY") || u.contains("TOKEN") || u.contains("SECRET")
242 })
243 .collect();
244 assert!(
245 suspicious.is_empty(),
246 "default allowlist must not contain secret-like names: {suspicious:?}"
247 );
248 }
249 EnvPolicy::Inherit => panic!(
250 "EnvPolicy::default() must NOT be Inherit (F-FS-2). \
251 Use EnvPolicy::Inherit explicitly if you really want it."
252 ),
253 }
254 }
255
256 #[test]
257 fn daemon_env_allowlist_contains_path() {
258 assert!(DAEMON_ENV_ALLOWLIST.contains(&"PATH"));
259 }
260}