Skip to main content

farhand_core/
local.rs

1//! The local allowlist. The model sees the local filesystem only through
2//! this module, and this module only ever resolves paths inside the
3//! directories the user listed in `local.allowed_dirs`.
4
5use std::path::{Path, PathBuf};
6
7use crate::config::expand_home;
8use crate::error::{Error, Result};
9
10pub struct LocalScope {
11    roots: Vec<PathBuf>,
12}
13
14#[derive(Debug, Clone)]
15pub struct LocalEntry {
16    pub name: String,
17    pub is_dir: bool,
18    pub size: u64,
19}
20
21impl LocalScope {
22    /// Roots are canonicalised now so symlinked roots still compare equal
23    /// to canonicalised requests. A root that does not exist yet is kept
24    /// as written and simply never matches.
25    pub fn new(allowed: &[PathBuf]) -> Self {
26        let roots = allowed
27            .iter()
28            .map(|p| {
29                let p = expand_home(p);
30                p.canonicalize().unwrap_or(p)
31            })
32            .collect();
33        Self { roots }
34    }
35
36    pub fn roots(&self) -> &[PathBuf] {
37        &self.roots
38    }
39
40    pub fn is_empty(&self) -> bool {
41        self.roots.is_empty()
42    }
43
44    /// Resolve a model-supplied path to a canonical path inside a root.
45    /// Symlinks are followed *before* the containment check, so a link
46    /// pointing outside the allowed directories is refused.
47    pub fn resolve_existing(&self, raw: &str) -> Result<PathBuf> {
48        if self.roots.is_empty() {
49            return Err(Error::Denied(
50                "no local directories are allowed (local.allowed_dirs is empty)".into(),
51            ));
52        }
53        let p = expand_home(Path::new(raw));
54        let p = if p.is_absolute() {
55            p
56        } else if self.roots.len() == 1 {
57            self.roots[0].join(p)
58        } else {
59            return Err(Error::Invalid(format!(
60                "`{raw}` is relative but several local directories are allowed; use an absolute \
61                 path"
62            )));
63        };
64        let canon = p.canonicalize().map_err(|e| {
65            Error::Io(std::io::Error::new(
66                e.kind(),
67                format!("{}: {e}", p.display()),
68            ))
69        })?;
70        self.ensure_inside(&canon)?;
71        Ok(canon)
72    }
73
74    /// Resolve a path that may not exist yet (a download target). The
75    /// nearest existing ancestor must be inside a root.
76    pub fn resolve_for_write(&self, raw: &str) -> Result<PathBuf> {
77        if self.roots.is_empty() {
78            return Err(Error::Denied(
79                "no local directories are allowed (local.allowed_dirs is empty)".into(),
80            ));
81        }
82        let p = expand_home(Path::new(raw));
83        let p = if p.is_absolute() {
84            p
85        } else if self.roots.len() == 1 {
86            self.roots[0].join(p)
87        } else {
88            return Err(Error::Invalid(format!(
89                "`{raw}` is relative but several local directories are allowed; use an absolute \
90                 path"
91            )));
92        };
93        if p.components()
94            .any(|c| matches!(c, std::path::Component::ParentDir))
95        {
96            return Err(Error::Denied(format!("`{raw}` contains `..`")));
97        }
98        let mut existing = p.as_path();
99        let mut tail = Vec::new();
100        loop {
101            if existing.exists() {
102                break;
103            }
104            match (existing.parent(), existing.file_name()) {
105                (Some(parent), Some(name)) => {
106                    tail.push(name.to_owned());
107                    existing = parent;
108                }
109                _ => return Err(Error::Invalid(format!("`{raw}` has no existing ancestor"))),
110            }
111        }
112        let mut canon = existing.canonicalize()?;
113        self.ensure_inside(&canon)?;
114        for name in tail.into_iter().rev() {
115            canon.push(name);
116        }
117        Ok(canon)
118    }
119
120    fn ensure_inside(&self, canon: &Path) -> Result<()> {
121        if self.roots.iter().any(|r| canon.starts_with(r)) {
122            Ok(())
123        } else {
124            Err(Error::Denied(format!(
125                "`{}` is outside the allowed local directories ({})",
126                canon.display(),
127                self.roots
128                    .iter()
129                    .map(|r| r.display().to_string())
130                    .collect::<Vec<_>>()
131                    .join(", ")
132            )))
133        }
134    }
135
136    pub fn list(&self, dir: &Path) -> Result<Vec<LocalEntry>> {
137        let mut out = Vec::new();
138        for entry in std::fs::read_dir(dir)? {
139            let entry = entry?;
140            let meta = entry.metadata()?;
141            out.push(LocalEntry {
142                name: entry.file_name().to_string_lossy().into_owned(),
143                is_dir: meta.is_dir(),
144                size: meta.len(),
145            });
146        }
147        out.sort_by(|a, b| a.name.cmp(&b.name));
148        Ok(out)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn refuses_outside_and_symlink_escape() {
158        let tmp = tempfile::tempdir().unwrap();
159        let root = tmp.path().join("allowed");
160        let outside = tmp.path().join("outside");
161        std::fs::create_dir_all(&root).unwrap();
162        std::fs::create_dir_all(&outside).unwrap();
163        std::fs::write(outside.join("secret.txt"), "x").unwrap();
164        std::fs::write(root.join("ok.txt"), "x").unwrap();
165        #[cfg(unix)]
166        std::os::unix::fs::symlink(outside.join("secret.txt"), root.join("link.txt")).unwrap();
167
168        let scope = LocalScope::new(std::slice::from_ref(&root));
169        assert!(scope
170            .resolve_existing(root.join("ok.txt").to_str().unwrap())
171            .is_ok());
172        assert!(scope.resolve_existing("ok.txt").is_ok());
173        assert!(scope
174            .resolve_existing(outside.join("secret.txt").to_str().unwrap())
175            .is_err());
176        assert!(scope.resolve_existing("../outside/secret.txt").is_err());
177        #[cfg(unix)]
178        assert!(scope.resolve_existing("link.txt").is_err());
179    }
180
181    #[test]
182    fn write_target_may_not_exist_yet() {
183        let tmp = tempfile::tempdir().unwrap();
184        let root = tmp.path().join("allowed");
185        std::fs::create_dir_all(&root).unwrap();
186        let scope = LocalScope::new(std::slice::from_ref(&root));
187        let p = scope.resolve_for_write("new/dir/file.bin").unwrap();
188        assert!(p.starts_with(root.canonicalize().unwrap()));
189        assert!(scope.resolve_for_write("../x").is_err());
190        assert!(scope.resolve_for_write("/etc/passwd").is_err());
191    }
192
193    #[test]
194    fn empty_scope_denies_everything() {
195        let scope = LocalScope::new(&[]);
196        assert!(matches!(
197            scope.resolve_existing("/tmp"),
198            Err(Error::Denied(_))
199        ));
200    }
201}