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 match (
102 self.network.as_deref(),
103 self.rpc_url.clone(),
104 self.network_passphrase.clone(),
105 ) {
106 (None, None, None) => {
107 Ok(DEFAULTS.get(DEFAULT_NETWORK_KEY).unwrap().into())
109 }
110 (_, Some(_), None) => Err(Error::MissingNetworkPassphrase),
111 (_, None, Some(_)) => Err(Error::MissingRpcUrl),
112 (Some(network), None, None) => Ok(locator.read_network(network)?),
113 (_, Some(rpc_url), Some(network_passphrase)) => {
114 let rpc_headers = self
115 .rpc_headers
116 .iter()
117 .map(|h| parse_http_header(h))
118 .collect::<Result<Vec<_>, _>>()?;
119 Ok(Network {
120 rpc_url,
121 rpc_headers,
122 network_passphrase,
123 })
124 }
125 }
126 }
127}
128
129#[derive(clap::Args, Serialize, Deserialize, Clone)]
130#[group(skip)]
131pub struct Network {
132 #[arg(
134 long = "rpc-url",
135 env = "STELLAR_RPC_URL",
136 help_heading = HEADING_RPC,
137 )]
138 pub rpc_url: String,
139 #[arg(
141 long = "rpc-header",
142 env = "STELLAR_RPC_HEADERS",
143 help_heading = HEADING_RPC,
144 num_args = 1,
145 action = clap::ArgAction::Append,
146 value_delimiter = '\n',
147 value_parser = accept_raw_rpc_header,
148 hide_env_values = true,
149 )]
150 pub rpc_headers: Vec<(String, String)>,
151 #[arg(
153 long,
154 env = "STELLAR_NETWORK_PASSPHRASE",
155 help_heading = HEADING_RPC,
156 )]
157 pub network_passphrase: String,
158}
159
160impl std::fmt::Debug for Network {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 let concealed: Vec<(&str, &str)> = self
163 .rpc_headers
164 .iter()
165 .map(|(k, _)| (k.as_str(), "<concealed>"))
166 .collect();
167 f.debug_struct("Network")
168 .field("rpc_url", &redact_url(&self.rpc_url))
169 .field("rpc_headers", &concealed)
170 .field("network_passphrase", &self.network_passphrase)
171 .finish()
172 }
173}
174
175fn parse_http_header(header: &str) -> Result<(String, String), Error> {
176 let header_components = header.splitn(2, ':');
177
178 let (key, value) = header_components
179 .map(str::trim)
180 .next_tuple()
181 .ok_or_else(|| Error::InvalidHeader)?;
182
183 HeaderName::from_str(key)?;
184 HeaderValue::from_str(value)?;
185
186 Ok((key.to_string(), value.to_string()))
187}
188
189#[allow(clippy::unnecessary_wraps)]
192fn accept_raw_rpc_header(header: &str) -> Result<(String, String), std::convert::Infallible> {
193 match header.split_once(':') {
194 Some((key, value)) => Ok((key.trim().to_string(), value.trim().to_string())),
195 None => Ok((String::new(), header.to_string())),
196 }
197}
198
199fn validate_rpc_headers(headers: &[(String, String)]) -> Result<(), Error> {
200 for (key, value) in headers {
201 HeaderName::from_str(key).map_err(|_| Error::InvalidHeader)?;
202 HeaderValue::from_str(value).map_err(|_| Error::InvalidHeader)?;
203 }
204 Ok(())
205}
206
207impl Network {
208 pub fn validate_headers(&self) -> Result<(), Error> {
209 validate_rpc_headers(&self.rpc_headers)
210 }
211
212 pub async fn helper_url(&self, addr: &str) -> Result<Url, Error> {
213 tracing::debug!("address {addr:?}");
214 let rpc_url = Url::from_str(&self.rpc_url)
215 .map_err(|_| Error::InvalidUrl(redact_url(&self.rpc_url)))?;
216 if self.network_passphrase.as_str() == passphrase::LOCAL {
217 let mut local_url = rpc_url;
218 local_url.set_path("/friendbot");
219 local_url.set_query(Some(&format!("addr={addr}")));
220 Ok(local_url)
221 } else {
222 let client = self.rpc_client()?;
223 let network = client.get_network().await?;
224 tracing::debug!(
225 "network passphrase={:?} protocol_version={} friendbot_url={:?}",
226 network.passphrase,
227 network.protocol_version,
228 network.friendbot_url.as_deref().map(redact_url),
229 );
230 let url = client.friendbot_url().await?;
231 tracing::debug!("URL {}", redact_url(&url));
232 let mut url = Url::from_str(&url).map_err(|e| {
233 tracing::error!("{e}");
234 Error::InvalidUrl(redact_url(&url))
235 })?;
236 url.query_pairs_mut().append_pair("addr", addr);
237 Ok(url)
238 }
239 }
240
241 #[allow(clippy::similar_names)]
242 pub async fn fund_address(&self, addr: &PublicKey) -> Result<(), Error> {
243 let uri = self.helper_url(&addr.to_string()).await?;
244 tracing::debug!("URL {}", redact_url(uri.as_str()));
245 let response = http::client().get(uri.as_str()).send().await?;
246
247 let request_successful = response.status().is_success();
248 let body = response.bytes().await?;
249 let res = serde_json::from_slice::<serde_json::Value>(&body)
250 .map_err(|e| Error::FailedToParseJSON(redact_url(uri.as_str()), e))?;
251 tracing::debug!("{res:#?}");
252 if !request_successful {
253 if let Some(detail) = res.get("detail").and_then(Value::as_str) {
254 if detail.contains("account already funded to starting balance") {
255 tracing::debug!("already funded error ignored because account is funded");
260 } else {
261 return Err(Error::FundingFailed(detail.to_string()));
262 }
263 } else {
264 return Err(Error::FundingFailed("unknown cause".to_string()));
265 }
266 }
267 Ok(())
268 }
269
270 pub fn rpc_uri(&self) -> Result<Url, Error> {
271 Url::from_str(&self.rpc_url).map_err(|_| Error::InvalidUrl(redact_url(&self.rpc_url)))
272 }
273
274 pub fn rpc_client(&self) -> Result<Client, Error> {
275 let mut header_hash_map = HashMap::new();
276 for (header_name, header_value) in &self.rpc_headers {
277 header_hash_map.insert(header_name.clone(), header_value.clone());
278 }
279
280 let header_map: HeaderMap = (&header_hash_map)
281 .try_into()
282 .map_err(|_| Error::InvalidHeader)?;
283
284 rpc::Client::new_with_headers(&self.rpc_url, header_map).map_err(|e| match e {
285 rpc::Error::InvalidRpcUrl(..) | rpc::Error::InvalidRpcUrlFromUriParts(..) => {
286 Error::InvalidUrl(redact_url(&self.rpc_url))
287 }
288 other => Error::Rpc(other),
289 })
290 }
291}
292
293pub const DEFAULT_NETWORK_KEY: &str = "testnet";
295
296pub static DEFAULTS: phf::Map<&'static str, (&'static str, &'static str)> = phf_map! {
297 "local" => (
298 "http://localhost:8000/rpc",
299 passphrase::LOCAL,
300 ),
301 "futurenet" => (
302 "https://rpc-futurenet.stellar.org:443",
303 passphrase::FUTURENET,
304 ),
305 "testnet" => (
306 "https://soroban-testnet.stellar.org",
307 passphrase::TESTNET,
308 ),
309 "mainnet" => (
310 "Bring Your Own: https://developers.stellar.org/docs/data/rpc/rpc-providers",
311 passphrase::MAINNET,
312 ),
313};
314
315impl From<&(&str, &str)> for Network {
316 fn from(n: &(&str, &str)) -> Self {
318 Self {
319 rpc_url: n.0.to_string(),
320 rpc_headers: Vec::new(),
321 network_passphrase: n.1.to_string(),
322 }
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use mockito::Server;
330 use serde_json::json;
331
332 const INVALID_HEADER_NAME: &str = "api key";
333 const INVALID_HEADER_VALUE: &str = "cannot include a carriage return \r in the value";
334
335 #[tokio::test]
336 async fn test_helper_url_local_network() {
337 let network = Network {
338 rpc_url: "http://localhost:8000".to_string(),
339 network_passphrase: passphrase::LOCAL.to_string(),
340 rpc_headers: Vec::new(),
341 };
342
343 let result = network
344 .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
345 .await;
346
347 assert!(result.is_ok());
348 let url = result.unwrap();
349 assert_eq!(url.as_str(), "http://localhost:8000/friendbot?addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
350 }
351
352 #[tokio::test]
353 async fn test_helper_url_test_network() {
354 let mut server = Server::new_async().await;
355 let _mock = server
356 .mock("POST", "/")
357 .with_body_from_request(|req| {
358 let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap();
359 let id = body["id"].clone();
360 json!({
361 "jsonrpc": "2.0",
362 "id": id,
363 "result": {
364 "friendbotUrl": "https://friendbot.stellar.org/",
365 "passphrase": passphrase::TESTNET.to_string(),
366 "protocolVersion": 21
367 }
368 })
369 .to_string()
370 .into()
371 })
372 .create_async()
373 .await;
374
375 let network = Network {
376 rpc_url: server.url(),
377 network_passphrase: passphrase::TESTNET.to_string(),
378 rpc_headers: Vec::new(),
379 };
380 let url = network
381 .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
382 .await
383 .unwrap();
384 assert_eq!(url.as_str(), "https://friendbot.stellar.org/?addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
385 }
386
387 #[tokio::test]
388 async fn test_helper_url_test_network_with_path_and_params() {
389 let mut server = Server::new_async().await;
390 let _mock = server.mock("POST", "/")
391 .with_body_from_request(|req| {
392 let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap();
393 let id = body["id"].clone();
394 json!({
395 "jsonrpc": "2.0",
396 "id": id,
397 "result": {
398 "friendbotUrl": "https://friendbot.stellar.org/secret?api_key=123456&user=demo",
399 "passphrase": passphrase::TESTNET.to_string(),
400 "protocolVersion": 21
401 }
402 }).to_string().into()
403 })
404 .create_async().await;
405
406 let network = Network {
407 rpc_url: server.url(),
408 network_passphrase: passphrase::TESTNET.to_string(),
409 rpc_headers: Vec::new(),
410 };
411 let url = network
412 .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
413 .await
414 .unwrap();
415 assert_eq!(url.as_str(), "https://friendbot.stellar.org/secret?api_key=123456&user=demo&addr=GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI");
416 }
417
418 #[tokio::test]
420 async fn test_parse_http_header_ok() {
421 let result = parse_http_header("Authorization: Bearer 1234");
422 assert!(result.is_ok());
423 }
424
425 #[tokio::test]
426 async fn test_parse_http_header_error_with_invalid_name() {
427 let invalid_header = format!("{INVALID_HEADER_NAME}: Bearer 1234");
428 let result = parse_http_header(&invalid_header);
429 assert!(result.is_err());
430 assert_eq!(
431 result.unwrap_err().to_string(),
432 format!("invalid HTTP header name")
433 );
434 }
435
436 #[tokio::test]
437 async fn test_parse_http_header_error_with_invalid_value() {
438 let invalid_header = format!("Authorization: {INVALID_HEADER_VALUE}");
439 let result = parse_http_header(&invalid_header);
440 assert!(result.is_err());
441 assert_eq!(
442 result.unwrap_err().to_string(),
443 format!("failed to parse header value")
444 );
445 }
446
447 #[tokio::test]
450 async fn test_rpc_client_is_ok_when_there_are_no_headers() {
451 let network = Network {
452 rpc_url: "http://localhost:1234".to_string(),
453 network_passphrase: "Network passphrase".to_string(),
454 rpc_headers: [].to_vec(),
455 };
456
457 let result = network.rpc_client();
458 assert!(result.is_ok());
459 }
460
461 #[tokio::test]
462 async fn test_rpc_client_is_ok_with_correctly_formatted_headers() {
463 let network = Network {
464 rpc_url: "http://localhost:1234".to_string(),
465 network_passphrase: "Network passphrase".to_string(),
466 rpc_headers: [("Authorization".to_string(), "Bearer 1234".to_string())].to_vec(),
467 };
468
469 let result = network.rpc_client();
470 assert!(result.is_ok());
471 }
472
473 #[tokio::test]
474 async fn test_rpc_client_is_ok_with_multiple_headers() {
475 let network = Network {
476 rpc_url: "http://localhost:1234".to_string(),
477 network_passphrase: "Network passphrase".to_string(),
478 rpc_headers: [
479 ("Authorization".to_string(), "Bearer 1234".to_string()),
480 ("api-key".to_string(), "5678".to_string()),
481 ]
482 .to_vec(),
483 };
484
485 let result = network.rpc_client();
486 assert!(result.is_ok());
487 }
488
489 #[tokio::test]
490 async fn test_rpc_client_returns_err_with_invalid_header_name() {
491 let network = Network {
492 rpc_url: "http://localhost:8000".to_string(),
493 network_passphrase: passphrase::LOCAL.to_string(),
494 rpc_headers: [(INVALID_HEADER_NAME.to_string(), "Bearer".to_string())].to_vec(),
495 };
496
497 let result = network.rpc_client();
498 assert!(result.is_err());
499 assert_eq!(
500 result.unwrap_err().to_string(),
501 format!("invalid HTTP header: must be in the form 'key:value'")
502 );
503 }
504
505 #[tokio::test]
506 async fn test_rpc_client_returns_err_with_invalid_header_value() {
507 let network = Network {
508 rpc_url: "http://localhost:8000".to_string(),
509 network_passphrase: passphrase::LOCAL.to_string(),
510 rpc_headers: [("api-key".to_string(), INVALID_HEADER_VALUE.to_string())].to_vec(),
511 };
512
513 let result = network.rpc_client();
514 assert!(result.is_err());
515 assert_eq!(
516 result.unwrap_err().to_string(),
517 format!("invalid HTTP header: must be in the form 'key:value'")
518 );
519 }
520
521 #[tokio::test]
522 async fn test_rpc_client_returns_err_with_bad_rpc_url() {
523 let network = Network {
524 rpc_url: "Bring Your Own: http://localhost:8000".to_string(),
525 network_passphrase: passphrase::LOCAL.to_string(),
526 rpc_headers: [].to_vec(),
527 };
528
529 let result = network.rpc_client();
530 assert!(result.is_err());
531 assert_eq!(
532 result.unwrap_err().to_string(),
533 format!("Invalid URL Bring Your Own: http://localhost:8000")
534 );
535 }
536
537 #[tokio::test]
538 async fn test_default_to_testnet_when_no_network_specified() {
539 use super::super::locator;
540
541 let args = Args::default(); let locator_args = locator::Args::default();
543
544 let result = args.get(&locator_args);
545 assert!(result.is_ok());
546
547 let network = result.unwrap();
548 assert_eq!(network.network_passphrase, passphrase::TESTNET);
549 assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org");
550 }
551
552 #[tokio::test]
553 async fn test_user_config_default_overrides_automatic_testnet() {
554 use super::super::locator;
555 use std::env;
556
557 let original_home = env::var("HOME").ok();
559 let original_stellar_config_home = env::var("STELLAR_CONFIG_HOME").ok();
560
561 env::set_var("HOME", "/dev/null");
563 env::set_var("STELLAR_CONFIG_HOME", "/dev/null");
564
565 let args = Args::default(); let locator_args = locator::Args::default();
567
568 let result = args.get(&locator_args);
569 assert!(result.is_ok());
570
571 let network = result.unwrap();
572 assert_eq!(network.network_passphrase, passphrase::TESTNET);
574 assert_eq!(network.rpc_url, "https://soroban-testnet.stellar.org");
575
576 if let Some(home) = original_home {
578 env::set_var("HOME", home);
579 } else {
580 env::remove_var("HOME");
581 }
582 if let Some(config_home) = original_stellar_config_home {
583 env::set_var("STELLAR_CONFIG_HOME", config_home);
584 } else {
585 env::remove_var("STELLAR_CONFIG_HOME");
586 }
587 }
588
589 #[test]
590 fn test_malformed_rpc_header_accepted_by_clap_without_error() {
591 use crate::test_utils::with_env_guard;
592 use clap::Parser;
593
594 #[derive(clap::Parser)]
595 struct TestCmd {
596 #[command(flatten)]
597 args: Args,
598 }
599
600 let secret = "Authorization Bearer secret_poc_token_12345";
601 with_env_guard(&["STELLAR_RPC_HEADERS"], || {
602 std::env::set_var("STELLAR_RPC_HEADERS", secret);
603 let result = TestCmd::try_parse_from(["stellar"]);
604 assert!(
605 result.is_ok(),
606 "Clap must accept malformed RPC headers without error — validation is deferred to application code to prevent secrets from being echoed in clap error messages"
607 );
608 });
609 }
610
611 #[test]
612 fn test_validate_headers_rejects_missing_colon_without_exposing_value() {
613 let network = Network {
615 rpc_url: "http://localhost:8000".to_string(),
616 network_passphrase: "Test".to_string(),
617 rpc_headers: vec![(
618 String::new(),
619 "Authorization Bearer secret_token_xyz".to_string(),
620 )],
621 };
622
623 let result = network.validate_headers();
624 assert!(result.is_err());
625 let error_msg = result.unwrap_err().to_string();
626 assert_eq!(
627 error_msg,
628 "invalid HTTP header: must be in the form 'key:value'"
629 );
630 assert!(
631 !error_msg.contains("secret_token_xyz"),
632 "Error must not expose the raw header value, got: {error_msg}"
633 );
634 }
635
636 #[test]
637 fn test_malformed_rpc_header_app_error_does_not_expose_value() {
638 use super::super::locator;
639
640 let secret = "Authorization Bearer secret_poc_token_12345";
641 let args = Args {
642 rpc_url: Some("https://example.com".to_string()),
643 rpc_headers: vec![secret.to_string()],
644 network_passphrase: Some("Test SDF Network ; September 2015".to_string()),
645 network: None,
646 };
647
648 let result = args.get(&locator::Args::default());
649 assert!(result.is_err());
650 let error_msg = result.unwrap_err().to_string();
651 assert!(
652 !error_msg.contains("secret_poc_token_12345"),
653 "Application error must not expose secret header value, got: {error_msg}"
654 );
655 }
656
657 #[test]
658 fn test_debug_conceals_rpc_header_values() {
659 let network = Network {
660 rpc_url: "http://localhost:8000/rpc".to_string(),
661 network_passphrase: "Test Network".to_string(),
662 rpc_headers: vec![
663 ("Authorization".to_string(), "Bearer secret123".to_string()),
664 ("X-Api-Key".to_string(), "mykey".to_string()),
665 ],
666 };
667 assert_eq!(
668 format!("{network:?}"),
669 r#"Network { rpc_url: "http://localhost:8000/rpc", rpc_headers: [("Authorization", "<concealed>"), ("X-Api-Key", "<concealed>")], network_passphrase: "Test Network" }"#
670 );
671 }
672
673 #[test]
674 fn test_debug_conceals_rpc_url_password() {
675 let network = Network {
676 rpc_url: "https://alice:supersecret@rpc.example.com/soroban".to_string(),
677 network_passphrase: "Test Network".to_string(),
678 rpc_headers: Vec::new(),
679 };
680 let rendered = format!("{network:?}");
681 assert!(
682 !rendered.contains("supersecret"),
683 "password leaked into Debug output: {rendered}"
684 );
685 assert!(
686 rendered.contains("alice:redacted"),
687 "expected `alice:redacted` in Debug output: {rendered}"
688 );
689 }
690
691 #[tokio::test]
692 async fn fund_address_failed_to_parse_json_does_not_leak_credentialed_rpc_url() {
693 let mut server = Server::new_async().await;
694 let _mock = server
697 .mock("GET", mockito::Matcher::Any)
698 .with_status(200)
699 .with_body("not valid json")
700 .create_async()
701 .await;
702
703 let host_port = server
704 .url()
705 .strip_prefix("http://")
706 .expect("mockito url starts with http://")
707 .to_string();
708 let credentialed_rpc_url = format!("http://alice:supersecret@{host_port}");
709
710 let network = Network {
711 rpc_url: credentialed_rpc_url,
712 network_passphrase: passphrase::LOCAL.to_string(),
713 rpc_headers: Vec::new(),
714 };
715
716 let addr =
717 PublicKey::from_string("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
718 .unwrap();
719 let err = network
720 .fund_address(&addr)
721 .await
722 .expect_err("fund_address must return Err when friendbot replies with non-JSON body");
723 let rendered = err.to_string();
724 assert!(
725 !rendered.contains("supersecret"),
726 "password leaked into error display: {rendered}"
727 );
728 assert!(
729 rendered.contains("alice:redacted"),
730 "expected `alice:redacted` placeholder in error display: {rendered}"
731 );
732 }
733
734 #[tokio::test]
735 async fn helper_url_returned_credentialed_url_is_redactable_at_display_sinks() {
736 let mut server = Server::new_async().await;
744 let _mock = server
745 .mock("POST", "/")
746 .with_body_from_request(|req| {
747 let body: Value = serde_json::from_slice(req.body().unwrap()).unwrap();
748 let id = body["id"].clone();
749 json!({
754 "jsonrpc": "2.0",
755 "id": id,
756 "result": {
757 "friendbotUrl": "https://alice:supersecret@friendbot.example/",
758 "passphrase": passphrase::TESTNET.to_string(),
759 "protocolVersion": 21,
760 }
761 })
762 .to_string()
763 .into()
764 })
765 .create_async()
766 .await;
767
768 let network = Network {
769 rpc_url: server.url(),
770 network_passphrase: passphrase::TESTNET.to_string(),
771 rpc_headers: Vec::new(),
772 };
773 let returned = network
774 .helper_url("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI")
775 .await
776 .expect("helper_url should accept a parseable credentialed friendbot URL");
777 assert_eq!(returned.password(), Some("supersecret"));
779 let redacted_for_display = redact_url(returned.as_str());
780 assert!(
781 !redacted_for_display.contains("supersecret"),
782 "redact_url failed to redact a parseable friendbot URL: {redacted_for_display}"
783 );
784 }
785}