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)]
360 fn fake_ghola(dir: &Path, body: &str) -> std::path::PathBuf {
361 use std::os::unix::fs::PermissionsExt;
362 let path = dir.join("ghola");
363 std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
364 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
365 path
366 }
367
368 #[cfg(unix)]
369 #[test]
370 fn test_start_sidecar_reports_a_sidecar_that_exits() {
371 let _lock = sidecar_lock();
372 if is_bridge_running() {
373 eprintln!("skipped: something answers on {SIDECAR_ADDR}");
374 return;
375 }
376 let dir = tempfile::tempdir().unwrap();
377 let ghola = fake_ghola(dir.path(), "[ \"$1\" = --serve ] && exit 3");
378 let err = start_sidecar(&ghola, Duration::from_secs(10)).unwrap_err();
379 assert!(err.to_string().contains("exited"), "{err}");
380 assert_eq!(spawned_sidecar(), None);
381 }
382
383 #[cfg(unix)]
384 #[test]
385 fn test_start_sidecar_stops_a_sidecar_that_never_answers() {
386 let _lock = sidecar_lock();
387 if is_bridge_running() {
388 eprintln!("skipped: something answers on {SIDECAR_ADDR}");
389 return;
390 }
391 let dir = tempfile::tempdir().unwrap();
392 let ghola = fake_ghola(dir.path(), "exec sleep 60");
393 let started = Instant::now();
394 let err = start_sidecar(&ghola, Duration::from_millis(300)).unwrap_err();
395 assert!(err.to_string().contains("did not answer"), "{err}");
396 assert!(started.elapsed() < Duration::from_secs(5));
397 assert_eq!(spawned_sidecar(), None);
398 }
399
400 #[cfg(unix)]
401 #[test]
402 fn test_start_sidecar_keeps_a_sidecar_that_answers() {
403 let _lock = sidecar_lock();
404 let Ok(_port) = std::net::TcpListener::bind(SIDECAR_ADDR) else {
406 eprintln!("skipped: {SIDECAR_ADDR} is in use");
407 return;
408 };
409 let dir = tempfile::tempdir().unwrap();
410 let ghola = fake_ghola(dir.path(), "exec sleep 60");
411 let pid = start_sidecar(&ghola, Duration::from_secs(5)).unwrap();
412 assert_eq!(spawned_sidecar(), Some(pid));
413 assert_eq!(stop_spawned_sidecar(), Some(pid));
414 assert_eq!(spawned_sidecar(), None);
415 }
416
417 #[test]
418 fn test_is_bridge_running_returns_bool() {
419 let _ = is_bridge_running();
421 }
422
423 #[test]
424 fn test_bridge_request_full_serialization() {
425 let mut headers = HashMap::new();
426 headers.insert("Authorization".to_string(), "Bearer tk".to_string());
427 let req = BridgeRequest {
428 url: "https://api.test.com/v1".to_string(),
429 method: "POST".to_string(),
430 headers,
431 body: r#"{"data":1}"#.to_string(),
432 drift: false,
433 ghost: true,
434 retries: 0,
435 buffer_size: 8192,
436 };
437 let json = serde_json::to_string(&req).unwrap();
438 assert!(json.contains("\"method\":\"POST\""));
439 assert!(json.contains("\"ghost\":true"));
440 assert!(json.contains("\"drift\":false"));
441 assert!(json.contains("\"retries\":0"));
442 assert!(json.contains("\"buffer_size\":8192"));
443 assert!(json.contains("Authorization"));
444 }
445
446 #[test]
447 fn test_bridge_response_roundtrip() {
448 let mut headers = HashMap::new();
449 headers.insert("content-type".to_string(), "application/json".to_string());
450 let json = serde_json::json!({
451 "status_code": 201,
452 "headers": headers,
453 "body": r#"{"id":42}"#,
454 "error": ""
455 });
456 let resp: BridgeResponse = serde_json::from_value(json).unwrap();
457 assert_eq!(resp.status_code, 201);
458 assert_eq!(resp.body, r#"{"id":42}"#);
459 assert!(resp.error.is_empty());
460 assert_eq!(
461 resp.headers.get("content-type").map(String::as_str),
462 Some("application/json")
463 );
464 }
465
466 #[tokio::test]
467 async fn test_send_to_mock_sidecar() {
468 let mut server = mockito::Server::new_async().await;
469 let mock = server
470 .mock("POST", "/")
471 .with_status(200)
472 .with_header("content-type", "application/json")
473 .with_body(
474 r#"{"status_code":200,"headers":{},"body":"{\"result\":\"ok\"}","error":""}"#,
475 )
476 .create_async()
477 .await;
478
479 let ghola = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
480
481 let req = Request::get("https://api.example.com/data");
482 let resp = ghola.send(req).await.unwrap();
483 assert_eq!(resp.status_code, 200);
484 assert_eq!(resp.body, r#"{"result":"ok"}"#);
485 mock.assert_async().await;
486 }
487
488 #[test]
489 fn test_sidecar_constants() {
490 assert_eq!(SIDECAR_ADDR, "127.0.0.1:18789");
491 assert_eq!(SIDECAR_URL, "http://127.0.0.1:18789");
492 assert_eq!(SPAWN_TIMEOUT, Duration::from_secs(5));
493 assert_eq!(POLL_INTERVAL, Duration::from_millis(100));
494 }
495
496 #[tokio::test]
497 async fn test_send_success_via_mock() {
498 let mut server = mockito::Server::new_async().await;
499 let mock = server
500 .mock("POST", "/")
501 .with_status(200)
502 .with_header("content-type", "application/json")
503 .with_body(r#"{"status_code":200,"headers":{"x-test":"yes"},"body":"{\"data\":42}","error":""}"#)
504 .create_async()
505 .await;
506
507 let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
508 let req = Request::get("https://api.example.com/v1");
509 let resp = client.send(req).await.unwrap();
510
511 assert_eq!(resp.status_code, 200);
512 assert!(resp.is_success());
513 assert_eq!(resp.body, r#"{"data":42}"#);
514 assert_eq!(resp.headers.get("x-test").map(String::as_str), Some("yes"));
515 mock.assert_async().await;
516 }
517
518 #[tokio::test]
519 async fn test_send_with_stealth_off() {
520 let mut server = mockito::Server::new_async().await;
521 let mock = server
522 .mock("POST", "/")
523 .with_status(200)
524 .with_header("content-type", "application/json")
525 .with_body(r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#)
526 .create_async()
527 .await;
528
529 let client = GholaHttpClient::with_base_url(false, 4096, &server.url()).unwrap();
530 let req = Request::post_json("https://api.example.com", r#"{"q":1}"#);
531 let resp = client.send(req).await.unwrap();
532
533 assert_eq!(resp.status_code, 200);
534 assert_eq!(resp.body, "ok");
535 mock.assert_async().await;
536 }
537
538 #[tokio::test]
539 async fn test_send_bridge_error_response() {
540 let mut server = mockito::Server::new_async().await;
541 let mock = server
542 .mock("POST", "/")
543 .with_status(200)
544 .with_header("content-type", "application/json")
545 .with_body(r#"{"status_code":0,"headers":{},"body":"","error":"upstream timeout"}"#)
546 .create_async()
547 .await;
548
549 let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
550 let req = Request::get("https://api.example.com");
551 let result = client.send(req).await;
552
553 assert!(result.is_err());
554 assert!(result.unwrap_err().to_string().contains("upstream timeout"));
555 mock.assert_async().await;
556 }
557
558 #[tokio::test]
559 async fn test_send_invalid_json_response() {
560 let mut server = mockito::Server::new_async().await;
561 let mock = server
562 .mock("POST", "/")
563 .with_status(200)
564 .with_body("not valid json at all")
565 .create_async()
566 .await;
567
568 let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
569 let req = Request::get("https://api.example.com");
570 let result = client.send(req).await;
571
572 assert!(result.is_err());
573 assert!(
574 result
575 .unwrap_err()
576 .to_string()
577 .contains("invalid sidecar response")
578 );
579 mock.assert_async().await;
580 }
581
582 #[tokio::test]
583 async fn test_send_non_success_bridge_status() {
584 let mut server = mockito::Server::new_async().await;
585 let mock = server
586 .mock("POST", "/")
587 .with_status(200)
588 .with_header("content-type", "application/json")
589 .with_body(r#"{"status_code":429,"headers":{},"body":"rate limited","error":""}"#)
590 .create_async()
591 .await;
592
593 let client = GholaHttpClient::with_base_url(false, 4096, &server.url()).unwrap();
594 let req = Request::get("https://api.example.com");
595 let resp = client.send(req).await.unwrap();
596
597 assert_eq!(resp.status_code, 429);
598 assert!(!resp.is_success());
599 assert_eq!(resp.body, "rate limited");
600 mock.assert_async().await;
601 }
602
603 #[tokio::test]
604 async fn test_send_with_custom_headers() {
605 let mut server = mockito::Server::new_async().await;
606 let mock = server
607 .mock("POST", "/")
608 .with_status(200)
609 .with_header("content-type", "application/json")
610 .with_body(r#"{"status_code":200,"headers":{},"body":"{}","error":""}"#)
611 .create_async()
612 .await;
613
614 let client = GholaHttpClient::with_base_url(true, 4096, &server.url()).unwrap();
615 let req = Request::get("https://api.example.com")
616 .with_header("Authorization", "Bearer token")
617 .with_header("X-Chain", "ethereum");
618 let resp = client.send(req).await.unwrap();
619
620 assert!(resp.is_success());
621 mock.assert_async().await;
622 }
623
624 #[tokio::test]
625 async fn test_send_connection_refused() {
626 let client = GholaHttpClient::with_base_url(true, 4096, "http://127.0.0.1:1").unwrap();
627 let req = Request::get("https://api.example.com");
628 let result = client.send(req).await;
629
630 assert!(result.is_err());
631 assert!(
632 result
633 .unwrap_err()
634 .to_string()
635 .contains("failed to reach ghola sidecar")
636 );
637 }
638
639 #[tokio::test]
640 async fn test_send_includes_buffer_size() {
641 let mut server = mockito::Server::new_async().await;
642 let mock = server
643 .mock("POST", "/")
644 .match_body(mockito::Matcher::PartialJsonString(
645 r#"{"buffer_size":8192}"#.to_string(),
646 ))
647 .with_status(200)
648 .with_header("content-type", "application/json")
649 .with_body(r#"{"status_code":200,"headers":{},"body":"ok","error":""}"#)
650 .create_async()
651 .await;
652
653 let client = GholaHttpClient::with_base_url(true, 8192, &server.url()).unwrap();
654 let req = Request::get("https://api.example.com");
655 let resp = client.send(req).await.unwrap();
656
657 assert_eq!(resp.status_code, 200);
658 mock.assert_async().await;
659 }
660
661 #[test]
662 fn test_custom_buffer_size_stored() {
663 let client = GholaHttpClient::new(true, 16384).unwrap();
664 assert_eq!(client.buffer_size, 16384);
665 }
666
667 #[test]
668 fn test_default_buffer_size() {
669 let client = GholaHttpClient::new(false, 4096).unwrap();
670 assert_eq!(client.buffer_size, 4096);
671 }
672}