Skip to main content

ecr_store/
paths.rs

1use crate::error::{Error, Result};
2use crate::parse::{MbsyncConfig, MsmtpConfig, NotmuchConfig};
3use crate::settings::ServerSettings;
4use ecr_core::doctor::{ConfigKind, ConfigSource, ResolvedConfig};
5use std::path::{Component, Path, PathBuf};
6
7#[derive(Debug, Clone)]
8pub struct Candidate {
9    pub path: PathBuf,
10    pub source: ConfigSource,
11}
12
13#[derive(Debug, Clone)]
14pub struct Env {
15    pub home: PathBuf,
16    pub config_dir: PathBuf,
17    pub notmuch_config: Option<PathBuf>,
18    pub notmuch_profile: Option<String>,
19    pub mbsyncrc: Option<PathBuf>,
20}
21
22impl Env {
23    pub fn from_process() -> Self {
24        let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
25        Self {
26            config_dir: dirs::config_dir().unwrap_or_else(|| home.join(".config")),
27            home,
28            notmuch_config: std::env::var_os("NOTMUCH_CONFIG").map(PathBuf::from),
29            notmuch_profile: std::env::var("NOTMUCH_PROFILE").ok(),
30            mbsyncrc: std::env::var_os("MBSYNCRC").map(PathBuf::from),
31        }
32    }
33
34    pub fn rooted_at(home: &Path) -> Self {
35        Self {
36            home: home.to_path_buf(),
37            config_dir: home.join(".config"),
38            notmuch_config: None,
39            notmuch_profile: None,
40            mbsyncrc: None,
41        }
42    }
43
44    fn candidates(&self, kind: ConfigKind, settings: &ServerSettings) -> Vec<Candidate> {
45        let mut out = Vec::new();
46
47        let explicit = match kind {
48            ConfigKind::Notmuch => settings.notmuch_config.as_ref(),
49            ConfigKind::Mbsync => settings.mbsync_config.as_ref(),
50            ConfigKind::Msmtp => settings.msmtp_config.as_ref(),
51        };
52        if let Some(path) = explicit {
53            out.push(Candidate {
54                path: path.clone(),
55                source: ConfigSource::ServerToml,
56            });
57        }
58
59        match kind {
60            ConfigKind::Notmuch => {
61                if let Some(path) = &self.notmuch_config {
62                    out.push(Candidate {
63                        path: path.clone(),
64                        source: ConfigSource::EnvVar("NOTMUCH_CONFIG".into()),
65                    });
66                }
67                let profile = self.notmuch_profile.as_deref().unwrap_or("default");
68                out.push(Candidate {
69                    path: self.config_dir.join("notmuch").join(profile).join("config"),
70                    source: ConfigSource::Xdg,
71                });
72                out.push(Candidate {
73                    path: self.home.join(".notmuch-config"),
74                    source: ConfigSource::LegacyDotfile,
75                });
76            }
77            ConfigKind::Mbsync => {
78                if let Some(path) = &self.mbsyncrc {
79                    out.push(Candidate {
80                        path: path.clone(),
81                        source: ConfigSource::EnvVar("MBSYNCRC".into()),
82                    });
83                }
84                out.push(Candidate {
85                    path: self.config_dir.join("isyncrc"),
86                    source: ConfigSource::Xdg,
87                });
88                out.push(Candidate {
89                    path: self.config_dir.join("mbsync").join("mbsyncrc"),
90                    source: ConfigSource::Xdg,
91                });
92                out.push(Candidate {
93                    path: self.home.join(".mbsyncrc"),
94                    source: ConfigSource::LegacyDotfile,
95                });
96            }
97            ConfigKind::Msmtp => {
98                out.push(Candidate {
99                    path: self.config_dir.join("msmtp").join("config"),
100                    source: ConfigSource::Xdg,
101                });
102                out.push(Candidate {
103                    path: self.home.join(".msmtprc"),
104                    source: ConfigSource::LegacyDotfile,
105                });
106            }
107        }
108        out
109    }
110
111    pub fn resolve(&self, kind: ConfigKind, settings: &ServerSettings) -> ResolvedConfig {
112        let candidates = self.candidates(kind, settings);
113        let mut chosen: Option<Candidate> = None;
114        let mut shadowed = Vec::new();
115
116        for candidate in candidates {
117            if !candidate.path.is_file() {
118                continue;
119            }
120            match chosen {
121                None => chosen = Some(candidate),
122                Some(_) => shadowed.push(candidate.path),
123            }
124        }
125
126        match chosen {
127            Some(c) => ResolvedConfig {
128                kind,
129                path: Some(c.path),
130                source: c.source,
131                shadowed,
132            },
133            None => ResolvedConfig::missing(kind),
134        }
135    }
136}
137
138#[derive(Debug, Clone)]
139pub struct MailPaths {
140    pub notmuch: ResolvedConfig,
141    pub mbsync: ResolvedConfig,
142    pub msmtp: ResolvedConfig,
143    pub notmuch_config: NotmuchConfig,
144    pub mbsync_config: MbsyncConfig,
145    pub msmtp_config: MsmtpConfig,
146    pub maildir_root: PathBuf,
147    pub database_path: PathBuf,
148    pub binaries: crate::settings::Binaries,
149    /// Where ecr keeps its own files, as opposed to the mail tools' files.
150    pub ecr_config_dir: PathBuf,
151}
152
153impl MailPaths {
154    pub fn discover() -> Result<Self> {
155        Self::with(&Env::from_process(), &ServerSettings::load())
156    }
157
158    pub fn with(env: &Env, settings: &ServerSettings) -> Result<Self> {
159        let notmuch = env.resolve(ConfigKind::Notmuch, settings);
160        let mbsync = env.resolve(ConfigKind::Mbsync, settings);
161        let msmtp = env.resolve(ConfigKind::Msmtp, settings);
162
163        let notmuch_path = notmuch.path.clone().ok_or_else(|| Error::ConfigNotFound {
164            kind: "notmuch",
165            searched: env
166                .candidates(ConfigKind::Notmuch, settings)
167                .into_iter()
168                .map(|c| c.path)
169                .collect(),
170        })?;
171
172        let notmuch_config = NotmuchConfig::parse(&std::fs::read_to_string(&notmuch_path)?);
173        let mbsync_config = read_optional(&mbsync)
174            .map(|t| MbsyncConfig::parse(&t))
175            .unwrap_or_default();
176        let msmtp_config = read_optional(&msmtp)
177            .map(|t| MsmtpConfig::parse(&t))
178            .unwrap_or_default();
179
180        let database_path =
181            notmuch_config
182                .database_path
183                .clone()
184                .ok_or_else(|| Error::NoDatabasePath {
185                    path: notmuch_path.clone(),
186                })?;
187
188        let maildir_root = settings
189            .maildir_root
190            .clone()
191            .or_else(|| notmuch_config.effective_mail_root().cloned())
192            .unwrap_or_else(|| database_path.clone());
193
194        Ok(Self {
195            notmuch,
196            mbsync,
197            msmtp,
198            notmuch_config,
199            mbsync_config,
200            msmtp_config,
201            maildir_root,
202            database_path,
203            binaries: crate::settings::Binaries::from_settings(settings),
204            ecr_config_dir: env.config_dir.join("ecr"),
205        })
206    }
207
208    /// The user-facing settings file, shared by every client.
209    pub fn settings_file(&self) -> PathBuf {
210        self.ecr_config_dir.join("settings.toml")
211    }
212
213    /// Where the shipped presets are seeded and the user's own themes live.
214    pub fn themes_dir(&self) -> PathBuf {
215        self.ecr_config_dir.join("themes")
216    }
217
218    /// Resolves a path written in settings.toml against ecr's own directory.
219    ///
220    /// The link is user input that arrives over HTTP, so this is a boundary, not
221    /// a convenience: anything that could climb out of `ecr_config_dir` or name a
222    /// file ecr has no business reading is rejected rather than clamped. The
223    /// check is lexical because the target need not exist yet — a theme can be
224    /// pointed at before it is written.
225    pub fn resolve_relative(&self, rel: &str) -> Result<PathBuf> {
226        let unsafe_path = |reason| Error::UnsafePath {
227            path: rel.to_string(),
228            reason,
229        };
230
231        if rel.trim().is_empty() {
232            return Err(unsafe_path("it is empty"));
233        }
234
235        let candidate = Path::new(rel);
236        if candidate.is_absolute() {
237            return Err(unsafe_path("it is absolute"));
238        }
239
240        for part in candidate.components() {
241            match part {
242                Component::Normal(_) => {}
243                Component::CurDir => {}
244                Component::ParentDir => return Err(unsafe_path("it climbs above the config dir")),
245                Component::RootDir | Component::Prefix(_) => {
246                    return Err(unsafe_path("it is absolute"));
247                }
248            }
249        }
250
251        if candidate.extension().and_then(|e| e.to_str()) != Some("toml") {
252            return Err(unsafe_path("it is not a .toml file"));
253        }
254
255        Ok(self.ecr_config_dir.join(candidate))
256    }
257
258    pub fn xapian_dir(&self) -> PathBuf {
259        self.database_path.join(".notmuch").join("xapian")
260    }
261
262    pub fn post_new_hook(&self) -> Option<PathBuf> {
263        let hook = self
264            .notmuch
265            .path
266            .as_ref()?
267            .parent()?
268            .join("hooks")
269            .join("post-new");
270        hook.is_file().then_some(hook)
271    }
272}
273
274fn read_optional(resolved: &ResolvedConfig) -> Option<String> {
275    std::fs::read_to_string(resolved.path.as_ref()?).ok()
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use std::fs;
282
283    fn write(path: &Path, contents: &str) {
284        fs::create_dir_all(path.parent().unwrap()).unwrap();
285        fs::write(path, contents).unwrap();
286    }
287
288    #[test]
289    fn xdg_config_wins_over_the_legacy_dotfile() {
290        let home = tempfile::tempdir().unwrap();
291        let home = home.path();
292        write(
293            &home.join(".config/notmuch/default/config"),
294            "[database]\npath=/srv/Mail\n",
295        );
296        write(
297            &home.join(".notmuch-config"),
298            "[database]\npath=/srv/stale-mail\n",
299        );
300
301        let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
302
303        assert_eq!(paths.notmuch.source, ConfigSource::Xdg);
304        assert_eq!(paths.maildir_root, PathBuf::from("/srv/Mail"));
305    }
306
307    #[test]
308    fn the_stale_dotfile_is_reported_as_shadowed() {
309        let home = tempfile::tempdir().unwrap();
310        let home = home.path();
311        write(
312            &home.join(".config/notmuch/default/config"),
313            "[database]\npath=/srv/Mail\n",
314        );
315        write(&home.join(".notmuch-config"), "[database]\npath=/srv/old\n");
316
317        let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
318
319        assert_eq!(paths.notmuch.shadowed, vec![home.join(".notmuch-config")]);
320    }
321
322    #[test]
323    fn server_settings_override_everything() {
324        let home = tempfile::tempdir().unwrap();
325        let home = home.path();
326        write(
327            &home.join(".config/notmuch/default/config"),
328            "[database]\npath=/srv/xdg\n",
329        );
330        let explicit = home.join("explicit-config");
331        write(&explicit, "[database]\npath=/srv/explicit\n");
332
333        let settings = ServerSettings {
334            notmuch_config: Some(explicit.clone()),
335            ..Default::default()
336        };
337        let paths = MailPaths::with(&Env::rooted_at(home), &settings).unwrap();
338
339        assert_eq!(paths.notmuch.source, ConfigSource::ServerToml);
340        assert_eq!(paths.maildir_root, PathBuf::from("/srv/explicit"));
341    }
342
343    #[test]
344    fn falls_back_to_the_legacy_dotfile_when_xdg_is_absent() {
345        let home = tempfile::tempdir().unwrap();
346        let home = home.path();
347        write(
348            &home.join(".notmuch-config"),
349            "[database]\npath=/srv/only\n",
350        );
351
352        let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
353
354        assert_eq!(paths.notmuch.source, ConfigSource::LegacyDotfile);
355        assert_eq!(paths.maildir_root, PathBuf::from("/srv/only"));
356    }
357
358    #[test]
359    fn missing_notmuch_config_lists_every_location_searched() {
360        let home = tempfile::tempdir().unwrap();
361        let err =
362            MailPaths::with(&Env::rooted_at(home.path()), &ServerSettings::default()).unwrap_err();
363
364        let message = err.to_string();
365        assert!(
366            message.contains(".config/notmuch/default/config"),
367            "{message}"
368        );
369        assert!(message.contains(".notmuch-config"), "{message}");
370    }
371
372    #[test]
373    fn maildir_root_never_guesses_from_the_data_dir() {
374        let home = tempfile::tempdir().unwrap();
375        let home = home.path();
376        write(
377            &home.join(".config/notmuch/default/config"),
378            "[database]\npath=/home/someone/.local/share/Mail\n",
379        );
380
381        let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
382
383        assert_eq!(
384            paths.maildir_root,
385            PathBuf::from("/home/someone/.local/share/Mail")
386        );
387    }
388
389    fn rooted_paths(home: &Path) -> MailPaths {
390        write(
391            &home.join(".config/notmuch/default/config"),
392            "[database]\npath=/srv/Mail\n",
393        );
394        MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap()
395    }
396
397    #[test]
398    fn a_relative_theme_resolves_inside_the_config_dir() {
399        let home = tempfile::tempdir().unwrap();
400        let paths = rooted_paths(home.path());
401
402        assert_eq!(
403            paths.resolve_relative("themes/nord.toml").unwrap(),
404            home.path().join(".config/ecr/themes/nord.toml")
405        );
406    }
407
408    #[test]
409    fn a_relative_path_cannot_climb_out_of_the_config_dir() {
410        let home = tempfile::tempdir().unwrap();
411        let paths = rooted_paths(home.path());
412
413        for attempt in [
414            "../../../etc/passwd.toml",
415            "themes/../../secrets.toml",
416            "/etc/passwd.toml",
417            "themes/../../../../../../etc/shadow.toml",
418        ] {
419            assert!(
420                paths.resolve_relative(attempt).is_err(),
421                "{attempt} was accepted"
422            );
423        }
424    }
425
426    #[test]
427    fn only_toml_files_resolve() {
428        let home = tempfile::tempdir().unwrap();
429        let paths = rooted_paths(home.path());
430
431        assert!(paths.resolve_relative("themes/nord.conf").is_err());
432        assert!(paths.resolve_relative("../.ssh/id_rsa").is_err());
433        assert!(paths.resolve_relative("").is_err());
434        assert!(paths.resolve_relative("   ").is_err());
435    }
436}