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 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
208pub(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}