use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Result};
use serde::Deserialize;
pub const SUPPORTED_COOKIE_BROWSERS: [&str; 5] = ["chrome", "edge", "brave", "chromium", "firefox"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BrowserProfile {
pub browser: String,
pub name: String,
pub display_name: String,
pub path: PathBuf,
pub is_default: bool,
}
#[derive(Debug, Clone)]
pub struct BrowserProfileOptions {
pub browser: Option<String>,
pub home_dir: PathBuf,
pub platform: String,
}
impl Default for BrowserProfileOptions {
fn default() -> Self {
Self {
browser: None,
home_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")),
platform: current_platform().to_string(),
}
}
}
impl BrowserProfileOptions {
pub fn browser(mut self, browser: impl Into<String>) -> Self {
self.browser = Some(browser.into());
self
}
pub fn home_dir(mut self, home_dir: impl Into<PathBuf>) -> Self {
self.home_dir = home_dir.into();
self
}
pub fn platform(mut self, platform: impl AsRef<str>) -> Self {
self.platform = normalize_platform(platform.as_ref()).to_string();
self
}
}
pub(crate) fn current_platform() -> &'static str {
normalize_platform(std::env::consts::OS)
}
pub(crate) fn normalize_platform(platform: &str) -> &str {
match platform {
"macos" => "darwin",
"windows" => "win32",
other => other,
}
}
pub(crate) fn normalize_cookie_browser(browser: &str) -> Result<&str> {
let normalized = if browser == "msedge" { "edge" } else { browser };
if SUPPORTED_COOKIE_BROWSERS.contains(&normalized) {
Ok(normalized)
} else {
Err(anyhow!(
"Unsupported browser: {browser}. Expected one of {}",
SUPPORTED_COOKIE_BROWSERS.join(", ")
))
}
}
pub(crate) fn browser_profile_root(
browser: &str,
platform: &str,
home_dir: &Path,
) -> Result<PathBuf> {
let browser = normalize_cookie_browser(browser)?;
let local = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| home_dir.join("AppData/Local"));
let roaming = std::env::var_os("APPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| home_dir.join("AppData/Roaming"));
let support = home_dir.join("Library/Application Support");
let root = match (normalize_platform(platform), browser) {
("darwin", "chrome") => support.join("Google/Chrome"),
("darwin", "edge") => support.join("Microsoft Edge"),
("darwin", "brave") => support.join("BraveSoftware/Brave-Browser"),
("darwin", "chromium") => support.join("Chromium"),
("darwin", "firefox") => support.join("Firefox"),
("win32", "chrome") => local.join("Google/Chrome/User Data"),
("win32", "edge") => local.join("Microsoft/Edge/User Data"),
("win32", "brave") => local.join("BraveSoftware/Brave-Browser/User Data"),
("win32", "chromium") => local.join("Chromium/User Data"),
("win32", "firefox") => roaming.join("Mozilla/Firefox"),
(_, "chrome") => home_dir.join(".config/google-chrome"),
(_, "edge") => home_dir.join(".config/microsoft-edge"),
(_, "brave") => home_dir.join(".config/BraveSoftware/Brave-Browser"),
(_, "chromium") => home_dir.join(".config/chromium"),
(_, "firefox") => home_dir.join(".mozilla/firefox"),
_ => unreachable!(),
};
Ok(root)
}
pub(crate) fn find_cookie_database(browser: &str, profile_path: &Path) -> Option<PathBuf> {
if browser == "firefox" {
let candidate = profile_path.join("cookies.sqlite");
return candidate.is_file().then_some(candidate);
}
[
profile_path.join("Network/Cookies"),
profile_path.join("Cookies"),
]
.into_iter()
.find(|candidate| candidate.is_file())
}
#[derive(Debug, Default, Deserialize)]
struct LocalState {
#[serde(default)]
profile: LocalStateProfile,
}
#[derive(Debug, Default, Deserialize)]
struct LocalStateProfile {
last_used: Option<String>,
#[serde(default)]
info_cache: BTreeMap<String, LocalStateProfileInfo>,
}
#[derive(Debug, Default, Deserialize)]
struct LocalStateProfileInfo {
name: Option<String>,
}
fn list_chromium_profiles(browser: &str, root: &Path) -> Vec<BrowserProfile> {
if !root.is_dir() {
return Vec::new();
}
let state = fs::read_to_string(root.join("Local State"))
.ok()
.and_then(|contents| serde_json::from_str::<LocalState>(&contents).ok())
.unwrap_or_default();
let mut names = state
.profile
.info_cache
.keys()
.cloned()
.collect::<BTreeSet<_>>();
if let Ok(entries) = fs::read_dir(root) {
for name in entries
.flatten()
.filter_map(|entry| entry.file_name().into_string().ok())
.filter(|name| name == "Default" || name.starts_with("Profile "))
{
names.insert(name);
}
}
let default_name = state.profile.last_used.as_deref().unwrap_or("Default");
let only_default = names.len() == 1 && names.contains("Default");
let mut profiles = names
.into_iter()
.filter_map(|name| {
let path = root.join(&name);
find_cookie_database(browser, &path)?;
let display_name = state
.profile
.info_cache
.get(&name)
.and_then(|info| info.name.clone())
.unwrap_or_else(|| name.clone());
Some(BrowserProfile {
browser: browser.to_string(),
is_default: name == default_name || (only_default && name == "Default"),
name,
display_name,
path,
})
})
.collect::<Vec<_>>();
profiles.sort_by_key(|profile| (!profile.is_default, profile.name.clone()));
profiles
}
fn parse_ini(contents: &str) -> Vec<BTreeMap<String, String>> {
let mut sections = Vec::new();
let mut current: Option<BTreeMap<String, String>> = None;
for raw_line in contents.lines() {
let line = raw_line.trim();
if line.starts_with('[') && line.ends_with(']') {
if let Some(section) = current.take() {
sections.push(section);
}
let mut section = BTreeMap::new();
section.insert("section".into(), line[1..line.len() - 1].into());
current = Some(section);
} else if let (Some(section), Some((key, value))) = (current.as_mut(), line.split_once('='))
{
section.insert(key.trim().into(), value.trim().into());
}
}
if let Some(section) = current {
sections.push(section);
}
sections
}
fn list_firefox_profiles(root: &Path) -> Vec<BrowserProfile> {
let contents = match fs::read_to_string(root.join("profiles.ini")) {
Ok(contents) => contents,
Err(_) => return Vec::new(),
};
let mut profiles = parse_ini(&contents)
.into_iter()
.filter(|section| {
section
.get("section")
.is_some_and(|name| name.starts_with("Profile"))
})
.filter_map(|section| {
let configured = PathBuf::from(section.get("Path")?);
let path = if section.get("IsRelative").map(String::as_str) == Some("0") {
configured
} else {
root.join(configured)
};
find_cookie_database("firefox", &path)?;
let name = section
.get("Name")
.cloned()
.or_else(|| path.file_name()?.to_str().map(String::from))?;
Some(BrowserProfile {
browser: "firefox".into(),
name: name.clone(),
display_name: name,
path,
is_default: section.get("Default").map(String::as_str) == Some("1"),
})
})
.collect::<Vec<_>>();
profiles.sort_by_key(|profile| (!profile.is_default, profile.name.clone()));
profiles
}
pub fn list_browser_profiles(options: BrowserProfileOptions) -> Result<Vec<BrowserProfile>> {
let browsers = match options.browser.as_deref() {
Some(browser) => vec![normalize_cookie_browser(browser)?],
None => SUPPORTED_COOKIE_BROWSERS.to_vec(),
};
let mut profiles = Vec::new();
for browser in browsers {
let root = browser_profile_root(browser, &options.platform, &options.home_dir)?;
if browser == "firefox" {
profiles.extend(list_firefox_profiles(&root));
} else {
profiles.extend(list_chromium_profiles(browser, &root));
}
}
Ok(profiles)
}
pub(crate) fn resolve_browser_profile(
browser: &str,
requested_profile: Option<&str>,
options: &BrowserProfileOptions,
) -> Result<BrowserProfile> {
let profiles = list_browser_profiles(options.clone().browser(browser))?;
let selected = requested_profile
.and_then(|requested| {
profiles.iter().find(|profile| {
profile.name == requested
|| profile.display_name == requested
|| profile.path.file_name().and_then(|name| name.to_str()) == Some(requested)
})
})
.or_else(|| profiles.iter().find(|profile| profile.is_default))
.or_else(|| profiles.first());
selected.cloned().ok_or_else(|| {
let detail = requested_profile
.map(|profile| format!(" profile \"{profile}\""))
.unwrap_or_else(|| " profile".into());
anyhow!("Could not find a cookie database for {browser}{detail}")
})
}