1#![deny(clippy::pedantic, clippy::unwrap_used)]
2#![allow(clippy::doc_markdown)]
3#![allow(clippy::missing_errors_doc)]
4#![allow(clippy::missing_panics_doc)]
5#![allow(clippy::module_name_repetitions)]
6#![allow(clippy::must_use_candidate)]
7#![allow(clippy::ref_option)]
8#![allow(clippy::return_self_not_must_use)]
9#![allow(clippy::too_many_lines)]
10#![allow(clippy::large_futures)]
11
12mod cli;
13mod client;
14mod db;
15pub mod envs;
16mod utils;
17mod visualize;
18
19use core::fmt;
20use std::collections::BTreeMap;
21use std::fmt::Debug;
22use std::io::{IsTerminal, Read, Write};
23use std::path::{Path, PathBuf};
24use std::process::exit;
25use std::str::FromStr;
26use std::sync::Arc;
27use std::time::Duration;
28use std::{fs, result};
29
30use anyhow::{Context, format_err};
31use clap::{CommandFactory, Parser};
32use cli::{
33 AdminCmd, Command, DatabaseBackend, DecodeType, DevCmd, EncodeType, OOBNotesJson, Opts,
34 SetupAdminArgs, SetupAdminCmd, VisualizeCmd,
35};
36use envs::SALT_FILE;
37use fedimint_aead::{encrypted_read, encrypted_write, get_encryption_key};
38use fedimint_api_client::api::{DynGlobalApi, FederationApiExt, FederationError};
39use fedimint_bip39::{Bip39RootSecretStrategy, Mnemonic};
40use fedimint_client::db::ApiSecretKey;
41use fedimint_client::module::meta::{FetchKind, LegacyMetaSource, MetaSource};
42use fedimint_client::module::module::init::ClientModuleInit;
43use fedimint_client::module_init::ClientModuleInitRegistry;
44use fedimint_client::secret::RootSecretStrategy;
45use fedimint_client::{AdminCreds, Client, ClientBuilder, ClientHandleArc, RootSecret};
46use fedimint_connectors::ConnectorRegistry;
47use fedimint_core::base32::FEDIMINT_PREFIX;
48use fedimint_core::config::{FederationId, FederationIdPrefix};
49use fedimint_core::core::ModuleInstanceId;
50use fedimint_core::db::{Database, DatabaseValue, IDatabaseTransactionOpsCoreTyped as _};
51use fedimint_core::encoding::Decodable;
52use fedimint_core::invite_code::InviteCode;
53use fedimint_core::module::registry::ModuleRegistry;
54use fedimint_core::module::{ApiAuth, ApiRequestErased};
55use fedimint_core::setup_code::PeerSetupCode;
56use fedimint_core::transaction::Transaction;
57use fedimint_core::util::{SafeUrl, backoff_util, handle_version_hash_command, retry};
58use fedimint_core::{PeerId, base32, fedimint_build_code_version_env, runtime};
59use fedimint_derive_secret::DerivableSecret;
60use fedimint_eventlog::EventLogTrimableId;
61use fedimint_ln_client::LightningClientInit;
62use fedimint_logging::{LOG_CLIENT, TracingSetup};
63use fedimint_meta_client::{MetaClientInit, MetaModuleMetaSourceWithFallback};
64use fedimint_mint_client::{MintClientInit, MintClientModule, OOBNotes};
65use fedimint_wallet_client::api::WalletFederationApi;
66use fedimint_wallet_client::{WalletClientInit, WalletClientModule};
67use futures::future::pending;
68use itertools::Itertools;
69use rand::thread_rng;
70use serde::Serialize;
71use serde_json::{Value, json};
72use thiserror::Error;
73use tracing::{debug, info, warn};
74
75use crate::client::ClientCmd;
76use crate::db::{StoredAdminCreds, load_admin_creds, store_admin_creds};
77
78#[derive(Serialize)]
80#[serde(rename_all = "snake_case")]
81#[serde(untagged)]
82enum CliOutput {
83 VersionHash {
84 hash: String,
85 },
86
87 UntypedApiOutput {
88 value: Value,
89 },
90
91 WaitBlockCount {
92 reached: u64,
93 },
94
95 InviteCode {
96 invite_code: InviteCode,
97 },
98
99 DecodeInviteCode {
100 url: SafeUrl,
101 federation_id: FederationId,
102 },
103
104 Join {
105 joined: String,
106 },
107
108 DecodeTransaction {
109 transaction: String,
110 },
111
112 EpochCount {
113 count: u64,
114 },
115
116 ConfigDecrypt,
117
118 ConfigEncrypt,
119
120 SetupCode {
121 setup_code: PeerSetupCode,
122 },
123
124 Raw(serde_json::Value),
125}
126
127impl fmt::Display for CliOutput {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 write!(
130 f,
131 "{}",
132 serde_json::to_string_pretty(self).expect("CliOutput is serializable")
133 )
134 }
135}
136
137type CliResult<E> = Result<E, CliError>;
139
140type CliOutputResult = Result<CliOutput, CliError>;
142
143#[derive(Serialize, Error)]
145#[serde(tag = "error", rename_all(serialize = "snake_case"))]
146struct CliError {
147 error: String,
148}
149
150trait CliResultExt<O, E> {
153 fn map_err_cli(self) -> Result<O, CliError>;
155 fn map_err_cli_msg(self, msg: impl fmt::Display + Send + Sync + 'static)
157 -> Result<O, CliError>;
158}
159
160impl<O, E> CliResultExt<O, E> for result::Result<O, E>
161where
162 E: Into<anyhow::Error>,
163{
164 fn map_err_cli(self) -> Result<O, CliError> {
165 self.map_err(|e| {
166 let e = e.into();
167 CliError {
168 error: format!("{e:#}"),
169 }
170 })
171 }
172
173 fn map_err_cli_msg(
174 self,
175 msg: impl fmt::Display + Send + Sync + 'static,
176 ) -> Result<O, CliError> {
177 self.map_err(|e| Into::<anyhow::Error>::into(e))
178 .context(msg)
179 .map_err(|e| CliError {
180 error: format!("{e:#}"),
181 })
182 }
183}
184
185trait CliOptionExt<O> {
188 fn ok_or_cli_msg(self, msg: impl Into<String>) -> Result<O, CliError>;
189}
190
191impl<O> CliOptionExt<O> for Option<O> {
192 fn ok_or_cli_msg(self, msg: impl Into<String>) -> Result<O, CliError> {
193 self.ok_or_else(|| CliError { error: msg.into() })
194 }
195}
196
197impl From<FederationError> for CliError {
199 fn from(e: FederationError) -> Self {
200 CliError {
201 error: e.to_string(),
202 }
203 }
204}
205
206impl Debug for CliError {
207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208 f.debug_struct("CliError")
209 .field("error", &self.error)
210 .finish()
211 }
212}
213
214impl fmt::Display for CliError {
215 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
216 let json = serde_json::to_value(self).expect("CliError is valid json");
217 let json_as_string =
218 serde_json::to_string_pretty(&json).expect("valid json is serializable");
219 write!(f, "{json_as_string}")
220 }
221}
222
223impl Opts {
224 fn data_dir(&self) -> CliResult<&PathBuf> {
225 self.data_dir
226 .as_ref()
227 .ok_or_cli_msg("`--data-dir=` argument not set.")
228 }
229
230 async fn data_dir_create(&self) -> CliResult<&PathBuf> {
232 let dir = self.data_dir()?;
233
234 tokio::fs::create_dir_all(&dir).await.map_err_cli()?;
235
236 Ok(dir)
237 }
238 fn iroh_enable_dht(&self) -> bool {
239 self.iroh_enable_dht.unwrap_or(true)
240 }
241
242 fn iroh_enable_next(&self) -> bool {
243 self.iroh_enable_next.unwrap_or(true)
244 }
245
246 fn use_tor(&self) -> bool {
247 #[cfg(feature = "tor")]
248 return self.use_tor;
249 #[cfg(not(feature = "tor"))]
250 false
251 }
252
253 async fn admin_client(
254 &self,
255 peer_urls: &BTreeMap<PeerId, SafeUrl>,
256 api_secret: Option<&str>,
257 ) -> CliResult<DynGlobalApi> {
258 self.admin_client_with_db(peer_urls, api_secret, None).await
259 }
260
261 async fn admin_client_with_db(
262 &self,
263 peer_urls: &BTreeMap<PeerId, SafeUrl>,
264 api_secret: Option<&str>,
265 db: Option<&Database>,
266 ) -> CliResult<DynGlobalApi> {
267 let our_id = if let Some(id) = self.our_id {
269 id
270 } else if let Some(db) = db {
271 if let Some(stored_creds) = load_admin_creds(db).await {
272 stored_creds.peer_id
273 } else {
274 return Err(CliError {
275 error: "Admin client needs our-id set (no stored credentials found)"
276 .to_string(),
277 });
278 }
279 } else {
280 return Err(CliError {
281 error: "Admin client needs our-id set".to_string(),
282 });
283 };
284
285 DynGlobalApi::new_admin(
286 self.make_endpoints().await.map_err(|e| CliError {
287 error: e.to_string(),
288 })?,
289 our_id,
290 peer_urls
291 .get(&our_id)
292 .cloned()
293 .context("Our peer URL not found in config")
294 .map_err_cli()?,
295 api_secret,
296 )
297 .map_err_cli()
298 }
299
300 async fn make_endpoints(&self) -> Result<ConnectorRegistry, anyhow::Error> {
301 ConnectorRegistry::build_from_client_defaults()
302 .iroh_next(self.iroh_enable_next())
303 .iroh_pkarr_dht(self.iroh_enable_dht())
304 .ws_force_tor(self.use_tor())
305 .bind()
306 .await
307 }
308
309 fn auth(&self) -> CliResult<ApiAuth> {
310 let password = self
311 .password
312 .clone()
313 .ok_or_cli_msg("CLI needs password set")?;
314 Ok(ApiAuth::new(password))
315 }
316
317 async fn load_database(&self) -> CliResult<Database> {
318 debug!(target: LOG_CLIENT, "Loading client database");
319 let db_path = self.data_dir_create().await?.join("client.db");
320 match self.db_backend {
321 DatabaseBackend::RocksDb => {
322 debug!(target: LOG_CLIENT, "Using RocksDB database backend");
323 Ok(fedimint_rocksdb::RocksDb::build(db_path)
324 .open()
325 .await
326 .map_err_cli_msg("could not open rocksdb database")?
327 .into())
328 }
329 DatabaseBackend::CursedRedb => {
330 debug!(target: LOG_CLIENT, "Using CursedRedb database backend");
331 Ok(fedimint_cursed_redb::MemAndRedb::new(db_path)
332 .await
333 .map_err_cli_msg("could not open cursed redb database")?
334 .into())
335 }
336 }
337 }
338}
339
340fn decode_federation_secret_hex(federation_secret_hex: &str) -> CliResult<DerivableSecret> {
341 <DerivableSecret as Decodable>::consensus_decode_hex(
342 federation_secret_hex,
343 &ModuleRegistry::default(),
344 )
345 .map_err_cli_msg("invalid federation secret hex")
346}
347
348enum RecoverySecret {
349 Mnemonic(Mnemonic),
350 FederationSecret(DerivableSecret),
351}
352
353fn root_secret_from_mnemonic(mnemonic: &Mnemonic) -> RootSecret {
354 RootSecret::StandardDoubleDerive(Bip39RootSecretStrategy::<12>::to_root_secret(mnemonic))
355}
356
357async fn load_or_generate_mnemonic(db: &Database) -> Result<Mnemonic, CliError> {
358 Ok(
359 if let Ok(entropy) = Client::load_decodable_client_secret::<Vec<u8>>(db).await {
360 Mnemonic::from_entropy(&entropy).map_err_cli()?
361 } else {
362 debug!(
363 target: LOG_CLIENT,
364 "Generating mnemonic and writing entropy to client storage"
365 );
366 let mnemonic = Bip39RootSecretStrategy::<12>::random(&mut thread_rng());
367 Client::store_encodable_client_secret(db, mnemonic.to_entropy())
368 .await
369 .map_err_cli()?;
370 mnemonic
371 },
372 )
373}
374
375pub struct FedimintCli {
376 module_inits: ClientModuleInitRegistry,
377 cli_args: Opts,
378}
379
380impl FedimintCli {
381 pub fn new(version_hash: &str) -> anyhow::Result<FedimintCli> {
383 assert_eq!(
384 fedimint_build_code_version_env!().len(),
385 version_hash.len(),
386 "version_hash must have an expected length"
387 );
388
389 handle_version_hash_command(version_hash);
390
391 let cli_args = Opts::parse();
392 let base_level = if cli_args.verbose { "debug" } else { "info" };
393 TracingSetup::default()
394 .with_base_level(base_level)
395 .init()
396 .expect("tracing initializes");
397
398 let version = env!("CARGO_PKG_VERSION");
399 debug!(target: LOG_CLIENT, "Starting fedimint-cli (version: {version} version_hash: {version_hash})");
400
401 Ok(Self {
402 module_inits: ClientModuleInitRegistry::new(),
403 cli_args,
404 })
405 }
406
407 pub fn with_module<T>(mut self, r#gen: T) -> Self
408 where
409 T: ClientModuleInit + 'static + Send + Sync,
410 {
411 self.module_inits.attach(r#gen);
412 self
413 }
414
415 pub fn with_default_modules(self) -> Self {
416 self.with_module(LightningClientInit::default())
417 .with_module(MintClientInit)
418 .with_module(fedimint_mintv2_client::MintClientInit)
419 .with_module(WalletClientInit::default())
420 .with_module(MetaClientInit)
421 .with_module(fedimint_lnv2_client::LightningClientInit::default())
422 .with_module(fedimint_walletv2_client::WalletClientInit)
423 }
424
425 pub async fn run(&mut self) {
426 match self.handle_command(self.cli_args.clone()).await {
427 Ok(output) => {
428 let _ = writeln!(std::io::stdout(), "{output}");
430 }
431 Err(err) => {
432 debug!(target: LOG_CLIENT, err = %err.error.as_str(), "Command failed");
433 let _ = writeln!(std::io::stdout(), "{err}");
434 exit(1);
435 }
436 }
437 }
438
439 async fn make_client_builder(&self, cli: &Opts) -> CliResult<(ClientBuilder, Database)> {
440 let mut client_builder = Client::builder()
441 .await
442 .map_err_cli()?
443 .with_iroh_enable_dht(cli.iroh_enable_dht())
444 .with_iroh_enable_next(cli.iroh_enable_next());
445 client_builder.with_module_inits(self.module_inits.clone());
446
447 let db = cli.load_database().await?;
448 Ok((client_builder, db))
449 }
450
451 async fn client_join(
452 &mut self,
453 cli: &Opts,
454 invite_code: InviteCode,
455 ) -> CliResult<ClientHandleArc> {
456 let (client_builder, db) = self.make_client_builder(cli).await?;
457
458 let mnemonic = load_or_generate_mnemonic(&db).await?;
459
460 let client = client_builder
461 .preview(cli.make_endpoints().await.map_err_cli()?, &invite_code)
462 .await
463 .map_err_cli()?
464 .join(db, root_secret_from_mnemonic(&mnemonic))
465 .await
466 .map(Arc::new)
467 .map_err_cli()?;
468
469 print_welcome_message(&client).await;
470 log_expiration_notice(&client).await;
471
472 Ok(client)
473 }
474
475 async fn client_open(&self, cli: &Opts) -> CliResult<ClientHandleArc> {
476 let (mut client_builder, db) = self.make_client_builder(cli).await?;
477
478 if let Some(our_id) = cli.our_id {
480 client_builder.set_admin_creds(AdminCreds {
481 peer_id: our_id,
482 auth: cli.auth()?,
483 });
484 } else if let Some(stored_creds) = load_admin_creds(&db).await {
485 debug!(target: LOG_CLIENT, "Using stored admin credentials");
486 client_builder.set_admin_creds(AdminCreds {
487 peer_id: stored_creds.peer_id,
488 auth: ApiAuth::new(stored_creds.auth),
489 });
490 }
491
492 let existing_mnemonic = Client::load_decodable_client_secret_opt::<Vec<u8>>(&db)
493 .await
494 .map_err_cli()?;
495
496 let root_secret = match (cli.federation_secret_hex.as_deref(), existing_mnemonic) {
497 (Some(_), Some(_)) => {
498 return Err(CliError {
499 error: "client secret is already set in DB; --federation-secret-hex open requires a client DB without any stored secret".to_owned(),
500 });
501 }
502 (Some(federation_secret_hex), None) => {
503 RootSecret::Custom(decode_federation_secret_hex(federation_secret_hex)?)
504 }
505 (None, Some(entropy)) => {
506 let mnemonic = Mnemonic::from_entropy(&entropy).map_err_cli()?;
507 root_secret_from_mnemonic(&mnemonic)
508 }
509 (None, None) => {
510 return Err(CliError {
511 error: "Encoded client secret not present in DB".to_owned(),
512 });
513 }
514 };
515
516 let client = client_builder
517 .open(cli.make_endpoints().await.map_err_cli()?, db, root_secret)
518 .await
519 .map(Arc::new)
520 .map_err_cli()?;
521
522 log_expiration_notice(&client).await;
523
524 Ok(client)
525 }
526
527 async fn client_recover(
528 &mut self,
529 cli: &Opts,
530 recovery_secret: RecoverySecret,
531 invite_code: InviteCode,
532 ) -> CliResult<ClientHandleArc> {
533 let (builder, db) = self.make_client_builder(cli).await?;
534 let existing_mnemonic = Client::load_decodable_client_secret_opt::<Vec<u8>>(&db)
535 .await
536 .map_err_cli()?;
537
538 let root_secret = match (recovery_secret, existing_mnemonic) {
539 (RecoverySecret::Mnemonic(mnemonic), Some(existing)) => {
540 if existing != mnemonic.to_entropy() {
541 Err(anyhow::anyhow!("Previously set mnemonic does not match")).map_err_cli()?;
542 }
543
544 root_secret_from_mnemonic(&mnemonic)
545 }
546 (RecoverySecret::Mnemonic(mnemonic), None) => {
547 Client::store_encodable_client_secret(&db, mnemonic.to_entropy())
548 .await
549 .map_err_cli()?;
550 root_secret_from_mnemonic(&mnemonic)
551 }
552 (RecoverySecret::FederationSecret(federation_secret), None) => {
553 RootSecret::Custom(federation_secret)
554 }
555 (RecoverySecret::FederationSecret(_), Some(_)) => {
556 return Err(CliError {
557 error: "client secret is already set in DB; --federation-secret-hex restore requires a client DB without any stored secret".to_owned(),
558 });
559 }
560 };
561
562 let preview = builder
563 .preview(cli.make_endpoints().await.map_err_cli()?, &invite_code)
564 .await
565 .map_err_cli()?;
566
567 #[allow(deprecated)]
568 let backup = preview
569 .download_backup_from_federation(root_secret.clone())
570 .await
571 .map_err_cli()?;
572
573 let client = preview
574 .recover(db, root_secret, backup)
575 .await
576 .map(Arc::new)
577 .map_err_cli()?;
578
579 print_welcome_message(&client).await;
580 log_expiration_notice(&client).await;
581
582 Ok(client)
583 }
584
585 async fn handle_command(&mut self, cli: Opts) -> CliOutputResult {
586 if cli.federation_secret_hex.is_some() && matches!(&cli.command, Command::Join { .. }) {
587 return Err(CliError {
588 error: "--federation-secret-hex cannot be used with join".to_owned(),
589 });
590 }
591
592 match cli.command.clone() {
593 Command::InviteCode { peer } => {
594 let client = self.client_open(&cli).await?;
595
596 let invite_code = client
597 .invite_code(peer)
598 .await
599 .ok_or_cli_msg("peer not found")?;
600
601 Ok(CliOutput::InviteCode { invite_code })
602 }
603 Command::Join { invite_code } => {
604 {
605 let invite_code: InviteCode = InviteCode::from_str(&invite_code)
606 .map_err_cli_msg("invalid invite code")?;
607
608 let _client = self.client_join(&cli, invite_code).await?;
610 }
611
612 Ok(CliOutput::Join {
613 joined: invite_code,
614 })
615 }
616 Command::VersionHash => Ok(CliOutput::VersionHash {
617 hash: fedimint_build_code_version_env!().to_string(),
618 }),
619 Command::Client(ClientCmd::Restore {
620 mnemonic,
621 invite_code,
622 }) => {
623 let invite_code: InviteCode =
624 InviteCode::from_str(&invite_code).map_err_cli_msg("invalid invite code")?;
625 let recovery_secret = match (
626 mnemonic.as_deref(),
627 cli.federation_secret_hex.as_deref(),
628 ) {
629 (Some(_), Some(_)) => {
630 return Err(CliError {
631 error: "restore accepts either --mnemonic or --federation-secret-hex, not both".to_owned(),
632 });
633 }
634 (Some(mnemonic), None) => {
635 let mnemonic = Mnemonic::from_str(mnemonic).map_err_cli()?;
636 RecoverySecret::Mnemonic(mnemonic)
637 }
638 (None, Some(federation_secret_hex)) => {
639 let federation_secret =
640 decode_federation_secret_hex(federation_secret_hex)?;
641 RecoverySecret::FederationSecret(federation_secret)
642 }
643 (None, None) => {
644 return Err(CliError {
645 error: "restore requires either --mnemonic or --federation-secret-hex"
646 .to_owned(),
647 });
648 }
649 };
650 let client = self
651 .client_recover(&cli, recovery_secret, invite_code)
652 .await?;
653
654 debug!(target: LOG_CLIENT, "Waiting for mint module recovery to finish");
657 client.wait_for_all_recoveries().await.map_err_cli()?;
658
659 debug!(target: LOG_CLIENT, "Recovery complete");
660
661 Ok(CliOutput::Raw(
662 serde_json::to_value(()).expect("unit type is serializable"),
663 ))
664 }
665 Command::Client(command) => {
666 let client = self.client_open(&cli).await?;
667 Ok(CliOutput::Raw(
668 client::handle_command(command, client)
669 .await
670 .map_err_cli()?,
671 ))
672 }
673 Command::Admin(AdminCmd::Auth {
674 peer_id,
675 password,
676 no_verify,
677 force,
678 }) => {
679 let db = cli.load_database().await?;
680 let peer_id = PeerId::from(peer_id);
681 let auth = ApiAuth::new(password);
682
683 if !force {
685 let existing = load_admin_creds(&db).await;
686 if existing.is_some() {
687 return Err(CliError {
688 error: "Admin credentials already stored. Use --force to overwrite."
689 .to_string(),
690 });
691 }
692 }
693
694 let config = Client::get_config_from_db(&db)
696 .await
697 .ok_or_cli_msg("Client not initialized. Please join a federation first.")?;
698
699 let peer_url =
701 config
702 .global
703 .api_endpoints
704 .get(&peer_id)
705 .ok_or_else(|| CliError {
706 error: format!(
707 "Peer ID {} not found in federation. Valid peer IDs are: {:?}",
708 peer_id,
709 config.global.api_endpoints.keys().collect::<Vec<_>>()
710 ),
711 })?;
712
713 if !no_verify {
715 if !std::io::stdin().is_terminal() {
717 return Err(CliError {
718 error: "Interactive verification requires a terminal. Use --no-verify to skip.".to_string(),
719 });
720 }
721
722 eprintln!("Guardian endpoint for peer {}: {}", peer_id, peer_url.url);
723 eprint!("Does this look correct? (y/N): ");
724 std::io::stderr().flush().map_err_cli()?;
725
726 let mut input = String::new();
727 std::io::stdin().read_line(&mut input).map_err_cli()?;
728 let input = input.trim().to_lowercase();
729
730 if input != "y" && input != "yes" {
731 return Err(CliError {
732 error: "Endpoint verification cancelled by user.".to_string(),
733 });
734 }
735 }
736
737 eprintln!("Verifying credentials...");
739 let admin_api = DynGlobalApi::new_admin(
740 cli.make_endpoints().await.map_err_cli()?,
741 peer_id,
742 peer_url.url.clone(),
743 db.begin_transaction_nc()
744 .await
745 .get_value(&ApiSecretKey)
746 .await
747 .as_deref(),
748 )
749 .map_err_cli()?;
750
751 admin_api.auth(auth.clone()).await.map_err(|e| CliError {
753 error: format!(
754 "Failed to verify credentials: {e}. Please check your peer ID and password."
755 ),
756 })?;
757
758 store_admin_creds(
760 &db,
761 &StoredAdminCreds {
762 peer_id,
763 auth: auth.as_str().to_string(),
764 },
765 )
766 .await;
767
768 eprintln!("Admin credentials verified and saved successfully.");
769 Ok(CliOutput::Raw(json!({
770 "peer_id": peer_id,
771 "endpoint": peer_url.url.to_string(),
772 "status": "saved"
773 })))
774 }
775 Command::Admin(AdminCmd::Audit) => {
776 let client = self.client_open(&cli).await?;
777
778 let audit = cli
779 .admin_client(
780 &client.get_peer_urls().await,
781 client.api_secret().as_deref(),
782 )
783 .await?
784 .audit(cli.auth()?)
785 .await?;
786 Ok(CliOutput::Raw(
787 serde_json::to_value(audit).map_err_cli_msg("invalid response")?,
788 ))
789 }
790 Command::Admin(AdminCmd::Status) => {
791 let client = self.client_open(&cli).await?;
792
793 let status = cli
794 .admin_client_with_db(
795 &client.get_peer_urls().await,
796 client.api_secret().as_deref(),
797 Some(client.db()),
798 )
799 .await?
800 .status()
801 .await?;
802 Ok(CliOutput::Raw(
803 serde_json::to_value(status).map_err_cli_msg("invalid response")?,
804 ))
805 }
806 Command::Admin(AdminCmd::GuardianConfigBackup) => {
807 let client = self.client_open(&cli).await?;
808
809 let guardian_config_backup = cli
810 .admin_client(
811 &client.get_peer_urls().await,
812 client.api_secret().as_deref(),
813 )
814 .await?
815 .guardian_config_backup(cli.auth()?)
816 .await?;
817 Ok(CliOutput::Raw(
818 serde_json::to_value(guardian_config_backup)
819 .map_err_cli_msg("invalid response")?,
820 ))
821 }
822 Command::Admin(AdminCmd::Setup(dkg_args)) => self
823 .handle_admin_setup_command(cli, dkg_args)
824 .await
825 .map(CliOutput::Raw)
826 .map_err_cli_msg("Config Gen Error"),
827 Command::Admin(AdminCmd::SignApiAnnouncement {
828 api_url,
829 override_url,
830 }) => {
831 let client = self.client_open(&cli).await?;
832
833 if !["ws", "wss"].contains(&api_url.scheme()) {
834 return Err(CliError {
835 error: format!(
836 "Unsupported URL scheme {}, use ws:// or wss://",
837 api_url.scheme()
838 ),
839 });
840 }
841
842 let announcement = cli
843 .admin_client(
844 &override_url
845 .and_then(|url| Some(vec![(cli.our_id?, url)].into_iter().collect()))
846 .unwrap_or(client.get_peer_urls().await),
847 client.api_secret().as_deref(),
848 )
849 .await?
850 .sign_api_announcement(api_url, cli.auth()?)
851 .await?;
852
853 Ok(CliOutput::Raw(
854 serde_json::to_value(announcement).map_err_cli_msg("invalid response")?,
855 ))
856 }
857 Command::Admin(AdminCmd::SignGuardianMetadata { api_urls, pkarr_id }) => {
858 let client = self.client_open(&cli).await?;
859
860 let metadata = fedimint_core::net::guardian_metadata::GuardianMetadata {
861 api_urls,
862 pkarr_id_z32: pkarr_id,
863 timestamp_secs: fedimint_core::time::duration_since_epoch().as_secs(),
864 };
865
866 let signed_metadata = cli
867 .admin_client(
868 &client.get_peer_urls().await,
869 client.api_secret().as_deref(),
870 )
871 .await?
872 .sign_guardian_metadata(metadata, cli.auth()?)
873 .await?;
874
875 Ok(CliOutput::Raw(
876 serde_json::to_value(signed_metadata).map_err_cli_msg("invalid response")?,
877 ))
878 }
879 Command::Admin(AdminCmd::Shutdown { session_idx }) => {
880 let client = self.client_open(&cli).await?;
881
882 cli.admin_client(
883 &client.get_peer_urls().await,
884 client.api_secret().as_deref(),
885 )
886 .await?
887 .shutdown(Some(session_idx), cli.auth()?)
888 .await?;
889
890 Ok(CliOutput::Raw(json!(null)))
891 }
892 Command::Admin(AdminCmd::BackupStatistics) => {
893 let client = self.client_open(&cli).await?;
894
895 let backup_statistics = cli
896 .admin_client(
897 &client.get_peer_urls().await,
898 client.api_secret().as_deref(),
899 )
900 .await?
901 .backup_statistics(cli.auth()?)
902 .await?;
903
904 Ok(CliOutput::Raw(
905 serde_json::to_value(backup_statistics).expect("Can be encoded"),
906 ))
907 }
908 Command::Admin(AdminCmd::ChangePassword { new_password }) => {
909 let client = self.client_open(&cli).await?;
910
911 cli.admin_client(
912 &client.get_peer_urls().await,
913 client.api_secret().as_deref(),
914 )
915 .await?
916 .change_password(cli.auth()?, &new_password)
917 .await?;
918
919 warn!(target: LOG_CLIENT, "Password changed, please restart fedimintd manually");
920
921 Ok(CliOutput::Raw(json!(null)))
922 }
923 Command::Dev(DevCmd::Api {
924 method,
925 params,
926 peer_id,
927 password: auth,
928 module,
929 }) => {
930 let params = serde_json::from_str::<Value>(¶ms).unwrap_or_else(|err| {
933 debug!(
934 target: LOG_CLIENT,
935 "Failed to serialize params:{}. Converting it to JSON string",
936 err
937 );
938
939 serde_json::Value::String(params)
940 });
941
942 let mut params = ApiRequestErased::new(params);
943 if let Some(auth) = auth {
944 params = params.with_auth(ApiAuth::new(auth));
945 }
946 let client = self.client_open(&cli).await?;
947
948 let api = client.api_clone();
949
950 let module_api = match module {
951 Some(selector) => {
952 Some(api.with_module(selector.resolve(&client).map_err_cli()?))
953 }
954 None => None,
955 };
956
957 let response: Value = match (peer_id, module_api) {
958 (Some(peer_id), Some(module_api)) => module_api
959 .request_raw(peer_id.into(), &method, ¶ms)
960 .await
961 .map_err_cli()?,
962 (Some(peer_id), None) => api
963 .request_raw(peer_id.into(), &method, ¶ms)
964 .await
965 .map_err_cli()?,
966 (None, Some(module_api)) => module_api
967 .request_current_consensus(method, params)
968 .await
969 .map_err_cli()?,
970 (None, None) => api
971 .request_current_consensus(method, params)
972 .await
973 .map_err_cli()?,
974 };
975
976 Ok(CliOutput::UntypedApiOutput { value: response })
977 }
978 Command::Dev(DevCmd::AdvanceNoteIdx { count, amount }) => {
979 let client = self.client_open(&cli).await?;
980
981 let mint = client
982 .get_first_module::<MintClientModule>()
983 .map_err_cli_msg("can't get mint module")?;
984
985 for _ in 0..count {
986 mint.advance_note_idx(amount)
987 .await
988 .map_err_cli_msg("failed to advance the note_idx")?;
989 }
990
991 Ok(CliOutput::Raw(serde_json::Value::Null))
992 }
993 Command::Dev(DevCmd::ApiAnnouncements) => {
994 let client = self.client_open(&cli).await?;
995 let announcements = client.get_peer_url_announcements().await;
996 Ok(CliOutput::Raw(
997 serde_json::to_value(announcements).expect("Can be encoded"),
998 ))
999 }
1000 Command::Dev(DevCmd::GuardianMetadata) => {
1001 let client = self.client_open(&cli).await?;
1002 let metadata = client.get_guardian_metadata().await;
1003 Ok(CliOutput::Raw(
1004 serde_json::to_value(metadata).expect("Can be encoded"),
1005 ))
1006 }
1007 Command::Dev(DevCmd::WaitBlockCount { count: target }) => retry(
1008 "wait_block_count",
1009 backoff_util::custom_backoff(
1010 Duration::from_millis(100),
1011 Duration::from_secs(5),
1012 None,
1013 ),
1014 || async {
1015 let client = self.client_open(&cli).await?;
1016 let wallet = client.get_first_module::<WalletClientModule>()?;
1017 let count = client
1018 .api()
1019 .with_module(wallet.id)
1020 .fetch_consensus_block_count()
1021 .await?;
1022 if count >= target {
1023 Ok(CliOutput::WaitBlockCount { reached: count })
1024 } else {
1025 info!(target: LOG_CLIENT, current=count, target, "Block count not reached");
1026 Err(format_err!("target not reached"))
1027 }
1028 },
1029 )
1030 .await
1031 .map_err_cli(),
1032
1033 Command::Dev(DevCmd::WaitComplete) => {
1034 let client = self.client_open(&cli).await?;
1035 client
1036 .wait_for_all_active_state_machines()
1037 .await
1038 .map_err_cli_msg("failed to wait for all active state machines")?;
1039 Ok(CliOutput::Raw(serde_json::Value::Null))
1040 }
1041 Command::Dev(DevCmd::Wait { seconds }) => {
1042 let client = self.client_open(&cli).await?;
1043 client
1047 .task_group()
1048 .spawn_cancellable("fedimint-cli dev wait: init networking", {
1049 let client = client.clone();
1050 async move {
1051 let _ = client.api().session_count().await;
1052 }
1053 });
1054
1055 if let Some(secs) = seconds {
1056 runtime::sleep(Duration::from_secs_f32(secs)).await;
1057 } else {
1058 pending::<()>().await;
1059 }
1060 Ok(CliOutput::Raw(serde_json::Value::Null))
1061 }
1062 Command::Dev(DevCmd::Decode { decode_type }) => match decode_type {
1063 DecodeType::InviteCode { invite_code } => Ok(CliOutput::DecodeInviteCode {
1064 url: invite_code.url(),
1065 federation_id: invite_code.federation_id(),
1066 }),
1067 DecodeType::Notes { notes, file } => {
1068 let notes = if let Some(notes) = notes {
1069 notes
1070 } else if let Some(file) = file {
1071 let notes_str =
1072 fs::read_to_string(file).map_err_cli_msg("failed to read file")?;
1073 OOBNotes::from_str(¬es_str).map_err_cli_msg("failed to decode notes")?
1074 } else {
1075 unreachable!("Clap enforces either notes or file being set");
1076 };
1077
1078 let notes_json = notes
1079 .notes_json()
1080 .map_err_cli_msg("failed to decode notes")?;
1081 Ok(CliOutput::Raw(notes_json))
1082 }
1083 DecodeType::Transaction { hex_string } => {
1084 let bytes: Vec<u8> = hex::FromHex::from_hex(&hex_string)
1085 .map_err_cli_msg("failed to decode transaction")?;
1086
1087 let client = self.client_open(&cli).await?;
1088 let tx = fedimint_core::transaction::Transaction::from_bytes(
1089 &bytes,
1090 client.decoders(),
1091 )
1092 .map_err_cli_msg("failed to decode transaction")?;
1093
1094 Ok(CliOutput::DecodeTransaction {
1095 transaction: (format!("{tx:?}")),
1096 })
1097 }
1098 DecodeType::SetupCode { setup_code } => {
1099 let setup_code = base32::decode_prefixed(FEDIMINT_PREFIX, &setup_code)
1100 .map_err_cli_msg("failed to decode setup code")?;
1101
1102 Ok(CliOutput::SetupCode { setup_code })
1103 }
1104 },
1105 Command::Dev(DevCmd::Encode { encode_type }) => match encode_type {
1106 EncodeType::InviteCode {
1107 url,
1108 federation_id,
1109 peer,
1110 api_secret,
1111 } => Ok(CliOutput::InviteCode {
1112 invite_code: InviteCode::new(url, peer, federation_id, api_secret),
1113 }),
1114 EncodeType::Notes { notes_json } => {
1115 let notes = serde_json::from_str::<OOBNotesJson>(¬es_json)
1116 .map_err_cli_msg("invalid JSON for notes")?;
1117 let prefix =
1118 FederationIdPrefix::from_str(¬es.federation_id_prefix).map_err_cli()?;
1119 let notes = OOBNotes::new(prefix, notes.notes);
1120 Ok(CliOutput::Raw(notes.to_string().into()))
1121 }
1122 },
1123 Command::Dev(DevCmd::SessionCount) => {
1124 let client = self.client_open(&cli).await?;
1125 let count = client.api().session_count().await?;
1126 Ok(CliOutput::EpochCount { count })
1127 }
1128 Command::Dev(DevCmd::Config) => {
1129 let client = self.client_open(&cli).await?;
1130 let config = client.get_config_json().await;
1131 Ok(CliOutput::Raw(
1132 serde_json::to_value(config).expect("Client config is serializable"),
1133 ))
1134 }
1135 Command::Dev(DevCmd::ConfigDecrypt {
1136 in_file,
1137 out_file,
1138 salt_file,
1139 password,
1140 }) => {
1141 let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&in_file));
1142 let salt = fs::read_to_string(salt_file).map_err_cli()?;
1143 let key = get_encryption_key(&password, &salt).map_err_cli()?;
1144 let decrypted_bytes = encrypted_read(&key, in_file).map_err_cli()?;
1145
1146 let mut out_file_handle = fs::File::options()
1147 .create_new(true)
1148 .write(true)
1149 .open(out_file)
1150 .expect("Could not create output cfg file");
1151 out_file_handle.write_all(&decrypted_bytes).map_err_cli()?;
1152 Ok(CliOutput::ConfigDecrypt)
1153 }
1154 Command::Dev(DevCmd::ConfigEncrypt {
1155 in_file,
1156 out_file,
1157 salt_file,
1158 password,
1159 }) => {
1160 let mut in_file_handle =
1161 fs::File::open(in_file).expect("Could not create output cfg file");
1162 let mut plaintext_bytes = vec![];
1163 in_file_handle
1164 .read_to_end(&mut plaintext_bytes)
1165 .expect("Could not read input cfg file");
1166
1167 let salt_file = salt_file.unwrap_or_else(|| salt_from_file_path(&out_file));
1168 let salt = fs::read_to_string(salt_file).map_err_cli()?;
1169 let key = get_encryption_key(&password, &salt).map_err_cli()?;
1170 encrypted_write(plaintext_bytes, &key, out_file).map_err_cli()?;
1171 Ok(CliOutput::ConfigEncrypt)
1172 }
1173 Command::Dev(DevCmd::ListOperationStates { operation_id }) => {
1174 #[derive(Serialize)]
1175 struct ReactorLogState {
1176 active: bool,
1177 module_instance: ModuleInstanceId,
1178 creation_time: String,
1179 #[serde(skip_serializing_if = "Option::is_none")]
1180 end_time: Option<String>,
1181 state: String,
1182 }
1183
1184 let client = self.client_open(&cli).await?;
1185
1186 let (active_states, inactive_states) =
1187 client.executor().get_operation_states(operation_id).await;
1188 let all_states =
1189 active_states
1190 .into_iter()
1191 .map(|(active_state, active_meta)| ReactorLogState {
1192 active: true,
1193 module_instance: active_state.module_instance_id(),
1194 creation_time: crate::client::time_to_iso8601(&active_meta.created_at),
1195 end_time: None,
1196 state: format!("{active_state:?}",),
1197 })
1198 .chain(inactive_states.into_iter().map(
1199 |(inactive_state, inactive_meta)| ReactorLogState {
1200 active: false,
1201 module_instance: inactive_state.module_instance_id(),
1202 creation_time: crate::client::time_to_iso8601(
1203 &inactive_meta.created_at,
1204 ),
1205 end_time: Some(crate::client::time_to_iso8601(
1206 &inactive_meta.exited_at,
1207 )),
1208 state: format!("{inactive_state:?}",),
1209 },
1210 ))
1211 .sorted_by(|a, b| a.creation_time.cmp(&b.creation_time))
1212 .collect::<Vec<_>>();
1213
1214 Ok(CliOutput::Raw(json!({
1215 "states": all_states
1216 })))
1217 }
1218 Command::Dev(DevCmd::MetaFields) => {
1219 let client = self.client_open(&cli).await?;
1220 let source = MetaModuleMetaSourceWithFallback::<LegacyMetaSource>::default();
1221
1222 let meta_fields = source
1223 .fetch(
1224 &client.config().await,
1225 &client.api_clone(),
1226 FetchKind::Initial,
1227 None,
1228 )
1229 .await
1230 .map_err_cli()?;
1231
1232 Ok(CliOutput::Raw(
1233 serde_json::to_value(meta_fields).expect("Can be encoded"),
1234 ))
1235 }
1236 Command::Dev(DevCmd::PeerVersion { peer_id }) => {
1237 let client = self.client_open(&cli).await?;
1238 let version = client
1239 .api()
1240 .fedimintd_version(peer_id.into())
1241 .await
1242 .map_err_cli()?;
1243
1244 Ok(CliOutput::Raw(json!({ "version": version })))
1245 }
1246 Command::Dev(DevCmd::ShowEventLog { pos, limit }) => {
1247 let client = self.client_open(&cli).await?;
1248
1249 let events: Vec<_> = client
1250 .get_event_log(pos, limit)
1251 .await
1252 .into_iter()
1253 .map(|v| {
1254 let id = v.id();
1255 let v = v.as_raw();
1256 let module_id = v.module.as_ref().map(|m| m.1);
1257 let module_kind = v.module.as_ref().map(|m| m.0.clone());
1258 serde_json::json!({
1259 "id": id,
1260 "kind": v.kind,
1261 "module_kind": module_kind,
1262 "module_id": module_id,
1263 "ts": v.ts_usecs,
1264 "payload": serde_json::from_slice(&v.payload).unwrap_or_else(|_| hex::encode(&v.payload)),
1265 })
1266 })
1267 .collect();
1268
1269 Ok(CliOutput::Raw(
1270 serde_json::to_value(events).expect("Can be encoded"),
1271 ))
1272 }
1273 Command::Dev(DevCmd::ShowEventLogTrimable { pos, limit }) => {
1274 let client = self.client_open(&cli).await?;
1275
1276 let events: Vec<_> = client
1277 .get_event_log_trimable(
1278 pos.map(|id| EventLogTrimableId::from(u64::from(id))),
1279 limit,
1280 )
1281 .await
1282 .into_iter()
1283 .map(|v| {
1284 let id = v.id();
1285 let v = v.as_raw();
1286 let module_id = v.module.as_ref().map(|m| m.1);
1287 let module_kind = v.module.as_ref().map(|m| m.0.clone());
1288 serde_json::json!({
1289 "id": id,
1290 "kind": v.kind,
1291 "module_kind": module_kind,
1292 "module_id": module_id,
1293 "ts": v.ts_usecs,
1294 "payload": serde_json::from_slice(&v.payload).unwrap_or_else(|_| hex::encode(&v.payload)),
1295 })
1296 })
1297 .collect();
1298
1299 Ok(CliOutput::Raw(
1300 serde_json::to_value(events).expect("Can be encoded"),
1301 ))
1302 }
1303 Command::Dev(DevCmd::SubmitTransaction { transaction }) => {
1304 let client = self.client_open(&cli).await?;
1305 let tx = Transaction::consensus_decode_hex(&transaction, client.decoders())
1306 .map_err_cli()?;
1307 let tx_outcome = client
1308 .api()
1309 .submit_transaction(tx)
1310 .await
1311 .try_into_inner(client.decoders())
1312 .map_err_cli()?;
1313
1314 Ok(CliOutput::Raw(
1315 serde_json::to_value(tx_outcome.0.map_err_cli()?).expect("Can be encoded"),
1316 ))
1317 }
1318 Command::Dev(DevCmd::TestEventLogHandling) => {
1319 let client = self.client_open(&cli).await?;
1320
1321 client
1322 .handle_events(
1323 client.built_in_application_event_log_tracker(),
1324 move |_dbtx, event| {
1325 Box::pin(async move {
1326 info!(target: LOG_CLIENT, "{event:?}");
1327
1328 Ok(())
1329 })
1330 },
1331 )
1332 .await
1333 .map_err_cli()?;
1334 unreachable!(
1335 "handle_events exits only if client shuts down, which we don't do here"
1336 )
1337 }
1338 Command::Dev(DevCmd::Panic) => {
1339 panic!("This panic is intentional for testing backtrace handling");
1340 }
1341 Command::Dev(DevCmd::ChainId) => {
1342 let client = self.client_open(&cli).await?;
1343 let chain_id = client
1344 .db()
1345 .begin_transaction_nc()
1346 .await
1347 .get_value(&fedimint_client::db::ChainIdKey)
1348 .await
1349 .ok_or_cli_msg("Chain ID not cached in client database")?;
1350
1351 Ok(CliOutput::Raw(serde_json::json!({
1352 "chain_id": chain_id.to_string()
1353 })))
1354 }
1355 Command::Dev(DevCmd::Visualize { visualize_type }) => {
1356 let client = self.client_open(&cli).await?;
1357
1358 match visualize_type {
1359 VisualizeCmd::Notes { limit } => {
1360 visualize::cmd_notes(&client, limit).await?;
1361 }
1362 VisualizeCmd::Transactions {
1363 operation_id,
1364 limit,
1365 } => {
1366 visualize::cmd_transactions(&client, operation_id, limit).await?;
1367 }
1368 VisualizeCmd::Operations {
1369 operation_id,
1370 limit,
1371 } => {
1372 visualize::cmd_operations(&client, operation_id, limit).await?;
1373 }
1374 }
1375 Ok(CliOutput::Raw(json!({})))
1376 }
1377 Command::Completion { shell } => {
1378 let bin_path = PathBuf::from(
1379 std::env::args_os()
1380 .next()
1381 .expect("Binary name is always provided if we get this far"),
1382 );
1383 let bin_name = bin_path
1384 .file_name()
1385 .expect("path has file name")
1386 .to_string_lossy();
1387 clap_complete::generate(
1388 shell,
1389 &mut Opts::command(),
1390 bin_name.as_ref(),
1391 &mut std::io::stdout(),
1392 );
1393 Ok(CliOutput::Raw(serde_json::Value::Bool(true)))
1395 }
1396 }
1397 }
1398
1399 async fn handle_admin_setup_command(
1400 &self,
1401 cli: Opts,
1402 args: SetupAdminArgs,
1403 ) -> anyhow::Result<Value> {
1404 let client =
1405 DynGlobalApi::new_admin_setup(cli.make_endpoints().await?, args.endpoint.clone())?;
1406
1407 match &args.subcommand {
1408 SetupAdminCmd::Status => {
1409 let status = client.setup_status(cli.auth()?).await?;
1410
1411 Ok(serde_json::to_value(status).expect("JSON serialization failed"))
1412 }
1413 SetupAdminCmd::SetLocalParams {
1414 name,
1415 federation_name,
1416 federation_size,
1417 } => {
1418 let info = client
1419 .set_local_params(
1420 name.clone(),
1421 federation_name.clone(),
1422 None,
1423 None,
1424 *federation_size,
1425 cli.auth()?,
1426 )
1427 .await?;
1428
1429 Ok(serde_json::to_value(info).expect("JSON serialization failed"))
1430 }
1431 SetupAdminCmd::AddPeer { info } => {
1432 let name = client
1433 .add_peer_connection_info(info.clone(), cli.auth()?)
1434 .await?;
1435
1436 Ok(serde_json::to_value(name).expect("JSON serialization failed"))
1437 }
1438 SetupAdminCmd::StartDkg => {
1439 client.start_dkg(cli.auth()?).await?;
1440
1441 Ok(Value::Null)
1442 }
1443 }
1444 }
1445}
1446
1447async fn log_expiration_notice(client: &Client) {
1448 client.get_meta_expiration_timestamp().await;
1449 if let Some(expiration_time) = client.get_meta_expiration_timestamp().await {
1450 match expiration_time.duration_since(fedimint_core::time::now()) {
1451 Ok(until_expiration) => {
1452 let days = until_expiration.as_secs() / (60 * 60 * 24);
1453
1454 if 90 < days {
1455 debug!(target: LOG_CLIENT, %days, "This federation will expire");
1456 } else if 30 < days {
1457 info!(target: LOG_CLIENT, %days, "This federation will expire");
1458 } else {
1459 warn!(target: LOG_CLIENT, %days, "This federation will expire soon");
1460 }
1461 }
1462 Err(_) => {
1463 tracing::error!(target: LOG_CLIENT, "This federation has expired and might not be safe to use");
1464 }
1465 }
1466 }
1467}
1468async fn print_welcome_message(client: &Client) {
1469 if let Some(welcome_message) = client
1470 .meta_service()
1471 .get_field::<String>(client.db(), "welcome_message")
1472 .await
1473 .and_then(|v| v.value)
1474 {
1475 eprintln!("{welcome_message}");
1476 }
1477}
1478
1479fn salt_from_file_path(file_path: &Path) -> PathBuf {
1480 file_path
1481 .parent()
1482 .expect("File has no parent?!")
1483 .join(SALT_FILE)
1484}
1485
1486fn metadata_from_clap_cli(metadata: Vec<String>) -> Result<BTreeMap<String, String>, CliError> {
1488 let metadata: BTreeMap<String, String> = metadata
1489 .into_iter()
1490 .map(|item| {
1491 match &item
1492 .splitn(2, '=')
1493 .map(ToString::to_string)
1494 .collect::<Vec<String>>()[..]
1495 {
1496 [] => Err(format_err!("Empty metadata argument not allowed")),
1497 [key] => Err(format_err!("Metadata {key} is missing a value")),
1498 [key, val] => Ok((key.clone(), val.clone())),
1499 [..] => unreachable!(),
1500 }
1501 })
1502 .collect::<anyhow::Result<_>>()
1503 .map_err_cli_msg("invalid metadata")?;
1504 Ok(metadata)
1505}
1506
1507#[test]
1508#[allow(clippy::unwrap_used)]
1509fn metadata_from_clap_cli_test() {
1510 for (args, expected) in [
1511 (
1512 vec!["a=b".to_string()],
1513 BTreeMap::from([("a".into(), "b".into())]),
1514 ),
1515 (
1516 vec!["a=b".to_string(), "c=d".to_string()],
1517 BTreeMap::from([("a".into(), "b".into()), ("c".into(), "d".into())]),
1518 ),
1519 ] {
1520 assert_eq!(metadata_from_clap_cli(args).unwrap(), expected);
1521 }
1522}