1use super::{HttpClient, Request, Response};
15use crate::error::ScopeError;
16use async_trait::async_trait;
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19use std::net::TcpStream;
20use std::path::Path;
21use std::process::{Child, Command, Stdio};
22use std::sync::Mutex;
23use std::time::{Duration, Instant};
24
25pub(crate) const SIDECAR_ADDR: &str = "127.0.0.1:18789";
26const SIDECAR_URL: &str = "http://127.0.0.1:18789";
27pub(crate) const SPAWN_TIMEOUT: Duration = Duration::from_secs(5);
29const POLL_INTERVAL: Duration = Duration::from_millis(100);
30
31static SPAWNED: Mutex<Option<Child>> = Mutex::new(None);
33
34#[derive(Serialize)]
35struct BridgeRequest {
36 url: String,
37 method: String,
38 headers: HashMap<String, String>,
39 body: String,
40 drift: bool,
41 ghost: bool,
42 retries: i32,
43 buffer_size: u32,
44}
45
46#[derive(Deserialize)]
47struct BridgeResponse {
48 status_code: u16,
49 headers: HashMap<String, String>,
50 body: String,
51 #[serde(default)]
52 error: String,
53}
54
55pub struct GholaHttpClient {
59 client: reqwest::Client,
60 stealth: bool,
61 buffer_size: u32,
62 base_url: String,
63}
64
65impl GholaHttpClient {
66 pub fn new(stealth: bool, buffer_size: u32) -> Result<Self, ScopeError> {
68 let client = reqwest::Client::builder()
69 .timeout(Duration::from_secs(30))
70 .build()
71 .map_err(|e| {
72 ScopeError::Network(format!("failed to build ghola bridge client: {e}"))
73 })?;
74 Ok(Self {
75 client,
76 stealth,
77 buffer_size,
78 base_url: SIDECAR_URL.to_string(),
79 })
80 }
81
82 #[cfg(test)]
84 pub fn with_base_url(
85 stealth: bool,
86 buffer_size: u32,
87 base_url: &str,
88 ) -> Result<Self, ScopeError> {
89 let client = reqwest::Client::builder()
90 .timeout(Duration::from_secs(5))
91 .build()
92 .map_err(|e| {
93 ScopeError::Network(format!("failed to build ghola bridge client: {e}"))
94 })?;
95 Ok(Self {
96 client,
97 stealth,
98 buffer_size,
99 base_url: base_url.to_string(),
100 })
101 }
102}
103
104#[async_trait]
105impl HttpClient for GholaHttpClient {
106 async fn send(&self, request: Request) -> Result<Response, ScopeError> {
107 let bridge_req = BridgeRequest {
108 url: request.url,
109 method: request.method,
110 headers: request.headers,
111 body: request.body.unwrap_or_default(),
112 drift: self.stealth,
113 ghost: self.stealth,
114 retries: 0,
115 buffer_size: self.buffer_size,
116 };
117
118 let resp = self
119 .client
120 .post(&self.base_url)
121 .json(&bridge_req)
122 .send()
123 .await
124 .map_err(|e| ScopeError::Network(format!("failed to reach ghola sidecar: {e}")))?;
125
126 let bridge_resp: BridgeResponse = resp
127 .json()
128 .await
129 .map_err(|e| ScopeError::Network(format!("invalid sidecar response: {e}")))?;
130
131 if !bridge_resp.error.is_empty() {
132 return Err(ScopeError::Network(format!(
133 "sidecar error: {}",
134 bridge_resp.error
135 )));
136 }
137
138 Ok(Response {
139 status_code: bridge_resp.status_code,
140 headers: bridge_resp.headers,
141 body: bridge_resp.body,
142 })
143 }
144}
145
146pub(crate) fn is_bridge_running() -> bool {
147 SIDECAR_ADDR
148 .parse()
149 .ok()
150 .and_then(|addr| TcpStream::connect_timeout(&addr, Duration::from_millis(200)).ok())
151 .is_some()
152}
153
154fn spawned() -> std::sync::MutexGuard<'static, Option<Child>> {
155 SPAWNED.lock().unwrap_or_else(|e| e.into_inner())
156}
157
158pub fn start_sidecar(binary: &Path, timeout: Duration) -> Result<u32, ScopeError> {
169 let mut child = Command::new(binary)
170 .arg("--serve")
171 .stdin(Stdio::null())
172 .stdout(Stdio::null())
173 .stderr(Stdio::null())
174 .spawn()
175 .map_err(|e| {
176 ScopeError::io_context(e, format!("failed to start {} --serve", binary.display()))
177 })?;
178 let start = Instant::now();
179 loop {
180 if is_bridge_running() {
181 let pid = child.id();
182 *spawned() = Some(child);
183 return Ok(pid);
184 }
185 if let Ok(Some(status)) = child.try_wait() {
186 return Err(ScopeError::Network(format!(
187 "{} --serve exited ({status}) before it answered on {SIDECAR_ADDR}",
188 binary.display()
189 )));
190 }
191 if start.elapsed() >= timeout {
192 let _ = child.kill();
193 let _ = child.wait();
194 return Err(ScopeError::Network(format!(
195 "the ghola sidecar did not answer on {SIDECAR_ADDR} within {timeout:?}"
196 )));
197 }
198 std::thread::sleep(POLL_INTERVAL);
199 }
200}
201
202pub fn spawned_sidecar() -> Option<u32> {
204 let mut guard = spawned();
205 match guard.as_mut().map(Child::try_wait) {
206 Some(Ok(None)) => guard.as_ref().map(Child::id),
207 _ => {
208 *guard = None;
209 None
210 }
211 }
212}
213
214pub fn stop_spawned_sidecar() -> Option<u32> {
217 let mut child = spawned().take()?;
218 let _ = child.kill();
219 let _ = child.wait();
220 Some(child.id())
221}
222
223#[cfg(any(test, feature = "test-support"))]
226pub fn adopt_sidecar(child: Child) {
227 *spawned() = Some(child);
228}
229
230#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn test_bridge_request_serialization() {
240 let req = BridgeRequest {
241 url: "https://example.com".to_string(),
242 method: "GET".to_string(),
243 headers: HashMap::new(),
244 body: String::new(),
245 drift: true,
246 ghost: false,
247 retries: 3,
248 buffer_size: 4096,
249 };
250 let json = serde_json::to_string(&req).unwrap();
251 assert!(json.contains("\"drift\":true"));
252 assert!(json.contains("\"ghost\":false"));
253 assert!(json.contains("\"retries\":3"));
254 assert!(json.contains("\"buffer_size\":4096"));
255 }
256
257 #[test]
258 fn test_bridge_response_deserialization() {
259 let json = r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#;
260 let resp: BridgeResponse = serde_json::from_str(json).unwrap();
261 assert_eq!(resp.status_code, 200);
262 assert_eq!(resp.body, "ok");
263 assert!(resp.error.is_empty());
264 }
265
266 #[test]
267 fn test_bridge_response_with_error() {
268 let json = r#"{"status_code":0,"headers":{},"body":"","error":"connection refused"}"#;
269 let resp: BridgeResponse = serde_json::from_str(json).unwrap();
270 assert_eq!(resp.error, "connection refused");
271 }
272
273 #[test]
274 fn test_bridge_response_missing_error_field() {
275 let json = r#"{"status_code":200,"headers":{},"body":"data"}"#;
276 let resp: BridgeResponse = serde_json::from_str(json).unwrap();
277 assert!(resp.error.is_empty());
278 }
279
280 #[test]
281 fn test_ghola_client_creation() {
282 let client = GholaHttpClient::new(true, 4096);
283 assert!(client.is_ok());
284 }
285
286 #[test]
287 fn test_ghola_client_creation_stealth_off() {
288 let client = GholaHttpClient::new(false, 4096);
289 assert!(client.is_ok());
290 }
291
292 static SIDECAR: Mutex<()> = Mutex::new(());
294
295 fn sidecar_lock() -> std::sync::MutexGuard<'static, ()> {
296 SIDECAR.lock().unwrap_or_else(|e| e.into_inner())
297 }
298
299 #[test]
300 fn test_no_sidecar_is_spawned_by_default() {
301 let _lock = sidecar_lock();
302 assert_eq!(spawned_sidecar(), None);
303 assert_eq!(stop_spawned_sidecar(), None);
304 }
305
306 #[test]
307 fn test_start_sidecar_reports_a_missing_binary() {
308 let _lock = sidecar_lock();
309 let dir = tempfile::tempdir().unwrap();
310 let err =
311 start_sidecar(&dir.path().join("no-ghola"), Duration::from_millis(50)).unwrap_err();
312 assert!(err.to_string().contains("--serve"), "{err}");
313 assert_eq!(spawned_sidecar(), None);
314 }
315
316 fn long_running() -> Child {
318 let mut command = if cfg!(windows) {
319 let mut c = Command::new("ping");
320 c.args(["-n", "60", "127.0.0.1"]);
321 c
322 } else {
323 let mut c = Command::new("sleep");
324 c.arg("60");
325 c
326 };
327 command
328 .stdin(Stdio::null())
329 .stdout(Stdio::null())
330 .stderr(Stdio::null())
331 .spawn()
332 .unwrap()
333 }
334
335 #[test]
336 fn test_an_adopted_sidecar_is_kept_until_stopped() {
337 let _lock = sidecar_lock();
338 let child = long_running();
339 let pid = child.id();
340 adopt_sidecar(child);
341 assert_eq!(spawned_sidecar(), Some(pid));
342 assert_eq!(stop_spawned_sidecar(), Some(pid));
343 assert_eq!(spawned_sidecar(), None);
344 assert_eq!(stop_spawned_sidecar(), None);
345 }
346
347 #[test]
348 fn test_a_sidecar_that_exited_is_forgotten() {
349 let _lock = sidecar_lock();
350 let mut child = long_running();
351 child.kill().unwrap();
352 child.wait().unwrap();
353 adopt_sidecar(child);
354 assert_eq!(spawned_sidecar(), None);
355 assert_eq!(stop_spawned_sidecar(), None);
356 }
357
358 #[cfg(unix)]
362 fn start_retrying(ghola: &Path, timeout: Duration) -> Result<u32, ScopeError> {
363 for _ in 0..50 {
364 match start_sidecar(ghola, timeout) {
365 Err(e) if e.to_string().contains("Text file busy") => {
366 std::thread::sleep(Duration::from_millis(20));
367 }
368 other => return other,
369 }
370 }
371 start_sidecar(ghola, timeout)
372 }
373
374 #[cfg(unix)]
376 fn fake_ghola(dir: &Path, body: &str) -> std::path::PathBuf {
377 use std::os::unix::fs::PermissionsExt;
378 let path = dir.join("ghola");
379 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
380 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
381 path
382 }
383
384 #[cfg(unix)]
385 #[test]
386 fn test_start_sidecar_reports_a_sidecar_that_exits() {
387 let _lock = sidecar_lock();
388 if is_bridge_running() {
389 eprintln!("skipped: something answers on {SIDECAR_ADDR}");
390 return;
391 }
392 let dir = tempfile::tempdir().unwrap();
393 let ghola = fake_ghola(dir.path(), "[ \"$1\" = --serve ] && exit 3");
394 let err = start_retrying(&ghola, Duration::from_secs(10)).unwrap_err();
395 assert!(err.to_string().contains("exited"), "{err}");
396 assert_eq!(spawned_sidecar(), None);
397 }
398
399 #[cfg(unix)]
400 #[test]
401 fn test_start_sidecar_stops_a_sidecar_that_never_answers() {
402 let _lock = sidecar_lock();
403 if is_bridge_running() {
404 eprintln!("skipped: something answers on {SIDECAR_ADDR}");
405 return;
406 }
407 let dir = tempfile::tempdir().unwrap();
408 let ghola = fake_ghola(dir.path(), "exec sleep 60");
409 let started = Instant::now();
410 let err = start_retrying(&ghola, Duration::from_millis(300)).unwrap_err();
411 assert!(err.to_string().contains("did not answer"), "{err}");
412 assert!(started.elapsed() < Duration::from_secs(5));
413 assert_eq!(spawned_sidecar(), None);
414 }
415
416 #[cfg(unix)]
417 #[test]
418 fn test_start_sidecar_keeps_a_sidecar_that_answers() {
419 let _lock = sidecar_lock();
420 let Ok(_port) = std::net::TcpListener::bind(SIDECAR_ADDR) else {
422 eprintln!("skipped: {SIDECAR_ADDR} is in use");
423 return;
424 };
425 let dir = tempfile::tempdir().unwrap();
426 let ghola = fake_ghola(dir.path(), "exec sleep 60");
427 let pid = start_retrying(&ghola, Duration::from_secs(5)).unwrap();
428 assert_eq!(spawned_sidecar(), Some(pid));
429 assert_eq!(stop_spawned_sidecar(), Some(pid));
430 assert_eq!(spawned_sidecar(), None);
431 }
432
433 #[test]
434 fn test_is_bridge_running_returns_bool() {
435 let _ = is_bridge_running();
437 }
438
439 #[test]
440 fn test_bridge_request_full_serialization() {
441 let mut headers = HashMap::new();
442 headers.insert("Authorization".to_string(), "Bearer tk".to_string());
443 let req = BridgeRequest {
444 url: "https://api.test.com/v1".to_string(),
445 method: "POST".to_string(),
446 headers,
447 body: r#"{"data":1}"#.to_string(),
448 drift: false,
449 ghost: true,
450 retries: 0,
451 buffer_size: 8192,
452 };
453 let json = serde_json::to_string(&req).unwrap();
454 assert!(json.contains("\"method\":\"POST\""));
455 assert!(json.contains("\"ghost\":true"));
456 assert!(json.contains("\"drift\":false"));
457 assert!(json.contains("\"retries\":0"));
458 assert!(json.contains("\"buffer_size\":8192"));
459 assert!(json.contains("Authorization"));
460 }
461
462 #[test]
463 fn test_bridge_response_roundtrip() {
464 let mut headers = HashMap::new();
465 headers.insert("content-type".to_string(), "application/json".to_string());
466 let json = serde_json::json!({
467 "status_code": 201,
468 "headers": headers,
469 "body": r#"{"id":42}"#,
470 "error": ""
471 });
472 let resp: BridgeResponse = serde_json::from_value(json).unwrap();
473 assert_eq!(resp.status_code, 201);
474 assert_eq!(resp.body, r#"{"id":42}"#);
475 assert!(resp.error.is_empty());
476 assert_eq!(
477 resp.headers.get("content-type").map(String::as_str),
478 Some("application/json")
479 );
480 }
481
482 #[tokio::test]
483 async fn test_send_to_mock_sidecar() {
484 let mut server = mockito::Server::new_async().await;
485 let mock = server
486 .mock("POST", "/")
487 .with_status(200)
488 .with_header("content-type", "application/json")
489 .with_body(
490 r#"{"status_code":200,"headers":{},"body":"{\"result\":\"ok\"}","error":""}"#,
491 )
492 .create_async()
493 .await;
494
495 let ghola = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
496
497 let req = Request::get("https://api.example.com/data");
498 let resp = ghola.send(req).await.unwrap();
499 assert_eq!(resp.status_code, 200);
500 assert_eq!(resp.body, r#"{"result":"ok"}"#);
501 mock.assert_async().await;
502 }
503
504 #[test]
505 fn test_sidecar_constants() {
506 assert_eq!(SIDECAR_ADDR, "127.0.0.1:18789");
507 assert_eq!(SIDECAR_URL, "http://127.0.0.1:18789");
508 assert_eq!(SPAWN_TIMEOUT, Duration::from_secs(5));
509 assert_eq!(POLL_INTERVAL, Duration::from_millis(100));
510 }
511
512 #[tokio::test]
513 async fn test_send_success_via_mock() {
514 let mut server = mockito::Server::new_async().await;
515 let mock = server
516 .mock("POST", "/")
517 .with_status(200)
518 .with_header("content-type", "application/json")
519 .with_body(r#"{"status_code":200,"headers":{"x-test":"yes"},"body":"{\"data\":42}","error":""}"#)
520 .create_async()
521 .await;
522
523 let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
524 let req = Request::get("https://api.example.com/v1");
525 let resp = client.send(req).await.unwrap();
526
527 assert_eq!(resp.status_code, 200);
528 assert!(resp.is_success());
529 assert_eq!(resp.body, r#"{"data":42}"#);
530 assert_eq!(resp.headers.get("x-test").map(String::as_str), Some("yes"));
531 mock.assert_async().await;
532 }
533
534 #[tokio::test]
535 async fn test_send_with_stealth_off() {
536 let mut server = mockito::Server::new_async().await;
537 let mock = server
538 .mock("POST", "/")
539 .with_status(200)
540 .with_header("content-type", "application/json")
541 .with_body(r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#)
542 .create_async()
543 .await;
544
545 let client = GholaHttpClient::with_base_url(false, 4096, &server.url()).unwrap();
546 let req = Request::post_json("https://api.example.com", r#"{"q":1}"#);
547 let resp = client.send(req).await.unwrap();
548
549 assert_eq!(resp.status_code, 200);
550 assert_eq!(resp.body, "ok");
551 mock.assert_async().await;
552 }
553
554 #[tokio::test]
555 async fn test_send_bridge_error_response() {
556 let mut server = mockito::Server::new_async().await;
557 let mock = server
558 .mock("POST", "/")
559 .with_status(200)
560 .with_header("content-type", "application/json")
561 .with_body(r#"{"status_code":0,"headers":{},"body":"","error":"upstream timeout"}"#)
562 .create_async()
563 .await;
564
565 let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
566 let req = Request::get("https://api.example.com");
567 let result = client.send(req).await;
568
569 assert!(result.is_err());
570 assert!(result.unwrap_err().to_string().contains("upstream timeout"));
571 mock.assert_async().await;
572 }
573
574 #[tokio::test]
575 async fn test_send_invalid_json_response() {
576 let mut server = mockito::Server::new_async().await;
577 let mock = server
578 .mock("POST", "/")
579 .with_status(200)
580 .with_body("not valid json at all")
581 .create_async()
582 .await;
583
584 let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
585 let req = Request::get("https://api.example.com");
586 let result = client.send(req).await;
587
588 assert!(result.is_err());
589 assert!(
590 result
591 .unwrap_err()
592 .to_string()
593 .contains("invalid sidecar response")
594 );
595 mock.assert_async().await;
596 }
597
598 #[tokio::test]
599 async fn test_send_non_success_bridge_status() {
600 let mut server = mockito::Server::new_async().await;
601 let mock = server
602 .mock("POST", "/")
603 .with_status(200)
604 .with_header("content-type", "application/json")
605 .with_body(r#"{"status_code":429,"headers":{},"body":"rate limited","error":""}"#)
606 .create_async()
607 .await;
608
609 let client = GholaHttpClient::with_base_url(false, 4096, &server.url()).unwrap();
610 let req = Request::get("https://api.example.com");
611 let resp = client.send(req).await.unwrap();
612
613 assert_eq!(resp.status_code, 429);
614 assert!(!resp.is_success());
615 assert_eq!(resp.body, "rate limited");
616 mock.assert_async().await;
617 }
618
619 #[tokio::test]
620 async fn test_send_with_custom_headers() {
621 let mut server = mockito::Server::new_async().await;
622 let mock = server
623 .mock("POST", "/")
624 .with_status(200)
625 .with_header("content-type", "application/json")
626 .with_body(r#"{"status_code":200,"headers":{},"body":"{}","error":""}"#)
627 .create_async()
628 .await;
629
630 let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
631 let req = Request::get("https://api.example.com")
632 .with_header("Authorization", "Bearer token")
633 .with_header("X-Chain", "ethereum");
634 let resp = client.send(req).await.unwrap();
635
636 assert!(resp.is_success());
637 mock.assert_async().await;
638 }
639
640 #[tokio::test]
641 async fn test_send_connection_refused() {
642 let client = GholaHttpClient::with_base_url(true, 4096, "http://127.0.0.1:1").unwrap();
643 let req = Request::get("https://api.example.com");
644 let result = client.send(req).await;
645
646 assert!(result.is_err());
647 assert!(
648 result
649 .unwrap_err()
650 .to_string()
651 .contains("failed to reach ghola sidecar")
652 );
653 }
654
655 #[tokio::test]
656 async fn test_send_includes_buffer_size() {
657 let mut server = mockito::Server::new_async().await;
658 let mock = server
659 .mock("POST", "/")
660 .match_body(mockito::Matcher::PartialJsonString(
661 r#"{"buffer_size":8192}"#.to_string(),
662 ))
663 .with_status(200)
664 .with_header("content-type", "application/json")
665 .with_body(r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#)
666 .create_async()
667 .await;
668
669 let client = GholaHttpClient::with_base_url(true, 8192, &server.url()).unwrap();
670 let req = Request::get("https://api.example.com");
671 let resp = client.send(req).await.unwrap();
672
673 assert_eq!(resp.status_code, 200);
674 mock.assert_async().await;
675 }
676
677 #[test]
678 fn test_custom_buffer_size_stored() {
679 let client = GholaHttpClient::new(true, 16384).unwrap();
680 assert_eq!(client.buffer_size, 16384);
681 }
682
683 #[test]
684 fn test_default_buffer_size() {
685 let client = GholaHttpClient::new(false, 4096).unwrap();
686 assert_eq!(client.buffer_size, 4096);
687 }
688}