fs_mistrust/
dir.rs

1//! Implement a wrapper for access to the members of a directory whose status
2//! we've checked.
3
4use std::{
5    fs::{File, Metadata, OpenOptions},
6    io,
7    path::{Path, PathBuf},
8};
9
10use crate::{walk::PathType, Error, Mistrust, Result, Verifier};
11
12/// A directory whose access properties we have verified, along with accessor
13/// functions to access members of that directory.
14///
15/// The accessor functions will enforce that whatever security properties we
16/// checked on the directory also apply to all of the members that we access
17/// within the directory.
18///
19/// ## Limitations
20///
21/// Having a `CheckedDir` means only that, at the time it was created, we were
22/// confident that no _untrusted_ user could access it inappropriately.  It is
23/// still possible, after the `CheckedDir` is created, that a _trusted_ user can
24/// alter its permissions, make its path point somewhere else, or so forth.
25///
26/// If this kind of time-of-use/time-of-check issue is unacceptable, you may
27/// wish to look at other solutions, possibly involving `openat()` or related
28/// APIs.
29///
30/// See also the crate-level [Limitations](crate#limitations) section.
31#[derive(Debug, Clone)]
32pub struct CheckedDir {
33    /// The `Mistrust` object whose rules we apply to members of this directory.
34    mistrust: Mistrust,
35    /// The location of this directory, in its original form.
36    location: PathBuf,
37    /// The "readable_okay" flag that we used to create this CheckedDir.
38    readable_okay: bool,
39}
40
41impl CheckedDir {
42    /// Create a CheckedDir.
43    pub(crate) fn new(verifier: &Verifier<'_>, path: &Path) -> Result<Self> {
44        let mut mistrust = verifier.mistrust.clone();
45        // Ignore the path that we already verified.  Since ignore_prefix
46        // canonicalizes the path, we _will_ recheck the directory if it starts
47        // pointing to a new canonical location.  That's probably a feature.
48        //
49        // TODO:
50        //   * If `path` is a prefix of the original ignored path, this will
51        //     make us ignore _less_.
52        mistrust.ignore_prefix = crate::canonicalize_opt_prefix(&Some(Some(path.to_path_buf())))?;
53        Ok(CheckedDir {
54            mistrust,
55            location: path.to_path_buf(),
56            readable_okay: verifier.readable_okay,
57        })
58    }
59
60    /// Construct a new directory within this CheckedDir, if it does not already
61    /// exist.
62    ///
63    /// `path` must be a relative path to the new directory, containing no `..`
64    /// components.
65    pub fn make_directory<P: AsRef<Path>>(&self, path: P) -> Result<()> {
66        let path = path.as_ref();
67        self.check_path(path)?;
68        self.verifier().make_directory(self.location.join(path))
69    }
70
71    /// Construct a new `CheckedDir` within this `CheckedDir`
72    ///
73    /// Creates the directory if it does not already exist.
74    ///
75    /// `path` must be a relative path to the new directory, containing no `..`
76    /// components.
77    pub fn make_secure_directory<P: AsRef<Path>>(&self, path: P) -> Result<CheckedDir> {
78        let path = path.as_ref();
79        self.make_directory(path)?;
80        // TODO I think this rechecks parents, but it need not, since we already did that.
81        self.verifier().secure_dir(self.location.join(path))
82    }
83
84    /// Create a new [`FileAccess`](crate::FileAccess) for reading or writing files within this directory.
85    pub fn file_access(&self) -> crate::FileAccess<'_> {
86        crate::FileAccess::from_checked_dir(self)
87    }
88
89    /// Open a file within this CheckedDir, using a set of [`OpenOptions`].
90    ///
91    /// `path` must be a relative path to the new directory, containing no `..`
92    /// components.  We check, but do not create, the file's parent directories.
93    /// We check the file's permissions after opening it.  If the file already
94    /// exists, it must not be a symlink.
95    ///
96    /// If the file is created (and this is a unix-like operating system), we
97    /// always create it with mode `600`, regardless of any mode options set in
98    /// `options`.
99    pub fn open<P: AsRef<Path>>(&self, path: P, options: &OpenOptions) -> Result<File> {
100        self.file_access().open(path, options)
101    }
102
103    /// List the contents of a directory within this [`CheckedDir`].
104    ///
105    /// `path` must be a relative path, containing no `..` components.  Before
106    /// listing the directory, we verify that that no untrusted user is able
107    /// change its contents or make it point somewhere else.
108    ///
109    /// The return value is an iterator as returned by [`std::fs::ReadDir`].  We
110    /// _do not_ check any properties of the elements of this iterator.
111    pub fn read_directory<P: AsRef<Path>>(&self, path: P) -> Result<std::fs::ReadDir> {
112        let path = self.verified_full_path(path.as_ref(), FullPathCheck::CheckPath)?;
113
114        std::fs::read_dir(&path).map_err(|e| Error::io(e, path, "read directory"))
115    }
116
117    /// Remove a file within this [`CheckedDir`].
118    ///
119    /// `path` must be a relative path, containing no `..` components.
120    ///
121    /// Note that we ensure that the _parent_ of the file to be removed is
122    /// unmodifiable by any untrusted user, but we do not check any permissions
123    /// on the file itself, since those are irrelevant to removing it.
124    pub fn remove_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
125        // We insist that the ownership and permissions on everything up to and
126        // including the _parent_ of the path that we are removing have to be
127        // correct.  (If it were otherwise, we could be tricked into removing
128        // the wrong thing.)  But we don't care about the permissions on file we
129        // are removing.
130        let path = self.verified_full_path(path.as_ref(), FullPathCheck::CheckParent)?;
131
132        std::fs::remove_file(&path).map_err(|e| Error::io(e, path, "remove file"))
133    }
134
135    /// Return a reference to this directory as a [`Path`].
136    ///
137    /// Note that this function lets you work with a broader collection of
138    /// functions, including functions that might let you access or create a
139    /// file that is accessible by non-trusted users.  Be careful!
140    pub fn as_path(&self) -> &Path {
141        self.location.as_path()
142    }
143
144    /// Return a new [`PathBuf`] containing this directory's path, with `path`
145    /// appended to it.
146    ///
147    /// Return an error if `path` has any components that could take us outside
148    /// of this directory.
149    pub fn join<P: AsRef<Path>>(&self, path: P) -> Result<PathBuf> {
150        let path = path.as_ref();
151        self.check_path(path)?;
152        Ok(self.location.join(path))
153    }
154
155    /// Read the contents of the file at `path` within this directory, as a
156    /// String, if possible.
157    ///
158    /// Return an error if `path` is absent, if its permissions are incorrect,
159    /// if it has any components that could take us outside of this directory,
160    /// or if its contents are not UTF-8.
161    pub fn read_to_string<P: AsRef<Path>>(&self, path: P) -> Result<String> {
162        self.file_access().read_to_string(path)
163    }
164
165    /// Read the contents of the file at `path` within this directory, as a
166    /// vector of bytes, if possible.
167    ///
168    /// Return an error if `path` is absent, if its permissions are incorrect,
169    /// or if it has any components that could take us outside of this
170    /// directory.
171    pub fn read<P: AsRef<Path>>(&self, path: P) -> Result<Vec<u8>> {
172        self.file_access().read(path)
173    }
174
175    /// Store `contents` into the file located at `path` within this directory.
176    ///
177    /// We won't write to `path` directly: instead, we'll write to a temporary
178    /// file in the same directory as `path`, and then replace `path` with that
179    /// temporary file if we were successful.  (This isn't truly atomic on all
180    /// file systems, but it's closer than many alternatives.)
181    ///
182    /// # Limitations
183    ///
184    /// This function will clobber any existing files with the same name as
185    /// `path` but with the extension `tmp`.  (That is, if you are writing to
186    /// "foo.txt", it will replace "foo.tmp" in the same directory.)
187    ///
188    /// This function may give incorrect behavior if multiple threads or
189    /// processes are writing to the same file at the same time: it is the
190    /// programmer's responsibility to use appropriate locking to avoid this.
191    pub fn write_and_replace<P: AsRef<Path>, C: AsRef<[u8]>>(
192        &self,
193        path: P,
194        contents: C,
195    ) -> Result<()> {
196        self.file_access().write_and_replace(path, contents)
197    }
198
199    /// Return the [`Metadata`] of the file located at `path`.
200    ///
201    /// `path` must be a relative path, containing no `..` components.
202    /// We check the file's parent directories,
203    /// and the file's permissions.
204    /// If the file exists, it must not be a symlink.
205    ///
206    /// Returns [`Error::NotFound`] if the file does not exist.
207    ///
208    /// Return an error if `path` is absent, if its permissions are incorrect[^1],
209    /// if the permissions of any of its the parent directories are incorrect,
210    /// or if it has any components that could take us outside of this directory.
211    ///
212    /// [^1]: the permissions are incorrect if the path is readable or writable by untrusted users
213    pub fn metadata<P: AsRef<Path>>(&self, path: P) -> Result<Metadata> {
214        let path = self.verified_full_path(path.as_ref(), FullPathCheck::CheckParent)?;
215
216        let meta = path
217            .symlink_metadata()
218            .map_err(|e| Error::inspecting(e, &path))?;
219
220        if meta.is_symlink() {
221            // TODO: this is inconsistent with CheckedDir::open()'s behavior, which returns a
222            // FilesystemLoop io error in this case (we can't construct such an error here, because
223            // ErrorKind::FilesystemLoop is only available on nightly)
224            let err = io::Error::new(
225                io::ErrorKind::Other,
226                format!("Path {:?} is a symlink", path),
227            );
228            return Err(Error::io(err, &path, "metadata"));
229        }
230
231        if let Some(error) = self
232            .verifier()
233            .check_one(path.as_path(), PathType::Content, &meta)
234            .into_iter()
235            .next()
236        {
237            Err(error)
238        } else {
239            Ok(meta)
240        }
241    }
242
243    /// Create a [`Verifier`] with the appropriate rules for this
244    /// `CheckedDir`.
245    pub fn verifier(&self) -> Verifier<'_> {
246        let mut v = self.mistrust.verifier();
247        if self.readable_okay {
248            v = v.permit_readable();
249        }
250        v
251    }
252
253    /// Helper: Make sure that the path `p` is a relative path that can be
254    /// guaranteed to stay within this directory.
255    ///
256    /// (Specifically, we reject absolute paths, ".." items, and Windows path prefixes.)
257    fn check_path(&self, p: &Path) -> Result<()> {
258        use std::path::Component;
259        // This check should be redundant, but let's be certain.
260        if p.is_absolute() {
261            return Err(Error::InvalidSubdirectory);
262        }
263
264        for component in p.components() {
265            match component {
266                Component::Prefix(_) | Component::RootDir | Component::ParentDir => {
267                    return Err(Error::InvalidSubdirectory)
268                }
269                Component::CurDir | Component::Normal(_) => {}
270            }
271        }
272
273        Ok(())
274    }
275
276    /// Check whether `p` is a valid relative path within this directory,
277    /// verify its permissions or the permissions of its parent, depending on `check_type`,
278    /// and return an absolute path for `p`.
279    pub(crate) fn verified_full_path(
280        &self,
281        p: &Path,
282        check_type: FullPathCheck,
283    ) -> Result<PathBuf> {
284        self.check_path(p)?;
285        let full_path = self.location.join(p);
286        let to_verify: &Path = match check_type {
287            FullPathCheck::CheckPath => full_path.as_ref(),
288            FullPathCheck::CheckParent => full_path.parent().unwrap_or_else(|| full_path.as_ref()),
289        };
290        self.verifier().check(to_verify)?;
291
292        Ok(full_path)
293    }
294}
295
296/// Type argument for [`CheckedDir::verified_full_path`].
297#[derive(Clone, Copy, Debug)]
298pub(crate) enum FullPathCheck {
299    /// Check all elements of the path, including the final element.
300    CheckPath,
301    /// Check all elements of the path, not including the final element.
302    CheckParent,
303}
304
305#[cfg(test)]
306mod test {
307    // @@ begin test lint list maintained by maint/add_warning @@
308    #![allow(clippy::bool_assert_comparison)]
309    #![allow(clippy::clone_on_copy)]
310    #![allow(clippy::dbg_macro)]
311    #![allow(clippy::mixed_attributes_style)]
312    #![allow(clippy::print_stderr)]
313    #![allow(clippy::print_stdout)]
314    #![allow(clippy::single_char_pattern)]
315    #![allow(clippy::unwrap_used)]
316    #![allow(clippy::unchecked_duration_subtraction)]
317    #![allow(clippy::useless_vec)]
318    #![allow(clippy::needless_pass_by_value)]
319    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
320    use super::*;
321    use crate::testing::Dir;
322    use std::io::Write;
323
324    #[test]
325    fn easy_case() {
326        let d = Dir::new();
327        d.dir("a/b/c");
328        d.dir("a/b/d");
329        d.file("a/b/c/f1");
330        d.file("a/b/c/f2");
331        d.file("a/b/d/f3");
332
333        d.chmod("a", 0o755);
334        d.chmod("a/b", 0o700);
335        d.chmod("a/b/c", 0o700);
336        d.chmod("a/b/d", 0o777);
337        d.chmod("a/b/c/f1", 0o600);
338        d.chmod("a/b/c/f2", 0o666);
339        d.chmod("a/b/d/f3", 0o600);
340
341        let m = Mistrust::builder()
342            .ignore_prefix(d.canonical_root())
343            .build()
344            .unwrap();
345
346        let sd = m.verifier().secure_dir(d.path("a/b")).unwrap();
347
348        // Try make_directory.
349        sd.make_directory("c/sub1").unwrap();
350        #[cfg(target_family = "unix")]
351        {
352            let e = sd.make_directory("d/sub2").unwrap_err();
353            assert!(matches!(e, Error::BadPermission(..)));
354        }
355
356        // Try opening a file that exists.
357        let f1 = sd.open("c/f1", OpenOptions::new().read(true)).unwrap();
358        drop(f1);
359        #[cfg(target_family = "unix")]
360        {
361            let e = sd.open("c/f2", OpenOptions::new().read(true)).unwrap_err();
362            assert!(matches!(e, Error::BadPermission(..)));
363            let e = sd.open("d/f3", OpenOptions::new().read(true)).unwrap_err();
364            assert!(matches!(e, Error::BadPermission(..)));
365        }
366
367        // Try creating a file.
368        let mut f3 = sd
369            .open("c/f-new", OpenOptions::new().write(true).create(true))
370            .unwrap();
371        f3.write_all(b"Hello world").unwrap();
372        drop(f3);
373
374        #[cfg(target_family = "unix")]
375        {
376            let e = sd
377                .open("d/f-new", OpenOptions::new().write(true).create(true))
378                .unwrap_err();
379            assert!(matches!(e, Error::BadPermission(..)));
380        }
381    }
382
383    #[test]
384    fn bad_paths() {
385        let d = Dir::new();
386        d.dir("a");
387        d.chmod("a", 0o700);
388
389        let m = Mistrust::builder()
390            .ignore_prefix(d.canonical_root())
391            .build()
392            .unwrap();
393
394        let sd = m.verifier().secure_dir(d.path("a")).unwrap();
395
396        let e = sd.make_directory("hello/../world").unwrap_err();
397        assert!(matches!(e, Error::InvalidSubdirectory));
398        let e = sd.metadata("hello/../world").unwrap_err();
399        assert!(matches!(e, Error::InvalidSubdirectory));
400
401        let e = sd.make_directory("/hello").unwrap_err();
402        assert!(matches!(e, Error::InvalidSubdirectory));
403        let e = sd.metadata("/hello").unwrap_err();
404        assert!(matches!(e, Error::InvalidSubdirectory));
405
406        sd.make_directory("hello/world").unwrap();
407    }
408
409    #[test]
410    fn read_and_write() {
411        let d = Dir::new();
412        d.dir("a");
413        d.chmod("a", 0o700);
414        let m = Mistrust::builder()
415            .ignore_prefix(d.canonical_root())
416            .build()
417            .unwrap();
418
419        let checked = m.verifier().secure_dir(d.path("a")).unwrap();
420
421        // Simple case: write and read.
422        checked
423            .write_and_replace("foo.txt", "this is incredibly silly")
424            .unwrap();
425
426        let s1 = checked.read_to_string("foo.txt").unwrap();
427        let s2 = checked.read("foo.txt").unwrap();
428        assert_eq!(s1, "this is incredibly silly");
429        assert_eq!(s1.as_bytes(), &s2[..]);
430
431        // Checked subdirectory
432        let sub = "sub";
433        let sub_checked = checked.make_secure_directory(sub).unwrap();
434        assert_eq!(sub_checked.as_path(), checked.as_path().join(sub));
435
436        // Trickier: write when the preferred temporary already has content.
437        checked
438            .open("bar.tmp", OpenOptions::new().create(true).write(true))
439            .unwrap()
440            .write_all("be the other guy".as_bytes())
441            .unwrap();
442        assert!(checked.join("bar.tmp").unwrap().try_exists().unwrap());
443
444        checked
445            .write_and_replace("bar.txt", "its hard and nobody understands")
446            .unwrap();
447
448        // Temp file should be gone.
449        assert!(!checked.join("bar.tmp").unwrap().try_exists().unwrap());
450        let s4 = checked.read_to_string("bar.txt").unwrap();
451        assert_eq!(s4, "its hard and nobody understands");
452    }
453
454    #[test]
455    fn read_directory() {
456        let d = Dir::new();
457        d.dir("a");
458        d.chmod("a", 0o700);
459        d.dir("a/b");
460        d.file("a/b/f");
461        d.file("a/c.d");
462        d.dir("a/x");
463
464        d.chmod("a", 0o700);
465        d.chmod("a/b", 0o700);
466        d.chmod("a/x", 0o777);
467        let m = Mistrust::builder()
468            .ignore_prefix(d.canonical_root())
469            .build()
470            .unwrap();
471
472        let checked = m.verifier().secure_dir(d.path("a")).unwrap();
473
474        assert!(matches!(
475            checked.read_directory("/"),
476            Err(Error::InvalidSubdirectory)
477        ));
478        assert!(matches!(
479            checked.read_directory("b/.."),
480            Err(Error::InvalidSubdirectory)
481        ));
482        let mut members: Vec<String> = checked
483            .read_directory(".")
484            .unwrap()
485            .map(|ent| ent.unwrap().file_name().to_string_lossy().to_string())
486            .collect();
487        members.sort();
488        assert_eq!(members, vec!["b", "c.d", "x"]);
489
490        let members: Vec<String> = checked
491            .read_directory("b")
492            .unwrap()
493            .map(|ent| ent.unwrap().file_name().to_string_lossy().to_string())
494            .collect();
495        assert_eq!(members, vec!["f"]);
496
497        #[cfg(target_family = "unix")]
498        {
499            assert!(matches!(
500                checked.read_directory("x"),
501                Err(Error::BadPermission(_, _, _))
502            ));
503        }
504    }
505
506    #[test]
507    fn remove_file() {
508        let d = Dir::new();
509        d.dir("a");
510        d.chmod("a", 0o700);
511        d.dir("a/b");
512        d.file("a/b/f");
513        d.dir("a/b/d");
514        d.dir("a/x");
515        d.dir("a/x/y");
516        d.file("a/x/y/z");
517
518        d.chmod("a", 0o700);
519        d.chmod("a/b", 0o700);
520        d.chmod("a/x", 0o777);
521
522        let m = Mistrust::builder()
523            .ignore_prefix(d.canonical_root())
524            .build()
525            .unwrap();
526        let checked = m.verifier().secure_dir(d.path("a")).unwrap();
527
528        // Remove a file that is there, and then make sure it is gone.
529        assert!(checked.read_to_string("b/f").is_ok());
530        assert!(checked.metadata("b/f").unwrap().is_file());
531        checked.remove_file("b/f").unwrap();
532        assert!(matches!(
533            checked.read_to_string("b/f"),
534            Err(Error::NotFound(_))
535        ));
536        assert!(matches!(checked.metadata("b/f"), Err(Error::NotFound(_))));
537        assert!(matches!(
538            checked.remove_file("b/f"),
539            Err(Error::NotFound(_))
540        ));
541
542        // Remove a file in a nonexistent subdirectory
543        assert!(matches!(
544            checked.remove_file("b/xyzzy/fred"),
545            Err(Error::NotFound(_))
546        ));
547
548        // Remove a file in a directory whose permissions are too open.
549        #[cfg(target_family = "unix")]
550        {
551            assert!(matches!(
552                checked.remove_file("x/y/z"),
553                Err(Error::BadPermission(_, _, _))
554            ));
555            assert!(matches!(
556                checked.metadata("x/y/z"),
557                Err(Error::BadPermission(_, _, _))
558            ));
559        }
560    }
561
562    #[test]
563    #[cfg(target_family = "unix")]
564    fn access_symlink() {
565        use crate::testing::LinkType;
566
567        let d = Dir::new();
568        d.dir("a/b");
569        d.file("a/b/f1");
570
571        d.chmod("a/b", 0o700);
572        d.chmod("a/b/f1", 0o600);
573        d.link_rel(LinkType::File, "f1", "a/b/f1-link");
574
575        let m = Mistrust::builder()
576            .ignore_prefix(d.canonical_root())
577            .build()
578            .unwrap();
579
580        let sd = m.verifier().secure_dir(d.path("a/b")).unwrap();
581
582        assert!(sd.open("f1", OpenOptions::new().read(true)).is_ok());
583
584        // Metadata returns an error if called on a symlink
585        let e = sd.metadata("f1-link").unwrap_err();
586        assert!(
587            matches!(e, Error::Io { ref err, .. } if err.to_string().contains("is a symlink")),
588            "{e:?}"
589        );
590
591        // Open returns an error if called on a symlink.
592        let e = sd
593            .open("f1-link", OpenOptions::new().read(true))
594            .unwrap_err();
595        assert!(
596            matches!(e, Error::Io { ref err, .. } if err.to_string().contains("symbolic")), // Error is ELOOP.
597            "{e:?}"
598        );
599    }
600}