use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use anyhow::{Context, Result, bail};
use crate::text;
pub struct Config {
pub data_dir: PathBuf,
}
impl Config {
pub fn load() -> Result<Self> {
let data_dir = make_private_dir(&data_dir()?)?;
Ok(Self { data_dir })
}
}
pub fn read_small(path: &Path) -> std::io::Result<String> {
String::from_utf8(read_capped(path, 1 << 20)?).map_err(std::io::Error::other)
}
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);
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)
}
#[cfg(unix)]
enum Entry {
File,
Folder,
Socket,
}
#[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())
}
#[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();
}
#[cfg(unix)]
pub fn euid() -> u32 {
unsafe { libc::geteuid() }
}
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);
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"))
}
pub fn on_another_machine(path: &Path) -> bool {
matches!(
path.as_os_str().as_encoded_bytes(),
[b'/' | b'\\', b'/' | b'\\', ..]
)
}
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'\\', ..])
}
pub fn shown(path: &Path) -> String {
text::clean(&path.display().to_string())
}
#[cfg(unix)]
fn private_place(dir: &Path) -> Result<()> {
use std::os::unix::fs::MetadataExt;
let uid = euid();
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();
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(())
}
static DOTENV: OnceLock<HashMap<String, String>> = OnceLock::new();
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);
}
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());
#[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;
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();
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);
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");
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();
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");
}
}