fluidattacks-core 0.11.0

Fluid Attacks Core Library
Documentation
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

const CONFIG_SUBDIR: &str = "fluidattacks";
const TOKEN_FILE: &str = "oauth.json";

/// The persisted OAuth session, stored owner-only.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoredToken {
    pub access_token: String,
    pub refresh_token: String,
    pub expires_at: u64,
    pub email: String,
}

impl StoredToken {
    /// Whether the access token should be refreshed rather than used.
    #[must_use]
    pub const fn is_access_expired(&self, now: u64, skew: u64) -> bool {
        now.saturating_add(skew) >= self.expires_at
    }
}

/// Load the stored session, or `None` when absent or unreadable.
pub fn load() -> io::Result<Option<StoredToken>> {
    token_path().map_or(Ok(None), |path| load_from(&path))
}

/// Persist the session, replacing any existing one, owner-readable only.
pub fn save(token: &StoredToken) -> io::Result<()> {
    let path =
        token_path().ok_or_else(|| io::Error::other("no per-user config directory found"))?;
    save_at(&path, token)
}

/// Remove the stored session if present; a missing file is success.
pub fn delete() -> io::Result<()> {
    token_path().map_or(Ok(()), |path| delete_at(&path))
}

fn token_path() -> Option<PathBuf> {
    config_dir().map(|dir| dir.join(CONFIG_SUBDIR).join(TOKEN_FILE))
}

// Per-user config base: XDG_CONFIG_HOME, else the platform default under HOME.
fn config_dir() -> Option<PathBuf> {
    if let Some(base) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
        return Some(PathBuf::from(base));
    }
    let home = PathBuf::from(env::var_os("HOME").filter(|value| !value.is_empty())?);
    Some(if cfg!(target_os = "macos") {
        home.join("Library").join("Application Support")
    } else {
        home.join(".config")
    })
}

fn load_from(path: &Path) -> io::Result<Option<StoredToken>> {
    let raw = match fs::read_to_string(path) {
        Ok(raw) => raw,
        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
        Err(err) => return Err(err),
    };
    match serde_json::from_str(&raw) {
        Ok(token) => Ok(Some(token)),
        Err(err) => {
            tracing::warn!(error = %err, "ignoring unreadable OAuth token store");
            Ok(None)
        }
    }
}

fn save_at(path: &Path, token: &StoredToken) -> io::Result<()> {
    if let Some(parent) = path.parent() {
        create_private_dir(parent)?;
    }
    let payload = serde_json::to_string(token).map_err(io::Error::other)?;
    write_private(path, &payload)
}

fn delete_at(path: &Path) -> io::Result<()> {
    match fs::remove_file(path) {
        Ok(()) => Ok(()),
        Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
        Err(err) => Err(err),
    }
}

fn create_private_dir(dir: &Path) -> io::Result<()> {
    fs::create_dir_all(dir)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
    }
    Ok(())
}

fn write_private(path: &Path, contents: &str) -> io::Result<()> {
    #[cfg(unix)]
    {
        use std::io::Write as _;
        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
        // Create and keep the file owner-only (0600).
        let mut file = fs::OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .mode(0o600)
            .open(path)?;
        file.write_all(contents.as_bytes())?;
        file.set_permissions(fs::Permissions::from_mode(0o600))?;
    }
    #[cfg(not(unix))]
    {
        fs::write(path, contents)?;
    }
    Ok(())
}

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

    fn sample() -> StoredToken {
        StoredToken {
            access_token: "acc".to_owned(),
            refresh_token: "ref".to_owned(),
            expires_at: 1000,
            email: "u@fluidattacks.com".to_owned(),
        }
    }

    #[test]
    fn missing_file_loads_as_none() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("nested").join("oauth.json");
        assert_eq!(load_from(&path).unwrap(), None);
    }

    #[test]
    fn save_then_load_round_trips() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("nested").join("oauth.json");
        save_at(&path, &sample()).unwrap();
        assert_eq!(load_from(&path).unwrap(), Some(sample()));
    }

    #[test]
    fn corrupt_file_loads_as_none() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("oauth.json");
        fs::write(&path, "{ not json").unwrap();
        assert_eq!(load_from(&path).unwrap(), None);
    }

    #[test]
    fn delete_is_idempotent() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("oauth.json");
        save_at(&path, &sample()).unwrap();
        delete_at(&path).unwrap();
        delete_at(&path).unwrap();
        assert_eq!(load_from(&path).unwrap(), None);
    }

    #[cfg(unix)]
    #[test]
    fn saved_file_is_owner_only() {
        use std::os::unix::fs::PermissionsExt;
        let dir = tempdir().unwrap();
        let path = dir.path().join("oauth.json");
        save_at(&path, &sample()).unwrap();
        let mode = fs::metadata(&path).unwrap().permissions().mode();
        assert_eq!(mode & 0o777, 0o600);
    }

    #[test]
    fn expiry_accounts_for_skew() {
        let token = sample();
        assert!(!token.is_access_expired(900, 30));
        assert!(token.is_access_expired(980, 30));
        assert!(token.is_access_expired(1000, 0));
    }
}