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
65/// Resolve an existing path through the filesystem and return its canonical
66/// workspace-relative spelling only when it remains beneath an existing
67/// workspace directory. Unlike [`normalize_workspace_path`], this follows
68/// symlinks and never applies leading-slash root-drift recovery.
69pub fn canonicalize_existing_workspace_path(path: &Path, workspace_root: &Path) -> Option<String> {
70    if path.as_os_str().is_empty() {
71        return None;
72    }
73    let canonical_root = std::fs::canonicalize(workspace_root).ok()?;
74    if !canonical_root.is_dir() {
75        return None;
76    }
77
78    let target = if path.is_absolute() {
79        path.to_path_buf()
80    } else {
81        canonical_root.join(path)
82    };
83    let canonical_target = std::fs::canonicalize(target).ok()?;
84    let relative = canonical_target.strip_prefix(&canonical_root).ok()?;
85    let workspace_path = to_posix(&relative.to_string_lossy());
86    Some(if workspace_path.is_empty() {
87        ".".to_string()
88    } else {
89        workspace_path
90    })
91}
92
93pub fn classify_workspace_path(path: &str, workspace_root: Option<&Path>) -> WorkspacePathInfo {
94    let input = path.to_string();
95    let trimmed = path.trim();
96    if trimmed.is_empty() {
97        return invalid_info(input, String::new(), "path is empty");
98    }
99    if trimmed.contains('\0') {
100        return invalid_info(input, to_posix(trimmed), "path contains NUL bytes");
101    }
102
103    let normalized_input = normalize_lexical(trimmed);
104    let root_path = workspace_root.map(normalize_workspace_root);
105    let root_norm = root_path
106        .as_ref()
107        .map(|root| normalize_host_path(root))
108        .filter(|root| !root.is_empty());
109
110    if !is_absolute_str(trimmed) {
111        let workspace_path = normalized_input;
112        if escapes_workspace(&workspace_path) {
113            let host_path = root_path.as_ref().map(|root| {
114                normalize_host_path(&root.join(PathBuf::from(workspace_path.as_str())))
115            });
116            return WorkspacePathInfo {
117                input,
118                kind: WorkspacePathKind::Invalid,
119                normalized: workspace_path,
120                workspace_path: None,
121                host_path,
122                recovered_root_drift: false,
123                reason: Some("workspace-relative path escapes the workspace root".to_string()),
124            };
125        }
126        let host_path = root_path
127            .as_ref()
128            .map(|root| normalize_host_path(&root.join(PathBuf::from(workspace_path.as_str()))));
129        return WorkspacePathInfo {
130            input,
131            kind: WorkspacePathKind::WorkspaceRelative,
132            normalized: workspace_path.clone(),
133            workspace_path: Some(workspace_path),
134            host_path,
135            recovered_root_drift: false,
136            reason: None,
137        };
138    }
139
140    let host_path = normalized_input;
141    if let Some(root_norm) = root_norm.as_deref() {
142        if let Some(workspace_path) = workspace_relative_from_absolute(&host_path, root_norm) {
143            return WorkspacePathInfo {
144                input,
145                kind: WorkspacePathKind::HostAbsolute,
146                normalized: host_path.clone(),
147                workspace_path: Some(workspace_path),
148                host_path: Some(host_path),
149                recovered_root_drift: false,
150                reason: None,
151            };
152        }
153
154        if let Some(root_path) = root_path.as_ref() {
155            if let Some(recovered) = recover_root_drift(trimmed, root_path) {
156                return WorkspacePathInfo {
157                    input,
158                    kind: WorkspacePathKind::WorkspaceRelative,
159                    normalized: recovered.clone(),
160                    workspace_path: Some(recovered.clone()),
161                    host_path: Some(normalize_host_path(
162                        &root_path.join(PathBuf::from(recovered.as_str())),
163                    )),
164                    recovered_root_drift: true,
165                    reason: None,
166                };
167            }
168        }
169    }
170
171    WorkspacePathInfo {
172        input,
173        kind: WorkspacePathKind::HostAbsolute,
174        normalized: host_path.clone(),
175        workspace_path: None,
176        host_path: Some(host_path),
177        recovered_root_drift: false,
178        reason: None,
179    }
180}
181
182fn invalid_info(input: String, normalized: String, reason: &str) -> WorkspacePathInfo {
183    WorkspacePathInfo {
184        input,
185        kind: WorkspacePathKind::Invalid,
186        normalized,
187        workspace_path: None,
188        host_path: None,
189        recovered_root_drift: false,
190        reason: Some(reason.to_string()),
191    }
192}
193
194fn normalize_workspace_root(root: &Path) -> PathBuf {
195    if root.is_absolute() {
196        root.to_path_buf()
197    } else {
198        std::env::current_dir()
199            .unwrap_or_else(|_| PathBuf::from("."))
200            .join(root)
201    }
202}
203
204fn to_posix(s: &str) -> String {
205    s.replace('\\', "/")
206}
207
208fn is_absolute_str(path: &str) -> bool {
209    let path = to_posix(path);
210    if path.starts_with('/') {
211        return true;
212    }
213    let bytes = path.as_bytes();
214    bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/'
215}
216
217fn split_segments(path: &str) -> (bool, Option<String>, Vec<String>) {
218    let posix = to_posix(path);
219    let mut drive: Option<String> = None;
220    let mut rest = posix.as_str();
221    let bytes = posix.as_bytes();
222    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
223        drive = Some(posix[..2].to_string());
224        rest = &posix[2..];
225    }
226    let absolute = rest.starts_with('/');
227    let segments = rest
228        .split('/')
229        .filter(|segment| !segment.is_empty())
230        .map(|segment| segment.to_string())
231        .collect();
232    (absolute, drive, segments)
233}
234
235fn normalize_lexical(path: &str) -> String {
236    let (absolute, drive, segments) = split_segments(path);
237    let mut stack = Vec::new();
238    for segment in segments {
239        match segment.as_str() {
240            "." => {}
241            ".." => {
242                if let Some(top) = stack.last() {
243                    if top != ".." {
244                        stack.pop();
245                        continue;
246                    }
247                }
248                if !absolute {
249                    stack.push("..".to_string());
250                }
251            }
252            _ => stack.push(segment),
253        }
254    }
255
256    let mut normalized = String::new();
257    if let Some(drive) = drive {
258        normalized.push_str(&drive);
259    }
260    if absolute {
261        normalized.push('/');
262    }
263    normalized.push_str(&stack.join("/"));
264    if normalized.is_empty() {
265        ".".to_string()
266    } else {
267        normalized
268    }
269}
270
271fn normalize_host_path(path: &Path) -> String {
272    normalize_lexical(&path.to_string_lossy())
273}
274
275fn escapes_workspace(path: &str) -> bool {
276    path == ".." || path.starts_with("../")
277}
278
279fn workspace_relative_from_absolute(path: &str, workspace_root: &str) -> Option<String> {
280    let (path_abs, path_drive, path_segments) = split_segments(path);
281    let (root_abs, root_drive, root_segments) = split_segments(workspace_root);
282    if !path_abs || !root_abs || path_drive != root_drive {
283        return None;
284    }
285    if path_segments.len() < root_segments.len()
286        || !path_segments.starts_with(root_segments.as_slice())
287    {
288        return None;
289    }
290    let remainder = &path_segments[root_segments.len()..];
291    if remainder.is_empty() {
292        Some(".".to_string())
293    } else {
294        Some(remainder.join("/"))
295    }
296}
297
298fn recover_root_drift(path: &str, workspace_root: &Path) -> Option<String> {
299    let posix = to_posix(path);
300    if !posix.starts_with('/') {
301        return None;
302    }
303    let trimmed = posix.trim_start_matches('/');
304    if trimmed.is_empty() {
305        return None;
306    }
307    let workspace_path = normalize_lexical(trimmed);
308    if workspace_path == "." || escapes_workspace(&workspace_path) {
309        return None;
310    }
311    if Path::new(path).exists() {
312        return None;
313    }
314    let candidate = workspace_root.join(PathBuf::from(workspace_path.as_str()));
315    if candidate.exists() || candidate.parent().is_some_and(Path::exists) {
316        Some(workspace_path)
317    } else {
318        None
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn relative_path_is_workspace_relative() {
328        let dir = tempfile::tempdir().unwrap();
329        let info = classify_workspace_path("src/main.rs", Some(dir.path()));
330        assert_eq!(info.kind, WorkspacePathKind::WorkspaceRelative);
331        assert_eq!(info.workspace_path.as_deref(), Some("src/main.rs"));
332        assert_eq!(
333            info.host_path.as_deref(),
334            Some(normalize_host_path(&dir.path().join("src/main.rs")).as_str())
335        );
336    }
337
338    #[test]
339    fn parent_escape_is_invalid() {
340        let dir = tempfile::tempdir().unwrap();
341        let info = classify_workspace_path("../secret.txt", Some(dir.path()));
342        assert_eq!(info.kind, WorkspacePathKind::Invalid);
343        assert_eq!(
344            info.reason.as_deref(),
345            Some("workspace-relative path escapes the workspace root")
346        );
347    }
348
349    #[test]
350    fn windows_drive_relative_path_is_not_host_absolute() {
351        let dir = tempfile::tempdir().unwrap();
352        let info = classify_workspace_path("C:src/main.harn", Some(dir.path()));
353        assert_eq!(info.kind, WorkspacePathKind::WorkspaceRelative);
354        assert_eq!(info.workspace_path.as_deref(), Some("C:src/main.harn"));
355    }
356
357    #[test]
358    fn absolute_path_inside_workspace_gets_relative_projection() {
359        let dir = tempfile::tempdir().unwrap();
360        let file = dir.path().join("packages/app/host.harn");
361        std::fs::create_dir_all(file.parent().unwrap()).unwrap();
362        std::fs::write(&file, "ok").unwrap();
363        let info = classify_workspace_path(file.to_string_lossy().as_ref(), Some(dir.path()));
364        assert_eq!(info.kind, WorkspacePathKind::HostAbsolute);
365        assert_eq!(
366            info.workspace_path.as_deref(),
367            Some("packages/app/host.harn")
368        );
369        assert!(!info.recovered_root_drift);
370    }
371
372    #[test]
373    fn leading_slash_workspace_drift_recovers_when_workspace_candidate_exists() {
374        let dir = tempfile::tempdir().unwrap();
375        let file = dir.path().join("packages/app/host.harn");
376        std::fs::create_dir_all(file.parent().unwrap()).unwrap();
377        std::fs::write(&file, "ok").unwrap();
378        let info = classify_workspace_path("/packages/app/host.harn", Some(dir.path()));
379        assert_eq!(info.kind, WorkspacePathKind::WorkspaceRelative);
380        assert_eq!(
381            info.workspace_path.as_deref(),
382            Some("packages/app/host.harn")
383        );
384        assert!(info.recovered_root_drift);
385    }
386
387    #[test]
388    fn unknown_absolute_path_stays_host_absolute() {
389        let dir = tempfile::tempdir().unwrap();
390        let info = classify_workspace_path("/tmp/harn-issue-125-nope", Some(dir.path()));
391        assert_eq!(info.kind, WorkspacePathKind::HostAbsolute);
392        assert!(info.workspace_path.is_none());
393        assert!(!info.recovered_root_drift);
394    }
395
396    #[test]
397    fn normalize_workspace_path_returns_relative_projection() {
398        let dir = tempfile::tempdir().unwrap();
399        std::fs::create_dir_all(dir.path().join("packages/app")).unwrap();
400        assert_eq!(
401            normalize_workspace_path("/packages/app", Some(dir.path())).as_deref(),
402            Some("packages/app")
403        );
404    }
405
406    #[test]
407    fn canonical_existing_workspace_path_returns_contained_relative_child() {
408        let root = tempfile::tempdir().unwrap();
409        let file = root.path().join("packages/app/test.harn");
410        std::fs::create_dir_all(file.parent().unwrap()).unwrap();
411        std::fs::write(&file, "ok").unwrap();
412
413        assert_eq!(
414            canonicalize_existing_workspace_path(Path::new("packages/app/test.harn"), root.path(),)
415                .as_deref(),
416            Some("packages/app/test.harn")
417        );
418        assert_eq!(
419            canonicalize_existing_workspace_path(&file, root.path()).as_deref(),
420            Some("packages/app/test.harn")
421        );
422    }
423
424    #[test]
425    fn canonical_existing_workspace_path_rejects_missing_and_parent_escape() {
426        let parent = tempfile::tempdir().unwrap();
427        let root = parent.path().join("workspace");
428        std::fs::create_dir(&root).unwrap();
429        let secret = parent.path().join("secret.harn");
430        std::fs::write(&secret, "secret").unwrap();
431
432        assert_eq!(
433            canonicalize_existing_workspace_path(Path::new("missing.harn"), &root),
434            None
435        );
436        assert_eq!(
437            canonicalize_existing_workspace_path(Path::new(""), &root),
438            None
439        );
440        assert_eq!(
441            canonicalize_existing_workspace_path(Path::new("../secret.harn"), &root),
442            None
443        );
444        assert_eq!(canonicalize_existing_workspace_path(&secret, &root), None);
445    }
446
447    #[cfg(unix)]
448    #[test]
449    fn canonical_existing_workspace_path_rejects_outside_symlink_and_projects_inside_one() {
450        let root = tempfile::tempdir().unwrap();
451        let outside = tempfile::tempdir().unwrap();
452        let secret = outside.path().join("secret.harn");
453        std::fs::write(&secret, "secret").unwrap();
454        std::os::unix::fs::symlink(&secret, root.path().join("outside-link")).unwrap();
455
456        let actual = root.path().join("actual/test.harn");
457        std::fs::create_dir_all(actual.parent().unwrap()).unwrap();
458        std::fs::write(&actual, "ok").unwrap();
459        std::os::unix::fs::symlink("actual", root.path().join("inside-link")).unwrap();
460
461        assert_eq!(
462            canonicalize_existing_workspace_path(&root.path().join("outside-link"), root.path()),
463            None
464        );
465        assert_eq!(
466            canonicalize_existing_workspace_path(
467                &root.path().join("inside-link/test.harn"),
468                root.path(),
469            )
470            .as_deref(),
471            Some("actual/test.harn")
472        );
473    }
474
475    #[cfg(unix)]
476    #[test]
477    fn canonical_existing_workspace_path_accepts_a_symlinked_workspace_root() {
478        let parent = tempfile::tempdir().unwrap();
479        let actual_root = parent.path().join("actual-root");
480        let file = actual_root.join("nested/test.harn");
481        std::fs::create_dir_all(file.parent().unwrap()).unwrap();
482        std::fs::write(&file, "ok").unwrap();
483        let linked_root = parent.path().join("workspace-link");
484        std::os::unix::fs::symlink("actual-root", &linked_root).unwrap();
485
486        assert_eq!(
487            canonicalize_existing_workspace_path(Path::new("nested/test.harn"), &linked_root)
488                .as_deref(),
489            Some("nested/test.harn")
490        );
491    }
492}