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