butterfly-bot 0.6.0

Butterfly Bot is an opinionated personal-ops AI assistant built for people who want results, not setup overhead.
Documentation
use std::env;
use std::path::PathBuf;
use std::sync::OnceLock;

use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use diesel::sqlite::SqliteConnection;
use diesel_async::sync_connection_wrapper::SyncConnectionWrapper;
use rand::rngs::SysRng;
use rand::TryRng;

use crate::error::{ButterflyBotError, Result};

const DB_KEY_NAME: &str = "db_encryption_key";
const DB_KEY_FILE_NAME: &str = "db_encryption_key";

fn tune_sqlcipher_log_level_sync(conn: &mut SqliteConnection) {
    if let Err(err) =
        diesel::RunQueryDsl::execute(diesel::sql_query("PRAGMA cipher_log_level = ERROR"), conn)
    {
        tracing::debug!("Unable to set SQLCipher log level (sync): {}", err);
    }
}

async fn tune_sqlcipher_log_level_async(conn: &mut SyncConnectionWrapper<SqliteConnection>) {
    if let Err(err) = diesel_async::RunQueryDsl::execute(
        diesel::sql_query("PRAGMA cipher_log_level = ERROR"),
        conn,
    )
    .await
    {
        tracing::debug!("Unable to set SQLCipher log level (async): {}", err);
    }
}

fn log_sqlcipher_key_source_once(source: &str) {
    static LOGGED: OnceLock<()> = OnceLock::new();
    if LOGGED.set(()).is_ok() {
        tracing::info!(db_key_source = source, "Resolved SQLCipher key source");
    }
}

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

fn load_sqlcipher_key_from_file() -> Result<Option<String>> {
    let path = db_key_file_path();
    if !path.exists() {
        return Ok(None);
    }

    let raw = std::fs::read_to_string(&path).map_err(|e| {
        ButterflyBotError::Runtime(format!(
            "Failed to read SQLCipher key file {}: {e}",
            path.to_string_lossy()
        ))
    })?;
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return Err(ButterflyBotError::Runtime(format!(
            "SQLCipher key file {} is empty",
            path.to_string_lossy()
        )));
    }
    Ok(Some(trimmed.to_string()))
}

fn persist_sqlcipher_key_to_file(key: &str) -> Result<()> {
    let path = db_key_file_path();
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| {
            ButterflyBotError::Runtime(format!(
                "Failed to create SQLCipher key directory {}: {e}",
                parent.to_string_lossy()
            ))
        })?;

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
        }
    }

    std::fs::write(&path, key).map_err(|e| {
        ButterflyBotError::Runtime(format!(
            "Failed to write SQLCipher key file {}: {e}",
            path.to_string_lossy()
        ))
    })?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
    }

    Ok(())
}

fn generated_db_key() -> Result<String> {
    let mut bytes = [0u8; 32];
    let mut rng = SysRng;
    rng.try_fill_bytes(&mut bytes)
        .map_err(|e| ButterflyBotError::Runtime(e.to_string()))?;
    Ok(URL_SAFE_NO_PAD.encode(bytes))
}

pub fn get_sqlcipher_key() -> Result<String> {
    if let Ok(value) = env::var("BUTTERFLY_BOT_DB_KEY") {
        let trimmed = value.trim();
        if !trimmed.is_empty() {
            log_sqlcipher_key_source_once("env");
            return Ok(trimmed.to_string());
        }
    }

    if let Some(value) = crate::vault::get_secret(DB_KEY_NAME)? {
        let trimmed = value.trim();
        if !trimmed.is_empty() {
            std::env::set_var("BUTTERFLY_BOT_DB_KEY", trimmed);
            log_sqlcipher_key_source_once("keychain");
            return Ok(trimmed.to_string());
        }
    }

    if let Some(value) = load_sqlcipher_key_from_file()? {
        std::env::set_var("BUTTERFLY_BOT_DB_KEY", &value);
        log_sqlcipher_key_source_once("file_fallback");
        return Ok(value);
    }

    let generated = generated_db_key()?;

    if crate::vault::set_secret_required(DB_KEY_NAME, &generated).is_err() {
        persist_sqlcipher_key_to_file(&generated)?;
        log_sqlcipher_key_source_once("generated_file_fallback");
    } else {
        log_sqlcipher_key_source_once("generated_keychain");
    }

    std::env::set_var("BUTTERFLY_BOT_DB_KEY", &generated);
    Ok(generated)
}

pub fn apply_sqlcipher_key_sync(conn: &mut SqliteConnection) -> Result<()> {
    diesel::RunQueryDsl::execute(diesel::sql_query("PRAGMA busy_timeout = 5000"), conn)
        .map_err(|e| ButterflyBotError::Runtime(e.to_string()))?;
    let key = get_sqlcipher_key()?;
    let escaped_key = key.replace('\'', "''");
    diesel::RunQueryDsl::execute(
        diesel::sql_query(format!("PRAGMA key = '{escaped_key}'")),
        conn,
    )
    .map_err(|e| ButterflyBotError::Runtime(e.to_string()))?;
    tune_sqlcipher_log_level_sync(conn);
    Ok(())
}

pub async fn apply_sqlcipher_key_async(
    conn: &mut SyncConnectionWrapper<SqliteConnection>,
) -> Result<()> {
    diesel_async::RunQueryDsl::execute(diesel::sql_query("PRAGMA busy_timeout = 5000"), conn)
        .await
        .map_err(|e| ButterflyBotError::Runtime(e.to_string()))?;
    let key = get_sqlcipher_key()?;
    let escaped_key = key.replace('\'', "''");
    diesel_async::RunQueryDsl::execute(
        diesel::sql_query(format!("PRAGMA key = '{escaped_key}'")),
        conn,
    )
    .await
    .map_err(|e| ButterflyBotError::Runtime(e.to_string()))?;
    tune_sqlcipher_log_level_async(conn).await;
    Ok(())
}