butterfly-bot 0.7.0

Butterfly Bot is an opinionated personal-ops AI assistant built for people who want results, not setup overhead.
Documentation
use crate::error::{ButterflyBotError, Result};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use rand::rngs::SysRng;
use rand::TryRng;
use std::path::PathBuf;

const SERVICE: &str = "butterfly-bot";
const DAEMON_TOKEN_FILE: &str = "daemon_auth_token";

fn daemon_auth_token_file() -> PathBuf {
    crate::runtime_paths::app_root()
        .join("secrets")
        .join(DAEMON_TOKEN_FILE)
}

fn secret_fallback_file(name: &str) -> PathBuf {
    let encoded = URL_SAFE_NO_PAD.encode(name.as_bytes());
    crate::runtime_paths::app_root()
        .join("secrets")
        .join("fallback")
        .join(encoded)
}

fn read_secret_fallback_file(name: &str) -> Option<String> {
    let path = secret_fallback_file(name);
    let raw = std::fs::read_to_string(path).ok()?;
    let trimmed = raw.trim().to_string();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed)
    }
}

fn write_secret_fallback_file(name: &str, value: &str) {
    let path = secret_fallback_file(name);
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    let _ = std::fs::write(&path, value);
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
    }
}

fn read_daemon_auth_token_file() -> Option<String> {
    let path = daemon_auth_token_file();
    let raw = std::fs::read_to_string(path).ok()?;
    let trimmed = raw.trim().to_string();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed)
    }
}

fn write_daemon_auth_token_file(token: &str) {
    let path = daemon_auth_token_file();
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    let _ = std::fs::write(&path, token);
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
    }
}

fn keyring_backend_unavailable(message: &str) -> bool {
    let message = message.to_ascii_lowercase();
    message.contains("dbus")
        || message.contains("secret service")
        || message.contains("keyring")
        || message.contains("message recipient disconnected")
        || message.contains("no such interface")
        || message.contains("service unknown")
        || message.contains("backend not available")
        || message.contains("platform secure storage failure")
        || message.contains("keychain")
        || message.contains("user interaction is not allowed")
}

fn env_token() -> Option<String> {
    std::env::var("BUTTERFLY_BOT_TOKEN").ok().and_then(|token| {
        let trimmed = token.trim().to_string();
        if trimmed.is_empty() {
            None
        } else {
            Some(trimmed)
        }
    })
}

fn keyring_disabled() -> bool {
    std::env::var("BUTTERFLY_BOT_DISABLE_KEYRING")
        .ok()
        .map(|value| {
            matches!(
                value.trim().to_ascii_lowercase().as_str(),
                "1" | "true" | "yes" | "on"
            )
        })
        .unwrap_or(false)
}

pub fn set_secret(name: &str, value: &str) -> Result<()> {
    if keyring_disabled() {
        write_secret_fallback_file(name, value);
        return Ok(());
    }
    let entry = keyring::Entry::new(SERVICE, name)
        .map_err(|e| ButterflyBotError::Runtime(e.to_string()))?;
    if let Err(err) = entry.set_password(value) {
        if keyring_backend_unavailable(&err.to_string()) {
            write_secret_fallback_file(name, value);
            return Ok(());
        }
        return Err(ButterflyBotError::Runtime(err.to_string()));
    }
    Ok(())
}

pub fn set_secret_required(name: &str, value: &str) -> Result<()> {
    if keyring_disabled() {
        write_secret_fallback_file(name, value);
        return Ok(());
    }
    let entry = keyring::Entry::new(SERVICE, name)
        .map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))?;
    if let Err(err) = entry.set_password(value) {
        if keyring_backend_unavailable(&err.to_string()) {
            write_secret_fallback_file(name, value);
            return Ok(());
        }
        return Err(ButterflyBotError::SecurityStorage(err.to_string()));
    }
    Ok(())
}

pub fn get_secret(name: &str) -> Result<Option<String>> {
    if keyring_disabled() {
        return Ok(read_secret_fallback_file(name));
    }
    let entry = keyring::Entry::new(SERVICE, name)
        .map_err(|e| ButterflyBotError::Runtime(e.to_string()))?;
    match entry.get_password() {
        Ok(value) => Ok(Some(value)),
        Err(keyring::Error::NoEntry) => Ok(read_secret_fallback_file(name)),
        Err(err) => {
            if keyring_backend_unavailable(&err.to_string()) {
                return Ok(read_secret_fallback_file(name));
            }
            Err(ButterflyBotError::Runtime(err.to_string()))
        }
    }
}

pub fn ensure_daemon_auth_token() -> Result<String> {
    if let Some(token) = env_token() {
        return Ok(token);
    }

    if let Some(token) = get_secret("daemon_auth_token")? {
        let trimmed = token.trim().to_string();
        if !trimmed.is_empty() {
            std::env::set_var("BUTTERFLY_BOT_TOKEN", &trimmed);
            return Ok(trimmed);
        }
    }

    if let Some(token) = read_daemon_auth_token_file() {
        std::env::set_var("BUTTERFLY_BOT_TOKEN", &token);
        return Ok(token);
    }

    let mut bytes = [0u8; 32];
    let mut rng = SysRng;
    rng.try_fill_bytes(&mut bytes)
        .map_err(|e| ButterflyBotError::Runtime(e.to_string()))?;
    let generated = URL_SAFE_NO_PAD.encode(bytes);
    let _ = set_secret("daemon_auth_token", &generated);
    write_daemon_auth_token_file(&generated);
    std::env::set_var("BUTTERFLY_BOT_TOKEN", &generated);
    Ok(generated)
}