tor_rpc_connect/
load.rs

1//! Functionality for reading a connect point from a file,
2//! and verifying that its permissions are correct.
3
4use std::{
5    collections::HashMap,
6    fs, io,
7    path::{Path, PathBuf},
8    sync::Arc,
9};
10
11use crate::{ClientErrorAction, HasClientErrorAction, ParsedConnectPoint};
12use fs_mistrust::{CheckedDir, Mistrust};
13
14/// Helper: Individual member of the vector returned by [`ParsedConnectPoint::load_dir`]
15type PathEntry = (PathBuf, Result<ParsedConnectPoint, LoadError>);
16
17impl ParsedConnectPoint {
18    /// Load all the connect files from a directory.
19    ///
20    /// The directory, and individual files loaded within it,
21    /// must satisfy `mistrust`.
22    ///
23    /// Within a directory:
24    ///   * only filenames ending with `.toml` are considered.
25    ///   * on unix, filenames beginning with `.` are ignored.
26    ///   * files are considered in lexicographic order.
27    ///
28    /// Use `options` as a set of per-file options
29    /// mapping the names of files within `path`
30    /// to rules for reading them.
31    ///
32    /// Return an iterator yielding, for each element of the directory,
33    /// its filename, and a `ParsedConnectPoint` or an error.
34    pub fn load_dir<'a>(
35        path: &Path,
36        mistrust: &Mistrust,
37        options: &'a HashMap<PathBuf, LoadOptions>,
38    ) -> Result<ConnPointIterator<'a>, LoadError> {
39        let dir = match mistrust.verifier().permit_readable().secure_dir(path) {
40            Ok(checked_dir) => checked_dir,
41            Err(fs_mistrust::Error::BadType(_)) => return Err(LoadError::NotADirectory),
42            Err(other) => return Err(other.into()),
43        };
44
45        // Okay, this is a directory.  List its contents...
46        let mut entries: Vec<(PathBuf, fs::DirEntry)> = dir
47            .read_directory(".")?
48            .map(|res| {
49                let dirent = res?;
50                Ok::<_, io::Error>((dirent.file_name().into(), dirent))
51            })
52            .collect::<Result<Vec<_>, _>>()?;
53        // ... and sort those contents by name.
54        //
55        // (We sort in reverse order so that ConnPointIterator can pop them off the end of the Vec.)
56        entries.sort_unstable_by(|a, b| a.0.cmp(&b.0).reverse());
57
58        Ok(ConnPointIterator {
59            dir,
60            entries,
61            options,
62        })
63    }
64
65    /// Load the file at `path` as a ParsedConnectPoint.
66    ///
67    /// It is an error if `path` does not satisfy `mistrust`.
68    pub fn load_file(path: &Path, mistrust: &Mistrust) -> Result<ParsedConnectPoint, LoadError> {
69        Ok(mistrust
70            .verifier()
71            .require_file()
72            .permit_readable()
73            .file_access()
74            .follow_final_links(true)
75            .read_to_string(path)?
76            .parse()?)
77    }
78}
79
80/// Iterator returned by [`ParsedConnectPoint::load_dir()`]
81#[derive(Debug)]
82pub struct ConnPointIterator<'a> {
83    /// Directory object used to read checked files.
84    dir: CheckedDir,
85    /// The entries of `dir`, sorted in _reverse_ lexicographic order,
86    /// so that we can perform a forward iteration by popping items off the end.
87    ///
88    /// (We compute the `PathBuf`s in advance,
89    /// since every call to `DirEntry::file_name()` allocates a string).
90    entries: Vec<(PathBuf, fs::DirEntry)>,
91    //// The `Options` map passed to `load_dir`.
92    options: &'a HashMap<PathBuf, LoadOptions>,
93}
94
95impl<'a> Iterator for ConnPointIterator<'a> {
96    type Item = PathEntry;
97
98    fn next(&mut self) -> Option<Self::Item> {
99        loop {
100            let (fname, entry) = self.entries.pop()?;
101            if let Some(outcome) =
102                load_dirent(&self.dir, &entry, fname.as_path(), self.options).transpose()
103            {
104                return Some((self.dir.as_path().join(fname), outcome));
105            }
106        }
107    }
108}
109
110/// Helper for `load_dir`: Read the element listed as `entry` within `dir`.
111///
112/// This is a separate method to help make sure that we capture
113/// every possible error while loading the file.
114///
115/// Return `Ok(None)` if we are skipping this `DirEntry`
116/// without reading a ParsedConnectPoint.
117fn load_dirent(
118    dir: &CheckedDir,
119    entry: &fs::DirEntry,
120    name: &Path,
121    overrides: &HashMap<PathBuf, LoadOptions>,
122) -> Result<Option<ParsedConnectPoint>, LoadError> {
123    let settings = overrides.get(name);
124    if matches!(settings, Some(LoadOptions { disable: true })) {
125        // We have been told to disable this entry: Skip.
126        return Ok(None);
127    }
128    if name.extension() != Some("toml".as_ref()) {
129        // Wrong extension: Skip.
130        return Ok(None);
131    }
132    #[cfg(unix)]
133    if name.to_string_lossy().starts_with('.') {
134        // Unix-hidden file: skip.
135        return Ok(None);
136    }
137    if !entry.file_type()?.is_file() {
138        // Not a plain file: skip.
139        return Ok(None);
140    }
141
142    let contents = dir
143        .file_access()
144        .follow_final_links(true)
145        .read_to_string(name)?;
146    Ok(Some(contents.parse()?))
147}
148
149/// Configured options for a single file within a directory.
150#[derive(Clone, Debug, derive_builder::Builder)]
151pub struct LoadOptions {
152    /// If true, do not try to read the file.
153    #[builder(default)]
154    disable: bool,
155}
156
157/// An error encountered while trying to read a `ParsedConnectPoint`.
158#[derive(Clone, Debug, thiserror::Error)]
159#[non_exhaustive]
160pub enum LoadError {
161    /// We couldn't access the path.
162    ///
163    /// This can happen if permissions are wrong,
164    /// the file doesn't exist, we encounter an IO error, or something similar.
165    #[error("Problem accessing file or directory")]
166    Access(#[from] fs_mistrust::Error),
167    /// We encountered an IO error while trying to read the file or list the directory.
168    #[error("IO error while loading a file or directory")]
169    Io(#[source] Arc<io::Error>),
170    /// We read a file, but it was not a valid TOML connect point.
171    #[error("Unable to parse connect point")]
172    Parse(#[from] crate::connpt::ParseError),
173    /// We called `load_dir` on something other than a directory.
174    #[error("not a directory")]
175    NotADirectory,
176}
177impl From<io::Error> for LoadError {
178    fn from(value: io::Error) -> Self {
179        LoadError::Io(Arc::new(value))
180    }
181}
182impl HasClientErrorAction for LoadError {
183    fn client_action(&self) -> ClientErrorAction {
184        use ClientErrorAction as A;
185        use LoadError as E;
186        match self {
187            E::Access(error) => error.client_action(),
188            E::Io(error) => crate::fs_error_action(error),
189            E::Parse(error) => error.client_action(),
190            E::NotADirectory => A::Abort,
191        }
192    }
193}
194
195#[cfg(test)]
196mod test {
197    // @@ begin test lint list maintained by maint/add_warning @@
198    #![allow(clippy::bool_assert_comparison)]
199    #![allow(clippy::clone_on_copy)]
200    #![allow(clippy::dbg_macro)]
201    #![allow(clippy::mixed_attributes_style)]
202    #![allow(clippy::print_stderr)]
203    #![allow(clippy::print_stdout)]
204    #![allow(clippy::single_char_pattern)]
205    #![allow(clippy::unwrap_used)]
206    #![allow(clippy::unchecked_duration_subtraction)]
207    #![allow(clippy::useless_vec)]
208    #![allow(clippy::needless_pass_by_value)]
209    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
210
211    use super::*;
212
213    use assert_matches::assert_matches;
214    use io::Write;
215    #[cfg(unix)]
216    use std::os::unix::fs::PermissionsExt;
217
218    use crate::testing::tempdir;
219
220    fn write(dir: &Path, fname: &str, mode: u32, content: &str) -> PathBuf {
221        #[cfg(not(unix))]
222        let _ = mode;
223
224        let p: PathBuf = dir.join(fname);
225
226        let mut f = fs::File::create(&p).unwrap();
227        f.write_all(content.as_bytes()).unwrap();
228
229        // We need to chmod manually, to override our umask.
230        #[cfg(unix)]
231        f.set_permissions(PermissionsExt::from_mode(mode)).unwrap();
232
233        p
234    }
235
236    const EXAMPLE_1: &str = r#"
237    [connect]
238socket = "inet:[::1]:9191"
239socket_canonical = "inet:[::1]:2020"
240
241auth = { cookie = { path = "/home/user/.arti_rpc/cookie" } }
242"#;
243
244    const EXAMPLE_2: &str = r#"
245[connect]
246socket = "inet:[::1]:9000"
247socket_canonical = "inet:[::1]:2000"
248
249auth = { cookie = { path = "/home/user/.arti_rpc/cookie" } }
250"#;
251
252    const EXAMPLE_3: &str = r#"
253[connect]
254socket = "inet:[::1]:413"
255socket_canonical = "inet:[::1]:612"
256
257auth = { cookie = { path = "/home/user/.arti_rpc/cookie" } }
258"#;
259
260    /// Kludge: use Debug to assert that two ParsedConnectPoints are equal.
261    fn assert_conn_pt_eq(a: &ParsedConnectPoint, b: &ParsedConnectPoint) {
262        assert_eq!(format!("{:?}", a), format!("{:?}", b));
263    }
264    /// Kludge: use Debug to assert that two ParsedConnectPoints are unequal.
265    fn assert_conn_pt_ne(a: &ParsedConnectPoint, b: &ParsedConnectPoint) {
266        assert_ne!(format!("{:?}", a), format!("{:?}", b));
267    }
268
269    /// Various tests for load cases that don't depend on fs_mistrust checking or permissions.
270    #[test]
271    fn load_normally() {
272        let (_tmpdir, dir, m) = tempdir();
273
274        let fname1 = write(dir.as_ref(), "01-file.toml", 0o600, EXAMPLE_1);
275        let fname2 = write(dir.as_ref(), "02-file.toml", 0o600, EXAMPLE_2);
276        // Invalid toml should cause an Err to appear in the result.
277        let _fname3 = write(dir.as_ref(), "03-junk.toml", 0o600, "not toml at all");
278        // Doesn't end with toml, should get skipped.
279        let _not_dot_toml = write(dir.as_ref(), "README.config", 0o600, "skip me");
280        // Should get skipped on unix.
281        #[cfg(unix)]
282        let _dotfile = write(dir.as_ref(), ".foo.toml", 0o600, "also skipped");
283
284        // we don't recurse; create a file in a subdir to demonstrate this.
285        let subdirname = dir.join("subdir");
286        m.make_directory(&subdirname).unwrap();
287        let _in_subdir = write(subdirname.as_ref(), "hello.toml", 0o600, EXAMPLE_1);
288
289        let connpt1: ParsedConnectPoint = EXAMPLE_1.parse().unwrap();
290        let connpt2: ParsedConnectPoint = EXAMPLE_2.parse().unwrap();
291
292        // Try "load_file"
293        let p = ParsedConnectPoint::load_file(fname1.as_ref(), &m).unwrap();
294        assert_conn_pt_eq(&p, &connpt1);
295        assert_conn_pt_ne(&p, &connpt2);
296
297        // Try "load_file" on a directory.
298        let err = ParsedConnectPoint::load_file(dir.as_ref(), &m).unwrap_err();
299        assert_matches!(err, LoadError::Access(fs_mistrust::Error::BadType(_)));
300
301        // Try "load_dir" on a file.
302        let err = ParsedConnectPoint::load_dir(fname2.as_ref(), &m, &HashMap::new()).unwrap_err();
303        assert_matches!(err, LoadError::NotADirectory);
304
305        // Try "load_dir" on a directory.
306        let v: Vec<_> = ParsedConnectPoint::load_dir(dir.as_ref(), &m, &HashMap::new())
307            .unwrap()
308            .collect();
309        assert_eq!(v.len(), 3);
310        assert_eq!(v[0].0.file_name().unwrap().to_str(), Some("01-file.toml"));
311        assert_conn_pt_eq(v[0].1.as_ref().unwrap(), &connpt1);
312        assert_eq!(v[1].0.file_name().unwrap().to_str(), Some("02-file.toml"));
313        assert_conn_pt_eq(v[1].1.as_ref().unwrap(), &connpt2);
314        assert_eq!(v[2].0.file_name().unwrap().to_str(), Some("03-junk.toml"));
315        assert_matches!(&v[2].1, Err(LoadError::Parse(_)));
316
317        // Try load_dir with `options`.
318        let options: HashMap<_, _> = [
319            (
320                PathBuf::from("01-file.toml"),
321                LoadOptions { disable: false },
322            ), // Doesn't actually do anything.
323            (PathBuf::from("02-file.toml"), LoadOptions { disable: true }),
324        ]
325        .into_iter()
326        .collect();
327        let v: Vec<_> = ParsedConnectPoint::load_dir(dir.as_ref(), &m, &options)
328            .unwrap()
329            .collect();
330        assert_eq!(v.len(), 2);
331        assert_conn_pt_eq(v[0].1.as_ref().unwrap(), &connpt1);
332        assert_matches!(&v[1].1, Err(LoadError::Parse(_)));
333    }
334
335    #[test]
336    #[cfg(unix)]
337    fn bad_permissions() {
338        let (_tmpdir, dir, m) = tempdir();
339
340        let fname1 = write(dir.as_ref(), "01-file.toml", 0o600, EXAMPLE_1);
341        // World-writeable: no good.
342        let fname2 = write(dir.as_ref(), "02-file.toml", 0o777, EXAMPLE_2);
343        // Good file, to make sure we keep reading.
344        let _fname3 = write(dir.as_ref(), "03-file.toml", 0o600, EXAMPLE_3);
345
346        let connpt1: ParsedConnectPoint = EXAMPLE_1.parse().unwrap();
347        let connpt3: ParsedConnectPoint = EXAMPLE_3.parse().unwrap();
348
349        // We can still load a file with good permissions.
350        let p = ParsedConnectPoint::load_file(fname1.as_ref(), &m).unwrap();
351        assert_conn_pt_eq(&p, &connpt1);
352
353        // Can't load file with bad permissions.
354        let err: LoadError = ParsedConnectPoint::load_file(fname2.as_ref(), &m).unwrap_err();
355        assert_matches!(
356            err,
357            LoadError::Access(fs_mistrust::Error::BadPermission(..))
358        );
359
360        // Reading directory gives us the file with good permissions, but not the other.
361        let v: Vec<_> = ParsedConnectPoint::load_dir(dir.as_ref(), &m, &HashMap::new())
362            .unwrap()
363            .collect();
364        assert_eq!(v.len(), 3);
365        assert_conn_pt_eq(v[0].1.as_ref().unwrap(), &connpt1);
366        assert_matches!(
367            v[1].1.as_ref().unwrap_err(),
368            LoadError::Access(fs_mistrust::Error::BadPermission(..))
369        );
370        assert_conn_pt_eq(v[2].1.as_ref().unwrap(), &connpt3);
371    }
372
373    // TODO: Check symlink behavior once it is specified
374}