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 #[arg(
66 long = "rpc-url",
67 env = "STELLAR_RPC_URL",
68 help_heading = HEADING_RPC,
69 )]
70 pub rpc_url: Option<String>,
71 #[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 #[arg(
84 long = "network-passphrase",
85 env = "STELLAR_NETWORK_PASSPHRASE",
86 help_heading = HEADING_RPC,
87 )]
88 pub network_passphrase: Option<String>,
89 #[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 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 Ok(DEFAULTS.get(DEFAULT_NETWORK_KEY).unwrap().into())
120 }
121 (_, Some(_), None) => Err(Error::MissingNetworkPassphrase),
122 (_, 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 #[arg(
152 long = "rpc-url",
153 env = "STELLAR_RPC_URL",
154 help_heading = HEADING_RPC,
155 )]
156 pub rpc_url: String,
157 #[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 #[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#[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 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
311pub 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 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 #[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 #[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(); 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 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 let original_home = env::var("HOME").ok();
648 let original_stellar_config_home = env::var("STELLAR_CONFIG_HOME").ok();
649
650 env::set_var("HOME", "/dev/null");
652 env::set_var("STELLAR_CONFIG_HOME", "/dev/null");
653
654 let args = Args::default(); 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 assert_eq!(network.network_passphrase, passphrase::TESTNET);
663 assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org");
664
665 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 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 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 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 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 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}