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_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}