Add Windows-only garage capture via MITM proxy and ADB automation

This commit is contained in:
Jan-Peter von Hunnius 2026-08-22 10:23:56 +02:00
parent 7ac889cbff
commit b85672559b
6 changed files with 1962 additions and 7 deletions

902
src/capture/windows.rs Normal file
View file

@ -0,0 +1,902 @@
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 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 http::request::Request;
use http_body_util::BodyExt;
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<T> = std::result::Result<T, Box<dyn std::error::Error>>;
#[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,
}
#[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,
}
}
}
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<CaptureConfig> {
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<String>,
}
#[derive(Clone)]
struct CaptureHandler {
state: Arc<CaptureState>,
}
struct CaptureState {
found: AtomicBool,
done: tokio::sync::Notify,
project_root: PathBuf,
output: PathBuf,
pending: std::sync::Mutex<Option<CapturedRequest>>,
}
impl HttpHandler for CaptureHandler {
async fn handle_request(
&mut self,
_ctx: &HttpContext,
mut request: Request<Body>,
) -> RequestOrResponse {
let host = request_host(&request).to_lowercase();
if request.method() == http::Method::CONNECT {
if !is_noise_host(&host) {
println!(" CONNECT {host}");
}
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<Body>,
) -> http::Response<Body> {
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::<serde_json::Value>(&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<Body>) -> 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<Vec<u8>> {
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(&timestamp);
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<String> {
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<String> {
let serial = config.device.serial.as_str();
run_adb(config, &[&["-s", serial][..], args].concat())
}
fn adb_shell(config: &CaptureConfig, command: &str) -> Result<String> {
adb(config, &["shell", command])
}
fn adb_shell_root(config: &CaptureConfig, command: &str) -> Result<String> {
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<u8> {
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 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<ProxyFuture> {
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<dyn std::future::Future<Output = std::result::Result<(), hudsucker::Error>> + 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)?;
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()),
};
clear_device_proxy(config);
let remove_socket = format!("--remove tcp:{}", config.proxy.port);
let _ = adb(config, &["reverse", remove_socket.as_str()]);
println!("device proxy settings restored");
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)?;
clear_device_proxy(config);
let remove_socket = format!("--remove tcp:{}", config.proxy.port);
let _ = adb(config, &["reverse", remove_socket.as_str()]);
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");
println!("proxy settings cleared and CA removed from the device");
Ok(())
}
fn print_usage() {
println!(
"usage: capture <command>
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 remove proxy settings and the injected CA
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);
}
}