1use directories::UserDirs;
2use itertools::Itertools;
3use serde::de::DeserializeOwned;
4use std::{
5 ffi::OsStr,
6 fmt::Display,
7 fs, io,
8 path::{Path, PathBuf},
9 str::FromStr,
10};
11use stellar_strkey::{Contract, DecodeError};
12
13use crate::{
14 commands::{global, HEADING_GLOBAL},
15 print::Print,
16 signer::secure_store,
17 utils::find_config_dir,
18 xdr, Pwd,
19};
20
21use super::{
22 alias,
23 key::{self, Key},
24 network::{self, Network},
25 secret::Secret,
26 utils, Config,
27};
28
29#[derive(thiserror::Error, Debug)]
30pub enum Error {
31 #[error(transparent)]
32 TomlSerialize(#[from] toml::ser::Error),
33 #[error("Failed to find home directory")]
34 HomeDirNotFound,
35 #[error("Failed read current directory")]
36 CurrentDirNotFound,
37 #[error("Failed read current directory and no STELLAR_CONFIG_HOME is set")]
38 NoConfigEnvVar,
39 #[error("Failed to create directory: {path:?}")]
40 DirCreationFailed { path: PathBuf },
41 #[error("Failed to read secret's file: {path}.\nProbably need to use `stellar keys add`")]
42 SecretFileRead { path: PathBuf },
43 #[error("Failed to read network file: {path};\nProbably need to use `stellar network add`")]
44 NetworkFileRead { path: PathBuf },
45 #[error("Failed to read file: {path}")]
46 FileRead { path: PathBuf },
47 #[error(transparent)]
48 Toml(#[from] toml::de::Error),
49 #[error("Secret file failed to deserialize")]
50 Deserialization,
51 #[error("Failed to write identity file:{filepath}: {error}")]
52 IdCreationFailed { filepath: PathBuf, error: io::Error },
53 #[error("Secret file failed to deserialize")]
54 NetworkDeserialization,
55 #[error("Failed to write network file: {0}")]
56 NetworkCreationFailed(std::io::Error),
57 #[error("Error Identity directory is invalid: {name}")]
58 IdentityList { name: String },
59 #[error("Config file failed to serialize")]
62 ConfigSerialization,
63 #[error("STELLAR_CONFIG_HOME env variable is not a valid path. Got {0}")]
66 StellarConfigDir(String),
67 #[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}{hint}", hint = wasm_hash_hint(.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 #[error("Unable to get project directory")]
104 ProjectDirsError(),
105 #[error(transparent)]
106 InvalidName(#[from] utils::Error),
107 #[error("invalid signing key or identity name")]
108 InvalidSigningKey,
109}
110
111fn wasm_hash_hint(value: &str) -> &'static str {
112 if value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) {
113 "; expected a contract address (C...), got a hash"
114 } else {
115 ""
116 }
117}
118
119#[derive(Debug, clap::Args, Default, Clone)]
120#[group(skip)]
121pub struct Args {
122 #[arg(long, global = true, help_heading = HEADING_GLOBAL)]
125 pub config_dir: Option<PathBuf>,
126}
127
128#[derive(Clone)]
129pub enum Location {
130 Local(PathBuf),
131 Global(PathBuf),
132}
133
134impl Display for Location {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 write!(
137 f,
138 "{} {:?}",
139 match self {
140 Location::Local(_) => "Local",
141 Location::Global(_) => "Global",
142 },
143 self.as_ref()
144 )
145 }
146}
147
148impl AsRef<Path> for Location {
149 fn as_ref(&self) -> &Path {
150 match self {
151 Location::Local(p) | Location::Global(p) => p.as_path(),
152 }
153 }
154}
155
156impl Location {
157 #[must_use]
158 pub fn wrap(&self, p: PathBuf) -> Self {
159 match self {
160 Location::Local(_) => Location::Local(p),
161 Location::Global(_) => Location::Global(p),
162 }
163 }
164}
165
166impl Args {
167 pub fn config_dir(&self) -> Result<PathBuf, Error> {
168 self.global_config_path()
169 }
170
171 pub fn local_and_global(&self) -> Result<[Location; 2], Error> {
172 Ok([
173 Location::Local(self.local_config()?),
174 Location::Global(self.global_config_path()?),
175 ])
176 }
177
178 pub fn local_config(&self) -> Result<PathBuf, Error> {
179 let pwd = std::env::current_dir().map_err(|_| Error::CurrentDirNotFound)?;
183 Ok(find_config_dir(pwd.clone()).unwrap_or_else(|_| pwd.join(".stellar")))
184 }
185
186 pub fn current_dir(&self) -> Result<PathBuf, Error> {
187 self.config_dir.as_ref().map_or_else(
188 || std::env::current_dir().map_err(|_| Error::CurrentDirNotFound),
189 |pwd| Ok(pwd.clone()),
190 )
191 }
192
193 pub fn write_identity(&self, name: &str, secret: &Secret) -> Result<PathBuf, Error> {
194 if let Ok(Some(_)) = self.load_contract_from_alias(name) {
195 return Err(Error::KeyCannotOverlapWithContractAlias(name.to_owned()));
196 }
197 KeyType::Identity.write(name, secret, &self.config_dir()?)
198 }
199
200 pub fn write_public_key(
201 &self,
202 name: &str,
203 public_key: &stellar_strkey::ed25519::PublicKey,
204 ) -> Result<PathBuf, Error> {
205 self.write_key(name, &public_key.into())
206 }
207
208 pub fn write_key(&self, name: &str, key: &Key) -> Result<PathBuf, Error> {
209 KeyType::Identity.write(name, key, &self.config_dir()?)
210 }
211
212 pub fn write_network(&self, name: &str, network: &Network) -> Result<PathBuf, Error> {
213 KeyType::Network.write(name, network, &self.config_dir()?)
214 }
215
216 pub fn write_default_network(&self, name: &str) -> Result<(), Error> {
217 let path = self.global_config_path()?.join("config.toml");
218 Config::load(&path)?.set_network(name).save_to(&path)
219 }
220
221 pub fn write_default_identity(&self, name: &str) -> Result<(), Error> {
222 let path = self.global_config_path()?.join("config.toml");
223 Config::load(&path)?.set_identity(name).save_to(&path)
224 }
225
226 pub fn write_default_inclusion_fee(&self, inclusion_fee: u32) -> Result<(), Error> {
227 let path = self.global_config_path()?.join("config.toml");
228 Config::load(&path)?
229 .set_inclusion_fee(inclusion_fee)
230 .save_to(&path)
231 }
232
233 pub fn unset_default_identity(&self) -> Result<(), Error> {
234 let path = self.global_config_path()?.join("config.toml");
235 Config::load(&path)?.unset_identity().save_to(&path)
236 }
237
238 pub fn unset_default_network(&self) -> Result<(), Error> {
239 let path = self.global_config_path()?.join("config.toml");
240 Config::load(&path)?.unset_network().save_to(&path)
241 }
242
243 pub fn unset_default_inclusion_fee(&self) -> Result<(), Error> {
244 let path = self.global_config_path()?.join("config.toml");
245 Config::load(&path)?.unset_inclusion_fee().save_to(&path)
246 }
247
248 pub fn list_identities(&self) -> Result<Vec<String>, Error> {
249 Ok(KeyType::Identity
250 .list_paths(&self.local_and_global()?)?
251 .into_iter()
252 .map(|(name, _)| name)
253 .collect())
254 }
255
256 pub fn list_identities_long(&self) -> Result<Vec<(String, String)>, Error> {
257 Ok(KeyType::Identity
258 .list_paths(&self.local_and_global()?)
259 .into_iter()
260 .flatten()
261 .map(|(name, location)| {
262 let path = match location {
263 Location::Local(path) | Location::Global(path) => path,
264 };
265 (name, format!("{}", path.display()))
266 })
267 .collect())
268 }
269
270 pub fn list_networks(&self) -> Result<Vec<String>, Error> {
271 let saved_networks = KeyType::Network
272 .list_paths(&self.local_and_global()?)
273 .into_iter()
274 .flatten()
275 .map(|x| x.0);
276 let default_networks = network::DEFAULTS.keys().map(ToString::to_string);
277 Ok(saved_networks.chain(default_networks).unique().collect())
278 }
279
280 pub fn list_networks_long(&self) -> Result<Vec<(String, Network, String)>, Error> {
281 let saved_networks = KeyType::Network
282 .list_paths(&self.local_and_global()?)
283 .into_iter()
284 .flatten()
285 .filter_map(|(name, location)| {
286 Some((
287 name,
288 KeyType::read_from_path::<Network>(location.as_ref()).ok()?,
289 location.to_string(),
290 ))
291 });
292 let default_networks = network::DEFAULTS
293 .into_iter()
294 .map(|(name, network)| ((*name).to_string(), network.into(), "Default".to_owned()));
295 Ok(saved_networks.chain(default_networks).collect())
296 }
297
298 pub fn read_identity(&self, name: &str) -> Result<Key, Error> {
299 utils::validate_name(name)?;
300 KeyType::Identity.read_with_global(name, self)
301 }
302
303 pub fn read_key(&self, key_or_name: &str) -> Result<Key, Error> {
304 key_or_name
305 .parse()
306 .or_else(|_| self.read_identity(key_or_name))
307 }
308
309 pub fn read_key_with_secure_store_cache(
313 &self,
314 key_or_name: &str,
315 hd_path: Option<u32>,
316 ) -> Result<Key, Error> {
317 if let Ok(literal) = key_or_name.parse::<Key>() {
318 return Ok(literal);
319 }
320 let key = self.read_identity(key_or_name)?;
321 if let Key::Secret(Secret::SecureStore {
322 entry_name,
323 public_key: None,
324 hd_path: persisted_hd_path,
325 }) = &key
326 {
327 let effective = hd_path.or(*persisted_hd_path);
332 let pk = secure_store::get_public_key(entry_name, effective)?;
333 let migrated = Key::Secret(Secret::SecureStore {
334 entry_name: entry_name.clone(),
335 public_key: Some(format!("{pk}")),
336 hd_path: effective,
337 });
338 let _ = self.write_key(key_or_name, &migrated);
341 return Ok(migrated);
342 }
343 Ok(key)
344 }
345
346 pub fn get_secret_key(&self, key_or_name: &str) -> Result<Secret, Error> {
347 let key = self.read_key(key_or_name).map_err(|e| match e {
348 Error::InvalidName(_) | Error::ConfigMissing(_, _) => Error::InvalidSigningKey,
349 other => other,
350 })?;
351 match key {
352 Key::Secret(s) => Ok(s),
353 _ => Err(Error::InvalidSigningKey),
354 }
355 }
356
357 pub fn get_secret_key_with_hd_path(
362 &self,
363 key_or_name: &str,
364 hd_path: Option<u32>,
365 ) -> Result<Secret, Error> {
366 let key = self
367 .read_key_with_secure_store_cache(key_or_name, hd_path)
368 .map_err(|e| match e {
369 Error::InvalidName(_) | Error::ConfigMissing(_, _) => Error::InvalidSigningKey,
370 other => other,
371 })?;
372 match key {
373 Key::Secret(s) => Ok(s),
374 _ => Err(Error::InvalidSigningKey),
375 }
376 }
377
378 pub fn get_public_key(
379 &self,
380 key_or_name: &str,
381 hd_path: Option<u32>,
382 ) -> Result<xdr::MuxedAccount, Error> {
383 Ok(self.read_key(key_or_name)?.muxed_account(hd_path)?)
384 }
385
386 pub fn read_network(&self, name: &str) -> Result<Network, Error> {
387 utils::validate_name(name)?;
388 let res = KeyType::Network.read_with_global(name, self);
389 if let Err(Error::ConfigMissing(_, _)) = &res {
390 let Some(network) = network::DEFAULTS.get(name) else {
391 return res;
392 };
393 return Ok(network.into());
394 }
395 res
396 }
397
398 pub fn remove_identity(&self, name: &str, global_args: &global::Args) -> Result<(), Error> {
399 let print = Print::new(global_args.quiet);
400 let identity = self.read_identity(name)?;
401
402 if let Key::Secret(Secret::SecureStore { entry_name, .. }) = identity {
403 secure_store::delete_secret(&print, &entry_name)?;
404 }
405
406 print.infoln("Removing the key's cli config file");
407 KeyType::Identity.remove(name, &self.config_dir()?)
408 }
409
410 pub fn remove_network(&self, name: &str) -> Result<(), Error> {
411 KeyType::Network.remove(name, &self.config_dir()?)
412 }
413
414 fn load_contract_from_alias(&self, alias: &str) -> Result<Option<alias::Data>, Error> {
415 utils::validate_name(alias)?;
416 let file_name = format!("{alias}.json");
417 let config_dirs = self.local_and_global()?;
418 let local = &config_dirs[0];
419 let global = &config_dirs[1];
420
421 match local {
422 Location::Local(config_dir) => {
423 if config_dir.exists() {
424 print_deprecation_warning(config_dir);
425 }
426 }
427 Location::Global(_) => unreachable!(),
428 }
429
430 match global {
431 Location::Global(config_dir) => {
432 let path = config_dir.join("contract-ids").join(&file_name);
433 if !path.exists() {
434 return Ok(None);
435 }
436
437 let content = fs::read_to_string(path)?;
438 let data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
439
440 Ok(Some(data))
441 }
442 Location::Local(_) => unreachable!(),
443 }
444 }
445
446 fn alias_path(&self, alias: &str) -> Result<PathBuf, Error> {
447 utils::validate_name(alias)?;
448 let file_name = format!("{alias}.json");
449 let config_dir = self.config_dir()?;
450 Ok(config_dir.join("contract-ids").join(file_name))
451 }
452
453 pub fn save_contract_id(
454 &self,
455 network_passphrase: &str,
456 contract_id: &stellar_strkey::Contract,
457 alias: &str,
458 ) -> Result<(), Error> {
459 if self.read_identity(alias).is_ok() {
460 return Err(Error::ContractAliasCannotOverlapWithKey(alias.to_owned()));
461 }
462 let path = self.alias_path(alias)?;
463 let dir = path.parent().ok_or(Error::CannotAccessConfigDir)?;
464
465 #[cfg(unix)]
466 {
467 use std::os::unix::fs::DirBuilderExt;
468 std::fs::DirBuilder::new()
469 .recursive(true)
470 .mode(0o700)
471 .create(dir)
472 .map_err(|_| Error::CannotAccessConfigDir)?;
473 }
474
475 #[cfg(not(unix))]
476 std::fs::create_dir_all(dir).map_err(|_| Error::CannotAccessConfigDir)?;
477
478 let content = fs::read_to_string(&path).unwrap_or_default();
479 let mut data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
480
481 data.ids
482 .insert(network_passphrase.into(), format!("{contract_id}"));
483
484 let content = serde_json::to_string(&data)?;
485 write_hardened_file(&path, content.as_bytes())?;
486
487 #[cfg(unix)]
488 if let Ok(root) = self.config_dir() {
489 fix_config_permissions(root);
490 }
491
492 Ok(())
493 }
494
495 pub fn remove_contract_id(&self, network_passphrase: &str, alias: &str) -> Result<(), Error> {
496 let path = self.alias_path(alias)?;
497
498 if !path.is_file() {
499 return Err(Error::CannotAccessAliasConfigFile);
500 }
501
502 let content = fs::read_to_string(&path).unwrap_or_default();
503 let mut data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
504
505 data.ids.remove::<str>(network_passphrase);
506
507 let content = serde_json::to_string(&data)?;
508 write_hardened_file(&path, content.as_bytes())?;
509 Ok(())
510 }
511
512 pub fn get_contract_id(
513 &self,
514 alias: &str,
515 network_passphrase: &str,
516 ) -> Result<Option<Contract>, Error> {
517 let Some(alias_data) = self.load_contract_from_alias(alias)? else {
518 return Ok(None);
519 };
520
521 alias_data
522 .ids
523 .get(network_passphrase)
524 .map(|id| id.parse())
525 .transpose()
526 .map_err(|e| Error::CannotParseContractId(alias.to_owned(), e))
527 }
528
529 pub fn resolve_contract_id(
530 &self,
531 alias_or_contract_id: &str,
532 network_passphrase: &str,
533 ) -> Result<Contract, Error> {
534 let Some(contract) = self.get_contract_id(alias_or_contract_id, network_passphrase)? else {
535 return alias_or_contract_id
536 .parse()
537 .map_err(|e| Error::CannotParseContractId(alias_or_contract_id.to_owned(), e));
538 };
539 Ok(contract)
540 }
541
542 pub fn global_config_path(&self) -> Result<PathBuf, Error> {
543 if let Some(config_dir) = &self.config_dir {
544 return Ok(config_dir.clone());
545 }
546
547 global_config_path()
548 }
549}
550
551pub fn print_deprecation_warning(dir: &Path) {
552 let print = Print::new(false);
553 let Ok(global_dir) = global_config_path() else {
554 return;
555 };
556 let global_dir = fs::canonicalize(&global_dir).unwrap_or(global_dir);
557
558 if dir == global_dir {
560 return;
561 }
562
563 print.warnln(format!(
564 "A local config was found at {dir:?} but is no longer read."
565 ));
566 print.blankln(format!(
567 " Run `stellar config migrate` to move the local config into the global config ({global_dir:?})."
568 ));
569}
570
571impl Pwd for Args {
572 fn set_pwd(&mut self, pwd: &Path) {
573 self.config_dir = Some(pwd.to_path_buf());
574 }
575}
576
577#[cfg(unix)]
578fn fix_config_permissions(root: std::path::PathBuf) {
579 use std::os::unix::fs::PermissionsExt;
580
581 let mut bad_dirs = Vec::new();
582 let mut bad_files = Vec::new();
583 let mut stack = vec![root];
584
585 while let Some(dir) = stack.pop() {
586 if let Ok(meta) = std::fs::metadata(&dir) {
587 if meta.permissions().mode() & 0o777 != 0o700 {
588 bad_dirs.push(dir.clone());
589 }
590 }
591
592 if let Ok(entries) = std::fs::read_dir(&dir) {
593 for entry in entries.filter_map(Result::ok) {
594 let path = entry.path();
595
596 if path.is_dir() {
597 stack.push(path);
598 } else if let Ok(meta) = std::fs::metadata(&path) {
599 if meta.permissions().mode() & 0o777 != 0o600 {
600 bad_files.push(path);
601 }
602 }
603 }
604 }
605 }
606
607 let print = Print::new(false);
608
609 if !bad_dirs.is_empty() {
610 print.warnln("Updated config directories permissions to 0700.");
611
612 for dir in bad_dirs {
613 let _ = set_hardened_permissions(&dir);
614 }
615 }
616
617 if !bad_files.is_empty() {
618 print.warnln("Updated config files permissions to 0600.");
619
620 for file in bad_files {
621 let _ = set_hardened_permissions(&file);
622 }
623 }
624}
625
626#[allow(unused_variables, clippy::unnecessary_wraps)]
627pub(crate) fn set_hardened_permissions(path: &Path) -> io::Result<()> {
628 #[cfg(unix)]
629 {
630 use std::os::unix::fs::PermissionsExt;
631 let mode = if path.is_dir() { 0o700 } else { 0o600 };
632 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
633 }
634 Ok(())
635}
636
637pub(crate) fn write_hardened_file(path: &Path, contents: &[u8]) -> io::Result<()> {
642 #[cfg(unix)]
643 {
644 use std::io::Write as _;
645 use std::os::unix::fs::OpenOptionsExt;
646 let mut file = std::fs::OpenOptions::new()
647 .write(true)
648 .create(true)
649 .truncate(true)
650 .mode(0o600)
651 .open(path)?;
652 file.write_all(contents)?;
653 set_hardened_permissions(path)?;
654 }
655
656 #[cfg(not(unix))]
657 std::fs::write(path, contents)?;
658
659 Ok(())
660}
661
662pub fn ensure_directory(dir: PathBuf) -> Result<PathBuf, Error> {
663 let parent = dir.parent().ok_or(Error::HomeDirNotFound)?;
664
665 #[cfg(unix)]
666 {
667 use std::os::unix::fs::DirBuilderExt;
668 std::fs::DirBuilder::new()
669 .recursive(true)
670 .mode(0o700)
671 .create(parent)
672 .map_err(|_| dir_creation_failed(parent))?;
673 fix_config_permissions(parent.to_path_buf());
674 }
675
676 #[cfg(not(unix))]
677 std::fs::create_dir_all(parent).map_err(|_| dir_creation_failed(parent))?;
678
679 Ok(dir)
680}
681
682fn dir_creation_failed(p: &Path) -> Error {
683 Error::DirCreationFailed {
684 path: p.to_path_buf(),
685 }
686}
687
688pub enum KeyType {
689 Identity,
690 Network,
691 ContractIds,
692}
693
694impl Display for KeyType {
695 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
696 write!(
697 f,
698 "{}",
699 match self {
700 KeyType::Identity => "identity",
701 KeyType::Network => "network",
702 KeyType::ContractIds => "contract-ids",
703 }
704 )
705 }
706}
707
708impl KeyType {
709 pub fn read_from_path<T: DeserializeOwned>(path: &Path) -> Result<T, Error> {
710 let data = fs::read_to_string(path).map_err(|_| Error::NetworkFileRead {
711 path: path.to_path_buf(),
712 })?;
713
714 Ok(toml::from_str(&data)?)
715 }
716
717 pub fn read_with_global<T: DeserializeOwned>(
718 &self,
719 key: &str,
720 locator: &Args,
721 ) -> Result<T, Error> {
722 Ok(self.read_with_global_with_location(key, locator)?.0)
723 }
724
725 pub fn read_with_global_with_location<T: DeserializeOwned>(
726 &self,
727 key: &str,
728 locator: &Args,
729 ) -> Result<(T, Location), Error> {
730 for location in locator.local_and_global()? {
731 match &location {
732 Location::Local(config_dir) => {
733 if config_dir.exists() {
734 print_deprecation_warning(config_dir);
735 }
736 continue;
737 }
738 Location::Global(_) => {}
739 }
740
741 let path = self.path(location.as_ref(), key);
742 if let Ok(t) = Self::read_from_path(&path) {
743 return Ok((t, location));
744 }
745 }
746 Err(Error::ConfigMissing(self.to_string(), key.to_string()))
747 }
748
749 pub fn write<T: serde::Serialize>(
750 &self,
751 key: &str,
752 value: &T,
753 pwd: &Path,
754 ) -> Result<PathBuf, Error> {
755 let filepath = ensure_directory(self.path(pwd, key))?;
756 let data = toml::to_string(value).map_err(|_| Error::ConfigSerialization)?;
757 write_hardened_file(&filepath, data.as_bytes()).map_err(|error| {
758 Error::IdCreationFailed {
759 filepath: filepath.clone(),
760 error,
761 }
762 })?;
763
764 #[cfg(unix)]
765 fix_config_permissions(pwd.to_path_buf());
766
767 Ok(filepath)
768 }
769
770 fn root(&self, pwd: &Path) -> PathBuf {
771 pwd.join(self.to_string())
772 }
773
774 pub fn path(&self, pwd: &Path, key: &str) -> PathBuf {
775 let mut path = self.root(pwd).join(key);
776 match self {
777 KeyType::Identity | KeyType::Network => path.set_extension("toml"),
778 KeyType::ContractIds => path.set_extension("json"),
779 };
780 path
781 }
782
783 pub fn list_paths(&self, paths: &[Location]) -> Result<Vec<(String, Location)>, Error> {
784 Ok(paths
785 .iter()
786 .filter(|p| {
787 if let Location::Local(dir) = p {
788 if dir.exists() {
789 print_deprecation_warning(dir);
790 }
791 return false;
792 }
793 true
794 })
795 .unique_by(|p| location_to_string(p))
796 .flat_map(|p| self.list(p, false).unwrap_or_default())
797 .collect())
798 }
799
800 pub fn list_paths_silent(&self, paths: &[Location]) -> Result<Vec<(String, Location)>, Error> {
801 Ok(paths
802 .iter()
803 .flat_map(|p| self.list(p, false).unwrap_or_default())
804 .collect())
805 }
806
807 #[allow(unused_variables)]
808 pub fn list(
809 &self,
810 pwd: &Location,
811 print_warning: bool,
812 ) -> Result<Vec<(String, Location)>, Error> {
813 let path = self.root(pwd.as_ref());
814 if path.exists() {
815 let mut files = self.read_dir(&path)?;
816 files.sort();
817
818 if let Location::Local(config_dir) = pwd {
819 if files.len() > 1 && print_warning {
820 print_deprecation_warning(config_dir);
821 }
822 }
823
824 Ok(files
825 .into_iter()
826 .map(|(name, p)| (name, pwd.wrap(p)))
827 .collect())
828 } else {
829 Ok(vec![])
830 }
831 }
832
833 fn read_dir(&self, dir: &Path) -> Result<Vec<(String, PathBuf)>, Error> {
834 let contents = std::fs::read_dir(dir)?;
835 let mut res = vec![];
836 for entry in contents.filter_map(Result::ok) {
837 let path = entry.path();
838 let extension = match self {
839 KeyType::Identity | KeyType::Network => "toml",
840 KeyType::ContractIds => "json",
841 };
842 if let Some(ext) = path.extension().and_then(OsStr::to_str) {
843 if ext == extension {
844 if let Some(os_str) = path.file_stem() {
845 res.push((os_str.to_string_lossy().trim().to_string(), path));
846 }
847 }
848 }
849 }
850 res.sort();
851 Ok(res)
852 }
853
854 pub fn remove(&self, key: &str, pwd: &Path) -> Result<(), Error> {
855 let path = self.path(pwd, key);
856
857 if path.exists() {
858 std::fs::remove_file(&path)
859 .map_err(|_| Error::ConfigRemoval(self.to_string(), key.to_string()))
860 } else {
861 Ok(())
862 }
863 }
864}
865
866fn global_config_path() -> Result<PathBuf, Error> {
867 if let Ok(config_home) = std::env::var("STELLAR_CONFIG_HOME") {
868 return PathBuf::from_str(&config_home).map_err(|_| Error::StellarConfigDir(config_home));
869 }
870
871 let config_dir = if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
872 PathBuf::from_str(&config_home).map_err(|_| Error::XdgConfigHome(config_home))?
873 } else {
874 UserDirs::new()
875 .ok_or(Error::HomeDirNotFound)?
876 .home_dir()
877 .join(".config")
878 };
879
880 let soroban_dir = config_dir.join("soroban");
881 let stellar_dir = config_dir.join("stellar");
882 let soroban_exists = soroban_dir.exists();
883 let stellar_exists = stellar_dir.exists();
884
885 if stellar_exists && soroban_exists {
886 tracing::warn!("the .stellar and .soroban config directories exist at path {config_dir:?}, using the .stellar");
887 }
888
889 if stellar_exists {
890 return Ok(stellar_dir);
891 }
892
893 if soroban_exists {
894 return Ok(soroban_dir);
895 }
896
897 Ok(stellar_dir)
898}
899
900fn location_to_string(location: &Location) -> String {
901 match location {
902 Location::Local(p) | Location::Global(p) => fs::canonicalize(AsRef::<Path>::as_ref(p))
903 .unwrap_or(p.clone())
904 .display()
905 .to_string(),
906 }
907}
908
909pub fn cli_config_file() -> Result<PathBuf, Error> {
912 Ok(global_config_path()?.join("config.toml"))
913}
914
915#[cfg(test)]
916mod error_message_tests {
917 use super::*;
918
919 #[test]
920 fn contract_not_found_plain_alias_has_no_hint() {
921 let err = Error::ContractNotFound("alice".to_string());
922 assert_eq!(err.to_string(), "contract not found: alice");
923 }
924
925 #[test]
926 fn contract_not_found_64_char_lowercase_hex_includes_wasm_hash_hint() {
927 let hash = "5ea0f3d6c880148c8da088809e851732127fc36b7b42bbdde6052fcc6f6253f3";
928 let err = Error::ContractNotFound(hash.to_string());
929 assert_eq!(
930 err.to_string(),
931 format!("contract not found: {hash}; expected a contract address (C...), got a hash"),
932 );
933 }
934
935 #[test]
936 fn contract_not_found_64_char_uppercase_hex_includes_wasm_hash_hint() {
937 let hash = "5EA0F3D6C880148C8DA088809E851732127FC36B7B42BBDDE6052FCC6F6253F3";
938 let err = Error::ContractNotFound(hash.to_string());
939 assert!(
940 err.to_string().contains("got a hash"),
941 "expected wasm-hash hint for uppercase hex, got: {err}",
942 );
943 }
944
945 #[test]
946 fn contract_not_found_64_char_mixed_case_hex_includes_wasm_hash_hint() {
947 let hash = "5ea0F3d6C880148c8DA088809e851732127fc36b7b42BBDDE6052fcc6F6253F3";
948 let err = Error::ContractNotFound(hash.to_string());
949 assert!(
950 err.to_string().contains("got a hash"),
951 "expected wasm-hash hint for mixed-case hex, got: {err}",
952 );
953 }
954
955 #[test]
956 fn contract_not_found_short_hex_string_has_no_hint() {
957 let err = Error::ContractNotFound("deadbeef".to_string());
958 assert_eq!(err.to_string(), "contract not found: deadbeef");
959 }
960
961 #[test]
962 fn contract_not_found_64_char_non_hex_has_no_hint() {
963 let value = "z".repeat(64);
964 let err = Error::ContractNotFound(value.clone());
965 assert_eq!(err.to_string(), format!("contract not found: {value}"));
966 }
967}
968
969#[cfg(all(test, unix))]
970mod tests {
971 use super::*;
972 use serial_test::serial;
973 use std::collections::HashMap;
974
975 #[test]
976 fn overwrite_resets_file_permissions_to_0600() {
977 use std::os::unix::fs::PermissionsExt;
978
979 let dir = tempfile::tempdir().unwrap();
980 let identity_dir = dir.path().join("identity");
981 std::fs::create_dir_all(&identity_dir).unwrap();
982
983 let alice = identity_dir.join("alice.toml");
985 std::fs::write(&alice, "seed_phrase = \"old\"\n").unwrap();
986 std::fs::set_permissions(&alice, std::fs::Permissions::from_mode(0o644)).unwrap();
987
988 assert_eq!(
989 std::fs::metadata(&alice).unwrap().permissions().mode() & 0o777,
990 0o644,
991 "setup: alice.toml should start at 0644"
992 );
993
994 let value: HashMap<String, String> = HashMap::new();
995 KeyType::Identity
996 .write("alice", &value, dir.path())
997 .unwrap();
998
999 let perms = std::fs::metadata(&alice).unwrap().permissions();
1000 assert_eq!(
1001 perms.mode() & 0o777,
1002 0o600,
1003 "overwritten identity file should be 0600, got {:o}",
1004 perms.mode() & 0o777
1005 );
1006 }
1007
1008 #[test]
1009 fn test_write_sets_file_permissions_to_0600() {
1010 use std::os::unix::fs::PermissionsExt;
1011
1012 let dir = tempfile::tempdir().unwrap();
1013 let value: HashMap<String, String> = HashMap::new();
1014 let path = KeyType::Identity
1015 .write("test-key", &value, dir.path())
1016 .unwrap();
1017
1018 let perms = std::fs::metadata(&path).unwrap().permissions();
1019
1020 assert_eq!(
1021 perms.mode() & 0o777,
1022 0o600,
1023 "identity file should be owner-only readable (0600), got {:o}",
1024 perms.mode() & 0o777
1025 );
1026 }
1027
1028 #[test]
1029 fn test_ensure_directory_sets_dir_permissions_to_0700() {
1030 use std::os::unix::fs::PermissionsExt;
1031
1032 let dir = tempfile::tempdir().unwrap();
1033 let target = dir.path().join("sub").join("file.toml");
1034 ensure_directory(target).unwrap();
1035
1036 let perms = std::fs::metadata(dir.path().join("sub"))
1037 .unwrap()
1038 .permissions();
1039
1040 assert_eq!(
1041 perms.mode() & 0o777,
1042 0o700,
1043 "identity directory should be owner-only (0700), got {:o}",
1044 perms.mode() & 0o777
1045 );
1046 }
1047
1048 use crate::test_utils::{with_cwd_guard, with_env_guard};
1049
1050 #[test]
1051 #[serial]
1052 fn local_config_identity_is_not_read() {
1053 use crate::config::key::Key;
1054
1055 let tmp = tempfile::tempdir().unwrap();
1056
1057 with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME"], || {
1058 with_cwd_guard(|| {
1059 let local_identity_dir = tmp.path().join(".stellar/identity");
1060 std::fs::create_dir_all(&local_identity_dir).unwrap();
1061 std::fs::write(
1062 local_identity_dir.join("alice.toml"),
1063 "seed_phrase = \"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\"\n",
1064 )
1065 .unwrap();
1066
1067 let global_cfg = tmp.path().join("global");
1068 std::fs::create_dir_all(&global_cfg).unwrap();
1069 std::env::set_var("STELLAR_CONFIG_HOME", &global_cfg);
1070
1071 std::env::set_current_dir(tmp.path()).unwrap();
1072
1073 let locator = Args { config_dir: None };
1074 let result = locator.read_identity("alice");
1075 assert!(
1076 result.is_err(),
1077 "local config identity should not be read, but got: {:?}",
1078 result.map(|k: Key| format!("{k:?}"))
1079 );
1080 });
1081 });
1082 }
1083
1084 #[test]
1085 #[serial]
1086 fn local_config_contract_alias_is_not_read() {
1087 let tmp = tempfile::tempdir().unwrap();
1088
1089 with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME"], || {
1090 with_cwd_guard(|| {
1091 let local_alias_dir = tmp.path().join(".stellar/contract-ids");
1092 std::fs::create_dir_all(&local_alias_dir).unwrap();
1093 std::fs::write(
1094 local_alias_dir.join("mycontract.json"),
1095 r#"{"ids":{"testnet":"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"}}"#,
1096 )
1097 .unwrap();
1098
1099 let global_cfg = tmp.path().join("global");
1100 std::fs::create_dir_all(&global_cfg).unwrap();
1101 std::env::set_var("STELLAR_CONFIG_HOME", &global_cfg);
1102
1103 std::env::set_current_dir(tmp.path()).unwrap();
1104
1105 let locator = Args { config_dir: None };
1106 let result = locator.load_contract_from_alias("mycontract").unwrap();
1107 assert!(
1108 result.is_none(),
1109 "local config contract alias should not be read"
1110 );
1111 });
1112 });
1113 }
1114
1115 #[test]
1116 #[serial]
1117 fn local_config_identity_not_listed() {
1118 let tmp = tempfile::tempdir().unwrap();
1119
1120 with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME"], || {
1121 with_cwd_guard(|| {
1122 let local_identity_dir = tmp.path().join(".stellar/identity");
1123 std::fs::create_dir_all(&local_identity_dir).unwrap();
1124 std::fs::write(
1125 local_identity_dir.join("alice.toml"),
1126 "seed_phrase = \"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\"\n",
1127 )
1128 .unwrap();
1129
1130 let global_cfg = tmp.path().join("global");
1131 std::fs::create_dir_all(&global_cfg).unwrap();
1132 std::env::set_var("STELLAR_CONFIG_HOME", &global_cfg);
1133
1134 std::env::set_current_dir(tmp.path()).unwrap();
1135
1136 let locator = Args { config_dir: None };
1137 let identities = locator.list_identities().unwrap();
1138 assert!(
1139 !identities.contains(&"alice".to_string()),
1140 "local config identities should not appear in list, got: {identities:?}"
1141 );
1142 });
1143 });
1144 }
1145
1146 #[test]
1147 #[serial]
1148 fn local_config_network_is_not_read() {
1149 let tmp = tempfile::tempdir().unwrap();
1150
1151 with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME"], || {
1152 with_cwd_guard(|| {
1153 let local_network_dir = tmp.path().join(".stellar/network");
1154 std::fs::create_dir_all(&local_network_dir).unwrap();
1155 std::fs::write(
1156 local_network_dir.join("mynet.toml"),
1157 "rpc_url = \"https://127.0.0.1\"\nnetwork_passphrase = \"Local\"\n",
1158 )
1159 .unwrap();
1160
1161 let global_cfg = tmp.path().join("global");
1162 std::fs::create_dir_all(&global_cfg).unwrap();
1163 std::env::set_var("STELLAR_CONFIG_HOME", &global_cfg);
1164
1165 std::env::set_current_dir(tmp.path()).unwrap();
1166
1167 let locator = Args { config_dir: None };
1168 let result = locator.read_network("mynet");
1169 assert!(result.is_err(), "local config network should not be read");
1170 });
1171 });
1172 }
1173
1174 #[test]
1175 #[serial]
1176 fn local_config_network_not_listed() {
1177 let tmp = tempfile::tempdir().unwrap();
1178
1179 with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME"], || {
1180 with_cwd_guard(|| {
1181 let local_network_dir = tmp.path().join(".stellar/network");
1182 std::fs::create_dir_all(&local_network_dir).unwrap();
1183 std::fs::write(
1184 local_network_dir.join("mynet.toml"),
1185 "rpc_url = \"https://127.0.0.1\"\nnetwork_passphrase = \"Local\"\n",
1186 )
1187 .unwrap();
1188
1189 let global_cfg = tmp.path().join("global");
1190 std::fs::create_dir_all(&global_cfg).unwrap();
1191 std::env::set_var("STELLAR_CONFIG_HOME", &global_cfg);
1192
1193 std::env::set_current_dir(tmp.path()).unwrap();
1194
1195 let locator = Args { config_dir: None };
1196 let networks = locator.list_networks().unwrap();
1197 assert!(
1198 !networks.contains(&"mynet".to_string()),
1199 "local config networks should not appear in list, got: {networks:?}"
1200 );
1201 });
1202 });
1203 }
1204
1205 #[test]
1206 #[serial]
1207 fn local_config_contract_alias_not_listed() {
1208 let tmp = tempfile::tempdir().unwrap();
1209
1210 with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME"], || {
1211 with_cwd_guard(|| {
1212 let local_alias_dir = tmp.path().join(".stellar/contract-ids");
1213 std::fs::create_dir_all(&local_alias_dir).unwrap();
1214 std::fs::write(
1215 local_alias_dir.join("mycontract.json"),
1216 r#"{"ids":{"testnet":"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"}}"#,
1217 )
1218 .unwrap();
1219
1220 let global_cfg = tmp.path().join("global");
1221 std::fs::create_dir_all(&global_cfg).unwrap();
1222 std::env::set_var("STELLAR_CONFIG_HOME", &global_cfg);
1223
1224 std::env::set_current_dir(tmp.path()).unwrap();
1225
1226 let locator = Args { config_dir: None };
1227 let [local, global] = locator.local_and_global().unwrap();
1228
1229 assert!(matches!(local, Location::Local(_)));
1231 assert!(matches!(global, Location::Global(_)));
1232 let global_alias_dir = global.as_ref().join("contract-ids");
1233 assert!(
1234 !global_alias_dir.exists(),
1235 "global alias dir should be empty — local alias must not bleed through"
1236 );
1237 });
1238 });
1239 }
1240
1241 #[test]
1242 #[serial]
1243 fn config_dir_does_not_search_ancestors_for_identity() {
1244 use crate::config::key::Key;
1250
1251 let tmp = tempfile::tempdir().unwrap();
1252
1253 with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME"], || {
1254 let ancestor_identity_dir = tmp.path().join(".stellar/identity");
1256 std::fs::create_dir_all(&ancestor_identity_dir).unwrap();
1257 std::fs::write(
1258 ancestor_identity_dir.join("alice.toml"),
1259 "seed_phrase = \"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\"\n",
1260 )
1261 .unwrap();
1262
1263 let isolated = tmp.path().join("sub/deep");
1265 std::fs::create_dir_all(&isolated).unwrap();
1266
1267 let global_cfg = tmp.path().join("global-cfg");
1269 std::fs::create_dir_all(&global_cfg).unwrap();
1270 std::env::set_var("STELLAR_CONFIG_HOME", &global_cfg);
1271
1272 let locator = Args {
1273 config_dir: Some(isolated),
1274 };
1275
1276 let result = locator.read_identity("alice");
1277 assert!(
1278 result.is_err(),
1279 "expected error when alice is absent from --config-dir and global, \
1280 but got: {:?}",
1281 result.map(|k: Key| format!("{k:?}"))
1282 );
1283 });
1284 }
1285
1286 #[test]
1287 #[serial]
1288 fn test_print_deprecation_warning_no_panic_when_global_dir_missing() {
1289 let tmp = tempfile::tempdir().unwrap();
1290
1291 with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME", "HOME"], || {
1292 let fake_home = tmp.path().join("home");
1293 std::fs::create_dir_all(&fake_home).unwrap();
1294 std::env::set_var("HOME", &fake_home);
1295
1296 let local_dir = tmp.path().join("workdir/.stellar");
1297 std::fs::create_dir_all(&local_dir).unwrap();
1298
1299 print_deprecation_warning(&local_dir);
1301 });
1302 }
1303
1304 mod secure_store_cache {
1305 use super::super::*;
1306
1307 const TEST_PUBLIC_KEY: &str = "GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ";
1308 const TEST_SECRET_KEY: &str = "SBF5HLRREHMS36XZNTUSKZ6FTXDZGNXOHF4EXKUL5UCWZLPBX3NGJ4BH";
1309
1310 fn locator_with_tempdir() -> (tempfile::TempDir, Args) {
1311 let dir = tempfile::tempdir().unwrap();
1312 let args = Args {
1313 config_dir: Some(dir.path().to_path_buf()),
1314 };
1315 (dir, args)
1316 }
1317
1318 #[test]
1326 fn passes_through_already_cached_identity_without_keychain_access() {
1327 let (_dir, locator) = locator_with_tempdir();
1328
1329 let already = Secret::SecureStore {
1332 entry_name: "secure_store:org.stellar.cli-no-such-entry".to_string(),
1333 public_key: Some(TEST_PUBLIC_KEY.to_string()),
1334 hd_path: None,
1335 };
1336 locator.write_identity("already", &already).unwrap();
1337
1338 let key = locator
1339 .read_key_with_secure_store_cache("already", None)
1340 .unwrap();
1341 match key {
1342 Key::Secret(Secret::SecureStore {
1343 public_key: Some(pk),
1344 ..
1345 }) => assert_eq!(pk, TEST_PUBLIC_KEY),
1346 other => panic!("expected SecureStore, got {other:?}"),
1347 }
1348 }
1349
1350 #[test]
1351 fn passes_through_non_secure_store_identity() {
1352 let (_dir, locator) = locator_with_tempdir();
1353
1354 let secret = Secret::SecretKey {
1355 secret_key: TEST_SECRET_KEY.to_string(),
1356 };
1357 locator.write_identity("plain", &secret).unwrap();
1358
1359 let key = locator
1360 .read_key_with_secure_store_cache("plain", None)
1361 .unwrap();
1362 assert!(matches!(
1363 key,
1364 Key::Secret(Secret::SecretKey { ref secret_key }) if secret_key == TEST_SECRET_KEY
1365 ));
1366 }
1367
1368 #[test]
1369 fn returns_literal_public_key_without_disk_lookup() {
1370 let (_dir, locator) = locator_with_tempdir();
1371
1372 let key = locator
1373 .read_key_with_secure_store_cache(TEST_PUBLIC_KEY, None)
1374 .unwrap();
1375 assert!(matches!(key, Key::PublicKey(_)));
1376 }
1377 }
1378}