Skip to main content

scope/http/
shared.rs

1//! # Shared HTTP transport
2//!
3//! The one place that selects the HTTP transport. `build` makes it from
4//! config and the environment; the caller passes it to every client it
5//! creates (see `DefaultClientFactory`). Nothing is installed for the
6//! process (#59).
7
8use 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
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    /// Ghola is enabled, no sidecar answers and no `ghola` binary was
45    /// found. A caller that may ask the user can offer to install it
46    /// (`ghola_install`) and build again.
47    pub ghola_missing: bool,
48}
49
50/// The local Ghola sidecar, as the build sees it.
51trait Sidecar {
52    /// True when a sidecar answers on `ghola::SIDECAR_ADDR`.
53    fn up(&self) -> bool;
54    /// The `ghola` binary, when one is installed.
55    fn binary(&self) -> Option<PathBuf>;
56    /// Starts `binary --serve` and waits until it answers.
57    fn start(&self, binary: &Path) -> Result<(), ScopeError>;
58}
59
60/// The real sidecar: the probe, `ghola_install::find` and `ghola::start_sidecar`.
61struct 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
75/// The kind the config and environment select, derived without building a client.
76pub fn configured_kind(config: &Config) -> Kind {
77    configured_kind_with(config, &|k| std::env::var(k).ok())
78}
79
80/// Like `configured_kind`, reading environment variables through `env`.
81pub 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
90/// Builds the transport the config and environment select.
91pub fn build(config: &Config) -> Result<Built, ScopeError> {
92    build_with(config, &|k| std::env::var(k).ok())
93}
94
95/// Builds the transport, reading environment variables through `env`.
96///
97/// Order: replay or record when `SCOPE_HTTP_MODE` is set (with
98/// `SCOPE_HTTP_FIXTURES`), else Ghola when enabled and available, else native.
99///
100/// With Ghola enabled and no sidecar answering, it starts an installed
101/// `ghola --serve` (from PATH or `~/.scope-bca/bin`) and waits for it.
102/// That sidecar keeps running until `ghola::stop_spawned_sidecar`.
103pub 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
110/// [`build_with`] with the Ghola sidecar injected, so tests do not depend
111/// on a sidecar or a binary on the test machine. Ghola is chosen only when
112/// the sidecar answers, at once or after a start (#64).
113fn 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                // The normal transport (Ghola or native) does the real requests.
133                // The empty env lookup stops it from selecting record again.
134                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
190/// Makes sure a sidecar answers: one already running, or an installed
191/// binary started now.
192fn 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
210/// Values that must never be written to a fixture.
211pub 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
240/// A transport for the deprecated constructors that take none: built
241/// fresh from the environment and the default config on each call, so
242/// `SCOPE_HTTP_MODE` still applies. Nothing is installed or shared.
243///
244/// # Panics
245///
246/// Panics when that transport cannot be built, for example
247/// `SCOPE_HTTP_MODE=replay` with no `SCOPE_HTTP_FIXTURES`. A replay run must
248/// not go to the network in its place.
249pub(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        // The mode wins over ghola.
293        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    /// A sidecar for tests: `up` answers, `binary` is installed, and a
369    /// start succeeds when `starts`. It counts the starts.
370    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    /// Ghola must never be probed or started when it is disabled.
406    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    // #64: an enabled Ghola with no sidecar must not be selected, or every
421    // request fails at send time. Native takes over, and the user is told why.
422    #[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        // It is installed: nothing to offer.
476        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    // Record mode wraps the normal transport, so it must use the same probe.
488    #[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}