1use std::collections::{BTreeMap, BTreeSet, HashSet};
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use anyhow::{anyhow, Result};
12use serde::Deserialize;
13
14use super::browser_sources::{
15 browser_family, browser_ids, current_environment, is_single_profile_browser,
16 normalize_browser_id, resolve_browser_roots, Environment,
17};
18use super::default_browser::{default_run_command, resolve_default_browser, RunCommand};
19
20pub use super::browser_sources::SUPPORTED_COOKIE_BROWSERS;
23
24const DEFAULT_BROWSER_KEYWORDS: [&str; 2] = ["default", "auto"];
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct BrowserProfile {
32 pub browser: String,
34 pub name: String,
36 pub display_name: String,
38 pub path: PathBuf,
40 pub is_default: bool,
42}
43
44#[derive(Clone)]
46pub struct BrowserProfileOptions {
47 pub browser: Option<String>,
49 pub home_dir: PathBuf,
51 pub platform: String,
53 pub environment: Environment,
55 pub run_command: Option<RunCommand>,
58}
59
60impl std::fmt::Debug for BrowserProfileOptions {
61 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 formatter
63 .debug_struct("BrowserProfileOptions")
64 .field("browser", &self.browser)
65 .field("home_dir", &self.home_dir)
66 .field("platform", &self.platform)
67 .field("environment", &self.environment)
68 .field("run_command", &self.run_command.as_ref().map(|_| "<fn>"))
69 .finish()
70 }
71}
72
73impl Default for BrowserProfileOptions {
74 fn default() -> Self {
75 Self {
76 browser: None,
77 home_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")),
78 platform: current_platform().to_string(),
79 environment: current_environment(),
80 run_command: None,
81 }
82 }
83}
84
85impl BrowserProfileOptions {
86 pub fn browser(mut self, browser: impl Into<String>) -> Self {
88 self.browser = Some(browser.into());
89 self
90 }
91
92 pub fn home_dir(mut self, home_dir: impl Into<PathBuf>) -> Self {
94 self.home_dir = home_dir.into();
95 self
96 }
97
98 pub fn platform(mut self, platform: impl AsRef<str>) -> Self {
100 self.platform = normalize_platform(platform.as_ref()).to_string();
101 self
102 }
103
104 pub fn environment(mut self, environment: Environment) -> Self {
106 self.environment = environment;
107 self
108 }
109
110 pub fn run_command(mut self, run_command: RunCommand) -> Self {
112 self.run_command = Some(run_command);
113 self
114 }
115}
116
117pub(crate) fn current_platform() -> &'static str {
118 normalize_platform(std::env::consts::OS)
119}
120
121pub(crate) fn normalize_platform(platform: &str) -> &str {
122 match platform {
123 "macos" => "darwin",
124 "windows" => "win32",
125 other => other,
126 }
127}
128
129pub(crate) fn normalize_cookie_browser(browser: &str) -> Result<&'static str> {
131 normalize_browser_id(browser)
132}
133
134pub fn is_default_browser_keyword(browser: &str) -> bool {
136 let needle = browser.trim().to_lowercase();
137 DEFAULT_BROWSER_KEYWORDS.contains(&needle.as_str())
138}
139
140pub fn resolve_source_browser(
144 browser: &str,
145 platform: &str,
146 environment: &Environment,
147 run_command: Option<&RunCommand>,
148) -> Result<&'static str> {
149 if !is_default_browser_keyword(browser) {
150 return normalize_cookie_browser(browser);
151 }
152 let platform = normalize_platform(platform);
153 let fallback;
154 let runner = match run_command {
155 Some(runner) => runner,
156 None => {
157 fallback = default_run_command();
158 &fallback
159 }
160 };
161 resolve_default_browser(platform, environment, runner)?.ok_or_else(|| {
162 anyhow!(
163 "Could not determine the system default browser; pass an explicit browser instead of \"default\"."
164 )
165 })
166}
167
168pub(crate) fn browser_profile_root(
170 browser: &str,
171 platform: &str,
172 home_dir: &Path,
173) -> Result<PathBuf> {
174 browser_profile_root_in(browser, platform, home_dir, ¤t_environment())
175}
176
177pub(crate) fn browser_profile_root_in(
179 browser: &str,
180 platform: &str,
181 home_dir: &Path,
182 environment: &Environment,
183) -> Result<PathBuf> {
184 let browser = normalize_browser_id(browser)?;
185 resolve_browser_roots(browser, platform, &home_dir.to_string_lossy(), environment)?
186 .into_iter()
187 .next()
188 .ok_or_else(|| anyhow!("{browser} has no profile root on {platform}"))
189}
190
191pub(crate) fn find_cookie_database(browser: &str, profile_path: &Path) -> Option<PathBuf> {
192 if browser_family(browser)
193 .map(|family| family == "firefox")
194 .unwrap_or(false)
195 {
196 let candidate = profile_path.join("cookies.sqlite");
197 return candidate.is_file().then_some(candidate);
198 }
199 [
200 profile_path.join("Network/Cookies"),
201 profile_path.join("Cookies"),
202 ]
203 .into_iter()
204 .find(|candidate| candidate.is_file())
205}
206
207#[derive(Debug, Default, Deserialize)]
208struct LocalState {
209 #[serde(default)]
210 profile: LocalStateProfile,
211}
212
213#[derive(Debug, Default, Deserialize)]
214struct LocalStateProfile {
215 last_used: Option<String>,
216 #[serde(default)]
217 info_cache: BTreeMap<String, LocalStateProfileInfo>,
218}
219
220#[derive(Debug, Default, Deserialize)]
221struct LocalStateProfileInfo {
222 name: Option<String>,
223}
224
225fn list_chromium_profiles(browser: &str, root: &Path) -> Vec<BrowserProfile> {
226 if !root.is_dir() {
227 return Vec::new();
228 }
229 if is_single_profile_browser(browser).unwrap_or(false) {
232 return match find_cookie_database(browser, root) {
233 Some(_) => vec![BrowserProfile {
234 browser: browser.to_string(),
235 name: "Default".to_string(),
236 display_name: "Default".to_string(),
237 path: root.to_path_buf(),
238 is_default: true,
239 }],
240 None => Vec::new(),
241 };
242 }
243 let state = fs::read_to_string(root.join("Local State"))
244 .ok()
245 .and_then(|contents| serde_json::from_str::<LocalState>(&contents).ok())
246 .unwrap_or_default();
247 let mut names = state
248 .profile
249 .info_cache
250 .keys()
251 .cloned()
252 .collect::<BTreeSet<_>>();
253 if let Ok(entries) = fs::read_dir(root) {
254 for name in entries
255 .flatten()
256 .filter_map(|entry| entry.file_name().into_string().ok())
257 .filter(|name| name == "Default" || name.starts_with("Profile "))
258 {
259 names.insert(name);
260 }
261 }
262 let default_name = state.profile.last_used.as_deref().unwrap_or("Default");
263 let only_default = names.len() == 1 && names.contains("Default");
264 let mut profiles = names
265 .into_iter()
266 .filter_map(|name| {
267 let path = root.join(&name);
268 find_cookie_database(browser, &path)?;
269 let display_name = state
270 .profile
271 .info_cache
272 .get(&name)
273 .and_then(|info| info.name.clone())
274 .unwrap_or_else(|| name.clone());
275 Some(BrowserProfile {
276 browser: browser.to_string(),
277 is_default: name == default_name || (only_default && name == "Default"),
278 name,
279 display_name,
280 path,
281 })
282 })
283 .collect::<Vec<_>>();
284 profiles.sort_by_key(|profile| (!profile.is_default, profile.name.clone()));
285 profiles
286}
287
288fn parse_ini(contents: &str) -> Vec<BTreeMap<String, String>> {
289 let mut sections = Vec::new();
290 let mut current: Option<BTreeMap<String, String>> = None;
291 for raw_line in contents.lines() {
292 let line = raw_line.trim();
293 if line.starts_with('[') && line.ends_with(']') {
294 if let Some(section) = current.take() {
295 sections.push(section);
296 }
297 let mut section = BTreeMap::new();
298 section.insert("section".into(), line[1..line.len() - 1].into());
299 current = Some(section);
300 } else if let (Some(section), Some((key, value))) = (current.as_mut(), line.split_once('='))
301 {
302 if !line.starts_with(';') {
303 section.insert(key.trim().into(), value.trim().into());
304 }
305 }
306 }
307 if let Some(section) = current {
308 sections.push(section);
309 }
310 sections
311}
312
313fn firefox_sections(root: &Path) -> Vec<BTreeMap<String, String>> {
314 if let Ok(contents) = fs::read_to_string(root.join("profiles.ini")) {
315 return parse_ini(&contents);
316 }
317 let profiles_root = root.join("Profiles");
319 let Ok(entries) = fs::read_dir(&profiles_root) else {
320 return Vec::new();
321 };
322 entries
323 .flatten()
324 .filter(|entry| entry.path().is_dir())
325 .filter_map(|entry| {
326 let name = entry.file_name().into_string().ok()?;
327 let mut section = BTreeMap::new();
328 section.insert("section".into(), "Profile".into());
329 section.insert("Name".into(), name.clone());
330 section.insert("Path".into(), name);
331 section.insert(
332 "ProfilesRoot".into(),
333 profiles_root.to_string_lossy().into_owned(),
334 );
335 Some(section)
336 })
337 .collect()
338}
339
340fn list_firefox_profiles(browser: &str, root: &Path) -> Vec<BrowserProfile> {
341 if !root.is_dir() {
342 return Vec::new();
343 }
344 let mut profiles = firefox_sections(root)
345 .into_iter()
346 .filter(|section| {
347 section
348 .get("section")
349 .is_some_and(|name| name.starts_with("Profile"))
350 })
351 .filter_map(|section| {
352 let configured = section.get("Path")?;
353 let relative_root = section
354 .get("ProfilesRoot")
355 .map(PathBuf::from)
356 .unwrap_or_else(|| root.to_path_buf());
357 let path = if section.get("IsRelative").map(String::as_str) == Some("0") {
358 PathBuf::from(configured)
359 } else {
360 relative_root.join(configured)
361 };
362 find_cookie_database(browser, &path)?;
363 let display_name = section
364 .get("Name")
365 .cloned()
366 .or_else(|| path.file_name()?.to_str().map(String::from))?;
367 Some(BrowserProfile {
368 browser: browser.to_string(),
369 name: display_name.clone(),
370 display_name,
371 path,
372 is_default: section.get("Default").map(String::as_str) == Some("1"),
373 })
374 })
375 .collect::<Vec<_>>();
376 profiles.sort_by_key(|profile| (!profile.is_default, profile.name.clone()));
377 profiles
378}
379
380fn list_profiles_for_browser(
381 browser: &str,
382 platform: &str,
383 home_dir: &Path,
384 environment: &Environment,
385) -> Result<Vec<BrowserProfile>> {
386 let roots = resolve_browser_roots(browser, platform, &home_dir.to_string_lossy(), environment)?;
387 let family = browser_family(browser)?;
388 let mut profiles = Vec::new();
389 for root in roots {
390 if family == "firefox" {
391 profiles.extend(list_firefox_profiles(browser, &root));
392 } else {
393 profiles.extend(list_chromium_profiles(browser, &root));
394 }
395 }
396 Ok(profiles)
397}
398
399pub fn list_browser_profiles(options: BrowserProfileOptions) -> Result<Vec<BrowserProfile>> {
402 let browsers: Vec<&'static str> = match options.browser.as_deref() {
403 Some(browser) => vec![resolve_source_browser(
404 browser,
405 &options.platform,
406 &options.environment,
407 options.run_command.as_ref(),
408 )?],
409 None => browser_ids(),
410 };
411 let mut profiles = Vec::new();
412 let mut seen: HashSet<PathBuf> = HashSet::new();
416 for browser in browsers {
417 for profile in list_profiles_for_browser(
418 browser,
419 &options.platform,
420 &options.home_dir,
421 &options.environment,
422 )? {
423 if seen.insert(profile.path.clone()) {
424 profiles.push(profile);
425 }
426 }
427 }
428 Ok(profiles)
429}
430
431pub(crate) fn resolve_browser_profile(
432 browser: &str,
433 requested_profile: Option<&str>,
434 options: &BrowserProfileOptions,
435) -> Result<BrowserProfile> {
436 let browser = resolve_source_browser(
437 browser,
438 &options.platform,
439 &options.environment,
440 options.run_command.as_ref(),
441 )?;
442 let profiles = list_browser_profiles(options.clone().browser(browser))?;
443 let selected = requested_profile
444 .and_then(|requested| {
445 profiles.iter().find(|profile| {
446 profile.name == requested
447 || profile.display_name == requested
448 || profile.path.file_name().and_then(|name| name.to_str()) == Some(requested)
449 })
450 })
451 .or_else(|| profiles.iter().find(|profile| profile.is_default))
452 .or_else(|| profiles.first());
453 selected.cloned().ok_or_else(|| {
454 let detail = requested_profile
455 .map(|profile| format!(" profile \"{profile}\""))
456 .unwrap_or_else(|| " profile".into());
457 anyhow!("Could not find a cookie database for {browser}{detail}")
458 })
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use std::sync::Arc;
465
466 struct TempDir(PathBuf);
467
468 impl TempDir {
469 fn new(prefix: &str) -> Self {
470 use std::sync::atomic::{AtomicU64, Ordering};
471 static COUNTER: AtomicU64 = AtomicU64::new(0);
472 let nanos = std::time::SystemTime::now()
473 .duration_since(std::time::UNIX_EPOCH)
474 .map(|elapsed| elapsed.as_nanos())
475 .unwrap_or_default();
476 let count = COUNTER.fetch_add(1, Ordering::Relaxed);
477 let path = std::env::temp_dir()
478 .join(format!("{prefix}{}-{nanos:x}-{count}", std::process::id()));
479 fs::create_dir_all(&path).expect("temporary directory");
480 Self(path)
481 }
482
483 fn path(&self) -> &Path {
484 &self.0
485 }
486 }
487
488 impl Drop for TempDir {
489 fn drop(&mut self) {
490 let _ = fs::remove_dir_all(&self.0);
491 }
492 }
493
494 fn write(path: &Path, contents: &str) {
495 fs::create_dir_all(path.parent().unwrap()).expect("parent");
496 fs::write(path, contents).expect("write");
497 }
498
499 fn write_firefox_profile(home: &Path, root_name: &[&str], profile_name: &str) -> PathBuf {
500 let mut root = home.to_path_buf();
501 for part in root_name {
502 root = root.join(part);
503 }
504 let profile_path = root.join(profile_name);
505 fs::create_dir_all(&profile_path).expect("profile dir");
506 write(
507 &root.join("profiles.ini"),
508 &format!(
509 "[Profile0]\nName={}\nIsRelative=1\nPath={profile_name}\nDefault=1\n",
510 profile_name
511 .split_once('.')
512 .map(|(_, name)| name)
513 .unwrap_or(profile_name)
514 ),
515 );
516 fs::write(profile_path.join("cookies.sqlite"), b"SQLite format 3\0").expect("cookies");
517 profile_path
518 }
519
520 fn options(home: &Path) -> BrowserProfileOptions {
521 BrowserProfileOptions {
522 browser: None,
523 home_dir: home.to_path_buf(),
524 platform: "linux".to_string(),
525 environment: Environment::new(),
526 run_command: None,
527 }
528 }
529
530 #[test]
531 fn reads_an_opera_single_profile_layout_from_the_root_itself() {
532 let temp = TempDir::new("bc-opera-");
533 let root = temp.path().join(".config").join("opera");
534 fs::create_dir_all(root.join("Network")).expect("network");
535 fs::write(root.join("Network").join("Cookies"), b"SQLite format 3\0").expect("cookies");
536
537 let profiles = list_browser_profiles(options(temp.path()).browser("opera")).unwrap();
538 assert_eq!(
539 profiles,
540 vec![BrowserProfile {
541 browser: "opera".into(),
542 name: "Default".into(),
543 display_name: "Default".into(),
544 path: root,
545 is_default: true,
546 }]
547 );
548 }
549
550 #[test]
551 fn discovers_a_firefox_fork_librewolf_by_its_own_root() {
552 let temp = TempDir::new("bc-librewolf-");
553 let profile_path = write_firefox_profile(temp.path(), &[".librewolf"], "abcd.default");
554
555 let profiles = list_browser_profiles(options(temp.path()).browser("librewolf")).unwrap();
556 assert_eq!(
557 profiles,
558 vec![BrowserProfile {
559 browser: "librewolf".into(),
560 name: "default".into(),
561 display_name: "default".into(),
562 path: profile_path,
563 is_default: true,
564 }]
565 );
566 }
567
568 #[test]
569 fn lists_firefox_once_when_its_channels_share_a_root() {
570 let temp = TempDir::new("bc-ffshare-");
571 write_firefox_profile(temp.path(), &[".mozilla", "firefox"], "xyz.default-release");
572
573 let profiles = list_browser_profiles(options(temp.path())).unwrap();
574 let browsers: Vec<&str> = profiles
575 .iter()
576 .filter(|profile| profile.browser.starts_with("firefox"))
577 .map(|profile| profile.browser.as_str())
578 .collect();
579 assert_eq!(browsers, vec!["firefox"]);
580 }
581
582 fn firefox_runner() -> RunCommand {
583 Arc::new(|command: &str, args: &[&str], _env: &Environment| {
584 if command == "xdg-settings" && args == ["get", "default-web-browser"] {
585 Ok("firefox.desktop\n".to_string())
586 } else {
587 Err(anyhow!("unexpected command"))
588 }
589 })
590 }
591
592 #[test]
593 fn resolves_browser_default_to_the_system_default_browser() {
594 let runner = firefox_runner();
595 assert_eq!(
596 resolve_source_browser("default", "linux", &Environment::new(), Some(&runner)).unwrap(),
597 "firefox"
598 );
599 assert_eq!(
600 resolve_source_browser("AUTO", "linux", &Environment::new(), Some(&runner)).unwrap(),
601 "firefox"
602 );
603 }
604
605 #[test]
606 fn lists_the_default_browser_profile_when_browser_is_default() {
607 let temp = TempDir::new("bc-default-");
608 let profile_path =
609 write_firefox_profile(temp.path(), &[".mozilla", "firefox"], "xyz.default-release");
610 let runner: RunCommand = Arc::new(|_command: &str, _args: &[&str], _env: &Environment| {
611 Ok("firefox.desktop\n".to_string())
612 });
613
614 let resolved = resolve_browser_profile(
615 "default",
616 None,
617 &options(temp.path()).run_command(runner.clone()),
618 )
619 .unwrap();
620 assert_eq!(resolved.browser, "firefox");
621 assert_eq!(resolved.path, profile_path);
622
623 let profiles =
624 list_browser_profiles(options(temp.path()).browser("auto").run_command(runner))
625 .unwrap();
626 assert_eq!(
627 profiles
628 .iter()
629 .map(|profile| profile.browser.as_str())
630 .collect::<Vec<_>>(),
631 vec!["firefox"]
632 );
633 }
634
635 #[test]
636 fn reports_a_clear_error_when_the_default_browser_is_unknown() {
637 let runner: RunCommand =
638 Arc::new(|_command: &str, _args: &[&str], _env: &Environment| Err(anyhow!("no xdg")));
639 let error = resolve_source_browser("default", "linux", &Environment::new(), Some(&runner))
640 .unwrap_err()
641 .to_string();
642 assert!(
643 error.contains("Could not determine the system default browser"),
644 "{error}"
645 );
646 }
647}