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:
387
src/auth/browser.rs
Normal file
387
src/auth/browser.rs
Normal 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(_))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user