Skip to main content

winx_code_agent/utils/
path.rs

1use std::io;
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4
5use tracing::warn;
6
7/// Security error for path validation
8#[derive(Debug)]
9pub enum PathSecurityError {
10    /// Path escapes the workspace root (path traversal attempt)
11    PathTraversal { path: PathBuf, workspace: PathBuf },
12    /// Path is a symlink pointing outside workspace
13    SymlinkEscape { path: PathBuf, target: PathBuf, workspace: PathBuf },
14    /// Failed to canonicalize path
15    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
49/// Parse the `:`-separated `WINX_ALLOW_PATHS` value into canonical roots.
50///
51/// Entries must be absolute (relative to *what* would be ambiguous — the tools
52/// that use this run with a per-session cwd) and must exist, so that the roots
53/// are canonical and comparable with `starts_with`. Anything else is dropped
54/// with a warning: a typo'd root silently widening nothing is the safe failure.
55/// Strip Windows' extended-length (`\\?\`) prefix from a canonical path.
56///
57/// `std::fs::canonicalize` returns the verbatim form on Windows. That form is
58/// rejected as a `cmd.exe` working directory, never appears in user-supplied
59/// paths, and gets copied by models into commands, so every canonical path Winx
60/// stores, compares, or shows is simplified to the plain `C:\…` spelling. Rust's
61/// std re-adds the prefix internally when a path exceeds `MAX_PATH`.
62pub 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
85/// Render a workspace-relative path for the model with `/` separators on every
86/// platform, so `CodeMap`/repo output stays stable across hosts.
87pub 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` uses the platform list separator (`:` on Unix,
98    // `;` on Windows), so `C:\…` entries are not cut at the drive colon.
99    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
121/// Extra roots the file tools may reach outside the workspace, from
122/// `WINX_ALLOW_PATHS` (absolute paths separated by the platform list separator:
123/// `:` on Unix, `;` on Windows; same convention as `WINX_SANDBOX_RW_PATHS`).
124///
125/// Read from the environment ONCE, at first use. That is deliberate: the
126/// containment policy is set by whoever starts the server, out of band, and is
127/// not renegotiable at runtime — no tool argument and no shell command the model
128/// is talked into running can widen it mid-session.
129///
130/// `WINX_ALLOW_PATHS=/` degenerates to "unconfined" (every canonical path on
131/// unix starts with `/`), which is the intended way to turn containment off — an
132/// explicit, visible config value rather than a separate kill-switch flag.
133fn 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
149/// Effective operator-configured roots outside the workspace. Exposed for
150/// diagnostics so `doctor` reports the same once-read policy the file tools use.
151pub fn configured_allowed_roots() -> &'static [PathBuf] {
152    allowed_roots()
153}
154
155/// Containment predicate: a resolved path is acceptable if it is inside the
156/// workspace or inside one of the operator-configured extra roots.
157fn 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
166/// Validates that a path is within the workspace root (or an extra root allowed
167/// via `WINX_ALLOW_PATHS`). Returns the canonicalized path if valid.
168///
169/// # Security
170/// - Prevents path traversal attacks (../)
171/// - Detects symlinks pointing outside the allowed roots
172/// - Canonicalizes path before comparison
173pub 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
180/// The real implementation, with the allowed roots injected so tests can drive
181/// every case without touching process-global environment state.
182pub(crate) fn validate_path_with_roots(
183    path: &Path,
184    workspace_root: &Path,
185    extra_roots: &[PathBuf],
186) -> Result<PathBuf, PathSecurityError> {
187    // Resolve the workspace boundary once, up front; everything is checked
188    // against this. Fail closed if the workspace itself can't be canonicalized.
189    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 `path` itself is a symlink, resolve its target and reject if it escapes.
198    // Fail CLOSED: a target we can't resolve (e.g. dangling) is refused, not
199    // waved through (the old code silently passed on any resolution failure).
200    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    // Resolve `path`. If it exists, canonicalize() collapses `..`, resolves
228    // symlinks, etc. If it doesn't (creating a new file/dir), fall back to a
229    // lexical resolution of the not-yet-existing tail.
230    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
250/// Resolve a not-yet-existing path for containment checking (supports creating
251/// files in directories that don't exist yet — i.e. `mkdir -p` semantics).
252///
253/// Strategy: walk up to the first ancestor that exists *as a filesystem entry*,
254/// canonicalize it (resolving any real symlinks in the existing prefix), then
255/// re-apply the remaining (non-existent) components purely lexically — `..`
256/// pops, `.` is skipped — and verify the result is inside `canonical_workspace`.
257///
258/// Two subtleties make this safe:
259/// - We stop the walk-up on `symlink_metadata` (entry exists), NOT `exists()`
260///   (which follows symlinks). A dangling symlink in an intermediate component
261///   therefore becomes the canonicalize target and fails closed, instead of
262///   being treated as a fresh lexical component that a later `create_dir_all`
263///   would follow out of the workspace.
264/// - The lexical pass resolves `..` before the containment check, so
265///   `workspace/new/../../etc/passwd` is rejected.
266fn 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    // Walk up to the first ancestor that exists as a filesystem entry.
277    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    // The deepest existing ancestor must resolve (a dangling symlink here fails
289    // closed) and anchor the resolution.
290    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    // Apply the components after `existing` lexically (they don't exist yet, so
296    // there are no symlinks among them to follow).
297    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            // RootDir / Prefix must not appear in a relative remainder.
307            _ => 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
318/// Check if a path is a symlink without following it
319pub fn is_symlink(path: &Path) -> bool {
320    std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
321}
322
323/// Expands a path that starts with ~ to the user's home directory
324pub fn expand_user(path: &str) -> String {
325    if path.starts_with('~') {
326        // Only expand when home is known AND valid UTF-8. The old `to_str()
327        // .unwrap_or("")` mapped `~/x` to `/x` (the filesystem root!) on a
328        // non-UTF-8 $HOME — silently pointing at the wrong place. Leaving the
329        // literal `~` is the safe failure.
330        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
337/// Ensures a directory exists, creating it if necessary
338pub 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
345/// Resolve a user-supplied path string (possibly empty, relative, or `~`) into a
346/// workspace-confined absolute path. Empty input resolves to the workspace root.
347/// Used by the read-only search/glob tools to scope and confine their root.
348pub 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
356/// Deterministic variant used by security tests and helpers that need to inject
357/// the exact containment policy instead of inheriting process-global overrides.
358pub(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
376/// Match a glob against a workspace-relative path. `*`/`?` do NOT cross `/`
377/// (so `src/*.ts` matches only direct children — `**` is the recursive form,
378/// matching `find`/`bash` semantics); a bare pattern (e.g. `*.rs`) also matches
379/// the file name at any depth, giving the intuitive "all .rs files".
380pub 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        /// The containment invariant the whole file-safety story rests on: no
402        /// relative path string — however many `..`/`.`/odd segments — can make
403        /// resolve_in_workspace return Ok pointing OUTSIDE the workspace. Either it
404        /// errors, or the result is contained. (Uses the real /tmp as a stand-in
405        /// workspace so each case is read-only and cheap.)
406        #[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        /// Same invariant at the lower layer, for ANY input string (incl. absolute
424        /// paths, traversal, junk): validate_path_in_workspace must never accept a
425        /// path that escapes, and must never panic.
426        #[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        // Regression: a non-UTF-8 (or unknown) $HOME used to turn `~/sub` into
444        // `/sub` (the filesystem root). It must expand to a home-prefixed path or
445        // stay literal — never collapse to root.
446        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        // mkdir -p semantics: a deep, not-yet-existing path resolves and stays
467        // contained (validation happens before the dirs are created).
468        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        // An intermediate component is a dangling symlink pointing outside the
519        // workspace. The walk-up must stop on it (symlink_metadata) and fail
520        // closed, NOT treat it as a fresh lexical component that a later
521        // create_dir_all would follow out of the workspace.
522        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        // Without the allowlist it is a traversal; with it, it resolves.
568        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        // The `mkdir -p` path (resolve_new_path) must honour the extra roots too.
578        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        // Allowing one root must not allow everything above or beside it.
592        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        // The documented way to turn containment off: every canonical unix path
609        // starts with `/`, so `WINX_ALLOW_PATHS=/` accepts anything that resolves.
610        // Asserted against a temp dir rather than a system path, and compared to
611        // the CANONICAL form: validation always returns a canonicalized path, and
612        // on macOS the system prefixes are symlinks (`/etc` -> `/private/etc`,
613        // `/var` -> `/private/var`), so a literal comparison would be wrong there.
614        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        // A symlink out of the workspace is an escape only if the target is
630        // outside every allowed root.
631        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        // A symlink to a directory INSIDE the workspace is fine; the resolved
652        // path stays contained.
653        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}