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)]
107pub struct Args {
108 #[arg(long, global = true, help_heading = HEADING_GLOBAL)]
110 pub global: bool,
111
112 #[arg(long, global = true, help_heading = HEADING_GLOBAL)]
115 pub config_dir: Option<PathBuf>,
116}
117
118pub enum Location {
119 Local(PathBuf),
120 Global(PathBuf),
121}
122
123impl Display for Location {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 write!(
126 f,
127 "{} {:?}",
128 match self {
129 Location::Local(_) => "Local",
130 Location::Global(_) => "Global",
131 },
132 self.as_ref()
133 )
134 }
135}
136
137impl AsRef<Path> for Location {
138 fn as_ref(&self) -> &Path {
139 match self {
140 Location::Local(p) | Location::Global(p) => p.as_path(),
141 }
142 }
143}
144
145impl Location {
146 #[must_use]
147 pub fn wrap(&self, p: PathBuf) -> Self {
148 match self {
149 Location::Local(_) => Location::Local(p),
150 Location::Global(_) => Location::Global(p),
151 }
152 }
153}
154
155impl Args {
156 pub fn config_dir(&self) -> Result<PathBuf, Error> {
157 if self.global {
158 let print = Print::new(false);
159 print.warnln("Flag --global is deprecated: global config is always used");
160 }
161
162 self.global_config_path()
163 }
164
165 pub fn local_and_global(&self) -> Result<[Location; 2], Error> {
166 Ok([
167 Location::Local(self.local_config()?),
168 Location::Global(self.global_config_path()?),
169 ])
170 }
171
172 pub fn local_config(&self) -> Result<PathBuf, Error> {
173 let pwd = self.current_dir()?;
174 Ok(find_config_dir(pwd.clone()).unwrap_or_else(|_| pwd.join(".stellar")))
175 }
176
177 pub fn current_dir(&self) -> Result<PathBuf, Error> {
178 self.config_dir.as_ref().map_or_else(
179 || std::env::current_dir().map_err(|_| Error::CurrentDirNotFound),
180 |pwd| Ok(pwd.clone()),
181 )
182 }
183
184 pub fn write_identity(&self, name: &str, secret: &Secret) -> Result<PathBuf, Error> {
185 if let Ok(Some(_)) = self.load_contract_from_alias(name) {
186 return Err(Error::KeyCannotOverlapWithContractAlias(name.to_owned()));
187 }
188 KeyType::Identity.write(name, secret, &self.config_dir()?)
189 }
190
191 pub fn write_public_key(
192 &self,
193 name: &str,
194 public_key: &stellar_strkey::ed25519::PublicKey,
195 ) -> Result<PathBuf, Error> {
196 self.write_key(name, &public_key.into())
197 }
198
199 pub fn write_key(&self, name: &str, key: &Key) -> Result<PathBuf, Error> {
200 KeyType::Identity.write(name, key, &self.config_dir()?)
201 }
202
203 pub fn write_network(&self, name: &str, network: &Network) -> Result<PathBuf, Error> {
204 KeyType::Network.write(name, network, &self.config_dir()?)
205 }
206
207 pub fn write_default_network(&self, name: &str) -> Result<(), Error> {
208 Config::new()?.set_network(name).save()
209 }
210
211 pub fn write_default_identity(&self, name: &str) -> Result<(), Error> {
212 Config::new()?.set_identity(name).save()
213 }
214
215 pub fn list_identities(&self) -> Result<Vec<String>, Error> {
216 Ok(KeyType::Identity
217 .list_paths(&self.local_and_global()?)?
218 .into_iter()
219 .map(|(name, _)| name)
220 .collect())
221 }
222
223 pub fn list_identities_long(&self) -> Result<Vec<(String, String)>, Error> {
224 Ok(KeyType::Identity
225 .list_paths(&self.local_and_global()?)
226 .into_iter()
227 .flatten()
228 .map(|(name, location)| {
229 let path = match location {
230 Location::Local(path) | Location::Global(path) => path,
231 };
232 (name, format!("{}", path.display()))
233 })
234 .collect())
235 }
236
237 pub fn list_networks(&self) -> Result<Vec<String>, Error> {
238 let saved_networks = KeyType::Network
239 .list_paths(&self.local_and_global()?)
240 .into_iter()
241 .flatten()
242 .map(|x| x.0);
243 let default_networks = network::DEFAULTS.keys().map(ToString::to_string);
244 Ok(saved_networks.chain(default_networks).unique().collect())
245 }
246
247 pub fn list_networks_long(&self) -> Result<Vec<(String, Network, String)>, Error> {
248 let saved_networks = KeyType::Network
249 .list_paths(&self.local_and_global()?)
250 .into_iter()
251 .flatten()
252 .filter_map(|(name, location)| {
253 Some((
254 name,
255 KeyType::read_from_path::<Network>(location.as_ref()).ok()?,
256 location.to_string(),
257 ))
258 });
259 let default_networks = network::DEFAULTS
260 .into_iter()
261 .map(|(name, network)| ((*name).to_string(), network.into(), "Default".to_owned()));
262 Ok(saved_networks.chain(default_networks).collect())
263 }
264
265 pub fn read_identity(&self, name: &str) -> Result<Key, Error> {
266 KeyType::Identity.read_with_global(name, self)
267 }
268
269 pub fn read_key(&self, key_or_name: &str) -> Result<Key, Error> {
270 key_or_name
271 .parse()
272 .or_else(|_| self.read_identity(key_or_name))
273 }
274
275 pub fn get_secret_key(&self, key_or_name: &str) -> Result<Secret, Error> {
276 match self.read_key(key_or_name)? {
277 Key::Secret(s) => Ok(s),
278 _ => Err(Error::SecretKeyOnly(key_or_name.to_string())),
279 }
280 }
281
282 pub fn get_public_key(
283 &self,
284 key_or_name: &str,
285 hd_path: Option<usize>,
286 ) -> Result<xdr::MuxedAccount, Error> {
287 Ok(self.read_key(key_or_name)?.muxed_account(hd_path)?)
288 }
289
290 pub fn read_network(&self, name: &str) -> Result<Network, Error> {
291 let res = KeyType::Network.read_with_global(name, self);
292 if let Err(Error::ConfigMissing(_, _)) = &res {
293 let Some(network) = network::DEFAULTS.get(name) else {
294 return res;
295 };
296 return Ok(network.into());
297 }
298 res
299 }
300
301 pub fn remove_identity(&self, name: &str, global_args: &global::Args) -> Result<(), Error> {
302 let print = Print::new(global_args.quiet);
303 let identity = self.read_identity(name)?;
304
305 if let Key::Secret(Secret::SecureStore { entry_name }) = identity {
306 secure_store::delete_secret(&print, &entry_name)?;
307 }
308
309 print.infoln("Removing the key's cli config file");
310 KeyType::Identity.remove(name, &self.config_dir()?)
311 }
312
313 pub fn remove_network(&self, name: &str) -> Result<(), Error> {
314 KeyType::Network.remove(name, &self.config_dir()?)
315 }
316
317 fn load_contract_from_alias(&self, alias: &str) -> Result<Option<alias::Data>, Error> {
318 let file_name = format!("{alias}.json");
319 let config_dirs = self.local_and_global()?;
320 let local = &config_dirs[0];
321 let global = &config_dirs[1];
322
323 match local {
324 Location::Local(config_dir) => {
325 let path = config_dir.join("contract-ids").join(&file_name);
326 if path.exists() {
327 print_deprecation_warning(config_dir);
328
329 let content = fs::read_to_string(path)?;
330 let data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
331
332 return Ok(Some(data));
333 }
334 }
335 Location::Global(_) => unreachable!(),
336 }
337
338 match global {
339 Location::Global(config_dir) => {
340 let path = config_dir.join("contract-ids").join(&file_name);
341 if !path.exists() {
342 return Ok(None);
343 }
344
345 let content = fs::read_to_string(path)?;
346 let data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
347
348 Ok(Some(data))
349 }
350 Location::Local(_) => unreachable!(),
351 }
352 }
353
354 fn alias_path(&self, alias: &str) -> Result<PathBuf, Error> {
355 let file_name = format!("{alias}.json");
356 let config_dir = self.config_dir()?;
357 Ok(config_dir.join("contract-ids").join(file_name))
358 }
359
360 pub fn save_contract_id(
361 &self,
362 network_passphrase: &str,
363 contract_id: &stellar_strkey::Contract,
364 alias: &str,
365 ) -> Result<(), Error> {
366 if self.read_identity(alias).is_ok() {
367 return Err(Error::ContractAliasCannotOverlapWithKey(alias.to_owned()));
368 }
369 let path = self.alias_path(alias)?;
370 let dir = path.parent().ok_or(Error::CannotAccessConfigDir)?;
371
372 create_dir_all(dir).map_err(|_| Error::CannotAccessConfigDir)?;
373
374 let content = fs::read_to_string(&path).unwrap_or_default();
375 let mut data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
376
377 let mut to_file = OpenOptions::new()
378 .create(true)
379 .truncate(true)
380 .write(true)
381 .open(path)?;
382
383 data.ids
384 .insert(network_passphrase.into(), contract_id.to_string());
385
386 let content = serde_json::to_string(&data)?;
387
388 Ok(to_file.write_all(content.as_bytes())?)
389 }
390
391 pub fn remove_contract_id(&self, network_passphrase: &str, alias: &str) -> Result<(), Error> {
392 let path = self.alias_path(alias)?;
393
394 if !path.is_file() {
395 return Err(Error::CannotAccessAliasConfigFile);
396 }
397
398 let content = fs::read_to_string(&path).unwrap_or_default();
399 let mut data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
400
401 let mut to_file = OpenOptions::new()
402 .create(true)
403 .truncate(true)
404 .write(true)
405 .open(path)?;
406
407 data.ids.remove::<str>(network_passphrase);
408
409 let content = serde_json::to_string(&data)?;
410
411 Ok(to_file.write_all(content.as_bytes())?)
412 }
413
414 pub fn get_contract_id(
415 &self,
416 alias: &str,
417 network_passphrase: &str,
418 ) -> Result<Option<Contract>, Error> {
419 let Some(alias_data) = self.load_contract_from_alias(alias)? else {
420 return Ok(None);
421 };
422
423 alias_data
424 .ids
425 .get(network_passphrase)
426 .map(|id| id.parse())
427 .transpose()
428 .map_err(|e| Error::CannotParseContractId(alias.to_owned(), e))
429 }
430
431 pub fn resolve_contract_id(
432 &self,
433 alias_or_contract_id: &str,
434 network_passphrase: &str,
435 ) -> Result<Contract, Error> {
436 let Some(contract) = self.get_contract_id(alias_or_contract_id, network_passphrase)? else {
437 return alias_or_contract_id
438 .parse()
439 .map_err(|e| Error::CannotParseContractId(alias_or_contract_id.to_owned(), e));
440 };
441 Ok(contract)
442 }
443
444 pub fn global_config_path(&self) -> Result<PathBuf, Error> {
445 if let Some(config_dir) = &self.config_dir {
446 return Ok(config_dir.clone());
447 }
448
449 global_config_path()
450 }
451}
452
453pub fn print_deprecation_warning(dir: &Path) {
454 let print = Print::new(false);
455 let global_dir = global_config_path().expect("Couldn't retrieve global directory.");
456
457 print.warnln(format!("A local config was found at {dir:?}."));
458 print.blankln(" Local config is deprecated and will be removed in the future.".to_string());
459 print.blankln(format!(
460 " Run `stellar config migrate` to move the local config into the global config ({global_dir:?})."
461 ));
462}
463
464impl Pwd for Args {
465 fn set_pwd(&mut self, pwd: &Path) {
466 self.config_dir = Some(pwd.to_path_buf());
467 }
468}
469
470pub fn ensure_directory(dir: PathBuf) -> Result<PathBuf, Error> {
471 let parent = dir.parent().ok_or(Error::HomeDirNotFound)?;
472 std::fs::create_dir_all(parent).map_err(|_| dir_creation_failed(parent))?;
473 Ok(dir)
474}
475
476fn dir_creation_failed(p: &Path) -> Error {
477 Error::DirCreationFailed {
478 path: p.to_path_buf(),
479 }
480}
481
482pub enum KeyType {
483 Identity,
484 Network,
485 ContractIds,
486}
487
488impl Display for KeyType {
489 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
490 write!(
491 f,
492 "{}",
493 match self {
494 KeyType::Identity => "identity",
495 KeyType::Network => "network",
496 KeyType::ContractIds => "contract-ids",
497 }
498 )
499 }
500}
501
502impl KeyType {
503 pub fn read_from_path<T: DeserializeOwned>(path: &Path) -> Result<T, Error> {
504 let data = fs::read_to_string(path).map_err(|_| Error::NetworkFileRead {
505 path: path.to_path_buf(),
506 })?;
507 Ok(toml::from_str(&data)?)
508 }
509
510 pub fn read_with_global<T: DeserializeOwned>(
511 &self,
512 key: &str,
513 locator: &Args,
514 ) -> Result<T, Error> {
515 for location in locator.local_and_global()? {
516 let path = self.path(location.as_ref(), key);
517
518 if let Ok(t) = Self::read_from_path(&path) {
519 if let Location::Local(config_dir) = location {
520 print_deprecation_warning(&config_dir);
521 }
522
523 return Ok(t);
524 }
525 }
526 Err(Error::ConfigMissing(self.to_string(), key.to_string()))
527 }
528
529 pub fn write<T: serde::Serialize>(
530 &self,
531 key: &str,
532 value: &T,
533 pwd: &Path,
534 ) -> Result<PathBuf, Error> {
535 let filepath = ensure_directory(self.path(pwd, key))?;
536 let data = toml::to_string(value).map_err(|_| Error::ConfigSerialization)?;
537 std::fs::write(&filepath, data).map_err(|error| Error::IdCreationFailed {
538 filepath: filepath.clone(),
539 error,
540 })?;
541 Ok(filepath)
542 }
543
544 fn root(&self, pwd: &Path) -> PathBuf {
545 pwd.join(self.to_string())
546 }
547
548 fn path(&self, pwd: &Path, key: &str) -> PathBuf {
549 let mut path = self.root(pwd).join(key);
550 path.set_extension("toml");
551 path
552 }
553
554 pub fn list_paths(&self, paths: &[Location]) -> Result<Vec<(String, Location)>, Error> {
555 Ok(paths
556 .iter()
557 .flat_map(|p| self.list(p, true).unwrap_or_default())
558 .collect())
559 }
560
561 pub fn list_paths_silent(&self, paths: &[Location]) -> Result<Vec<(String, Location)>, Error> {
562 Ok(paths
563 .iter()
564 .flat_map(|p| self.list(p, false).unwrap_or_default())
565 .collect())
566 }
567
568 #[allow(unused_variables)]
569 pub fn list(
570 &self,
571 pwd: &Location,
572 print_warning: bool,
573 ) -> Result<Vec<(String, Location)>, Error> {
574 let path = self.root(pwd.as_ref());
575 if path.exists() {
576 let mut files = self.read_dir(&path)?;
577 files.sort();
578
579 if let Location::Local(config_dir) = pwd {
580 if files.len() > 1 && print_warning {
581 print_deprecation_warning(config_dir);
582 }
583 }
584
585 Ok(files
586 .into_iter()
587 .map(|(name, p)| (name, pwd.wrap(p)))
588 .collect())
589 } else {
590 Ok(vec![])
591 }
592 }
593
594 fn read_dir(&self, dir: &Path) -> Result<Vec<(String, PathBuf)>, Error> {
595 let contents = std::fs::read_dir(dir)?;
596 let mut res = vec![];
597 for entry in contents.filter_map(Result::ok) {
598 let path = entry.path();
599 let extension = match self {
600 KeyType::Identity | KeyType::Network => "toml",
601 KeyType::ContractIds => "json",
602 };
603 if let Some(ext) = path.extension().and_then(OsStr::to_str) {
604 if ext == extension {
605 if let Some(os_str) = path.file_stem() {
606 res.push((os_str.to_string_lossy().trim().to_string(), path));
607 }
608 }
609 }
610 }
611 res.sort();
612 Ok(res)
613 }
614
615 pub fn remove(&self, key: &str, pwd: &Path) -> Result<(), Error> {
616 let path = self.path(pwd, key);
617
618 if path.exists() {
619 std::fs::remove_file(&path)
620 .map_err(|_| Error::ConfigRemoval(self.to_string(), key.to_string()))
621 } else {
622 Ok(())
623 }
624 }
625}
626
627fn global_config_path() -> Result<PathBuf, Error> {
628 let config_dir = if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
629 PathBuf::from_str(&config_home).map_err(|_| Error::XdgConfigHome(config_home))?
630 } else {
631 UserDirs::new()
632 .ok_or(Error::HomeDirNotFound)?
633 .home_dir()
634 .join(".config")
635 };
636
637 let soroban_dir = config_dir.join("soroban");
638 let stellar_dir = config_dir.join("stellar");
639 let soroban_exists = soroban_dir.exists();
640 let stellar_exists = stellar_dir.exists();
641
642 if stellar_exists && soroban_exists {
643 tracing::warn!("the .stellar and .soroban config directories exist at path {config_dir:?}, using the .stellar");
644 }
645
646 if stellar_exists {
647 return Ok(stellar_dir);
648 }
649
650 if soroban_exists {
651 return Ok(soroban_dir);
652 }
653
654 Ok(stellar_dir)
655}
656
657pub fn cli_config_file() -> Result<PathBuf, Error> {
660 Ok(global_config_path()?.join("config.toml"))
661}