dory/src/mailstore/filesystem.rs
Nathan Fisher c6675cb024 Create error type for Filesystem instead of using io::Error; Fix
creation of user account folder so that the entire directory has
the correct permissions, bot just the Inbox folder
2023-06-23 12:21:59 -04:00

520 lines
17 KiB
Rust

use {
super::*,
crate::{
message::Parser as MessageParser,
prelude::{ClientCertificateStore, Certificate, ParseMailboxError},
},
rustls_pemfile::{read_one, Item},
std::{
ffi::OsString,
fs::{self, File},
io::{self, BufReader, BufWriter, Write},
iter,
os::{fd::AsRawFd, unix::{fs::DirBuilderExt, prelude::OpenOptionsExt}},
path::{Path, PathBuf},
},
};
mod error;
pub use error::Error;
pub trait MultiDomain: MailStore {
type Error;
fn domains(&self) -> Result<Vec<String>, <Self as filesystem::MultiDomain>::Error>;
fn add_domain(&mut self, domain: &str) -> Result<(), <Self as filesystem::MultiDomain>::Error>;
fn remove_domain(
&mut self,
domain: &str,
) -> Result<(), <Self as filesystem::MultiDomain>::Error>;
}
/// The Filestore storage backend uses the filesystem to map folders to domains,
/// mail users and folders of messages. It can also be used as a `ClientCertificateStore`.
/// The layout of files inside a filesystem mailstore is as below.
/// ```Sh
/// test/mailstore/
/// └── misfin.example.org
/// ├── dick
/// │   ├── Inbox
/// │   │   ├── 619310.gmi
/// │   │   └── 868379.gmi
/// │   ├── blurb
/// │   └── certificate.pem
/// └── jane
/// ├── Inbox
/// │   ├── 252366.gmi
/// │   └── 654064.gmi
/// ├── blurb
/// └── certificate.pem
/// ```
/// In this made up example the Misfin server in question serves the domain
/// "misfin.example.org", which has the users "dick" and "jane", whose mailbox
/// addresses would be "dick@misfin.example.org" and "jane@misfin.example.org".
/// Within each account folder any subfolders are to be considered as mail folders,
/// with the default folder for delivering new mail being "Inbox". The "blurb"
/// file (optional) contains the display name of the user, and their client
/// certificate is stored in pem-encoded ascii armor format in "certificate.pem",
/// if the `Filesystem` is also to be used as a `ClientCertificateStore`.
///
/// The permissions on each individual account folder are set to 0o2700 at the
/// time of creation, such that the folder is read/write for the user and group
/// and any entries created within will have the same group membership. If the
/// username of the account corresponds to a group name on the system, and the
/// user which the server runs as is a member of this group, then said group will
/// own the directory. In this way the server will be able to write incoming
/// mail into the folder and the user will be able to read and manage their
/// folders and messages, without being able to see any other user's mail and
/// without the server requiring elevated privileges.
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
pub struct Filesystem {
pub path: PathBuf,
}
impl From<String> for Filesystem {
fn from(value: String) -> Self {
Self { path: value.into() }
}
}
impl From<&str> for Filesystem {
fn from(value: &str) -> Self {
Self { path: value.into() }
}
}
impl From<&Path> for Filesystem {
fn from(value: &Path) -> Self {
Self { path: value.into() }
}
}
impl From<PathBuf> for Filesystem {
fn from(value: PathBuf) -> Self {
Self { path: value }
}
}
impl From<OsString> for Filesystem {
fn from(value: OsString) -> Self {
Self { path: value.into() }
}
}
impl MailStore for Filesystem {
type Error = Error;
fn users(&self) -> Vec<Mailuser> {
let mut users = vec![];
let Ok(domains) = fs::read_dir(&self.path) else {
return users;
};
for d in domains {
let Ok(dom) = d else {
continue;
};
let name = dom.file_name().to_string_lossy().to_string();
let Ok(host) = name.parse::<Host>() else {
continue;
};
let dom_path = dom.path();
let Ok(dom_users) = fs::read_dir(&dom_path) else {
continue;
};
for u in dom_users {
let Ok(user) = u else {
continue;
};
if let Ok(ft) = user.file_type() {
if ft.is_dir() {
let username = user.file_name().to_string_lossy().to_string();
users.push(Mailuser {
username,
host: host.clone(),
});
}
}
}
}
users
}
fn serves_domain(&self, domain: &str) -> bool {
let mut path = self.path.clone();
path.push(domain);
path.exists()
}
fn has_mailuser(&self, mailuser: &str) -> bool {
if let Some((user, host)) = mailuser.rsplit_once('@') {
let mut path = self.path.clone();
if host.contains('.') && !host.contains(char::is_whitespace) {
path.push(host);
path.push(user);
return path.exists();
}
}
false
}
fn get_folder(&self, user: &str, folder: &str) -> Option<Folder> {
let Some((user, host)) = user.rsplit_once('@') else {
return None;
};
let mut path = self.path.clone();
path.push(host);
path.push(user);
path.push(folder);
let Ok(dir) = fs::read_dir(path) else {
return None;
};
let mut folder = Folder {
name: folder.to_string(),
messages: HashMap::new(),
};
dir.filter_map(Result::ok).for_each(|e| {
if let Ok(contents) = fs::read_to_string(e.path()) {
if let Some(Some(p)) = e.path().file_stem().map(|s| s.to_str()) {
let parser = MessageParser::new(p);
if let Ok(message) = parser.parse(&contents) {
folder.messages.insert(message.id.clone(), message);
}
}
}
});
Some(folder)
}
fn create_folder(&self, user: &str, folder: &str) -> Result<(), Error> {
if self.has_mailuser(user) {
let user: Mailbox = user.parse()?;
let user: Mailuser = user.into();
let mut path = self.path.clone();
path.push(&user.host.to_string());
path.push(&user.username);
path.push(folder);
fs::create_dir_all(&path)?;
}
Ok(())
}
fn get_message(&self, user: &str, folder: &str, id: &str) -> Option<Message> {
self.get_folder(user, folder)
.and_then(|f| f.messages.get(id).cloned())
}
fn add_message(
&mut self,
user: &str,
folder: &str,
message: Message,
) -> Result<(), Self::Error> {
let mb: Mailbox = user.parse()?;
let mut path = self.path.clone();
path.push(&mb.host.to_string());
path.push(&mb.username);
path.push(folder);
path.push(&message.id);
path.set_extension("gmi");
let mut fd = File::create(path)?;
write!(fd, "{message}")?;
Ok(())
}
fn delete_message(&mut self, user: &str, folder: &str, id: &str) -> Result<(), Self::Error> {
let mb: Mailbox = user.parse()?;
let mut path = self.path.clone();
path.push(&mb.host.to_string());
path.push(&mb.username);
path.push(folder);
path.push(id);
path.set_extension("gmi");
if path.exists() {
fs::remove_file(&path)?;
}
Ok(())
}
fn move_message(
&mut self,
user: &str,
id: &str,
folder1: &str,
folder2: &str,
) -> Result<(), Self::Error> {
let mb: Mailbox = user.parse()?;
let mut infile = self.path.clone();
infile.push(&mb.host.to_string());
infile.push(&mb.username);
let mut outfile = infile.clone();
infile.push(folder1);
infile.push(id);
infile.set_extension("gmi");
outfile.push(folder2);
outfile.push(id);
outfile.set_extension("gmi");
fs::copy(&infile, &outfile)?;
fs::remove_file(infile)?;
Ok(())
}
fn add_user(&mut self, user: &str) -> Result<(), Self::Error> {
let mb: Mailbox = user.parse()?;
let mut path = self.path.clone();
path.push(&mb.host.to_string());
path.push(&mb.username);
fs::create_dir_all(&path)?;
if let Some(ref blurb) = mb.blurb {
path.push("blurb");
let fd = File::options()
.create(true)
.write(true)
.truncate(true)
.mode(0o2770)
.open(&path)?;
if let Some(pw) = pw::Passwd::getpw()?
{
let groups = pw.groups()?;
if let Some(gr) = groups.iter().find(|g| g.name == mb.username) {
unsafe {
if libc::fchown(fd.as_raw_fd(), pw.uid, gr.gid) != 0 {
return Err(io::Error::last_os_error().into());
}
}
}
}
let mut writer = BufWriter::new(fd);
writer.write_all(blurb.as_bytes())?;
}
let _last = path.pop();
path.push("Inbox");
fs::create_dir_all(&path)?;
Ok(())
}
fn remove_user(&mut self, user: &str) -> bool {
let Ok(mb) = user.parse::<Mailbox>() else {
return false;
};
let mut path = self.path.clone();
path.push(&mb.host.to_string());
path.push(&mb.username);
if path.exists() {
if fs::remove_dir_all(path).is_ok() {
return true;
}
}
false
}
}
impl MultiDomain for Filesystem {
type Error = io::Error;
fn domains(&self) -> Result<Vec<String>, io::Error> {
Ok(self
.path
.read_dir()?
.filter(|x| {
if let Ok(x) = x {
if let Ok(t) = x.file_type() {
return t.is_dir();
}
}
false
})
.map(|x| x.unwrap().file_name().to_string_lossy().to_string())
.collect::<Vec<String>>())
}
fn add_domain(&mut self, domain: &str) -> Result<(), io::Error> {
let mut path = self.path.clone();
path.push(domain);
if !path.exists() {
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(path)?;
}
Ok(())
}
fn remove_domain(&mut self, domain: &str) -> Result<(), io::Error> {
let mut path = self.path.clone();
path.push(domain);
if path.exists() {
fs::remove_dir_all(path)?;
}
Ok(())
}
}
impl ClientCertificateStore for Filesystem {
fn get_certificate(&self, user: &Mailuser) -> Option<Certificate> {
let mut path = self.path.clone();
path.push(&user.host.to_string());
path.push(&user.username);
path.push("certificate.pem");
if path.exists() {
let Ok(fd) = File::open(&path) else {
return None;
};
let mut reader = BufReader::new(fd);
let mut certificate = Certificate {
der: vec![],
key: vec![],
};
for item in iter::from_fn(|| read_one(&mut reader).transpose()) {
match item {
Ok(Item::X509Certificate(cert)) => {
if certificate.der.is_empty() {
certificate.der = cert;
} else {
return None;
}
}
Ok(Item::RSAKey(key)) => {
if certificate.key.is_empty() {
certificate.key = key;
} else {
return None;
}
}
Ok(Item::PKCS8Key(key)) => {
if certificate.key.is_empty() {
certificate.key = key;
} else {
return None;
}
}
Ok(Item::ECKey(key)) => {
if certificate.key.is_empty() {
certificate.key = key;
} else {
return None;
}
}
_ => {}
}
}
if certificate.der.is_empty() || certificate.key.is_empty() {
None
} else {
Some(certificate)
}
} else {
None
}
}
fn insert_certificate(
&mut self,
user: &Mailuser,
certificate: Certificate,
) -> Option<Certificate> {
todo!()
}
fn contains_certificate(&self, user: &Mailuser) -> bool {
self.get_certificate(user).is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> Filesystem {
"test/mailstore".into()
}
#[test]
fn users() {
let users = store().users();
assert!(users
.iter()
.any(|u| u.host.to_string() == "mail.gmi.org" && u.username == "jane"));
assert!(users
.iter()
.any(|u| u.host.to_string() == "mail.gmi.org" && u.username == "dick"));
assert!(users
.iter()
.any(|u| u.host.to_string() == "misfin.example.org" && u.username == "jane"));
assert!(users
.iter()
.any(|u| u.host.to_string() == "misfin.example.org" && u.username == "dick"));
assert!(users.len() >= 4 && users.len() < 6);
}
#[test]
fn serves_domain() {
let store = store();
assert!(store.serves_domain("mail.gmi.org"));
assert!(store.serves_domain("misfin.example.org"));
assert!(!store.serves_domain("iron.maiden.org"));
}
#[test]
fn has_user() {
let store = store();
assert!(store.has_mailuser("jane@mail.gmi.org"));
assert!(store.has_mailuser("dick@mail.gmi.org"));
assert!(store.has_mailuser("jane@misfin.example.org"));
assert!(store.has_mailuser("dick@misfin.example.org"));
assert!(!store.has_mailuser("fred@mail.gmi.org"));
}
#[test]
fn get_folder() {
let store = store();
let folder = store.get_folder("jane@mail.gmi.org", "Inbox").unwrap();
let msg = folder.messages.get("867017");
assert!(msg.is_some());
let msg = folder.messages.get("488571");
assert!(msg.is_some());
}
#[test]
fn get_message() {
let msg = store()
.get_message("jane@mail.gmi.org", "Inbox", "867017")
.unwrap();
assert_eq!(msg.from.username, "april");
assert_eq!(msg.from.host.subdomain.unwrap(), "we.wear");
}
#[test]
fn add_move_delete_message() {
let msg = Message {
id: String::from("1687407844"),
from: Mailbox::from_str("suzie@thebanshees.legs").unwrap(),
senders: vec![],
recipients: vec![],
timestamp: None,
title: Some(String::from("Get a look at 'is 'orse")),
body: String::from("Gorgeous, innit?"),
};
store()
.add_message("dick@mail.gmi.org", "Inbox", msg)
.unwrap();
assert!(PathBuf::from("test/mailstore/mail.gmi.org/dick/Inbox/1687407844.gmi").exists());
store()
.move_message("dick@mail.gmi.org", "1687407844", "Inbox", "Lists")
.unwrap();
assert!(!PathBuf::from("test/mailstore/mail.gmi.org/dick/Inbox/1687407844.gmi").exists());
assert!(PathBuf::from("test/mailstore/mail.gmi.org/dick/Lists/1687407844.gmi").exists());
store()
.delete_message("dick@mail.gmi.org", "Lists", "1687407844")
.unwrap();
assert!(!PathBuf::from("test/mailstore/mail.gmi.org/dick/Lists/1687407844.gmi").exists());
}
#[test]
fn add_remove_user() {
store()
.add_user("rob@misfin.example.org Rob Zombie")
.unwrap();
assert!(store().has_mailuser("rob@misfin.example.org"));
assert!(store().remove_user("rob@misfin.example.org"));
assert!(!store().has_mailuser("rob@misfin.example.org"));
}
}