Skip to main content

scope/http/
shared.rs

1//! # Shared HTTP transport
2//!
3//! The one place that selects the HTTP transport. `init` builds it from
4//! config and installs it for the process. Clients that do not get a
5//! transport passed in call `transport()`. See the TUI test harness
6//! design, section 2.2.
7
8use 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
15/// An RPC URL path segment this long or longer is treated as a secret.
16const MIN_SECRET_SEGMENT_LEN: usize = 16;
17
18/// Which transport was built.
19#[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/// A built transport and the notices the caller must show the user.
39#[derive(Clone)]
40pub struct Built {
41    pub client: Arc<dyn HttpClient>,
42    pub kind: Kind,
43    pub notices: Vec<String>,
44}
45
46/// The process-wide transport. `fell_back` is set in the same write as the
47/// install, so a concurrent `init` always sees it.
48struct Installed {
49    client: Arc<dyn HttpClient>,
50    kind: Kind,
51    /// True when `transport()` installed native because nothing was installed.
52    fell_back: bool,
53}
54
55static INSTALLED: OnceLock<Installed> = OnceLock::new();
56/// Set once the "not applied" notice has been returned, so it is shown once.
57static NOTICE_SENT: AtomicBool = AtomicBool::new(false);
58
59/// The kind the config and environment select, derived without building a client.
60pub fn configured_kind(config: &Config) -> Kind {
61    configured_kind_with(config, &|k| std::env::var(k).ok())
62}
63
64/// Like `configured_kind`, reading environment variables through `env`.
65pub 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
74/// Builds the transport the config and environment select.
75pub fn build(config: &Config) -> Result<Built, ScopeError> {
76    build_with(config, &|k| std::env::var(k).ok())
77}
78
79/// Builds the transport, reading environment variables through `env`.
80///
81/// Order: replay or record when `SCOPE_HTTP_MODE` is set (with
82/// `SCOPE_HTTP_FIXTURES`), else Ghola when enabled and available, else native.
83pub 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
90/// [`build_with`] with the Ghola sidecar check injected, so tests do not
91/// depend on a sidecar on the test machine. Ghola is chosen only when
92/// `sidecar_up` says the sidecar answers (#64).
93fn 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                // The normal transport (Ghola or native) does the real requests.
112                // The empty env lookup stops it from selecting record again.
113                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
163/// Values that must never be written to a fixture.
164pub 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
193/// Builds and installs the transport the first time; later calls return
194/// the installed transport with no notices.
195///
196/// # Errors
197///
198/// Returns an error when the transport cannot be built, or when
199/// `SCOPE_HTTP_MODE` selects replay or record but `transport()` already
200/// installed a different transport (the run must not use the network).
201pub fn init(config: &Config) -> Result<Built, ScopeError> {
202    init_with(config, &|k| std::env::var(k).ok())
203}
204
205/// Like `init`, reading environment variables through `env`.
206pub 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        // A fallen-back recorder was built from the default config, so it
215        // scanned for no configured secrets. Refuse it even when record is
216        // also the wanted mode.
217        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        // Another thread installed first: use its transport.
255        Err(_) => init_with(config, env),
256    }
257}
258
259/// The installed transport. If nothing is installed, installs the
260/// transport that the default config and the environment select, so
261/// `SCOPE_HTTP_MODE` applies even before `init`.
262///
263/// # Panics
264///
265/// Panics when that transport cannot be built, for example
266/// `SCOPE_HTTP_MODE=replay` with no `SCOPE_HTTP_FIXTURES`. A replay run must
267/// not go to the network in its place.
268pub fn transport() -> Arc<dyn HttpClient> {
269    transport_with(&|k| std::env::var(k).ok())
270}
271
272/// Like `transport`, reading environment variables through `env`.
273pub 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    // If another thread won the race, `get` below returns its transport.
285    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
293/// The installed transport kind, or `"none"`.
294pub 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        // The mode wins over ghola.
331        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    // #64: an enabled Ghola with no sidecar must not be selected, or every
407    // request fails at send time. Native takes over, and the user is told why.
408    #[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    // Record mode wraps the normal transport, so it must use the same probe.
438    #[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}