Skip to main content

ljos_cli/mailbox/
config.rs

1//! The optional `[mail]` table.
2//!
3//! It names the identity drafts are sent as, the deny list, and the
4//! notmuch database, so only the user may set it. It is read from the
5//! user's own files, `ljos.toml` then `mail.toml` in the ljos config dir,
6//! each opened by `open_owned`, as are `deny_file` and `notmuch_config`
7//! where they are read, so no other user can change them. Any other
8//! config layer (`./ljos.toml`, `./vissue.toml`, `VISSUE_CONFIG`,
9//! `LJOS_CONFIG`, the vissue config) that carries a `[mail]` table is an
10//! error, as is a layer that cannot be read or parsed: a seat that can
11//! write the working directory must not swap the identity or the deny
12//! list.
13
14use std::fs::File;
15use std::io::{ErrorKind, Read as _};
16use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _};
17use std::path::{Path, PathBuf};
18
19use anyhow::{bail, Context as _, Result};
20use serde::Deserialize;
21
22/// What the `[mail]` layers say. Every key is optional.
23#[derive(Debug, Clone, Default, PartialEq, Eq)]
24pub struct MailConfig {
25    /// `NOTMUCH_CONFIG` for every read. The reader refuses without it.
26    pub notmuch_config: Option<PathBuf>,
27    /// The notmuch binary: an absolute path, or a bare name found on PATH.
28    pub notmuch: Option<PathBuf>,
29    /// The one address a draft's From may carry. Needs `deny_file`.
30    pub identity: Option<String>,
31    /// The deny list the content checks read.
32    pub deny_file: Option<PathBuf>,
33    /// Attribution phrases refused on top of the built-in ones.
34    pub attribution: Vec<String>,
35}
36
37#[derive(Deserialize)]
38struct Layer {
39    mail: Option<Table>,
40}
41
42#[derive(Deserialize)]
43#[serde(deny_unknown_fields)]
44struct Table {
45    notmuch_config: Option<PathBuf>,
46    notmuch: Option<PathBuf>,
47    identity: Option<String>,
48    deny_file: Option<PathBuf>,
49    attribution: Option<Vec<String>>,
50}
51
52/// The `[mail]` config from the user's files, after checking that no
53/// other layer sets it. `None` when no user file has the table.
54pub fn load() -> Result<Option<MailConfig>> {
55    let user: Vec<PathBuf> = crate::converge::config_base()
56        .map(|base| vec![base.join("ljos/ljos.toml"), base.join("ljos/mail.toml")])
57        .unwrap_or_default();
58    let shared: Vec<PathBuf> = crate::converge::config_paths(None)
59        .into_iter()
60        .filter(|path| !user.contains(path))
61        .collect();
62    load_from(&user, &shared)
63}
64
65/// The `[mail]` config from the `user` files, in order, a later one
66/// overriding the keys it sets. A `shared` file with a `[mail]` table is
67/// an error. A missing file is skipped; any other read failure is an
68/// error.
69pub fn load_from(user: &[PathBuf], shared: &[PathBuf]) -> Result<Option<MailConfig>> {
70    for path in shared {
71        let Some(text) = read(path)? else {
72            continue;
73        };
74        let layer: toml::Value =
75            toml::from_str(&text).with_context(|| format!("mail config {}", path.display()))?;
76        if layer.get("mail").is_some() {
77            bail!(
78                "mail config {}: [mail] may be set only in the user's own ljos.toml or mail.toml",
79                path.display()
80            );
81        }
82    }
83    let mut config: Option<MailConfig> = None;
84    for path in user {
85        let Some((mut file, _)) = open_owned(path, "mail config")? else {
86            continue;
87        };
88        let mut text = String::new();
89        file.read_to_string(&mut text)
90            .with_context(|| format!("mail config {}: cannot read", path.display()))?;
91        let layer: Layer =
92            toml::from_str(&text).with_context(|| format!("mail config {}", path.display()))?;
93        let Some(table) = layer.mail else {
94            continue;
95        };
96        check(&table).with_context(|| format!("mail config {}", path.display()))?;
97        let merged = config.get_or_insert_with(MailConfig::default);
98        if table.notmuch_config.is_some() {
99            merged.notmuch_config = table.notmuch_config;
100        }
101        if table.notmuch.is_some() {
102            merged.notmuch = table.notmuch;
103        }
104        if table.identity.is_some() {
105            merged.identity = table.identity;
106        }
107        if table.deny_file.is_some() {
108            merged.deny_file = table.deny_file;
109        }
110        if let Some(extra) = table.attribution {
111            merged.attribution = extra;
112        }
113    }
114    if config
115        .as_ref()
116        .is_some_and(|c| c.identity.is_some() && c.deny_file.is_none())
117    {
118        bail!("mail config: identity is set without deny_file, which would deny nothing");
119    }
120    Ok(config)
121}
122
123fn read(path: &Path) -> Result<Option<String>> {
124    match std::fs::read_to_string(path) {
125        Ok(text) => Ok(Some(text)),
126        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
127        Err(e) => Err(e).with_context(|| format!("mail config {}: cannot read", path.display())),
128    }
129}
130
131/// `path` open for reading, and the path it was opened at, when no other
132/// user can change what it reads. Every directory above it, with
133/// symlinks resolved, must be owned by this user or root and not be
134/// group or world writable unless it is sticky like `/tmp`. The file is
135/// opened `O_NOFOLLOW`, and the descriptor must be a regular file owned
136/// by this user or root and not group or world writable. `None` when it
137/// does not exist. `what` names it in errors.
138pub(crate) fn open_owned(path: &Path, what: &str) -> Result<Option<(File, PathBuf)>> {
139    let Some(name) = path.file_name() else {
140        bail!("{what} {}: not a file path", path.display());
141    };
142    let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
143    let dir = match std::fs::canonicalize(parent.unwrap_or(Path::new("."))) {
144        Ok(dir) => dir,
145        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
146        Err(e) => return Err(e).with_context(|| format!("{what} {}", path.display())),
147    };
148    let at = dir.join(name);
149    let file = match std::fs::OpenOptions::new()
150        .read(true)
151        .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK)
152        .open(&at)
153    {
154        Ok(file) => file,
155        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
156        Err(e) if e.raw_os_error() == Some(libc::ELOOP) => {
157            bail!("{what} {}: is a symlink", path.display())
158        }
159        Err(e) => return Err(e).with_context(|| format!("{what} {}: cannot open", path.display())),
160    };
161    let trusted = |uid: u32| uid == crate::euid() || uid == 0;
162    for above in dir.ancestors() {
163        let meta = std::fs::symlink_metadata(above).with_context(|| {
164            format!("{what} {}: cannot stat {}", path.display(), above.display())
165        })?;
166        if !trusted(meta.uid()) {
167            bail!(
168                "{what} {}: {} is not owned by this user or root",
169                path.display(),
170                above.display()
171            );
172        }
173        if meta.mode() & 0o022 != 0 && meta.mode() & 0o1000 == 0 {
174            bail!(
175                "{what} {}: {} is group or world writable",
176                path.display(),
177                above.display()
178            );
179        }
180    }
181    let meta = file
182        .metadata()
183        .with_context(|| format!("{what} {}: cannot stat", path.display()))?;
184    if !meta.is_file() {
185        bail!("{what} {}: not a regular file", path.display());
186    }
187    if !trusted(meta.uid()) {
188        bail!("{what} {}: not owned by this user or root", path.display());
189    }
190    if meta.mode() & 0o022 != 0 {
191        bail!("{what} {}: group or world writable", path.display());
192    }
193    Ok(Some((file, at)))
194}
195
196fn check(table: &Table) -> Result<()> {
197    for (key, path) in [
198        ("notmuch_config", &table.notmuch_config),
199        ("deny_file", &table.deny_file),
200    ] {
201        if path.as_deref().is_some_and(|p| !p.is_absolute()) {
202            bail!("{key} must be an absolute path");
203        }
204    }
205    if let Some(bin) = &table.notmuch {
206        if !bin.is_absolute() && bin.components().count() != 1 {
207            bail!("notmuch must be an absolute path or a bare name on PATH");
208        }
209    }
210    if let Some(identity) = &table.identity {
211        if !is_bare_address(identity) {
212            bail!("identity must be a bare address such as owner@example.test");
213        }
214    }
215    Ok(())
216}
217
218/// `local@domain` with no display name, brackets or spaces.
219pub(crate) fn is_bare_address(text: &str) -> bool {
220    let Some((local, domain)) = text.split_once('@') else {
221        return false;
222    };
223    !local.is_empty()
224        && domain.contains('.')
225        && !domain.starts_with('.')
226        && !domain.ends_with('.')
227        && !text
228            .chars()
229            .any(|c| c.is_whitespace() || c.is_control() || "<>\"(),;:[]\\".contains(c))
230        && !domain.contains('@')
231}
232
233impl MailConfig {
234    /// The notmuch binary to run.
235    #[must_use]
236    pub fn notmuch_bin(&self) -> &Path {
237        self.notmuch.as_deref().unwrap_or(Path::new("notmuch"))
238    }
239}