feat(06-02): add browser cookie extraction module

- Implements find_firefox_profile() to locate Firefox profile directories
- Implements extract_firefox_cookies() to read cookies from Firefox SQLite database
- Implements find_chrome_profile() to locate Chrome profile directories
- Implements extract_chrome_cookies() to read cookies from Chrome SQLite database
- Handles encrypted Chrome cookies gracefully with warnings
- Implements extract_browser_cookies() as unified API for both browsers
- Uses tempfile to avoid database locking issues
- Adds comprehensive error handling and logging
This commit is contained in:
2026-02-16 10:07:59 +01:00
parent 09675aa49a
commit 43f1f8d87a

387
src/auth/browser.rs Normal file
View File

@@ -0,0 +1,387 @@
//! Browser cookie extraction for Firefox and Chrome
//!
//! This module provides functionality to extract cookies directly from
//! browser SQLite cookie databases, enabling seamless authentication
//! without manual cookie file exports.
use rusqlite::{params, Connection};
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::PathBuf;
/// Error type for browser cookie extraction
#[derive(Debug)]
pub enum BrowserError {
/// I/O error
Io(io::Error),
/// SQLite error
Sqlite(rusqlite::Error),
/// Profile not found
ProfileNotFound(String),
/// Database not found
DatabaseNotFound(String),
/// Encrypted cookie (Chrome)
EncryptedCookie(String),
/// Other errors
Other(String),
}
impl std::fmt::Display for BrowserError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BrowserError::Io(e) => write!(f, "I/O error: {}", e),
BrowserError::Sqlite(e) => write!(f, "SQLite error: {}", e),
BrowserError::ProfileNotFound(s) => write!(f, "Profile not found: {}", s),
BrowserError::DatabaseNotFound(s) => write!(f, "Database not found: {}", s),
BrowserError::EncryptedCookie(s) => write!(f, "Encrypted cookie: {}", s),
BrowserError::Other(s) => write!(f, "Error: {}", s),
}
}
}
impl std::error::Error for BrowserError {}
impl From<io::Error> for BrowserError {
fn from(err: io::Error) -> Self {
BrowserError::Io(err)
}
}
impl From<rusqlite::Error> for BrowserError {
fn from(err: rusqlite::Error) -> Self {
BrowserError::Sqlite(err)
}
}
/// Get the user's home directory
fn get_home_dir() -> Result<PathBuf, BrowserError> {
dirs::home_dir()
.ok_or_else(|| BrowserError::Other("Could not determine home directory".to_string()))
}
/// Copy a file to a temporary location to avoid locking issues
fn copy_to_temp<P: AsRef<std::path::Path>>(path: P) -> Result<tempfile::TempPath, BrowserError> {
let temp_file = tempfile::NamedTempFile::new()?;
fs::copy(path.as_ref(), temp_file.path())?;
Ok(temp_file.into_temp_path())
}
/// Find the Firefox profile directory
///
/// Searches in ~/.mozilla/firefox/ for profiles
pub fn find_firefox_profile() -> Result<PathBuf, BrowserError> {
let home = get_home_dir()?;
let firefox_dir = home.join(".mozilla").join("firefox");
if !firefox_dir.exists() {
return Err(BrowserError::ProfileNotFound(format!(
"Firefox directory not found: {:?}",
firefox_dir
)));
}
// Read directory entries
let entries = fs::read_dir(&firefox_dir).map_err(|e| BrowserError::Io(e))?;
let mut profile_dirs: Vec<(String, PathBuf)> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
// Check if this is a profile directory (contains cookies.sqlite)
let cookies_path = path.join("cookies.sqlite");
if cookies_path.exists() {
// Get the profile name from the directory name
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
profile_dirs.push((name, path));
}
}
}
if profile_dirs.is_empty() {
return Err(BrowserError::ProfileNotFound(
"No Firefox profiles with cookies found".to_string(),
));
}
// Prefer default-release profile, otherwise use first available
profile_dirs.sort_by(|a, b| {
let a_default = a.0.contains("default-release");
let b_default = b.0.contains("default-release");
match (a_default, b_default) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => std::cmp::Ordering::Equal,
}
});
let selected = &profile_dirs[0].1;
log::info!("Found Firefox profile: {:?}", selected);
Ok(selected.clone())
}
/// Extract cookies from Firefox profile
///
/// # Arguments
/// * `domain` - Optional domain to filter cookies (e.g., ".twitter.com")
///
/// Returns a HashMap of cookie name -> value
pub fn extract_firefox_cookies(
domain: Option<&str>,
) -> Result<HashMap<String, String>, BrowserError> {
let profile_dir = find_firefox_profile()?;
let cookies_path = profile_dir.join("cookies.sqlite");
if !cookies_path.exists() {
return Err(BrowserError::DatabaseNotFound(format!(
"Firefox cookies database not found: {:?}",
cookies_path
)));
}
// Copy to temp to avoid locking
let temp_path = copy_to_temp(&cookies_path)?;
let conn = Connection::open(&temp_path)?;
let mut cookies = HashMap::new();
let query = match domain {
Some(d) => {
// Query with domain filter
let pattern = format!("%{}", d);
let mut stmt = conn.prepare("SELECT name, value FROM moz_cookies WHERE host LIKE ?")?;
let rows = stmt.query_map([pattern], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
for row in rows.flatten() {
cookies.insert(row.0, row.1);
}
cookies
}
None => {
// Get all cookies
let mut stmt = conn.prepare("SELECT name, value FROM moz_cookies")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
for row in rows.flatten() {
cookies.insert(row.0, row.1);
}
cookies
}
};
log::info!("Extracted {} cookies from Firefox", cookies.len());
Ok(cookies)
}
/// Find the Chrome profile directory
///
/// Searches in ~/.config/google-chrome/ for Default profile
pub fn find_chrome_profile() -> Result<PathBuf, BrowserError> {
let home = get_home_dir()?;
// Try different possible Chrome config locations
let possible_paths = vec![
home.join(".config").join("google-chrome"),
home.join(".config").join("chromium"),
home.join("Library")
.join("Application Support")
.join("Google Chrome"),
];
for chrome_dir in possible_paths {
if chrome_dir.exists() {
let default_profile = chrome_dir.join("Default");
if default_profile.exists() {
let cookies_path = default_profile.join("Cookies");
if cookies_path.exists() {
log::info!("Found Chrome profile: {:?}", default_profile);
return Ok(default_profile);
}
}
}
}
Err(BrowserError::ProfileNotFound(
"Chrome profile not found in standard locations".to_string(),
))
}
/// Extract cookies from Chrome profile
///
/// Note: Chrome stores some cookies with encrypted values using the OS keyring.
/// This function extracts plaintext cookies and logs a warning for encrypted ones.
///
/// # Arguments
/// * `domain` - Optional domain to filter cookies (e.g., ".twitter.com")
///
/// Returns a HashMap of cookie name -> value
pub fn extract_chrome_cookies(
domain: Option<&str>,
) -> Result<HashMap<String, String>, BrowserError> {
let profile_dir = find_chrome_profile()?;
let cookies_path = profile_dir.join("Cookies");
if !cookies_path.exists() {
return Err(BrowserError::DatabaseNotFound(format!(
"Chrome cookies database not found: {:?}",
cookies_path
)));
}
// Copy to temp to avoid locking
let temp_path = copy_to_temp(&cookies_path)?;
let conn = Connection::open(&temp_path)?;
let mut cookies = HashMap::new();
let mut encrypted_count = 0;
// Chrome uses different table schema - check for encrypted_value column
let has_encrypted = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('cookies') WHERE name='encrypted_value'",
[],
|row| row.get::<_, i32>(0),
)
.unwrap_or(0)
> 0;
let query = match domain {
Some(d) => format!("%{}%", d),
None => String::new(),
};
let mut stmt = if query.is_empty() {
conn.prepare("SELECT name, value, encrypted_value FROM cookies")?
} else {
let pattern = format!("%{}%", domain.unwrap_or(""));
conn.prepare("SELECT name, value, encrypted_value FROM cookies WHERE host LIKE ?")?
};
let rows = if query.is_empty() {
stmt.query_map([], |row| {
let name: String = row.get(0)?;
let value: String = row.get(1)?;
let encrypted: Option<Vec<u8>> = row.get(2).ok();
Ok((name, value, encrypted))
})?
} else {
let pattern = format!("%{}%", domain.unwrap_or(""));
stmt.query_map([pattern], |row| {
let name: String = row.get(0)?;
let value: String = row.get(1)?;
let encrypted: Option<Vec<u8>> = row.get(2).ok();
Ok((name, value, encrypted))
})?
};
for row in rows.flatten() {
let (name, value, encrypted) = row;
// Check if cookie has encrypted value
if has_encrypted {
if let Some(enc) = encrypted {
if !enc.is_empty() {
encrypted_count += 1;
continue; // Skip encrypted cookies
}
}
}
cookies.insert(name, value);
}
if encrypted_count > 0 {
log::warn!(
"Skipped {} encrypted Chrome cookies (OS keyring required). \
Run with --cookies-file for encrypted cookies.",
encrypted_count
);
}
log::info!(
"Extracted {} cookies from Chrome ({} encrypted skipped)",
cookies.len(),
encrypted_count
);
Ok(cookies)
}
/// Extract cookies from a browser
///
/// # Arguments
/// * `browser` - Browser name: "firefox", "chrome", or "chromium"
/// * `domain` - Optional domain to filter cookies
///
/// # Example
/// ```
/// use gallery_dl::auth::extract_browser_cookies;
///
/// // Get all cookies from Firefox
/// let cookies = extract_browser_cookies("firefox", None).unwrap();
///
/// // Get Twitter cookies from Chrome
/// let twitter_cookies = extract_browser_cookies("chrome", Some("twitter.com")).unwrap();
/// ```
pub fn extract_browser_cookies(
browser: &str,
domain: Option<&str>,
) -> Result<HashMap<String, String>, BrowserError> {
match browser.to_lowercase().as_str() {
"firefox" | "ff" => extract_firefox_cookies(domain),
"chrome" | "google-chrome" => extract_chrome_cookies(domain),
"chromium" => extract_chrome_cookies(domain),
_ => Err(BrowserError::Other(format!(
"Unsupported browser: {}. Supported: firefox, chrome, chromium",
browser
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_get_home_dir() {
let home = get_home_dir();
assert!(home.is_ok());
}
#[test]
fn test_extract_browser_cookies_unsupported() {
let result = extract_browser_cookies("unsupported", None);
assert!(result.is_err());
}
#[test]
fn test_extract_browser_cookies_case_insensitive() {
// Should not error, just return empty or ProfileNotFound
let result = extract_browser_cookies("FIREFOX", None);
// Either works or profile not found (acceptable in test env)
assert!(result.is_ok() || matches!(result, Err(BrowserError::ProfileNotFound(_))));
}
#[test]
fn test_firefox_cookies_with_domain() {
// Should not error even if profile not found in test env
let result = extract_firefox_cookies(Some("twitter.com"));
// Either works or profile not found (acceptable in test env)
assert!(result.is_ok() || matches!(result, Err(BrowserError::ProfileNotFound(_))));
}
#[test]
fn test_chrome_cookies_with_domain() {
// Should not error even if profile not found in test env
let result = extract_chrome_cookies(Some("twitter.com"));
// Either works or profile not found (acceptable in test env)
assert!(result.is_ok() || matches!(result, Err(BrowserError::ProfileNotFound(_))));
}
}