diff --git a/.github/workflows/browser-integration.yml b/.github/workflows/browser-integration.yml new file mode 100644 index 0000000..192583b --- /dev/null +++ b/.github/workflows/browser-integration.yml @@ -0,0 +1,184 @@ +name: Browser Integration Tests + +on: + pull_request: + paths: + - "src/browser/profile_porter.rs" + - "src/browser/profile_store.rs" + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + linux: + name: ${{ matrix.browser }} (Linux) + runs-on: ubuntu-latest + env: + DISPLAY: ":99" + strategy: + fail-fast: false + matrix: + include: + - browser: chrome + test_filter: integration_chrome + binary: google-chrome-stable + setup: | + wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.list + sudo apt-get update && sudo apt-get install -y google-chrome-stable + - browser: brave + test_filter: integration_brave + binary: brave-browser + setup: | + sudo curl -fsSLo /usr/share/keyrings/brave-browser-archive-keyring.gpg https://brave-browser-apt-release.s3.brave.com/brave-browser-archive-keyring.gpg + echo "deb [signed-by=/usr/share/keyrings/brave-browser-archive-keyring.gpg] https://brave-browser-apt-release.s3.brave.com/ stable main" | sudo tee /etc/apt/sources.list.d/brave-browser.list + sudo apt-get update && sudo apt-get install -y brave-browser + - browser: edge + test_filter: integration_edge + binary: microsoft-edge-stable + setup: | + curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --dearmor -o /usr/share/keyrings/microsoft-edge.gpg + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-edge.gpg] https://packages.microsoft.com/repos/edge stable main" | sudo tee /etc/apt/sources.list.d/microsoft-edge.list + sudo apt-get update && sudo apt-get install -y microsoft-edge-stable + - browser: opera + test_filter: integration_opera + binary: opera + setup: | + curl -fsSL https://deb.opera.com/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/opera.gpg + echo "deb [signed-by=/usr/share/keyrings/opera.gpg] https://deb.opera.com/opera-stable/ stable non-free" | sudo tee /etc/apt/sources.list.d/opera.list + sudo apt-get update && sudo apt-get install -y opera-stable + - browser: vivaldi + test_filter: integration_vivaldi + binary: vivaldi-stable + setup: | + curl -fsSL https://repo.vivaldi.com/archive/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/vivaldi.gpg + echo "deb [signed-by=/usr/share/keyrings/vivaldi.gpg] https://repo.vivaldi.com/archive/deb/ stable main" | sudo tee /etc/apt/sources.list.d/vivaldi.list + sudo apt-get update && sudo apt-get install -y vivaldi-stable + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@nextest + + - name: Install ${{ matrix.browser }} + run: ${{ matrix.setup }} + + - name: Ensure Chrome available (for Chromium load test) + run: | + command -v google-chrome-stable &>/dev/null || ( + wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg && + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.list && + sudo apt-get update && sudo apt-get install -y google-chrome-stable + ) + + - name: Start Xvfb + run: | + sudo apt-get install -y xvfb + Xvfb :99 -screen 0 1920x1080x24 & + sleep 2 + + - name: Initialize browser profile with cookies + run: | + python3 -c " + from http.server import HTTPServer, BaseHTTPRequestHandler + class H(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header('Set-Cookie', 'steel_test=integration; Path=/; Max-Age=86400') + self.send_header('Set-Cookie', 'steel_session=abc123; Path=/; Max-Age=86400') + self.end_headers() + self.wfile.write(b'ok') + def log_message(self, *args): pass + HTTPServer(('127.0.0.1', 8765), H).serve_forever() + " & + sleep 1 + timeout 15 ${{ matrix.binary }} --no-first-run --disable-sync --disable-gpu http://127.0.0.1:8765/ || true + pkill -f "${{ matrix.binary }}" 2>/dev/null || true + kill %1 2>/dev/null || true + sleep 1 + find ~/.config -name "Cookies" -type f || true + + - name: Run integration tests + run: cargo nextest run --run-ignored only -E 'test(${{ matrix.test_filter }})' + + gnome-keyring: + name: GNOME Keyring (Linux) + runs-on: ubuntu-latest + env: + DISPLAY: ":99" + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@nextest + + - name: Install Chrome + GNOME Keyring + run: | + wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.list + sudo apt-get update && sudo apt-get install -y google-chrome-stable gnome-keyring libsecret-tools xvfb dbus-x11 + + - name: Setup, initialize, and test + run: | + Xvfb :99 -screen 0 1920x1080x24 & + sleep 2 + eval $(dbus-launch --sh-syntax) + echo "" | gnome-keyring-daemon --unlock --components=secrets + echo -n "my-custom-chrome-password" | secret-tool store --label="Chrome Safe Storage" application chrome + python3 -c " + from http.server import HTTPServer, BaseHTTPRequestHandler + class H(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header('Set-Cookie', 'steel_test=integration; Path=/; Max-Age=86400') + self.send_header('Set-Cookie', 'steel_session=abc123; Path=/; Max-Age=86400') + self.end_headers() + self.wfile.write(b'ok') + def log_message(self, *args): pass + HTTPServer(('127.0.0.1', 8765), H).serve_forever() + " & + sleep 1 + timeout 15 google-chrome-stable --no-first-run --disable-sync --disable-gpu http://127.0.0.1:8765/ || true + pkill -f "google-chrome" 2>/dev/null || true + kill %1 2>/dev/null || true + sleep 1 + STEEL_TEST_GNOME_KEYRING=1 cargo nextest run --run-ignored only -E 'test(integration_gnome_keyring)' + + macos: + name: Chrome (macOS) + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@nextest + + - name: Initialize Chrome profile with cookies + run: | + python3 -c " + from http.server import HTTPServer, BaseHTTPRequestHandler + class H(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header('Set-Cookie', 'steel_test=integration; Path=/; Max-Age=86400') + self.send_header('Set-Cookie', 'steel_session=abc123; Path=/; Max-Age=86400') + self.end_headers() + self.wfile.write(b'ok') + def log_message(self, *args): pass + HTTPServer(('127.0.0.1', 8765), H).serve_forever() + " & + sleep 1 + timeout 15 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --no-first-run --disable-sync http://127.0.0.1:8765/ || true + pkill -f "Google Chrome" 2>/dev/null || true + kill %1 2>/dev/null || true + sleep 1 + + - name: Run integration tests + run: cargo nextest run --run-ignored only -E 'test(integration_chrome)' diff --git a/src/browser/profile_porter.rs b/src/browser/profile_porter.rs index 9876676..f34dd1e 100644 --- a/src/browser/profile_porter.rs +++ b/src/browser/profile_porter.rs @@ -673,9 +673,11 @@ fn reencrypt_cookies_db(original_path: &Path, provider: &KeyProvider) -> Result< let conn = rusqlite::Connection::open(&tmp_path)?; let meta_version: i64 = conn - .query_row("SELECT value FROM meta WHERE key='version'", [], |row| { - row.get(0) - }) + .query_row( + "SELECT CAST(value AS INTEGER) FROM meta WHERE key='version'", + [], + |row| row.get(0), + ) .unwrap_or(0); let mut stmt = conn.prepare( @@ -1128,4 +1130,707 @@ mod tests { assert_eq!(files.len(), 1); assert!(files.contains_key("good.txt")); } + + struct TestCookie { + host_key: &'static str, + name: &'static str, + value: &'static str, + } + + /// Create a minimal Chromium Cookies SQLite DB with the real schema. + fn create_test_cookies_db(path: &Path, meta_version: i64, cookies: &[(&str, &str, &[u8])]) { + let conn = rusqlite::Connection::open(path).unwrap(); + conn.execute_batch( + "CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR); + CREATE TABLE cookies( + creation_utc INTEGER NOT NULL, + host_key TEXT NOT NULL DEFAULT '', + top_frame_site_key TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + value TEXT NOT NULL DEFAULT '', + encrypted_value BLOB NOT NULL DEFAULT X'', + path TEXT NOT NULL DEFAULT '/', + expires_utc INTEGER NOT NULL DEFAULT 0, + is_secure INTEGER NOT NULL DEFAULT 0, + is_httponly INTEGER NOT NULL DEFAULT 0, + last_access_utc INTEGER NOT NULL DEFAULT 0, + has_expires INTEGER NOT NULL DEFAULT 1, + is_persistent INTEGER NOT NULL DEFAULT 1, + priority INTEGER NOT NULL DEFAULT 1, + samesite INTEGER NOT NULL DEFAULT -1, + source_scheme INTEGER NOT NULL DEFAULT 0, + source_port INTEGER NOT NULL DEFAULT -1, + last_update_utc INTEGER NOT NULL DEFAULT 0, + source_type INTEGER NOT NULL DEFAULT 0, + has_cross_site_ancestor INTEGER NOT NULL DEFAULT 0 + );", + ) + .unwrap(); + conn.execute( + "INSERT INTO meta (key, value) VALUES ('version', ?1)", + rusqlite::params![meta_version], + ) + .unwrap(); + for (i, (host_key, name, encrypted_value)) in cookies.iter().enumerate() { + conn.execute( + "INSERT INTO cookies (creation_utc, host_key, name, encrypted_value, \ + last_access_utc, last_update_utc) VALUES (?1, ?2, ?3, ?4, ?1, ?1)", + rusqlite::params![i as i64 + 1, host_key, name, *encrypted_value], + ) + .unwrap(); + } + } + + /// Verify that a re-encrypted DB can be decrypted with the "peanuts" key. + fn verify_reencrypted_db( + db_bytes: &[u8], + expected_cookies: &[(&str, &str, &str)], + meta_version: i64, + ) { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), db_bytes).unwrap(); + let conn = rusqlite::Connection::open(tmp.path()).unwrap(); + let peanuts_key = derive_target_key(); + + let mut stmt = conn + .prepare("SELECT host_key, name, encrypted_value FROM cookies ORDER BY creation_utc") + .unwrap(); + let rows: Vec<(String, String, Vec)> = stmt + .query_map([], |row| { + let ev: Vec = match row.get_ref(2)? { + rusqlite::types::ValueRef::Blob(b) => b.to_vec(), + rusqlite::types::ValueRef::Text(t) => t.to_vec(), + _ => Vec::new(), + }; + Ok((row.get(0)?, row.get(1)?, ev)) + }) + .unwrap() + .collect::>() + .unwrap(); + + assert_eq!(rows.len(), expected_cookies.len(), "cookie count mismatch"); + for ((host_key, name, encrypted_value), (exp_host, exp_name, exp_value)) in + rows.iter().zip(expected_cookies.iter()) + { + assert_eq!(host_key, exp_host); + assert_eq!(name, exp_name); + let decrypted = decrypt_cookie( + encrypted_value, + &peanuts_key, + host_key, + meta_version, + CryptoAlgorithm::Aes128Cbc, + ); + assert_eq!( + decrypted.as_deref(), + Some(*exp_value), + "Cookie {name} on {host_key} not decryptable with peanuts key" + ); + } + } + + /// Find a Chromium-based binary on the system for compatibility testing. + fn find_chromium_binary() -> Option { + let candidates: &[&str] = if cfg!(target_os = "macos") { + &["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"] + } else { + &[ + "google-chrome-stable", + "google-chrome", + "chromium-browser", + "chromium", + ] + }; + for &c in candidates { + let ok = if c.contains('/') { + Path::new(c).exists() + } else { + std::process::Command::new(c) + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + }; + if ok { + return Some(c.to_string()); + } + } + None + } + + /// Extract the packaged zip, launch Chromium with the profile, and verify + /// all cookies from the DB are accessible via CDP (`Storage.getCookies`). + fn verify_chromium_loads_profile(zip_buffer: &[u8]) { + let Some(chromium) = find_chromium_binary() else { + eprintln!("Skipping Chromium load test — no binary found"); + return; + }; + + let tmp = tempfile::TempDir::new().unwrap(); + let cursor = std::io::Cursor::new(zip_buffer); + let mut archive = zip::ZipArchive::new(cursor).unwrap(); + archive.extract(tmp.path()).unwrap(); + + // Extract expected cookies directly from the packaged DB + let expected_cookies: Vec<(String, String)> = { + let db_path = tmp.path().join("Default/Cookies"); + if !db_path.exists() { + Vec::new() + } else { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + let mut stmt = conn.prepare("SELECT host_key, name FROM cookies").unwrap(); + stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .collect::>() + .unwrap() + } + }; + + // Find a free port for CDP + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + + let mut child = std::process::Command::new(&chromium) + .args([ + "--headless", + "--disable-gpu", + "--no-sandbox", + "--no-first-run", + "--disable-sync", + &format!("--user-data-dir={}", tmp.path().display()), + &format!("--remote-debugging-port={port}"), + "about:blank", + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("failed to launch Chromium"); + + // Wait for CDP to become available + let cdp_base = format!("http://127.0.0.1:{port}"); + let mut cdp_ready = false; + for _ in 0..50 { + std::thread::sleep(std::time::Duration::from_millis(200)); + if std::net::TcpStream::connect(format!("127.0.0.1:{port}")).is_ok() { + cdp_ready = true; + break; + } + } + if !cdp_ready { + child.kill().ok(); + child.wait().ok(); + panic!("Chromium CDP did not become available within 10s"); + } + + // Get the browser's WebSocket debugger URL + let version_body = http_get(&format!("{cdp_base}/json/version")); + let version: serde_json::Value = serde_json::from_str(&version_body) + .unwrap_or_else(|e| panic!("bad /json/version response: {e}\n{version_body}")); + let ws_url = version["webSocketDebuggerUrl"] + .as_str() + .expect("missing webSocketDebuggerUrl"); + + // Query cookies via CDP + let cookies_json = cdp_get_all_cookies(ws_url); + let cdp_cookies: Vec = cookies_json + .as_array() + .expect("cookies should be an array") + .to_vec(); + + // Every cookie from the packaged DB must be visible to Chromium + for (host, name) in &expected_cookies { + let found = cdp_cookies + .iter() + .any(|c| c["domain"].as_str() == Some(host) && c["name"].as_str() == Some(name)); + assert!( + found, + "Cookie {name} on {host} not found via CDP. Got: {cdp_cookies:?}" + ); + } + + child.kill().ok(); + child.wait().ok(); + } + + /// Minimal sync HTTP GET using std::net only (no reqwest dependency in tests). + fn http_get(url: &str) -> String { + let url = url.strip_prefix("http://").unwrap_or(url); + let (host_port, path) = url.split_once('/').unwrap_or((url, "")); + let path = format!("/{path}"); + + let mut stream = std::net::TcpStream::connect(host_port).unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .ok(); + let request = + format!("GET {path} HTTP/1.1\r\nHost: {host_port}\r\nConnection: close\r\n\r\n"); + std::io::Write::write_all(&mut stream, request.as_bytes()).unwrap(); + + let mut response = Vec::new(); + std::io::Read::read_to_end(&mut stream, &mut response).ok(); + let response = String::from_utf8_lossy(&response); + + // Split headers from body + response + .split_once("\r\n\r\n") + .map(|(_, body)| body.to_string()) + .unwrap_or_default() + } + + /// Connect to CDP WebSocket and call Storage.getCookies. + /// Uses a raw TCP + minimal WebSocket frame implementation. + fn cdp_get_all_cookies(ws_url: &str) -> serde_json::Value { + // Parse ws://host:port/path + let url = ws_url.strip_prefix("ws://").expect("expected ws:// URL"); + let (host_port, path) = url.split_once('/').unwrap_or((url, "")); + let path = format!("/{path}"); + + let mut stream = std::net::TcpStream::connect(host_port).unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(10))) + .ok(); + + // WebSocket handshake + let handshake = format!( + "GET {path} HTTP/1.1\r\n\ + Host: {host_port}\r\n\ + Upgrade: websocket\r\n\ + Connection: Upgrade\r\n\ + Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ + Sec-WebSocket-Version: 13\r\n\r\n" + ); + std::io::Write::write_all(&mut stream, handshake.as_bytes()).unwrap(); + + // Read HTTP response headers + let mut header_buf = Vec::new(); + loop { + let mut byte = [0u8; 1]; + std::io::Read::read_exact(&mut stream, &mut byte).unwrap(); + header_buf.push(byte[0]); + if header_buf.ends_with(b"\r\n\r\n") { + break; + } + } + + let header_str = String::from_utf8_lossy(&header_buf); + assert!( + header_str.contains("101"), + "WebSocket upgrade failed: {header_str}" + ); + + // Send CDP command: Storage.getCookies + let cmd = serde_json::json!({"id": 1, "method": "Storage.getCookies"}); + let payload = serde_json::to_vec(&cmd).unwrap(); + ws_send_frame(&mut stream, &payload); + + // Read response frames until we get our result (id: 1) + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + if std::time::Instant::now() > deadline { + panic!("Timed out waiting for CDP response"); + } + let frame = ws_read_frame(&mut stream); + if let Ok(msg) = serde_json::from_slice::(&frame) + && msg.get("id") == Some(&serde_json::json!(1)) + { + return msg["result"]["cookies"].clone(); + } + } + } + + fn ws_send_frame(stream: &mut std::net::TcpStream, payload: &[u8]) { + let mut frame = Vec::new(); + // FIN + text opcode + frame.push(0x81); + // Masked + length + let len = payload.len(); + if len < 126 { + frame.push(0x80 | len as u8); + } else if len <= 65535 { + frame.push(0x80 | 126); + frame.extend_from_slice(&(len as u16).to_be_bytes()); + } else { + frame.push(0x80 | 127); + frame.extend_from_slice(&(len as u64).to_be_bytes()); + } + // Masking key (all zeros — simple, fine for tests) + let mask = [0u8; 4]; + frame.extend_from_slice(&mask); + // Payload (mask XOR with zero = identity) + frame.extend_from_slice(payload); + std::io::Write::write_all(stream, &frame).unwrap(); + } + + fn ws_read_frame(stream: &mut std::net::TcpStream) -> Vec { + let mut header = [0u8; 2]; + std::io::Read::read_exact(stream, &mut header).unwrap(); + + let masked = header[1] & 0x80 != 0; + let mut len = (header[1] & 0x7F) as u64; + + if len == 126 { + let mut buf = [0u8; 2]; + std::io::Read::read_exact(stream, &mut buf).unwrap(); + len = u16::from_be_bytes(buf) as u64; + } else if len == 127 { + let mut buf = [0u8; 8]; + std::io::Read::read_exact(stream, &mut buf).unwrap(); + len = u64::from_be_bytes(buf); + } + + let mask_key = if masked { + let mut buf = [0u8; 4]; + std::io::Read::read_exact(stream, &mut buf).unwrap(); + buf + } else { + [0u8; 4] + }; + + let mut payload = vec![0u8; len as usize]; + std::io::Read::read_exact(stream, &mut payload).unwrap(); + + if masked { + for (i, byte) in payload.iter_mut().enumerate() { + *byte ^= mask_key[i % 4]; + } + } + payload + } + + // ─── Unit tests: reencrypt pipeline ────────────────────────────────────── + + #[test] + fn reencrypt_cookies_db_synthetic() { + let tmp = tempfile::TempDir::new().unwrap(); + let cookies_path = tmp.path().join("Cookies"); + + let source_key = derive_key("testpass", 1); + let target_key = derive_target_key(); + assert_ne!(source_key, target_key, "source and target keys must differ"); + + let meta_version = 20; + let test_cookies = [ + TestCookie { + host_key: ".example.com", + name: "session", + value: "abc123", + }, + TestCookie { + host_key: ".google.com", + name: "NID", + value: "some_value", + }, + TestCookie { + host_key: "localhost", + name: "dev_token", + value: "dev_secret", + }, + ]; + + let encrypted: Vec<(&str, &str, Vec)> = test_cookies + .iter() + .map(|c| { + let enc = encrypt_cookie(c.value, &source_key, c.host_key, meta_version); + (c.host_key, c.name, enc) + }) + .collect(); + let cookie_refs: Vec<(&str, &str, &[u8])> = encrypted + .iter() + .map(|(h, n, e)| (*h, *n, e.as_slice())) + .collect(); + + create_test_cookies_db(&cookies_path, meta_version, &cookie_refs); + + let mut sk = [0u8; 32]; + sk[..16].copy_from_slice(&source_key); + let provider = KeyProvider { + source_key: sk, + source_key_len: 16, + source_algorithm: CryptoAlgorithm::Aes128Cbc, + }; + + let result = reencrypt_cookies_db(&cookies_path, &provider).unwrap(); + assert_eq!(result.converted, 3); + assert_eq!(result.skipped, 0); + + verify_reencrypted_db( + &result.buffer, + &[ + (".example.com", "session", "abc123"), + (".google.com", "NID", "some_value"), + ("localhost", "dev_token", "dev_secret"), + ], + meta_version, + ); + } + + #[test] + fn reencrypt_cookies_db_v24_domain_hash() { + let tmp = tempfile::TempDir::new().unwrap(); + let cookies_path = tmp.path().join("Cookies"); + + let source_key = derive_key("testpass", 1); + let meta_version = 24; + + let test_cookies = [ + TestCookie { + host_key: ".example.com", + name: "session", + value: "v24_value", + }, + TestCookie { + host_key: ".domain.org", + name: "pref", + value: "domain_pref", + }, + ]; + + let encrypted: Vec<(&str, &str, Vec)> = test_cookies + .iter() + .map(|c| { + let enc = encrypt_cookie(c.value, &source_key, c.host_key, meta_version); + (c.host_key, c.name, enc) + }) + .collect(); + let cookie_refs: Vec<(&str, &str, &[u8])> = encrypted + .iter() + .map(|(h, n, e)| (*h, *n, e.as_slice())) + .collect(); + + create_test_cookies_db(&cookies_path, meta_version, &cookie_refs); + + let mut sk = [0u8; 32]; + sk[..16].copy_from_slice(&source_key); + let provider = KeyProvider { + source_key: sk, + source_key_len: 16, + source_algorithm: CryptoAlgorithm::Aes128Cbc, + }; + + let result = reencrypt_cookies_db(&cookies_path, &provider).unwrap(); + assert_eq!(result.converted, 2); + assert_eq!(result.skipped, 0); + + verify_reencrypted_db( + &result.buffer, + &[ + (".example.com", "session", "v24_value"), + (".domain.org", "pref", "domain_pref"), + ], + meta_version, + ); + } + + #[test] + fn reencrypt_mixed_valid_and_corrupt() { + let tmp = tempfile::TempDir::new().unwrap(); + let cookies_path = tmp.path().join("Cookies"); + + let source_key = derive_key("testpass", 1); + let meta_version = 20; + + let valid_enc = encrypt_cookie("good_value", &source_key, ".example.com", meta_version); + let corrupt1 = b"v10garbage_not_valid_ciphertext".to_vec(); + let corrupt2 = b"v10\x00\x01\x02\x03".to_vec(); + + let cookies: Vec<(&str, &str, &[u8])> = vec![ + (".example.com", "good", valid_enc.as_slice()), + (".bad1.com", "corrupt1", corrupt1.as_slice()), + (".bad2.com", "corrupt2", corrupt2.as_slice()), + ]; + + create_test_cookies_db(&cookies_path, meta_version, &cookies); + + let mut sk = [0u8; 32]; + sk[..16].copy_from_slice(&source_key); + let provider = KeyProvider { + source_key: sk, + source_key_len: 16, + source_algorithm: CryptoAlgorithm::Aes128Cbc, + }; + + let result = reencrypt_cookies_db(&cookies_path, &provider).unwrap(); + assert_eq!(result.converted, 1); + assert_eq!(result.skipped, 2); + } + + // ─── Browser integration tests (#[ignore]) ────────────────────────────── + + fn run_browser_integration_test(browser: BrowserId) { + if cfg!(target_os = "windows") { + eprintln!("Skipping integration test on Windows"); + return; + } + + // 1. Browser installation check + let profiles = find_browser_profiles(browser); + if profiles.is_empty() { + eprintln!("Skipping {} — no profiles found", browser.display_name()); + return; + } + + // 2. Key extraction + let provider = get_key_provider(browser) + .unwrap_or_else(|e| panic!("Failed to get key for {}: {e}", browser.display_name())); + + // 3. Schema verification on the real profile + let desc = browser.descriptor(); + let base_dir = desc.profile_base_dir().unwrap(); + let profile = &profiles[0]; + let cookies_path = base_dir.join(&profile.dir_name).join("Cookies"); + + if cookies_path.exists() { + let conn = rusqlite::Connection::open(&cookies_path).unwrap(); + let _v: i64 = conn + .query_row( + "SELECT CAST(value AS INTEGER) FROM meta WHERE key='version'", + [], + |r| r.get(0), + ) + .expect("meta table should have version"); + let mut stmt = conn.prepare("PRAGMA table_info(cookies)").unwrap(); + let columns: Vec = stmt + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::>() + .unwrap(); + assert!( + columns.contains(&"host_key".to_string()), + "missing host_key" + ); + assert!( + columns.contains(&"encrypted_value".to_string()), + "missing encrypted_value" + ); + } + + // 4. Reencrypt the real cookies DB + let reencrypt = reencrypt_cookies_db(&cookies_path, &provider).unwrap(); + eprintln!( + "{}: {} converted, {} skipped", + browser.display_name(), + reencrypt.converted, + reencrypt.skipped + ); + + // 5. Package the real profile + let pkg = package_profile(browser, &profile.dir_name, false, &|_| {}).unwrap(); + + // 6. Verify zip structure + let cursor = std::io::Cursor::new(&pkg.zip_buffer); + let mut archive = zip::ZipArchive::new(cursor).unwrap(); + + { + let mut entry = archive + .by_name("Default/Cookies") + .expect("zip missing Default/Cookies"); + let mut buf = Vec::new(); + std::io::Read::read_to_end(&mut entry, &mut buf).unwrap(); + assert_eq!(&buf[..16], b"SQLite format 3\0", "not a valid SQLite file"); + } + + { + let mut entry = archive + .by_name("Default/Preferences") + .expect("zip missing Default/Preferences"); + let mut buf = Vec::new(); + std::io::Read::read_to_end(&mut entry, &mut buf).unwrap(); + let prefs: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!(prefs["profile"]["exit_type"], "Normal"); + } + + for i in 0..archive.len() { + let name = archive.by_index(i).unwrap().name().to_string(); + assert!(!name.contains("LOCK"), "zip contains LOCK: {name}"); + assert!(!name.ends_with(".log"), "zip contains .log: {name}"); + assert!(!name.ends_with(".pma"), "zip contains .pma: {name}"); + } + + // 7. Launch Chromium with packaged profile, verify cookies via CDP + verify_chromium_loads_profile(&pkg.zip_buffer); + } + + #[test] + #[ignore] + fn integration_chrome_profile_import() { + run_browser_integration_test(BrowserId::Chrome); + } + + #[test] + #[ignore] + fn integration_brave_profile_import() { + run_browser_integration_test(BrowserId::Brave); + } + + #[test] + #[ignore] + fn integration_edge_profile_import() { + run_browser_integration_test(BrowserId::Edge); + } + + #[test] + #[ignore] + fn integration_opera_profile_import() { + run_browser_integration_test(BrowserId::Opera); + } + + #[test] + #[ignore] + fn integration_vivaldi_profile_import() { + run_browser_integration_test(BrowserId::Vivaldi); + } + + #[test] + #[ignore] + fn integration_gnome_keyring_key_differentiation() { + if std::env::var("STEEL_TEST_GNOME_KEYRING").is_err() { + eprintln!("Skipping — STEEL_TEST_GNOME_KEYRING not set"); + return; + } + + let provider = + get_key_provider(BrowserId::Chrome).expect("Chrome key provider with GNOME Keyring"); + let source_key_16: [u8; 16] = provider.source_key[..16].try_into().unwrap(); + let peanuts_key = derive_target_key(); + assert_ne!( + source_key_16, peanuts_key, + "GNOME Keyring source key should differ from peanuts" + ); + + let tmp = tempfile::TempDir::new().unwrap(); + let cookies_path = tmp.path().join("Cookies"); + let meta_version = 20; + + let test_cookies = [TestCookie { + host_key: ".gnome.test", + name: "keyring", + value: "secret_from_keyring", + }]; + let encrypted: Vec<(&str, &str, Vec)> = test_cookies + .iter() + .map(|c| { + let enc = encrypt_cookie(c.value, &source_key_16, c.host_key, meta_version); + (c.host_key, c.name, enc) + }) + .collect(); + let cookie_refs: Vec<(&str, &str, &[u8])> = encrypted + .iter() + .map(|(h, n, e)| (*h, *n, e.as_slice())) + .collect(); + + create_test_cookies_db(&cookies_path, meta_version, &cookie_refs); + + let result = reencrypt_cookies_db(&cookies_path, &provider).unwrap(); + assert_eq!(result.converted, 1); + assert_eq!(result.skipped, 0); + + verify_reencrypted_db( + &result.buffer, + &[(".gnome.test", "keyring", "secret_from_keyring")], + meta_version, + ); + } }