//! Functionality for reading a connect point from a file, //! and verifying that its permissions are correct. use std::{ collections::HashMap, fs, io, path::{Path, PathBuf}, sync::Arc, }; use crate::{ClientErrorAction, HasClientErrorAction, ParsedConnectPoint}; use fs_mistrust::{CheckedDir, Mistrust}; /// Helper: Individual member of the vector returned by [`ParsedConnectPoint::load_dir`] type PathEntry = (PathBuf, Result); impl ParsedConnectPoint { /// Load all the connect files from a directory. /// /// The directory, and individual files loaded within it, /// must satisfy `mistrust`. /// /// Within a directory: /// * only filenames ending with `.toml` are considered. /// * on unix, filenames beginning with `.` are ignored. /// * files are considered in lexicographic order. /// /// Use `options` as a set of per-file options /// mapping the names of files within `path` /// to rules for reading them. /// /// Return an iterator yielding, for each element of the directory, /// its filename, and a `ParsedConnectPoint` or an error. pub fn load_dir<'a>( path: &Path, mistrust: &Mistrust, options: &'a HashMap, ) -> Result, LoadError> { let dir = match mistrust.verifier().permit_readable().secure_dir(path) { Ok(checked_dir) => checked_dir, Err(fs_mistrust::Error::BadType(_)) => return Err(LoadError::NotADirectory), Err(other) => return Err(other.into()), }; // Okay, this is a directory. List its contents... let mut entries: Vec<(PathBuf, fs::DirEntry)> = dir .read_directory(".")? .map(|res| { let dirent = res?; Ok::<_, io::Error>((dirent.file_name().into(), dirent)) }) .collect::, _>>()?; // ... and sort those contents by name. // // (We sort in reverse order so that ConnPointIterator can pop them off the end of the Vec.) entries.sort_unstable_by(|a, b| a.0.cmp(&b.0).reverse()); Ok(ConnPointIterator { dir, entries, options, }) } /// Load the file at `path` as a ParsedConnectPoint. /// /// It is an error if `path` does not satisfy `mistrust`. pub fn load_file(path: &Path, mistrust: &Mistrust) -> Result { Ok(mistrust .verifier() .require_file() .permit_readable() .file_access() .follow_final_links(true) .read_to_string(path)? .parse()?) } } /// Iterator returned by [`ParsedConnectPoint::load_dir()`] #[derive(Debug)] pub struct ConnPointIterator<'a> { /// Directory object used to read checked files. dir: CheckedDir, /// The entries of `dir`, sorted in _reverse_ lexicographic order, /// so that we can perform a forward iteration by popping items off the end. /// /// (We compute the `PathBuf`s in advance, /// since every call to `DirEntry::file_name()` allocates a string). entries: Vec<(PathBuf, fs::DirEntry)>, /// The `Options` map passed to `load_dir`. options: &'a HashMap, } impl<'a> Iterator for ConnPointIterator<'a> { type Item = PathEntry; fn next(&mut self) -> Option { loop { let (fname, entry) = self.entries.pop()?; if let Some(outcome) = load_dirent(&self.dir, &entry, fname.as_path(), self.options).transpose() { return Some((self.dir.as_path().join(fname), outcome)); } } } } /// Helper for `load_dir`: Read the element listed as `entry` within `dir`. /// /// This is a separate method to help make sure that we capture /// every possible error while loading the file. /// /// Return `Ok(None)` if we are skipping this `DirEntry` /// without reading a ParsedConnectPoint. fn load_dirent( dir: &CheckedDir, entry: &fs::DirEntry, name: &Path, overrides: &HashMap, ) -> Result, LoadError> { let settings = overrides.get(name); if matches!(settings, Some(LoadOptions { disable: true })) { // We have been told to disable this entry: Skip. return Ok(None); } if name.extension() != Some("toml".as_ref()) { // Wrong extension: Skip. return Ok(None); } #[cfg(unix)] if name.to_string_lossy().starts_with('.') { // Unix-hidden file: skip. return Ok(None); } if !entry.file_type()?.is_file() { // Not a plain file: skip. return Ok(None); } let contents = dir .file_access() .follow_final_links(true) .read_to_string(name)?; Ok(Some(contents.parse()?)) } /// Configured options for a single file within a directory. #[derive(Clone, Debug, derive_builder::Builder)] pub struct LoadOptions { /// If true, do not try to read the file. #[builder(default)] disable: bool, } /// An error encountered while trying to read a `ParsedConnectPoint`. #[derive(Clone, Debug, thiserror::Error)] #[non_exhaustive] pub enum LoadError { /// We couldn't access the path. /// /// This can happen if permissions are wrong, /// the file doesn't exist, we encounter an IO error, or something similar. #[error("Problem accessing file or directory")] Access(#[from] fs_mistrust::Error), /// We encountered an IO error while trying to read the file or list the directory. #[error("IO error while loading a file or directory")] Io(#[source] Arc), /// We read a file, but it was not a valid TOML connect point. #[error("Unable to parse connect point")] Parse(#[from] crate::connpt::ParseError), /// We called `load_dir` on something other than a directory. #[error("not a directory")] NotADirectory, } impl From for LoadError { fn from(value: io::Error) -> Self { LoadError::Io(Arc::new(value)) } } impl HasClientErrorAction for LoadError { fn client_action(&self) -> ClientErrorAction { use ClientErrorAction as A; use LoadError as E; match self { E::Access(error) => error.client_action(), E::Io(error) => crate::fs_error_action(error), E::Parse(error) => error.client_action(), E::NotADirectory => A::Abort, } } } #[cfg(test)] mod test { // @@ begin test lint list maintained by maint/add_warning @@ #![allow(clippy::bool_assert_comparison)] #![allow(clippy::clone_on_copy)] #![allow(clippy::dbg_macro)] #![allow(clippy::mixed_attributes_style)] #![allow(clippy::print_stderr)] #![allow(clippy::print_stdout)] #![allow(clippy::single_char_pattern)] #![allow(clippy::unwrap_used)] #![allow(clippy::unchecked_time_subtraction)] #![allow(clippy::useless_vec)] #![allow(clippy::needless_pass_by_value)] #![allow(clippy::string_slice)] // See arti#2571 //! use super::*; use assert_matches::assert_matches; use io::Write; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use crate::testing::tempdir; fn write(dir: &Path, fname: &str, mode: u32, content: &str) -> PathBuf { #[cfg(not(unix))] let _ = mode; let p: PathBuf = dir.join(fname); let mut f = fs::File::create(&p).unwrap(); f.write_all(content.as_bytes()).unwrap(); // We need to chmod manually, to override our umask. #[cfg(unix)] f.set_permissions(PermissionsExt::from_mode(mode)).unwrap(); p } const EXAMPLE_1: &str = r#" [connect] socket = "inet:[::1]:9191" socket_canonical = "inet:[::1]:2020" auth = { cookie = { path = "/home/user/.arti_rpc/cookie" } } "#; const EXAMPLE_2: &str = r#" [connect] socket = "inet:[::1]:9000" socket_canonical = "inet:[::1]:2000" auth = { cookie = { path = "/home/user/.arti_rpc/cookie" } } "#; const EXAMPLE_3: &str = r#" [connect] socket = "inet:[::1]:413" socket_canonical = "inet:[::1]:612" auth = { cookie = { path = "/home/user/.arti_rpc/cookie" } } "#; /// Kludge: use Debug to assert that two ParsedConnectPoints are equal. fn assert_conn_pt_eq(a: &ParsedConnectPoint, b: &ParsedConnectPoint) { assert_eq!(format!("{:?}", a), format!("{:?}", b)); } /// Kludge: use Debug to assert that two ParsedConnectPoints are unequal. fn assert_conn_pt_ne(a: &ParsedConnectPoint, b: &ParsedConnectPoint) { assert_ne!(format!("{:?}", a), format!("{:?}", b)); } /// Various tests for load cases that don't depend on fs_mistrust checking or permissions. #[test] fn load_normally() { let (_tmpdir, dir, m) = tempdir(); let fname1 = write(dir.as_ref(), "01-file.toml", 0o600, EXAMPLE_1); let fname2 = write(dir.as_ref(), "02-file.toml", 0o600, EXAMPLE_2); // Invalid toml should cause an Err to appear in the result. let _fname3 = write(dir.as_ref(), "03-junk.toml", 0o600, "not toml at all"); // Doesn't end with toml, should get skipped. let _not_dot_toml = write(dir.as_ref(), "README.config", 0o600, "skip me"); // Should get skipped on unix. #[cfg(unix)] let _dotfile = write(dir.as_ref(), ".foo.toml", 0o600, "also skipped"); // we don't recurse; create a file in a subdir to demonstrate this. let subdirname = dir.join("subdir"); m.make_directory(&subdirname).unwrap(); let _in_subdir = write(subdirname.as_ref(), "hello.toml", 0o600, EXAMPLE_1); let connpt1: ParsedConnectPoint = EXAMPLE_1.parse().unwrap(); let connpt2: ParsedConnectPoint = EXAMPLE_2.parse().unwrap(); // Try "load_file" let p = ParsedConnectPoint::load_file(fname1.as_ref(), &m).unwrap(); assert_conn_pt_eq(&p, &connpt1); assert_conn_pt_ne(&p, &connpt2); // Try "load_file" on a directory. let err = ParsedConnectPoint::load_file(dir.as_ref(), &m).unwrap_err(); assert_matches!(err, LoadError::Access(fs_mistrust::Error::BadType(_))); // Try "load_dir" on a file. let err = ParsedConnectPoint::load_dir(fname2.as_ref(), &m, &HashMap::new()).unwrap_err(); assert_matches!(err, LoadError::NotADirectory); // Try "load_dir" on a directory. let v: Vec<_> = ParsedConnectPoint::load_dir(dir.as_ref(), &m, &HashMap::new()) .unwrap() .collect(); assert_eq!(v.len(), 3); assert_eq!(v[0].0.file_name().unwrap().to_str(), Some("01-file.toml")); assert_conn_pt_eq(v[0].1.as_ref().unwrap(), &connpt1); assert_eq!(v[1].0.file_name().unwrap().to_str(), Some("02-file.toml")); assert_conn_pt_eq(v[1].1.as_ref().unwrap(), &connpt2); assert_eq!(v[2].0.file_name().unwrap().to_str(), Some("03-junk.toml")); assert_matches!(&v[2].1, Err(LoadError::Parse(_))); // Try load_dir with `options`. let options: HashMap<_, _> = [ ( PathBuf::from("01-file.toml"), LoadOptions { disable: false }, ), // Doesn't actually do anything. (PathBuf::from("02-file.toml"), LoadOptions { disable: true }), ] .into_iter() .collect(); let v: Vec<_> = ParsedConnectPoint::load_dir(dir.as_ref(), &m, &options) .unwrap() .collect(); assert_eq!(v.len(), 2); assert_conn_pt_eq(v[0].1.as_ref().unwrap(), &connpt1); assert_matches!(&v[1].1, Err(LoadError::Parse(_))); } #[test] #[cfg(unix)] fn bad_permissions() { let (_tmpdir, dir, m) = tempdir(); let fname1 = write(dir.as_ref(), "01-file.toml", 0o600, EXAMPLE_1); // World-writeable: no good. let fname2 = write(dir.as_ref(), "02-file.toml", 0o777, EXAMPLE_2); // Good file, to make sure we keep reading. let _fname3 = write(dir.as_ref(), "03-file.toml", 0o600, EXAMPLE_3); let connpt1: ParsedConnectPoint = EXAMPLE_1.parse().unwrap(); let connpt3: ParsedConnectPoint = EXAMPLE_3.parse().unwrap(); // We can still load a file with good permissions. let p = ParsedConnectPoint::load_file(fname1.as_ref(), &m).unwrap(); assert_conn_pt_eq(&p, &connpt1); // Can't load file with bad permissions. let err: LoadError = ParsedConnectPoint::load_file(fname2.as_ref(), &m).unwrap_err(); assert_matches!( err, LoadError::Access(fs_mistrust::Error::BadPermission(..)) ); // Reading directory gives us the file with good permissions, but not the other. let v: Vec<_> = ParsedConnectPoint::load_dir(dir.as_ref(), &m, &HashMap::new()) .unwrap() .collect(); assert_eq!(v.len(), 3); assert_conn_pt_eq(v[0].1.as_ref().unwrap(), &connpt1); assert_matches!( v[1].1.as_ref().unwrap_err(), LoadError::Access(fs_mistrust::Error::BadPermission(..)) ); assert_conn_pt_eq(v[2].1.as_ref().unwrap(), &connpt3); } // TODO: Check symlink behavior once it is specified }