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
104fn is_contained(
107 canonical_path: &Path,
108 canonical_workspace: &Path,
109 extra_roots: &[PathBuf],
110) -> bool {
111 canonical_path.starts_with(canonical_workspace)
112 || extra_roots.iter().any(|root| canonical_path.starts_with(root))
113}
114
115pub fn validate_path_in_workspace(
123 path: &Path,
124 workspace_root: &Path,
125) -> Result<PathBuf, PathSecurityError> {
126 validate_path_with_roots(path, workspace_root, allowed_roots())
127}
128
129fn validate_path_with_roots(
132 path: &Path,
133 workspace_root: &Path,
134 extra_roots: &[PathBuf],
135) -> Result<PathBuf, PathSecurityError> {
136 let canonical_workspace = workspace_root.canonicalize().map_err(|e| {
139 PathSecurityError::CanonicalizationFailed { path: workspace_root.to_path_buf(), error: e }
140 })?;
141
142 if let Ok(metadata) = std::fs::symlink_metadata(path) {
146 if metadata.file_type().is_symlink() {
147 let target = std::fs::read_link(path).map_err(|e| {
148 PathSecurityError::CanonicalizationFailed { path: path.to_path_buf(), error: e }
149 })?;
150 let absolute_target = if target.is_absolute() {
151 target
152 } else {
153 path.parent().unwrap_or(Path::new("/")).join(&target)
154 };
155 let canonical_target = absolute_target.canonicalize().map_err(|e| {
156 PathSecurityError::CanonicalizationFailed {
157 path: absolute_target.clone(),
158 error: e,
159 }
160 })?;
161 if !is_contained(&canonical_target, &canonical_workspace, extra_roots) {
162 return Err(PathSecurityError::SymlinkEscape {
163 path: path.to_path_buf(),
164 target: canonical_target,
165 workspace: canonical_workspace,
166 });
167 }
168 }
169 }
170
171 match path.canonicalize() {
175 Ok(canonical_path) => {
176 if is_contained(&canonical_path, &canonical_workspace, extra_roots) {
177 Ok(canonical_path)
178 } else {
179 Err(PathSecurityError::PathTraversal {
180 path: path.to_path_buf(),
181 workspace: canonical_workspace,
182 })
183 }
184 }
185 Err(e) if e.kind() == io::ErrorKind::NotFound => {
186 resolve_new_path(path, &canonical_workspace, extra_roots)
187 }
188 Err(e) => {
189 Err(PathSecurityError::CanonicalizationFailed { path: path.to_path_buf(), error: e })
190 }
191 }
192}
193
194fn resolve_new_path(
211 path: &Path,
212 canonical_workspace: &Path,
213 extra_roots: &[PathBuf],
214) -> Result<PathBuf, PathSecurityError> {
215 let traversal = || PathSecurityError::PathTraversal {
216 path: path.to_path_buf(),
217 workspace: canonical_workspace.to_path_buf(),
218 };
219
220 let mut existing = path;
222 loop {
223 if std::fs::symlink_metadata(existing).is_ok() {
224 break;
225 }
226 match existing.parent() {
227 Some(parent) => existing = parent,
228 None => break,
229 }
230 }
231
232 let canonical_base = existing.canonicalize().map_err(|e| {
235 PathSecurityError::CanonicalizationFailed { path: existing.to_path_buf(), error: e }
236 })?;
237
238 let remainder = path.strip_prefix(existing).map_err(|_| traversal())?;
241 let mut resolved = canonical_base;
242 for component in remainder.components() {
243 match component {
244 std::path::Component::Normal(c) => resolved.push(c),
245 std::path::Component::ParentDir => {
246 resolved.pop();
247 }
248 std::path::Component::CurDir => {}
249 _ => return Err(traversal()),
251 }
252 }
253
254 if is_contained(&resolved, canonical_workspace, extra_roots) {
255 Ok(resolved)
256 } else {
257 Err(traversal())
258 }
259}
260
261pub fn is_symlink(path: &Path) -> bool {
263 std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
264}
265
266pub fn expand_user(path: &str) -> String {
268 if path.starts_with('~') {
269 if let Some(home_str) = home::home_dir().as_deref().and_then(Path::to_str) {
274 return path.replacen('~', home_str, 1);
275 }
276 }
277 path.to_string()
278}
279
280pub fn ensure_directory_exists(path: &Path) -> std::io::Result<()> {
282 if !path.exists() {
283 std::fs::create_dir_all(path)?;
284 }
285 Ok(())
286}
287
288pub fn resolve_in_workspace(
292 path: &str,
293 cwd: &Path,
294 workspace_root: &Path,
295) -> Result<PathBuf, PathSecurityError> {
296 if path.trim().is_empty() {
297 return validate_path_in_workspace(workspace_root, workspace_root);
298 }
299 let expanded = expand_user(path);
300 let candidate = if Path::new(&expanded).is_absolute() {
301 PathBuf::from(expanded)
302 } else {
303 cwd.join(expanded)
304 };
305 validate_path_in_workspace(&candidate, workspace_root)
306}
307
308pub fn glob_matches(pattern: &glob::Pattern, relative: &Path) -> bool {
313 let opts = glob::MatchOptions { require_literal_separator: true, ..glob::MatchOptions::new() };
314 if pattern.matches_path_with(relative, opts) {
315 return true;
316 }
317 relative
318 .file_name()
319 .is_some_and(|name| pattern.matches_with(name.to_string_lossy().as_ref(), opts))
320}
321
322#[cfg(test)]
323mod tests {
324 #![allow(clippy::unwrap_used)]
325 use super::*;
326 use proptest::prelude::*;
327 use std::fs;
328 use tempfile::TempDir;
329
330 proptest! {
331 #![proptest_config(ProptestConfig::with_cases(256))]
332
333 #[test]
339 fn resolve_in_workspace_ok_implies_contained(
340 segments in prop::collection::vec(
341 prop_oneof![Just("..".to_string()), Just(".".to_string()), "[a-zA-Z0-9_]{1,5}"],
342 0..10,
343 )
344 ) {
345 let ws = std::env::temp_dir().canonicalize().unwrap();
346 let rel = segments.join("/");
347 if let Ok(resolved) = resolve_in_workspace(&rel, &ws, &ws) {
348 prop_assert!(
349 resolved.starts_with(&ws),
350 "resolve_in_workspace({rel:?}) escaped to {resolved:?}"
351 );
352 }
353 }
354
355 #[test]
359 fn validate_ok_implies_contained_any_input(s in ".*") {
360 let ws = std::env::temp_dir().canonicalize().unwrap();
361 if let Ok(p) = validate_path_in_workspace(Path::new(&s), &ws) {
362 prop_assert!(p.starts_with(&ws), "accepted escaping path {p:?} from input {s:?}");
363 }
364 }
365 }
366
367 #[test]
368 fn expand_user_leaves_non_tilde_paths_untouched() {
369 assert_eq!(expand_user("/abs/path"), "/abs/path");
370 assert_eq!(expand_user("rel/path"), "rel/path");
371 }
372
373 #[test]
374 fn expand_user_never_maps_tilde_to_root() {
375 let out = expand_user("~/sub");
379 assert_ne!(out, "/sub", "~/sub must not become the filesystem root");
380 if let Some(home) = home::home_dir().and_then(|h| h.to_str().map(String::from)) {
381 assert_eq!(out, format!("{home}/sub"));
382 }
383 }
384
385 #[test]
386 fn allows_existing_file_in_workspace() {
387 let ws = TempDir::new().unwrap();
388 let f = ws.path().join("a.txt");
389 fs::write(&f, "x").unwrap();
390 let v = validate_path_in_workspace(&f, ws.path()).unwrap();
391 assert!(v.starts_with(ws.path().canonicalize().unwrap()));
392 }
393
394 #[test]
395 fn allows_new_file_in_nested_nonexistent_dir() {
396 let ws = TempDir::new().unwrap();
399 let f = ws.path().join("new/deep/dir/file.txt");
400 let v = validate_path_in_workspace(&f, ws.path()).unwrap();
401 assert!(v.starts_with(ws.path().canonicalize().unwrap()));
402 assert!(v.ends_with("new/deep/dir/file.txt"));
403 }
404
405 #[test]
406 fn rejects_traversal_via_dotdot_in_new_path() {
407 let ws = TempDir::new().unwrap();
408 let f = ws.path().join("nope/../../etc/passwd");
409 assert!(matches!(
410 validate_path_in_workspace(&f, ws.path()),
411 Err(PathSecurityError::PathTraversal { .. })
412 ));
413 }
414
415 #[test]
416 fn rejects_existing_path_outside_workspace() {
417 let ws = TempDir::new().unwrap();
418 let outside = TempDir::new().unwrap();
419 let f = outside.path().join("secret.txt");
420 fs::write(&f, "s").unwrap();
421 assert!(matches!(
422 validate_path_in_workspace(&f, ws.path()),
423 Err(PathSecurityError::PathTraversal { .. })
424 ));
425 }
426
427 #[cfg(unix)]
428 #[test]
429 fn rejects_symlink_escaping_workspace() {
430 use std::os::unix::fs::symlink;
431 let ws = TempDir::new().unwrap();
432 let outside = TempDir::new().unwrap();
433 let secret = outside.path().join("secret.txt");
434 fs::write(&secret, "s").unwrap();
435 let link = ws.path().join("link.txt");
436 symlink(&secret, &link).unwrap();
437 assert!(matches!(
438 validate_path_in_workspace(&link, ws.path()),
439 Err(PathSecurityError::SymlinkEscape { .. })
440 ));
441 }
442
443 #[cfg(unix)]
444 #[test]
445 fn rejects_new_file_through_dangling_symlink_dir() {
446 use std::os::unix::fs::symlink;
451 let ws = TempDir::new().unwrap();
452 let outside = TempDir::new().unwrap();
453 let link = ws.path().join("evil");
454 symlink(outside.path().join("nonexistent"), &link).unwrap();
455 let f = link.join("file.txt");
456 assert!(validate_path_in_workspace(&f, ws.path()).is_err());
457 }
458
459 #[test]
460 fn parse_allow_paths_keeps_absolute_existing_roots_only() {
461 let existing = TempDir::new().unwrap();
462 let existing_str = existing.path().to_string_lossy().to_string();
463 let raw = format!("{existing_str}: :relative/dir:/definitely/not/here/xyz");
464 let roots = parse_allow_paths(&raw);
465 assert_eq!(roots, vec![existing.path().canonicalize().unwrap()]);
466 }
467
468 #[test]
469 fn allowed_root_permits_existing_path_outside_workspace() {
470 let ws = TempDir::new().unwrap();
471 let outside = TempDir::new().unwrap();
472 let f = outside.path().join("note.md");
473 fs::write(&f, "x").unwrap();
474 let roots = vec![outside.path().canonicalize().unwrap()];
475
476 assert!(validate_path_with_roots(&f, ws.path(), &[]).is_err());
478 let v = validate_path_with_roots(&f, ws.path(), &roots).unwrap();
479 assert!(v.starts_with(outside.path().canonicalize().unwrap()));
480 }
481
482 #[test]
483 fn allowed_root_permits_new_file_outside_workspace() {
484 let ws = TempDir::new().unwrap();
486 let outside = TempDir::new().unwrap();
487 let f = outside.path().join("new/deep/file.txt");
488 let roots = vec![outside.path().canonicalize().unwrap()];
489
490 assert!(validate_path_with_roots(&f, ws.path(), &[]).is_err());
491 let v = validate_path_with_roots(&f, ws.path(), &roots).unwrap();
492 assert!(v.ends_with("new/deep/file.txt"));
493 }
494
495 #[test]
496 fn allowed_root_does_not_widen_to_its_siblings() {
497 let ws = TempDir::new().unwrap();
499 let allowed = TempDir::new().unwrap();
500 let other = TempDir::new().unwrap();
501 let f = other.path().join("secret.txt");
502 fs::write(&f, "s").unwrap();
503 let roots = vec![allowed.path().canonicalize().unwrap()];
504 assert!(matches!(
505 validate_path_with_roots(&f, ws.path(), &roots),
506 Err(PathSecurityError::PathTraversal { .. })
507 ));
508 }
509
510 #[cfg(unix)]
511 #[test]
512 fn root_slash_allow_path_disables_containment() {
513 let ws = TempDir::new().unwrap();
520 let outside = TempDir::new().unwrap();
521 let f = outside.path().join("anywhere.txt");
522 fs::write(&f, "x").unwrap();
523 let roots = parse_allow_paths("/");
524 assert_eq!(roots, vec![PathBuf::from("/")]);
525
526 assert!(validate_path_with_roots(&f, ws.path(), &[]).is_err());
527 let v = validate_path_with_roots(&f, ws.path(), &roots).unwrap();
528 assert_eq!(v, f.canonicalize().unwrap());
529 }
530
531 #[cfg(unix)]
532 #[test]
533 fn symlink_into_allowed_root_is_accepted() {
534 use std::os::unix::fs::symlink;
537 let ws = TempDir::new().unwrap();
538 let outside = TempDir::new().unwrap();
539 let target = outside.path().join("data.txt");
540 fs::write(&target, "d").unwrap();
541 let link = ws.path().join("link.txt");
542 symlink(&target, &link).unwrap();
543 let roots = vec![outside.path().canonicalize().unwrap()];
544
545 assert!(matches!(
546 validate_path_with_roots(&link, ws.path(), &[]),
547 Err(PathSecurityError::SymlinkEscape { .. })
548 ));
549 assert!(validate_path_with_roots(&link, ws.path(), &roots).is_ok());
550 }
551
552 #[cfg(unix)]
553 #[test]
554 fn allows_new_file_through_internal_symlink_dir() {
555 use std::os::unix::fs::symlink;
558 let ws = TempDir::new().unwrap();
559 let real = ws.path().join("real");
560 fs::create_dir(&real).unwrap();
561 let link = ws.path().join("link");
562 symlink(&real, &link).unwrap();
563 let f = link.join("file.txt");
564 let v = validate_path_in_workspace(&f, ws.path()).unwrap();
565 assert!(v.starts_with(ws.path().canonicalize().unwrap()));
566 }
567}