1use super::replay::ReplayHttpClient;
9use super::{GholaHttpClient, HttpClient, NativeHttpClient, ghola};
10use crate::config::Config;
11use crate::error::ScopeError;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::{Arc, OnceLock};
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}
45
46struct Installed {
49 client: Arc<dyn HttpClient>,
50 kind: Kind,
51 fell_back: bool,
53}
54
55static INSTALLED: OnceLock<Installed> = OnceLock::new();
56static NOTICE_SENT: AtomicBool = AtomicBool::new(false);
58
59pub fn configured_kind(config: &Config) -> Kind {
61 configured_kind_with(config, &|k| std::env::var(k).ok())
62}
63
64pub fn configured_kind_with(config: &Config, env: &dyn Fn(&str) -> Option<String>) -> Kind {
66 match env("SCOPE_HTTP_MODE").as_deref() {
67 Some("replay") => Kind::Replay,
68 Some("record") => Kind::Record,
69 _ if config.ghola.enabled => Kind::Ghola,
70 _ => Kind::Native,
71 }
72}
73
74pub fn build(config: &Config) -> Result<Built, ScopeError> {
76 build_with(config, &|k| std::env::var(k).ok())
77}
78
79pub fn build_with(
84 config: &Config,
85 env: &dyn Fn(&str) -> Option<String>,
86) -> Result<Built, ScopeError> {
87 build_probed(config, env, &ghola::is_bridge_running)
88}
89
90fn build_probed(
94 config: &Config,
95 env: &dyn Fn(&str) -> Option<String>,
96 sidecar_up: &dyn Fn() -> bool,
97) -> Result<Built, ScopeError> {
98 if let Some(mode) = env("SCOPE_HTTP_MODE") {
99 let dir = env("SCOPE_HTTP_FIXTURES")
100 .map(std::path::PathBuf::from)
101 .ok_or_else(|| {
102 ScopeError::Network("SCOPE_HTTP_MODE is set but SCOPE_HTTP_FIXTURES is not".into())
103 })?;
104 return match mode.as_str() {
105 "replay" => Ok(Built {
106 client: Arc::new(ReplayHttpClient::replay(dir)),
107 kind: Kind::Replay,
108 notices: vec![],
109 }),
110 "record" => {
111 let inner = build_probed(config, &|_| None, sidecar_up)?;
114 Ok(Built {
115 client: Arc::new(ReplayHttpClient::record(
116 dir,
117 inner.client,
118 secret_values(config),
119 )),
120 kind: Kind::Record,
121 notices: inner.notices,
122 })
123 }
124 other => Err(ScopeError::Network(format!(
125 "unknown SCOPE_HTTP_MODE \"{other}\" (use replay or record)"
126 ))),
127 };
128 }
129 let mut notices = Vec::new();
130 if config.ghola.enabled {
131 let client = if sidecar_up() {
132 GholaHttpClient::new(config.ghola.stealth, config.ghola.buffer_size)
133 } else {
134 Err(ScopeError::Network(format!(
135 "no sidecar answers on {}",
136 ghola::SIDECAR_ADDR
137 )))
138 };
139 match client {
140 Ok(client) => {
141 tracing::info!("Using Ghola sidecar for HTTP transport");
142 return Ok(Built {
143 client: Arc::new(client),
144 kind: Kind::Ghola,
145 notices,
146 });
147 }
148 Err(e) => {
149 notices.push(format!("⚠ Ghola sidecar enabled but unavailable: {e}"));
150 notices
151 .push(" Install: go install github.com/robot-accomplice/ghola@latest".into());
152 notices.push(" Falling back to native HTTP transport".into());
153 }
154 }
155 }
156 Ok(Built {
157 client: Arc::new(NativeHttpClient::new()?),
158 kind: Kind::Native,
159 notices,
160 })
161}
162
163pub fn secret_values(config: &Config) -> Vec<String> {
165 let mut out: Vec<String> = config.chains.api_keys.values().cloned().collect();
166 if let Some(t) = &config.web.auth_token {
167 out.push(t.clone());
168 }
169 let rpcs = [
170 &config.chains.ethereum_rpc,
171 &config.chains.bsc_rpc,
172 &config.chains.aegis_rpc,
173 &config.chains.solana_rpc,
174 &config.chains.tron_api,
175 ];
176 for url in rpcs.into_iter().flatten() {
177 if let Ok(u) = reqwest::Url::parse(url) {
178 if let Some(p) = u.password() {
179 out.push(p.to_string());
180 }
181 out.extend(
182 u.path_segments()
183 .into_iter()
184 .flatten()
185 .filter(|s| s.len() >= MIN_SECRET_SEGMENT_LEN)
186 .map(str::to_string),
187 );
188 }
189 }
190 out
191}
192
193pub fn init(config: &Config) -> Result<Built, ScopeError> {
202 init_with(config, &|k| std::env::var(k).ok())
203}
204
205pub fn init_with(
207 config: &Config,
208 env: &dyn Fn(&str) -> Option<String>,
209) -> Result<Built, ScopeError> {
210 if let Some(installed) = INSTALLED.get() {
211 let mut notices = Vec::new();
212 let wanted = configured_kind_with(config, env);
213 let mismatch = installed.fell_back && wanted != installed.kind;
214 if installed.fell_back && wanted == Kind::Record && installed.kind == Kind::Record {
218 return Err(ScopeError::Network(
219 "record mode started before the config was loaded, so secrets from the \
220 config could not be scanned for; the run stops"
221 .into(),
222 ));
223 }
224 if mismatch && matches!(wanted, Kind::Replay | Kind::Record) {
225 return Err(ScopeError::Network(format!(
226 "HTTP was used before startup with the {} transport; SCOPE_HTTP_MODE={} \
227 was not applied, so the run stops",
228 installed.kind.name(),
229 wanted.name()
230 )));
231 }
232 if mismatch && !NOTICE_SENT.swap(true, Ordering::SeqCst) {
233 let msg = format!(
234 "⚠ HTTP was used before startup; the {} transport from config was not applied",
235 wanted.name()
236 );
237 tracing::warn!("{msg}");
238 notices.push(msg);
239 }
240 return Ok(Built {
241 client: installed.client.clone(),
242 kind: installed.kind,
243 notices,
244 });
245 }
246 let built = build_with(config, env)?;
247 let installed = Installed {
248 client: built.client.clone(),
249 kind: built.kind,
250 fell_back: false,
251 };
252 match INSTALLED.set(installed) {
253 Ok(()) => Ok(built),
254 Err(_) => init_with(config, env),
256 }
257}
258
259pub fn transport() -> Arc<dyn HttpClient> {
269 transport_with(&|k| std::env::var(k).ok())
270}
271
272pub fn transport_with(env: &dyn Fn(&str) -> Option<String>) -> Arc<dyn HttpClient> {
274 if let Some(installed) = INSTALLED.get() {
275 return installed.client.clone();
276 }
277 let built = build_with(&Config::default(), env).unwrap_or_else(|e| {
278 panic!("HTTP used before startup, and the transport could not be built: {e}")
279 });
280 tracing::debug!(
281 "no HTTP transport installed; using {} from the environment and default config",
282 built.kind.name()
283 );
284 let _ = INSTALLED.set(Installed {
286 client: built.client,
287 kind: built.kind,
288 fell_back: true,
289 });
290 INSTALLED.get().expect("installed above").client.clone()
291}
292
293pub fn describe() -> &'static str {
295 INSTALLED.get().map_or("none", |i| i.kind.name())
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 fn no_env(_: &str) -> Option<String> {
303 None
304 }
305
306 #[test]
307 fn test_configured_kind_follows_ghola_enabled() {
308 assert_eq!(configured_kind(&Config::default()), Kind::Native);
309 let mut config = Config::default();
310 config.ghola.enabled = true;
311 assert_eq!(configured_kind(&config), Kind::Ghola);
312 }
313
314 fn env_of(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
315 move |k| {
316 pairs
317 .iter()
318 .find(|(n, _)| *n == k)
319 .map(|(_, v)| v.to_string())
320 }
321 }
322
323 #[test]
324 fn test_configured_kind_with_env_selects_replay_and_record() {
325 let c = Config::default();
326 let replay = env_of(&[("SCOPE_HTTP_MODE", "replay")]);
327 let record = env_of(&[("SCOPE_HTTP_MODE", "record")]);
328 assert_eq!(configured_kind_with(&c, &replay), Kind::Replay);
329 assert_eq!(configured_kind_with(&c, &record), Kind::Record);
330 let mut g = Config::default();
332 g.ghola.enabled = true;
333 assert_eq!(configured_kind_with(&g, &record), Kind::Record);
334 assert_eq!(configured_kind_with(&g, &no_env), Kind::Ghola);
335 }
336
337 #[test]
338 fn test_replay_mode_needs_a_fixture_dir() {
339 let err = build_with(
340 &Config::default(),
341 &env_of(&[("SCOPE_HTTP_MODE", "replay")]),
342 )
343 .err()
344 .unwrap();
345 assert!(err.to_string().contains("SCOPE_HTTP_FIXTURES"), "{err}");
346 }
347
348 #[test]
349 fn test_replay_mode_selects_replay() {
350 let env = env_of(&[
351 ("SCOPE_HTTP_MODE", "replay"),
352 ("SCOPE_HTTP_FIXTURES", "/tmp/x"),
353 ]);
354 let built = build_with(&Config::default(), &env).unwrap();
355 assert_eq!(built.kind, Kind::Replay);
356 }
357
358 #[test]
359 fn test_record_mode_selects_record() {
360 let env = env_of(&[
361 ("SCOPE_HTTP_MODE", "record"),
362 ("SCOPE_HTTP_FIXTURES", "/tmp/x"),
363 ]);
364 let built = build_with(&Config::default(), &env).unwrap();
365 assert_eq!(built.kind, Kind::Record);
366 }
367
368 #[test]
369 fn test_unknown_mode_is_an_error() {
370 let env = env_of(&[
371 ("SCOPE_HTTP_MODE", "playback"),
372 ("SCOPE_HTTP_FIXTURES", "/tmp/x"),
373 ]);
374 let err = build_with(&Config::default(), &env).err().unwrap();
375 assert!(err.to_string().contains("playback"), "{err}");
376 }
377
378 #[test]
379 fn test_secret_values_include_keys_token_and_rpc_path() {
380 let mut c = Config::default();
381 c.chains
382 .api_keys
383 .insert("etherscan".into(), "ABCDEF123456".into());
384 c.web.auth_token = Some("tok-secret".into());
385 c.chains.ethereum_rpc = Some("https://mainnet.infura.io/v3/0123456789abcdef0123".into());
386 let s = secret_values(&c);
387 assert!(s.contains(&"ABCDEF123456".to_string()));
388 assert!(s.contains(&"tok-secret".to_string()));
389 assert!(s.contains(&"0123456789abcdef0123".to_string()));
390 assert!(!s.contains(&"v3".to_string()));
391 }
392
393 #[test]
394 fn test_build_default_is_native_with_no_notices() {
395 let built = build_with(&Config::default(), &no_env).unwrap();
396 assert!(built.notices.is_empty());
397 assert_eq!(built.kind, Kind::Native);
398 }
399
400 fn ghola_config() -> Config {
401 let mut config = Config::default();
402 config.ghola.enabled = true;
403 config
404 }
405
406 #[test]
409 fn test_build_ghola_enabled_sidecar_down_falls_back_to_native() {
410 let built = build_probed(&ghola_config(), &no_env, &|| false).unwrap();
411 assert_eq!(built.kind, Kind::Native);
412 assert!(
413 built
414 .notices
415 .iter()
416 .any(|n| n.contains(ghola::SIDECAR_ADDR))
417 );
418 assert!(built.notices.iter().any(|n| n.contains("native")));
419 }
420
421 #[test]
422 fn test_build_ghola_enabled_sidecar_up_selects_ghola() {
423 let built = build_probed(&ghola_config(), &no_env, &|| true).unwrap();
424 assert_eq!(built.kind, Kind::Ghola);
425 assert!(built.notices.is_empty());
426 }
427
428 #[test]
429 fn test_build_ghola_disabled_never_probes() {
430 let built = build_probed(&Config::default(), &no_env, &|| {
431 panic!("probed the sidecar with Ghola disabled")
432 })
433 .unwrap();
434 assert_eq!(built.kind, Kind::Native);
435 }
436
437 #[test]
439 fn test_record_mode_falls_back_when_sidecar_down() {
440 let env = env_of(&[
441 ("SCOPE_HTTP_MODE", "record"),
442 ("SCOPE_HTTP_FIXTURES", "/tmp/x"),
443 ]);
444 let built = build_probed(&ghola_config(), &env, &|| false).unwrap();
445 assert_eq!(built.kind, Kind::Record);
446 assert!(
447 built
448 .notices
449 .iter()
450 .any(|n| n.contains(ghola::SIDECAR_ADDR))
451 );
452 }
453}