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
49fn parse_allow_paths(raw: &str) -> Vec<PathBuf> {
56 raw.split(':')
57 .map(str::trim)
58 .filter(|entry| !entry.is_empty())
59 .filter_map(|entry| {
60 let candidate = Path::new(entry);
61 if !candidate.is_absolute() {
62 warn!("WINX_ALLOW_PATHS: ignoring '{entry}' (must be an absolute path)");
63 return None;
64 }
65 match candidate.canonicalize() {
66 Ok(root) => Some(root),
67 Err(e) => {
68 warn!("WINX_ALLOW_PATHS: ignoring '{entry}' ({e})");
69 None
70 }
71 }
72 })
73 .collect()
74}
75
76fn allowed_roots() -> &'static [PathBuf] {
89 static ROOTS: OnceLock<Vec<PathBuf>> = OnceLock::new();
90 ROOTS.get_or_init(|| {
91 let roots = crate::config::env_text("WINX_ALLOW_PATHS").map(|raw| parse_allow_paths(&raw));
92 let roots = roots.unwrap_or_default();
93 if !roots.is_empty() {
94 let list = roots.iter().map(|r| r.display().to_string()).collect::<Vec<_>>().join(", ");
95 warn!(
96 "WINX_ALLOW_PATHS active: the file tools may read and write outside the \
97 workspace, under: {list}"
98 );
99 }
100 roots
101 })
102}
103
104pub fn configured_allowed_roots() -> &'static [PathBuf] {
107 allowed_roots()
108}
109
110fn is_contained(
113 canonical_path: &Path,
114 canonical_workspace: &Path,
115 extra_roots: &[PathBuf],
116) -> bool {
117 canonical_path.starts_with(canonical_workspace)
118 || extra_roots.iter().any(|root| canonical_path.starts_with(root))
119}
120
121pub fn validate_path_in_workspace(
129 path: &Path,
130 workspace_root: &Path,
131) -> Result<PathBuf, PathSecurityError> {
132 validate_path_with_roots(path, workspace_root, allowed_roots())
133}
134
135pub(crate) fn validate_path_with_roots(
138 path: &Path,
139 workspace_root: &Path,
140 extra_roots: &[PathBuf],
141) -> Result<PathBuf, PathSecurityError> {
142 let canonical_workspace = workspace_root.canonicalize().map_err(|e| {
145 PathSecurityError::CanonicalizationFailed { path: workspace_root.to_path_buf(), error: e }
146 })?;
147
148 if let Ok(metadata) = std::fs::symlink_metadata(path) {
152 if metadata.file_type().is_symlink() {
153 let target = std::fs::read_link(path).map_err(|e| {
154 PathSecurityError::CanonicalizationFailed { path: path.to_path_buf(), error: e }
155 })?;
156 let absolute_target = if target.is_absolute() {
157 target
158 } else {
159 path.parent().unwrap_or(Path::new("/")).join(&target)
160 };
161 let canonical_target = absolute_target.canonicalize().map_err(|e| {
162 PathSecurityError::CanonicalizationFailed {
163 path: absolute_target.clone(),
164 error: e,
165 }
166 })?;
167 if !is_contained(&canonical_target, &canonical_workspace, extra_roots) {
168 return Err(PathSecurityError::SymlinkEscape {
169 path: path.to_path_buf(),
170 target: canonical_target,
171 workspace: canonical_workspace,
172 });
173 }
174 }
175 }
176
177 match path.canonicalize() {
181 Ok(canonical_path) => {
182 if is_contained(&canonical_path, &canonical_workspace, extra_roots) {
183 Ok(canonical_path)
184 } else {
185 Err(PathSecurityError::PathTraversal {
186 path: path.to_path_buf(),
187 workspace: canonical_workspace,
188 })
189 }
190 }
191 Err(e) if e.kind() == io::ErrorKind::NotFound => {
192 resolve_new_path(path, &canonical_workspace, extra_roots)
193 }
194 Err(e) => {
195 Err(PathSecurityError::CanonicalizationFailed { path: path.to_path_buf(), error: e })
196 }
197 }
198}
199
200fn resolve_new_path(
217 path: &Path,
218 canonical_workspace: &Path,
219 extra_roots: &[PathBuf],
220) -> Result<PathBuf, PathSecurityError> {
221 let traversal = || PathSecurityError::PathTraversal {
222 path: path.to_path_buf(),
223 workspace: canonical_workspace.to_path_buf(),
224 };
225
226 let mut existing = path;
228 loop {
229 if std::fs::symlink_metadata(existing).is_ok() {
230 break;
231 }
232 match existing.parent() {
233 Some(parent) => existing = parent,
234 None => break,
235 }
236 }
237
238 let canonical_base = existing.canonicalize().map_err(|e| {
241 PathSecurityError::CanonicalizationFailed { path: existing.to_path_buf(), error: e }
242 })?;
243
244 let remainder = path.strip_prefix(existing).map_err(|_| traversal())?;
247 let mut resolved = canonical_base;
248 for component in remainder.components() {
249 match component {
250 std::path::Component::Normal(c) => resolved.push(c),
251 std::path::Component::ParentDir => {
252 resolved.pop();
253 }
254 std::path::Component::CurDir => {}
255 _ => return Err(traversal()),
257 }
258 }
259
260 if is_contained(&resolved, canonical_workspace, extra_roots) {
261 Ok(resolved)
262 } else {
263 Err(traversal())
264 }
265}
266
267pub fn is_symlink(path: &Path) -> bool {
269 std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
270}
271
272pub fn expand_user(path: &str) -> String {
274 if path.starts_with('~') {
275 if let Some(home_str) = home::home_dir().as_deref().and_then(Path::to_str) {
280 return path.replacen('~', home_str, 1);
281 }
282 }
283 path.to_string()
284}
285
286pub fn ensure_directory_exists(path: &Path) -> std::io::Result<()> {
288 if !path.exists() {
289 std::fs::create_dir_all(path)?;
290 }
291 Ok(())
292}
293
294pub fn resolve_in_workspace(
298 path: &str,
299 cwd: &Path,
300 workspace_root: &Path,
301) -> Result<PathBuf, PathSecurityError> {
302 resolve_in_workspace_with_roots(path, cwd, workspace_root, allowed_roots())
303}
304
305pub(crate) fn resolve_in_workspace_with_roots(
308 path: &str,
309 cwd: &Path,
310 workspace_root: &Path,
311 extra_roots: &[PathBuf],
312) -> Result<PathBuf, PathSecurityError> {
313 if path.trim().is_empty() {
314 return validate_path_with_roots(workspace_root, workspace_root, extra_roots);
315 }
316 let expanded = expand_user(path);
317 let candidate = if Path::new(&expanded).is_absolute() {
318 PathBuf::from(expanded)
319 } else {
320 cwd.join(expanded)
321 };
322 validate_path_with_roots(&candidate, workspace_root, extra_roots)
323}
324
325pub fn glob_matches(pattern: &glob::Pattern, relative: &Path) -> bool {
330 let opts = glob::MatchOptions { require_literal_separator: true, ..glob::MatchOptions::new() };
331 if pattern.matches_path_with(relative, opts) {
332 return true;
333 }
334 relative
335 .file_name()
336 .is_some_and(|name| pattern.matches_with(name.to_string_lossy().as_ref(), opts))
337}
338
339#[cfg(test)]
340mod tests {
341 #![allow(clippy::unwrap_used)]
342 use super::*;
343 use proptest::prelude::*;
344 use std::fs;
345 use tempfile::TempDir;
346
347 proptest! {
348 #![proptest_config(ProptestConfig::with_cases(256))]
349
350 #[test]
356 fn resolve_in_workspace_ok_implies_contained(
357 segments in prop::collection::vec(
358 prop_oneof![Just("..".to_string()), Just(".".to_string()), "[a-zA-Z0-9_]{1,5}"],
359 0..10,
360 )
361 ) {
362 let ws = std::env::temp_dir().canonicalize().unwrap();
363 let rel = segments.join("/");
364 if let Ok(resolved) = resolve_in_workspace_with_roots(&rel, &ws, &ws, &[]) {
365 prop_assert!(
366 resolved.starts_with(&ws),
367 "resolve_in_workspace({rel:?}) escaped to {resolved:?}"
368 );
369 }
370 }
371
372 #[test]
376 fn validate_ok_implies_contained_any_input(s in ".*") {
377 let ws = std::env::temp_dir().canonicalize().unwrap();
378 if let Ok(p) = validate_path_with_roots(Path::new(&s), &ws, &[]) {
379 prop_assert!(p.starts_with(&ws), "accepted escaping path {p:?} from input {s:?}");
380 }
381 }
382 }
383
384 #[test]
385 fn expand_user_leaves_non_tilde_paths_untouched() {
386 assert_eq!(expand_user("/abs/path"), "/abs/path");
387 assert_eq!(expand_user("rel/path"), "rel/path");
388 }
389
390 #[test]
391 fn expand_user_never_maps_tilde_to_root() {
392 let out = expand_user("~/sub");
396 assert_ne!(out, "/sub", "~/sub must not become the filesystem root");
397 if let Some(home) = home::home_dir().and_then(|h| h.to_str().map(String::from)) {
398 assert_eq!(out, format!("{home}/sub"));
399 }
400 }
401
402 #[test]
403 fn allows_existing_file_in_workspace() {
404 let ws = TempDir::new().unwrap();
405 let f = ws.path().join("a.txt");
406 fs::write(&f, "x").unwrap();
407 let v = validate_path_with_roots(&f, ws.path(), &[]).unwrap();
408 assert!(v.starts_with(ws.path().canonicalize().unwrap()));
409 }
410
411 #[test]
412 fn allows_new_file_in_nested_nonexistent_dir() {
413 let ws = TempDir::new().unwrap();
416 let f = ws.path().join("new/deep/dir/file.txt");
417 let v = validate_path_with_roots(&f, ws.path(), &[]).unwrap();
418 assert!(v.starts_with(ws.path().canonicalize().unwrap()));
419 assert!(v.ends_with("new/deep/dir/file.txt"));
420 }
421
422 #[test]
423 fn rejects_traversal_via_dotdot_in_new_path() {
424 let ws = TempDir::new().unwrap();
425 let f = ws.path().join("nope/../../etc/passwd");
426 assert!(matches!(
427 validate_path_with_roots(&f, ws.path(), &[]),
428 Err(PathSecurityError::PathTraversal { .. })
429 ));
430 }
431
432 #[test]
433 fn rejects_existing_path_outside_workspace() {
434 let ws = TempDir::new().unwrap();
435 let outside = TempDir::new().unwrap();
436 let f = outside.path().join("secret.txt");
437 fs::write(&f, "s").unwrap();
438 assert!(matches!(
439 validate_path_with_roots(&f, ws.path(), &[]),
440 Err(PathSecurityError::PathTraversal { .. })
441 ));
442 }
443
444 #[cfg(unix)]
445 #[test]
446 fn rejects_symlink_escaping_workspace() {
447 use std::os::unix::fs::symlink;
448 let ws = TempDir::new().unwrap();
449 let outside = TempDir::new().unwrap();
450 let secret = outside.path().join("secret.txt");
451 fs::write(&secret, "s").unwrap();
452 let link = ws.path().join("link.txt");
453 symlink(&secret, &link).unwrap();
454 assert!(matches!(
455 validate_path_with_roots(&link, ws.path(), &[]),
456 Err(PathSecurityError::SymlinkEscape { .. })
457 ));
458 }
459
460 #[cfg(unix)]
461 #[test]
462 fn rejects_new_file_through_dangling_symlink_dir() {
463 use std::os::unix::fs::symlink;
468 let ws = TempDir::new().unwrap();
469 let outside = TempDir::new().unwrap();
470 let link = ws.path().join("evil");
471 symlink(outside.path().join("nonexistent"), &link).unwrap();
472 let f = link.join("file.txt");
473 assert!(validate_path_with_roots(&f, ws.path(), &[]).is_err());
474 }
475
476 #[test]
477 fn parse_allow_paths_keeps_absolute_existing_roots_only() {
478 let existing = TempDir::new().unwrap();
479 let existing_str = existing.path().to_string_lossy().to_string();
480 let raw = format!("{existing_str}: :relative/dir:/definitely/not/here/xyz");
481 let roots = parse_allow_paths(&raw);
482 assert_eq!(roots, vec![existing.path().canonicalize().unwrap()]);
483 }
484
485 #[test]
486 fn allowed_root_permits_existing_path_outside_workspace() {
487 let ws = TempDir::new().unwrap();
488 let outside = TempDir::new().unwrap();
489 let f = outside.path().join("note.md");
490 fs::write(&f, "x").unwrap();
491 let roots = vec![outside.path().canonicalize().unwrap()];
492
493 assert!(validate_path_with_roots(&f, ws.path(), &[]).is_err());
495 let v = validate_path_with_roots(&f, ws.path(), &roots).unwrap();
496 assert!(v.starts_with(outside.path().canonicalize().unwrap()));
497 }
498
499 #[test]
500 fn allowed_root_permits_new_file_outside_workspace() {
501 let ws = TempDir::new().unwrap();
503 let outside = TempDir::new().unwrap();
504 let f = outside.path().join("new/deep/file.txt");
505 let roots = vec![outside.path().canonicalize().unwrap()];
506
507 assert!(validate_path_with_roots(&f, ws.path(), &[]).is_err());
508 let v = validate_path_with_roots(&f, ws.path(), &roots).unwrap();
509 assert!(v.ends_with("new/deep/file.txt"));
510 }
511
512 #[test]
513 fn allowed_root_does_not_widen_to_its_siblings() {
514 let ws = TempDir::new().unwrap();
516 let allowed = TempDir::new().unwrap();
517 let other = TempDir::new().unwrap();
518 let f = other.path().join("secret.txt");
519 fs::write(&f, "s").unwrap();
520 let roots = vec![allowed.path().canonicalize().unwrap()];
521 assert!(matches!(
522 validate_path_with_roots(&f, ws.path(), &roots),
523 Err(PathSecurityError::PathTraversal { .. })
524 ));
525 }
526
527 #[cfg(unix)]
528 #[test]
529 fn root_slash_allow_path_disables_containment() {
530 let ws = TempDir::new().unwrap();
537 let outside = TempDir::new().unwrap();
538 let f = outside.path().join("anywhere.txt");
539 fs::write(&f, "x").unwrap();
540 let roots = parse_allow_paths("/");
541 assert_eq!(roots, vec![PathBuf::from("/")]);
542
543 assert!(validate_path_with_roots(&f, ws.path(), &[]).is_err());
544 let v = validate_path_with_roots(&f, ws.path(), &roots).unwrap();
545 assert_eq!(v, f.canonicalize().unwrap());
546 }
547
548 #[cfg(unix)]
549 #[test]
550 fn symlink_into_allowed_root_is_accepted() {
551 use std::os::unix::fs::symlink;
554 let ws = TempDir::new().unwrap();
555 let outside = TempDir::new().unwrap();
556 let target = outside.path().join("data.txt");
557 fs::write(&target, "d").unwrap();
558 let link = ws.path().join("link.txt");
559 symlink(&target, &link).unwrap();
560 let roots = vec![outside.path().canonicalize().unwrap()];
561
562 assert!(matches!(
563 validate_path_with_roots(&link, ws.path(), &[]),
564 Err(PathSecurityError::SymlinkEscape { .. })
565 ));
566 assert!(validate_path_with_roots(&link, ws.path(), &roots).is_ok());
567 }
568
569 #[cfg(unix)]
570 #[test]
571 fn allows_new_file_through_internal_symlink_dir() {
572 use std::os::unix::fs::symlink;
575 let ws = TempDir::new().unwrap();
576 let real = ws.path().join("real");
577 fs::create_dir(&real).unwrap();
578 let link = ws.path().join("link");
579 symlink(&real, &link).unwrap();
580 let f = link.join("file.txt");
581 let v = validate_path_with_roots(&f, ws.path(), &[]).unwrap();
582 assert!(v.starts_with(ws.path().canonicalize().unwrap()));
583 }
584}