tuimail 0.2.0

Email in your terminal: a vim-style IMAP, POP3 and SMTP client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;

use anyhow::{Context, Result, bail};

use crate::text;

/// Settings read from the environment, or, in development builds, from
/// `.env` in the source folder ([`load_dotenv`]).
pub struct Config {
    /// Where your accounts, the mail cache (`tuimail.db`) and
    /// `settings.toml` are kept.
    pub data_dir: PathBuf,
}

impl Config {
    pub fn load() -> Result<Self> {
        let data_dir = make_private_dir(&data_dir()?)?;
        Ok(Self { data_dir })
    }
}

/// Makes the data folder, or checks the one that's there, so only you can
/// reach it, and gives its path with links resolved. In this order, so
/// nothing is created or changed somewhere unsafe: the folders above it may
/// be changed only by you or the system ([`private_place`]); it's then
/// made, mode 0700, or found to be a real folder (not a link) that's yours;
/// then set to 0700 again, and on macOS cleared of access rules inherited
/// from above, which would let others in whatever the mode says.
/// Reads a file tuimail keeps, refusing anything that isn't a plain file of
/// a sane size, so a link to `/dev/zero` or a pipe can't hang the start.
pub fn read_small(path: &Path) -> std::io::Result<String> {
    String::from_utf8(read_capped(path, 1 << 20)?).map_err(std::io::Error::other)
}

/// The bytes of `path` if it's a plain file of at most `cap` bytes: checked
/// and read through one open file (nothing can be put in its place between
/// the two), never waiting on a pipe, and never more than `cap` read.
pub fn read_capped(path: &Path, cap: u64) -> std::io::Result<Vec<u8>> {
    use std::io::Read;
    let mut options = std::fs::OpenOptions::new();
    options.read(true);
    #[cfg(unix)]
    std::os::unix::fs::OpenOptionsExt::custom_flags(&mut options, libc::O_NONBLOCK);
    let file = options.open(path)?;
    let meta = file.metadata()?;
    let too_big = || std::io::Error::other(format!("not a plain file under {} KB", cap >> 10));
    if !meta.is_file() || meta.len() > cap {
        return Err(too_big());
    }
    let mut bytes = Vec::new();
    file.take(cap + 1).read_to_end(&mut bytes)?;
    if bytes.len() as u64 > cap {
        return Err(too_big());
    }
    Ok(bytes)
}

#[cfg(unix)]
fn make_private_dir(dir: &Path) -> Result<PathBuf> {
    use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
    let shown = shown(dir);
    let (Some(parent), Some(name)) = (dir.parent(), dir.file_name()) else {
        bail!("{shown} isn't a folder tuimail can use");
    };
    std::fs::create_dir_all(parent).with_context(|| format!("cannot create {shown}"))?;
    let parent = std::fs::canonicalize(parent).with_context(|| format!("cannot read {shown}"))?;
    private_place(&parent)?;
    let dir = parent.join(name);
    match std::fs::DirBuilder::new().mode(0o700).create(&dir) {
        Err(e) if e.kind() != std::io::ErrorKind::AlreadyExists => {
            return Err(e).with_context(|| format!("cannot create {shown}"));
        }
        _ => {}
    }
    let meta = std::fs::symlink_metadata(&dir).with_context(|| format!("cannot read {shown}"))?;
    if meta.file_type().is_symlink() || !meta.is_dir() || meta.uid() != euid() {
        bail!("{shown} isn't a folder of yours, so it's no place for your data");
    }
    std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
        .with_context(|| format!("cannot protect {shown}"))?;
    #[cfg(target_os = "macos")]
    clear_acls(&dir);
    // What's in it must be yours alone too: a link or file another account
    // left while it could still write here would stay in use (a link at
    // accounts.toml choosing the program that gives your password).
    // So must the agents' sockets: one left there would get every agent's
    // questions, and answer them.
    use std::os::unix::fs::FileTypeExt;
    for (entry, kind) in [
        ("accounts.toml", Entry::File),
        ("settings.toml", Entry::File),
        ("tuimail.db", Entry::File),
        ("tuimail.db-wal", Entry::File),
        ("tuimail.db-shm", Entry::File),
        ("drafts", Entry::Folder),
        ("opened", Entry::Folder),
        (crate::agent::SOCKET, Entry::Socket),
        (crate::agent::DEMO_SOCKET, Entry::Socket),
    ] {
        let Ok(meta) = std::fs::symlink_metadata(dir.join(entry)) else {
            continue;
        };
        let kind = match kind {
            Entry::Folder => meta.is_dir(),
            Entry::File => meta.is_file() && meta.nlink() == 1,
            Entry::Socket => meta.file_type().is_socket() && meta.nlink() == 1,
        };
        if meta.file_type().is_symlink() || !kind || meta.uid() != euid() {
            bail!(
                "{entry} in {shown} isn't yours alone (a link, or another account's): move it away"
            );
        }
    }
    Ok(dir)
}

/// What an entry of the data folder must be.
#[cfg(unix)]
enum Entry {
    File,
    Folder,
    Socket,
}

/// On Windows, a folder set with `TM_DATA_DIR` must be in your own profile,
/// whose permissions are yours alone; tuimail doesn't set Windows ACLs.
#[cfg(not(unix))]
fn make_private_dir(dir: &Path) -> Result<PathBuf> {
    let shown = shown(dir);
    if var("TM_DATA_DIR").is_some() {
        let mine = ["USERPROFILE", "LOCALAPPDATA"]
            .iter()
            .filter_map(std::env::var_os)
            .any(|base| dir.starts_with(base));
        if !mine {
            bail!("TM_DATA_DIR must be a folder in your user profile ({shown} isn't)");
        }
    }
    std::fs::create_dir_all(dir).with_context(|| format!("cannot create {shown}"))?;
    Ok(dir.to_path_buf())
}

/// Removes access rules (ACLs) from `dir` and everything in it: a folder
/// made inside one with an inherited rule for everyone gets that rule too,
/// and mode 0700 doesn't override it. `/bin/chmod -N` by its full path.
#[cfg(target_os = "macos")]
fn clear_acls(dir: &Path) {
    let _ = std::process::Command::new("/bin/chmod")
        .args(["-R", "-N"])
        .arg(dir)
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status();
}

/// The user tuimail runs as.
#[cfg(unix)]
pub fn euid() -> u32 {
    // SAFETY: geteuid can't fail and touches no memory.
    unsafe { libc::geteuid() }
}

/// `TM_DATA_DIR`, else the platform's place for app data: `~/Library/Application
/// Support/tuimail` on macOS, `~/.local/share/tuimail` on Linux, `%LOCALAPPDATA%\tuimail`
/// on Windows. The same wherever the command is run from.
pub fn data_dir() -> Result<PathBuf> {
    data_dir_from(var("TM_DATA_DIR"))
}

fn data_dir_from(set: Option<String>) -> Result<PathBuf> {
    if let Some(dir) = set {
        let dir = PathBuf::from(dir);
        // On Windows even creating a folder on another machine hands that
        // server your login hash. A relative path would depend on where
        // tuimail is started: a cloned folder could hand you its own data.
        let elsewhere = maybe_remote(&dir) || !dir.is_absolute();
        if elsewhere {
            bail!(
                "TM_DATA_DIR must be a full path to a folder on this computer \
                 (with a drive letter on Windows)"
            );
        }
        return Ok(dir);
    }
    let base = dirs::data_local_dir().context("no home directory found; set TM_DATA_DIR")?;
    Ok(base.join("tuimail"))
}

/// Whether `path` names a file on another machine: `\\server\share\…` (or
/// `//server/share/…`) on Windows. Looking at such a path connects to that
/// server and hands it the user's Windows login hash.
pub fn on_another_machine(path: &Path) -> bool {
    matches!(
        path.as_os_str().as_encoded_bytes(),
        [b'/' | b'\\', b'/' | b'\\', ..]
    )
}

/// Whether `path` may not be on this computer's own drives: on Windows, any
/// path that starts with a separator, since besides `\\server\share` the NT
/// spellings (`\??\UNC\server\…`) reach the network too, and only a drive
/// letter is surely local. Elsewhere, [`on_another_machine`].
pub fn maybe_remote(path: &Path) -> bool {
    on_another_machine(path) || (cfg!(windows) && starts_with_separator(path))
}

fn starts_with_separator(path: &Path) -> bool {
    matches!(path.as_os_str().as_encoded_bytes(), [b'/' | b'\\', ..])
}

/// A path as it can be printed: it may come from the environment.
pub fn shown(path: &Path) -> String {
    text::clean(&path.display().to_string())
}

/// Checks that only you or the system can change `dir` and the folders
/// above it: otherwise another account could rename the data folder away
/// and put its own in its place. Folders anyone may write to, like `/tmp`,
/// are fine when only an entry's owner can move it (the sticky bit) and the
/// system owns them. Owners are compared with the user tuimail runs as,
/// not with the folder's own owner.
#[cfg(unix)]
fn private_place(dir: &Path) -> Result<()> {
    use std::os::unix::fs::MetadataExt;
    let uid = euid();
    // SAFETY: getgid can't fail and touches no memory.
    let gid = unsafe { libc::getgid() };
    let unsafe_place = |what: &Path| {
        anyhow::anyhow!(
            "{} can be changed by other users, so it's no place for your data; \
             set TM_DATA_DIR somewhere in your home folder",
            shown(what)
        )
    };
    for folder in std::fs::canonicalize(dir)?.ancestors() {
        let meta = std::fs::metadata(folder)?;
        let mode = meta.mode();
        // Your own group is only yours on Linux (user private groups); on
        // macOS every account is in `staff`.
        let group_is_others = cfg!(target_os = "macos") || meta.gid() != gid;
        let others_write = mode & 0o002 != 0 || (mode & 0o020 != 0 && group_is_others);
        let sticky = mode & 0o1000 != 0;
        let owner_ok = meta.uid() == uid || meta.uid() == 0;
        if !owner_ok || (others_write && !(sticky && meta.uid() == 0)) {
            return Err(unsafe_place(folder));
        }
    }
    Ok(())
}

/// `TM_*` settings from `.env` in the source folder, for development.
static DOTENV: OnceLock<HashMap<String, String>> = OnceLock::new();

/// Reads `.env` from tuimail's own source folder (where it was built), not
/// the working directory, and keeps only its `TM_*` keys, without touching
/// the process environment. `TM_DATA_DIR` picks where your mail and
/// passwords' settings are kept, so a `.env` in whatever folder tuimail is
/// started from must never count. Only development builds read it.
pub fn load_dotenv() {
    if !cfg!(debug_assertions) {
        let _ = DOTENV.set(HashMap::new());
        return;
    }
    let vars = dotenvy::from_path_iter(concat!(env!("CARGO_MANIFEST_DIR"), "/.env"))
        .into_iter()
        .flatten()
        .filter_map(Result::ok)
        .filter(|(key, _)| key.starts_with("TM_"))
        .collect();
    let _ = DOTENV.set(vars);
}

/// A variable's trimmed value from the environment, else from `.env`, or
/// `None` if it's unset or blank.
pub fn var(name: &str) -> Option<String> {
    let value = std::env::var(name)
        .ok()
        .or_else(|| DOTENV.get()?.get(name).cloned())?;
    let value = value.trim();
    (!value.is_empty()).then(|| value.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn small_files_are_read_whole_and_nothing_else() {
        let dir = std::env::temp_dir().join(format!("tuimail-capped-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let small = dir.join("small");
        std::fs::write(&small, b"12345").unwrap();
        assert_eq!(read_capped(&small, 5).unwrap(), b"12345");
        assert!(read_capped(&small, 4).is_err());
        assert!(read_capped(&dir, 1 << 20).is_err());
        // A pipe in a file's place is refused, not waited on.
        #[cfg(unix)]
        {
            let fifo = dir.join("fifo");
            let c = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap();
            assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o600) }, 0);
            assert!(read_capped(&fifo, 1 << 20).is_err());
        }
        std::fs::remove_dir_all(&dir).unwrap();
    }

    #[test]
    fn a_data_folder_on_another_machine_is_refused() {
        for dir in ["//evil.example/s/tt", "\\\\evil.example\\s\\tt"] {
            assert!(data_dir_from(Some(dir.into())).is_err(), "{dir}");
        }
        assert!(
            data_dir_from(Some("./.tuimail".into())).is_err(),
            "relative"
        );
        assert!(data_dir_from(Some(".tuimail".into())).is_err(), "relative");
    }

    #[test]
    fn nt_spellings_of_a_share_count_as_maybe_remote_on_windows() {
        for path in [
            "\\??\\UNC\\evil.example\\s\\subscriptions.csv",
            "\\\\?\\UNC\\evil.example\\s\\x.csv",
            "\\\\evil.example\\s\\x.csv",
            "/??/UNC/evil.example/s/x.csv",
        ] {
            assert!(starts_with_separator(Path::new(path)), "{path}");
        }
        assert!(!starts_with_separator(Path::new("C:\\Users\\me\\x.csv")));
        assert!(!on_another_machine(Path::new("\\??\\UNC\\h\\s\\x.csv")));
        assert_eq!(
            maybe_remote(Path::new("\\??\\UNC\\h\\s\\x.csv")),
            cfg!(windows),
            "refused on Windows, a plain (odd) local path elsewhere"
        );
    }

    #[cfg(unix)]
    #[test]
    fn a_data_folder_others_could_swap_out_is_refused() {
        use std::os::unix::fs::PermissionsExt;
        // SAFETY: geteuid can't fail and touches no memory.
        if unsafe { libc::geteuid() } == 0 {
            return;
        }
        let parent = std::env::temp_dir().join(format!("tuimail-place-{}", std::process::id()));
        let dir = parent.join("tt");
        std::fs::create_dir_all(&dir).unwrap();
        let mode = |path: &Path, mode| {
            std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap()
        };
        mode(&parent, 0o777);
        assert!(private_place(&dir).is_err(), "anyone could rename it");
        mode(&parent, 0o1777);
        assert!(private_place(&dir).is_err(), "sticky, but not the system's");
        mode(&parent, 0o755);
        private_place(&dir).unwrap();
        // A data folder that's someone else's (the system's here), however
        // safe the folders above it.
        assert!(make_private_dir(Path::new("/usr/share")).is_err());
        let made = make_private_dir(&parent.join("made")).unwrap();
        let mode_of = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
        assert_eq!(mode_of(&made), 0o700);
        // A link in its place is refused, not followed.
        let target = parent.join("elsewhere");
        std::fs::create_dir(&target).unwrap();
        std::os::unix::fs::symlink(&target, parent.join("link")).unwrap();
        assert!(make_private_dir(&parent.join("link")).is_err());
        assert_eq!(mode_of(&target), 0o755, "not changed through the link");
        // What's left in a data folder that was open to others: a link at
        // accounts.toml, a second name for settings.toml.
        let outside = parent.join("outside.toml");
        std::fs::write(&outside, "").unwrap();
        std::os::unix::fs::symlink(&outside, made.join("accounts.toml")).unwrap();
        assert!(make_private_dir(&made).is_err(), "a link is refused");
        std::fs::remove_file(made.join("accounts.toml")).unwrap();
        std::fs::hard_link(&outside, made.join("settings.toml")).unwrap();
        assert!(make_private_dir(&made).is_err(), "a second name is refused");
        std::fs::remove_file(made.join("settings.toml")).unwrap();
        // The agents' sockets: a link to someone else's, or not a socket.
        for socket in [crate::agent::SOCKET, crate::agent::DEMO_SOCKET] {
            std::os::unix::fs::symlink(&outside, made.join(socket)).unwrap();
            assert!(
                make_private_dir(&made).is_err(),
                "a link at {socket} is refused"
            );
            std::fs::remove_file(made.join(socket)).unwrap();
            std::fs::write(made.join(socket), "").unwrap();
            assert!(
                make_private_dir(&made).is_err(),
                "a file at {socket} is refused"
            );
            std::fs::remove_file(made.join(socket)).unwrap();
        }
        std::fs::write(made.join("accounts.toml"), "").unwrap();
        make_private_dir(&made).unwrap();
        std::fs::remove_dir_all(&parent).unwrap();
    }

    #[test]
    fn paths_are_printed_without_control_characters() {
        assert_eq!(shown(Path::new("/tmp/a\u{1b}]0;x\u{7}b")), "/tmp/a]0;xb");
    }
}