Skip to main content

cardanowall_cli/config/
resolve_gateways.rs

1//! Resolve the gateway slots (cardano, arweave, ipfs, blockfrost) plus the two
2//! scalar slots (threshold, deny-hosts) using the precedence:
3//!
4//! ```text
5//! flag (repeatable / comma-list)  →
6//! env  (comma-separated)          →
7//! config-file (string or array)   →
8//! built-in default chain
9//! ```
10//!
11//! First non-empty source wins; lower-precedence sources are NOT merged in. URL
12//! shape is validated here (https-only, except loopback).
13
14use cardanowall::verifier::KOIOS_MAINNET_URL;
15
16use crate::config::read_config_file::CardanoWallConfig;
17use crate::util::CliError;
18
19/// The resolved gateway chains and scalars the verifier / inbox paths consume.
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct ResolvedGateways {
22    /// Cardano (Koios-compatible) gateway chain.
23    pub cardano_gateway_chain: Vec<String>,
24    /// Blockfrost project id, when configured.
25    pub blockfrost_project_id: Option<String>,
26    /// Arweave gateway chain.
27    pub arweave_gateway_chain: Vec<String>,
28    /// IPFS gateway chain, when configured (no baked-in default).
29    pub ipfs_gateway_chain: Option<Vec<String>>,
30    /// Confirmation-depth threshold, when set anywhere.
31    pub confirmation_depth_threshold: Option<u32>,
32    /// Deny-host patterns, when set anywhere (the canonical default applies
33    /// downstream when this is `None`).
34    pub deny_hosts: Option<Vec<String>>,
35}
36
37/// Flag inputs, already collected by clap (empty vec = flag not given).
38#[derive(Debug, Clone, Default)]
39pub struct GatewayFlags {
40    /// `--cardano-gateway` (repeatable).
41    pub gateway: Vec<String>,
42    /// `--blockfrost`.
43    pub blockfrost: Option<String>,
44    /// `--arweave-gateway` (repeatable).
45    pub arweave_gateway: Vec<String>,
46    /// `--ipfs-gateway` (repeatable).
47    pub ipfs_gateway: Vec<String>,
48    /// `--threshold` (already parsed to a non-negative integer).
49    pub threshold: Option<u32>,
50    /// `--deny-host` (repeatable).
51    pub deny_host: Vec<String>,
52}
53
54/// The environment lookups the resolver needs, injected for tests.
55pub trait GatewayEnv {
56    /// Read an environment variable.
57    fn var(&self, key: &str) -> Option<String>;
58}
59
60/// The production env: real process environment.
61pub struct SystemGatewayEnv;
62
63impl GatewayEnv for SystemGatewayEnv {
64    fn var(&self, key: &str) -> Option<String> {
65        std::env::var(key).ok()
66    }
67}
68
69fn default_cardano_chain() -> Vec<String> {
70    vec![KOIOS_MAINNET_URL.to_string()]
71}
72
73fn default_arweave_chain() -> Vec<String> {
74    vec![
75        "https://ar-io.net".to_string(),
76        "https://arweave.net".to_string(),
77        "https://g8way.io".to_string(),
78    ]
79}
80
81/// Split a comma-separated env value into a trimmed, non-empty list.
82fn split_env_list(value: Option<&str>) -> Option<Vec<String>> {
83    let trimmed = value?.trim();
84    if trimmed.is_empty() {
85        return None;
86    }
87    let list: Vec<String> = trimmed
88        .split(',')
89        .map(|s| s.trim().to_string())
90        .filter(|s| !s.is_empty())
91        .collect();
92    if list.is_empty() {
93        None
94    } else {
95        Some(list)
96    }
97}
98
99fn pick_chain(
100    flag: &[String],
101    env: Option<&str>,
102    cfg: Option<Vec<String>>,
103    fallback: Vec<String>,
104) -> Vec<String> {
105    if !flag.is_empty() {
106        return flag.to_vec();
107    }
108    if let Some(list) = split_env_list(env) {
109        return list;
110    }
111    if let Some(list) = cfg {
112        if !list.is_empty() {
113            return list;
114        }
115    }
116    fallback
117}
118
119fn pick_scalar_string(flag: Option<&str>, env: Option<&str>, cfg: Option<&str>) -> Option<String> {
120    if let Some(f) = flag {
121        if !f.is_empty() {
122            return Some(f.to_string());
123        }
124    }
125    if let Some(e) = env {
126        let t = e.trim();
127        if !t.is_empty() {
128            return Some(t.to_string());
129        }
130    }
131    if let Some(c) = cfg {
132        if !c.is_empty() {
133            return Some(c.to_string());
134        }
135    }
136    None
137}
138
139fn pick_threshold(
140    flag: Option<u32>,
141    env: Option<&str>,
142    cfg: Option<i64>,
143) -> Result<Option<u32>, CliError> {
144    if let Some(f) = flag {
145        return Ok(Some(f));
146    }
147    if let Some(e) = env {
148        let t = e.trim();
149        if !t.is_empty() {
150            let n: i64 = t.parse().map_err(|_| {
151                CliError::input(format!(
152                    "verify: CARDANOWALL_CONFIRMATION_DEPTH_THRESHOLD must be a non-negative integer; got \"{e}\""
153                ))
154            })?;
155            if n < 0 {
156                return Err(CliError::input(format!(
157                    "verify: CARDANOWALL_CONFIRMATION_DEPTH_THRESHOLD must be a non-negative integer; got \"{e}\""
158                )));
159            }
160            return Ok(Some(n as u32));
161        }
162    }
163    if let Some(c) = cfg {
164        if c < 0 {
165            return Err(CliError::input(format!(
166                "verify: config-file confirmation_depth_threshold must be a non-negative integer; got {c}"
167            )));
168        }
169        return Ok(Some(c as u32));
170    }
171    Ok(None)
172}
173
174fn pick_deny_hosts(
175    flag: &[String],
176    env: Option<&str>,
177    cfg: Option<&[String]>,
178) -> Option<Vec<String>> {
179    if !flag.is_empty() {
180        return Some(flag.to_vec());
181    }
182    if let Some(list) = split_env_list(env) {
183        return Some(list);
184    }
185    if let Some(c) = cfg {
186        if !c.is_empty() {
187            return Some(c.to_vec());
188        }
189    }
190    None
191}
192
193/// Validate a single gateway URL: https only, except http on loopback.
194fn validate_url(url: &str, slot: &str) -> Result<(), CliError> {
195    // Minimal scheme + host check without a URL crate: parse `scheme://host…`.
196    let lowered = url.trim();
197    let (scheme, rest) = match lowered.split_once("://") {
198        Some(parts) => parts,
199        None => {
200            return Err(CliError::input(format!(
201                "verify: {slot} URL is not a valid URL; got \"{url}\""
202            )))
203        }
204    };
205    let host = rest
206        .split('/')
207        .next()
208        .unwrap_or("")
209        .split('@')
210        .next_back()
211        .unwrap_or("");
212    // Strip a port for the loopback comparison.
213    let host_only = if host.starts_with('[') {
214        // bracketed IPv6 literal
215        host.split(']')
216            .next()
217            .map(|h| format!("{h}]"))
218            .unwrap_or_default()
219    } else {
220        host.rsplit_once(':').map_or(host, |(h, _)| h).to_string()
221    };
222    match scheme {
223        "https" => Ok(()),
224        "http" => {
225            let is_loopback = matches!(
226                host_only.as_str(),
227                "localhost" | "127.0.0.1" | "::1" | "[::1]"
228            );
229            if is_loopback {
230                Ok(())
231            } else {
232                Err(CliError::input(format!(
233                    "verify: {slot} URL must use https (http is only permitted for localhost); got \"{url}\""
234                )))
235            }
236        }
237        _ => Err(CliError::input(format!(
238            "verify: {slot} URL must be https (or http on localhost); got \"{url}\""
239        ))),
240    }
241}
242
243fn validate_chain(chain: &[String], slot: &str) -> Result<(), CliError> {
244    for url in chain {
245        validate_url(url, slot)?;
246    }
247    Ok(())
248}
249
250/// Resolve all gateway slots, applying precedence and validating URL shape.
251///
252/// # Errors
253///
254/// Returns [`CliError`] (exit `4`) on an invalid URL or a malformed threshold.
255pub fn resolve_gateways(
256    flags: &GatewayFlags,
257    env: &dyn GatewayEnv,
258    config: Option<&CardanoWallConfig>,
259) -> Result<ResolvedGateways, CliError> {
260    let cardano_gateway_chain = pick_chain(
261        &flags.gateway,
262        env.var("CARDANOWALL_CARDANO_GATEWAY").as_deref(),
263        config.and_then(|c| c.cardano_gateway.as_ref().map(|v| v.to_vec())),
264        default_cardano_chain(),
265    );
266    validate_chain(&cardano_gateway_chain, "--cardano-gateway")?;
267
268    let arweave_gateway_chain = pick_chain(
269        &flags.arweave_gateway,
270        env.var("CARDANOWALL_ARWEAVE_GATEWAY").as_deref(),
271        config.and_then(|c| c.arweave_gateway.as_ref().map(|v| v.to_vec())),
272        default_arweave_chain(),
273    );
274    validate_chain(&arweave_gateway_chain, "--arweave-gateway")?;
275
276    let ipfs_gateway_chain = {
277        let from_flag = &flags.ipfs_gateway;
278        let from_env = split_env_list(env.var("CARDANOWALL_IPFS_GATEWAY").as_deref());
279        let from_cfg = config.and_then(|c| c.ipfs_gateway.as_ref().map(|v| v.to_vec()));
280        let chain = if !from_flag.is_empty() {
281            Some(from_flag.clone())
282        } else if let Some(list) = from_env {
283            Some(list)
284        } else {
285            from_cfg.filter(|l| !l.is_empty())
286        };
287        if let Some(ref c) = chain {
288            validate_chain(c, "--ipfs-gateway")?;
289        }
290        chain
291    };
292
293    let blockfrost_project_id = pick_scalar_string(
294        flags.blockfrost.as_deref(),
295        env.var("CARDANOWALL_BLOCKFROST_PROJECT_ID").as_deref(),
296        config.and_then(|c| c.blockfrost_project_id.as_deref()),
297    );
298
299    let confirmation_depth_threshold = pick_threshold(
300        flags.threshold,
301        env.var("CARDANOWALL_CONFIRMATION_DEPTH_THRESHOLD")
302            .as_deref(),
303        config.and_then(|c| c.confirmation_depth_threshold),
304    )?;
305
306    let deny_hosts = pick_deny_hosts(
307        &flags.deny_host,
308        env.var("CARDANOWALL_DENY_HOST").as_deref(),
309        config.and_then(|c| c.deny_host.as_deref()),
310    );
311
312    Ok(ResolvedGateways {
313        cardano_gateway_chain,
314        blockfrost_project_id,
315        arweave_gateway_chain,
316        ipfs_gateway_chain,
317        confirmation_depth_threshold,
318        deny_hosts,
319    })
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::config::read_config_file::StringOrList;
326    use std::collections::HashMap;
327
328    struct FakeEnv(HashMap<String, String>);
329    impl GatewayEnv for FakeEnv {
330        fn var(&self, key: &str) -> Option<String> {
331            self.0.get(key).cloned()
332        }
333    }
334    fn env(pairs: &[(&str, &str)]) -> FakeEnv {
335        FakeEnv(
336            pairs
337                .iter()
338                .map(|(k, v)| (k.to_string(), v.to_string()))
339                .collect(),
340        )
341    }
342
343    #[test]
344    fn falls_back_to_koios_default() {
345        let out = resolve_gateways(&GatewayFlags::default(), &env(&[]), None).unwrap();
346        assert_eq!(out.cardano_gateway_chain, vec![KOIOS_MAINNET_URL]);
347    }
348
349    #[test]
350    fn flag_overrides_env_and_config() {
351        let flags = GatewayFlags {
352            gateway: vec!["https://flag-1.example".to_string()],
353            ..GatewayFlags::default()
354        };
355        let cfg = CardanoWallConfig {
356            cardano_gateway: Some(StringOrList::One("https://config.example".to_string())),
357            ..CardanoWallConfig::default()
358        };
359        let out = resolve_gateways(
360            &flags,
361            &env(&[("CARDANOWALL_CARDANO_GATEWAY", "https://env.example")]),
362            Some(&cfg),
363        )
364        .unwrap();
365        assert_eq!(out.cardano_gateway_chain, vec!["https://flag-1.example"]);
366    }
367
368    #[test]
369    fn env_comma_splits_into_chain() {
370        let out = resolve_gateways(
371            &GatewayFlags::default(),
372            &env(&[(
373                "CARDANOWALL_CARDANO_GATEWAY",
374                "https://a.example,https://b.example",
375            )]),
376            None,
377        )
378        .unwrap();
379        assert_eq!(
380            out.cardano_gateway_chain,
381            vec!["https://a.example", "https://b.example"]
382        );
383    }
384
385    #[test]
386    fn rejects_non_https_non_loopback() {
387        let flags = GatewayFlags {
388            gateway: vec!["http://evil.example".to_string()],
389            ..GatewayFlags::default()
390        };
391        assert_eq!(
392            resolve_gateways(&flags, &env(&[]), None).unwrap_err().code,
393            4
394        );
395    }
396
397    #[test]
398    fn allows_http_loopback() {
399        let flags = GatewayFlags {
400            gateway: vec!["http://localhost:8080/api".to_string()],
401            ..GatewayFlags::default()
402        };
403        assert!(resolve_gateways(&flags, &env(&[]), None).is_ok());
404    }
405
406    #[test]
407    fn rejects_unparseable_url() {
408        let flags = GatewayFlags {
409            gateway: vec!["not-a-url".to_string()],
410            ..GatewayFlags::default()
411        };
412        assert_eq!(
413            resolve_gateways(&flags, &env(&[]), None).unwrap_err().code,
414            4
415        );
416    }
417}