@@ -1292,9 +1292,9 @@ mod tests {
12921292 None
12931293 }
12941294
1295- /// Extract the packaged zip and launch Chromium headless to verify the
1296- /// profile loads without crashing .
1297- fn verify_chromium_loads_profile ( zip_buffer : & [ u8 ] ) {
1295+ /// Extract the packaged zip, launch Chromium with the profile, and verify
1296+ /// cookies are accessible via CDP (`Storage.getCookies`) .
1297+ fn verify_chromium_loads_profile ( zip_buffer : & [ u8 ] , expected_cookies : & [ ( & str , & str ) ] ) {
12981298 let Some ( chromium) = find_chromium_binary ( ) else {
12991299 eprintln ! ( "Skipping Chromium load test — no binary found" ) ;
13001300 return ;
@@ -1305,25 +1305,228 @@ mod tests {
13051305 let mut archive = zip:: ZipArchive :: new ( cursor) . unwrap ( ) ;
13061306 archive. extract ( tmp. path ( ) ) . unwrap ( ) ;
13071307
1308- let output = std:: process:: Command :: new ( & chromium)
1308+ // Find a free port for CDP
1309+ let listener = std:: net:: TcpListener :: bind ( "127.0.0.1:0" ) . unwrap ( ) ;
1310+ let port = listener. local_addr ( ) . unwrap ( ) . port ( ) ;
1311+ drop ( listener) ;
1312+
1313+ let mut child = std:: process:: Command :: new ( & chromium)
13091314 . args ( [
13101315 "--headless" ,
13111316 "--disable-gpu" ,
13121317 "--no-sandbox" ,
13131318 "--no-first-run" ,
13141319 "--disable-sync" ,
13151320 & format ! ( "--user-data-dir={}" , tmp. path( ) . display( ) ) ,
1316- "--dump-dom" ,
1321+ & format ! ( "--remote-debugging-port={port}" ) ,
13171322 "about:blank" ,
13181323 ] )
1319- . output ( )
1324+ . stdout ( std:: process:: Stdio :: null ( ) )
1325+ . stderr ( std:: process:: Stdio :: null ( ) )
1326+ . spawn ( )
13201327 . expect ( "failed to launch Chromium" ) ;
13211328
1329+ // Wait for CDP to become available
1330+ let cdp_base = format ! ( "http://127.0.0.1:{port}" ) ;
1331+ let mut cdp_ready = false ;
1332+ for _ in 0 ..50 {
1333+ std:: thread:: sleep ( std:: time:: Duration :: from_millis ( 200 ) ) ;
1334+ if std:: net:: TcpStream :: connect ( format ! ( "127.0.0.1:{port}" ) ) . is_ok ( ) {
1335+ cdp_ready = true ;
1336+ break ;
1337+ }
1338+ }
1339+ if !cdp_ready {
1340+ child. kill ( ) . ok ( ) ;
1341+ child. wait ( ) . ok ( ) ;
1342+ panic ! ( "Chromium CDP did not become available within 10s" ) ;
1343+ }
1344+
1345+ // Get the first page's WebSocket debugger URL
1346+ let version_body = http_get ( & format ! ( "{cdp_base}/json/version" ) ) ;
1347+ let version: serde_json:: Value = serde_json:: from_str ( & version_body)
1348+ . unwrap_or_else ( |e| panic ! ( "bad /json/version response: {e}\n {version_body}" ) ) ;
1349+ let ws_url = version[ "webSocketDebuggerUrl" ]
1350+ . as_str ( )
1351+ . expect ( "missing webSocketDebuggerUrl" ) ;
1352+
1353+ // Connect to WebSocket and call Storage.getCookies
1354+ let cookies_json = cdp_get_all_cookies ( ws_url) ;
1355+ let cookies: Vec < serde_json:: Value > = cookies_json
1356+ . as_array ( )
1357+ . expect ( "cookies should be an array" )
1358+ . to_vec ( ) ;
1359+
1360+ // Verify expected cookies are present
1361+ for ( host, name) in expected_cookies {
1362+ let found = cookies. iter ( ) . any ( |c| {
1363+ c[ "domain" ] . as_str ( ) == Some ( host) && c[ "name" ] . as_str ( ) == Some ( name)
1364+ } ) ;
1365+ assert ! ( found, "Cookie {name} on {host} not found via CDP. Got: {cookies:?}" ) ;
1366+ }
1367+
1368+ child. kill ( ) . ok ( ) ;
1369+ child. wait ( ) . ok ( ) ;
1370+ }
1371+
1372+ /// Minimal sync HTTP GET using std::net only (no reqwest dependency in tests).
1373+ fn http_get ( url : & str ) -> String {
1374+ let url = url. strip_prefix ( "http://" ) . unwrap_or ( url) ;
1375+ let ( host_port, path) = url. split_once ( '/' ) . unwrap_or ( ( url, "" ) ) ;
1376+ let path = format ! ( "/{path}" ) ;
1377+
1378+ let mut stream = std:: net:: TcpStream :: connect ( host_port) . unwrap ( ) ;
1379+ stream
1380+ . set_read_timeout ( Some ( std:: time:: Duration :: from_secs ( 5 ) ) )
1381+ . ok ( ) ;
1382+ let request = format ! ( "GET {path} HTTP/1.1\r \n Host: {host_port}\r \n Connection: close\r \n \r \n " ) ;
1383+ std:: io:: Write :: write_all ( & mut stream, request. as_bytes ( ) ) . unwrap ( ) ;
1384+
1385+ let mut response = Vec :: new ( ) ;
1386+ std:: io:: Read :: read_to_end ( & mut stream, & mut response) . ok ( ) ;
1387+ let response = String :: from_utf8_lossy ( & response) ;
1388+
1389+ // Split headers from body
1390+ response
1391+ . split_once ( "\r \n \r \n " )
1392+ . map ( |( _, body) | body. to_string ( ) )
1393+ . unwrap_or_default ( )
1394+ }
1395+
1396+ /// Connect to CDP WebSocket and call Storage.getCookies.
1397+ /// Uses a raw TCP + minimal WebSocket frame implementation.
1398+ fn cdp_get_all_cookies ( ws_url : & str ) -> serde_json:: Value {
1399+ use sha1:: { Digest , Sha1 } ;
1400+
1401+ // Parse ws://host:port/path
1402+ let url = ws_url. strip_prefix ( "ws://" ) . expect ( "expected ws:// URL" ) ;
1403+ let ( host_port, path) = url. split_once ( '/' ) . unwrap_or ( ( url, "" ) ) ;
1404+ let path = format ! ( "/{path}" ) ;
1405+
1406+ let mut stream = std:: net:: TcpStream :: connect ( host_port) . unwrap ( ) ;
1407+ stream
1408+ . set_read_timeout ( Some ( std:: time:: Duration :: from_secs ( 10 ) ) )
1409+ . ok ( ) ;
1410+
1411+ // WebSocket handshake
1412+ let ws_key = "dGhlIHNhbXBsZSBub25jZQ==" ; // static key, fine for tests
1413+ let handshake = format ! (
1414+ "GET {path} HTTP/1.1\r \n \
1415+ Host: {host_port}\r \n \
1416+ Upgrade: websocket\r \n \
1417+ Connection: Upgrade\r \n \
1418+ Sec-WebSocket-Key: {ws_key}\r \n \
1419+ Sec-WebSocket-Version: 13\r \n \r \n "
1420+ ) ;
1421+ std:: io:: Write :: write_all ( & mut stream, handshake. as_bytes ( ) ) . unwrap ( ) ;
1422+
1423+ // Read until we get the end of HTTP headers
1424+ let mut header_buf = Vec :: new ( ) ;
1425+ loop {
1426+ let mut byte = [ 0u8 ; 1 ] ;
1427+ std:: io:: Read :: read_exact ( & mut stream, & mut byte) . unwrap ( ) ;
1428+ header_buf. push ( byte[ 0 ] ) ;
1429+ if header_buf. ends_with ( b"\r \n \r \n " ) {
1430+ break ;
1431+ }
1432+ }
1433+
1434+ let header_str = String :: from_utf8_lossy ( & header_buf) ;
13221435 assert ! (
1323- output. status. success( ) ,
1324- "Chromium failed to load packaged profile:\n {}" ,
1325- String :: from_utf8_lossy( & output. stderr)
1436+ header_str. contains( "101" ) ,
1437+ "WebSocket upgrade failed: {header_str}"
13261438 ) ;
1439+
1440+ // Verify Sec-WebSocket-Accept
1441+ let expected_accept = {
1442+ let mut hasher = Sha1 :: new ( ) ;
1443+ hasher. update ( ws_key. as_bytes ( ) ) ;
1444+ hasher. update ( b"258EAFA5-E914-47DA-95CA-5AB5DC11650B" ) ;
1445+ use base64:: Engine ;
1446+ base64:: engine:: general_purpose:: STANDARD . encode ( hasher. finalize ( ) )
1447+ } ;
1448+ assert ! (
1449+ header_str. contains( & expected_accept) ,
1450+ "bad Sec-WebSocket-Accept"
1451+ ) ;
1452+
1453+ // Send CDP command: Storage.getCookies
1454+ let cmd = serde_json:: json!( { "id" : 1 , "method" : "Storage.getCookies" } ) ;
1455+ let payload = serde_json:: to_vec ( & cmd) . unwrap ( ) ;
1456+ ws_send_frame ( & mut stream, & payload) ;
1457+
1458+ // Read response frames until we get our result (id: 1)
1459+ let deadline = std:: time:: Instant :: now ( ) + std:: time:: Duration :: from_secs ( 10 ) ;
1460+ loop {
1461+ if std:: time:: Instant :: now ( ) > deadline {
1462+ panic ! ( "Timed out waiting for CDP response" ) ;
1463+ }
1464+ let frame = ws_read_frame ( & mut stream) ;
1465+ if let Ok ( msg) = serde_json:: from_slice :: < serde_json:: Value > ( & frame)
1466+ && msg. get ( "id" ) == Some ( & serde_json:: json!( 1 ) )
1467+ {
1468+ return msg[ "result" ] [ "cookies" ] . clone ( ) ;
1469+ }
1470+ }
1471+ }
1472+
1473+ fn ws_send_frame ( stream : & mut std:: net:: TcpStream , payload : & [ u8 ] ) {
1474+ let mut frame = Vec :: new ( ) ;
1475+ // FIN + text opcode
1476+ frame. push ( 0x81 ) ;
1477+ // Masked + length
1478+ let len = payload. len ( ) ;
1479+ if len < 126 {
1480+ frame. push ( 0x80 | len as u8 ) ;
1481+ } else if len <= 65535 {
1482+ frame. push ( 0x80 | 126 ) ;
1483+ frame. extend_from_slice ( & ( len as u16 ) . to_be_bytes ( ) ) ;
1484+ } else {
1485+ frame. push ( 0x80 | 127 ) ;
1486+ frame. extend_from_slice ( & ( len as u64 ) . to_be_bytes ( ) ) ;
1487+ }
1488+ // Masking key (all zeros — simple, fine for tests)
1489+ let mask = [ 0u8 ; 4 ] ;
1490+ frame. extend_from_slice ( & mask) ;
1491+ // Payload (mask XOR with zero = identity)
1492+ frame. extend_from_slice ( payload) ;
1493+ std:: io:: Write :: write_all ( stream, & frame) . unwrap ( ) ;
1494+ }
1495+
1496+ fn ws_read_frame ( stream : & mut std:: net:: TcpStream ) -> Vec < u8 > {
1497+ let mut header = [ 0u8 ; 2 ] ;
1498+ std:: io:: Read :: read_exact ( stream, & mut header) . unwrap ( ) ;
1499+
1500+ let masked = header[ 1 ] & 0x80 != 0 ;
1501+ let mut len = ( header[ 1 ] & 0x7F ) as u64 ;
1502+
1503+ if len == 126 {
1504+ let mut buf = [ 0u8 ; 2 ] ;
1505+ std:: io:: Read :: read_exact ( stream, & mut buf) . unwrap ( ) ;
1506+ len = u16:: from_be_bytes ( buf) as u64 ;
1507+ } else if len == 127 {
1508+ let mut buf = [ 0u8 ; 8 ] ;
1509+ std:: io:: Read :: read_exact ( stream, & mut buf) . unwrap ( ) ;
1510+ len = u64:: from_be_bytes ( buf) ;
1511+ }
1512+
1513+ let mask_key = if masked {
1514+ let mut buf = [ 0u8 ; 4 ] ;
1515+ std:: io:: Read :: read_exact ( stream, & mut buf) . unwrap ( ) ;
1516+ buf
1517+ } else {
1518+ [ 0u8 ; 4 ]
1519+ } ;
1520+
1521+ let mut payload = vec ! [ 0u8 ; len as usize ] ;
1522+ std:: io:: Read :: read_exact ( stream, & mut payload) . unwrap ( ) ;
1523+
1524+ if masked {
1525+ for ( i, byte) in payload. iter_mut ( ) . enumerate ( ) {
1526+ * byte ^= mask_key[ i % 4 ] ;
1527+ }
1528+ }
1529+ payload
13271530 }
13281531
13291532 // ─── Unit tests: reencrypt pipeline ──────────────────────────────────────
@@ -1618,8 +1821,15 @@ mod tests {
16181821 assert ! ( !name. ends_with( ".pma" ) , "zip contains .pma: {name}" ) ;
16191822 }
16201823
1621- // 7. Verify Chromium can load the packaged profile
1622- verify_chromium_loads_profile ( & pkg. zip_buffer ) ;
1824+ // 7. Verify Chromium can load the packaged profile and read cookies via CDP
1825+ verify_chromium_loads_profile (
1826+ & pkg. zip_buffer ,
1827+ & [
1828+ ( ".example.com" , "session" ) ,
1829+ ( ".test.org" , "auth" ) ,
1830+ ( "localhost" , "dev" ) ,
1831+ ] ,
1832+ ) ;
16231833
16241834 // 8. Cleanup
16251835 let _ = std:: fs:: remove_dir_all ( & test_profile_dir) ;
0 commit comments