1
//! Functionality for reading a connect point from a file,
2
//! and verifying that its permissions are correct.
3

            
4
use std::{
5
    collections::HashMap,
6
    fs, io,
7
    path::{Path, PathBuf},
8
    sync::Arc,
9
};
10

            
11
use crate::{ClientErrorAction, HasClientErrorAction, ParsedConnectPoint};
12
use fs_mistrust::{CheckedDir, Mistrust};
13

            
14
/// Helper: Individual member of the vector returned by [`ParsedConnectPoint::load_dir`]
15
type PathEntry = (PathBuf, Result<ParsedConnectPoint, LoadError>);
16

            
17
impl 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
8
    pub fn load_dir<'a>(
35
8
        path: &Path,
36
8
        mistrust: &Mistrust,
37
8
        options: &'a HashMap<PathBuf, LoadOptions>,
38
8
    ) -> Result<ConnPointIterator<'a>, LoadError> {
39
8
        let dir = match mistrust.verifier().permit_readable().secure_dir(path) {
40
6
            Ok(checked_dir) => checked_dir,
41
2
            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
6
        let mut entries: Vec<(PathBuf, fs::DirEntry)> = dir
47
6
            .read_directory(".")?
48
33
            .map(|res| {
49
30
                let dirent = res?;
50
30
                Ok::<_, io::Error>((dirent.file_name().into(), dirent))
51
33
            })
52
6
            .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
69
        entries.sort_unstable_by(|a, b| a.0.cmp(&b.0).reverse());
57
6

            
58
6
        Ok(ConnPointIterator {
59
6
            dir,
60
6
            entries,
61
6
            options,
62
6
        })
63
8
    }
64

            
65
    /// Load the file at `path` as a ParsedConnectPoint.
66
    ///
67
    /// It is an error if `path` does not satisfy `mistrust`.
68
8
    pub fn load_file(path: &Path, mistrust: &Mistrust) -> Result<ParsedConnectPoint, LoadError> {
69
8
        Ok(mistrust
70
8
            .verifier()
71
8
            .require_file()
72
8
            .permit_readable()
73
8
            .file_access()
74
8
            .follow_final_links(true)
75
8
            .read_to_string(path)?
76
4
            .parse()?)
77
8
    }
78
}
79

            
80
/// Iterator returned by [`ParsedConnectPoint::load_dir()`]
81
#[derive(Debug)]
82
pub 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

            
95
impl<'a> Iterator for ConnPointIterator<'a> {
96
    type Item = PathEntry;
97

            
98
22
    fn next(&mut self) -> Option<Self::Item> {
99
        loop {
100
36
            let (fname, entry) = self.entries.pop()?;
101
16
            if let Some(outcome) =
102
30
                load_dirent(&self.dir, &entry, fname.as_path(), self.options).transpose()
103
            {
104
16
                return Some((self.dir.as_path().join(fname), outcome));
105
14
            }
106
        }
107
22
    }
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.
117
30
fn load_dirent(
118
30
    dir: &CheckedDir,
119
30
    entry: &fs::DirEntry,
120
30
    name: &Path,
121
30
    overrides: &HashMap<PathBuf, LoadOptions>,
122
30
) -> Result<Option<ParsedConnectPoint>, LoadError> {
123
30
    let settings = overrides.get(name);
124
30
    if matches!(settings, Some(LoadOptions { disable: true })) {
125
        // We have been told to disable this entry: Skip.
126
2
        return Ok(None);
127
28
    }
128
28
    if name.extension() != Some("toml".as_ref()) {
129
        // Wrong extension: Skip.
130
8
        return Ok(None);
131
20
    }
132
20
    #[cfg(unix)]
133
20
    if name.to_string_lossy().starts_with('.') {
134
        // Unix-hidden file: skip.
135
4
        return Ok(None);
136
16
    }
137
16
    if !entry.file_type()?.is_file() {
138
        // Not a plain file: skip.
139
        return Ok(None);
140
16
    }
141

            
142
16
    let contents = dir
143
16
        .file_access()
144
16
        .follow_final_links(true)
145
16
        .read_to_string(name)?;
146
14
    Ok(Some(contents.parse()?))
147
30
}
148

            
149
/// Configured options for a single file within a directory.
150
#[derive(Clone, Debug, derive_builder::Builder)]
151
pub 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]
160
pub 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
}
177
impl From<io::Error> for LoadError {
178
    fn from(value: io::Error) -> Self {
179
        LoadError::Io(Arc::new(value))
180
    }
181
}
182
impl 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)]
196
mod 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]
238
socket = "inet:[::1]:9191"
239
socket_canonical = "inet:[::1]:2020"
240

            
241
auth = { cookie = { path = "/home/user/.arti_rpc/cookie" } }
242
"#;
243

            
244
    const EXAMPLE_2: &str = r#"
245
[connect]
246
socket = "inet:[::1]:9000"
247
socket_canonical = "inet:[::1]:2000"
248

            
249
auth = { cookie = { path = "/home/user/.arti_rpc/cookie" } }
250
"#;
251

            
252
    const EXAMPLE_3: &str = r#"
253
[connect]
254
socket = "inet:[::1]:413"
255
socket_canonical = "inet:[::1]:612"
256

            
257
auth = { 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
}