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_path_syntax(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
208/// Return whether a serialized path is absolute in either POSIX or Windows
209/// syntax, independent of the host running Harn.
210pub(crate) fn is_absolute_path_syntax(path: &str) -> bool {
211    let path = to_posix(path);
212    if path.starts_with('/') {
213        return true;
214    }
215    let bytes = path.as_bytes();
216    bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/'
217}
218
219fn split_segments(path: &str) -> (bool, Option<String>, Vec<String>) {
220    let posix = to_posix(path);
221    let mut drive: Option<String> = None;
222    let mut rest = posix.as_str();
223    let bytes = posix.as_bytes();
224    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
225        let (drive_prefix, remainder) = posix.split_at(2);
226        drive = Some(drive_prefix.to_string());
227        rest = remainder;
228    }
229    let absolute = rest.starts_with('/');
230    let segments = rest
231        .split('/')
232        .filter(|segment| !segment.is_empty())
233        .map(|segment| segment.to_string())
234        .collect();
235    (absolute, drive, segments)
236}
237
238fn normalize_lexical(path: &str) -> String {
239    let (absolute, drive, segments) = split_segments(path);
240    let mut stack = Vec::new();
241    for segment in segments {
242        match segment.as_str() {
243            "." => {}
244            ".." => {
245                if let Some(top) = stack.last() {
246                    if top != ".." {
247                        stack.pop();
248                        continue;
249                    }
250                }
251                if !absolute {
252                    stack.push("..".to_string());
253                }
254            }
255            _ => stack.push(segment),
256        }
257    }
258
259    let mut normalized = String::new();
260    if let Some(drive) = drive {
261        normalized.push_str(&drive);
262    }
263    if absolute {
264        normalized.push('/');
265    }
266    normalized.push_str(&stack.join("/"));
267    if normalized.is_empty() {
268        ".".to_string()
269    } else {
270        normalized
271    }
272}
273
274fn normalize_host_path(path: &Path) -> String {
275    normalize_lexical(&path.to_string_lossy())
276}
277
278fn escapes_workspace(path: &str) -> bool {
279    path == ".." || path.starts_with("../")
280}
281
282fn workspace_relative_from_absolute(path: &str, workspace_root: &str) -> Option<String> {
283    let (path_abs, path_drive, path_segments) = split_segments(path);
284    let (root_abs, root_drive, root_segments) = split_segments(workspace_root);
285    if !path_abs || !root_abs || path_drive != root_drive {
286        return None;
287    }
288    if path_segments.len() < root_segments.len()
289        || !path_segments.starts_with(root_segments.as_slice())
290    {
291        return None;
292    }
293    let remainder = &path_segments[root_segments.len()..];
294    if remainder.is_empty() {
295        Some(".".to_string())
296    } else {
297        Some(remainder.join("/"))
298    }
299}
300
301fn recover_root_drift(path: &str, workspace_root: &Path) -> Option<String> {
302    let posix = to_posix(path);
303    if !posix.starts_with('/') {
304        return None;
305    }
306    let trimmed = posix.trim_start_matches('/');
307    if trimmed.is_empty() {
308        return None;
309    }
310    let workspace_path = normalize_lexical(trimmed);
311    if workspace_path == "." || escapes_workspace(&workspace_path) {
312        return None;
313    }
314    if Path::new(path).exists() {
315        return None;
316    }
317    let candidate = workspace_root.join(PathBuf::from(workspace_path.as_str()));
318    if candidate.exists() || candidate.parent().is_some_and(Path::exists) {
319        Some(workspace_path)
320    } else {
321        None
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn relative_path_is_workspace_relative() {
331        let dir = tempfile::tempdir().unwrap();
332        let info = classify_workspace_path("src/main.rs", Some(dir.path()));
333        assert_eq!(info.kind, WorkspacePathKind::WorkspaceRelative);
334        assert_eq!(info.workspace_path.as_deref(), Some("src/main.rs"));
335        assert_eq!(
336            info.host_path.as_deref(),
337            Some(normalize_host_path(&dir.path().join("src/main.rs")).as_str())
338        );
339    }
340
341    #[test]
342    fn parent_escape_is_invalid() {
343        let dir = tempfile::tempdir().unwrap();
344        let info = classify_workspace_path("../secret.txt", Some(dir.path()));
345        assert_eq!(info.kind, WorkspacePathKind::Invalid);
346        assert_eq!(
347            info.reason.as_deref(),
348            Some("workspace-relative path escapes the workspace root")
349        );
350    }
351
352    #[test]
353    fn windows_drive_relative_path_is_not_host_absolute() {
354        let dir = tempfile::tempdir().unwrap();
355        let info = classify_workspace_path("C:src/main.harn", Some(dir.path()));
356        assert_eq!(info.kind, WorkspacePathKind::WorkspaceRelative);
357        assert_eq!(info.workspace_path.as_deref(), Some("C:src/main.harn"));
358    }
359
360    #[test]
361    fn absolute_path_inside_workspace_gets_relative_projection() {
362        let dir = tempfile::tempdir().unwrap();
363        let file = dir.path().join("packages/app/host.harn");
364        std::fs::create_dir_all(file.parent().unwrap()).unwrap();
365        std::fs::write(&file, "ok").unwrap();
366        let info = classify_workspace_path(file.to_string_lossy().as_ref(), Some(dir.path()));
367        assert_eq!(info.kind, WorkspacePathKind::HostAbsolute);
368        assert_eq!(
369            info.workspace_path.as_deref(),
370            Some("packages/app/host.harn")
371        );
372        assert!(!info.recovered_root_drift);
373    }
374
375    #[test]
376    fn leading_slash_workspace_drift_recovers_when_workspace_candidate_exists() {
377        let dir = tempfile::tempdir().unwrap();
378        let file = dir.path().join("packages/app/host.harn");
379        std::fs::create_dir_all(file.parent().unwrap()).unwrap();
380        std::fs::write(&file, "ok").unwrap();
381        let info = classify_workspace_path("/packages/app/host.harn", Some(dir.path()));
382        assert_eq!(info.kind, WorkspacePathKind::WorkspaceRelative);
383        assert_eq!(
384            info.workspace_path.as_deref(),
385            Some("packages/app/host.harn")
386        );
387        assert!(info.recovered_root_drift);
388    }
389
390    #[test]
391    fn unknown_absolute_path_stays_host_absolute() {
392        let dir = tempfile::tempdir().unwrap();
393        let info = classify_workspace_path("/tmp/harn-issue-125-nope", Some(dir.path()));
394        assert_eq!(info.kind, WorkspacePathKind::HostAbsolute);
395        assert!(info.workspace_path.is_none());
396        assert!(!info.recovered_root_drift);
397    }
398
399    #[test]
400    fn normalize_workspace_path_returns_relative_projection() {
401        let dir = tempfile::tempdir().unwrap();
402        std::fs::create_dir_all(dir.path().join("packages/app")).unwrap();
403        assert_eq!(
404            normalize_workspace_path("/packages/app", Some(dir.path())).as_deref(),
405            Some("packages/app")
406        );
407    }
408
409    #[test]
410    fn canonical_existing_workspace_path_returns_contained_relative_child() {
411        let root = tempfile::tempdir().unwrap();
412        let file = root.path().join("packages/app/test.harn");
413        std::fs::create_dir_all(file.parent().unwrap()).unwrap();
414        std::fs::write(&file, "ok").unwrap();
415
416        assert_eq!(
417            canonicalize_existing_workspace_path(Path::new("packages/app/test.harn"), root.path(),)
418                .as_deref(),
419            Some("packages/app/test.harn")
420        );
421        assert_eq!(
422            canonicalize_existing_workspace_path(&file, root.path()).as_deref(),
423            Some("packages/app/test.harn")
424        );
425    }
426
427    #[test]
428    fn canonical_existing_workspace_path_rejects_missing_and_parent_escape() {
429        let parent = tempfile::tempdir().unwrap();
430        let root = parent.path().join("workspace");
431        std::fs::create_dir(&root).unwrap();
432        let secret = parent.path().join("secret.harn");
433        std::fs::write(&secret, "secret").unwrap();
434
435        assert_eq!(
436            canonicalize_existing_workspace_path(Path::new("missing.harn"), &root),
437            None
438        );
439        assert_eq!(
440            canonicalize_existing_workspace_path(Path::new(""), &root),
441            None
442        );
443        assert_eq!(
444            canonicalize_existing_workspace_path(Path::new("../secret.harn"), &root),
445            None
446        );
447        assert_eq!(canonicalize_existing_workspace_path(&secret, &root), None);
448    }
449
450    #[cfg(unix)]
451    #[test]
452    fn canonical_existing_workspace_path_rejects_outside_symlink_and_projects_inside_one() {
453        let root = tempfile::tempdir().unwrap();
454        let outside = tempfile::tempdir().unwrap();
455        let secret = outside.path().join("secret.harn");
456        std::fs::write(&secret, "secret").unwrap();
457        std::os::unix::fs::symlink(&secret, root.path().join("outside-link")).unwrap();
458
459        let actual = root.path().join("actual/test.harn");
460        std::fs::create_dir_all(actual.parent().unwrap()).unwrap();
461        std::fs::write(&actual, "ok").unwrap();
462        std::os::unix::fs::symlink("actual", root.path().join("inside-link")).unwrap();
463
464        assert_eq!(
465            canonicalize_existing_workspace_path(&root.path().join("outside-link"), root.path()),
466            None
467        );
468        assert_eq!(
469            canonicalize_existing_workspace_path(
470                &root.path().join("inside-link/test.harn"),
471                root.path(),
472            )
473            .as_deref(),
474            Some("actual/test.harn")
475        );
476    }
477
478    #[cfg(unix)]
479    #[test]
480    fn canonical_existing_workspace_path_accepts_a_symlinked_workspace_root() {
481        let parent = tempfile::tempdir().unwrap();
482        let actual_root = parent.path().join("actual-root");
483        let file = actual_root.join("nested/test.harn");
484        std::fs::create_dir_all(file.parent().unwrap()).unwrap();
485        std::fs::write(&file, "ok").unwrap();
486        let linked_root = parent.path().join("workspace-link");
487        std::os::unix::fs::symlink("actual-root", &linked_root).unwrap();
488
489        assert_eq!(
490            canonicalize_existing_workspace_path(Path::new("nested/test.harn"), &linked_root)
491                .as_deref(),
492            Some("nested/test.harn")
493        );
494    }
495}