use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock};
use anyhow::Result;
use regex::Regex;
use super::fs_utils::path_exists;
use super::{ClassOutcome, MigrationEntry};
use crate::browser::browser_cookies::{
read_browser_cookies, BrowserCookie, BrowserCookieReadOptions,
};
pub const DBSC_BOUND_COOKIE_NAMES: [&str; 3] = ["__Secure-1PSIDTS", "__Secure-3PSIDTS", "SIDTS"];
const DBSC_REGISTRATION_FILES: [&str; 2] = ["Network/DeviceBoundSessions", "DeviceBoundSessions"];
const 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.";
pub type CookieReader =
Arc<dyn Fn(BrowserCookieReadOptions) -> Result<Vec<BrowserCookie>> + Send + Sync>;
pub(crate) fn default_cookie_reader() -> CookieReader {
Arc::new(read_browser_cookies)
}
static GOOGLE_COUNTRY_HOST: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(^|\.)google\.[a-z.]+$").expect("the Google host pattern is valid")
});
fn is_google_host(domain: &str) -> bool {
let host = domain.strip_prefix('.').unwrap_or(domain).to_lowercase();
host == "google.com" || host.ends_with(".google.com") || GOOGLE_COUNTRY_HOST.is_match(&host)
}
pub(crate) fn is_dbsc_bound_cookie(cookie: &BrowserCookie) -> bool {
is_google_host(&cookie.domain) && DBSC_BOUND_COOKIE_NAMES.contains(&cookie.name.as_str())
}
fn find_dbsc_registration(profile_dir: &Path) -> Option<PathBuf> {
DBSC_REGISTRATION_FILES
.iter()
.map(|relative| profile_dir.join(relative))
.find(|candidate| path_exists(candidate))
}
pub(crate) struct CookieSource<'a> {
pub browser: &'a str,
pub profile: Option<&'a str>,
pub source_profile_dir: Option<&'a Path>,
pub domains: &'a [String],
pub platform: &'a str,
pub home_dir: &'a Path,
}
pub(crate) fn migrate_cookies(
source: &CookieSource<'_>,
read_cookies: &CookieReader,
) -> Result<(Vec<BrowserCookie>, ClassOutcome)> {
let domain_filters: Vec<Option<&str>> = if source.domains.is_empty() {
vec![None]
} else {
source
.domains
.iter()
.map(|domain| Some(domain.as_str()))
.collect()
};
let mut order: Vec<String> = Vec::new();
let mut seen: HashMap<String, BrowserCookie> = HashMap::new();
for domain_filter in domain_filters {
let mut options = BrowserCookieReadOptions::new(source.browser)
.ignore_decryption_errors(true)
.platform(source.platform)
.home_dir(source.home_dir);
if let Some(profile) = source.profile {
options = options.profile(profile);
}
if let Some(profile_dir) = source.source_profile_dir {
options = options.profile_dir(profile_dir);
}
if let Some(domain) = domain_filter {
options = options.domain_filter(domain);
}
for cookie in read_cookies(options)? {
if !super::domains::matches_domains(&cookie.domain, source.domains) {
continue;
}
let key = format!("{}\0{}\0{}", cookie.domain, cookie.name, cookie.path);
if !seen.contains_key(&key) {
order.push(key.clone());
}
seen.insert(key, cookie);
}
}
let cookies: Vec<BrowserCookie> = order.iter().filter_map(|key| seen.remove(key)).collect();
let skipped = cookies
.iter()
.filter(|cookie| is_dbsc_bound_cookie(cookie))
.map(|cookie| {
MigrationEntry::new(
"cookies",
format!("{} {}", cookie.domain, cookie.name),
"dbsc-bound",
)
})
.collect();
let mut warnings = Vec::new();
if source
.source_profile_dir
.and_then(find_dbsc_registration)
.is_some()
{
warnings.push(
MigrationEntry::new(
"cookies",
"DeviceBoundSessions",
"dbsc-registration-present",
)
.with_detail(DBSC_REGISTRATION_DETAIL),
);
}
let outcome = ClassOutcome {
migrated: cookies.len() as u64,
skipped,
warnings,
};
Ok((cookies, outcome))
}