browser-commander 0.10.9

Universal browser automation library that supports multiple browser engines with a unified API
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use aes::Aes128;
use browser_commander::{
    list_browser_profiles, read_browser_cookies, BrowserCookieReadOptions, BrowserProfileOptions,
};
use cbc::cipher::{block_padding::Pkcs7, BlockEncryptMut, KeyIvInit};
use pbkdf2::pbkdf2_hmac;
use rusqlite::{params, Connection};
use sha1::Sha1;
use sha2::{Digest, Sha256};

const CHROME_EPOCH_OFFSET_SECONDS: i64 = 11_644_473_600;

type Aes128CbcEncryptor = cbc::Encryptor<Aes128>;

#[test]
fn discovers_and_reads_installed_browser_cookie_profiles() -> anyhow::Result<()> {
    let temporary_directory = TempDir::new("browser-cookies")?;
    let host = ".example.com";
    let chrome_path = create_chromium_profile(temporary_directory.path(), host)?;
    let firefox_path = create_firefox_profile(temporary_directory.path())?;

    let profiles = list_browser_profiles(
        BrowserProfileOptions::default()
            .home_dir(temporary_directory.path())
            .platform("linux"),
    )?;
    assert_eq!(profiles.len(), 2);
    assert_eq!(profiles[0].browser, "chrome");
    assert_eq!(profiles[0].display_name, "Primary profile");
    assert_eq!(profiles[0].path, chrome_path);
    assert!(profiles[0].is_default);
    assert_eq!(profiles[1].browser, "firefox");
    assert_eq!(profiles[1].path, firefox_path);

    let cookies = read_browser_cookies(
        BrowserCookieReadOptions::new("chrome")
            .home_dir(temporary_directory.path())
            .platform("linux")
            .domain_filter("example.com")
            .cache(false),
    )?;
    assert_eq!(cookies.len(), 1);
    assert_eq!(cookies[0].name, "SID");
    assert_eq!(cookies[0].value, "decrypted-session");
    assert_eq!(cookies[0].domain, host);
    assert_eq!(cookies[0].path, "/");
    assert_eq!(cookies[0].expires, 2_000_000_000);
    assert!(cookies[0].http_only);
    assert!(cookies[0].secure);
    assert_eq!(cookies[0].same_site, "Strict");

    let firefox_cookies = read_browser_cookies(
        BrowserCookieReadOptions::new("firefox")
            .home_dir(temporary_directory.path())
            .platform("linux")
            .cache(false),
    )?;
    assert_eq!(firefox_cookies.len(), 1);
    assert_eq!(firefox_cookies[0].name, "firefox-session");
    assert_eq!(firefox_cookies[0].value, "plain-value");
    assert_eq!(firefox_cookies[0].same_site, "Lax");

    Ok(())
}

#[test]
fn partial_result_cache_is_not_reused_for_strict_import() -> anyhow::Result<()> {
    let temporary_directory = TempDir::new("browser-cookie-strict-cache")?;
    let profile = create_chromium_profile(temporary_directory.path(), ".strict.example")?;
    let database = Connection::open(profile.join("Network/Cookies"))?;
    database.execute(
        "UPDATE cookies SET encrypted_value = ?1",
        params![b"v10invalid-cbc".as_slice()],
    )?;
    drop(database);
    let cache_dir = temporary_directory.path().join("cache");

    let partial = read_browser_cookies(
        BrowserCookieReadOptions::new("chrome")
            .home_dir(temporary_directory.path())
            .platform("linux")
            .cache_dir(&cache_dir)
            .ignore_decryption_errors(true),
    )?;
    assert!(partial.is_empty());

    let strict = read_browser_cookies(
        BrowserCookieReadOptions::new("chrome")
            .home_dir(temporary_directory.path())
            .platform("linux")
            .cache_dir(cache_dir),
    );
    assert!(strict
        .unwrap_err()
        .to_string()
        .contains("Could not decrypt cookie SID"));
    Ok(())
}

fn create_chromium_profile(home: &Path, host: &str) -> anyhow::Result<PathBuf> {
    let root = home.join(".config/google-chrome");
    let profile = root.join("Default");
    let cookie_path = profile.join("Network/Cookies");
    fs::create_dir_all(cookie_path.parent().unwrap())?;
    fs::write(
        root.join("Local State"),
        r#"{"profile":{"last_used":"Default","info_cache":{"Default":{"name":"Primary profile"}}}}"#,
    )?;

    let database = Connection::open(cookie_path)?;
    database.execute_batch(
        "CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);\
         INSERT INTO meta (key, value) VALUES ('version', '24');\
         CREATE TABLE cookies (\
           host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT,\
           expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, samesite INTEGER\
         );",
    )?;
    database.execute(
        "INSERT INTO cookies VALUES (?, 'SID', '', ?, '/', ?, 1, 1, 2)",
        params![
            host,
            encrypt_linux_cookie(host, "decrypted-session"),
            (2_000_000_000_i64 + CHROME_EPOCH_OFFSET_SECONDS) * 1_000_000
        ],
    )?;
    Ok(profile)
}

fn encrypt_linux_cookie(host: &str, value: &str) -> Vec<u8> {
    let mut key = [0_u8; 16];
    pbkdf2_hmac::<Sha1>(b"peanuts", b"saltysalt", 1, &mut key);
    let mut plaintext = Sha256::digest(host.as_bytes()).to_vec();
    plaintext.extend_from_slice(value.as_bytes());
    let encrypted = Aes128CbcEncryptor::new(&key.into(), &[0x20; 16].into())
        .encrypt_padded_vec_mut::<Pkcs7>(&plaintext);
    [b"v10".as_slice(), encrypted.as_slice()].concat()
}

fn create_firefox_profile(home: &Path) -> anyhow::Result<PathBuf> {
    let root = home.join(".mozilla/firefox");
    let profile = root.join("fixture.default-release");
    fs::create_dir_all(&profile)?;
    fs::write(
        root.join("profiles.ini"),
        "[Profile0]\nName=default-release\nIsRelative=1\nPath=fixture.default-release\nDefault=1\n",
    )?;
    let database = Connection::open(profile.join("cookies.sqlite"))?;
    database.execute_batch(
        "CREATE TABLE moz_cookies (\
           name TEXT, value TEXT, host TEXT, path TEXT, expiry INTEGER,\
           isSecure INTEGER, isHttpOnly INTEGER, sameSite INTEGER\
         );\
         INSERT INTO moz_cookies VALUES (\
           'firefox-session', 'plain-value', '.example.org', '/', 2000000001, 1, 0, 1\
         );",
    )?;
    Ok(profile)
}

struct TempDir(PathBuf);

impl TempDir {
    fn new(label: &str) -> std::io::Result<Self> {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|duration| duration.as_nanos())
            .unwrap_or_default();
        let path = std::env::temp_dir().join(format!(
            "browser-commander-{label}-{}-{unique}",
            std::process::id()
        ));
        fs::create_dir_all(&path)?;
        Ok(Self(path))
    }

    fn path(&self) -> &Path {
        &self.0
    }
}

impl Drop for TempDir {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.0);
    }
}