Skip to main content

soroban_cli/config/
network.rs

1use itertools::Itertools;
2use phf::phf_map;
3use reqwest::header::HeaderMap;
4use reqwest::header::{HeaderName, HeaderValue, InvalidHeaderName, InvalidHeaderValue};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::HashMap;
8use std::str::FromStr;
9use stellar_strkey::ed25519::PublicKey;
10use url::Url;
11
12use super::locator;
13use crate::utils::{http, url::redact_url};
14use crate::{
15    commands::HEADING_RPC,
16    rpc::{self, Client},
17};
18pub mod passphrase;
19
20#[derive(thiserror::Error, Debug)]
21pub enum Error {
22    #[error(transparent)]
23    Config(#[from] locator::Error),
24    #[error(
25        r#"Access to the network is required
26`--network` or `--rpc-url` and `--network-passphrase` are required if using the network.
27Network configuration can also be set using `network use` subcommand. For example, to use
28testnet, run `stellar network use testnet`.
29Alternatively you can use their corresponding environment variables:
30STELLAR_NETWORK, STELLAR_RPC_URL and STELLAR_NETWORK_PASSPHRASE"#
31    )]
32    Network,
33    #[error(
34        "rpc-url is used but network passphrase is missing, use `--network-passphrase` or `STELLAR_NETWORK_PASSPHRASE`"
35    )]
36    MissingNetworkPassphrase,
37    #[error(
38        "network passphrase is used but rpc-url is missing, use `--rpc-url` or `STELLAR_RPC_URL`"
39    )]
40    MissingRpcUrl,
41    #[error("cannot use both `--rpc-url` and `--network`")]
42    CannotUseBothRpcAndNetwork,
43    #[error(transparent)]
44    Rpc(#[from] rpc::Error),
45    #[error(transparent)]
46    HttpClient(#[from] reqwest::Error),
47    #[error("Failed to parse JSON from {0}, {1}")]
48    FailedToParseJSON(String, serde_json::Error),
49    #[error("Invalid URL {0}")]
50    InvalidUrl(String),
51    #[error("funding failed: {0}")]
52    FundingFailed(String),
53    #[error(transparent)]
54    InvalidHeaderName(#[from] InvalidHeaderName),
55    #[error(transparent)]
56    InvalidHeaderValue(#[from] InvalidHeaderValue),
57    #[error("invalid HTTP header: must be in the form 'key:value'")]
58    InvalidHeader,
59}
60
61#[derive(Debug, clap::Args, Clone, Default)]
62#[group(id = "network-args")]
63pub struct Args {
64    /// RPC server endpoint
65    #[arg(
66        long = "rpc-url",
67        env = "STELLAR_RPC_URL",
68        help_heading = HEADING_RPC,
69    )]
70    pub rpc_url: Option<String>,
71    /// RPC Header(s) to include in requests to the RPC provider, example: "X-API-Key: abc123". Multiple headers can be added by passing the option multiple times.
72    #[arg(
73        long = "rpc-header",
74        env = "STELLAR_RPC_HEADERS",
75        help_heading = HEADING_RPC,
76        num_args = 1,
77        action = clap::ArgAction::Append,
78        value_delimiter = '\n',
79        hide_env_values = true,
80    )]
81    pub rpc_headers: Vec<String>,
82    /// Network passphrase to sign the transaction sent to the rpc server
83    #[arg(
84        long = "network-passphrase",
85        env = "STELLAR_NETWORK_PASSPHRASE",
86        help_heading = HEADING_RPC,
87    )]
88    pub network_passphrase: Option<String>,
89    /// Name of network to use from config
90    #[arg(
91        long,
92        short = 'n',
93        env = "STELLAR_NETWORK",
94        help_heading = HEADING_RPC,
95    )]
96    pub network: Option<String>,
97}
98
99impl Args {
100    pub fn get(&self, locator: &locator::Args) -> Result<Network, Error> {
101        self.resolve(locator, true)
102    }
103
104    /// Resolve the network config.
105    ///
106    /// When `require_rpc` is false, a passphrase supplied on its own resolves to
107    /// a `Network` with an empty `rpc_url` (for commands like `tx sign` and
108    /// `tx hash` that never contact an RPC server). Such callers MUST NOT use
109    /// `rpc_client()`. When `require_rpc` is true, a passphrase-only invocation
110    /// errors with `MissingRpcUrl`.
111    pub fn resolve(&self, locator: &locator::Args, require_rpc: bool) -> Result<Network, Error> {
112        match (
113            self.network.as_deref(),
114            self.rpc_url.clone(),
115            self.network_passphrase.clone(),
116        ) {
117            (None, None, None) => {
118                // Fall back to testnet as the default network if no config default is set
119                Ok(DEFAULTS.get(DEFAULT_NETWORK_KEY).unwrap().into())
120            }
121            (_, Some(_), None) => Err(Error::MissingNetworkPassphrase),
122            // Signing-only commands don't need an RPC URL, so accept a
123            // passphrase on its own and leave `rpc_url` empty.
124            (_, None, Some(network_passphrase)) if !require_rpc => Ok(Network {
125                rpc_url: String::new(),
126                rpc_headers: Vec::new(),
127                network_passphrase,
128            }),
129            (_, None, Some(_)) => Err(Error::MissingRpcUrl),
130            (Some(network), None, None) => Ok(locator.read_network(network)?),
131            (_, Some(rpc_url), Some(network_passphrase)) => {
132                let rpc_headers = self
133                    .rpc_headers
134                    .iter()
135                    .map(|h| parse_http_header(h))
136                    .collect::<Result<Vec<_>, _>>()?;
137                Ok(Network {
138                    rpc_url,
139                    rpc_headers,
140                    network_passphrase,
141                })
142            }
143        }
144    }
145}
146
147#[derive(clap::Args, Serialize, Deserialize, Clone)]
148#[group(skip)]
149pub struct Network {
150    /// RPC server endpoint
151    #[arg(
152        long = "rpc-url",
153        env = "STELLAR_RPC_URL",
154        help_heading = HEADING_RPC,
155    )]
156    pub rpc_url: String,
157    /// Optional header to include in requests to the RPC, example: "X-API-Key: abc123". Multiple headers can be added by passing the option multiple times.
158    #[arg(
159        long = "rpc-header",
160        env = "STELLAR_RPC_HEADERS",
161        help_heading = HEADING_RPC,
162        num_args = 1,
163        action = clap::ArgAction::Append,
164        value_delimiter = '\n',
165        value_parser = accept_raw_rpc_header,
166        hide_env_values = true,
167    )]
168    pub rpc_headers: Vec<(String, String)>,
169    /// Network passphrase to sign the transaction sent to the rpc server
170    #[arg(
171            long,
172            env = "STELLAR_NETWORK_PASSPHRASE",
173            help_heading = HEADING_RPC,
174        )]
175    pub network_passphrase: String,
176}
177
178impl std::fmt::Debug for Network {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        let concealed: Vec<(&str, &str)> = self
181            .rpc_headers
182            .iter()
183            .map(|(k, _)| (k.as_str(), "<concealed>"))
184            .collect();
185        f.debug_struct("Network")
186            .field("rpc_url", &redact_url(&self.rpc_url))
187            .field("rpc_headers", &concealed)
188            .field("network_passphrase", &self.network_passphrase)
189            .finish()
190    }
191}
192
193fn parse_http_header(header: &str) -> Result<(String, String), Error> {
194    let header_components = header.splitn(2, ':');
195
196    let (key, value) = header_components
197        .map(str::trim)
198        .next_tuple()
199        .ok_or_else(|| Error::InvalidHeader)?;
200
201    HeaderName::from_str(key)?;
202    HeaderValue::from_str(value)?;
203
204    Ok((key.to_string(), value.to_string()))
205}
206
207/// Clap value_parser for `Network::rpc_headers` that always succeeds, deferring
208/// validation to application code so clap never echoes the raw value in error messages.
209#[allow(clippy::unnecessary_wraps)]
210fn accept_raw_rpc_header(header: &str) -> Result<(String, String), std::convert::Infallible> {
211    match header.split_once(':') {
212        Some((key, value)) => Ok((key.trim().to_string(), value.trim().to_string())),
213        None => Ok((String::new(), header.to_string())),
214    }
215}
216
217fn validate_rpc_headers(headers: &[(String, String)]) -> Result<(), Error> {
218    for (key, value) in headers {
219        HeaderName::from_str(key).map_err(|_| Error::InvalidHeader)?;
220        HeaderValue::from_str(value).map_err(|_| Error::InvalidHeader)?;
221    }
222    Ok(())
223}
224
225impl Network {
226    pub fn validate_headers(&self) -> Result<(), Error> {
227        validate_rpc_headers(&self.rpc_headers)
228    }
229
230    pub async fn helper_url(&self, addr: &str) -> Result<Url, Error> {
231        tracing::debug!("address {addr:?}");
232        let rpc_url = Url::from_str(&self.rpc_url)
233            .map_err(|_| Error::InvalidUrl(redact_url(&self.rpc_url)))?;
234        if self.network_passphrase.as_str() == passphrase::LOCAL {
235            let mut local_url = rpc_url;
236            local_url.set_path("/friendbot");
237            local_url.set_query(Some(&format!("addr={addr}")));
238            Ok(local_url)
239        } else {
240            let client = self.rpc_client()?;
241            let network = client.get_network().await?;
242            tracing::debug!(
243                "network passphrase={:?} protocol_version={} friendbot_url={:?}",
244                network.passphrase,
245                network.protocol_version,
246                network.friendbot_url.as_deref().map(redact_url),
247            );
248            let url = client.friendbot_url().await?;
249            tracing::debug!("URL {}", redact_url(&url));
250            let mut url = Url::from_str(&url).map_err(|e| {
251                tracing::error!("{e}");
252                Error::InvalidUrl(redact_url(&url))
253            })?;
254            url.query_pairs_mut().append_pair("addr", addr);
255            Ok(url)
256        }
257    }
258
259    #[allow(clippy::similar_names)]
260    pub async fn fund_address(&self, addr: &PublicKey) -> Result<(), Error> {
261        let uri = self.helper_url(&addr.to_string()).await?;
262        tracing::debug!("URL {}", redact_url(uri.as_str()));
263        let response = http::client().get(uri.as_str()).send().await?;
264
265        let request_successful = response.status().is_success();
266        let body = response.bytes().await?;
267        let res = serde_json::from_slice::<serde_json::Value>(&body)
268            .map_err(|e| Error::FailedToParseJSON(redact_url(uri.as_str()), e))?;
269        tracing::debug!("{res:#?}");
270        if !request_successful {
271            if let Some(detail) = res.get("detail").and_then(Value::as_str) {
272                if detail.contains("account already funded to starting balance") {
273                    // Don't error if friendbot indicated that the account is
274                    // already fully funded to the starting balance, because the
275                    // user's goal is to get funded, and the account is funded
276                    // so it is success much the same.
277                    tracing::debug!("already funded error ignored because account is funded");
278                } else {
279                    return Err(Error::FundingFailed(detail.to_string()));
280                }
281            } else {
282                return Err(Error::FundingFailed("unknown cause".to_string()));
283            }
284        }
285        Ok(())
286    }
287
288    pub fn rpc_uri(&self) -> Result<Url, Error> {
289        Url::from_str(&self.rpc_url).map_err(|_| Error::InvalidUrl(redact_url(&self.rpc_url)))
290    }
291
292    pub fn rpc_client(&self) -> Result<Client, Error> {
293        let mut header_hash_map = HashMap::new();
294        for (header_name, header_value) in &self.rpc_headers {
295            header_hash_map.insert(header_name.clone(), header_value.clone());
296        }
297
298        let header_map: HeaderMap = (&header_hash_map)
299            .try_into()
300            .map_err(|_| Error::InvalidHeader)?;
301
302        rpc::Client::new_with_headers(&self.rpc_url, header_map).map_err(|e| match e {
303            rpc::Error::InvalidRpcUrl(..) | rpc::Error::InvalidRpcUrlFromUriParts(..) => {
304                Error::InvalidUrl(redact_url(&self.rpc_url))
305            }
306            other => Error::Rpc(other),
307        })
308    }
309}
310
311/// Default network key to use when no network is specified
312pub const DEFAULT_NETWORK_KEY: &str = "testnet";
313
314pub static DEFAULTS: phf::Map<&'static str, (&'static str, &'static str)> = phf_map! {
315    "local" => (
316        "http://localhost:8000/rpc",
317        passphrase::LOCAL,
318    ),
319    "futurenet" => (
320        "https://rpc-futurenet.stellar.org:443",
321        passphrase::FUTURENET,
322    ),
323    "testnet" => (
324        "https://soroban-testnet.stellar.org",
325        passphrase::TESTNET,
326    ),
327    "mainnet" => (
328        "Bring Your Own: https://developers.stellar.org/docs/data/rpc/rpc-providers",
329        passphrase::MAINNET,
330    ),
331};
332
333impl From<&(&str, &str)> for Network {
334    /// Convert the return value of `DEFAULTS.get()` into a Network
335    fn from(n: &(&str, &str)) -> Self {
336        Self {
337            rpc_url: n.0.to_string(),
338            rpc_headers: Vec::new(),
339            network_passphrase: n.1.to_string(),
340        }
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use mockito::Server;
348    use serde_json::json;
349
350    const INVALID_HEADER_NAME: &str = "api key";
351    const INVALID_HEADER_VALUE: &str = "cannot include a carriage return \r in the value";
352
353    #[tokio::test]
354    async fn test_helper_url_local_network() {
355        let network = Network {
356            rpc_url: "http://localhost:8000".to_string(),
357            network_passphrase: passphrase::LOCAL.to_string(),
358            rpc_headers: Vec::new(),
359        };
360
361        let result = network
362            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
363            .await;
364
365        assert!(result.is_ok());
366        let url = result.unwrap();
367        assert_eq!(url.as_str(), "http://localhost:8000/friendbot?addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
368    }
369
370    #[tokio::test]
371    async fn test_helper_url_test_network() {
372        let mut server = Server::new_async().await;
373        let _mock = server
374            .mock("POST", "/")
375            .with_body_from_request(|req| {
376                let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap();
377                let id = body["id"].clone();
378                json!({
379                        "jsonrpc": "2.0",
380                        "id": id,
381                        "result": {
382                            "friendbotUrl": "https://friendbot.stellar.org/",
383                            "passphrase": passphrase::TESTNET.to_string(),
384                            "protocolVersion": 21
385                    }
386                })
387                .to_string()
388                .into()
389            })
390            .create_async()
391            .await;
392
393        let network = Network {
394            rpc_url: server.url(),
395            network_passphrase: passphrase::TESTNET.to_string(),
396            rpc_headers: Vec::new(),
397        };
398        let url = network
399            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
400            .await
401            .unwrap();
402        assert_eq!(url.as_str(), "https://friendbot.stellar.org/?addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
403    }
404
405    #[tokio::test]
406    async fn test_helper_url_test_network_with_path_and_params() {
407        let mut server = Server::new_async().await;
408        let _mock = server.mock("POST", "/")
409            .with_body_from_request(|req| {
410                let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap();
411                let id = body["id"].clone();
412                json!({
413                        "jsonrpc": "2.0",
414                        "id": id,
415                        "result": {
416                            "friendbotUrl": "https://friendbot.stellar.org/secret?api_key=123456&user=demo",
417                            "passphrase": passphrase::TESTNET.to_string(),
418                            "protocolVersion": 21
419                    }
420                }).to_string().into()
421            })
422            .create_async().await;
423
424        let network = Network {
425            rpc_url: server.url(),
426            network_passphrase: passphrase::TESTNET.to_string(),
427            rpc_headers: Vec::new(),
428        };
429        let url = network
430            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
431            .await
432            .unwrap();
433        assert_eq!(url.as_str(), "https://friendbot.stellar.org/secret?api_key=123456&user=demo&addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
434    }
435
436    // testing parse_header function
437    #[tokio::test]
438    async fn test_parse_http_header_ok() {
439        let result = parse_http_header("Authorization: Bearer 1234");
440        assert!(result.is_ok());
441    }
442
443    #[tokio::test]
444    async fn test_parse_http_header_error_with_invalid_name() {
445        let invalid_header = format!("{INVALID_HEADER_NAME}: Bearer 1234");
446        let result = parse_http_header(&invalid_header);
447        assert!(result.is_err());
448        assert_eq!(
449            result.unwrap_err().to_string(),
450            format!("invalid HTTP header name")
451        );
452    }
453
454    #[tokio::test]
455    async fn test_parse_http_header_error_with_invalid_value() {
456        let invalid_header = format!("Authorization: {INVALID_HEADER_VALUE}");
457        let result = parse_http_header(&invalid_header);
458        assert!(result.is_err());
459        assert_eq!(
460            result.unwrap_err().to_string(),
461            format!("failed to parse header value")
462        );
463    }
464
465    // testing rpc_client function - we're testing this and the parse_http_header function separately because when a user has their network already configured in a toml file, the parse_http_header function is not called and we want to make sure that if the toml file is correctly formatted, the rpc_client function will work as expected
466
467    #[tokio::test]
468    async fn test_rpc_client_is_ok_when_there_are_no_headers() {
469        let network = Network {
470            rpc_url: "http://localhost:1234".to_string(),
471            network_passphrase: "Network passphrase".to_string(),
472            rpc_headers: [].to_vec(),
473        };
474
475        let result = network.rpc_client();
476        assert!(result.is_ok());
477    }
478
479    #[tokio::test]
480    async fn test_rpc_client_is_ok_with_correctly_formatted_headers() {
481        let network = Network {
482            rpc_url: "http://localhost:1234".to_string(),
483            network_passphrase: "Network passphrase".to_string(),
484            rpc_headers: [("Authorization".to_string(), "Bearer 1234".to_string())].to_vec(),
485        };
486
487        let result = network.rpc_client();
488        assert!(result.is_ok());
489    }
490
491    #[tokio::test]
492    async fn test_rpc_client_is_ok_with_multiple_headers() {
493        let network = Network {
494            rpc_url: "http://localhost:1234".to_string(),
495            network_passphrase: "Network passphrase".to_string(),
496            rpc_headers: [
497                ("Authorization".to_string(), "Bearer 1234".to_string()),
498                ("api-key".to_string(), "5678".to_string()),
499            ]
500            .to_vec(),
501        };
502
503        let result = network.rpc_client();
504        assert!(result.is_ok());
505    }
506
507    #[tokio::test]
508    async fn test_rpc_client_returns_err_with_invalid_header_name() {
509        let network = Network {
510            rpc_url: "http://localhost:8000".to_string(),
511            network_passphrase: passphrase::LOCAL.to_string(),
512            rpc_headers: [(INVALID_HEADER_NAME.to_string(), "Bearer".to_string())].to_vec(),
513        };
514
515        let result = network.rpc_client();
516        assert!(result.is_err());
517        assert_eq!(
518            result.unwrap_err().to_string(),
519            format!("invalid HTTP header: must be in the form 'key:value'")
520        );
521    }
522
523    #[tokio::test]
524    async fn test_rpc_client_returns_err_with_invalid_header_value() {
525        let network = Network {
526            rpc_url: "http://localhost:8000".to_string(),
527            network_passphrase: passphrase::LOCAL.to_string(),
528            rpc_headers: [("api-key".to_string(), INVALID_HEADER_VALUE.to_string())].to_vec(),
529        };
530
531        let result = network.rpc_client();
532        assert!(result.is_err());
533        assert_eq!(
534            result.unwrap_err().to_string(),
535            format!("invalid HTTP header: must be in the form 'key:value'")
536        );
537    }
538
539    #[tokio::test]
540    async fn test_rpc_client_returns_err_with_bad_rpc_url() {
541        let network = Network {
542            rpc_url: "Bring Your Own: http://localhost:8000".to_string(),
543            network_passphrase: passphrase::LOCAL.to_string(),
544            rpc_headers: [].to_vec(),
545        };
546
547        let result = network.rpc_client();
548        assert!(result.is_err());
549        assert_eq!(
550            result.unwrap_err().to_string(),
551            format!("Invalid URL Bring Your Own: http://localhost:8000")
552        );
553    }
554
555    #[tokio::test]
556    async fn test_default_to_testnet_when_no_network_specified() {
557        use super::super::locator;
558
559        let args = Args::default(); // No network, rpc_url, or network_passphrase specified
560        let locator_args = locator::Args::default();
561
562        let result = args.get(&locator_args);
563        assert!(result.is_ok());
564
565        let network = result.unwrap();
566        assert_eq!(network.network_passphrase, passphrase::TESTNET);
567        assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org");
568    }
569
570    #[test]
571    fn test_resolve_no_rpc_accepts_passphrase_only() {
572        use super::super::locator;
573
574        let args = Args {
575            rpc_url: None,
576            rpc_headers: Vec::new(),
577            network_passphrase: Some("specified manually".to_string()),
578            network: None,
579        };
580
581        let network = args
582            .resolve(&locator::Args::default(), false)
583            .expect("passphrase-only network should resolve for signing-only commands");
584        assert_eq!(network.network_passphrase, "specified manually");
585        assert_eq!(network.rpc_url, "");
586        assert!(network.rpc_headers.is_empty());
587    }
588
589    #[test]
590    fn test_resolve_no_rpc_still_requires_passphrase_when_rpc_given() {
591        use super::super::locator;
592
593        let args = Args {
594            rpc_url: Some("https://example.com".to_string()),
595            rpc_headers: Vec::new(),
596            network_passphrase: None,
597            network: None,
598        };
599
600        let err = args.resolve(&locator::Args::default(), false).expect_err(
601            "rpc without passphrase should still error, even for signing-only commands",
602        );
603        assert!(matches!(err, Error::MissingNetworkPassphrase));
604    }
605
606    #[test]
607    fn test_resolve_no_rpc_preserves_rpc_url_when_both_given() {
608        use super::super::locator;
609
610        let args = Args {
611            rpc_url: Some("https://example.com".to_string()),
612            rpc_headers: Vec::new(),
613            network_passphrase: Some("specified manually".to_string()),
614            network: None,
615        };
616
617        let network = args.resolve(&locator::Args::default(), false).unwrap();
618        assert_eq!(network.rpc_url, "https://example.com");
619        assert_eq!(network.network_passphrase, "specified manually");
620    }
621
622    #[test]
623    fn test_get_strict_still_requires_rpc_url_with_passphrase_only() {
624        use super::super::locator;
625
626        let args = Args {
627            rpc_url: None,
628            rpc_headers: Vec::new(),
629            network_passphrase: Some("specified manually".to_string()),
630            network: None,
631        };
632
633        // The strict resolver used by RPC commands must keep rejecting a
634        // passphrase-only invocation.
635        let err = args
636            .get(&locator::Args::default())
637            .expect_err("strict get() must still require an rpc-url with passphrase-only args");
638        assert!(matches!(err, Error::MissingRpcUrl));
639    }
640
641    #[tokio::test]
642    async fn test_user_config_default_overrides_automatic_testnet() {
643        use super::super::locator;
644        use std::env;
645
646        // Override environment variables to prevent reading real user config
647        let original_home = env::var("HOME").ok();
648        let original_stellar_config_home = env::var("STELLAR_CONFIG_HOME").ok();
649
650        // Set to a non-existent directory to ensure Config::new() fails and we test the fallback
651        env::set_var("HOME", "/dev/null");
652        env::set_var("STELLAR_CONFIG_HOME", "/dev/null");
653
654        let args = Args::default(); // No network, rpc_url, or network_passphrase specified
655        let locator_args = locator::Args::default();
656
657        let result = args.get(&locator_args);
658        assert!(result.is_ok());
659
660        let network = result.unwrap();
661        // Should still default to testnet when config reading fails
662        assert_eq!(network.network_passphrase, passphrase::TESTNET);
663        assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org");
664
665        // Restore original environment variables
666        if let Some(home) = original_home {
667            env::set_var("HOME", home);
668        } else {
669            env::remove_var("HOME");
670        }
671        if let Some(config_home) = original_stellar_config_home {
672            env::set_var("STELLAR_CONFIG_HOME", config_home);
673        } else {
674            env::remove_var("STELLAR_CONFIG_HOME");
675        }
676    }
677
678    #[test]
679    fn test_malformed_rpc_header_accepted_by_clap_without_error() {
680        use crate::test_utils::with_env_guard;
681        use clap::Parser;
682
683        #[derive(clap::Parser)]
684        struct TestCmd {
685            #[command(flatten)]
686            args: Args,
687        }
688
689        let secret = "Authorization Bearer secret_poc_token_12345";
690        with_env_guard(&["STELLAR_RPC_HEADERS"], || {
691            std::env::set_var("STELLAR_RPC_HEADERS", secret);
692            let result = TestCmd::try_parse_from(["stellar"]);
693            assert!(
694                result.is_ok(),
695                "Clap must accept malformed RPC headers without error — validation is deferred to application code to prevent secrets from being echoed in clap error messages"
696            );
697        });
698    }
699
700    #[test]
701    fn test_validate_headers_rejects_missing_colon_without_exposing_value() {
702        // Simulates what accept_raw_rpc_header stores when no ':' is present.
703        let network = Network {
704            rpc_url: "http://localhost:8000".to_string(),
705            network_passphrase: "Test".to_string(),
706            rpc_headers: vec![(
707                String::new(),
708                "Authorization Bearer secret_token_xyz".to_string(),
709            )],
710        };
711
712        let result = network.validate_headers();
713        assert!(result.is_err());
714        let error_msg = result.unwrap_err().to_string();
715        assert_eq!(
716            error_msg,
717            "invalid HTTP header: must be in the form 'key:value'"
718        );
719        assert!(
720            !error_msg.contains("secret_token_xyz"),
721            "Error must not expose the raw header value, got: {error_msg}"
722        );
723    }
724
725    #[test]
726    fn test_malformed_rpc_header_app_error_does_not_expose_value() {
727        use super::super::locator;
728
729        let secret = "Authorization Bearer secret_poc_token_12345";
730        let args = Args {
731            rpc_url: Some("https://example.com".to_string()),
732            rpc_headers: vec![secret.to_string()],
733            network_passphrase: Some("Test SDF Network ; September 2015".to_string()),
734            network: None,
735        };
736
737        let result = args.get(&locator::Args::default());
738        assert!(result.is_err());
739        let error_msg = result.unwrap_err().to_string();
740        assert!(
741            !error_msg.contains("secret_poc_token_12345"),
742            "Application error must not expose secret header value, got: {error_msg}"
743        );
744    }
745
746    #[test]
747    fn test_debug_conceals_rpc_header_values() {
748        let network = Network {
749            rpc_url: "http://localhost:8000/rpc".to_string(),
750            network_passphrase: "Test Network".to_string(),
751            rpc_headers: vec![
752                ("Authorization".to_string(), "Bearer secret123".to_string()),
753                ("X-Api-Key".to_string(), "mykey".to_string()),
754            ],
755        };
756        assert_eq!(
757            format!("{network:?}"),
758            r#"Network { rpc_url: "http://localhost:8000/rpc", rpc_headers: [("Authorization", "<concealed>"), ("X-Api-Key", "<concealed>")], network_passphrase: "Test Network" }"#
759        );
760    }
761
762    #[test]
763    fn test_debug_conceals_rpc_url_password() {
764        let network = Network {
765            rpc_url: "https://alice:supersecret@rpc.example.com/soroban".to_string(),
766            network_passphrase: "Test Network".to_string(),
767            rpc_headers: Vec::new(),
768        };
769        let rendered = format!("{network:?}");
770        assert!(
771            !rendered.contains("supersecret"),
772            "password leaked into Debug output: {rendered}"
773        );
774        assert!(
775            rendered.contains("alice:redacted"),
776            "expected `alice:redacted` in Debug output: {rendered}"
777        );
778    }
779
780    #[tokio::test]
781    async fn fund_address_failed_to_parse_json_does_not_leak_credentialed_rpc_url() {
782        let mut server = Server::new_async().await;
783        // Friendbot returns a non-JSON body so serde_json::from_slice fails,
784        // triggering Error::FailedToParseJSON at the line we want to verify.
785        let _mock = server
786            .mock("GET", mockito::Matcher::Any)
787            .with_status(200)
788            .with_body("not valid json")
789            .create_async()
790            .await;
791
792        let host_port = server
793            .url()
794            .strip_prefix("http://")
795            .expect("mockito url starts with http://")
796            .to_string();
797        let credentialed_rpc_url = format!("http://alice:supersecret@{host_port}");
798
799        let network = Network {
800            rpc_url: credentialed_rpc_url,
801            network_passphrase: passphrase::LOCAL.to_string(),
802            rpc_headers: Vec::new(),
803        };
804
805        let addr =
806            PublicKey::from_string("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
807                .unwrap();
808        let err = network
809            .fund_address(&addr)
810            .await
811            .expect_err("fund_address must return Err when friendbot replies with non-JSON body");
812        let rendered = err.to_string();
813        assert!(
814            !rendered.contains("supersecret"),
815            "password leaked into error display: {rendered}"
816        );
817        assert!(
818            rendered.contains("alice:redacted"),
819            "expected `alice:redacted` placeholder in error display: {rendered}"
820        );
821    }
822
823    #[tokio::test]
824    async fn helper_url_returned_credentialed_url_is_redactable_at_display_sinks() {
825        // Non-LOCAL passphrase branch: helper_url asks the RPC for the friendbot URL.
826        // The mocked RPC returns a parseable URL carrying userinfo, so Url::from_str
827        // succeeds and helper_url returns Ok(url). The InvalidUrl branch is therefore
828        // not exercised here — driving it would require an unparseable URL, which by
829        // design leaks unchanged (see PR discussion). This test only documents that
830        // the parseable URL returned from helper_url can be safely run through
831        // redact_url at any subsequent display sink.
832        let mut server = Server::new_async().await;
833        let _mock = server
834            .mock("POST", "/")
835            .with_body_from_request(|req| {
836                let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap();
837                let id = body["id"].clone();
838                // Returned friendbot URL has userinfo + is parseable by url::Url.
839                // Url::from_str inside helper_url accepts it, so the InvalidUrl
840                // path at line 239 isn't exercised. Instead the URL flows into
841                // the tracing line and (after fund_address) into FailedToParseJSON.
842                json!({
843                    "jsonrpc": "2.0",
844                    "id": id,
845                    "result": {
846                        "friendbotUrl": "https://alice:supersecret@friendbot.example/",
847                        "passphrase": passphrase::TESTNET.to_string(),
848                        "protocolVersion": 21,
849                    }
850                })
851                .to_string()
852                .into()
853            })
854            .create_async()
855            .await;
856
857        let network = Network {
858            rpc_url: server.url(),
859            network_passphrase: passphrase::TESTNET.to_string(),
860            rpc_headers: Vec::new(),
861        };
862        let returned = network
863            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
864            .await
865            .expect("helper_url should accept a parseable credentialed friendbot URL");
866        // The Url returned still carries the password — callers need it to authenticate.
867        assert_eq!(returned.password(), Some("supersecret"));
868        let redacted_for_display = redact_url(returned.as_str());
869        assert!(
870            !redacted_for_display.contains("supersecret"),
871            "redact_url failed to redact a parseable friendbot URL: {redacted_for_display}"
872        );
873    }
874}