1use super::canonicalize::{canonicalize, canonicalize_async};
2use super::normalize::normalize_path;
3use anyhow::{Context, Result, anyhow, bail};
4use std::fs::FileType;
5use std::path::{Component, Path, PathBuf};
6use tracing::warn;
7
8pub fn validate_no_escaping_symlink_ancestors(path: &Path, allow_missing_tail: bool) -> std::io::Result<()> {
23 use std::fs;
24
25 let components = path.components().collect::<Vec<_>>();
26 let mut current = PathBuf::new();
27 for (index, component) in components.iter().enumerate() {
28 if matches!(component, Component::ParentDir) {
29 return Err(std::io::Error::other(format!("path contains traversal: {}", path.display())));
30 }
31 if matches!(component, Component::CurDir) {
32 continue;
33 }
34 current.push(component.as_os_str());
35 let is_leaf = index + 1 == components.len();
36 let metadata = match fs::symlink_metadata(¤t) {
37 Ok(metadata) => metadata,
38 Err(error) if allow_missing_tail && error.kind() == std::io::ErrorKind::NotFound => break,
39 Err(error) => return Err(error),
40 };
41 if metadata.file_type().is_symlink() {
42 if is_leaf {
43 return Err(std::io::Error::other(format!("refusing symlink path {}", current.display())));
44 }
45 let parent = current
46 .parent()
47 .filter(|parent| !parent.as_os_str().is_empty())
48 .unwrap_or_else(|| Path::new("."));
49 let canonical_parent = canonicalize(parent)?;
50 let canonical_target = canonicalize(¤t)?;
51 if !canonical_target.starts_with(&canonical_parent) {
52 return Err(std::io::Error::other(format!(
53 "path component {} escapes its containing directory",
54 current.display()
55 )));
56 }
57 if !fs::metadata(¤t)?.is_dir() {
58 return Err(std::io::Error::other(format!("path component {} is not a directory", current.display())));
59 }
60 } else if !is_leaf && !metadata.is_dir() {
61 return Err(std::io::Error::other(format!("path component {} is not a directory", current.display())));
62 }
63 }
64 Ok(())
65}
66
67pub fn workspace_relative_display(workspace_root: &Path, path: &Path) -> String {
78 WorkspacePathDisplay::new(workspace_root).render(path)
79}
80
81pub fn workspace_relative_slash(workspace_root: &Path, path: &Path) -> String {
88 match path.strip_prefix(workspace_root) {
89 Ok(relative) if relative.as_os_str().is_empty() => ".".into(),
90 Ok(relative) => relative.to_string_lossy().replace('\\', "/"),
91 Err(_) => path.to_string_lossy().into_owned(),
92 }
93}
94
95#[derive(Clone)]
103pub struct WorkspacePathDisplay {
104 workspace_root: PathBuf,
105 canonical_root: Option<PathBuf>,
107}
108
109impl WorkspacePathDisplay {
110 pub fn new(workspace_root: &Path) -> Self {
112 let canonical_root = canonicalize(workspace_root).ok();
113 Self {
114 workspace_root: workspace_root.to_path_buf(),
115 canonical_root,
116 }
117 }
118
119 pub fn render(&self, path: &Path) -> String {
122 let candidate = if path.is_absolute() {
123 path.to_path_buf()
124 } else {
125 self.workspace_root.join(path)
126 };
127
128 if let Some(canonical_workspace) = self.canonical_root.as_ref() {
129 match canonicalize_for_display(&candidate) {
133 DisplayResolution::Resolved(canonical_candidate) => {
134 return canonical_candidate
135 .strip_prefix(canonical_workspace)
136 .map(|relative| relative.to_string_lossy().into_owned())
137 .unwrap_or_else(|_| path.to_string_lossy().into_owned());
138 }
139 DisplayResolution::Unresolved => return path.to_string_lossy().into_owned(),
140 }
141 }
142
143 let normalized_candidate = normalize_path(&candidate);
147 let normalized_workspace = normalize_path(&self.workspace_root);
148 if let Ok(relative) = normalized_candidate.strip_prefix(normalized_workspace) {
149 return relative.to_string_lossy().into_owned();
150 }
151 path.to_string_lossy().into_owned()
152 }
153
154 pub fn render_entry(&self, path: &Path, file_type: Option<FileType>) -> String {
170 if file_type.is_some_and(|file_type| !file_type.is_symlink())
171 && let Some(relative) = self.lexical_relative(path)
172 {
173 return relative;
174 }
175 self.render(path)
176 }
177
178 pub fn relative_lexical(&self, path: &Path) -> Option<String> {
190 self.lexical_relative(path)
191 }
192
193 fn lexical_relative(&self, path: &Path) -> Option<String> {
199 if !path.is_absolute()
200 || path
201 .components()
202 .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
203 {
204 return None;
205 }
206 let candidate = path;
207
208 self.canonical_root
209 .iter()
210 .chain(std::iter::once(&self.workspace_root))
211 .find_map(|root| {
212 candidate
213 .strip_prefix(normalize_path(root))
214 .ok()
215 .map(|relative| relative.to_string_lossy().into_owned())
216 })
217 }
218}
219
220enum DisplayResolution {
221 Resolved(PathBuf),
222 Unresolved,
223}
224
225fn canonicalize_for_display(path: &Path) -> DisplayResolution {
226 if let Ok(canonical) = canonicalize(path) {
227 return DisplayResolution::Resolved(canonical);
228 }
229
230 let mut missing_tail = Vec::new();
231 let mut existing_prefix = path;
232 loop {
233 match std::fs::symlink_metadata(existing_prefix) {
234 Ok(_) => break,
235 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
236 let Some(file_name) = existing_prefix.file_name() else {
237 return DisplayResolution::Unresolved;
238 };
239 let Some(parent) = existing_prefix.parent() else {
240 return DisplayResolution::Unresolved;
241 };
242 missing_tail.push(PathBuf::from(file_name));
243 existing_prefix = parent;
244 }
245 Err(_) => return DisplayResolution::Unresolved,
246 }
247 }
248
249 let Ok(mut canonical) = canonicalize(existing_prefix) else {
250 return DisplayResolution::Unresolved;
253 };
254 for component in missing_tail.into_iter().rev() {
255 canonical.push(component);
256 }
257 DisplayResolution::Resolved(canonical)
258}
259
260pub fn resolve_workspace_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
262 let candidate = if user_path.is_absolute() {
263 user_path.to_path_buf()
264 } else {
265 workspace_root.join(user_path)
266 };
267
268 let canonical =
269 canonicalize(&candidate).with_context(|| format!("Failed to canonicalize path {}", candidate.display()))?;
270
271 let workspace_canonical = canonicalize(workspace_root)
272 .with_context(|| format!("Failed to canonicalize workspace root {}", workspace_root.display()))?;
273
274 if !canonical.starts_with(&workspace_canonical) {
275 return Err(anyhow!("Path {} escapes workspace root {}", canonical.display(), workspace_canonical.display()));
276 }
277
278 Ok(canonical)
279}
280
281pub fn secure_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
285 resolve_workspace_path(workspace_root, user_path)
287}
288
289pub fn ensure_path_within_workspace(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
299 let normalized_candidate = normalize_path(candidate);
300 let normalized_workspace = normalize_path(workspace_root);
301
302 if !normalized_candidate.starts_with(&normalized_workspace) {
303 bail!("Path '{}' escapes workspace '{}'", candidate.display(), workspace_root.display());
304 }
305
306 Ok(normalized_candidate)
307}
308
309pub async fn ensure_path_within_workspace_resolved(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
327 let normalized_root = normalize_path(workspace_root);
328 let normalized_candidate = normalize_path(candidate);
329
330 let canonical_root = match canonicalize_async(&normalized_root).await {
331 Ok(resolved) => resolved,
332 Err(error) => {
333 warn!(
334 path = %normalized_root.display(),
335 %error,
336 "Failed to canonicalize workspace root; falling back to provided path"
337 );
338 normalized_root.clone()
339 }
340 };
341
342 if normalized_root == normalized_candidate {
343 return Ok(normalized_candidate);
344 }
345
346 let relative = normalized_candidate
347 .strip_prefix(&normalized_root)
348 .map_err(|_error| anyhow!("path '{}' escapes the workspace root", candidate.display()))?
349 .to_path_buf();
350
351 let mut prefix = normalized_root.clone();
352 let mut components = relative.components().peekable();
353
354 while let Some(component) = components.next() {
355 prefix.push(component.as_os_str());
356
357 let metadata = match tokio::fs::symlink_metadata(&prefix).await {
358 Ok(metadata) => metadata,
359 Err(error) => {
360 if error.kind() == std::io::ErrorKind::NotFound {
361 break;
362 }
363 return Err(error).with_context(|| format!("failed to inspect path component '{}'", prefix.display()));
364 }
365 };
366
367 let resolved = canonicalize_async(&prefix)
368 .await
369 .with_context(|| format!("failed to canonicalize path component '{}'", prefix.display()))?;
370
371 if metadata.file_type().is_symlink() {
372 if !resolved.starts_with(&canonical_root) {
373 return Err(anyhow!(
374 "path '{}' escapes the workspace root via symlink '{}'",
375 candidate.display(),
376 prefix.display()
377 ));
378 }
379 } else {
380 if !resolved.starts_with(&canonical_root) {
381 return Err(anyhow!(
382 "path '{}' escapes the workspace root via component '{}'",
383 candidate.display(),
384 prefix.display()
385 ));
386 }
387
388 if metadata.is_file() && components.peek().is_some() {
389 return Err(anyhow!(
390 "path '{}' traverses through file component '{}'",
391 candidate.display(),
392 prefix.display()
393 ));
394 }
395 }
396 }
397
398 Ok(normalized_candidate)
399}
400
401#[cfg(test)]
402mod tests {
403 use super::super::canonicalize::canonicalize;
404 use super::*;
405 use std::path::{Path, PathBuf};
406
407 #[test]
408 fn workspace_relative_display_uses_workspace_relative_paths() {
409 let workspace = Path::new("/workspace");
410 let path = Path::new("/workspace/src/main.rs");
411
412 assert_eq!(workspace_relative_display(workspace, path), "src/main.rs");
413 }
414
415 #[test]
416 fn workspace_relative_display_preserves_external_paths() {
417 let workspace = Path::new("/workspace");
418 let path = Path::new("/tmp/external.txt");
419
420 assert_eq!(workspace_relative_display(workspace, path), "/tmp/external.txt");
421 }
422
423 #[test]
424 fn workspace_relative_display_handles_canonical_workspace_paths() {
425 let workspace = tempfile::tempdir().unwrap();
426 let workspace_path = canonicalize(workspace.path()).unwrap();
427 let path = workspace_path.join("docs").join("guide.md");
428
429 assert_eq!(workspace_relative_display(workspace.path(), &path), "docs/guide.md");
430 }
431
432 #[cfg(unix)]
433 #[test]
434 fn workspace_relative_display_resolves_workspace_aliases() {
435 use std::os::unix::fs::symlink;
436
437 let temp = tempfile::tempdir().unwrap();
438 let workspace = temp.path().join("workspace");
439 std::fs::create_dir_all(workspace.join("src")).unwrap();
440 let alias = temp.path().join("workspace-alias");
441 symlink(&workspace, &alias).unwrap();
442
443 let path = alias.join("src").join("main.rs");
444 assert_eq!(workspace_relative_display(&workspace, &path), "src/main.rs");
445 }
446
447 #[cfg(unix)]
448 #[test]
449 fn workspace_relative_display_preserves_symlink_escape() {
450 use std::os::unix::fs::symlink;
451
452 let temp = tempfile::tempdir().unwrap();
453 let workspace = temp.path().join("workspace");
454 let outside = temp.path().join("outside");
455 std::fs::create_dir_all(&workspace).unwrap();
456 std::fs::create_dir_all(&outside).unwrap();
457 std::fs::write(outside.join("secret.txt"), "secret").unwrap();
458 symlink(&outside, workspace.join("linked-outside")).unwrap();
459
460 let path = workspace.join("linked-outside").join("secret.txt");
461 assert_eq!(workspace_relative_display(&workspace, &path), path.to_string_lossy());
462 }
463
464 #[cfg(unix)]
465 #[test]
466 fn workspace_relative_display_fails_closed_for_dangling_symlink() {
467 use std::os::unix::fs::symlink;
468
469 let temp = tempfile::tempdir().unwrap();
470 let workspace = temp.path().join("workspace");
471 std::fs::create_dir_all(&workspace).unwrap();
472 let path = workspace.join("dangling-link");
473 symlink(temp.path().join("missing-target"), &path).unwrap();
474
475 assert_eq!(workspace_relative_display(&workspace, &path), path.to_string_lossy());
476 }
477
478 #[test]
479 fn path_display_matches_one_shot_render_for_many_entries() {
480 let workspace = tempfile::tempdir().unwrap();
481 let root = canonicalize(workspace.path()).unwrap();
482 std::fs::create_dir_all(root.join("src/nested")).unwrap();
483 std::fs::write(root.join("src/nested/lib.rs"), "x").unwrap();
484
485 let display = WorkspacePathDisplay::new(workspace.path());
486 for relative in ["src", "src/nested", "src/nested/lib.rs"] {
487 let path = root.join(relative);
488 assert_eq!(display.render(&path), workspace_relative_display(workspace.path(), &path));
489 assert_eq!(display.render(&path), relative);
490 }
491 }
492
493 #[test]
494 fn path_display_entry_fast_path_agrees_with_render() {
495 let workspace = tempfile::tempdir().unwrap();
496 let root = canonicalize(workspace.path()).unwrap();
497 std::fs::create_dir_all(root.join("src")).unwrap();
498 std::fs::write(root.join("src/lib.rs"), "x").unwrap();
499
500 let display = WorkspacePathDisplay::new(workspace.path());
501 let file_type = std::fs::symlink_metadata(root.join("src/lib.rs")).unwrap().file_type();
502
503 assert_eq!(display.render_entry(&root.join("src/lib.rs"), Some(file_type)), "src/lib.rs");
504 assert_eq!(
506 display.render_entry(&root.join("src/lib.rs"), Some(file_type)),
507 display.render(&root.join("src/lib.rs"))
508 );
509 assert_eq!(display.render_entry(&root.join("src/lib.rs"), None), display.render(&root.join("src/lib.rs")));
511 }
512
513 #[test]
514 fn path_display_entry_fast_path_rejects_dot_components() {
515 let workspace = tempfile::tempdir().unwrap();
516 let root = canonicalize(workspace.path()).unwrap();
517 std::fs::create_dir_all(root.join("src")).unwrap();
518
519 let display = WorkspacePathDisplay::new(workspace.path());
520 let dotted = root.join("src").join("..").join("src").join("lib.rs");
521 let file_type = std::fs::symlink_metadata(root.join("src")).unwrap().file_type();
522
523 assert_eq!(display.render_entry(&dotted, Some(file_type)), display.render(&dotted));
526 }
527
528 #[cfg(unix)]
529 #[test]
530 fn path_display_entry_fast_path_defers_for_symlinks() {
531 use std::os::unix::fs::symlink;
532
533 let temp = tempfile::tempdir().unwrap();
534 let workspace = temp.path().join("workspace");
535 let outside = temp.path().join("outside");
536 std::fs::create_dir_all(&workspace).unwrap();
537 std::fs::create_dir_all(&outside).unwrap();
538 std::fs::write(outside.join("secret.txt"), "secret").unwrap();
539 let link = workspace.join("linked-outside");
540 symlink(&outside, &link).unwrap();
541
542 let display = WorkspacePathDisplay::new(&workspace);
543 let link_type = std::fs::symlink_metadata(&link).unwrap().file_type();
544 assert!(link_type.is_symlink());
545
546 assert_eq!(display.render_entry(&link, Some(link_type)), display.render(&link));
549 assert_eq!(display.render_entry(&link, Some(link_type)), link.to_string_lossy());
550 }
551
552 #[test]
553 fn ensures_path_within_workspace_accepts_nested_path() {
554 let workspace = Path::new("/tmp/project");
555 let candidate = Path::new("/tmp/project/src/../src/lib.rs");
556 let normalized = ensure_path_within_workspace(candidate, workspace).unwrap();
557 assert_eq!(normalized, PathBuf::from("/tmp/project/src/lib.rs"));
558 }
559
560 #[test]
561 fn ensures_path_within_workspace_rejects_escape() {
562 let workspace = Path::new("/tmp/project");
563 let candidate = Path::new("/tmp/project/../../etc/passwd");
564 assert!(ensure_path_within_workspace(candidate, workspace).is_err());
565 }
566
567 #[tokio::test]
568 async fn resolved_check_accepts_nested_existing_path() {
569 let workspace = tempfile::tempdir().unwrap();
570 let root = canonicalize(workspace.path()).unwrap();
571 let nested = root.join("src");
572 tokio::fs::create_dir_all(&nested).await.unwrap();
573 let file = nested.join("lib.rs");
574 tokio::fs::write(&file, b"test").await.unwrap();
575
576 let result = ensure_path_within_workspace_resolved(&file, &root).await;
577 assert_eq!(result.unwrap(), file);
578 }
579
580 #[tokio::test]
581 async fn resolved_check_accepts_missing_tail_components() {
582 let workspace = tempfile::tempdir().unwrap();
583 let root = canonicalize(workspace.path()).unwrap();
584 let missing = root.join("new_dir/new_file.txt");
585
586 let result = ensure_path_within_workspace_resolved(&missing, &root).await;
587 assert_eq!(result.unwrap(), missing);
588 }
589
590 #[tokio::test]
591 async fn resolved_check_rejects_lexical_escape() {
592 let workspace = tempfile::tempdir().unwrap();
593 let root = canonicalize(workspace.path()).unwrap();
594 let escape = root.join("../outside.txt");
595
596 assert!(ensure_path_within_workspace_resolved(&escape, &root).await.is_err());
597 }
598
599 #[cfg(unix)]
600 #[tokio::test]
601 async fn resolved_check_rejects_symlink_escape() {
602 let workspace = tempfile::tempdir().unwrap();
603 let outside = tempfile::tempdir().unwrap();
604 let root = canonicalize(workspace.path()).unwrap();
605 let outside_dir = canonicalize(outside.path()).unwrap();
606
607 let link = root.join("escape");
608 tokio::fs::symlink(&outside_dir, &link).await.unwrap();
609
610 let candidate = link.join("secret.txt");
611 assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
612 }
613
614 #[cfg(unix)]
615 #[tokio::test]
616 async fn resolved_check_accepts_symlink_within_workspace() {
617 let workspace = tempfile::tempdir().unwrap();
618 let root = canonicalize(workspace.path()).unwrap();
619 let target = root.join("real");
620 tokio::fs::create_dir_all(&target).await.unwrap();
621 let link = root.join("alias");
622 tokio::fs::symlink(&target, &link).await.unwrap();
623
624 let candidate = link.join("file.txt");
625 assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_ok());
626 }
627
628 #[tokio::test]
629 async fn resolved_check_rejects_traversal_through_file() {
630 let workspace = tempfile::tempdir().unwrap();
631 let root = canonicalize(workspace.path()).unwrap();
632 let file = root.join("data.txt");
633 tokio::fs::write(&file, b"test").await.unwrap();
634
635 let candidate = file.join("child.txt");
636 assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
637 }
638
639 #[test]
640 fn symlink_ancestor_validation_rejects_traversal_and_missing_tail() {
641 let temp = tempfile::tempdir().expect("tempdir");
642 let missing = temp.path().join("absent").join("child");
643 assert!(validate_no_escaping_symlink_ancestors(&missing, true).is_ok());
644 assert!(validate_no_escaping_symlink_ancestors(&missing, false).is_err());
645 assert!(validate_no_escaping_symlink_ancestors(Path::new("a/../b"), false).is_err());
646 assert!(validate_no_escaping_symlink_ancestors(temp.path(), false).is_ok());
647 }
648
649 #[cfg(unix)]
650 #[test]
651 fn symlink_ancestor_validation_rejects_escaping_links_but_allows_in_parent_aliases() {
652 use std::os::unix::fs::symlink;
653
654 let temp = tempfile::tempdir().expect("tempdir");
655 let outside = tempfile::tempdir().expect("outside tempdir");
656 let escape = temp.path().join("escape");
657 symlink(outside.path(), &escape).expect("symlink");
658 let error = validate_no_escaping_symlink_ancestors(&escape.join("child"), false)
659 .expect_err("escaping ancestor must fail");
660 assert!(error.to_string().contains("escapes its containing directory"), "{error}");
661 let leaf_error = validate_no_escaping_symlink_ancestors(&escape, false).expect_err("symlinked leaf must fail");
662 assert!(leaf_error.to_string().contains("refusing symlink path"), "{leaf_error}");
663
664 let inner = temp.path().join("inner");
665 std::fs::create_dir(&inner).expect("mkdir");
666 symlink(&inner, temp.path().join("alias")).expect("symlink");
667 assert!(validate_no_escaping_symlink_ancestors(&temp.path().join("alias").join("child"), true).is_ok());
671 assert!(validate_no_escaping_symlink_ancestors(&temp.path().join("alias"), false).is_err());
672 }
673}