Skip to main content

harn_vm/
workspace_path.rs

1use std::collections::BTreeSet;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum WorkspacePathKind {
9    WorkspaceRelative,
10    HostAbsolute,
11    Invalid,
12}
13
14#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
15pub struct WorkspacePathInfo {
16    pub input: String,
17    pub kind: WorkspacePathKind,
18    pub normalized: String,
19    pub workspace_path: Option<String>,
20    pub host_path: Option<String>,
21    pub recovered_root_drift: bool,
22    pub reason: Option<String>,
23}
24
25impl WorkspacePathInfo {
26    pub fn normalized_workspace_path(&self) -> Option<&str> {
27        self.workspace_path.as_deref()
28    }
29
30    pub fn display_path(&self) -> &str {
31        self.workspace_path
32            .as_deref()
33            .or(self.host_path.as_deref())
34            .unwrap_or(&self.normalized)
35    }
36
37    pub fn policy_candidates(&self) -> Vec<String> {
38        let mut seen = BTreeSet::new();
39        let mut out = Vec::new();
40        for candidate in [
41            Some(self.input.as_str()),
42            Some(self.normalized.as_str()),
43            self.workspace_path.as_deref(),
44            self.host_path.as_deref(),
45        ]
46        .into_iter()
47        .flatten()
48        {
49            if !candidate.is_empty() && seen.insert(candidate.to_string()) {
50                out.push(candidate.to_string());
51            }
52        }
53        out
54    }
55
56    pub fn resolved_host_path(&self) -> Option<PathBuf> {
57        self.host_path.as_ref().map(PathBuf::from)
58    }
59}
60
61pub fn normalize_workspace_path(path: &str, workspace_root: Option<&Path>) -> Option<String> {
62    classify_workspace_path(path, workspace_root).workspace_path
63}
64
65pub fn classify_workspace_path(path: &str, workspace_root: Option<&Path>) -> WorkspacePathInfo {
66    let input = path.to_string();
67    let trimmed = path.trim();
68    if trimmed.is_empty() {
69        return invalid_info(input, String::new(), "path is empty");
70    }
71    if trimmed.contains('\0') {
72        return invalid_info(input, to_posix(trimmed), "path contains NUL bytes");
73    }
74
75    let normalized_input = normalize_lexical(trimmed);
76    let root_path = workspace_root.map(normalize_workspace_root);
77    let root_norm = root_path
78        .as_ref()
79        .map(|root| normalize_host_path(root))
80        .filter(|root| !root.is_empty());
81
82    if !is_absolute_str(trimmed) {
83        let workspace_path = normalized_input.clone();
84        if escapes_workspace(&workspace_path) {
85            let host_path = root_path.as_ref().map(|root| {
86                normalize_host_path(&root.join(PathBuf::from(workspace_path.as_str())))
87            });
88            return WorkspacePathInfo {
89                input,
90                kind: WorkspacePathKind::Invalid,
91                normalized: workspace_path,
92                workspace_path: None,
93                host_path,
94                recovered_root_drift: false,
95                reason: Some("workspace-relative path escapes the workspace root".to_string()),
96            };
97        }
98        let host_path = root_path
99            .as_ref()
100            .map(|root| normalize_host_path(&root.join(PathBuf::from(workspace_path.as_str()))));
101        return WorkspacePathInfo {
102            input,
103            kind: WorkspacePathKind::WorkspaceRelative,
104            normalized: workspace_path.clone(),
105            workspace_path: Some(workspace_path),
106            host_path,
107            recovered_root_drift: false,
108            reason: None,
109        };
110    }
111
112    let host_path = normalized_input.clone();
113    if let Some(root_norm) = root_norm.as_deref() {
114        if let Some(workspace_path) = workspace_relative_from_absolute(&host_path, root_norm) {
115            return WorkspacePathInfo {
116                input,
117                kind: WorkspacePathKind::HostAbsolute,
118                normalized: host_path.clone(),
119                workspace_path: Some(workspace_path),
120                host_path: Some(host_path),
121                recovered_root_drift: false,
122                reason: None,
123            };
124        }
125
126        if let Some(root_path) = root_path.as_ref() {
127            if let Some(recovered) = recover_root_drift(trimmed, root_path) {
128                return WorkspacePathInfo {
129                    input,
130                    kind: WorkspacePathKind::WorkspaceRelative,
131                    normalized: recovered.clone(),
132                    workspace_path: Some(recovered.clone()),
133                    host_path: Some(normalize_host_path(
134                        &root_path.join(PathBuf::from(recovered.as_str())),
135                    )),
136                    recovered_root_drift: true,
137                    reason: None,
138                };
139            }
140        }
141    }
142
143    WorkspacePathInfo {
144        input,
145        kind: WorkspacePathKind::HostAbsolute,
146        normalized: host_path.clone(),
147        workspace_path: None,
148        host_path: Some(host_path),
149        recovered_root_drift: false,
150        reason: None,
151    }
152}
153
154fn invalid_info(input: String, normalized: String, reason: &str) -> WorkspacePathInfo {
155    WorkspacePathInfo {
156        input,
157        kind: WorkspacePathKind::Invalid,
158        normalized,
159        workspace_path: None,
160        host_path: None,
161        recovered_root_drift: false,
162        reason: Some(reason.to_string()),
163    }
164}
165
166fn normalize_workspace_root(root: &Path) -> PathBuf {
167    if root.is_absolute() {
168        root.to_path_buf()
169    } else {
170        std::env::current_dir()
171            .unwrap_or_else(|_| PathBuf::from("."))
172            .join(root)
173    }
174}
175
176fn to_posix(s: &str) -> String {
177    s.replace('\\', "/")
178}
179
180fn is_absolute_str(path: &str) -> bool {
181    let path = to_posix(path);
182    if path.starts_with('/') {
183        return true;
184    }
185    let bytes = path.as_bytes();
186    bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/'
187}
188
189fn split_segments(path: &str) -> (bool, Option<String>, Vec<String>) {
190    let posix = to_posix(path);
191    let mut drive: Option<String> = None;
192    let mut rest = posix.as_str();
193    let bytes = posix.as_bytes();
194    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
195        drive = Some(posix[..2].to_string());
196        rest = &posix[2..];
197    }
198    let absolute = rest.starts_with('/');
199    let segments = rest
200        .split('/')
201        .filter(|segment| !segment.is_empty())
202        .map(|segment| segment.to_string())
203        .collect();
204    (absolute, drive, segments)
205}
206
207fn normalize_lexical(path: &str) -> String {
208    let (absolute, drive, segments) = split_segments(path);
209    let mut stack = Vec::new();
210    for segment in segments {
211        match segment.as_str() {
212            "." => {}
213            ".." => {
214                if let Some(top) = stack.last() {
215                    if top != ".." {
216                        stack.pop();
217                        continue;
218                    }
219                }
220                if !absolute {
221                    stack.push("..".to_string());
222                }
223            }
224            _ => stack.push(segment),
225        }
226    }
227
228    let mut normalized = String::new();
229    if let Some(drive) = drive {
230        normalized.push_str(&drive);
231    }
232    if absolute {
233        normalized.push('/');
234    }
235    normalized.push_str(&stack.join("/"));
236    if normalized.is_empty() {
237        ".".to_string()
238    } else {
239        normalized
240    }
241}
242
243fn normalize_host_path(path: &Path) -> String {
244    normalize_lexical(&path.to_string_lossy())
245}
246
247fn escapes_workspace(path: &str) -> bool {
248    path == ".." || path.starts_with("../")
249}
250
251fn workspace_relative_from_absolute(path: &str, workspace_root: &str) -> Option<String> {
252    let (path_abs, path_drive, path_segments) = split_segments(path);
253    let (root_abs, root_drive, root_segments) = split_segments(workspace_root);
254    if !path_abs || !root_abs || path_drive != root_drive {
255        return None;
256    }
257    if path_segments.len() < root_segments.len()
258        || !path_segments.starts_with(root_segments.as_slice())
259    {
260        return None;
261    }
262    let remainder = &path_segments[root_segments.len()..];
263    if remainder.is_empty() {
264        Some(".".to_string())
265    } else {
266        Some(remainder.join("/"))
267    }
268}
269
270fn recover_root_drift(path: &str, workspace_root: &Path) -> Option<String> {
271    let posix = to_posix(path);
272    if !posix.starts_with('/') {
273        return None;
274    }
275    let trimmed = posix.trim_start_matches('/');
276    if trimmed.is_empty() {
277        return None;
278    }
279    let workspace_path = normalize_lexical(trimmed);
280    if workspace_path == "." || escapes_workspace(&workspace_path) {
281        return None;
282    }
283    if Path::new(path).exists() {
284        return None;
285    }
286    let candidate = workspace_root.join(PathBuf::from(workspace_path.as_str()));
287    if candidate.exists() || candidate.parent().is_some_and(Path::exists) {
288        Some(workspace_path)
289    } else {
290        None
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn relative_path_is_workspace_relative() {
300        let dir = tempfile::tempdir().unwrap();
301        let info = classify_workspace_path("src/main.rs", Some(dir.path()));
302        assert_eq!(info.kind, WorkspacePathKind::WorkspaceRelative);
303        assert_eq!(info.workspace_path.as_deref(), Some("src/main.rs"));
304        assert_eq!(
305            info.host_path.as_deref(),
306            Some(normalize_host_path(&dir.path().join("src/main.rs")).as_str())
307        );
308    }
309
310    #[test]
311    fn parent_escape_is_invalid() {
312        let dir = tempfile::tempdir().unwrap();
313        let info = classify_workspace_path("../secret.txt", Some(dir.path()));
314        assert_eq!(info.kind, WorkspacePathKind::Invalid);
315        assert_eq!(
316            info.reason.as_deref(),
317            Some("workspace-relative path escapes the workspace root")
318        );
319    }
320
321    #[test]
322    fn windows_drive_relative_path_is_not_host_absolute() {
323        let dir = tempfile::tempdir().unwrap();
324        let info = classify_workspace_path("C:src/main.harn", Some(dir.path()));
325        assert_eq!(info.kind, WorkspacePathKind::WorkspaceRelative);
326        assert_eq!(info.workspace_path.as_deref(), Some("C:src/main.harn"));
327    }
328
329    #[test]
330    fn absolute_path_inside_workspace_gets_relative_projection() {
331        let dir = tempfile::tempdir().unwrap();
332        let file = dir.path().join("packages/app/host.harn");
333        std::fs::create_dir_all(file.parent().unwrap()).unwrap();
334        std::fs::write(&file, "ok").unwrap();
335        let info = classify_workspace_path(file.to_string_lossy().as_ref(), Some(dir.path()));
336        assert_eq!(info.kind, WorkspacePathKind::HostAbsolute);
337        assert_eq!(
338            info.workspace_path.as_deref(),
339            Some("packages/app/host.harn")
340        );
341        assert!(!info.recovered_root_drift);
342    }
343
344    #[test]
345    fn leading_slash_workspace_drift_recovers_when_workspace_candidate_exists() {
346        let dir = tempfile::tempdir().unwrap();
347        let file = dir.path().join("packages/app/host.harn");
348        std::fs::create_dir_all(file.parent().unwrap()).unwrap();
349        std::fs::write(&file, "ok").unwrap();
350        let info = classify_workspace_path("/packages/app/host.harn", Some(dir.path()));
351        assert_eq!(info.kind, WorkspacePathKind::WorkspaceRelative);
352        assert_eq!(
353            info.workspace_path.as_deref(),
354            Some("packages/app/host.harn")
355        );
356        assert!(info.recovered_root_drift);
357    }
358
359    #[test]
360    fn unknown_absolute_path_stays_host_absolute() {
361        let dir = tempfile::tempdir().unwrap();
362        let info = classify_workspace_path("/tmp/harn-issue-125-nope", Some(dir.path()));
363        assert_eq!(info.kind, WorkspacePathKind::HostAbsolute);
364        assert!(info.workspace_path.is_none());
365        assert!(!info.recovered_root_drift);
366    }
367
368    #[test]
369    fn normalize_workspace_path_returns_relative_projection() {
370        let dir = tempfile::tempdir().unwrap();
371        std::fs::create_dir_all(dir.path().join("packages/app")).unwrap();
372        assert_eq!(
373            normalize_workspace_path("/packages/app", Some(dir.path())).as_deref(),
374            Some("packages/app")
375        );
376    }
377}