use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use base64::Engine; use http::request::Request; use http_body_util::BodyExt; use hudsucker::certificate_authority::RcgenAuthority; use hudsucker::rcgen::{ BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, PKCS_ECDSA_P256_SHA256, }; use hudsucker::{Body, HttpContext, HttpHandler, Proxy, RequestOrResponse}; use md5::{Digest, Md5}; use serde::{Deserialize, Serialize}; const GAME_PACKAGE: &str = "com.hutchgames.cccg"; const CA_COMMON_NAME: &str = "Top Drives Capture CA"; const GARAGE_MARKER: &[u8] = b"playerDeck"; const CONFIG_FILE: &str = "capture.toml"; type Result = std::result::Result>; #[derive(Debug, Serialize, Deserialize)] #[serde(default)] struct CaptureConfig { device: DeviceConfig, paths: PathsConfig, proxy: ProxyConfig, capture: CaptureSettings, } #[derive(Debug, Serialize, Deserialize)] #[serde(default)] struct DeviceConfig { serial: String, wait_secs: u64, shutdown_bluestacks: bool, } #[derive(Debug, Serialize, Deserialize)] #[serde(default)] struct PathsConfig { adb: String, bluestacks: String, output: String, } #[derive(Debug, Serialize, Deserialize)] #[serde(default)] struct ProxyConfig { port: u16, } #[derive(Debug, Serialize, Deserialize)] #[serde(default)] struct CaptureSettings { timeout_secs: u64, replay_timeout_secs: u64, } impl Default for CaptureConfig { fn default() -> Self { Self { device: DeviceConfig::default(), paths: PathsConfig::default(), proxy: ProxyConfig::default(), capture: CaptureSettings::default(), } } } impl Default for DeviceConfig { fn default() -> Self { Self { serial: "127.0.0.1:5555".to_string(), wait_secs: 180, shutdown_bluestacks: true } } } impl Default for PathsConfig { fn default() -> Self { Self { adb: detect_adb(), bluestacks: detect_bluestacks(), output: "my_cars.json".to_string(), } } } impl Default for ProxyConfig { fn default() -> Self { Self { port: 8080 } } } impl Default for CaptureSettings { fn default() -> Self { Self { timeout_secs: 300, replay_timeout_secs: 30 } } } fn detect_adb() -> String { if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") { let candidate = format!("{local_app_data}\\Android\\Sdk\\platform-tools\\adb.exe"); if Path::new(&candidate).exists() { return candidate; } } "adb".to_string() } fn detect_bluestacks() -> String { const CANDIDATES: [&str; 3] = [ r"C:\Program Files\BlueStacks_nxt\HD-Player.exe", r"C:\Program Files\BlueStacks\HD-Player.exe", r"C:\Program Files (x86)\BlueStacks\HD-Player.exe", ]; CANDIDATES .iter() .find(|candidate| Path::new(candidate).exists()) .map(|candidate| candidate.to_string()) .unwrap_or_default() } fn load_config(project_root: &Path) -> Result { let config_path = project_root.join(CONFIG_FILE); if config_path.exists() { let text = std::fs::read_to_string(&config_path)?; let config: CaptureConfig = toml::from_str(&text)?; return Ok(config); } let config = CaptureConfig::default(); let pretty = toml::to_string_pretty(&config)?.replace("\\\\", "/"); std::fs::write(&config_path, &pretty)?; println!("created default configuration at {}", config_path.display()); println!("review it and adjust paths if needed, then run this command again"); std::process::exit(0); } fn output_path(config: &CaptureConfig, project_root: &Path) -> PathBuf { let raw = Path::new(&config.paths.output); if raw.is_absolute() { raw.to_path_buf() } else { project_root.join(raw) } } #[derive(Serialize, Deserialize)] struct CapturedRequest { method: String, url: String, headers: Vec<(String, String)>, #[serde(skip_serializing_if = "Option::is_none")] body: Option, } #[derive(Clone)] struct CaptureHandler { state: Arc, } struct CaptureState { found: AtomicBool, done: tokio::sync::Notify, project_root: PathBuf, output: PathBuf, pending: std::sync::Mutex>, } impl HttpHandler for CaptureHandler { async fn handle_request( &mut self, _ctx: &HttpContext, mut request: Request, ) -> RequestOrResponse { let host = request_host(&request).to_lowercase(); if request.method() == http::Method::CONNECT { return request.into(); } let path_and_query = request .uri() .path_and_query() .map(|value| value.as_str().to_string()) .unwrap_or_else(|| "/".to_string()); if !is_noise_host(&host) { println!(" {} {}{}", request.method(), host, path_and_query); } request.headers_mut().remove(http::header::ACCEPT_ENCODING); if !self.state.found.load(Ordering::SeqCst) && host.contains("topdrives") && path_and_query.contains("/sync") { let body_bytes = BodyExt::collect(std::mem::replace(request.body_mut(), Body::from(Vec::new()))) .await .map(|collected| collected.to_bytes().to_vec()) .unwrap_or_default(); let template = CapturedRequest { method: request.method().to_string(), url: format!("https://{host}{path_and_query}"), headers: request .headers() .iter() .map(|(name, value)| { ( name.as_str().to_string(), String::from_utf8_lossy(value.as_bytes()).to_string(), ) }) .collect(), body: if body_bytes.is_empty() { None } else { Some(base64::engine::general_purpose::STANDARD.encode(&body_bytes)) }, }; *self.state.pending.lock().unwrap() = Some(template); *request.body_mut() = Body::from(body_bytes); } request.into() } async fn handle_response( &mut self, _ctx: &HttpContext, mut response: http::Response, ) -> http::Response { if self.state.found.load(Ordering::SeqCst) { return response; } let looks_textual = response .headers() .get(http::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .map(|value| value.contains("json") || value.contains("text")) .unwrap_or(false); if !looks_textual { return response; } let bytes = match BodyExt::collect(std::mem::replace(response.body_mut(), Body::from(Vec::new()))) .await { Ok(collected) => collected.to_bytes().to_vec(), Err(_) => return response, }; if contains_marker(&bytes, GARAGE_MARKER) { if let Ok(json) = serde_json::from_slice::(&bytes) { if json.get("user").is_some() { let template = self.state.pending.lock().unwrap().take(); if let Err(error) = persist_capture( &self.state.project_root, &self.state.output, &json, template.as_ref(), ) { eprintln!(" failed to save capture: {error}"); } self.state.found.store(true, Ordering::SeqCst); self.state.done.notify_waiters(); } } } *response.body_mut() = Body::from(bytes); response } } fn request_host(request: &Request) -> String { if let Some(host) = request.uri().host() { return host.to_string(); } request .headers() .get(http::header::HOST) .and_then(|value| value.to_str().ok()) .and_then(|value| value.split(':').next()) .unwrap_or_default() .to_string() } async fn fetch_response( client: &reqwest::Client, method: &http::Method, url: &str, headers: &http::HeaderMap, body: &[u8], timeout: Duration, ) -> Result> { let mut builder = client .request(reqwest::Method::from_bytes(method.as_str().as_bytes())?, url) .timeout(timeout); for (name, value) in headers.iter() { let lower = name.as_str().to_lowercase(); if matches!( lower.as_str(), "host" | "content-length" | "accept-encoding" | "connection" | "proxy-connection" | "keep-alive" ) { continue; } builder = builder.header(name.as_str(), value.as_bytes()); } if !body.is_empty() { builder = builder.body(body.to_vec()); } let response = builder.send().await?; let status = response.status(); let bytes = response.bytes().await?; if !status.is_success() { return Err(format!("HTTP {status}").into()); } Ok(bytes.to_vec()) } fn persist_capture( project_root: &Path, output: &Path, json: &serde_json::Value, template: Option<&CapturedRequest>, ) -> Result<()> { let timestamp = time::OffsetDateTime::now_utc().format(&time::format_description::parse_borrowed::<2>( "[year]-[month]-[day]T[hour]-[minute]-[second]", )?)?; let run_dir = project_root.join("captures").join(×tamp); std::fs::create_dir_all(&run_dir)?; let pretty = serde_json::to_string_pretty(json)?; std::fs::write(run_dir.join("my_cars.json"), &pretty)?; if output.exists() { let backup = run_dir.join("my_cars.previous.json"); std::fs::copy(output, backup)?; } std::fs::write(output, &pretty)?; if let Some(template) = template { std::fs::write( project_root.join("captures").join("request.json"), serde_json::to_string_pretty(template)?, )?; } println!(" garage data saved to {}", output.display()); print_garage_summary(json); Ok(()) } fn print_garage_summary(json: &serde_json::Value) { let social_name = json .pointer("/user/socialName") .and_then(|value| value.as_str()) .unwrap_or("unknown player"); let deck = json .get("playerDeck") .and_then(|value| value.as_array()) .map(|cards| cards.len()) .unwrap_or(0); let locked = json .get("playerDeck") .and_then(|value| value.as_array()) .map(|cards| { cards .iter() .filter(|card| card.get("locked").and_then(|l| l.as_bool()).unwrap_or(false)) .count() }) .unwrap_or(0); println!(" player: {social_name}, garage: {deck} cars ({locked} locked)"); } fn is_noise_host(host: &str) -> bool { const NOISE: [&str; 15] = [ "google", "gstatic", "crashlytics", "firebase", "facebook", "fbcdn", "unity", "applovin", "admob", "doubleclick", "vungle", "ironsource", "chartboost", "sentry", "bugsnag", ]; NOISE.iter().any(|fragment| host.contains(fragment)) } fn contains_marker(haystack: &[u8], marker: &[u8]) -> bool { haystack.windows(marker.len()).any(|window| window == marker) } fn run_adb(config: &CaptureConfig, args: &[&str]) -> Result { let output = Command::new(&config.paths.adb) .args(args) .stdin(Stdio::null()) .output() .map_err(|error| format!("failed to run adb ({}): {error}", config.paths.adb))?; if !output.status.success() { return Err(format!( "adb {} failed: {}", args.join(" "), String::from_utf8_lossy(&output.stderr).trim() ) .into()); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } fn adb(config: &CaptureConfig, args: &[&str]) -> Result { let serial = config.device.serial.as_str(); run_adb(config, &[&["-s", serial][..], args].concat()) } fn adb_shell(config: &CaptureConfig, command: &str) -> Result { adb(config, &["shell", command]) } fn adb_shell_root(config: &CaptureConfig, command: &str) -> Result { let quoted = format!("su -c '{command}'"); adb(config, &["shell", quoted.as_str()]) } fn device_listed(config: &CaptureConfig) -> bool { match run_adb(config, &["devices"]) { Ok(devices) => devices .lines() .any(|line| line.starts_with(&config.device.serial) && line.contains("device")), Err(_) => false, } } fn ensure_device_connected(config: &CaptureConfig) -> Result<()> { let deadline = Instant::now() + Duration::from_secs(config.device.wait_secs); let mut bluestacks_started = false; loop { let _ = run_adb(config, &["connect", &config.device.serial]); if device_listed(config) { return Ok(()); } if !bluestacks_started { if config.paths.bluestacks.is_empty() || !Path::new(&config.paths.bluestacks).exists() { return Err(format!( "device {} is offline and no usable BlueStacks path is configured ({} in {CONFIG_FILE})", config.device.serial, config.paths.bluestacks ) .into()); } println!( "device {} offline, starting BlueStacks ({}) ...", config.device.serial, config.paths.bluestacks ); Command::new(&config.paths.bluestacks).spawn()?; bluestacks_started = true; } if Instant::now() >= deadline { return Err(format!( "device {} did not come online within {}s", config.device.serial, config.device.wait_secs ) .into()); } std::thread::sleep(Duration::from_secs(3)); } } fn android_sdk_level(config: &CaptureConfig) -> Result { adb_shell(config, "getprop ro.build.version.sdk")? .trim() .parse() .map_err(|_| "could not read Android SDK level".into()) } fn load_or_create_ca(project_root: &Path) -> Result<(String, String)> { let ca_dir = project_root.join("ca"); let cert_path = ca_dir.join("topdrives-ca.pem"); let key_path = ca_dir.join("topdrives-ca.key.pem"); if cert_path.exists() && key_path.exists() { let cert = std::fs::read_to_string(&cert_path)?; let key = std::fs::read_to_string(&key_path)?; return Ok((cert, key)); } std::fs::create_dir_all(&ca_dir)?; let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)?; let mut params = CertificateParams::new(Vec::new())?; params.distinguished_name.push(DnType::CommonName, CA_COMMON_NAME); params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); params.key_usages = vec![ KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign, KeyUsagePurpose::DigitalSignature, ]; let now = time::OffsetDateTime::now_utc(); params.not_before = now - time::Duration::days(1); params.not_after = now + time::Duration::days(3650); let certificate = params.self_signed(&key_pair)?; let cert_pem = certificate.pem(); let key_pem = key_pair.serialize_pem(); std::fs::write(&cert_path, &cert_pem)?; std::fs::write(&key_path, &key_pem)?; println!("created new CA certificate at {}", cert_path.display()); Ok((cert_pem, key_pem)) } fn android_ca_filename(common_name: &str) -> String { let mut canonical = Vec::new(); canonical.extend_from_slice(&[0x06, 0x03, 0x55, 0x04, 0x03]); canonical.push(0x0C); canonical.push(common_name.len() as u8); canonical.extend_from_slice(common_name.as_bytes()); let digest = Md5::digest(&canonical); let hash = u32::from_le_bytes([digest[0], digest[1], digest[2], digest[3]]); format!("{hash:08x}.0") } fn inject_ca(config: &CaptureConfig, cert_pem: &str, project_root: &Path) -> Result<()> { let filename = android_ca_filename(CA_COMMON_NAME); let store_path = format!("/system/etc/security/cacerts/{filename}"); let staging_path = "/data/local/tmp/topdrives-ca.pem"; let local_path = project_root.join("ca").join("topdrives-ca.pem"); std::fs::write(&local_path, cert_pem)?; let presence = adb_shell(config, &format!("test -f {store_path} && echo present || echo absent"))?; if presence.trim() == "present" { println!("CA already present in the system trust store"); return Ok(()); } adb(config, &["push", local_path.to_string_lossy().as_ref(), staging_path])?; adb_shell_root( config, "mkdir -p /data/local/tmp/cacerts-backup && cp /system/etc/security/cacerts/* /data/local/tmp/cacerts-backup/", )?; let mounts = adb_shell(config, "cat /proc/mounts")?; if !mounts.contains("/system/etc/security/cacerts") { adb_shell_root(config, "mount -t tmpfs tmpfs /system/etc/security/cacerts")?; } adb_shell_root( config, &format!( "cp /data/local/tmp/cacerts-backup/* /system/etc/security/cacerts/ && cp {staging_path} {store_path} && chmod 644 {store_path}" ), )?; let count_text = adb_shell(config, "ls /system/etc/security/cacerts | wc -l")?; let count: u32 = count_text .trim() .parse() .map_err(|_| "could not count certificates in the store".to_string())?; if count < 100 { return Err(format!("system store looks emptied ({count} certificates), aborting").into()); } println!("injected CA into the system trust store as {filename} ({count} certificates total)"); Ok(()) } fn set_device_proxy(config: &CaptureConfig, port: u16) -> Result<()> { adb_shell(config, &format!("settings put global http_proxy 127.0.0.1:{port}")).map(|_| ()) } fn clear_device_proxy(config: &CaptureConfig) { let _ = adb_shell(config, "settings put global http_proxy :0"); let _ = adb_shell(config, "settings delete global http_proxy"); } fn remove_adb_reverse(config: &CaptureConfig) { let remove_socket = format!("--remove tcp:{}", config.proxy.port); let _ = adb(config, &["reverse", remove_socket.as_str()]); } fn clear_stale_proxy(config: &CaptureConfig) { if let Ok(value) = adb_shell(config, "settings get global http_proxy") { let value = value.trim(); if !value.is_empty() && value != "null" { clear_device_proxy(config); println!("cleared stale proxy setting left by a previous run: {value}"); } } remove_adb_reverse(config); } fn revert_device_state(config: &CaptureConfig) { clear_device_proxy(config); remove_adb_reverse(config); let filename = android_ca_filename(CA_COMMON_NAME); let _ = adb_shell_root(config, &format!("rm -f /system/etc/security/cacerts/{filename}")); let _ = adb_shell_root(config, "umount /system/etc/security/cacerts"); let _ = adb_shell_root(config, "rm -rf /data/local/tmp/cacerts-backup"); let _ = adb_shell(config, "rm -f /data/local/tmp/topdrives-ca.pem"); println!( "device reverted: proxy cleared, CA removed from the trust store, staging files deleted" ); } fn setup_adb_reverse(config: &CaptureConfig, port: u16) -> Result<()> { let socket = format!("tcp:{port}"); match adb(config, &["reverse", socket.as_str(), socket.as_str()]) { Ok(_) => Ok(()), Err(_) => { let route = adb_shell(config, "ip route")?; let gateway = route.lines().find_map(|line| { line.strip_prefix("default via ").and_then(|rest| rest.split(' ').next()) }); match gateway { Some(gateway) => { println!( "adb reverse failed, falling back to gateway address {gateway}:{port}" ); adb_shell(config, &format!("settings put global http_proxy {gateway}:{port}")) .map(|_| ()) } None => Err("could not set up adb reverse and no default gateway found".into()), } } } } fn launch_game(config: &CaptureConfig) -> Result<()> { adb_shell(config, &format!("am force-stop {GAME_PACKAGE}"))?; std::thread::sleep(Duration::from_secs(2)); adb( config, &["shell", "monkey", "-p", GAME_PACKAGE, "-c", "android.intent.category.LAUNCHER", "1"], )?; Ok(()) } fn build_proxy( listener: tokio::net::TcpListener, cert_pem: &str, key_pem: &str, handler: CaptureHandler, ) -> Result { let key_pair = KeyPair::from_pem(key_pem)?; let issuer = Issuer::from_ca_cert_pem(cert_pem, key_pair)?; let provider = hudsucker::rustls::crypto::aws_lc_rs::default_provider(); let authority = RcgenAuthority::new(issuer, 1_000, provider); let proxy = Proxy::builder() .with_listener(listener) .with_ca(authority) .with_rustls_connector(hudsucker::rustls::crypto::aws_lc_rs::default_provider()) .with_http_handler(handler) .build()?; Ok(Box::pin(proxy.start())) } type ProxyFuture = std::pin::Pin< Box> + Send>, >; async fn command_run(config: &CaptureConfig) -> Result<()> { let project_root = std::env::current_dir()?; let (cert_pem, key_pem) = load_or_create_ca(&project_root)?; ensure_device_connected(config)?; clear_stale_proxy(config); let sdk_level = android_sdk_level(config)?; if sdk_level > 28 { println!("warning: Android {sdk_level} detected, the tmpfs CA injection is only tested up to Android 9"); } inject_ca(config, &cert_pem, &project_root)?; let listener = tokio::net::TcpListener::bind(std::net::SocketAddr::from(( [127, 0, 0, 1], config.proxy.port, ))) .await .map_err(|error| { format!( "cannot listen on 127.0.0.1:{} (HTTP Toolkit still running?): {error}", config.proxy.port ) })?; let state = Arc::new(CaptureState { found: AtomicBool::new(false), done: tokio::sync::Notify::new(), project_root: project_root.clone(), output: output_path(config, &project_root), pending: std::sync::Mutex::new(None), }); let proxy = build_proxy(listener, &cert_pem, &key_pem, CaptureHandler { state: state.clone() })?; tokio::spawn(proxy); set_device_proxy(config, config.proxy.port)?; setup_adb_reverse(config, config.proxy.port)?; println!("proxy active on 127.0.0.1:{}, launching {GAME_PACKAGE} ...", config.proxy.port); launch_game(config)?; println!( "waiting for the garage request (timeout {}s, Ctrl+C to abort)", config.capture.timeout_secs ); let outcome: Result<()> = tokio::select! { _ = state.done.notified() => Ok(()), _ = tokio::time::sleep(Duration::from_secs(config.capture.timeout_secs)) => Err("timed out waiting for the garage request".into()), _ = tokio::signal::ctrl_c() => Err("aborted".into()), }; revert_device_state(config); println!("stopping {GAME_PACKAGE}"); let _ = adb_shell(config, &format!("am force-stop {GAME_PACKAGE}")); let _ = run_adb(config, &["kill-server"]); println!("adb stopped"); if config.device.shutdown_bluestacks { close_bluestacks(); } match outcome { Ok(()) => { println!("capture finished, run `cargo run --bin records -- best` to analyze"); Ok(()) } Err(message) => Err(message), } } async fn command_setup(config: &CaptureConfig) -> Result<()> { let project_root = std::env::current_dir()?; let (cert_pem, _) = load_or_create_ca(&project_root)?; ensure_device_connected(config)?; let sdk_level = android_sdk_level(config)?; println!("device {} connected, Android SDK {}", config.device.serial, sdk_level); inject_ca(config, &cert_pem, &project_root)?; Ok(()) } async fn command_replay(config: &CaptureConfig) -> Result<()> { let project_root = std::env::current_dir()?; let request_path = project_root.join("captures").join("request.json"); if !request_path.exists() { return Err("no captured request found, run `capture run` first".into()); } let template: CapturedRequest = serde_json::from_str(&std::fs::read_to_string(&request_path)?)?; let mut headers = http::HeaderMap::new(); for (name, value) in &template.headers { let lower = name.to_lowercase(); if matches!( lower.as_str(), "host" | "content-length" | "accept-encoding" | "connection" | "proxy-connection" | "keep-alive" ) { continue; } headers.insert( http::HeaderName::from_bytes(name.as_bytes())?, http::HeaderValue::from_bytes(value.as_bytes())?, ); } let body = template .body .as_deref() .map(|encoded| base64::engine::general_purpose::STANDARD.decode(encoded)) .transpose()? .unwrap_or_default(); let method: http::Method = template.method.parse()?; let timeout = Duration::from_secs(config.capture.replay_timeout_secs); let http = reqwest::Client::builder().timeout(timeout).build()?; println!("replaying {} {}", method, template.url); let bytes = fetch_response(&http, &method, &template.url, &headers, &body, timeout).await?; if !contains_marker(&bytes, GARAGE_MARKER) { return Err( "response no longer contains the garage document, the session token probably expired; run `capture run` again".into(), ); } let json: serde_json::Value = serde_json::from_slice(&bytes)?; persist_capture(&project_root, &output_path(config, &project_root), &json, Some(&template))?; println!("replay finished successfully"); Ok(()) } async fn command_clean(config: &CaptureConfig) -> Result<()> { ensure_device_connected(config)?; revert_device_state(config); Ok(()) } fn close_bluestacks() { let silent = |args: &[&str]| { let _ = Command::new("taskkill") .args(args) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); }; silent(&["/IM", "HD-Player.exe"]); std::thread::sleep(Duration::from_secs(5)); silent(&["/F", "/IM", "HD-Player.exe"]); println!("BlueStacks closed"); } fn print_usage() { println!( "usage: capture commands: setup generate the CA and inject it into BlueStacks run full flow: start BlueStacks if needed, proxy, game launch, garage capture replay re-issue the stored garage request without the emulator clean revert the device: clear the proxy, remove the injected CA and staging files configuration is read from {CONFIG_FILE} in the working directory" ); } #[tokio::main] pub(crate) async fn run() { let command = std::env::args().nth(1).unwrap_or_default(); if command.is_empty() { print_usage(); return; } let project_root = std::env::current_dir().expect("cannot determine working directory"); let config = match load_config(&project_root) { Ok(config) => config, Err(error) => { eprintln!("error reading {CONFIG_FILE}: {error}"); std::process::exit(1); } }; let result = match command.as_str() { "setup" => command_setup(&config).await, "run" => command_run(&config).await, "replay" => command_replay(&config).await, "clean" => command_clean(&config).await, _ => { print_usage(); return; } }; if let Err(error) = result { eprintln!("error: {error}"); std::process::exit(1); } } #[cfg(test)] mod tests { use super::*; #[test] fn marker_detection_finds_nested_json_key() { let body = br#"{"serverId":"x","playerDeck":[{"cardId":"a"}]}"#; assert!(contains_marker(body, GARAGE_MARKER)); assert!(!contains_marker(b"{}", GARAGE_MARKER)); } #[test] fn android_ca_filename_is_openssl_style_hash() { let filename = android_ca_filename(CA_COMMON_NAME); assert_eq!(filename.len(), 10); assert!(filename.ends_with(".0")); assert!(filename[..8].chars().all(|character| character.is_ascii_hexdigit())); } #[test] fn noise_hosts_are_filtered() { assert!(is_noise_host("ads.google.com")); assert!(is_noise_host("config.firebase.io")); assert!(!is_noise_host("api.hutchgames.com")); assert!(!is_noise_host("topdrives-api.example.com")); } #[test] fn default_config_round_trips_through_toml() { let config = CaptureConfig::default(); let text = toml::to_string_pretty(&config).unwrap(); let parsed: CaptureConfig = toml::from_str(&text).unwrap(); assert_eq!(parsed.device.serial, config.device.serial); assert_eq!(parsed.paths.output, config.paths.output); assert_eq!(parsed.proxy.port, config.proxy.port); } }