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        // Canonical paths already accounted for. Two candidates can name the
119        // same file — $NOTMUCH_CONFIG is often the XDG default, and a
120        // server.toml override can point at either — and a file that is
121        // already the chosen config is not a stale copy. Without this, the
122        // config in use is reported as its own shadow and the reader is told
123        // to delete it.
124        let mut seen: Vec<PathBuf> = Vec::new();
125
126        for candidate in candidates {
127            if !candidate.path.is_file() {
128                continue;
129            }
130            let canon =
131                std::fs::canonicalize(&candidate.path).unwrap_or_else(|_| candidate.path.clone());
132            if seen.contains(&canon) {
133                continue;
134            }
135            seen.push(canon);
136            match chosen {
137                None => chosen = Some(candidate),
138                Some(_) => shadowed.push(candidate.path),
139            }
140        }
141
142        match chosen {
143            Some(c) => ResolvedConfig {
144                kind,
145                path: Some(c.path),
146                source: c.source,
147                shadowed,
148            },
149            None => ResolvedConfig::missing(kind),
150        }
151    }
152}
153
154#[derive(Debug, Clone)]
155pub struct MailPaths {
156    pub notmuch: ResolvedConfig,
157    pub mbsync: ResolvedConfig,
158    pub msmtp: ResolvedConfig,
159    pub notmuch_config: NotmuchConfig,
160    pub mbsync_config: MbsyncConfig,
161    pub msmtp_config: MsmtpConfig,
162    pub maildir_root: PathBuf,
163    pub database_path: PathBuf,
164    pub binaries: crate::settings::Binaries,
165    /// Where ecr keeps its own files, as opposed to the mail tools' files.
166    pub ecr_config_dir: PathBuf,
167    /// The same split XDG draws: state is what ecr rewrites as it runs — the
168    /// OAuth tokens and the mail index — as opposed to what the user edits.
169    pub ecr_state_dir: PathBuf,
170    /// Whether reads may be answered from the mail index. It is a cache of what
171    /// notmuch holds, so turning it off costs speed and nothing else; the
172    /// switch exists so a suspected disagreement can be settled without
173    /// rebuilding or reinstalling anything.
174    pub use_index: bool,
175}
176
177impl MailPaths {
178    pub fn discover() -> Result<Self> {
179        Self::with(&Env::from_process(), &ServerSettings::load())
180    }
181
182    pub fn with(env: &Env, settings: &ServerSettings) -> Result<Self> {
183        let notmuch = env.resolve(ConfigKind::Notmuch, settings);
184        let mbsync = env.resolve(ConfigKind::Mbsync, settings);
185        let msmtp = env.resolve(ConfigKind::Msmtp, settings);
186
187        let notmuch_path = notmuch.path.clone().ok_or_else(|| Error::ConfigNotFound {
188            kind: "notmuch",
189            searched: env
190                .candidates(ConfigKind::Notmuch, settings)
191                .into_iter()
192                .map(|c| c.path)
193                .collect(),
194        })?;
195
196        let notmuch_config = NotmuchConfig::parse(&std::fs::read_to_string(&notmuch_path)?);
197        let mbsync_config = read_optional(&mbsync)
198            .map(|t| MbsyncConfig::parse(&t))
199            .unwrap_or_default();
200        let msmtp_config = read_optional(&msmtp)
201            .map(|t| MsmtpConfig::parse(&t))
202            .unwrap_or_default();
203
204        let database_path =
205            notmuch_config
206                .database_path
207                .clone()
208                .ok_or_else(|| Error::NoDatabasePath {
209                    path: notmuch_path.clone(),
210                })?;
211
212        let maildir_root = settings
213            .maildir_root
214            .clone()
215            .or_else(|| notmuch_config.effective_mail_root().cloned())
216            .unwrap_or_else(|| database_path.clone());
217
218        Ok(Self {
219            notmuch,
220            mbsync,
221            msmtp,
222            notmuch_config,
223            mbsync_config,
224            msmtp_config,
225            maildir_root,
226            database_path,
227            binaries: crate::settings::Binaries::from_settings(settings),
228            ecr_config_dir: env.config_dir.join("ecr"),
229            ecr_state_dir: env.state_dir.join("ecr"),
230            use_index: settings.index.unwrap_or(true),
231        })
232    }
233
234    /// The user-facing settings file, shared by every client.
235    pub fn settings_file(&self) -> PathBuf {
236        self.ecr_config_dir.join("settings.toml")
237    }
238
239    /// The OAuth profiles, anchored to this `Env` rather than to the process.
240    ///
241    /// Doctor reports on tokens, and reading one adopts it from oauthman's
242    /// directory — so resolving the store from `dirs::config_dir()` instead of
243    /// from here made an integration test against a tempdir read, and write to,
244    /// the developer's real `~/.config/ecr`. That is the same trap as
245    /// `NOTMUCH_CONFIG` outranking a redirected `HOME`: isolation has to come
246    /// from the one `Env` everything else already resolves through.
247    pub fn oauth_profiles(&self) -> crate::oauth::Profiles {
248        crate::oauth::Profiles::under(&self.ecr_config_dir, &self.ecr_state_dir)
249    }
250
251    /// Where the shipped presets are seeded and the user's own themes live.
252    pub fn themes_dir(&self) -> PathBuf {
253        self.ecr_config_dir.join("themes")
254    }
255
256    /// Resolves a path written in settings.toml against ecr's own directory.
257    ///
258    /// The link is user input that arrives over HTTP, so this is a boundary, not
259    /// a convenience: anything that could climb out of `ecr_config_dir` or name a
260    /// file ecr has no business reading is rejected rather than clamped. The
261    /// check is lexical because the target need not exist yet — a theme can be
262    /// pointed at before it is written.
263    pub fn resolve_relative(&self, rel: &str) -> Result<PathBuf> {
264        let unsafe_path = |reason| Error::UnsafePath {
265            path: rel.to_string(),
266            reason,
267        };
268
269        if rel.trim().is_empty() {
270            return Err(unsafe_path("it is empty"));
271        }
272
273        let candidate = Path::new(rel);
274        if candidate.is_absolute() {
275            return Err(unsafe_path("it is absolute"));
276        }
277
278        for part in candidate.components() {
279            match part {
280                Component::Normal(_) => {}
281                Component::CurDir => {}
282                Component::ParentDir => return Err(unsafe_path("it climbs above the config dir")),
283                Component::RootDir | Component::Prefix(_) => {
284                    return Err(unsafe_path("it is absolute"));
285                }
286            }
287        }
288
289        if candidate.extension().and_then(|e| e.to_str()) != Some("toml") {
290            return Err(unsafe_path("it is not a .toml file"));
291        }
292
293        Ok(self.ecr_config_dir.join(candidate))
294    }
295
296    pub fn xapian_dir(&self) -> PathBuf {
297        self.database_path.join(".notmuch").join("xapian")
298    }
299
300    pub fn post_new_hook(&self) -> Option<PathBuf> {
301        let hook = self
302            .notmuch
303            .path
304            .as_ref()?
305            .parent()?
306            .join("hooks")
307            .join("post-new");
308        hook.is_file().then_some(hook)
309    }
310}
311
312fn read_optional(resolved: &ResolvedConfig) -> Option<String> {
313    std::fs::read_to_string(resolved.path.as_ref()?).ok()
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use std::fs;
320
321    fn write(path: &Path, contents: &str) {
322        fs::create_dir_all(path.parent().unwrap()).unwrap();
323        fs::write(path, contents).unwrap();
324    }
325
326    #[test]
327    fn xdg_config_wins_over_the_legacy_dotfile() {
328        let home = tempfile::tempdir().unwrap();
329        let home = home.path();
330        write(
331            &home.join(".config/notmuch/default/config"),
332            "[database]\npath=/srv/Mail\n",
333        );
334        write(
335            &home.join(".notmuch-config"),
336            "[database]\npath=/srv/stale-mail\n",
337        );
338
339        let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
340
341        assert_eq!(paths.notmuch.source, ConfigSource::Xdg);
342        assert_eq!(paths.maildir_root, PathBuf::from("/srv/Mail"));
343    }
344
345    #[test]
346    fn the_stale_dotfile_is_reported_as_shadowed() {
347        let home = tempfile::tempdir().unwrap();
348        let home = home.path();
349        write(
350            &home.join(".config/notmuch/default/config"),
351            "[database]\npath=/srv/Mail\n",
352        );
353        write(&home.join(".notmuch-config"), "[database]\npath=/srv/old\n");
354
355        let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
356
357        assert_eq!(paths.notmuch.shadowed, vec![home.join(".notmuch-config")]);
358    }
359
360    #[test]
361    fn an_env_var_pointing_at_the_xdg_default_is_not_its_own_stale_copy() {
362        let home = tempfile::tempdir().unwrap();
363        let home = home.path();
364        let xdg = home.join(".config/notmuch/default/config");
365        write(&xdg, "[database]\npath=/srv/Mail\n");
366
367        // $NOTMUCH_CONFIG set to the same path the XDG candidate would find.
368        let env = Env {
369            notmuch_config: Some(xdg.clone()),
370            ..Env::rooted_at(home)
371        };
372
373        let paths = MailPaths::with(&env, &ServerSettings::default()).unwrap();
374
375        assert_eq!(
376            paths.notmuch.source,
377            ConfigSource::EnvVar("NOTMUCH_CONFIG".into())
378        );
379        assert_eq!(paths.notmuch.path.as_deref(), Some(xdg.as_path()));
380        assert!(
381            paths.notmuch.shadowed.is_empty(),
382            "the XDG default reached through $NOTMUCH_CONFIG is the same file, not a stale copy: {:?}",
383            paths.notmuch.shadowed
384        );
385    }
386
387    #[test]
388    fn a_legacy_dotfile_symlinked_to_the_chosen_config_is_not_stale() {
389        let home = tempfile::tempdir().unwrap();
390        let home = home.path();
391        let xdg = home.join(".config/notmuch/default/config");
392        write(&xdg, "[database]\npath=/srv/Mail\n");
393        // ~/.notmuch-config is a symlink to the XDG config, not a separate file.
394        std::os::unix::fs::symlink(&xdg, home.join(".notmuch-config")).unwrap();
395
396        let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
397
398        assert_eq!(paths.notmuch.source, ConfigSource::Xdg);
399        assert!(
400            paths.notmuch.shadowed.is_empty(),
401            "a symlink to the chosen config is the same file, not a stale copy: {:?}",
402            paths.notmuch.shadowed
403        );
404    }
405
406    #[test]
407    fn server_settings_override_everything() {
408        let home = tempfile::tempdir().unwrap();
409        let home = home.path();
410        write(
411            &home.join(".config/notmuch/default/config"),
412            "[database]\npath=/srv/xdg\n",
413        );
414        let explicit = home.join("explicit-config");
415        write(&explicit, "[database]\npath=/srv/explicit\n");
416
417        let settings = ServerSettings {
418            notmuch_config: Some(explicit.clone()),
419            ..Default::default()
420        };
421        let paths = MailPaths::with(&Env::rooted_at(home), &settings).unwrap();
422
423        assert_eq!(paths.notmuch.source, ConfigSource::ServerToml);
424        assert_eq!(paths.maildir_root, PathBuf::from("/srv/explicit"));
425    }
426
427    #[test]
428    fn falls_back_to_the_legacy_dotfile_when_xdg_is_absent() {
429        let home = tempfile::tempdir().unwrap();
430        let home = home.path();
431        write(
432            &home.join(".notmuch-config"),
433            "[database]\npath=/srv/only\n",
434        );
435
436        let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
437
438        assert_eq!(paths.notmuch.source, ConfigSource::LegacyDotfile);
439        assert_eq!(paths.maildir_root, PathBuf::from("/srv/only"));
440    }
441
442    #[test]
443    fn missing_notmuch_config_lists_every_location_searched() {
444        let home = tempfile::tempdir().unwrap();
445        let err =
446            MailPaths::with(&Env::rooted_at(home.path()), &ServerSettings::default()).unwrap_err();
447
448        let message = err.to_string();
449        assert!(
450            message.contains(".config/notmuch/default/config"),
451            "{message}"
452        );
453        assert!(message.contains(".notmuch-config"), "{message}");
454    }
455
456    #[test]
457    fn maildir_root_never_guesses_from_the_data_dir() {
458        let home = tempfile::tempdir().unwrap();
459        let home = home.path();
460        write(
461            &home.join(".config/notmuch/default/config"),
462            "[database]\npath=/home/someone/.local/share/Mail\n",
463        );
464
465        let paths = MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap();
466
467        assert_eq!(
468            paths.maildir_root,
469            PathBuf::from("/home/someone/.local/share/Mail")
470        );
471    }
472
473    fn rooted_paths(home: &Path) -> MailPaths {
474        write(
475            &home.join(".config/notmuch/default/config"),
476            "[database]\npath=/srv/Mail\n",
477        );
478        MailPaths::with(&Env::rooted_at(home), &ServerSettings::default()).unwrap()
479    }
480
481    #[test]
482    fn a_relative_theme_resolves_inside_the_config_dir() {
483        let home = tempfile::tempdir().unwrap();
484        let paths = rooted_paths(home.path());
485
486        assert_eq!(
487            paths.resolve_relative("themes/nord.toml").unwrap(),
488            home.path().join(".config/ecr/themes/nord.toml")
489        );
490    }
491
492    #[test]
493    fn a_relative_path_cannot_climb_out_of_the_config_dir() {
494        let home = tempfile::tempdir().unwrap();
495        let paths = rooted_paths(home.path());
496
497        for attempt in [
498            "../../../etc/passwd.toml",
499            "themes/../../secrets.toml",
500            "/etc/passwd.toml",
501            "themes/../../../../../../etc/shadow.toml",
502        ] {
503            assert!(
504                paths.resolve_relative(attempt).is_err(),
505                "{attempt} was accepted"
506            );
507        }
508    }
509
510    #[test]
511    fn only_toml_files_resolve() {
512        let home = tempfile::tempdir().unwrap();
513        let paths = rooted_paths(home.path());
514
515        assert!(paths.resolve_relative("themes/nord.conf").is_err());
516        assert!(paths.resolve_relative("../.ssh/id_rsa").is_err());
517        assert!(paths.resolve_relative("").is_err());
518        assert!(paths.resolve_relative("   ").is_err());
519    }
520}