use std::fs::{self, OpenOptions};
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ServerConfig {
pub name: String,
pub url: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub login: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_cmd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_cmd: Option<String>,
}
#[derive(Debug, Clone)]
pub enum Credentials {
Password { login: String, password: String },
Token(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Open {
pub image: String,
pub video: String,
pub browser: String,
pub text: String,
pub other: String,
}
impl Default for Open {
fn default() -> Self {
let any = || "xdg-open".to_owned();
Self {
image: any(),
video: any(),
browser: any(),
text: any(),
other: any(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub editor: Option<String>,
pub copy: Option<String>,
pub open: Open,
}
impl Settings {
pub fn editor(&self, environment: Option<&str>) -> String {
let chosen = self.editor.as_deref().filter(|e| !e.is_empty());
let fallback = environment.filter(|e| !e.is_empty());
chosen.or(fallback).unwrap_or("vi").to_owned()
}
}
pub fn settings_path() -> Result<PathBuf> {
let dirs = ProjectDirs::from("", "", "mm").context("no home directory")?;
Ok(dirs.config_dir().join("config.toml"))
}
pub fn load_settings(path: &Path) -> Result<Settings> {
match fs::read_to_string(path) {
Ok(text) => toml::from_str(&text).with_context(|| format!("parsing {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct File {
#[serde(default, rename = "server")]
servers: Vec<ServerConfig>,
}
impl ServerConfig {
pub fn credentials(&self) -> Result<Credentials> {
let secrets = [
&self.password,
&self.password_cmd,
&self.token,
&self.token_cmd,
];
if secrets.iter().filter(|s| s.is_some()).count() != 1 {
bail!(
"server {}: set exactly one of password, password_cmd, token, token_cmd",
self.name
);
}
match (
&self.password,
&self.password_cmd,
&self.token,
&self.token_cmd,
) {
(Some(password), ..) => self.password_of(password.clone()),
(_, Some(cmd), ..) => self.password_of(run(cmd)?),
(_, _, Some(token), _) => Ok(Credentials::Token(token.clone())),
(.., Some(cmd)) => Ok(Credentials::Token(run(cmd)?)),
_ => bail!("server {}: no credentials", self.name),
}
}
fn password_of(&self, password: String) -> Result<Credentials> {
if self.login.is_empty() {
bail!("server {}: a password needs a login", self.name);
}
Ok(Credentials::Password {
login: self.login.clone(),
password,
})
}
}
fn run(cmd: &str) -> Result<String> {
let out = Command::new("sh")
.args(["-c", cmd])
.output()
.context("running credential command")?;
if !out.status.success() {
bail!("credential command failed");
}
Ok(String::from_utf8(out.stdout)
.context("credential command output is not UTF-8")?
.trim_end()
.to_owned())
}
pub fn default_path() -> Result<PathBuf> {
let dirs = ProjectDirs::from("", "", "mm").context("no home directory")?;
Ok(dirs.config_dir().join("servers.toml"))
}
pub fn load(path: &Path) -> Result<Vec<ServerConfig>> {
match fs::read_to_string(path) {
Ok(text) => Ok(toml::from_str::<File>(&text)
.with_context(|| format!("parsing {}", path.display()))?
.servers),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
pub fn save(path: &Path, servers: &[ServerConfig]) -> Result<()> {
let text = toml::to_string(&File {
servers: servers.to_vec(),
})?;
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)?;
file.write_all(text.as_bytes())?;
fs::rename(&tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::os::unix::fs::PermissionsExt;
use super::*;
fn server() -> ServerConfig {
ServerConfig {
name: "work".into(),
url: "https://chat.example.org".into(),
login: "paul".into(),
password: Some("hunter2".into()),
password_cmd: None,
token: None,
token_cmd: None,
}
}
#[test]
fn round_trip_keeps_owner_only_mode() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mm/servers.toml");
save(&path, &[server()]).unwrap();
assert_eq!(load(&path).unwrap(), vec![server()]);
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn missing_file_is_empty() {
let dir = tempfile::tempdir().unwrap();
assert!(load(&dir.path().join("none.toml")).unwrap().is_empty());
}
#[test]
fn two_secrets_are_rejected() {
let mut s = server();
s.password_cmd = Some("echo x".into());
assert!(s.credentials().is_err());
}
#[test]
fn password_cmd_is_run() {
let mut s = server();
s.password = None;
s.password_cmd = Some("echo secret".into());
match s.credentials().unwrap() {
Credentials::Password { password, .. } => assert_eq!(password, "secret"),
Credentials::Token(_) => panic!("expected a password"),
}
}
#[test]
fn token_needs_no_login() {
let s = ServerConfig {
login: String::new(),
password: None,
token: Some("abc".into()),
..server()
};
assert!(matches!(s.credentials().unwrap(), Credentials::Token(t) if t == "abc"));
}
#[test]
fn settings_default_to_xdg_open_and_the_environment_editor() {
let dir = tempfile::tempdir().unwrap();
let settings = load_settings(&dir.path().join("none.toml")).unwrap();
assert_eq!(settings.open, Open::default());
assert_eq!(settings.open.image, "xdg-open");
assert_eq!(settings.editor(Some("nano")), "nano");
assert_eq!(settings.editor(None), "vi");
assert_eq!(settings.editor(Some("")), "vi");
}
#[test]
fn a_partial_file_keeps_the_rest_and_the_editor_setting_wins() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(
&path,
"editor = \"vi\"\n[open]\nimage = \"nsxiv\"\ntext = \"nvim -R\"\n",
)
.unwrap();
let settings = load_settings(&path).unwrap();
assert_eq!(settings.open.image, "nsxiv");
assert_eq!(settings.open.text, "nvim -R");
assert_eq!(settings.open.video, "xdg-open");
assert_eq!(settings.editor(Some("nano")), "vi");
assert_eq!(settings.copy, None);
}
#[test]
fn a_typo_or_a_broken_file_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "edtor = \"vi\"\n").unwrap();
assert!(load_settings(&path).is_err());
fs::write(&path, "editor = \n").unwrap();
assert!(load_settings(&path).is_err());
}
}