fluidattacks-core 0.19.0

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

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 session stored for `key`, or `None` when absent or unreadable.
pub fn load(key: Option<&str>) -> io::Result<Option<StoredToken>> {
    token_path(key).map_or(Ok(None), |path| load_from(&path))
}

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

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

// One file per platform, so signing in to a local instance cannot displace a
// production login. The default platform keeps the original name, so sessions that
// predate this stay valid.
fn token_file(key: Option<&str>) -> String {
    key.map_or_else(
        || TOKEN_FILE.to_owned(),
        |key| format!("oauth-{}.json", slug(key)),
    )
}

// Host and port reduced to something safe as a file name, collapsing runs so the
// result stays readable when someone looks in the config directory.
fn slug(key: &str) -> String {
    let mut out = String::with_capacity(key.len());
    for c in key.chars() {
        if c.is_ascii_alphanumeric() {
            out.push(c.to_ascii_lowercase());
        } else if !out.ends_with('-') {
            out.push('-');
        }
    }
    out.trim_matches('-').to_owned()
}

fn token_path(key: Option<&str>) -> Option<PathBuf> {
    config_dir().map(|dir| dir.join(CONFIG_SUBDIR).join(token_file(key)))
}

// 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(())
}

// Written to a sibling and renamed over the target, so a concurrent reader sees either
// the old session or the new one. Truncating in place would let it read a half-written
// file, and an unparseable file reads as not being logged in at all.
fn write_private(path: &Path, contents: &str) -> io::Result<()> {
    let temporary = temporary_beside(path);
    write_new_private(&temporary, contents)?;
    match fs::rename(&temporary, path) {
        Ok(()) => Ok(()),
        Err(err) => {
            let _ = fs::remove_file(&temporary);
            Err(err)
        }
    }
}

// Unique per process, so two of them writing at once do not share a temporary.
fn temporary_beside(path: &Path) -> PathBuf {
    let mut name = path.file_name().unwrap_or_default().to_os_string();
    name.push(format!(".{}.tmp", process::id()));
    path.with_file_name(name)
}

fn write_new_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);
    }

    // The default platform keeps the original file, so an existing login survives.
    #[test]
    fn default_platform_keeps_the_original_file_name() {
        assert_eq!(token_file(None), "oauth.json");
    }

    // Two platforms must never share a slot: a dev login displacing a production one
    // is silent and only shows up as being logged out.
    #[test]
    fn each_platform_gets_its_own_file() {
        assert_eq!(
            token_file(Some("localhost:8001")),
            "oauth-localhost-8001.json"
        );
        assert_ne!(token_file(Some("localhost:8001")), token_file(None));
    }

    #[test]
    fn slug_reduces_anything_unsafe_for_a_file_name() {
        assert_eq!(slug("App.Fluidattacks.COM"), "app-fluidattacks-com");
        assert_eq!(slug("https://x/../y"), "https-x-y");
    }

    // A reader must see either the old session or the new one, never a half-written
    // file: an unparseable file reads as not being logged in at all.
    #[test]
    fn saving_replaces_the_file_rather_than_truncating_it() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("oauth.json");
        save_at(&path, &sample()).unwrap();
        let mut replacement = sample();
        replacement.access_token = "second".to_owned();
        save_at(&path, &replacement).unwrap();
        assert_eq!(load_from(&path).unwrap(), Some(replacement));
        // The temporary is not left behind.
        let leftovers: Vec<_> = fs::read_dir(dir.path())
            .unwrap()
            .filter_map(Result::ok)
            .filter(|entry| entry.file_name().to_string_lossy().contains(".tmp"))
            .collect();
        assert!(leftovers.is_empty());
    }

    #[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));
    }
}