1use clap::arg;
2use directories::UserDirs;
3use itertools::Itertools;
4use serde::de::DeserializeOwned;
5use std::{
6 ffi::OsStr,
7 fmt::Display,
8 fs::{self, create_dir_all, OpenOptions},
9 io::{self, Write},
10 path::{Path, PathBuf},
11 str::FromStr,
12};
13use stellar_strkey::{Contract, DecodeError};
14
15use crate::{
16 commands::{global, HEADING_GLOBAL},
17 print::Print,
18 signer::secure_store,
19 utils::find_config_dir,
20 xdr, Pwd,
21};
22
23use super::{
24 alias,
25 key::{self, Key},
26 network::{self, Network},
27 secret::Secret,
28 Config,
29};
30
31#[derive(thiserror::Error, Debug)]
32pub enum Error {
33 #[error(transparent)]
34 TomlSerialize(#[from] toml::ser::Error),
35 #[error("Failed to find home directory")]
36 HomeDirNotFound,
37 #[error("Failed read current directory")]
38 CurrentDirNotFound,
39 #[error("Failed read current directory and no SOROBAN_CONFIG_HOME is set")]
40 NoConfigEnvVar,
41 #[error("Failed to create directory: {path:?}")]
42 DirCreationFailed { path: PathBuf },
43 #[error("Failed to read secret's file: {path}.\nProbably need to use `stellar keys add`")]
44 SecretFileRead { path: PathBuf },
45 #[error("Failed to read network file: {path};\nProbably need to use `stellar network add`")]
46 NetworkFileRead { path: PathBuf },
47 #[error("Failed to read file: {path}")]
48 FileRead { path: PathBuf },
49 #[error(transparent)]
50 Toml(#[from] toml::de::Error),
51 #[error("Secret file failed to deserialize")]
52 Deserialization,
53 #[error("Failed to write identity file:{filepath}: {error}")]
54 IdCreationFailed { filepath: PathBuf, error: io::Error },
55 #[error("Secret file failed to deserialize")]
56 NetworkDeserialization,
57 #[error("Failed to write network file: {0}")]
58 NetworkCreationFailed(std::io::Error),
59 #[error("Error Identity directory is invalid: {name}")]
60 IdentityList { name: String },
61 #[error("Config file failed to serialize")]
64 ConfigSerialization,
65 #[error("XDG_CONFIG_HOME env variable is not a valid path. Got {0}")]
68 XdgConfigHome(String),
69 #[error(transparent)]
70 Io(#[from] std::io::Error),
71 #[error("Failed to remove {0}: {1}")]
72 ConfigRemoval(String, String),
73 #[error("Failed to find config {0} for {1}")]
74 ConfigMissing(String, String),
75 #[error(transparent)]
76 String(#[from] std::string::FromUtf8Error),
77 #[error(transparent)]
78 Secret(#[from] crate::config::secret::Error),
79 #[error(transparent)]
80 Json(#[from] serde_json::Error),
81 #[error("cannot access config dir for alias file")]
82 CannotAccessConfigDir,
83 #[error("cannot access alias config file (no permission or doesn't exist)")]
84 CannotAccessAliasConfigFile,
85 #[error("cannot parse contract ID {0}: {1}")]
86 CannotParseContractId(String, DecodeError),
87 #[error("contract not found: {0}")]
88 ContractNotFound(String),
89 #[error("Failed to read upgrade check file: {path}: {error}")]
90 UpgradeCheckReadFailed { path: PathBuf, error: io::Error },
91 #[error("Failed to write upgrade check file: {path}: {error}")]
92 UpgradeCheckWriteFailed { path: PathBuf, error: io::Error },
93 #[error("Contract alias {0}, cannot overlap with key")]
94 ContractAliasCannotOverlapWithKey(String),
95 #[error("Key cannot {0} cannot overlap with contract alias")]
96 KeyCannotOverlapWithContractAlias(String),
97 #[error(transparent)]
98 SecureStore(#[from] secure_store::Error),
99 #[error("Only private keys and seed phrases are supported for getting private keys {0}")]
100 SecretKeyOnly(String),
101 #[error(transparent)]
102 Key(#[from] key::Error),
103}
104
105#[derive(Debug, clap::Args, Default, Clone)]
106#[group(skip)]
107#[cfg(feature = "version_lt_23")]
108pub struct Args {
109 #[arg(long, global = true, help_heading = HEADING_GLOBAL)]
111 pub global: bool,
112
113 #[arg(long, global = true, help_heading = HEADING_GLOBAL)]
115 pub config_dir: Option<PathBuf>,
116}
117
118#[derive(Debug, clap::Args, Default, Clone)]
119#[group(skip)]
120#[cfg(not(feature = "version_lt_23"))]
121#[cfg(feature = "version_gte_23")]
122pub struct Args {
123 #[arg(long, global = true, help_heading = HEADING_GLOBAL)]
125 pub global: bool,
126
127 #[arg(long, global = true, help_heading = HEADING_GLOBAL)]
130 pub config_dir: Option<PathBuf>,
131}
132
133pub enum Location {
134 Local(PathBuf),
135 Global(PathBuf),
136}
137
138impl Display for Location {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 write!(
141 f,
142 "{} {:?}",
143 match self {
144 Location::Local(_) => "Local",
145 Location::Global(_) => "Global",
146 },
147 self.as_ref()
148 )
149 }
150}
151
152impl AsRef<Path> for Location {
153 fn as_ref(&self) -> &Path {
154 match self {
155 Location::Local(p) | Location::Global(p) => p.as_path(),
156 }
157 }
158}
159
160impl Location {
161 #[must_use]
162 pub fn wrap(&self, p: PathBuf) -> Self {
163 match self {
164 Location::Local(_) => Location::Local(p),
165 Location::Global(_) => Location::Global(p),
166 }
167 }
168}
169
170impl Args {
171 #[cfg(feature = "version_lt_23")]
172 pub fn config_dir(&self) -> Result<PathBuf, Error> {
173 if self.global {
174 self.global_config_path()
175 } else {
176 self.local_config()
177 }
178 }
179
180 #[cfg(not(feature = "version_lt_23"))]
181 #[cfg(feature = "version_gte_23")]
182 pub fn config_dir(&self) -> Result<PathBuf, Error> {
183 if self.global {
184 let print = Print::new(false);
185 print.warnln("Flag --global is deprecated: global config is always used");
186 }
187
188 self.global_config_path()
189 }
190
191 pub fn local_and_global(&self) -> Result<[Location; 2], Error> {
192 Ok([
193 Location::Local(self.local_config()?),
194 Location::Global(self.global_config_path()?),
195 ])
196 }
197
198 pub fn local_config(&self) -> Result<PathBuf, Error> {
199 let pwd = self.current_dir()?;
200 Ok(find_config_dir(pwd.clone()).unwrap_or_else(|_| pwd.join(".stellar")))
201 }
202
203 pub fn current_dir(&self) -> Result<PathBuf, Error> {
204 self.config_dir.as_ref().map_or_else(
205 || std::env::current_dir().map_err(|_| Error::CurrentDirNotFound),
206 |pwd| Ok(pwd.clone()),
207 )
208 }
209
210 pub fn write_identity(&self, name: &str, secret: &Secret) -> Result<PathBuf, Error> {
211 if let Ok(Some(_)) = self.load_contract_from_alias(name) {
212 return Err(Error::KeyCannotOverlapWithContractAlias(name.to_owned()));
213 }
214 KeyType::Identity.write(name, secret, &self.config_dir()?)
215 }
216
217 pub fn write_public_key(
218 &self,
219 name: &str,
220 public_key: &stellar_strkey::ed25519::PublicKey,
221 ) -> Result<PathBuf, Error> {
222 self.write_key(name, &public_key.into())
223 }
224
225 pub fn write_key(&self, name: &str, key: &Key) -> Result<PathBuf, Error> {
226 KeyType::Identity.write(name, key, &self.config_dir()?)
227 }
228
229 pub fn write_network(&self, name: &str, network: &Network) -> Result<PathBuf, Error> {
230 KeyType::Network.write(name, network, &self.config_dir()?)
231 }
232
233 pub fn write_default_network(&self, name: &str) -> Result<(), Error> {
234 Config::new()?.set_network(name).save()
235 }
236
237 pub fn write_default_identity(&self, name: &str) -> Result<(), Error> {
238 Config::new()?.set_identity(name).save()
239 }
240
241 pub fn list_identities(&self) -> Result<Vec<String>, Error> {
242 Ok(KeyType::Identity
243 .list_paths(&self.local_and_global()?)?
244 .into_iter()
245 .map(|(name, _)| name)
246 .collect())
247 }
248
249 pub fn list_identities_long(&self) -> Result<Vec<(String, String)>, Error> {
250 Ok(KeyType::Identity
251 .list_paths(&self.local_and_global()?)
252 .into_iter()
253 .flatten()
254 .map(|(name, location)| {
255 let path = match location {
256 Location::Local(path) | Location::Global(path) => path,
257 };
258 (name, format!("{}", path.display()))
259 })
260 .collect())
261 }
262
263 pub fn list_networks(&self) -> Result<Vec<String>, Error> {
264 let saved_networks = KeyType::Network
265 .list_paths(&self.local_and_global()?)
266 .into_iter()
267 .flatten()
268 .map(|x| x.0);
269 let default_networks = network::DEFAULTS.keys().map(ToString::to_string);
270 Ok(saved_networks.chain(default_networks).unique().collect())
271 }
272
273 pub fn list_networks_long(&self) -> Result<Vec<(String, Network, String)>, Error> {
274 let saved_networks = KeyType::Network
275 .list_paths(&self.local_and_global()?)
276 .into_iter()
277 .flatten()
278 .filter_map(|(name, location)| {
279 Some((
280 name,
281 KeyType::read_from_path::<Network>(location.as_ref()).ok()?,
282 location.to_string(),
283 ))
284 });
285 let default_networks = network::DEFAULTS
286 .into_iter()
287 .map(|(name, network)| ((*name).to_string(), network.into(), "Default".to_owned()));
288 Ok(saved_networks.chain(default_networks).collect())
289 }
290
291 pub fn read_identity(&self, name: &str) -> Result<Key, Error> {
292 KeyType::Identity.read_with_global(name, self)
293 }
294
295 pub fn read_key(&self, key_or_name: &str) -> Result<Key, Error> {
296 key_or_name
297 .parse()
298 .or_else(|_| self.read_identity(key_or_name))
299 }
300
301 pub fn get_secret_key(&self, key_or_name: &str) -> Result<Secret, Error> {
302 match self.read_key(key_or_name)? {
303 Key::Secret(s) => Ok(s),
304 _ => Err(Error::SecretKeyOnly(key_or_name.to_string())),
305 }
306 }
307
308 pub fn get_public_key(
309 &self,
310 key_or_name: &str,
311 hd_path: Option<usize>,
312 ) -> Result<xdr::MuxedAccount, Error> {
313 Ok(self.read_key(key_or_name)?.muxed_account(hd_path)?)
314 }
315
316 pub fn read_network(&self, name: &str) -> Result<Network, Error> {
317 let res = KeyType::Network.read_with_global(name, self);
318 if let Err(Error::ConfigMissing(_, _)) = &res {
319 let Some(network) = network::DEFAULTS.get(name) else {
320 return res;
321 };
322 return Ok(network.into());
323 }
324 res
325 }
326
327 pub fn remove_identity(&self, name: &str, global_args: &global::Args) -> Result<(), Error> {
328 let print = Print::new(global_args.quiet);
329 let identity = self.read_identity(name)?;
330
331 if let Key::Secret(Secret::SecureStore { entry_name }) = identity {
332 secure_store::delete_secret(&print, &entry_name)?;
333 }
334
335 print.infoln("Removing the key's cli config file");
336 KeyType::Identity.remove(name, &self.config_dir()?)
337 }
338
339 pub fn remove_network(&self, name: &str) -> Result<(), Error> {
340 KeyType::Network.remove(name, &self.config_dir()?)
341 }
342
343 #[cfg(feature = "version_lt_23")]
344 fn load_contract_from_alias(&self, alias: &str) -> Result<Option<alias::Data>, Error> {
345 let path = self.alias_path(alias)?;
346
347 if !path.exists() {
348 return Ok(None);
349 }
350
351 let content = fs::read_to_string(path)?;
352 let data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
353
354 Ok(Some(data))
355 }
356
357 #[cfg(not(feature = "version_lt_23"))]
358 #[cfg(feature = "version_gte_23")]
359 fn load_contract_from_alias(&self, alias: &str) -> Result<Option<alias::Data>, Error> {
360 let file_name = format!("{alias}.json");
361 let config_dirs = self.local_and_global()?;
362 let local = &config_dirs[0];
363 let global = &config_dirs[1];
364
365 match local {
366 Location::Local(config_dir) => {
367 let path = config_dir.join("contract-ids").join(&file_name);
368 if path.exists() {
369 print_deprecation_warning(config_dir);
370
371 let content = fs::read_to_string(path)?;
372 let data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
373
374 return Ok(Some(data));
375 }
376 }
377 Location::Global(_) => unreachable!(),
378 }
379
380 match global {
381 Location::Global(config_dir) => {
382 let path = config_dir.join("contract-ids").join(&file_name);
383 if !path.exists() {
384 return Ok(None);
385 }
386
387 let content = fs::read_to_string(path)?;
388 let data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
389
390 Ok(Some(data))
391 }
392 Location::Local(_) => unreachable!(),
393 }
394 }
395
396 fn alias_path(&self, alias: &str) -> Result<PathBuf, Error> {
397 let file_name = format!("{alias}.json");
398 let config_dir = self.config_dir()?;
399 Ok(config_dir.join("contract-ids").join(file_name))
400 }
401
402 pub fn save_contract_id(
403 &self,
404 network_passphrase: &str,
405 contract_id: &stellar_strkey::Contract,
406 alias: &str,
407 ) -> Result<(), Error> {
408 if self.read_identity(alias).is_ok() {
409 return Err(Error::ContractAliasCannotOverlapWithKey(alias.to_owned()));
410 }
411 let path = self.alias_path(alias)?;
412 let dir = path.parent().ok_or(Error::CannotAccessConfigDir)?;
413
414 create_dir_all(dir).map_err(|_| Error::CannotAccessConfigDir)?;
415
416 let content = fs::read_to_string(&path).unwrap_or_default();
417 let mut data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
418
419 let mut to_file = OpenOptions::new()
420 .create(true)
421 .truncate(true)
422 .write(true)
423 .open(path)?;
424
425 data.ids
426 .insert(network_passphrase.into(), contract_id.to_string());
427
428 let content = serde_json::to_string(&data)?;
429
430 Ok(to_file.write_all(content.as_bytes())?)
431 }
432
433 pub fn remove_contract_id(&self, network_passphrase: &str, alias: &str) -> Result<(), Error> {
434 let path = self.alias_path(alias)?;
435
436 if !path.is_file() {
437 return Err(Error::CannotAccessAliasConfigFile);
438 }
439
440 let content = fs::read_to_string(&path).unwrap_or_default();
441 let mut data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
442
443 let mut to_file = OpenOptions::new()
444 .create(true)
445 .truncate(true)
446 .write(true)
447 .open(path)?;
448
449 data.ids.remove::<str>(network_passphrase);
450
451 let content = serde_json::to_string(&data)?;
452
453 Ok(to_file.write_all(content.as_bytes())?)
454 }
455
456 pub fn get_contract_id(
457 &self,
458 alias: &str,
459 network_passphrase: &str,
460 ) -> Result<Option<Contract>, Error> {
461 let Some(alias_data) = self.load_contract_from_alias(alias)? else {
462 return Ok(None);
463 };
464
465 alias_data
466 .ids
467 .get(network_passphrase)
468 .map(|id| id.parse())
469 .transpose()
470 .map_err(|e| Error::CannotParseContractId(alias.to_owned(), e))
471 }
472
473 pub fn resolve_contract_id(
474 &self,
475 alias_or_contract_id: &str,
476 network_passphrase: &str,
477 ) -> Result<Contract, Error> {
478 let Some(contract) = self.get_contract_id(alias_or_contract_id, network_passphrase)? else {
479 return alias_or_contract_id
480 .parse()
481 .map_err(|e| Error::CannotParseContractId(alias_or_contract_id.to_owned(), e));
482 };
483 Ok(contract)
484 }
485
486 pub fn global_config_path(&self) -> Result<PathBuf, Error> {
487 #[cfg(feature = "version_gte_23")]
488 if let Some(config_dir) = &self.config_dir {
489 return Ok(config_dir.clone());
490 }
491
492 global_config_path()
493 }
494}
495
496#[cfg(feature = "version_gte_23")]
497pub fn print_deprecation_warning(dir: &Path) {
498 let print = Print::new(false);
499 let global_dir = global_config_path().expect("Couldn't retrieve global directory.");
500
501 print.warnln(format!("A local config was found at {dir:?}."));
502 print.blankln(" Local config is deprecated and will be removed in the future.".to_string());
503 print.blankln(format!(
504 " Run `stellar config migrate` to move the local config into the global config ({global_dir:?})."
505 ));
506}
507
508impl Pwd for Args {
509 fn set_pwd(&mut self, pwd: &Path) {
510 self.config_dir = Some(pwd.to_path_buf());
511 }
512}
513
514pub fn ensure_directory(dir: PathBuf) -> Result<PathBuf, Error> {
515 let parent = dir.parent().ok_or(Error::HomeDirNotFound)?;
516 std::fs::create_dir_all(parent).map_err(|_| dir_creation_failed(parent))?;
517 Ok(dir)
518}
519
520fn dir_creation_failed(p: &Path) -> Error {
521 Error::DirCreationFailed {
522 path: p.to_path_buf(),
523 }
524}
525
526pub enum KeyType {
527 Identity,
528 Network,
529 ContractIds,
530}
531
532impl Display for KeyType {
533 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
534 write!(
535 f,
536 "{}",
537 match self {
538 KeyType::Identity => "identity",
539 KeyType::Network => "network",
540 KeyType::ContractIds => "contract-ids",
541 }
542 )
543 }
544}
545
546impl KeyType {
547 pub fn read_from_path<T: DeserializeOwned>(path: &Path) -> Result<T, Error> {
548 let data = fs::read_to_string(path).map_err(|_| Error::NetworkFileRead {
549 path: path.to_path_buf(),
550 })?;
551 Ok(toml::from_str(&data)?)
552 }
553
554 pub fn read_with_global<T: DeserializeOwned>(
555 &self,
556 key: &str,
557 locator: &Args,
558 ) -> Result<T, Error> {
559 for location in locator.local_and_global()? {
560 let path = self.path(location.as_ref(), key);
561
562 if let Ok(t) = Self::read_from_path(&path) {
563 #[cfg(feature = "version_gte_23")]
564 if let Location::Local(config_dir) = location {
565 print_deprecation_warning(&config_dir);
566 }
567
568 return Ok(t);
569 }
570 }
571 Err(Error::ConfigMissing(self.to_string(), key.to_string()))
572 }
573
574 pub fn write<T: serde::Serialize>(
575 &self,
576 key: &str,
577 value: &T,
578 pwd: &Path,
579 ) -> Result<PathBuf, Error> {
580 let filepath = ensure_directory(self.path(pwd, key))?;
581 let data = toml::to_string(value).map_err(|_| Error::ConfigSerialization)?;
582 std::fs::write(&filepath, data).map_err(|error| Error::IdCreationFailed {
583 filepath: filepath.clone(),
584 error,
585 })?;
586 Ok(filepath)
587 }
588
589 fn root(&self, pwd: &Path) -> PathBuf {
590 pwd.join(self.to_string())
591 }
592
593 fn path(&self, pwd: &Path, key: &str) -> PathBuf {
594 let mut path = self.root(pwd).join(key);
595 path.set_extension("toml");
596 path
597 }
598
599 pub fn list_paths(&self, paths: &[Location]) -> Result<Vec<(String, Location)>, Error> {
600 Ok(paths
601 .iter()
602 .flat_map(|p| self.list(p, true).unwrap_or_default())
603 .collect())
604 }
605
606 pub fn list_paths_silent(&self, paths: &[Location]) -> Result<Vec<(String, Location)>, Error> {
607 Ok(paths
608 .iter()
609 .flat_map(|p| self.list(p, false).unwrap_or_default())
610 .collect())
611 }
612
613 #[allow(unused_variables)]
614 pub fn list(
615 &self,
616 pwd: &Location,
617 print_warning: bool,
618 ) -> Result<Vec<(String, Location)>, Error> {
619 let path = self.root(pwd.as_ref());
620 if path.exists() {
621 let mut files = self.read_dir(&path)?;
622 files.sort();
623
624 #[cfg(feature = "version_gte_23")]
625 if let Location::Local(config_dir) = pwd {
626 if files.len() > 1 && print_warning {
627 print_deprecation_warning(config_dir);
628 }
629 }
630
631 Ok(files
632 .into_iter()
633 .map(|(name, p)| (name, pwd.wrap(p)))
634 .collect())
635 } else {
636 Ok(vec![])
637 }
638 }
639
640 fn read_dir(&self, dir: &Path) -> Result<Vec<(String, PathBuf)>, Error> {
641 let contents = std::fs::read_dir(dir)?;
642 let mut res = vec![];
643 for entry in contents.filter_map(Result::ok) {
644 let path = entry.path();
645 let extension = match self {
646 KeyType::Identity | KeyType::Network => "toml",
647 KeyType::ContractIds => "json",
648 };
649 if let Some(ext) = path.extension().and_then(OsStr::to_str) {
650 if ext == extension {
651 if let Some(os_str) = path.file_stem() {
652 res.push((os_str.to_string_lossy().trim().to_string(), path));
653 }
654 }
655 }
656 }
657 res.sort();
658 Ok(res)
659 }
660
661 pub fn remove(&self, key: &str, pwd: &Path) -> Result<(), Error> {
662 let path = self.path(pwd, key);
663
664 if path.exists() {
665 std::fs::remove_file(&path)
666 .map_err(|_| Error::ConfigRemoval(self.to_string(), key.to_string()))
667 } else {
668 Ok(())
669 }
670 }
671}
672
673fn global_config_path() -> Result<PathBuf, Error> {
674 let config_dir = if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
675 PathBuf::from_str(&config_home).map_err(|_| Error::XdgConfigHome(config_home))?
676 } else {
677 UserDirs::new()
678 .ok_or(Error::HomeDirNotFound)?
679 .home_dir()
680 .join(".config")
681 };
682
683 let soroban_dir = config_dir.join("soroban");
684 let stellar_dir = config_dir.join("stellar");
685 let soroban_exists = soroban_dir.exists();
686 let stellar_exists = stellar_dir.exists();
687
688 if stellar_exists && soroban_exists {
689 tracing::warn!("the .stellar and .soroban config directories exist at path {config_dir:?}, using the .stellar");
690 }
691
692 if stellar_exists {
693 return Ok(stellar_dir);
694 }
695
696 if soroban_exists {
697 return Ok(soroban_dir);
698 }
699
700 Ok(stellar_dir)
701}
702
703pub fn cli_config_file() -> Result<PathBuf, Error> {
706 Ok(global_config_path()?.join("config.toml"))
707}