1use cardanowall::verifier::KOIOS_MAINNET_URL;
15
16use crate::config::read_config_file::CardanoWallConfig;
17use crate::util::CliError;
18
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct ResolvedGateways {
22 pub cardano_gateway_chain: Vec<String>,
24 pub blockfrost_project_id: Option<String>,
26 pub arweave_gateway_chain: Vec<String>,
28 pub ipfs_gateway_chain: Option<Vec<String>>,
30 pub confirmation_depth_threshold: Option<u32>,
32 pub deny_hosts: Option<Vec<String>>,
35}
36
37#[derive(Debug, Clone, Default)]
39pub struct GatewayFlags {
40 pub gateway: Vec<String>,
42 pub blockfrost: Option<String>,
44 pub arweave_gateway: Vec<String>,
46 pub ipfs_gateway: Vec<String>,
48 pub threshold: Option<u32>,
50 pub deny_host: Vec<String>,
52}
53
54pub trait GatewayEnv {
56 fn var(&self, key: &str) -> Option<String>;
58}
59
60pub 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
81fn 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
193fn validate_url(url: &str, slot: &str) -> Result<(), CliError> {
195 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 let host_only = if host.starts_with('[') {
214 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
250pub 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}