browser_commander/browser/migration/
cookies.rs1use std::collections::HashMap;
34use std::path::{Path, PathBuf};
35use std::sync::{Arc, LazyLock};
36
37use anyhow::Result;
38use regex::Regex;
39
40use super::fs_utils::path_exists;
41use super::{ClassOutcome, MigrationEntry};
42use crate::browser::browser_cookies::{
43 read_browser_cookies, BrowserCookie, BrowserCookieReadOptions,
44};
45
46pub const DBSC_BOUND_COOKIE_NAMES: [&str; 3] = ["__Secure-1PSIDTS", "__Secure-3PSIDTS", "SIDTS"];
48
49const DBSC_REGISTRATION_FILES: [&str; 2] = ["Network/DeviceBoundSessions", "DeviceBoundSessions"];
51
52const DBSC_REGISTRATION_DETAIL: &str = "The source profile has a Device Bound Session registration; cookies covered by it are bound to the source device key and will expire in the migrated profile.";
53
54pub type CookieReader =
56 Arc<dyn Fn(BrowserCookieReadOptions) -> Result<Vec<BrowserCookie>> + Send + Sync>;
57
58pub(crate) fn default_cookie_reader() -> CookieReader {
60 Arc::new(read_browser_cookies)
61}
62
63static GOOGLE_COUNTRY_HOST: LazyLock<Regex> = LazyLock::new(|| {
64 Regex::new(r"(^|\.)google\.[a-z.]+$").expect("the Google host pattern is valid")
65});
66
67fn is_google_host(domain: &str) -> bool {
68 let host = domain.strip_prefix('.').unwrap_or(domain).to_lowercase();
69 host == "google.com" || host.ends_with(".google.com") || GOOGLE_COUNTRY_HOST.is_match(&host)
70}
71
72pub(crate) fn is_dbsc_bound_cookie(cookie: &BrowserCookie) -> bool {
74 is_google_host(&cookie.domain) && DBSC_BOUND_COOKIE_NAMES.contains(&cookie.name.as_str())
75}
76
77fn find_dbsc_registration(profile_dir: &Path) -> Option<PathBuf> {
78 DBSC_REGISTRATION_FILES
79 .iter()
80 .map(|relative| profile_dir.join(relative))
81 .find(|candidate| path_exists(candidate))
82}
83
84pub(crate) struct CookieSource<'a> {
86 pub browser: &'a str,
87 pub profile: Option<&'a str>,
88 pub source_profile_dir: Option<&'a Path>,
89 pub domains: &'a [String],
90 pub platform: &'a str,
91 pub home_dir: &'a Path,
92}
93
94pub(crate) fn migrate_cookies(
97 source: &CookieSource<'_>,
98 read_cookies: &CookieReader,
99) -> Result<(Vec<BrowserCookie>, ClassOutcome)> {
100 let domain_filters: Vec<Option<&str>> = if source.domains.is_empty() {
101 vec![None]
102 } else {
103 source
104 .domains
105 .iter()
106 .map(|domain| Some(domain.as_str()))
107 .collect()
108 };
109
110 let mut order: Vec<String> = Vec::new();
112 let mut seen: HashMap<String, BrowserCookie> = HashMap::new();
113 for domain_filter in domain_filters {
114 let mut options = BrowserCookieReadOptions::new(source.browser)
115 .ignore_decryption_errors(true)
116 .platform(source.platform)
117 .home_dir(source.home_dir);
118 if let Some(profile) = source.profile {
119 options = options.profile(profile);
120 }
121 if let Some(domain) = domain_filter {
122 options = options.domain_filter(domain);
123 }
124 for cookie in read_cookies(options)? {
125 let key = format!("{}\0{}\0{}", cookie.domain, cookie.name, cookie.path);
126 if !seen.contains_key(&key) {
127 order.push(key.clone());
128 }
129 seen.insert(key, cookie);
130 }
131 }
132 let cookies: Vec<BrowserCookie> = order.iter().filter_map(|key| seen.remove(key)).collect();
133
134 let skipped = cookies
135 .iter()
136 .filter(|cookie| is_dbsc_bound_cookie(cookie))
137 .map(|cookie| {
138 MigrationEntry::new(
139 "cookies",
140 format!("{} {}", cookie.domain, cookie.name),
141 "dbsc-bound",
142 )
143 })
144 .collect();
145
146 let mut warnings = Vec::new();
147 if source
148 .source_profile_dir
149 .and_then(find_dbsc_registration)
150 .is_some()
151 {
152 warnings.push(
153 MigrationEntry::new(
154 "cookies",
155 "DeviceBoundSessions",
156 "dbsc-registration-present",
157 )
158 .with_detail(DBSC_REGISTRATION_DETAIL),
159 );
160 }
161
162 let outcome = ClassOutcome {
163 migrated: cookies.len() as u64,
164 skipped,
165 warnings,
166 };
167 Ok((cookies, outcome))
168}