1use super::replay::ReplayHttpClient;
9use super::{GholaHttpClient, HttpClient, NativeHttpClient, ghola, ghola_install};
10use crate::config::Config;
11use crate::error::ScopeError;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15const MIN_SECRET_SEGMENT_LEN: usize = 16;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Kind {
21 Native,
22 Ghola,
23 Replay,
24 Record,
25}
26
27impl Kind {
28 fn name(self) -> &'static str {
29 match self {
30 Kind::Native => "native",
31 Kind::Ghola => "ghola",
32 Kind::Replay => "replay",
33 Kind::Record => "record",
34 }
35 }
36}
37
38#[derive(Clone)]
40pub struct Built {
41 pub client: Arc<dyn HttpClient>,
42 pub kind: Kind,
43 pub notices: Vec<String>,
44 pub ghola_missing: bool,
48}
49
50trait Sidecar {
52 fn up(&self) -> bool;
54 fn binary(&self) -> Option<PathBuf>;
56 fn start(&self, binary: &Path) -> Result<(), ScopeError>;
58}
59
60struct LocalSidecar;
62
63impl Sidecar for LocalSidecar {
64 fn up(&self) -> bool {
65 ghola::is_bridge_running()
66 }
67 fn binary(&self) -> Option<PathBuf> {
68 ghola_install::find()
69 }
70 fn start(&self, binary: &Path) -> Result<(), ScopeError> {
71 ghola::start_sidecar(binary, ghola::SPAWN_TIMEOUT).map(|_| ())
72 }
73}
74
75pub fn configured_kind(config: &Config) -> Kind {
77 configured_kind_with(config, &|k| std::env::var(k).ok())
78}
79
80pub fn configured_kind_with(config: &Config, env: &dyn Fn(&str) -> Option<String>) -> Kind {
82 match env("SCOPE_HTTP_MODE").as_deref() {
83 Some("replay") => Kind::Replay,
84 Some("record") => Kind::Record,
85 _ if config.ghola.enabled => Kind::Ghola,
86 _ => Kind::Native,
87 }
88}
89
90pub fn build(config: &Config) -> Result<Built, ScopeError> {
92 build_with(config, &|k| std::env::var(k).ok())
93}
94
95pub fn build_with(
104 config: &Config,
105 env: &dyn Fn(&str) -> Option<String>,
106) -> Result<Built, ScopeError> {
107 build_probed(config, env, &LocalSidecar)
108}
109
110fn build_probed(
114 config: &Config,
115 env: &dyn Fn(&str) -> Option<String>,
116 sidecar: &dyn Sidecar,
117) -> Result<Built, ScopeError> {
118 if let Some(mode) = env("SCOPE_HTTP_MODE") {
119 let dir = env("SCOPE_HTTP_FIXTURES")
120 .map(std::path::PathBuf::from)
121 .ok_or_else(|| {
122 ScopeError::Network("SCOPE_HTTP_MODE is set but SCOPE_HTTP_FIXTURES is not".into())
123 })?;
124 return match mode.as_str() {
125 "replay" => Ok(Built {
126 client: Arc::new(ReplayHttpClient::replay(dir)),
127 kind: Kind::Replay,
128 notices: vec![],
129 ghola_missing: false,
130 }),
131 "record" => {
132 let inner = build_probed(config, &|_| None, sidecar)?;
135 Ok(Built {
136 client: Arc::new(ReplayHttpClient::record(
137 dir,
138 inner.client,
139 secret_values(config),
140 )),
141 kind: Kind::Record,
142 notices: inner.notices,
143 ghola_missing: inner.ghola_missing,
144 })
145 }
146 other => Err(ScopeError::Network(format!(
147 "unknown SCOPE_HTTP_MODE \"{other}\" (use replay or record)"
148 ))),
149 };
150 }
151 let mut notices = Vec::new();
152 let mut ghola_missing = false;
153 if config.ghola.enabled {
154 let client = match connect_sidecar(sidecar, &mut notices) {
155 Ok(()) => GholaHttpClient::new(config.ghola.stealth, config.ghola.buffer_size),
156 Err(e) => Err(e),
157 };
158 match client {
159 Ok(client) => {
160 tracing::info!("Using Ghola sidecar for HTTP transport");
161 return Ok(Built {
162 client: Arc::new(client),
163 kind: Kind::Ghola,
164 notices,
165 ghola_missing,
166 });
167 }
168 Err(e) => {
169 notices.push(format!("⚠ Ghola sidecar enabled but unavailable: {e}"));
170 if sidecar.binary().is_none() {
171 ghola_missing = true;
172 notices.extend(
173 ghola_install::manual_steps()
174 .iter()
175 .map(|s| format!(" {s}")),
176 );
177 }
178 notices.push(" Falling back to native HTTP transport".into());
179 }
180 }
181 }
182 Ok(Built {
183 client: Arc::new(NativeHttpClient::new()?),
184 kind: Kind::Native,
185 notices,
186 ghola_missing,
187 })
188}
189
190fn connect_sidecar(sidecar: &dyn Sidecar, notices: &mut Vec<String>) -> Result<(), ScopeError> {
193 if sidecar.up() {
194 return Ok(());
195 }
196 let binary = sidecar.binary().ok_or_else(|| {
197 ScopeError::Network(format!(
198 "no sidecar answers on {} and ghola is not installed",
199 ghola::SIDECAR_ADDR
200 ))
201 })?;
202 sidecar.start(&binary)?;
203 notices.push(format!(
204 "Started the Ghola sidecar ({} --serve)",
205 binary.display()
206 ));
207 Ok(())
208}
209
210pub fn secret_values(config: &Config) -> Vec<String> {
212 let mut out: Vec<String> = config.chains.api_keys.values().cloned().collect();
213 if let Some(t) = &config.web.auth_token {
214 out.push(t.clone());
215 }
216 let rpcs = [
217 &config.chains.ethereum_rpc,
218 &config.chains.bsc_rpc,
219 &config.chains.aegis_rpc,
220 &config.chains.solana_rpc,
221 &config.chains.tron_api,
222 ];
223 for url in rpcs.into_iter().flatten() {
224 if let Ok(u) = reqwest::Url::parse(url) {
225 if let Some(p) = u.password() {
226 out.push(p.to_string());
227 }
228 out.extend(
229 u.path_segments()
230 .into_iter()
231 .flatten()
232 .filter(|s| s.len() >= MIN_SECRET_SEGMENT_LEN)
233 .map(str::to_string),
234 );
235 }
236 }
237 out
238}
239
240pub(crate) fn fallback() -> Arc<dyn HttpClient> {
250 let built = build(&Config::default()).unwrap_or_else(|e| {
251 panic!("a client was built without a transport, and none could be built: {e}")
252 });
253 tracing::debug!(
254 "client built without a transport; using {} from the environment and default config",
255 built.kind.name()
256 );
257 built.client
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 fn no_env(_: &str) -> Option<String> {
265 None
266 }
267
268 #[test]
269 fn test_configured_kind_follows_ghola_enabled() {
270 assert_eq!(configured_kind(&Config::default()), Kind::Native);
271 let mut config = Config::default();
272 config.ghola.enabled = true;
273 assert_eq!(configured_kind(&config), Kind::Ghola);
274 }
275
276 fn env_of(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
277 move |k| {
278 pairs
279 .iter()
280 .find(|(n, _)| *n == k)
281 .map(|(_, v)| v.to_string())
282 }
283 }
284
285 #[test]
286 fn test_configured_kind_with_env_selects_replay_and_record() {
287 let c = Config::default();
288 let replay = env_of(&[("SCOPE_HTTP_MODE", "replay")]);
289 let record = env_of(&[("SCOPE_HTTP_MODE", "record")]);
290 assert_eq!(configured_kind_with(&c, &replay), Kind::Replay);
291 assert_eq!(configured_kind_with(&c, &record), Kind::Record);
292 let mut g = Config::default();
294 g.ghola.enabled = true;
295 assert_eq!(configured_kind_with(&g, &record), Kind::Record);
296 assert_eq!(configured_kind_with(&g, &no_env), Kind::Ghola);
297 }
298
299 #[test]
300 fn test_replay_mode_needs_a_fixture_dir() {
301 let err = build_with(
302 &Config::default(),
303 &env_of(&[("SCOPE_HTTP_MODE", "replay")]),
304 )
305 .err()
306 .unwrap();
307 assert!(err.to_string().contains("SCOPE_HTTP_FIXTURES"), "{err}");
308 }
309
310 #[test]
311 fn test_replay_mode_selects_replay() {
312 let env = env_of(&[
313 ("SCOPE_HTTP_MODE", "replay"),
314 ("SCOPE_HTTP_FIXTURES", "/tmp/x"),
315 ]);
316 let built = build_with(&Config::default(), &env).unwrap();
317 assert_eq!(built.kind, Kind::Replay);
318 }
319
320 #[test]
321 fn test_record_mode_selects_record() {
322 let env = env_of(&[
323 ("SCOPE_HTTP_MODE", "record"),
324 ("SCOPE_HTTP_FIXTURES", "/tmp/x"),
325 ]);
326 let built = build_with(&Config::default(), &env).unwrap();
327 assert_eq!(built.kind, Kind::Record);
328 }
329
330 #[test]
331 fn test_unknown_mode_is_an_error() {
332 let env = env_of(&[
333 ("SCOPE_HTTP_MODE", "playback"),
334 ("SCOPE_HTTP_FIXTURES", "/tmp/x"),
335 ]);
336 let err = build_with(&Config::default(), &env).err().unwrap();
337 assert!(err.to_string().contains("playback"), "{err}");
338 }
339
340 #[test]
341 fn test_secret_values_include_keys_token_and_rpc_path() {
342 let mut c = Config::default();
343 c.chains
344 .api_keys
345 .insert("etherscan".into(), "ABCDEF123456".into());
346 c.web.auth_token = Some("tok-secret".into());
347 c.chains.ethereum_rpc = Some("https://mainnet.infura.io/v3/0123456789abcdef0123".into());
348 let s = secret_values(&c);
349 assert!(s.contains(&"ABCDEF123456".to_string()));
350 assert!(s.contains(&"tok-secret".to_string()));
351 assert!(s.contains(&"0123456789abcdef0123".to_string()));
352 assert!(!s.contains(&"v3".to_string()));
353 }
354
355 #[test]
356 fn test_build_default_is_native_with_no_notices() {
357 let built = build_with(&Config::default(), &no_env).unwrap();
358 assert!(built.notices.is_empty());
359 assert_eq!(built.kind, Kind::Native);
360 }
361
362 fn ghola_config() -> Config {
363 let mut config = Config::default();
364 config.ghola.enabled = true;
365 config
366 }
367
368 struct FakeSidecar {
371 up: bool,
372 binary: Option<&'static str>,
373 starts: bool,
374 started: std::cell::Cell<u32>,
375 }
376
377 impl FakeSidecar {
378 fn new(up: bool, binary: Option<&'static str>, starts: bool) -> Self {
379 Self {
380 up,
381 binary,
382 starts,
383 started: std::cell::Cell::new(0),
384 }
385 }
386 }
387
388 impl Sidecar for FakeSidecar {
389 fn up(&self) -> bool {
390 self.up
391 }
392 fn binary(&self) -> Option<PathBuf> {
393 self.binary.map(PathBuf::from)
394 }
395 fn start(&self, _: &Path) -> Result<(), ScopeError> {
396 self.started.set(self.started.get() + 1);
397 if self.starts {
398 Ok(())
399 } else {
400 Err(ScopeError::Network("exited".into()))
401 }
402 }
403 }
404
405 struct NoSidecar;
407
408 impl Sidecar for NoSidecar {
409 fn up(&self) -> bool {
410 panic!("probed the sidecar with Ghola disabled")
411 }
412 fn binary(&self) -> Option<PathBuf> {
413 panic!("looked for ghola with Ghola disabled")
414 }
415 fn start(&self, _: &Path) -> Result<(), ScopeError> {
416 panic!("started ghola with Ghola disabled")
417 }
418 }
419
420 #[test]
423 fn test_build_ghola_enabled_sidecar_down_falls_back_to_native() {
424 let sidecar = FakeSidecar::new(false, None, false);
425 let built = build_probed(&ghola_config(), &no_env, &sidecar).unwrap();
426 assert_eq!(built.kind, Kind::Native);
427 assert!(built.ghola_missing);
428 assert_eq!(sidecar.started.get(), 0);
429 assert!(
430 built
431 .notices
432 .iter()
433 .any(|n| n.contains(ghola::SIDECAR_ADDR))
434 );
435 assert!(built.notices.iter().any(|n| n.contains("native")));
436 assert!(
437 built
438 .notices
439 .iter()
440 .any(|n| n.contains("/cmd/ghola@latest")),
441 "{:?}",
442 built.notices
443 );
444 }
445
446 #[test]
447 fn test_build_ghola_enabled_sidecar_up_selects_ghola() {
448 let sidecar = FakeSidecar::new(true, None, false);
449 let built = build_probed(&ghola_config(), &no_env, &sidecar).unwrap();
450 assert_eq!(built.kind, Kind::Ghola);
451 assert!(built.notices.is_empty());
452 assert!(!built.ghola_missing);
453 assert_eq!(sidecar.started.get(), 0);
454 }
455
456 #[test]
457 fn test_build_starts_an_installed_sidecar() {
458 let sidecar = FakeSidecar::new(false, Some("/home/u/.scope-bca/bin/ghola"), true);
459 let built = build_probed(&ghola_config(), &no_env, &sidecar).unwrap();
460 assert_eq!(built.kind, Kind::Ghola);
461 assert_eq!(sidecar.started.get(), 1);
462 assert!(!built.ghola_missing);
463 assert!(
464 built.notices.iter().any(|n| n.contains("Started")),
465 "{:?}",
466 built.notices
467 );
468 }
469
470 #[test]
471 fn test_build_falls_back_when_the_installed_sidecar_fails() {
472 let sidecar = FakeSidecar::new(false, Some("/opt/ghola"), false);
473 let built = build_probed(&ghola_config(), &no_env, &sidecar).unwrap();
474 assert_eq!(built.kind, Kind::Native);
475 assert!(!built.ghola_missing);
477 assert!(built.notices.iter().any(|n| n.contains("exited")));
478 }
479
480 #[test]
481 fn test_build_ghola_disabled_never_probes() {
482 let built = build_probed(&Config::default(), &no_env, &NoSidecar).unwrap();
483 assert_eq!(built.kind, Kind::Native);
484 assert!(!built.ghola_missing);
485 }
486
487 #[test]
489 fn test_record_mode_falls_back_when_sidecar_down() {
490 let env = env_of(&[
491 ("SCOPE_HTTP_MODE", "record"),
492 ("SCOPE_HTTP_FIXTURES", "/tmp/x"),
493 ]);
494 let sidecar = FakeSidecar::new(false, None, false);
495 let built = build_probed(&ghola_config(), &env, &sidecar).unwrap();
496 assert_eq!(built.kind, Kind::Record);
497 assert!(built.ghola_missing);
498 assert!(
499 built
500 .notices
501 .iter()
502 .any(|n| n.contains(ghola::SIDECAR_ADDR))
503 );
504 }
505}