Skip to main content

locode_host/
path.rs

1//! The path jail: resolve a model-supplied path under the workspace root, or reject it.
2
3use std::io::ErrorKind;
4use std::path::{Component, Path, PathBuf};
5
6use crate::{Host, PathPolicy};
7
8/// A path-jail failure.
9#[derive(Debug, thiserror::Error)]
10pub enum PathError {
11    /// The path resolves outside the workspace root.
12    #[error("path escapes the workspace root: {0}")]
13    Escape(String),
14    /// The workspace root itself is invalid (does not exist / cannot canonicalize).
15    #[error("workspace root is invalid: {0}")]
16    InvalidRoot(String),
17    /// An IO error while resolving.
18    #[error("io error resolving {path}: {source}")]
19    Io {
20        /// The path being resolved.
21        path: String,
22        /// The underlying IO error.
23        source: std::io::Error,
24    },
25}
26
27impl Host {
28    /// Resolve `candidate` (absolute, or relative to `cwd`) to a concrete absolute path.
29    ///
30    /// Under [`PathPolicy::Jailed`] the result is guaranteed to live under the workspace
31    /// root — `..`, absolute, and **symlink** escapes are rejected — while paths whose
32    /// leaf does not yet exist (create-new-file) are still allowed. Under
33    /// [`PathPolicy::Unrestricted`] the path is only made absolute; nothing is rejected.
34    ///
35    /// # Errors
36    /// [`PathError::Escape`] if a jailed path escapes the root; [`PathError::Io`] if an
37    /// ancestor cannot be canonicalized.
38    pub async fn resolve_in_jail(
39        &self,
40        cwd: &Path,
41        candidate: &Path,
42    ) -> Result<PathBuf, PathError> {
43        let absolute = if candidate.is_absolute() {
44            candidate.to_path_buf()
45        } else {
46            cwd.join(candidate)
47        };
48        let normalized = normalize_lexical(&absolute);
49
50        if self.path_policy == PathPolicy::Unrestricted {
51            return Ok(normalized);
52        }
53
54        // (1) Lexical prefix check — catches `../etc/passwd`, `/etc/passwd`, `a/../../b`.
55        if !normalized.starts_with(&self.workspace_root) {
56            return Err(PathError::Escape(normalized.display().to_string()));
57        }
58        // (2) Symlink check — canonicalize the deepest *existing* ancestor and confirm it
59        // is still under the (already-canonical) root. Catches an in-jail symlink that
60        // points out, while still allowing a not-yet-existing leaf.
61        let canonical_ancestor =
62            canonicalize_existing_ancestor(&normalized)
63                .await
64                .map_err(|source| PathError::Io {
65                    path: normalized.display().to_string(),
66                    source,
67                })?;
68        if !canonical_ancestor.starts_with(&self.workspace_root) {
69            return Err(PathError::Escape(normalized.display().to_string()));
70        }
71        Ok(normalized)
72    }
73}
74
75/// Syntactically resolve `.`/`..` without touching the filesystem.
76fn normalize_lexical(path: &Path) -> PathBuf {
77    let mut out = PathBuf::new();
78    for component in path.components() {
79        match component {
80            Component::ParentDir => {
81                out.pop();
82            }
83            Component::CurDir => {}
84            other => out.push(other.as_os_str()),
85        }
86    }
87    out
88}
89
90/// Canonicalize the deepest ancestor of `path` that actually exists (symlink-resolving).
91async fn canonicalize_existing_ancestor(path: &Path) -> std::io::Result<PathBuf> {
92    let mut current = path;
93    loop {
94        match tokio::fs::canonicalize(current).await {
95            Ok(canonical) => return Ok(canonical),
96            Err(e) if e.kind() == ErrorKind::NotFound => match current.parent() {
97                Some(parent) => current = parent,
98                None => return Err(e),
99            },
100            Err(e) => return Err(e),
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::{PathPolicy, test_host};
109    use tempfile::tempdir;
110
111    #[tokio::test]
112    async fn rejects_parent_and_absolute_escapes() {
113        let dir = tempdir().unwrap();
114        let host = test_host(dir.path(), PathPolicy::Jailed, false);
115        let root = host.workspace_root().to_path_buf();
116
117        for bad in ["../etc/passwd", "/etc/passwd", "a/../../b"] {
118            let err = host
119                .resolve_in_jail(&root, Path::new(bad))
120                .await
121                .expect_err(bad);
122            assert!(matches!(err, PathError::Escape(_)), "{bad} should escape");
123        }
124    }
125
126    #[tokio::test]
127    async fn allows_paths_in_jail_including_nonexistent_leaf() {
128        let dir = tempdir().unwrap();
129        let host = test_host(dir.path(), PathPolicy::Jailed, false);
130        let root = host.workspace_root().to_path_buf();
131
132        let resolved = host
133            .resolve_in_jail(&root, Path::new("newdir/new.txt"))
134            .await
135            .expect("nonexistent leaf under root is allowed");
136        assert!(resolved.starts_with(&root));
137
138        // Relative resolves against cwd.
139        let sub = root.join("sub");
140        let resolved = host
141            .resolve_in_jail(&sub, Path::new("f.txt"))
142            .await
143            .expect("relative under cwd");
144        assert_eq!(resolved, sub.join("f.txt"));
145    }
146
147    #[cfg(unix)]
148    #[tokio::test]
149    async fn rejects_symlink_escape() {
150        let dir = tempdir().unwrap();
151        let outside = tempdir().unwrap();
152        let host = test_host(dir.path(), PathPolicy::Jailed, false);
153        let root = host.workspace_root().to_path_buf();
154
155        // A symlink inside the jail pointing outside it.
156        std::os::unix::fs::symlink(outside.path(), root.join("link")).unwrap();
157        let err = host
158            .resolve_in_jail(&root, Path::new("link/secret.txt"))
159            .await
160            .expect_err("symlink escape");
161        assert!(matches!(err, PathError::Escape(_)));
162    }
163
164    #[tokio::test]
165    async fn unrestricted_allows_escapes() {
166        let dir = tempdir().unwrap();
167        let host = test_host(dir.path(), PathPolicy::Unrestricted, false);
168        let root = host.workspace_root().to_path_buf();
169
170        let resolved = host
171            .resolve_in_jail(&root, Path::new("/etc/hostname"))
172            .await
173            .expect("unrestricted allows absolute out-of-root");
174        assert_eq!(resolved, Path::new("/etc/hostname"));
175    }
176}