flintbase 0.3.1

Google / Firebase API key analyzer and APK secret scanner — tests keys against 20+ endpoints and extracts hardcoded credentials from Android apps
use std::collections::HashMap;
use std::path::PathBuf;

/// Firebase configuration extracted from an app or provided via CLI.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct FirebaseConfig {
    pub api_key: String,
    pub app_id: Option<String>,
    pub project_id: Option<String>,
    pub project_number: Option<String>,
    pub gcm_sender_id: Option<String>,
    pub storage_bucket: Option<String>,
    pub database_url: Option<String>,
}

/// Accumulated information discovered about the Firebase project during testing.
#[derive(Debug, Default)]
#[allow(dead_code)]
pub struct FirebaseProjectInfo {
    pub project_id: Option<String>,
    pub project_number: Option<String>,
    pub authorized_domains: Vec<String>,
    pub enabled_providers: Vec<String>,
    pub signup_disabled: Option<bool>,
    pub anonymous_auth_enabled: Option<bool>,
    pub email_auth_enabled: Option<bool>,
    pub realtime_db_public_read: Option<bool>,
    pub realtime_db_public_write: Option<bool>,
    pub storage_bucket: Option<String>,
    pub storage_public: Option<bool>,
    pub fcm_enabled: Option<bool>,
    pub fcm_token_obtained: Option<bool>,
    pub fcm_topics_accessible: Option<bool>,
    pub installations_api_enabled: Option<bool>,
    pub api_restrictions: Vec<String>,
    pub raw_errors: HashMap<String, String>,
}

/// Unified test result returned by every check function.
#[derive(Debug, Clone)]
pub struct TestResult {
    pub success: bool,
    pub status_code: Option<u16>,
    pub error: Option<String>,
    pub detail: Option<String>,
    pub extra: HashMap<String, String>,
}

impl TestResult {
    pub fn ok(status_code: u16, detail: impl Into<String>) -> Self {
        Self {
            success: true,
            status_code: Some(status_code),
            error: None,
            detail: Some(detail.into()),
            extra: HashMap::new(),
        }
    }

    pub fn fail(status_code: Option<u16>, error: impl Into<String>, detail: Option<String>) -> Self {
        Self {
            success: false,
            status_code,
            error: Some(error.into()),
            detail,
            extra: HashMap::new(),
        }
    }

    pub fn with_extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.extra.insert(key.into(), value.into());
        self
    }
}

// ═════════════════════════════════════════════════════════════════════════════
// Saved configuration store — ~/.config/flintbase/configs.json
// ═════════════════════════════════════════════════════════════════════════════

/// A saved Firebase app configuration (persisted to disk).
///
/// Users can manually edit the JSON file to tweak values before re-testing.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SavedConfig {
    /// Human-readable label (defaults to package name)
    pub name: String,
    /// Android package name (e.g. com.example.app)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub package: Option<String>,
    /// Google/Firebase API key (AIzaSy...)
    pub api_key: String,
    /// Firebase app ID (1:xxx:android:xxx)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub app_id: Option<String>,
    /// Firebase project ID
    #[serde(skip_serializing_if = "Option::is_none")]
    pub project_id: Option<String>,
    /// GCM sender ID / project number
    #[serde(skip_serializing_if = "Option::is_none")]
    pub gcm_sender_id: Option<String>,
    /// Firebase Storage bucket
    #[serde(skip_serializing_if = "Option::is_none")]
    pub storage_bucket: Option<String>,
    /// Firebase Realtime Database URL
    #[serde(skip_serializing_if = "Option::is_none")]
    pub database_url: Option<String>,
    /// ISO 8601 timestamp of when this config was saved
    #[serde(skip_serializing_if = "Option::is_none")]
    pub saved_at: Option<String>,
}

/// The top-level structure of the configs file.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct SavedConfigStore {
    /// Map of config name → saved config
    pub configs: indexmap::IndexMap<String, SavedConfig>,
}

/// Path to the saved configs file: ~/.config/flintbase/configs.json
pub fn config_file_path() -> PathBuf {
    let base = dirs_next().join("flintbase");
    base.join("configs.json")
}

/// Load saved configs from disk. Returns an empty store if the file doesn't exist.
pub fn load_saved_configs() -> SavedConfigStore {
    let path = config_file_path();
    if !path.exists() {
        return SavedConfigStore::default();
    }
    match std::fs::read_to_string(&path) {
        Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
            eprintln!(
                "Warning: Failed to parse {}: {}\nUsing empty config store.",
                path.display(),
                e
            );
            SavedConfigStore::default()
        }),
        Err(e) => {
            eprintln!(
                "Warning: Failed to read {}: {}\nUsing empty config store.",
                path.display(),
                e
            );
            SavedConfigStore::default()
        }
    }
}

/// Save configs to disk (creates parent dirs if needed).
pub fn save_config_store(store: &SavedConfigStore) -> anyhow::Result<()> {
    let path = config_file_path();
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let json = serde_json::to_string_pretty(store)?;
    std::fs::write(&path, json)?;
    Ok(())
}

/// Save a list of FirebaseConfigs under a given package name.
/// Merges into the existing store (overwrites entries with the same key).
/// Returns the number of configs saved.
pub fn save_firebase_configs(
    package: &str,
    configs: &[FirebaseConfig],
) -> anyhow::Result<usize> {
    let mut store = load_saved_configs();
    let now = chrono_now();

    let count = configs.len();

    for (i, cfg) in configs.iter().enumerate() {
        // Derive a unique key: "com.example.app" or "com.example.app.2" for multiple keys
        let key = if count == 1 {
            package.to_string()
        } else {
            format!("{}.{}", package, i + 1)
        };

        let name = if count == 1 {
            package.to_string()
        } else {
            format!("{} (key {})", package, i + 1)
        };

        store.configs.insert(
            key,
            SavedConfig {
                name,
                package: Some(package.to_string()),
                api_key: cfg.api_key.clone(),
                app_id: cfg.app_id.clone(),
                project_id: cfg.project_id.clone(),
                gcm_sender_id: cfg.gcm_sender_id.clone(),
                storage_bucket: cfg.storage_bucket.clone(),
                database_url: cfg.database_url.clone(),
                saved_at: Some(now.clone()),
            },
        );
    }

    save_config_store(&store)?;
    Ok(count)
}

/// Convert a SavedConfig to a FirebaseConfig for testing.
impl SavedConfig {
    pub fn to_firebase_config(&self) -> FirebaseConfig {
        FirebaseConfig {
            api_key: self.api_key.clone(),
            app_id: self.app_id.clone(),
            project_id: self.project_id.clone(),
            project_number: None,
            gcm_sender_id: self.gcm_sender_id.clone(),
            storage_bucket: self.storage_bucket.clone(),
            database_url: self.database_url.clone(),
        }
    }
}

// ── Helpers ──────────────────────────────────────────────────────────────────

/// XDG-style config directory (~/.config on Linux/macOS)
fn dirs_next() -> PathBuf {
    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
        return PathBuf::from(xdg);
    }
    if let Ok(home) = std::env::var("HOME") {
        return PathBuf::from(home).join(".config");
    }
    PathBuf::from(".config")
}

/// Simple ISO 8601 timestamp without pulling in the chrono crate.
fn chrono_now() -> String {
    let output = std::process::Command::new("date")
        .arg("-u")
        .arg("+%Y-%m-%dT%H:%M:%SZ")
        .output();
    match output {
        Ok(o) if o.status.success() => {
            String::from_utf8_lossy(&o.stdout).trim().to_string()
        }
        _ => String::from("unknown"),
    }
}