fs_mistrust/
anon_home.rs

1//! Replace the home-directory in a filename with `${HOME}` or `%UserProfile%`
2//!
3//! In some privacy-sensitive applications, we want to lower the amount of
4//! personally identifying information in our logs. In such environments, it's
5//! good to avoid logging the actual value of the home directory, since those
6//! frequently identify the user.
7
8use std::{
9    collections::HashSet,
10    fmt::Display,
11    path::{Path, PathBuf},
12};
13
14use once_cell::sync::Lazy;
15
16/// Cached value of our observed home directory.
17static HOMEDIRS: Lazy<Vec<PathBuf>> = Lazy::new(default_homedirs);
18
19/// Return a list of home directories in official and canonical forms.
20fn default_homedirs() -> Vec<PathBuf> {
21    if let Some(basic_home) = dirs::home_dir() {
22        // Build as a HashSet, to de-duplicate.
23        let mut homedirs = HashSet::new();
24
25        // We like our home directory.
26        homedirs.insert(basic_home.clone());
27        // We like the canonical version of our home directory.
28        if let Ok(canonical) = std::fs::canonicalize(&basic_home) {
29            homedirs.insert(canonical);
30        }
31        // We like the version of our home directory generated by `ResolvePath`.
32        if let Ok(rp) = crate::walk::ResolvePath::new(basic_home) {
33            let (mut p, rest) = rp.into_result();
34            p.extend(rest);
35            homedirs.insert(p);
36        }
37
38        homedirs.into_iter().collect()
39    } else {
40        vec![]
41    }
42}
43
44/// The string that we use to represent our home directory in a compacted path.
45const HOME_SUBSTITUTION: &str = {
46    if cfg!(target_family = "windows") {
47        "%UserProfile%"
48    } else {
49        "${HOME}"
50    }
51};
52
53/// An extension trait for [`Path`].
54pub trait PathExt {
55    /// If this is a path within our home directory, try to replace the home
56    /// directory component with a symbolic reference to our home directory.
57    ///
58    /// This function can be useful for outputting paths while reducing the risk
59    /// of exposing usernames in the log.
60    ///
61    /// # Examples
62    ///
63    /// ```no_run
64    /// use std::path::{Path,PathBuf};
65    /// use fs_mistrust::anon_home::PathExt as _;
66    ///
67    /// let path = PathBuf::from("/home/arachnidsGrip/.config/arti.toml");
68    /// assert_eq!(path.anonymize_home().to_string(),
69    ///            "${HOME}/.config/arti.toml");
70    /// panic!();
71    /// ```
72    fn anonymize_home(&self) -> AnonHomePath<'_>;
73}
74
75impl PathExt for Path {
76    fn anonymize_home(&self) -> AnonHomePath<'_> {
77        AnonHomePath(self)
78    }
79}
80
81/// A wrapper for `Path` which, when displayed, replaces the home directory with
82/// a symbolic reference.
83#[derive(Debug, Clone)]
84pub struct AnonHomePath<'a>(&'a Path);
85
86impl<'a> std::fmt::Display for AnonHomePath<'a> {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        /// `Path::display`, but with a better name and an allow
89        #[allow(clippy::disallowed_methods)]
90        fn display_lossy(p: &Path) -> impl Display + '_ {
91            p.display()
92        }
93
94        // We compare against both the home directory and the canonical home
95        // directory, since sometimes we'll want to canonicalize a path before
96        // passing it to this function and still have it work.
97        for home in HOMEDIRS.iter() {
98            if let Ok(suffix) = self.0.strip_prefix(home) {
99                return write!(
100                    f,
101                    "{}{}{}",
102                    HOME_SUBSTITUTION,
103                    std::path::MAIN_SEPARATOR,
104                    display_lossy(suffix),
105                );
106            }
107        }
108
109        // Didn't match any homedir.
110
111        display_lossy(self.0).fmt(f)
112    }
113}
114
115#[cfg(test)]
116mod test {
117    // @@ begin test lint list maintained by maint/add_warning @@
118    #![allow(clippy::bool_assert_comparison)]
119    #![allow(clippy::clone_on_copy)]
120    #![allow(clippy::dbg_macro)]
121    #![allow(clippy::mixed_attributes_style)]
122    #![allow(clippy::print_stderr)]
123    #![allow(clippy::print_stdout)]
124    #![allow(clippy::single_char_pattern)]
125    #![allow(clippy::unwrap_used)]
126    #![allow(clippy::unchecked_duration_subtraction)]
127    #![allow(clippy::useless_vec)]
128    #![allow(clippy::needless_pass_by_value)]
129    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
130    use super::*;
131
132    #[test]
133    fn no_change() {
134        // This is not your home directory
135        let path = PathBuf::from("/completely/untoucha8le");
136        assert_eq!(path.anonymize_home().to_string(), path.to_string_lossy());
137    }
138
139    fn check_with_home(homedir: &Path) {
140        let arti_conf = homedir.join("here").join("is").join("a").join("path");
141
142        #[cfg(target_family = "windows")]
143        assert_eq!(
144            arti_conf.anonymize_home().to_string(),
145            "%UserProfile%\\here\\is\\a\\path"
146        );
147
148        #[cfg(not(target_family = "windows"))]
149        assert_eq!(
150            arti_conf.anonymize_home().to_string(),
151            "${HOME}/here/is/a/path"
152        );
153    }
154
155    #[test]
156    fn in_home() {
157        if let Some(home) = dirs::home_dir() {
158            check_with_home(&home);
159        }
160    }
161
162    #[test]
163    fn in_canonical_home() {
164        if let Some(canonical_home) = dirs::home_dir()
165            .map(std::fs::canonicalize)
166            .transpose()
167            .ok()
168            .flatten()
169        {
170            check_with_home(&canonical_home);
171        }
172    }
173}