ljos_cli/mailbox/
config.rs1use 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
24pub struct MailConfig {
25 pub notmuch_config: Option<PathBuf>,
27 pub notmuch: Option<PathBuf>,
29 pub identity: Option<String>,
31 pub deny_file: Option<PathBuf>,
33 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
52pub 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
65pub 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
131pub(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
218pub(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 #[must_use]
236 pub fn notmuch_bin(&self) -> &Path {
237 self.notmuch.as_deref().unwrap_or(Path::new("notmuch"))
238 }
239}