1use std::io;
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4
5use tracing::warn;
6
7#[derive(Debug)]
9pub enum PathSecurityError {
10 PathTraversal { path: PathBuf, workspace: PathBuf },
12 SymlinkEscape { path: PathBuf, target: PathBuf, workspace: PathBuf },
14 CanonicalizationFailed { path: PathBuf, error: io::Error },
16}
17
18impl std::fmt::Display for PathSecurityError {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 match self {
21 PathSecurityError::PathTraversal { path, workspace } => {
22 write!(
23 f,
24 "Path traversal detected: '{}' escapes workspace '{}'. To allow paths outside \
25 the workspace, set WINX_ALLOW_PATHS in the winx server config (read once at \
26 startup — restart required; exporting it in a shell has no effect)",
27 path.display(),
28 workspace.display()
29 )
30 }
31 PathSecurityError::SymlinkEscape { path, target, workspace } => {
32 write!(
33 f,
34 "Symlink escape detected: '{}' points to '{}' outside workspace '{}'",
35 path.display(),
36 target.display(),
37 workspace.display()
38 )
39 }
40 PathSecurityError::CanonicalizationFailed { path, error } => {
41 write!(f, "Failed to resolve path '{}': {}", path.display(), error)
42 }
43 }
44 }
45}
46
47impl std::error::Error for PathSecurityError {}
48
49pub fn simplified(path: PathBuf) -> PathBuf {
63 #[cfg(windows)]
64 {
65 use std::path::{Component, Prefix};
66 let mut components = path.components();
67 let Some(Component::Prefix(prefix)) = components.next() else { return path };
68 let root = match prefix.kind() {
69 Prefix::VerbatimDisk(drive) => format!("{}:\\", char::from(drive)),
70 Prefix::VerbatimUNC(server, share) => {
71 format!("\\\\{}\\{}\\", server.to_string_lossy(), share.to_string_lossy())
72 }
73 _ => return path,
74 };
75 let mut plain = PathBuf::from(root);
76 plain.extend(components.filter(|component| !matches!(component, Component::RootDir)));
77 plain
78 }
79 #[cfg(not(windows))]
80 {
81 path
82 }
83}
84
85pub fn display_relative(path: &Path) -> String {
88 let rendered = path.to_string_lossy();
89 if cfg!(windows) {
90 rendered.replace('\\', "/")
91 } else {
92 rendered.into_owned()
93 }
94}
95
96fn parse_allow_paths(raw: &str) -> Vec<PathBuf> {
97 std::env::split_paths(raw)
100 .filter_map(|candidate| {
101 let entry = candidate.to_string_lossy().trim().to_string();
102 if entry.is_empty() {
103 return None;
104 }
105 let candidate = Path::new(&entry);
106 if !candidate.is_absolute() {
107 warn!("WINX_ALLOW_PATHS: ignoring '{entry}' (must be an absolute path)");
108 return None;
109 }
110 match candidate.canonicalize().map(crate::utils::path::simplified) {
111 Ok(root) => Some(root),
112 Err(e) => {
113 warn!("WINX_ALLOW_PATHS: ignoring '{entry}' ({e})");
114 None
115 }
116 }
117 })
118 .collect()
119}
120
121fn allowed_roots() -> &'static [PathBuf] {
134 static ROOTS: OnceLock<Vec<PathBuf>> = OnceLock::new();
135 ROOTS.get_or_init(|| {
136 let roots = crate::config::env_text("WINX_ALLOW_PATHS").map(|raw| parse_allow_paths(&raw));
137 let roots = roots.unwrap_or_default();
138 if !roots.is_empty() {
139 let list = roots.iter().map(|r| r.display().to_string()).collect::<Vec<_>>().join(", ");
140 warn!(
141 "WINX_ALLOW_PATHS active: the file tools may read and write outside the \
142 workspace, under: {list}"
143 );
144 }
145 roots
146 })
147}
148
149pub fn configured_allowed_roots() -> &'static [PathBuf] {
152 allowed_roots()
153}
154
155fn is_contained(
158 canonical_path: &Path,
159 canonical_workspace: &Path,
160 extra_roots: &[PathBuf],
161) -> bool {
162 canonical_path.starts_with(canonical_workspace)
163 || extra_roots.iter().any(|root| canonical_path.starts_with(root))
164}
165
166pub fn validate_path_in_workspace(
174 path: &Path,
175 workspace_root: &Path,
176) -> Result<PathBuf, PathSecurityError> {
177 validate_path_with_roots(path, workspace_root, allowed_roots())
178}
179
180pub(crate) fn validate_path_with_roots(
183 path: &Path,
184 workspace_root: &Path,
185 extra_roots: &[PathBuf],
186) -> Result<PathBuf, PathSecurityError> {
187 let canonical_workspace = workspace_root
190 .canonicalize()
191 .map(crate::utils::path::simplified)
192 .map_err(|e| PathSecurityError::CanonicalizationFailed {
193 path: workspace_root.to_path_buf(),
194 error: e,
195 })?;
196
197 if let Ok(metadata) = std::fs::symlink_metadata(path) {
201 if metadata.file_type().is_symlink() {
202 let target = std::fs::read_link(path).map_err(|e| {
203 PathSecurityError::CanonicalizationFailed { path: path.to_path_buf(), error: e }
204 })?;
205 let absolute_target = if target.is_absolute() {
206 target
207 } else {
208 path.parent().unwrap_or(Path::new("/")).join(&target)
209 };
210 let canonical_target = absolute_target
211 .canonicalize()
212 .map(crate::utils::path::simplified)
213 .map_err(|e| PathSecurityError::CanonicalizationFailed {
214 path: absolute_target.clone(),
215 error: e,
216 })?;
217 if !is_contained(&canonical_target, &canonical_workspace, extra_roots) {
218 return Err(PathSecurityError::SymlinkEscape {
219 path: path.to_path_buf(),
220 target: canonical_target,
221 workspace: canonical_workspace,
222 });
223 }
224 }
225 }
226
227 match path.canonicalize().map(crate::utils::path::simplified) {
231 Ok(canonical_path) => {
232 if is_contained(&canonical_path, &canonical_workspace, extra_roots) {
233 Ok(canonical_path)
234 } else {
235 Err(PathSecurityError::PathTraversal {
236 path: path.to_path_buf(),
237 workspace: canonical_workspace,
238 })
239 }
240 }
241 Err(e) if e.kind() == io::ErrorKind::NotFound => {
242 resolve_new_path(path, &canonical_workspace, extra_roots)
243 }
244 Err(e) => {
245 Err(PathSecurityError::CanonicalizationFailed { path: path.to_path_buf(), error: e })
246 }
247 }
248}
249
250fn resolve_new_path(
267 path: &Path,
268 canonical_workspace: &Path,
269 extra_roots: &[PathBuf],
270) -> Result<PathBuf, PathSecurityError> {
271 let traversal = || PathSecurityError::PathTraversal {
272 path: path.to_path_buf(),
273 workspace: canonical_workspace.to_path_buf(),
274 };
275
276 let mut existing = path;
278 loop {
279 if std::fs::symlink_metadata(existing).is_ok() {
280 break;
281 }
282 match existing.parent() {
283 Some(parent) => existing = parent,
284 None => break,
285 }
286 }
287
288 let canonical_base =
291 existing.canonicalize().map(crate::utils::path::simplified).map_err(|e| {
292 PathSecurityError::CanonicalizationFailed { path: existing.to_path_buf(), error: e }
293 })?;
294
295 let remainder = path.strip_prefix(existing).map_err(|_| traversal())?;
298 let mut resolved = canonical_base;
299 for component in remainder.components() {
300 match component {
301 std::path::Component::Normal(c) => resolved.push(c),
302 std::path::Component::ParentDir => {
303 resolved.pop();
304 }
305 std::path::Component::CurDir => {}
306 _ => return Err(traversal()),
308 }
309 }
310
311 if is_contained(&resolved, canonical_workspace, extra_roots) {
312 Ok(resolved)
313 } else {
314 Err(traversal())
315 }
316}
317
318pub fn is_symlink(path: &Path) -> bool {
320 std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
321}
322
323pub fn expand_user(path: &str) -> String {
325 if path.starts_with('~') {
326 if let Some(home_str) = home::home_dir().as_deref().and_then(Path::to_str) {
331 return path.replacen('~', home_str, 1);
332 }
333 }
334 path.to_string()
335}
336
337pub fn ensure_directory_exists(path: &Path) -> std::io::Result<()> {
339 if !path.exists() {
340 std::fs::create_dir_all(path)?;
341 }
342 Ok(())
343}
344
345pub fn resolve_in_workspace(
349 path: &str,
350 cwd: &Path,
351 workspace_root: &Path,
352) -> Result<PathBuf, PathSecurityError> {
353 resolve_in_workspace_with_roots(path, cwd, workspace_root, allowed_roots())
354}
355
356pub(crate) fn resolve_in_workspace_with_roots(
359 path: &str,
360 cwd: &Path,
361 workspace_root: &Path,
362 extra_roots: &[PathBuf],
363) -> Result<PathBuf, PathSecurityError> {
364 if path.trim().is_empty() {
365 return validate_path_with_roots(workspace_root, workspace_root, extra_roots);
366 }
367 let expanded = expand_user(path);
368 let candidate = if Path::new(&expanded).is_absolute() {
369 PathBuf::from(expanded)
370 } else {
371 cwd.join(expanded)
372 };
373 validate_path_with_roots(&candidate, workspace_root, extra_roots)
374}
375
376pub fn glob_matches(pattern: &glob::Pattern, relative: &Path) -> bool {
381 let opts = glob::MatchOptions { require_literal_separator: true, ..glob::MatchOptions::new() };
382 if pattern.matches_path_with(relative, opts) {
383 return true;
384 }
385 relative
386 .file_name()
387 .is_some_and(|name| pattern.matches_with(name.to_string_lossy().as_ref(), opts))
388}
389
390#[cfg(test)]
391mod tests {
392 #![allow(clippy::unwrap_used)]
393 use super::*;
394 use proptest::prelude::*;
395 use std::fs;
396 use tempfile::TempDir;
397
398 proptest! {
399 #![proptest_config(ProptestConfig::with_cases(256))]
400
401 #[test]
407 fn resolve_in_workspace_ok_implies_contained(
408 segments in prop::collection::vec(
409 prop_oneof![Just("..".to_string()), Just(".".to_string()), "[a-zA-Z0-9_]{1,5}"],
410 0..10,
411 )
412 ) {
413 let ws = std::env::temp_dir().canonicalize().map(crate::utils::path::simplified).unwrap();
414 let rel = segments.join("/");
415 if let Ok(resolved) = resolve_in_workspace_with_roots(&rel, &ws, &ws, &[]) {
416 prop_assert!(
417 resolved.starts_with(&ws),
418 "resolve_in_workspace({rel:?}) escaped to {resolved:?}"
419 );
420 }
421 }
422
423 #[test]
427 fn validate_ok_implies_contained_any_input(s in ".*") {
428 let ws = std::env::temp_dir().canonicalize().map(crate::utils::path::simplified).unwrap();
429 if let Ok(p) = validate_path_with_roots(Path::new(&s), &ws, &[]) {
430 prop_assert!(p.starts_with(&ws), "accepted escaping path {p:?} from input {s:?}");
431 }
432 }
433 }
434
435 #[test]
436 fn expand_user_leaves_non_tilde_paths_untouched() {
437 assert_eq!(expand_user("/abs/path"), "/abs/path");
438 assert_eq!(expand_user("rel/path"), "rel/path");
439 }
440
441 #[test]
442 fn expand_user_never_maps_tilde_to_root() {
443 let out = expand_user("~/sub");
447 assert_ne!(out, "/sub", "~/sub must not become the filesystem root");
448 if let Some(home) = home::home_dir().and_then(|h| h.to_str().map(String::from)) {
449 assert_eq!(out, format!("{home}/sub"));
450 }
451 }
452
453 #[test]
454 fn allows_existing_file_in_workspace() {
455 let ws = TempDir::new().unwrap();
456 let f = ws.path().join("a.txt");
457 fs::write(&f, "x").unwrap();
458 let v = validate_path_with_roots(&f, ws.path(), &[]).unwrap();
459 assert!(
460 v.starts_with(ws.path().canonicalize().map(crate::utils::path::simplified).unwrap())
461 );
462 }
463
464 #[test]
465 fn allows_new_file_in_nested_nonexistent_dir() {
466 let ws = TempDir::new().unwrap();
469 let f = ws.path().join("new/deep/dir/file.txt");
470 let v = validate_path_with_roots(&f, ws.path(), &[]).unwrap();
471 assert!(
472 v.starts_with(ws.path().canonicalize().map(crate::utils::path::simplified).unwrap())
473 );
474 assert!(v.ends_with("new/deep/dir/file.txt"));
475 }
476
477 #[test]
478 fn rejects_traversal_via_dotdot_in_new_path() {
479 let ws = TempDir::new().unwrap();
480 let f = ws.path().join("nope/../../etc/passwd");
481 assert!(matches!(
482 validate_path_with_roots(&f, ws.path(), &[]),
483 Err(PathSecurityError::PathTraversal { .. })
484 ));
485 }
486
487 #[test]
488 fn rejects_existing_path_outside_workspace() {
489 let ws = TempDir::new().unwrap();
490 let outside = TempDir::new().unwrap();
491 let f = outside.path().join("secret.txt");
492 fs::write(&f, "s").unwrap();
493 assert!(matches!(
494 validate_path_with_roots(&f, ws.path(), &[]),
495 Err(PathSecurityError::PathTraversal { .. })
496 ));
497 }
498
499 #[cfg(unix)]
500 #[test]
501 fn rejects_symlink_escaping_workspace() {
502 use std::os::unix::fs::symlink;
503 let ws = TempDir::new().unwrap();
504 let outside = TempDir::new().unwrap();
505 let secret = outside.path().join("secret.txt");
506 fs::write(&secret, "s").unwrap();
507 let link = ws.path().join("link.txt");
508 symlink(&secret, &link).unwrap();
509 assert!(matches!(
510 validate_path_with_roots(&link, ws.path(), &[]),
511 Err(PathSecurityError::SymlinkEscape { .. })
512 ));
513 }
514
515 #[cfg(unix)]
516 #[test]
517 fn rejects_new_file_through_dangling_symlink_dir() {
518 use std::os::unix::fs::symlink;
523 let ws = TempDir::new().unwrap();
524 let outside = TempDir::new().unwrap();
525 let link = ws.path().join("evil");
526 symlink(outside.path().join("nonexistent"), &link).unwrap();
527 let f = link.join("file.txt");
528 assert!(validate_path_with_roots(&f, ws.path(), &[]).is_err());
529 }
530
531 #[cfg(windows)]
532 #[test]
533 fn simplified_strips_verbatim_prefixes() {
534 use super::simplified;
535 assert_eq!(simplified(PathBuf::from(r"\\?\C:\a\b")), PathBuf::from(r"C:\a\b"));
536 assert_eq!(
537 simplified(PathBuf::from(r"\\?\UNC\srv\share\x")),
538 PathBuf::from(r"\\srv\share\x")
539 );
540 assert_eq!(simplified(PathBuf::from(r"C:\plain")), PathBuf::from(r"C:\plain"));
541 }
542
543 #[test]
544 fn parse_allow_paths_keeps_absolute_existing_roots_only() {
545 let existing = TempDir::new().unwrap();
546 let existing_str = existing.path().to_string_lossy().to_string();
547 let separator = if cfg!(windows) { ';' } else { ':' };
548 let raw = format!(
549 "{existing_str}{separator} {separator}relative/dir{separator}/definitely/not/here/xyz"
550 );
551 let roots = parse_allow_paths(&raw);
552 assert_eq!(
553 roots,
554 vec![existing.path().canonicalize().map(crate::utils::path::simplified).unwrap()]
555 );
556 }
557
558 #[test]
559 fn allowed_root_permits_existing_path_outside_workspace() {
560 let ws = TempDir::new().unwrap();
561 let outside = TempDir::new().unwrap();
562 let f = outside.path().join("note.md");
563 fs::write(&f, "x").unwrap();
564 let roots =
565 vec![outside.path().canonicalize().map(crate::utils::path::simplified).unwrap()];
566
567 assert!(validate_path_with_roots(&f, ws.path(), &[]).is_err());
569 let v = validate_path_with_roots(&f, ws.path(), &roots).unwrap();
570 assert!(v.starts_with(
571 outside.path().canonicalize().map(crate::utils::path::simplified).unwrap()
572 ));
573 }
574
575 #[test]
576 fn allowed_root_permits_new_file_outside_workspace() {
577 let ws = TempDir::new().unwrap();
579 let outside = TempDir::new().unwrap();
580 let f = outside.path().join("new/deep/file.txt");
581 let roots =
582 vec![outside.path().canonicalize().map(crate::utils::path::simplified).unwrap()];
583
584 assert!(validate_path_with_roots(&f, ws.path(), &[]).is_err());
585 let v = validate_path_with_roots(&f, ws.path(), &roots).unwrap();
586 assert!(v.ends_with("new/deep/file.txt"));
587 }
588
589 #[test]
590 fn allowed_root_does_not_widen_to_its_siblings() {
591 let ws = TempDir::new().unwrap();
593 let allowed = TempDir::new().unwrap();
594 let other = TempDir::new().unwrap();
595 let f = other.path().join("secret.txt");
596 fs::write(&f, "s").unwrap();
597 let roots =
598 vec![allowed.path().canonicalize().map(crate::utils::path::simplified).unwrap()];
599 assert!(matches!(
600 validate_path_with_roots(&f, ws.path(), &roots),
601 Err(PathSecurityError::PathTraversal { .. })
602 ));
603 }
604
605 #[cfg(unix)]
606 #[test]
607 fn root_slash_allow_path_disables_containment() {
608 let ws = TempDir::new().unwrap();
615 let outside = TempDir::new().unwrap();
616 let f = outside.path().join("anywhere.txt");
617 fs::write(&f, "x").unwrap();
618 let roots = parse_allow_paths("/");
619 assert_eq!(roots, vec![PathBuf::from("/")]);
620
621 assert!(validate_path_with_roots(&f, ws.path(), &[]).is_err());
622 let v = validate_path_with_roots(&f, ws.path(), &roots).unwrap();
623 assert_eq!(v, f.canonicalize().map(crate::utils::path::simplified).unwrap());
624 }
625
626 #[cfg(unix)]
627 #[test]
628 fn symlink_into_allowed_root_is_accepted() {
629 use std::os::unix::fs::symlink;
632 let ws = TempDir::new().unwrap();
633 let outside = TempDir::new().unwrap();
634 let target = outside.path().join("data.txt");
635 fs::write(&target, "d").unwrap();
636 let link = ws.path().join("link.txt");
637 symlink(&target, &link).unwrap();
638 let roots =
639 vec![outside.path().canonicalize().map(crate::utils::path::simplified).unwrap()];
640
641 assert!(matches!(
642 validate_path_with_roots(&link, ws.path(), &[]),
643 Err(PathSecurityError::SymlinkEscape { .. })
644 ));
645 assert!(validate_path_with_roots(&link, ws.path(), &roots).is_ok());
646 }
647
648 #[cfg(unix)]
649 #[test]
650 fn allows_new_file_through_internal_symlink_dir() {
651 use std::os::unix::fs::symlink;
654 let ws = TempDir::new().unwrap();
655 let real = ws.path().join("real");
656 fs::create_dir(&real).unwrap();
657 let link = ws.path().join("link");
658 symlink(&real, &link).unwrap();
659 let f = link.join("file.txt");
660 let v = validate_path_with_roots(&f, ws.path(), &[]).unwrap();
661 assert!(
662 v.starts_with(ws.path().canonicalize().map(crate::utils::path::simplified).unwrap())
663 );
664 }
665}