1use super::canonicalize::{canonicalize, canonicalize_async};
2use super::normalize::normalize_path;
3use anyhow::{Context, Result, anyhow, bail};
4use std::path::{Component, Path, PathBuf};
5use tracing::warn;
6
7pub fn validate_no_escaping_symlink_ancestors(path: &Path, allow_missing_tail: bool) -> std::io::Result<()> {
22 use std::fs;
23
24 let components = path.components().collect::<Vec<_>>();
25 let mut current = PathBuf::new();
26 for (index, component) in components.iter().enumerate() {
27 if matches!(component, Component::ParentDir) {
28 return Err(std::io::Error::other(format!("path contains traversal: {}", path.display())));
29 }
30 if matches!(component, Component::CurDir) {
31 continue;
32 }
33 current.push(component.as_os_str());
34 let is_leaf = index + 1 == components.len();
35 let metadata = match fs::symlink_metadata(¤t) {
36 Ok(metadata) => metadata,
37 Err(error) if allow_missing_tail && error.kind() == std::io::ErrorKind::NotFound => break,
38 Err(error) => return Err(error),
39 };
40 if metadata.file_type().is_symlink() {
41 if is_leaf {
42 return Err(std::io::Error::other(format!("refusing symlink path {}", current.display())));
43 }
44 let parent = current
45 .parent()
46 .filter(|parent| !parent.as_os_str().is_empty())
47 .unwrap_or_else(|| Path::new("."));
48 let canonical_parent = canonicalize(parent)?;
49 let canonical_target = canonicalize(¤t)?;
50 if !canonical_target.starts_with(&canonical_parent) {
51 return Err(std::io::Error::other(format!(
52 "path component {} escapes its containing directory",
53 current.display()
54 )));
55 }
56 if !fs::metadata(¤t)?.is_dir() {
57 return Err(std::io::Error::other(format!("path component {} is not a directory", current.display())));
58 }
59 } else if !is_leaf && !metadata.is_dir() {
60 return Err(std::io::Error::other(format!("path component {} is not a directory", current.display())));
61 }
62 }
63 Ok(())
64}
65
66pub fn workspace_relative_display(workspace_root: &Path, path: &Path) -> String {
73 let candidate = if path.is_absolute() {
74 path.to_path_buf()
75 } else {
76 workspace_root.join(path)
77 };
78
79 if let Ok(canonical_workspace) = canonicalize(workspace_root) {
80 match canonicalize_for_display(&candidate) {
84 DisplayResolution::Resolved(canonical_candidate) => {
85 return canonical_candidate
86 .strip_prefix(&canonical_workspace)
87 .map(|relative| relative.to_string_lossy().into_owned())
88 .unwrap_or_else(|_| path.to_string_lossy().into_owned());
89 }
90 DisplayResolution::Unresolved => return path.to_string_lossy().into_owned(),
91 }
92 }
93
94 let normalized_candidate = normalize_path(&candidate);
98 let normalized_workspace = normalize_path(workspace_root);
99 if let Ok(relative) = normalized_candidate.strip_prefix(normalized_workspace) {
100 return relative.to_string_lossy().into_owned();
101 }
102 path.to_string_lossy().into_owned()
103}
104
105enum DisplayResolution {
106 Resolved(PathBuf),
107 Unresolved,
108}
109
110fn canonicalize_for_display(path: &Path) -> DisplayResolution {
111 if let Ok(canonical) = canonicalize(path) {
112 return DisplayResolution::Resolved(canonical);
113 }
114
115 let mut missing_tail = Vec::new();
116 let mut existing_prefix = path;
117 loop {
118 match std::fs::symlink_metadata(existing_prefix) {
119 Ok(_) => break,
120 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
121 let Some(file_name) = existing_prefix.file_name() else {
122 return DisplayResolution::Unresolved;
123 };
124 let Some(parent) = existing_prefix.parent() else {
125 return DisplayResolution::Unresolved;
126 };
127 missing_tail.push(PathBuf::from(file_name));
128 existing_prefix = parent;
129 }
130 Err(_) => return DisplayResolution::Unresolved,
131 }
132 }
133
134 let Ok(mut canonical) = canonicalize(existing_prefix) else {
135 return DisplayResolution::Unresolved;
138 };
139 for component in missing_tail.into_iter().rev() {
140 canonical.push(component);
141 }
142 DisplayResolution::Resolved(canonical)
143}
144
145pub fn resolve_workspace_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
147 let candidate = if user_path.is_absolute() {
148 user_path.to_path_buf()
149 } else {
150 workspace_root.join(user_path)
151 };
152
153 let canonical =
154 canonicalize(&candidate).with_context(|| format!("Failed to canonicalize path {}", candidate.display()))?;
155
156 let workspace_canonical = canonicalize(workspace_root)
157 .with_context(|| format!("Failed to canonicalize workspace root {}", workspace_root.display()))?;
158
159 if !canonical.starts_with(&workspace_canonical) {
160 return Err(anyhow!("Path {} escapes workspace root {}", canonical.display(), workspace_canonical.display()));
161 }
162
163 Ok(canonical)
164}
165
166pub fn secure_path(workspace_root: &Path, user_path: &Path) -> Result<PathBuf> {
170 resolve_workspace_path(workspace_root, user_path)
172}
173
174pub fn ensure_path_within_workspace(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
184 let normalized_candidate = normalize_path(candidate);
185 let normalized_workspace = normalize_path(workspace_root);
186
187 if !normalized_candidate.starts_with(&normalized_workspace) {
188 bail!("Path '{}' escapes workspace '{}'", candidate.display(), workspace_root.display());
189 }
190
191 Ok(normalized_candidate)
192}
193
194pub async fn ensure_path_within_workspace_resolved(candidate: &Path, workspace_root: &Path) -> Result<PathBuf> {
212 let normalized_root = normalize_path(workspace_root);
213 let normalized_candidate = normalize_path(candidate);
214
215 let canonical_root = match canonicalize_async(&normalized_root).await {
216 Ok(resolved) => resolved,
217 Err(error) => {
218 warn!(
219 path = %normalized_root.display(),
220 %error,
221 "Failed to canonicalize workspace root; falling back to provided path"
222 );
223 normalized_root.clone()
224 }
225 };
226
227 if normalized_root == normalized_candidate {
228 return Ok(normalized_candidate);
229 }
230
231 let relative = normalized_candidate
232 .strip_prefix(&normalized_root)
233 .map_err(|_error| anyhow!("path '{}' escapes the workspace root", candidate.display()))?
234 .to_path_buf();
235
236 let mut prefix = normalized_root.clone();
237 let mut components = relative.components().peekable();
238
239 while let Some(component) = components.next() {
240 prefix.push(component.as_os_str());
241
242 let metadata = match tokio::fs::symlink_metadata(&prefix).await {
243 Ok(metadata) => metadata,
244 Err(error) => {
245 if error.kind() == std::io::ErrorKind::NotFound {
246 break;
247 }
248 return Err(error).with_context(|| format!("failed to inspect path component '{}'", prefix.display()));
249 }
250 };
251
252 let resolved = canonicalize_async(&prefix)
253 .await
254 .with_context(|| format!("failed to canonicalize path component '{}'", prefix.display()))?;
255
256 if metadata.file_type().is_symlink() {
257 if !resolved.starts_with(&canonical_root) {
258 return Err(anyhow!(
259 "path '{}' escapes the workspace root via symlink '{}'",
260 candidate.display(),
261 prefix.display()
262 ));
263 }
264 } else {
265 if !resolved.starts_with(&canonical_root) {
266 return Err(anyhow!(
267 "path '{}' escapes the workspace root via component '{}'",
268 candidate.display(),
269 prefix.display()
270 ));
271 }
272
273 if metadata.is_file() && components.peek().is_some() {
274 return Err(anyhow!(
275 "path '{}' traverses through file component '{}'",
276 candidate.display(),
277 prefix.display()
278 ));
279 }
280 }
281 }
282
283 Ok(normalized_candidate)
284}
285
286#[cfg(test)]
287mod tests {
288 use super::super::canonicalize::canonicalize;
289 use super::*;
290 use std::path::{Path, PathBuf};
291
292 #[test]
293 fn workspace_relative_display_uses_workspace_relative_paths() {
294 let workspace = Path::new("/workspace");
295 let path = Path::new("/workspace/src/main.rs");
296
297 assert_eq!(workspace_relative_display(workspace, path), "src/main.rs");
298 }
299
300 #[test]
301 fn workspace_relative_display_preserves_external_paths() {
302 let workspace = Path::new("/workspace");
303 let path = Path::new("/tmp/external.txt");
304
305 assert_eq!(workspace_relative_display(workspace, path), "/tmp/external.txt");
306 }
307
308 #[test]
309 fn workspace_relative_display_handles_canonical_workspace_paths() {
310 let workspace = tempfile::tempdir().unwrap();
311 let workspace_path = canonicalize(workspace.path()).unwrap();
312 let path = workspace_path.join("docs").join("guide.md");
313
314 assert_eq!(workspace_relative_display(workspace.path(), &path), "docs/guide.md");
315 }
316
317 #[cfg(unix)]
318 #[test]
319 fn workspace_relative_display_resolves_workspace_aliases() {
320 use std::os::unix::fs::symlink;
321
322 let temp = tempfile::tempdir().unwrap();
323 let workspace = temp.path().join("workspace");
324 std::fs::create_dir_all(workspace.join("src")).unwrap();
325 let alias = temp.path().join("workspace-alias");
326 symlink(&workspace, &alias).unwrap();
327
328 let path = alias.join("src").join("main.rs");
329 assert_eq!(workspace_relative_display(&workspace, &path), "src/main.rs");
330 }
331
332 #[cfg(unix)]
333 #[test]
334 fn workspace_relative_display_preserves_symlink_escape() {
335 use std::os::unix::fs::symlink;
336
337 let temp = tempfile::tempdir().unwrap();
338 let workspace = temp.path().join("workspace");
339 let outside = temp.path().join("outside");
340 std::fs::create_dir_all(&workspace).unwrap();
341 std::fs::create_dir_all(&outside).unwrap();
342 std::fs::write(outside.join("secret.txt"), "secret").unwrap();
343 symlink(&outside, workspace.join("linked-outside")).unwrap();
344
345 let path = workspace.join("linked-outside").join("secret.txt");
346 assert_eq!(workspace_relative_display(&workspace, &path), path.to_string_lossy());
347 }
348
349 #[cfg(unix)]
350 #[test]
351 fn workspace_relative_display_fails_closed_for_dangling_symlink() {
352 use std::os::unix::fs::symlink;
353
354 let temp = tempfile::tempdir().unwrap();
355 let workspace = temp.path().join("workspace");
356 std::fs::create_dir_all(&workspace).unwrap();
357 let path = workspace.join("dangling-link");
358 symlink(temp.path().join("missing-target"), &path).unwrap();
359
360 assert_eq!(workspace_relative_display(&workspace, &path), path.to_string_lossy());
361 }
362
363 #[test]
364 fn ensures_path_within_workspace_accepts_nested_path() {
365 let workspace = Path::new("/tmp/project");
366 let candidate = Path::new("/tmp/project/src/../src/lib.rs");
367 let normalized = ensure_path_within_workspace(candidate, workspace).unwrap();
368 assert_eq!(normalized, PathBuf::from("/tmp/project/src/lib.rs"));
369 }
370
371 #[test]
372 fn ensures_path_within_workspace_rejects_escape() {
373 let workspace = Path::new("/tmp/project");
374 let candidate = Path::new("/tmp/project/../../etc/passwd");
375 assert!(ensure_path_within_workspace(candidate, workspace).is_err());
376 }
377
378 #[tokio::test]
379 async fn resolved_check_accepts_nested_existing_path() {
380 let workspace = tempfile::tempdir().unwrap();
381 let root = canonicalize(workspace.path()).unwrap();
382 let nested = root.join("src");
383 tokio::fs::create_dir_all(&nested).await.unwrap();
384 let file = nested.join("lib.rs");
385 tokio::fs::write(&file, b"test").await.unwrap();
386
387 let result = ensure_path_within_workspace_resolved(&file, &root).await;
388 assert_eq!(result.unwrap(), file);
389 }
390
391 #[tokio::test]
392 async fn resolved_check_accepts_missing_tail_components() {
393 let workspace = tempfile::tempdir().unwrap();
394 let root = canonicalize(workspace.path()).unwrap();
395 let missing = root.join("new_dir/new_file.txt");
396
397 let result = ensure_path_within_workspace_resolved(&missing, &root).await;
398 assert_eq!(result.unwrap(), missing);
399 }
400
401 #[tokio::test]
402 async fn resolved_check_rejects_lexical_escape() {
403 let workspace = tempfile::tempdir().unwrap();
404 let root = canonicalize(workspace.path()).unwrap();
405 let escape = root.join("../outside.txt");
406
407 assert!(ensure_path_within_workspace_resolved(&escape, &root).await.is_err());
408 }
409
410 #[cfg(unix)]
411 #[tokio::test]
412 async fn resolved_check_rejects_symlink_escape() {
413 let workspace = tempfile::tempdir().unwrap();
414 let outside = tempfile::tempdir().unwrap();
415 let root = canonicalize(workspace.path()).unwrap();
416 let outside_dir = canonicalize(outside.path()).unwrap();
417
418 let link = root.join("escape");
419 tokio::fs::symlink(&outside_dir, &link).await.unwrap();
420
421 let candidate = link.join("secret.txt");
422 assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
423 }
424
425 #[cfg(unix)]
426 #[tokio::test]
427 async fn resolved_check_accepts_symlink_within_workspace() {
428 let workspace = tempfile::tempdir().unwrap();
429 let root = canonicalize(workspace.path()).unwrap();
430 let target = root.join("real");
431 tokio::fs::create_dir_all(&target).await.unwrap();
432 let link = root.join("alias");
433 tokio::fs::symlink(&target, &link).await.unwrap();
434
435 let candidate = link.join("file.txt");
436 assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_ok());
437 }
438
439 #[tokio::test]
440 async fn resolved_check_rejects_traversal_through_file() {
441 let workspace = tempfile::tempdir().unwrap();
442 let root = canonicalize(workspace.path()).unwrap();
443 let file = root.join("data.txt");
444 tokio::fs::write(&file, b"test").await.unwrap();
445
446 let candidate = file.join("child.txt");
447 assert!(ensure_path_within_workspace_resolved(&candidate, &root).await.is_err());
448 }
449
450 #[test]
451 fn symlink_ancestor_validation_rejects_traversal_and_missing_tail() {
452 let temp = tempfile::tempdir().expect("tempdir");
453 let missing = temp.path().join("absent").join("child");
454 assert!(validate_no_escaping_symlink_ancestors(&missing, true).is_ok());
455 assert!(validate_no_escaping_symlink_ancestors(&missing, false).is_err());
456 assert!(validate_no_escaping_symlink_ancestors(Path::new("a/../b"), false).is_err());
457 assert!(validate_no_escaping_symlink_ancestors(temp.path(), false).is_ok());
458 }
459
460 #[cfg(unix)]
461 #[test]
462 fn symlink_ancestor_validation_rejects_escaping_links_but_allows_in_parent_aliases() {
463 use std::os::unix::fs::symlink;
464
465 let temp = tempfile::tempdir().expect("tempdir");
466 let outside = tempfile::tempdir().expect("outside tempdir");
467 let escape = temp.path().join("escape");
468 symlink(outside.path(), &escape).expect("symlink");
469 let error = validate_no_escaping_symlink_ancestors(&escape.join("child"), false)
470 .expect_err("escaping ancestor must fail");
471 assert!(error.to_string().contains("escapes its containing directory"), "{error}");
472 let leaf_error = validate_no_escaping_symlink_ancestors(&escape, false).expect_err("symlinked leaf must fail");
473 assert!(leaf_error.to_string().contains("refusing symlink path"), "{leaf_error}");
474
475 let inner = temp.path().join("inner");
476 std::fs::create_dir(&inner).expect("mkdir");
477 symlink(&inner, temp.path().join("alias")).expect("symlink");
478 assert!(validate_no_escaping_symlink_ancestors(&temp.path().join("alias").join("child"), true).is_ok());
482 assert!(validate_no_escaping_symlink_ancestors(&temp.path().join("alias"), false).is_err());
483 }
484}