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!(result.unwrap_err().to_string(), "invalid HTTP header name");
449    }
450
451    #[tokio::test]
452    async fn test_parse_http_header_error_with_invalid_value() {
453        let invalid_header = format!("Authorization: {INVALID_HEADER_VALUE}");
454        let result = parse_http_header(&invalid_header);
455        assert!(result.is_err());
456        assert_eq!(
457            result.unwrap_err().to_string(),
458            "failed to parse header value"
459        );
460    }
461
462    // 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
463
464    #[tokio::test]
465    async fn test_rpc_client_is_ok_when_there_are_no_headers() {
466        let network = Network {
467            rpc_url: "http://localhost:1234".to_string(),
468            network_passphrase: "Network passphrase".to_string(),
469            rpc_headers: [].to_vec(),
470        };
471
472        let result = network.rpc_client();
473        assert!(result.is_ok());
474    }
475
476    #[tokio::test]
477    async fn test_rpc_client_is_ok_with_correctly_formatted_headers() {
478        let network = Network {
479            rpc_url: "http://localhost:1234".to_string(),
480            network_passphrase: "Network passphrase".to_string(),
481            rpc_headers: [("Authorization".to_string(), "Bearer 1234".to_string())].to_vec(),
482        };
483
484        let result = network.rpc_client();
485        assert!(result.is_ok());
486    }
487
488    #[tokio::test]
489    async fn test_rpc_client_is_ok_with_multiple_headers() {
490        let network = Network {
491            rpc_url: "http://localhost:1234".to_string(),
492            network_passphrase: "Network passphrase".to_string(),
493            rpc_headers: [
494                ("Authorization".to_string(), "Bearer 1234".to_string()),
495                ("api-key".to_string(), "5678".to_string()),
496            ]
497            .to_vec(),
498        };
499
500        let result = network.rpc_client();
501        assert!(result.is_ok());
502    }
503
504    #[tokio::test]
505    async fn test_rpc_client_returns_err_with_invalid_header_name() {
506        let network = Network {
507            rpc_url: "http://localhost:8000".to_string(),
508            network_passphrase: passphrase::LOCAL.to_string(),
509            rpc_headers: [(INVALID_HEADER_NAME.to_string(), "Bearer".to_string())].to_vec(),
510        };
511
512        let result = network.rpc_client();
513        assert!(result.is_err());
514        assert_eq!(
515            result.unwrap_err().to_string(),
516            "invalid HTTP header: must be in the form 'key:value'"
517        );
518    }
519
520    #[tokio::test]
521    async fn test_rpc_client_returns_err_with_invalid_header_value() {
522        let network = Network {
523            rpc_url: "http://localhost:8000".to_string(),
524            network_passphrase: passphrase::LOCAL.to_string(),
525            rpc_headers: [("api-key".to_string(), INVALID_HEADER_VALUE.to_string())].to_vec(),
526        };
527
528        let result = network.rpc_client();
529        assert!(result.is_err());
530        assert_eq!(
531            result.unwrap_err().to_string(),
532            "invalid HTTP header: must be in the form 'key:value'"
533        );
534    }
535
536    #[tokio::test]
537    async fn test_rpc_client_returns_err_with_bad_rpc_url() {
538        let network = Network {
539            rpc_url: "Bring Your Own: http://localhost:8000".to_string(),
540            network_passphrase: passphrase::LOCAL.to_string(),
541            rpc_headers: [].to_vec(),
542        };
543
544        let result = network.rpc_client();
545        assert!(result.is_err());
546        assert_eq!(
547            result.unwrap_err().to_string(),
548            "Invalid URL Bring Your Own: http://localhost:8000"
549        );
550    }
551
552    #[tokio::test]
553    async fn test_default_to_testnet_when_no_network_specified() {
554        use super::super::locator;
555
556        let args = Args::default(); // No network, rpc_url, or network_passphrase specified
557        let locator_args = locator::Args::default();
558
559        let result = args.get(&locator_args);
560        assert!(result.is_ok());
561
562        let network = result.unwrap();
563        assert_eq!(network.network_passphrase, passphrase::TESTNET);
564        assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org");
565    }
566
567    #[test]
568    fn test_resolve_no_rpc_accepts_passphrase_only() {
569        use super::super::locator;
570
571        let args = Args {
572            rpc_url: None,
573            rpc_headers: Vec::new(),
574            network_passphrase: Some("specified manually".to_string()),
575            network: None,
576        };
577
578        let network = args
579            .resolve(&locator::Args::default(), false)
580            .expect("passphrase-only network should resolve for signing-only commands");
581        assert_eq!(network.network_passphrase, "specified manually");
582        assert_eq!(network.rpc_url, "");
583        assert!(network.rpc_headers.is_empty());
584    }
585
586    #[test]
587    fn test_resolve_no_rpc_still_requires_passphrase_when_rpc_given() {
588        use super::super::locator;
589
590        let args = Args {
591            rpc_url: Some("https://example.com".to_string()),
592            rpc_headers: Vec::new(),
593            network_passphrase: None,
594            network: None,
595        };
596
597        let err = args.resolve(&locator::Args::default(), false).expect_err(
598            "rpc without passphrase should still error, even for signing-only commands",
599        );
600        assert!(matches!(err, Error::MissingNetworkPassphrase));
601    }
602
603    #[test]
604    fn test_resolve_no_rpc_preserves_rpc_url_when_both_given() {
605        use super::super::locator;
606
607        let args = Args {
608            rpc_url: Some("https://example.com".to_string()),
609            rpc_headers: Vec::new(),
610            network_passphrase: Some("specified manually".to_string()),
611            network: None,
612        };
613
614        let network = args.resolve(&locator::Args::default(), false).unwrap();
615        assert_eq!(network.rpc_url, "https://example.com");
616        assert_eq!(network.network_passphrase, "specified manually");
617    }
618
619    #[test]
620    fn test_get_strict_still_requires_rpc_url_with_passphrase_only() {
621        use super::super::locator;
622
623        let args = Args {
624            rpc_url: None,
625            rpc_headers: Vec::new(),
626            network_passphrase: Some("specified manually".to_string()),
627            network: None,
628        };
629
630        // The strict resolver used by RPC commands must keep rejecting a
631        // passphrase-only invocation.
632        let err = args
633            .get(&locator::Args::default())
634            .expect_err("strict get() must still require an rpc-url with passphrase-only args");
635        assert!(matches!(err, Error::MissingRpcUrl));
636    }
637
638    #[tokio::test]
639    async fn test_user_config_default_overrides_automatic_testnet() {
640        use super::super::locator;
641        use std::env;
642
643        // Override environment variables to prevent reading real user config
644        let original_home = env::var("HOME").ok();
645        let original_stellar_config_home = env::var("STELLAR_CONFIG_HOME").ok();
646
647        // Set to a non-existent directory to ensure Config::new() fails and we test the fallback
648        env::set_var("HOME", "/dev/null");
649        env::set_var("STELLAR_CONFIG_HOME", "/dev/null");
650
651        let args = Args::default(); // No network, rpc_url, or network_passphrase specified
652        let locator_args = locator::Args::default();
653
654        let result = args.get(&locator_args);
655        assert!(result.is_ok());
656
657        let network = result.unwrap();
658        // Should still default to testnet when config reading fails
659        assert_eq!(network.network_passphrase, passphrase::TESTNET);
660        assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org");
661
662        // Restore original environment variables
663        if let Some(home) = original_home {
664            env::set_var("HOME", home);
665        } else {
666            env::remove_var("HOME");
667        }
668        if let Some(config_home) = original_stellar_config_home {
669            env::set_var("STELLAR_CONFIG_HOME", config_home);
670        } else {
671            env::remove_var("STELLAR_CONFIG_HOME");
672        }
673    }
674
675    #[test]
676    fn test_malformed_rpc_header_accepted_by_clap_without_error() {
677        use crate::test_utils::with_env_guard;
678        use clap::Parser;
679
680        #[derive(clap::Parser)]
681        struct TestCmd {
682            #[command(flatten)]
683            args: Args,
684        }
685
686        let secret = "Authorization Bearer secret_poc_token_12345";
687        with_env_guard(&["STELLAR_RPC_HEADERS"], || {
688            std::env::set_var("STELLAR_RPC_HEADERS", secret);
689            let result = TestCmd::try_parse_from(["stellar"]);
690            assert!(
691                result.is_ok(),
692                "Clap must accept malformed RPC headers without error — validation is deferred to application code to prevent secrets from being echoed in clap error messages"
693            );
694        });
695    }
696
697    #[test]
698    fn test_validate_headers_rejects_missing_colon_without_exposing_value() {
699        // Simulates what accept_raw_rpc_header stores when no ':' is present.
700        let network = Network {
701            rpc_url: "http://localhost:8000".to_string(),
702            network_passphrase: "Test".to_string(),
703            rpc_headers: vec![(
704                String::new(),
705                "Authorization Bearer secret_token_xyz".to_string(),
706            )],
707        };
708
709        let result = network.validate_headers();
710        assert!(result.is_err());
711        let error_msg = result.unwrap_err().to_string();
712        assert_eq!(
713            error_msg,
714            "invalid HTTP header: must be in the form 'key:value'"
715        );
716        assert!(
717            !error_msg.contains("secret_token_xyz"),
718            "Error must not expose the raw header value, got: {error_msg}"
719        );
720    }
721
722    #[test]
723    fn test_malformed_rpc_header_app_error_does_not_expose_value() {
724        use super::super::locator;
725
726        let secret = "Authorization Bearer secret_poc_token_12345";
727        let args = Args {
728            rpc_url: Some("https://example.com".to_string()),
729            rpc_headers: vec![secret.to_string()],
730            network_passphrase: Some("Test SDF Network ; September 2015".to_string()),
731            network: None,
732        };
733
734        let result = args.get(&locator::Args::default());
735        assert!(result.is_err());
736        let error_msg = result.unwrap_err().to_string();
737        assert!(
738            !error_msg.contains("secret_poc_token_12345"),
739            "Application error must not expose secret header value, got: {error_msg}"
740        );
741    }
742
743    #[test]
744    fn test_debug_conceals_rpc_header_values() {
745        let network = Network {
746            rpc_url: "http://localhost:8000/rpc".to_string(),
747            network_passphrase: "Test Network".to_string(),
748            rpc_headers: vec![
749                ("Authorization".to_string(), "Bearer secret123".to_string()),
750                ("X-Api-Key".to_string(), "mykey".to_string()),
751            ],
752        };
753        assert_eq!(
754            format!("{network:?}"),
755            r#"Network { rpc_url: "http://localhost:8000/rpc", rpc_headers: [("Authorization", "<concealed>"), ("X-Api-Key", "<concealed>")], network_passphrase: "Test Network" }"#
756        );
757    }
758
759    #[test]
760    fn test_debug_conceals_rpc_url_password() {
761        let network = Network {
762            rpc_url: "https://alice:supersecret@rpc.example.com/soroban".to_string(),
763            network_passphrase: "Test Network".to_string(),
764            rpc_headers: Vec::new(),
765        };
766        let rendered = format!("{network:?}");
767        assert!(
768            !rendered.contains("supersecret"),
769            "password leaked into Debug output: {rendered}"
770        );
771        assert!(
772            rendered.contains("alice:redacted"),
773            "expected `alice:redacted` in Debug output: {rendered}"
774        );
775    }
776
777    #[tokio::test]
778    async fn fund_address_failed_to_parse_json_does_not_leak_credentialed_rpc_url() {
779        let mut server = Server::new_async().await;
780        // Friendbot returns a non-JSON body so serde_json::from_slice fails,
781        // triggering Error::FailedToParseJSON at the line we want to verify.
782        let _mock = server
783            .mock("GET", mockito::Matcher::Any)
784            .with_status(200)
785            .with_body("not valid json")
786            .create_async()
787            .await;
788
789        let host_port = server
790            .url()
791            .strip_prefix("http://")
792            .expect("mockito url starts with http://")
793            .to_string();
794        let credentialed_rpc_url = format!("http://alice:supersecret@{host_port}");
795
796        let network = Network {
797            rpc_url: credentialed_rpc_url,
798            network_passphrase: passphrase::LOCAL.to_string(),
799            rpc_headers: Vec::new(),
800        };
801
802        let addr =
803            PublicKey::from_string("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
804                .unwrap();
805        let err = network
806            .fund_address(&addr)
807            .await
808            .expect_err("fund_address must return Err when friendbot replies with non-JSON body");
809        let rendered = err.to_string();
810        assert!(
811            !rendered.contains("supersecret"),
812            "password leaked into error display: {rendered}"
813        );
814        assert!(
815            rendered.contains("alice:redacted"),
816            "expected `alice:redacted` placeholder in error display: {rendered}"
817        );
818    }
819
820    #[tokio::test]
821    async fn helper_url_returned_credentialed_url_is_redactable_at_display_sinks() {
822        // Non-LOCAL passphrase branch: helper_url asks the RPC for the friendbot URL.
823        // The mocked RPC returns a parseable URL carrying userinfo, so Url::from_str
824        // succeeds and helper_url returns Ok(url). The InvalidUrl branch is therefore
825        // not exercised here — driving it would require an unparseable URL, which by
826        // design leaks unchanged (see PR discussion). This test only documents that
827        // the parseable URL returned from helper_url can be safely run through
828        // redact_url at any subsequent display sink.
829        let mut server = Server::new_async().await;
830        let _mock = server
831            .mock("POST", "/")
832            .with_body_from_request(|req| {
833                let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap();
834                let id = body["id"].clone();
835                // Returned friendbot URL has userinfo + is parseable by url::Url.
836                // Url::from_str inside helper_url accepts it, so the InvalidUrl
837                // path at line 239 isn't exercised. Instead the URL flows into
838                // the tracing line and (after fund_address) into FailedToParseJSON.
839                json!({
840                    "jsonrpc": "2.0",
841                    "id": id,
842                    "result": {
843                        "friendbotUrl": "https://alice:supersecret@friendbot.example/",
844                        "passphrase": passphrase::TESTNET.to_string(),
845                        "protocolVersion": 21,
846                    }
847                })
848                .to_string()
849                .into()
850            })
851            .create_async()
852            .await;
853
854        let network = Network {
855            rpc_url: server.url(),
856            network_passphrase: passphrase::TESTNET.to_string(),
857            rpc_headers: Vec::new(),
858        };
859        let returned = network
860            .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
861            .await
862            .expect("helper_url should accept a parseable credentialed friendbot URL");
863        // The Url returned still carries the password — callers need it to authenticate.
864        assert_eq!(returned.password(), Some("supersecret"));
865        let redacted_for_display = redact_url(returned.as_str());
866        assert!(
867            !redacted_for_display.contains("supersecret"),
868            "redact_url failed to redact a parseable friendbot URL: {redacted_for_display}"
869        );
870    }
871}