1
//! A path type exposed from the configuration crate
2
//!
3
//! This type allows the user to specify paths as strings, with some
4
//! support for tab expansion and user directory support.
5

            
6
use std::path::{Path, PathBuf};
7

            
8
use directories::{BaseDirs, ProjectDirs};
9
use once_cell::sync::Lazy;
10
use serde::Deserialize;
11

            
12
use tor_error::{ErrorKind, HasKind};
13

            
14
/// A path in a configuration file: tilde expansion is performed, along
15
/// with expansion of certain variables.
16
///
17
/// The supported variables are:
18
///   * `ARTI_CACHE`: an arti-specific cache directory.
19
///   * `ARTI_CONFIG`: an arti-specific configuration directory.
20
///   * `ARTI_SHARED_DATA`: an arti-specific directory in the user's "shared
21
///     data" space.
22
///   * `ARTI_LOCAL_DATA`: an arti-specific directory in the user's "local
23
///     data" space.
24
///   * `USER_HOME`: the user's home directory.
25
///
26
/// These variables are implemented using the `directories` crate, and
27
/// so should use appropriate system-specific overrides under the
28
/// hood. (Some of those overrides are based on environment variables.)
29
/// For more information, see that crate's documentation.
30
48
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
31
#[serde(transparent)]
32
pub struct CfgPath(PathInner);
33

            
34
/// Inner implementation of CfgPath
35
48
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
36
#[serde(untagged)]
37
enum PathInner {
38
    /// A path that should be expanded from a string using ShellExpand.
39
    Shell(String),
40
    /// A path that should be used literally, with no expansion.
41
    Literal(PathBuf),
42
}
43

            
44
/// An error that has occurred while expanding a path.
45
1
#[derive(thiserror::Error, Debug, Clone)]
46
#[non_exhaustive]
47
pub enum CfgPathError {
48
    /// The path contained a variable we didn't recognize.
49
    #[error("unrecognized variable {0}")]
50
    UnknownVar(String),
51
    /// We couldn't construct a ProjectDirs object.
52
    #[error("can't construct project directories")]
53
    NoProjectDirs,
54
    /// We couldn't construct a BaseDirs object.
55
    #[error("can't construct base directories")]
56
    NoBaseDirs,
57
    /// We couldn't convert a variable to UTF-8.
58
    ///
59
    /// (This is due to a limitation in the shellexpand crate, which should
60
    /// be fixed in the future.)
61
    #[error("can't convert value of {0} to UTF-8")]
62
    BadUtf8(String),
63
    /// We couldn't convert a string to a valid path on the OS.
64
    #[error("invalid path string: {0:?}")]
65
    InvalidString(String),
66
}
67

            
68
impl HasKind for CfgPathError {
69
    fn kind(&self) -> ErrorKind {
70
        use CfgPathError as E;
71
        use ErrorKind as EK;
72
        match self {
73
            E::UnknownVar(_) | E::InvalidString(_) => EK::InvalidConfig,
74
            E::NoProjectDirs | E::NoBaseDirs => EK::NoHomeDirectory,
75
            E::BadUtf8(_) => {
76
                // Arguably, this should be a new "unsupported config"  type,
77
                // since it isn't truly "invalid" to have a string with bad UTF8
78
                // when it's going to be used as a filename.
79
                //
80
                // With luck, however, this error will cease to exist when shellexpand
81
                // improves its character-set handling.
82
                EK::InvalidConfig
83
            }
84
        }
85
    }
86
}
87

            
88
impl CfgPath {
89
    /// Create a new configuration path
90
363
    pub fn new(s: String) -> Self {
91
363
        CfgPath(PathInner::Shell(s))
92
363
    }
93

            
94
    /// Return the path on disk designated by this `CfgPath`.
95
93
    pub fn path(&self) -> Result<PathBuf, CfgPathError> {
96
93
        match &self.0 {
97
24
            PathInner::Shell(s) => expand(s),
98
69
            PathInner::Literal(p) => Ok(p.clone()),
99
        }
100
93
    }
101

            
102
    /// Construct a new `CfgPath` from a system path.
103
5
    pub fn from_path<P: AsRef<Path>>(path: P) -> Self {
104
5
        CfgPath(PathInner::Literal(path.as_ref().to_owned()))
105
5
    }
106
}
107

            
108
/// Helper: expand a directory given as a string.
109
#[cfg(feature = "expand-paths")]
110
24
fn expand(s: &str) -> Result<PathBuf, CfgPathError> {
111
24
    Ok(shellexpand::full_with_context(s, get_home, get_env)
112
24
        .map_err(|e| e.cause)?
113
22
        .into_owned()
114
22
        .into())
115
24
}
116

            
117
/// Helper: convert a string to a path without expansion.
118
#[cfg(not(feature = "expand-paths"))]
119
fn expand(s: &str) -> Result<PathBuf, CfgPathError> {
120
    s.try_into()
121
        .map_err(|_| CfgPathError::InvalidString(s.to_owned()))
122
}
123

            
124
/// Shellexpand helper: return the user's home directory if we can.
125
#[cfg(feature = "expand-paths")]
126
1
fn get_home() -> Option<&'static Path> {
127
1
    base_dirs().ok().map(BaseDirs::home_dir)
128
1
}
129

            
130
/// Shellexpand helper: Expand a shell variable if we can.
131
#[cfg(feature = "expand-paths")]
132
21
fn get_env(var: &str) -> Result<Option<&'static str>, CfgPathError> {
133
21
    let path = match var {
134
21
        "ARTI_CACHE" => project_dirs()?.cache_dir(),
135
20
        "ARTI_CONFIG" => project_dirs()?.config_dir(),
136
3
        "ARTI_SHARED_DATA" => project_dirs()?.data_dir(),
137
3
        "ARTI_LOCAL_DATA" => project_dirs()?.data_local_dir(),
138
3
        "USER_HOME" => base_dirs()?.home_dir(),
139
2
        _ => return Err(CfgPathError::UnknownVar(var.to_owned())),
140
    };
141

            
142
19
    match path.to_str() {
143
        // Note that we never return Ok(None) -- an absent variable is
144
        // always an error.
145
19
        Some(s) => Ok(Some(s)),
146
        // Note that this error is necessary because shellexpand
147
        // doesn't currently handle OsStr.  In the future, that might
148
        // change.
149
        None => Err(CfgPathError::BadUtf8(var.to_owned())),
150
    }
151
21
}
152

            
153
impl std::fmt::Display for CfgPath {
154
7
    fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155
7
        match &self.0 {
156
1
            PathInner::Literal(p) => write!(fmt, "{:?} [exactly]", p),
157
6
            PathInner::Shell(s) => s.fmt(fmt),
158
        }
159
7
    }
160
}
161

            
162
/// Return a ProjectDirs object for the Arti project.
163
#[cfg(feature = "expand-paths")]
164
19
fn project_dirs() -> Result<&'static ProjectDirs, CfgPathError> {
165
19
    /// lazy cell holding the ProjectDirs object.
166
19
    static PROJECT_DIRS: Lazy<Option<ProjectDirs>> =
167
19
        Lazy::new(|| ProjectDirs::from("org", "torproject", "Arti"));
168
19

            
169
19
    PROJECT_DIRS.as_ref().ok_or(CfgPathError::NoProjectDirs)
170
19
}
171

            
172
/// Return a UserDirs object for the current user.
173
#[cfg(feature = "expand-paths")]
174
2
fn base_dirs() -> Result<&'static BaseDirs, CfgPathError> {
175
2
    /// lazy cell holding the BaseDirs object.
176
2
    static BASE_DIRS: Lazy<Option<BaseDirs>> = Lazy::new(BaseDirs::new);
177
2

            
178
2
    BASE_DIRS.as_ref().ok_or(CfgPathError::NoBaseDirs)
179
2
}
180

            
181
#[cfg(all(test, feature = "expand-paths"))]
182
mod test {
183
    #![allow(clippy::unwrap_used)]
184
    use super::*;
185

            
186
    #[test]
187
    fn expand_no_op() {
188
        let p = CfgPath::new("Hello/world".to_string());
189
        assert_eq!(p.to_string(), "Hello/world".to_string());
190
        assert_eq!(p.path().unwrap().to_str(), Some("Hello/world"));
191

            
192
        let p = CfgPath::new("/usr/local/foo".to_string());
193
        assert_eq!(p.to_string(), "/usr/local/foo".to_string());
194
        assert_eq!(p.path().unwrap().to_str(), Some("/usr/local/foo"));
195
    }
196

            
197
    #[cfg(not(target_family = "windows"))]
198
    #[test]
199
    fn expand_home() {
200
        let p = CfgPath::new("~/.arti/config".to_string());
201
        assert_eq!(p.to_string(), "~/.arti/config".to_string());
202

            
203
        let expected = dirs::home_dir().unwrap().join(".arti/config");
204
        assert_eq!(p.path().unwrap().to_str(), expected.to_str());
205

            
206
        let p = CfgPath::new("${USER_HOME}/.arti/config".to_string());
207
        assert_eq!(p.to_string(), "${USER_HOME}/.arti/config".to_string());
208
        assert_eq!(p.path().unwrap().to_str(), expected.to_str());
209
    }
210

            
211
    #[cfg(target_family = "windows")]
212
    #[test]
213
    fn expand_home() {
214
        let p = CfgPath::new("~\\.arti\\config".to_string());
215
        assert_eq!(p.to_string(), "~\\.arti\\config".to_string());
216

            
217
        let expected = dirs::home_dir().unwrap().join(".arti\\config");
218
        assert_eq!(p.path().unwrap().to_str(), expected.to_str());
219

            
220
        let p = CfgPath::new("${USER_HOME}\\.arti\\config".to_string());
221
        assert_eq!(p.to_string(), "${USER_HOME}\\.arti\\config".to_string());
222
        assert_eq!(p.path().unwrap().to_str(), expected.to_str());
223
    }
224

            
225
    #[cfg(not(target_family = "windows"))]
226
    #[test]
227
    fn expand_cache() {
228
        let p = CfgPath::new("${ARTI_CACHE}/example".to_string());
229
        assert_eq!(p.to_string(), "${ARTI_CACHE}/example".to_string());
230

            
231
        let expected = project_dirs().unwrap().cache_dir().join("example");
232
        assert_eq!(p.path().unwrap().to_str(), expected.to_str());
233
    }
234

            
235
    #[cfg(target_family = "windows")]
236
    #[test]
237
    fn expand_cache() {
238
        let p = CfgPath::new("${ARTI_CACHE}\\example".to_string());
239
        assert_eq!(p.to_string(), "${ARTI_CACHE}\\example".to_string());
240

            
241
        let expected = project_dirs().unwrap().cache_dir().join("example");
242
        assert_eq!(p.path().unwrap().to_str(), expected.to_str());
243
    }
244

            
245
    #[test]
246
    fn expand_bogus() {
247
        let p = CfgPath::new("${ARTI_WOMBAT}/example".to_string());
248
        assert_eq!(p.to_string(), "${ARTI_WOMBAT}/example".to_string());
249

            
250
        assert!(matches!(p.path(), Err(CfgPathError::UnknownVar(_))));
251
        assert_eq!(
252
            &p.path().unwrap_err().to_string(),
253
            "unrecognized variable ARTI_WOMBAT"
254
        );
255
    }
256

            
257
    #[test]
258
    fn literal() {
259
        let p = CfgPath::from_path(PathBuf::from("${ARTI_CACHE}/literally"));
260
        // This doesn't get expanded, since we're using a literal path.
261
        assert_eq!(
262
            p.path().unwrap().to_str().unwrap(),
263
            "${ARTI_CACHE}/literally"
264
        );
265
        assert_eq!(p.to_string(), "\"${ARTI_CACHE}/literally\" [exactly]");
266
    }
267
}