asfml-core 0.1.1

Core library for reading Apache Pony Mail archives
Documentation
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::client::PonyMailClient;
use crate::error::{Error, Result};
use crate::models::{ListAddress, Session};

const SESSION_FILE_ENV: &str = "ASFML_SESSION_FILE";

pub fn store_session(session: &Session) -> Result<()> {
    store_file_session(session)
}

pub fn load_session() -> Result<Session> {
    load_file_session()
}

pub fn clear_session() -> Result<()> {
    clear_file_session()
}

pub fn validate_session(client: &PonyMailClient, list: Option<&ListAddress>) -> Result<String> {
    let prefs = client.preferences()?;
    let login = prefs
        .login
        .credentials
        .as_ref()
        .ok_or(Error::InvalidSession)?;
    if let Some(list) = list {
        if !prefs.has_list_access(list) {
            return Err(Error::NoListAccess(list.to_string()));
        }
    }

    let name = login
        .fullname
        .as_deref()
        .or(login.name.as_deref())
        .unwrap_or(&login.uid);
    Ok(match login.email {
        Some(ref email) if !email.is_empty() => format!("{name} <{email}>"),
        _ => name.to_string(),
    })
}

fn store_file_session(session: &Session) -> Result<()> {
    let path = session_file_path()?;
    store_file_session_at(&path, session)
}

fn load_file_session() -> Result<Session> {
    let path = session_file_path()?;
    load_file_session_at(&path)
}

fn clear_file_session() -> Result<()> {
    let path = session_file_path()?;
    match fs::remove_file(path) {
        Ok(()) => Ok(()),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(err) => Err(err.into()),
    }
}

fn session_file_path() -> Result<PathBuf> {
    if let Some(path) = env::var_os(SESSION_FILE_ENV).filter(|value| !value.is_empty()) {
        return Ok(PathBuf::from(path));
    }

    Ok(platform_config_dir()?.join("asfml").join("session.json"))
}

#[cfg(target_os = "windows")]
fn platform_config_dir() -> Result<PathBuf> {
    if let Some(appdata) = env::var_os("APPDATA") {
        return Ok(PathBuf::from(appdata));
    }
    if let Some(home) = env::var_os("USERPROFILE") {
        return Ok(PathBuf::from(home).join("AppData").join("Roaming"));
    }
    Err(Error::ConfigDirUnavailable)
}

#[cfg(target_os = "macos")]
fn platform_config_dir() -> Result<PathBuf> {
    env::var_os("HOME")
        .map(|home| {
            PathBuf::from(home)
                .join("Library")
                .join("Application Support")
        })
        .ok_or(Error::ConfigDirUnavailable)
}

#[cfg(all(unix, not(target_os = "macos")))]
fn platform_config_dir() -> Result<PathBuf> {
    env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
        .ok_or(Error::ConfigDirUnavailable)
}

#[cfg(not(any(unix, target_os = "windows")))]
fn platform_config_dir() -> Result<PathBuf> {
    Err(Error::ConfigDirUnavailable)
}

fn store_file_session_at(path: &Path, session: &Session) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }

    let stored = StoredSession {
        ponymail: session.ponymail.clone(),
    };
    let data = serde_json::to_vec_pretty(&stored)?;
    let temp = path.with_file_name(format!(
        ".{}.tmp",
        path.file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("session.json")
    ));

    write_private_file(&temp, &data)?;
    fs::rename(temp, path)?;
    set_private_permissions(path)?;
    Ok(())
}

fn load_file_session_at(path: &Path) -> Result<Session> {
    let data = match fs::read(path) {
        Ok(data) => data,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Err(Error::NoSession),
        Err(err) => return Err(err.into()),
    };
    let stored: StoredSession = serde_json::from_slice(&data)?;
    Ok(Session {
        ponymail: stored.ponymail,
    })
}

#[cfg(unix)]
fn write_private_file(path: &Path, data: &[u8]) -> Result<()> {
    use std::fs::OpenOptions;
    use std::io::Write;
    use std::os::unix::fs::OpenOptionsExt;

    let mut file = OpenOptions::new()
        .create(true)
        .truncate(true)
        .write(true)
        .mode(0o600)
        .open(path)?;
    file.write_all(data)?;
    file.sync_all()?;
    Ok(())
}

#[cfg(not(unix))]
fn write_private_file(path: &Path, data: &[u8]) -> Result<()> {
    fs::write(path, data)?;
    Ok(())
}

#[cfg(unix)]
fn set_private_permissions(path: &Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;

    fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
    Ok(())
}

#[cfg(not(unix))]
fn set_private_permissions(_path: &Path) -> Result<()> {
    Ok(())
}

#[derive(Debug, Deserialize, Serialize)]
struct StoredSession {
    ponymail: String,
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::PathBuf;

    use crate::models::Session;

    use super::{load_file_session_at, store_file_session_at};

    #[test]
    fn store_and_load_file_session() {
        let path = unique_test_path();
        let session = Session {
            ponymail: "abc-123".to_string(),
        };

        store_file_session_at(&path, &session).unwrap();
        let loaded = load_file_session_at(&path).unwrap();
        assert_eq!(loaded.ponymail, session.ponymail);

        cleanup_test_path(path);
    }

    #[cfg(unix)]
    #[test]
    fn file_session_uses_private_permissions() {
        use std::os::unix::fs::PermissionsExt;

        let path = unique_test_path();
        let session = Session {
            ponymail: "abc-123".to_string(),
        };

        store_file_session_at(&path, &session).unwrap();
        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
        assert_eq!(mode, 0o600);

        cleanup_test_path(path);
    }

    fn unique_test_path() -> PathBuf {
        static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);

        let mut path = std::env::temp_dir();
        path.push(format!(
            "asfml-session-{}-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos(),
            NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
        ));
        fs::create_dir_all(&path).unwrap();
        path.push("session.json");
        path
    }

    fn cleanup_test_path(path: PathBuf) {
        if let Some(parent) = path.parent() {
            let _ = fs::remove_dir_all(parent);
        }
    }
}