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//!
5//! A path is checked once, when it is resolved, and then reached from a
6//! handle on its allowed root one component at a time without following a
7//! symlink (`openat` with `O_NOFOLLOW`). Something that swaps a symlink in
8//! between the check and the access — a sync tool, an editor, another
9//! process — gets a refusal, not a file outside the allowlist.
10
11use std::ffi::{OsStr, OsString};
12use std::os::fd::OwnedFd;
13use std::os::unix::ffi::OsStrExt;
14use std::path::{Component, Path, PathBuf};
15use std::sync::Arc;
16
17use rustix::fs::{AtFlags, Dir, FileType, Mode, OFlags};
18use rustix::io::Errno;
19
20use crate::config::expand_home;
21use crate::error::{Error, Result};
22
23pub struct LocalScope {
24    roots: Vec<PathBuf>,
25    /// `(dev, ino)` of each root when the scope was made; `None` for a root
26    /// that did not exist then. A root replaced since is refused.
27    ids: Vec<Option<(u64, u64)>>,
28}
29
30#[derive(Debug, Clone)]
31pub struct LocalEntry {
32    pub name: String,
33    pub is_dir: bool,
34    pub size: u64,
35}
36
37impl LocalScope {
38    /// Roots are canonicalised now so symlinked roots still compare equal
39    /// to canonicalised requests, and their identity is recorded. A root
40    /// that does not exist yet is kept as written and simply never matches.
41    pub fn new(allowed: &[PathBuf]) -> Self {
42        let mut roots = Vec::with_capacity(allowed.len());
43        let mut ids = Vec::with_capacity(allowed.len());
44        for p in allowed {
45            let p = expand_home(p);
46            let (root, id) = match p.canonicalize() {
47                Ok(c) => {
48                    let id = rustix::fs::stat(&c).ok().map(|st| identity(&st));
49                    (c, id)
50                }
51                Err(_) => (p, None),
52            };
53            roots.push(root);
54            ids.push(id);
55        }
56        Self { roots, ids }
57    }
58
59    pub fn roots(&self) -> &[PathBuf] {
60        &self.roots
61    }
62
63    pub fn is_empty(&self) -> bool {
64        self.roots.is_empty()
65    }
66
67    /// Resolve a model-supplied path to a canonical path inside a root.
68    /// Symlinks are followed *before* the containment check, so a link
69    /// pointing outside the allowed directories is refused.
70    pub fn resolve_existing(&self, raw: &str) -> Result<PathBuf> {
71        let p = self.absolute(raw)?;
72        let canon = p.canonicalize().map_err(|e| {
73            Error::Io(std::io::Error::new(
74                e.kind(),
75                format!("{}: {e}", p.display()),
76            ))
77        })?;
78        self.ensure_inside(&canon)?;
79        Ok(canon)
80    }
81
82    /// Resolve a path that may not exist yet (a download target). The
83    /// nearest existing ancestor must be inside a root.
84    pub fn resolve_for_write(&self, raw: &str) -> Result<PathBuf> {
85        let p = self.absolute(raw)?;
86        if p.components().any(|c| matches!(c, Component::ParentDir)) {
87            return Err(Error::Denied(format!("`{raw}` contains `..`")));
88        }
89        let mut existing = p.as_path();
90        let mut tail = Vec::new();
91        loop {
92            if existing.exists() {
93                break;
94            }
95            match (existing.parent(), existing.file_name()) {
96                (Some(parent), Some(name)) => {
97                    tail.push(name.to_owned());
98                    existing = parent;
99                }
100                _ => return Err(Error::Invalid(format!("`{raw}` has no existing ancestor"))),
101            }
102        }
103        let mut canon = existing.canonicalize()?;
104        self.ensure_inside(&canon)?;
105        for name in tail.into_iter().rev() {
106            canon.push(name);
107        }
108        Ok(canon)
109    }
110
111    /// `raw` as an absolute path: relative paths are taken against the one
112    /// root, and are ambiguous when there are several.
113    fn absolute(&self, raw: &str) -> Result<PathBuf> {
114        if self.roots.is_empty() {
115            return Err(Error::Denied(
116                "no local directories are allowed (local.allowed_dirs is empty)".into(),
117            ));
118        }
119        let p = expand_home(Path::new(raw));
120        if p.is_absolute() {
121            Ok(p)
122        } else if self.roots.len() == 1 {
123            Ok(self.roots[0].join(p))
124        } else {
125            Err(Error::Invalid(format!(
126                "`{raw}` is relative but several local directories are allowed; use an absolute \
127                 path"
128            )))
129        }
130    }
131
132    fn ensure_inside(&self, canon: &Path) -> Result<()> {
133        if self.roots.iter().any(|r| canon.starts_with(r)) {
134            Ok(())
135        } else {
136            Err(self.outside(canon))
137        }
138    }
139
140    fn outside(&self, path: &Path) -> Error {
141        Error::Denied(format!(
142            "`{}` is outside the allowed local directories ({})",
143            path.display(),
144            self.roots
145                .iter()
146                .map(|r| r.display().to_string())
147                .collect::<Vec<_>>()
148                .join(", ")
149        ))
150    }
151
152    /// Resolve and open a regular file for reading.
153    pub fn open_file(&self, raw: &str) -> Result<(PathBuf, std::fs::File)> {
154        let canon = self.resolve_existing(raw)?;
155        let file = self.open_resolved(&canon)?;
156        Ok((canon, file))
157    }
158
159    /// Open the regular file at `path`, a path inside a root as
160    /// [`resolve_existing`](Self::resolve_existing) or a walk below one
161    /// returns it, without following a symlink anywhere below the root.
162    pub fn open_resolved(&self, path: &Path) -> Result<std::fs::File> {
163        let (dir, name) = self.walk(path, false)?;
164        let Some(name) = name else {
165            return Err(Error::Invalid(format!(
166                "`{}` is a directory",
167                path.display()
168            )));
169        };
170        // `O_NONBLOCK` so a FIFO cannot hang the open; it is refused below.
171        let fd = rustix::fs::openat(
172            &dir,
173            name,
174            OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
175            Mode::empty(),
176        )
177        .map_err(|e| refused(&dir, name, path, e))?;
178        let st = rustix::fs::fstat(&fd).map_err(std::io::Error::from)?;
179        match FileType::from_raw_mode(st.st_mode) {
180            FileType::RegularFile => Ok(std::fs::File::from(fd)),
181            FileType::Directory => Err(Error::Invalid(format!(
182                "`{}` is a directory",
183                path.display()
184            ))),
185            _ => Err(Error::Invalid(format!(
186                "`{}` is not a regular file",
187                path.display()
188            ))),
189        }
190    }
191
192    /// List the directory at `dir`, a path inside a root.
193    pub fn list(&self, dir: &Path) -> Result<Vec<LocalEntry>> {
194        let (parent, name) = self.walk(dir, false)?;
195        let fd = match name {
196            None => parent,
197            Some(n) => open_dir_at(&parent, n).map_err(|e| refused(&parent, n, dir, e))?,
198        };
199        let mut out = Vec::new();
200        for entry in Dir::read_from(&fd).map_err(std::io::Error::from)? {
201            let entry = entry.map_err(std::io::Error::from)?;
202            let name = entry.file_name();
203            if matches!(name.to_bytes(), b"." | b"..") {
204                continue;
205            }
206            // An entry removed since the directory was read is skipped.
207            let Ok(st) = rustix::fs::statat(&fd, name, AtFlags::SYMLINK_NOFOLLOW) else {
208                continue;
209            };
210            out.push(LocalEntry {
211                name: OsStr::from_bytes(name.to_bytes())
212                    .to_string_lossy()
213                    .into_owned(),
214                is_dir: FileType::from_raw_mode(st.st_mode) == FileType::Directory,
215                size: st.st_size as u64,
216            });
217        }
218        out.sort_by(|a, b| a.name.cmp(&b.name));
219        Ok(out)
220    }
221
222    /// Start a download to `dest` (as [`resolve_for_write`](Self::resolve_for_write)
223    /// returns it): missing folders are created, and the data goes to a
224    /// randomly named temporary file created exclusively in the destination
225    /// folder. [`PartFile::commit`] renames it into place.
226    pub fn create_part(&self, dest: &Path) -> Result<PartFile> {
227        let (dir, name) = self.walk(dest, true)?;
228        let Some(name) = name else {
229            return Err(Error::Invalid(format!(
230                "`{}` is a directory",
231                dest.display()
232            )));
233        };
234        let part = OsString::from(format!(
235            ".{}.{:016x}.farhand-part",
236            name.to_string_lossy(),
237            crate::remote::random_u64()
238        ));
239        let fd = rustix::fs::openat(
240            &dir,
241            &part,
242            OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
243            Mode::from_raw_mode(0o644),
244        )
245        .map_err(|e| {
246            Error::Io(std::io::Error::new(
247                std::io::Error::from(e).kind(),
248                format!(
249                    "cannot create a temporary file next to {}: {e}",
250                    dest.display()
251                ),
252            ))
253        })?;
254        Ok(PartFile {
255            dir,
256            part,
257            name: name.to_owned(),
258            file: Arc::new(std::fs::File::from(fd)),
259            dest: dest.to_path_buf(),
260            committed: false,
261        })
262    }
263
264    /// The handle on the folder holding `path` and the last component of
265    /// `path` (`None` when `path` is the root itself). Every folder on the
266    /// way is opened from the one above it without following a symlink;
267    /// with `create`, missing ones are made.
268    fn walk<'p>(&self, path: &'p Path, create: bool) -> Result<(OwnedFd, Option<&'p OsStr>)> {
269        // A root that existed at startup is preferred, so a nested one
270        // that did not cannot shadow it.
271        let found = |known: bool| {
272            self.roots.iter().enumerate().find_map(|(i, r)| {
273                (self.ids[i].is_some() == known).then_some(())?;
274                Some((i, path.strip_prefix(r).ok()?))
275            })
276        };
277        let Some((i, rel)) = found(true).or_else(|| found(false)) else {
278            return Err(self.outside(path));
279        };
280        let mut names = Vec::new();
281        for c in rel.components() {
282            match c {
283                Component::Normal(n) => names.push(n),
284                _ => {
285                    return Err(Error::Invalid(format!(
286                        "`{}` is not a resolved path",
287                        path.display()
288                    )))
289                }
290            }
291        }
292        let mut dir = self.open_root(i)?;
293        let Some((last, parents)) = names.split_last() else {
294            return Ok((dir, None));
295        };
296        for n in parents {
297            dir = match open_dir_at(&dir, n) {
298                Ok(d) => d,
299                Err(Errno::NOENT) if create => {
300                    match rustix::fs::mkdirat(&dir, *n, Mode::from_raw_mode(0o755)) {
301                        Ok(()) | Err(Errno::EXIST) => {}
302                        Err(e) => return Err(Error::Io(e.into())),
303                    }
304                    open_dir_at(&dir, n).map_err(|e| refused(&dir, n, path, e))?
305                }
306                Err(e) => return Err(refused(&dir, n, path, e)),
307            };
308        }
309        Ok((dir, Some(last)))
310    }
311
312    fn open_root(&self, i: usize) -> Result<OwnedFd> {
313        let root = &self.roots[i];
314        let gone = || {
315            Error::Denied(format!(
316                "the allowed folder `{}` did not exist or was replaced after FarHand started; \
317                 restart the agent to pick it up",
318                root.display()
319            ))
320        };
321        let expected = self.ids[i].ok_or_else(gone)?;
322        let fd = rustix::fs::open(
323            root,
324            OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
325            Mode::empty(),
326        )
327        .map_err(|_| gone())?;
328        let st = rustix::fs::fstat(&fd).map_err(std::io::Error::from)?;
329        if identity(&st) != expected {
330            return Err(gone());
331        }
332        Ok(fd)
333    }
334}
335
336/// A download in progress: a temporary file in its destination folder,
337/// held by a handle on that folder. Dropped without
338/// [`commit`](Self::commit), it is removed.
339pub struct PartFile {
340    dir: OwnedFd,
341    part: OsString,
342    name: OsString,
343    file: Arc<std::fs::File>,
344    dest: PathBuf,
345    committed: bool,
346}
347
348impl PartFile {
349    pub fn file(&self) -> &Arc<std::fs::File> {
350        &self.file
351    }
352
353    pub fn dest(&self) -> &Path {
354        &self.dest
355    }
356
357    /// Flush the data and rename it into place, within the one folder
358    /// handle: the file lands in the folder it was written in, and a
359    /// symlink at the destination is replaced rather than written through.
360    pub fn commit(mut self) -> Result<()> {
361        self.file.sync_all()?;
362        if rustix::fs::statat(&self.dir, &self.name, AtFlags::SYMLINK_NOFOLLOW)
363            .is_ok_and(|st| FileType::from_raw_mode(st.st_mode) == FileType::Directory)
364        {
365            return Err(Error::Invalid(format!(
366                "`{}` is a directory",
367                self.dest.display()
368            )));
369        }
370        rustix::fs::renameat(&self.dir, &self.part, &self.dir, &self.name)
371            .map_err(std::io::Error::from)?;
372        self.committed = true;
373        Ok(())
374    }
375}
376
377impl Drop for PartFile {
378    fn drop(&mut self) {
379        if !self.committed {
380            let _ = rustix::fs::unlinkat(&self.dir, &self.part, AtFlags::empty());
381        }
382    }
383}
384
385// The field types differ between platforms (`st_dev` is `i32` on macOS).
386#[allow(clippy::unnecessary_cast)]
387fn identity(st: &rustix::fs::Stat) -> (u64, u64) {
388    (st.st_dev as u64, st.st_ino as u64)
389}
390
391fn open_dir_at(dir: &OwnedFd, name: &OsStr) -> std::result::Result<OwnedFd, Errno> {
392    rustix::fs::openat(
393        dir,
394        name,
395        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
396        Mode::empty(),
397    )
398}
399
400/// The error for a component that would not open: a refusal if a symlink
401/// now stands where the resolved path had none, the I/O error otherwise.
402fn refused(dir: &OwnedFd, name: &OsStr, path: &Path, e: Errno) -> Error {
403    let is_link = rustix::fs::statat(dir, name, AtFlags::SYMLINK_NOFOLLOW)
404        .is_ok_and(|st| FileType::from_raw_mode(st.st_mode) == FileType::Symlink);
405    if is_link {
406        Error::Denied(format!(
407            "`{}` changed while it was being opened: `{}` is now a symlink",
408            path.display(),
409            name.to_string_lossy()
410        ))
411    } else {
412        let e = std::io::Error::from(e);
413        Error::Io(std::io::Error::new(
414            e.kind(),
415            format!("{}: {e}", path.display()),
416        ))
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use std::io::{Read, Write};
424    use std::os::unix::fs::symlink;
425
426    #[test]
427    fn refuses_outside_and_symlink_escape() {
428        let tmp = tempfile::tempdir().unwrap();
429        let root = tmp.path().join("allowed");
430        let outside = tmp.path().join("outside");
431        std::fs::create_dir_all(&root).unwrap();
432        std::fs::create_dir_all(&outside).unwrap();
433        std::fs::write(outside.join("secret.txt"), "x").unwrap();
434        std::fs::write(root.join("ok.txt"), "x").unwrap();
435        symlink(outside.join("secret.txt"), root.join("link.txt")).unwrap();
436
437        let scope = LocalScope::new(std::slice::from_ref(&root));
438        assert!(scope
439            .resolve_existing(root.join("ok.txt").to_str().unwrap())
440            .is_ok());
441        assert!(scope.resolve_existing("ok.txt").is_ok());
442        assert!(scope
443            .resolve_existing(outside.join("secret.txt").to_str().unwrap())
444            .is_err());
445        assert!(scope.resolve_existing("../outside/secret.txt").is_err());
446        assert!(scope.resolve_existing("link.txt").is_err());
447        assert!(scope.open_file("link.txt").is_err());
448    }
449
450    #[test]
451    fn write_target_may_not_exist_yet() {
452        let tmp = tempfile::tempdir().unwrap();
453        let root = tmp.path().join("allowed");
454        std::fs::create_dir_all(&root).unwrap();
455        let scope = LocalScope::new(std::slice::from_ref(&root));
456        let p = scope.resolve_for_write("new/dir/file.bin").unwrap();
457        assert!(p.starts_with(root.canonicalize().unwrap()));
458        assert!(scope.resolve_for_write("../x").is_err());
459        assert!(scope.resolve_for_write("/etc/passwd").is_err());
460    }
461
462    #[test]
463    fn empty_scope_denies_everything() {
464        let scope = LocalScope::new(&[]);
465        assert!(matches!(
466            scope.resolve_existing("/tmp"),
467            Err(Error::Denied(_))
468        ));
469    }
470
471    /// A tree with an allowed root holding `sub/file.txt` and a sibling
472    /// `outside/` holding `secret.txt`.
473    fn tree() -> (tempfile::TempDir, PathBuf, PathBuf, LocalScope) {
474        let tmp = tempfile::tempdir().unwrap();
475        let root = tmp.path().canonicalize().unwrap().join("allowed");
476        let outside = tmp.path().canonicalize().unwrap().join("outside");
477        std::fs::create_dir_all(root.join("sub")).unwrap();
478        std::fs::create_dir_all(&outside).unwrap();
479        std::fs::write(root.join("sub/file.txt"), "inside").unwrap();
480        std::fs::write(outside.join("file.txt"), "secret").unwrap();
481        let scope = LocalScope::new(std::slice::from_ref(&root));
482        (tmp, root, outside, scope)
483    }
484
485    #[test]
486    fn a_file_swapped_for_a_link_after_the_check_is_refused() {
487        let (_t, root, outside, scope) = tree();
488        let (path, mut f) = scope.open_file("sub/file.txt").unwrap();
489        let mut s = String::new();
490        f.read_to_string(&mut s).unwrap();
491        assert_eq!(s, "inside");
492
493        // The check passed; then the file becomes a link out.
494        std::fs::remove_file(root.join("sub/file.txt")).unwrap();
495        symlink(outside.join("file.txt"), root.join("sub/file.txt")).unwrap();
496        let err = scope.open_resolved(&path).unwrap_err();
497        assert!(matches!(err, Error::Denied(_)), "{err}");
498    }
499
500    #[test]
501    fn a_folder_swapped_for_a_link_after_the_check_is_refused() {
502        let (_t, root, outside, scope) = tree();
503        let path = scope.resolve_existing("sub/file.txt").unwrap();
504        std::fs::rename(root.join("sub"), root.join("old")).unwrap();
505        symlink(&outside, root.join("sub")).unwrap();
506        let err = scope.open_resolved(&path).unwrap_err();
507        assert!(matches!(err, Error::Denied(_)), "{err}");
508        let err = scope.list(&root.join("sub")).unwrap_err();
509        assert!(matches!(err, Error::Denied(_)), "{err}");
510
511        // A download through it is refused before anything is created.
512        let dest = root.join("sub/new.txt");
513        assert!(matches!(scope.create_part(&dest), Err(Error::Denied(_))));
514        assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1);
515    }
516
517    #[test]
518    fn a_replaced_root_is_refused() {
519        let (_t, root, _outside, scope) = tree();
520        std::fs::rename(&root, root.with_extension("old")).unwrap();
521        std::fs::create_dir_all(root.join("sub")).unwrap();
522        std::fs::write(root.join("sub/file.txt"), "new").unwrap();
523        let err = scope.open_file("sub/file.txt").unwrap_err();
524        assert!(matches!(err, Error::Denied(_)), "{err}");
525    }
526
527    #[test]
528    fn listing_does_not_follow_links() {
529        let (_t, root, outside, scope) = tree();
530        symlink(&outside, root.join("out")).unwrap();
531        let entries = scope.list(&root).unwrap();
532        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
533        assert_eq!(names, ["out", "sub"]);
534        assert!(!entries[0].is_dir);
535        assert!(scope.open_file("out").is_err());
536        assert!(scope.open_file("sub").is_err());
537    }
538
539    #[test]
540    fn downloads_land_in_place_and_replace_a_planted_link() {
541        let (_t, root, outside, scope) = tree();
542        // New folders are created on the way.
543        let dest = scope.resolve_for_write("a/b/new.txt").unwrap();
544        let part = scope.create_part(&dest).unwrap();
545        (&**part.file()).write_all(b"data").unwrap();
546        part.commit().unwrap();
547        assert_eq!(std::fs::read(root.join("a/b/new.txt")).unwrap(), b"data");
548
549        // A link at the destination is replaced, its target untouched.
550        symlink(outside.join("file.txt"), root.join("sub/link.txt")).unwrap();
551        let part = scope.create_part(&root.join("sub/link.txt")).unwrap();
552        (&**part.file()).write_all(b"new").unwrap();
553        part.commit().unwrap();
554        assert_eq!(std::fs::read(outside.join("file.txt")).unwrap(), b"secret");
555        assert!(!std::fs::symlink_metadata(root.join("sub/link.txt"))
556            .unwrap()
557            .file_type()
558            .is_symlink());
559
560        // An abandoned download leaves nothing behind.
561        let part = scope.create_part(&root.join("sub/gone.txt")).unwrap();
562        drop(part);
563        let names: Vec<_> = std::fs::read_dir(root.join("sub"))
564            .unwrap()
565            .map(|e| e.unwrap().file_name())
566            .collect();
567        assert_eq!(names.len(), 2, "{names:?}");
568    }
569}