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("'{0}' is reserved for the built-in native asset contract and cannot be added, overwritten, or removed")]
96 ContractAliasReserved(String),
97 #[error("alias '{alias}' is reserved for the native asset contract, but a stored alias points to {stored}; remove it with `stellar contract alias remove {alias}`, or use the contract id directly")]
98 ShadowedReservedAlias { alias: String, stored: Contract },
99 #[error("Key cannot {0} cannot overlap with contract alias")]
100 KeyCannotOverlapWithContractAlias(String),
101 #[error(transparent)]
102 SecureStore(#[from] secure_store::Error),
103 #[error("Only private keys and seed phrases are supported for getting private keys {0}")]
104 SecretKeyOnly(String),
105 #[error(transparent)]
106 Key(#[from] key::Error),
107 #[error("Unable to get project directory")]
108 ProjectDirsError(),
109 #[error(transparent)]
110 InvalidName(#[from] utils::Error),
111 #[error("invalid signing key or identity name")]
112 InvalidSigningKey,
113}
114
115fn wasm_hash_hint(value: &str) -> &'static str {
116 if value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) {
117 "; expected a contract address (C...), got a hash"
118 } else {
119 ""
120 }
121}
122
123#[derive(Debug, clap::Args, Default, Clone)]
124#[group(skip)]
125pub struct Args {
126 #[arg(long, global = true, help_heading = HEADING_GLOBAL)]
129 pub config_dir: Option<PathBuf>,
130}
131
132#[derive(Clone)]
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 pub fn config_dir(&self) -> Result<PathBuf, Error> {
172 self.global_config_path()
173 }
174
175 pub fn local_and_global(&self) -> Result<[Location; 2], Error> {
176 Ok([
177 Location::Local(self.local_config()?),
178 Location::Global(self.global_config_path()?),
179 ])
180 }
181
182 pub fn local_config(&self) -> Result<PathBuf, Error> {
183 let pwd = std::env::current_dir().map_err(|_| Error::CurrentDirNotFound)?;
187 Ok(find_config_dir(pwd.clone()).unwrap_or_else(|_| pwd.join(".stellar")))
188 }
189
190 pub fn current_dir(&self) -> Result<PathBuf, Error> {
191 self.config_dir.as_ref().map_or_else(
192 || std::env::current_dir().map_err(|_| Error::CurrentDirNotFound),
193 |pwd| Ok(pwd.clone()),
194 )
195 }
196
197 pub fn write_identity(&self, name: &str, secret: &Secret) -> Result<PathBuf, Error> {
198 alias::validate_reserved_aliases(name)?;
199 if let Ok(Some(_)) = self.load_contract_from_alias(name) {
200 return Err(Error::KeyCannotOverlapWithContractAlias(name.to_owned()));
201 }
202 KeyType::Identity.write(name, secret, &self.config_dir()?)
203 }
204
205 pub fn write_public_key(
206 &self,
207 name: &str,
208 public_key: &stellar_strkey::ed25519::PublicKey,
209 ) -> Result<PathBuf, Error> {
210 self.write_key(name, &public_key.into())
211 }
212
213 pub fn write_key(&self, name: &str, key: &Key) -> Result<PathBuf, Error> {
214 alias::validate_reserved_aliases(name)?;
215 KeyType::Identity.write(name, key, &self.config_dir()?)
216 }
217
218 pub fn write_network(&self, name: &str, network: &Network) -> Result<PathBuf, Error> {
219 KeyType::Network.write(name, network, &self.config_dir()?)
220 }
221
222 pub fn write_default_network(&self, name: &str) -> Result<(), Error> {
223 let path = self.global_config_path()?.join("config.toml");
224 Config::load(&path)?.set_network(name).save_to(&path)
225 }
226
227 pub fn write_default_identity(&self, name: &str) -> Result<(), Error> {
228 let path = self.global_config_path()?.join("config.toml");
229 Config::load(&path)?.set_identity(name).save_to(&path)
230 }
231
232 pub fn write_default_inclusion_fee(&self, inclusion_fee: u32) -> Result<(), Error> {
233 let path = self.global_config_path()?.join("config.toml");
234 Config::load(&path)?
235 .set_inclusion_fee(inclusion_fee)
236 .save_to(&path)
237 }
238
239 pub fn write_default_container_engine(&self, engine: &str) -> Result<(), Error> {
240 let path = self.global_config_path()?.join("config.toml");
241 Config::load(&path)?
242 .set_container_engine(engine)
243 .save_to(&path)
244 }
245
246 pub fn unset_default_container_engine(&self) -> Result<(), Error> {
247 let path = self.global_config_path()?.join("config.toml");
248 Config::load(&path)?.unset_container_engine().save_to(&path)
249 }
250
251 pub fn unset_default_identity(&self) -> Result<(), Error> {
252 let path = self.global_config_path()?.join("config.toml");
253 Config::load(&path)?.unset_identity().save_to(&path)
254 }
255
256 pub fn unset_default_network(&self) -> Result<(), Error> {
257 let path = self.global_config_path()?.join("config.toml");
258 Config::load(&path)?.unset_network().save_to(&path)
259 }
260
261 pub fn unset_default_inclusion_fee(&self) -> Result<(), Error> {
262 let path = self.global_config_path()?.join("config.toml");
263 Config::load(&path)?.unset_inclusion_fee().save_to(&path)
264 }
265
266 pub fn list_identities(&self) -> Result<Vec<String>, Error> {
267 Ok(KeyType::Identity
268 .list_paths(&self.local_and_global()?)?
269 .into_iter()
270 .map(|(name, _)| name)
271 .collect())
272 }
273
274 pub fn list_identities_long(&self) -> Result<Vec<(String, String)>, Error> {
275 Ok(KeyType::Identity
276 .list_paths(&self.local_and_global()?)
277 .into_iter()
278 .flatten()
279 .map(|(name, location)| {
280 let path = match location {
281 Location::Local(path) | Location::Global(path) => path,
282 };
283 (name, format!("{}", path.display()))
284 })
285 .collect())
286 }
287
288 pub fn list_networks(&self) -> Result<Vec<String>, Error> {
289 let saved_networks = KeyType::Network
290 .list_paths(&self.local_and_global()?)
291 .into_iter()
292 .flatten()
293 .map(|x| x.0);
294 let default_networks = network::DEFAULTS.keys().map(ToString::to_string);
295 Ok(saved_networks.chain(default_networks).unique().collect())
296 }
297
298 pub fn list_networks_long(&self) -> Result<Vec<(String, Network, String)>, Error> {
299 let saved_networks = KeyType::Network
300 .list_paths(&self.local_and_global()?)
301 .into_iter()
302 .flatten()
303 .filter_map(|(name, location)| {
304 Some((
305 name,
306 KeyType::read_from_path::<Network>(location.as_ref()).ok()?,
307 location.to_string(),
308 ))
309 });
310 let default_networks = network::DEFAULTS
311 .into_iter()
312 .map(|(name, network)| ((*name).to_string(), network.into(), "Default".to_owned()));
313 Ok(saved_networks.chain(default_networks).collect())
314 }
315
316 pub fn read_identity(&self, name: &str) -> Result<Key, Error> {
317 utils::validate_name(name)?;
318 KeyType::Identity.read_with_global(name, self)
319 }
320
321 pub fn read_key(&self, key_or_name: &str) -> Result<Key, Error> {
322 key_or_name
323 .parse()
324 .or_else(|_| self.read_identity(key_or_name))
325 }
326
327 pub fn read_key_with_secure_store_cache(
331 &self,
332 key_or_name: &str,
333 hd_path: Option<u32>,
334 ) -> Result<Key, Error> {
335 if let Ok(literal) = key_or_name.parse::<Key>() {
336 return Ok(literal);
337 }
338 let key = self.read_identity(key_or_name)?;
339 if let Key::Secret(Secret::SecureStore {
340 entry_name,
341 public_key: None,
342 hd_path: persisted_hd_path,
343 }) = &key
344 {
345 let effective = hd_path.or(*persisted_hd_path);
350 let pk = secure_store::get_public_key(entry_name, effective)?;
351 let migrated = Key::Secret(Secret::SecureStore {
352 entry_name: entry_name.clone(),
353 public_key: Some(format!("{pk}")),
354 hd_path: effective,
355 });
356 let _ = self.write_key(key_or_name, &migrated);
359 return Ok(migrated);
360 }
361 Ok(key)
362 }
363
364 pub fn get_secret_key(&self, key_or_name: &str) -> Result<Secret, Error> {
365 let key = self.read_key(key_or_name).map_err(|e| match e {
366 Error::InvalidName(_) | Error::ConfigMissing(_, _) => Error::InvalidSigningKey,
367 other => other,
368 })?;
369 match key {
370 Key::Secret(s) => Ok(s),
371 _ => Err(Error::InvalidSigningKey),
372 }
373 }
374
375 pub fn get_secret_key_with_hd_path(
380 &self,
381 key_or_name: &str,
382 hd_path: Option<u32>,
383 ) -> Result<Secret, Error> {
384 let key = self
385 .read_key_with_secure_store_cache(key_or_name, hd_path)
386 .map_err(|e| match e {
387 Error::InvalidName(_) | Error::ConfigMissing(_, _) => Error::InvalidSigningKey,
388 other => other,
389 })?;
390 match key {
391 Key::Secret(s) => Ok(s),
392 _ => Err(Error::InvalidSigningKey),
393 }
394 }
395
396 pub fn get_public_key(
397 &self,
398 key_or_name: &str,
399 hd_path: Option<u32>,
400 ) -> Result<xdr::MuxedAccount, Error> {
401 Ok(self.read_key(key_or_name)?.muxed_account(hd_path)?)
402 }
403
404 pub fn secret_by_public_key(
411 &self,
412 target: &stellar_strkey::ed25519::PublicKey,
413 hd_path: Option<u32>,
414 ) -> Result<Option<Secret>, Error> {
415 for name in self.list_identities()? {
416 let Ok(Key::Secret(secret)) = self.read_identity(&name) else {
417 continue;
418 };
419 if secret.public_key(hd_path).is_ok_and(|pk| &pk == target) {
420 return Ok(Some(secret));
421 }
422 }
423 Ok(None)
424 }
425
426 pub fn read_network(&self, name: &str) -> Result<Network, Error> {
427 utils::validate_name(name)?;
428 let res = KeyType::Network.read_with_global(name, self);
429 if let Err(Error::ConfigMissing(_, _)) = &res {
430 let Some(network) = network::DEFAULTS.get(name) else {
431 return res;
432 };
433 return Ok(network.into());
434 }
435 res
436 }
437
438 pub fn remove_identity(&self, name: &str, global_args: &global::Args) -> Result<(), Error> {
439 let print = Print::new(global_args.quiet);
440 let identity = self.read_identity(name)?;
441
442 if let Key::Secret(Secret::SecureStore { entry_name, .. }) = identity {
443 secure_store::delete_secret(&print, &entry_name)?;
444 }
445
446 print.infoln("Removing the key's cli config file");
447 KeyType::Identity.remove(name, &self.config_dir()?)
448 }
449
450 pub fn remove_network(&self, name: &str) -> Result<(), Error> {
451 KeyType::Network.remove(name, &self.config_dir()?)
452 }
453
454 fn load_contract_from_alias(&self, alias: &str) -> Result<Option<alias::Data>, Error> {
455 utils::validate_name(alias)?;
456 let file_name = format!("{alias}.json");
457 let config_dirs = self.local_and_global()?;
458 let local = &config_dirs[0];
459 let global = &config_dirs[1];
460
461 match local {
462 Location::Local(config_dir) => {
463 if config_dir.exists() {
464 print_deprecation_warning(config_dir);
465 }
466 }
467 Location::Global(_) => unreachable!(),
468 }
469
470 match global {
471 Location::Global(config_dir) => {
472 let path = config_dir.join("contract-ids").join(&file_name);
473 if !path.exists() {
474 return Ok(None);
475 }
476
477 let content = fs::read_to_string(path)?;
478 let data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
479
480 Ok(Some(data))
481 }
482 Location::Local(_) => unreachable!(),
483 }
484 }
485
486 fn alias_path(&self, alias: &str) -> Result<PathBuf, Error> {
487 utils::validate_name(alias)?;
488 let file_name = format!("{alias}.json");
489 let config_dir = self.config_dir()?;
490 Ok(config_dir.join("contract-ids").join(file_name))
491 }
492
493 pub fn save_contract_id(
494 &self,
495 network_passphrase: &str,
496 contract_id: &stellar_strkey::Contract,
497 alias: &str,
498 ) -> Result<(), Error> {
499 alias::validate_reserved_aliases(alias)?;
500 if self.read_identity(alias).is_ok() {
501 return Err(Error::ContractAliasCannotOverlapWithKey(alias.to_owned()));
502 }
503 let path = self.alias_path(alias)?;
504 let dir = path.parent().ok_or(Error::CannotAccessConfigDir)?;
505
506 #[cfg(unix)]
507 {
508 use std::os::unix::fs::DirBuilderExt;
509 std::fs::DirBuilder::new()
510 .recursive(true)
511 .mode(0o700)
512 .create(dir)
513 .map_err(|_| Error::CannotAccessConfigDir)?;
514 }
515
516 #[cfg(not(unix))]
517 std::fs::create_dir_all(dir).map_err(|_| Error::CannotAccessConfigDir)?;
518
519 let content = fs::read_to_string(&path).unwrap_or_default();
520 let mut data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
521
522 data.ids
523 .insert(network_passphrase.into(), format!("{contract_id}"));
524
525 let content = serde_json::to_string(&data)?;
526 write_hardened_file(&path, content.as_bytes())?;
527
528 #[cfg(unix)]
529 if let Ok(root) = self.config_dir() {
530 fix_config_permissions(root);
531 }
532
533 Ok(())
534 }
535
536 pub fn remove_contract_id(&self, network_passphrase: &str, alias: &str) -> Result<(), Error> {
537 let path = self.alias_path(alias)?;
540
541 if !path.is_file() {
542 return Err(Error::CannotAccessAliasConfigFile);
543 }
544
545 let content = fs::read_to_string(&path).unwrap_or_default();
546 let mut data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
547
548 data.ids.remove::<str>(network_passphrase);
549
550 let content = serde_json::to_string(&data)?;
551 write_hardened_file(&path, content.as_bytes())?;
552 Ok(())
553 }
554
555 pub fn get_contract_id(
556 &self,
557 alias: &str,
558 network_passphrase: &str,
559 ) -> Result<Option<Contract>, Error> {
560 if let Some(reserved) = alias::resolve_reserved(alias, self, network_passphrase) {
566 if let Some(stored) = self.get_stored_contract_id(alias, network_passphrase)? {
567 if stored != reserved {
568 return Err(Error::ShadowedReservedAlias {
569 alias: alias.to_owned(),
570 stored,
571 });
572 }
573 }
574
575 return Ok(Some(reserved));
576 }
577
578 self.get_stored_contract_id(alias, network_passphrase)
579 }
580
581 pub fn get_stored_contract_id(
584 &self,
585 alias: &str,
586 network_passphrase: &str,
587 ) -> Result<Option<Contract>, Error> {
588 let Some(alias_data) = self.load_contract_from_alias(alias)? else {
589 return Ok(None);
590 };
591
592 alias_data
593 .ids
594 .get(network_passphrase)
595 .map(|id| id.parse())
596 .transpose()
597 .map_err(|e| Error::CannotParseContractId(alias.to_owned(), e))
598 }
599
600 pub fn resolve_contract_id(
601 &self,
602 alias_or_contract_id: &str,
603 network_passphrase: &str,
604 ) -> Result<Contract, Error> {
605 let Some(contract) = self.get_contract_id(alias_or_contract_id, network_passphrase)? else {
606 return alias_or_contract_id
607 .parse()
608 .map_err(|e| Error::CannotParseContractId(alias_or_contract_id.to_owned(), e));
609 };
610 Ok(contract)
611 }
612
613 pub fn global_config_path(&self) -> Result<PathBuf, Error> {
614 if let Some(config_dir) = &self.config_dir {
615 return Ok(config_dir.clone());
616 }
617
618 global_config_path()
619 }
620}
621
622pub fn print_deprecation_warning(dir: &Path) {
623 let print = Print::new(false);
624 let Ok(global_dir) = global_config_path() else {
625 return;
626 };
627 let global_dir = fs::canonicalize(&global_dir).unwrap_or(global_dir);
628
629 if dir == global_dir {
631 return;
632 }
633
634 print.warnln(format!(
635 "A local config was found at {dir:?} but is no longer read."
636 ));
637 print.blankln(format!(
638 " Run `stellar config migrate` to move the local config into the global config ({global_dir:?})."
639 ));
640}
641
642impl Pwd for Args {
643 fn set_pwd(&mut self, pwd: &Path) {
644 self.config_dir = Some(pwd.to_path_buf());
645 }
646}
647
648#[cfg(unix)]
649fn fix_config_permissions(root: std::path::PathBuf) {
650 use std::os::unix::fs::PermissionsExt;
651
652 let mut bad_dirs = Vec::new();
653 let mut bad_files = Vec::new();
654 let mut stack = vec![root];
655
656 while let Some(dir) = stack.pop() {
657 if let Ok(meta) = std::fs::metadata(&dir) {
658 if meta.permissions().mode() & 0o777 != 0o700 {
659 bad_dirs.push(dir.clone());
660 }
661 }
662
663 if let Ok(entries) = std::fs::read_dir(&dir) {
664 for entry in entries.filter_map(Result::ok) {
665 let path = entry.path();
666
667 if path.is_dir() {
668 stack.push(path);
669 } else if let Ok(meta) = std::fs::metadata(&path) {
670 if meta.permissions().mode() & 0o777 != 0o600 {
671 bad_files.push(path);
672 }
673 }
674 }
675 }
676 }
677
678 let print = Print::new(false);
679
680 if !bad_dirs.is_empty() {
681 print.warnln("Updated config directories permissions to 0700.");
682
683 for dir in bad_dirs {
684 let _ = set_hardened_permissions(&dir);
685 }
686 }
687
688 if !bad_files.is_empty() {
689 print.warnln("Updated config files permissions to 0600.");
690
691 for file in bad_files {
692 let _ = set_hardened_permissions(&file);
693 }
694 }
695}
696
697#[allow(unused_variables, clippy::unnecessary_wraps)]
698pub(crate) fn set_hardened_permissions(path: &Path) -> io::Result<()> {
699 #[cfg(unix)]
700 {
701 use std::os::unix::fs::PermissionsExt;
702 let mode = if path.is_dir() { 0o700 } else { 0o600 };
703 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
704 }
705 Ok(())
706}
707
708pub(crate) fn write_hardened_file(path: &Path, contents: &[u8]) -> io::Result<()> {
713 #[cfg(unix)]
714 {
715 use std::io::Write as _;
716 use std::os::unix::fs::OpenOptionsExt;
717 let mut file = std::fs::OpenOptions::new()
718 .write(true)
719 .create(true)
720 .truncate(true)
721 .mode(0o600)
722 .open(path)?;
723 file.write_all(contents)?;
724 set_hardened_permissions(path)?;
725 }
726
727 #[cfg(not(unix))]
728 std::fs::write(path, contents)?;
729
730 Ok(())
731}
732
733pub fn ensure_directory(dir: PathBuf) -> Result<PathBuf, Error> {
734 let parent = dir.parent().ok_or(Error::HomeDirNotFound)?;
735
736 #[cfg(unix)]
737 {
738 use std::os::unix::fs::DirBuilderExt;
739 std::fs::DirBuilder::new()
740 .recursive(true)
741 .mode(0o700)
742 .create(parent)
743 .map_err(|_| dir_creation_failed(parent))?;
744 fix_config_permissions(parent.to_path_buf());
745 }
746
747 #[cfg(not(unix))]
748 std::fs::create_dir_all(parent).map_err(|_| dir_creation_failed(parent))?;
749
750 Ok(dir)
751}
752
753fn dir_creation_failed(p: &Path) -> Error {
754 Error::DirCreationFailed {
755 path: p.to_path_buf(),
756 }
757}
758
759pub enum KeyType {
760 Identity,
761 Network,
762 ContractIds,
763}
764
765impl Display for KeyType {
766 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
767 write!(
768 f,
769 "{}",
770 match self {
771 KeyType::Identity => "identity",
772 KeyType::Network => "network",
773 KeyType::ContractIds => "contract-ids",
774 }
775 )
776 }
777}
778
779impl KeyType {
780 pub fn read_from_path<T: DeserializeOwned>(path: &Path) -> Result<T, Error> {
781 let data = fs::read_to_string(path).map_err(|_| Error::NetworkFileRead {
782 path: path.to_path_buf(),
783 })?;
784
785 Ok(toml::from_str(&data)?)
786 }
787
788 pub fn read_with_global<T: DeserializeOwned>(
789 &self,
790 key: &str,
791 locator: &Args,
792 ) -> Result<T, Error> {
793 Ok(self.read_with_global_with_location(key, locator)?.0)
794 }
795
796 pub fn read_with_global_with_location<T: DeserializeOwned>(
797 &self,
798 key: &str,
799 locator: &Args,
800 ) -> Result<(T, Location), Error> {
801 for location in locator.local_and_global()? {
802 match &location {
803 Location::Local(config_dir) => {
804 if config_dir.exists() {
805 print_deprecation_warning(config_dir);
806 }
807 continue;
808 }
809 Location::Global(_) => {}
810 }
811
812 let path = self.path(location.as_ref(), key);
813 if let Ok(t) = Self::read_from_path(&path) {
814 return Ok((t, location));
815 }
816 }
817 Err(Error::ConfigMissing(self.to_string(), key.to_string()))
818 }
819
820 pub fn write<T: serde::Serialize>(
821 &self,
822 key: &str,
823 value: &T,
824 pwd: &Path,
825 ) -> Result<PathBuf, Error> {
826 let filepath = ensure_directory(self.path(pwd, key))?;
827 let data = toml::to_string(value).map_err(|_| Error::ConfigSerialization)?;
828 write_hardened_file(&filepath, data.as_bytes()).map_err(|error| {
829 Error::IdCreationFailed {
830 filepath: filepath.clone(),
831 error,
832 }
833 })?;
834
835 #[cfg(unix)]
836 fix_config_permissions(pwd.to_path_buf());
837
838 Ok(filepath)
839 }
840
841 fn root(&self, pwd: &Path) -> PathBuf {
842 pwd.join(self.to_string())
843 }
844
845 pub fn path(&self, pwd: &Path, key: &str) -> PathBuf {
846 let mut path = self.root(pwd).join(key);
847 match self {
848 KeyType::Identity | KeyType::Network => path.set_extension("toml"),
849 KeyType::ContractIds => path.set_extension("json"),
850 };
851 path
852 }
853
854 pub fn list_paths(&self, paths: &[Location]) -> Result<Vec<(String, Location)>, Error> {
855 Ok(paths
856 .iter()
857 .filter(|p| {
858 if let Location::Local(dir) = p {
859 if dir.exists() {
860 print_deprecation_warning(dir);
861 }
862 return false;
863 }
864 true
865 })
866 .unique_by(|p| location_to_string(p))
867 .flat_map(|p| self.list(p, false).unwrap_or_default())
868 .collect())
869 }
870
871 pub fn list_paths_silent(&self, paths: &[Location]) -> Result<Vec<(String, Location)>, Error> {
872 Ok(paths
873 .iter()
874 .flat_map(|p| self.list(p, false).unwrap_or_default())
875 .collect())
876 }
877
878 #[allow(unused_variables)]
879 pub fn list(
880 &self,
881 pwd: &Location,
882 print_warning: bool,
883 ) -> Result<Vec<(String, Location)>, Error> {
884 let path = self.root(pwd.as_ref());
885 if path.exists() {
886 let mut files = self.read_dir(&path)?;
887 files.sort();
888
889 if let Location::Local(config_dir) = pwd {
890 if files.len() > 1 && print_warning {
891 print_deprecation_warning(config_dir);
892 }
893 }
894
895 Ok(files
896 .into_iter()
897 .map(|(name, p)| (name, pwd.wrap(p)))
898 .collect())
899 } else {
900 Ok(vec![])
901 }
902 }
903
904 fn read_dir(&self, dir: &Path) -> Result<Vec<(String, PathBuf)>, Error> {
905 let contents = std::fs::read_dir(dir)?;
906 let mut res = vec![];
907 for entry in contents.filter_map(Result::ok) {
908 let path = entry.path();
909 let extension = match self {
910 KeyType::Identity | KeyType::Network => "toml",
911 KeyType::ContractIds => "json",
912 };
913 if let Some(ext) = path.extension().and_then(OsStr::to_str) {
914 if ext == extension {
915 if let Some(os_str) = path.file_stem() {
916 res.push((os_str.to_string_lossy().trim().to_string(), path));
917 }
918 }
919 }
920 }
921 res.sort();
922 Ok(res)
923 }
924
925 pub fn remove(&self, key: &str, pwd: &Path) -> Result<(), Error> {
926 let path = self.path(pwd, key);
927
928 if path.exists() {
929 std::fs::remove_file(&path)
930 .map_err(|_| Error::ConfigRemoval(self.to_string(), key.to_string()))
931 } else {
932 Ok(())
933 }
934 }
935}
936
937fn global_config_path() -> Result<PathBuf, Error> {
938 if let Ok(config_home) = std::env::var("STELLAR_CONFIG_HOME") {
939 return PathBuf::from_str(&config_home).map_err(|_| Error::StellarConfigDir(config_home));
940 }
941
942 let config_dir = if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
943 PathBuf::from_str(&config_home).map_err(|_| Error::XdgConfigHome(config_home))?
944 } else {
945 UserDirs::new()
946 .ok_or(Error::HomeDirNotFound)?
947 .home_dir()
948 .join(".config")
949 };
950
951 let soroban_dir = config_dir.join("soroban");
952 let stellar_dir = config_dir.join("stellar");
953 let soroban_exists = soroban_dir.exists();
954 let stellar_exists = stellar_dir.exists();
955
956 if stellar_exists && soroban_exists {
957 tracing::warn!("the .stellar and .soroban config directories exist at path {config_dir:?}, using the .stellar");
958 }
959
960 if stellar_exists {
961 return Ok(stellar_dir);
962 }
963
964 if soroban_exists {
965 return Ok(soroban_dir);
966 }
967
968 Ok(stellar_dir)
969}
970
971fn location_to_string(location: &Location) -> String {
972 match location {
973 Location::Local(p) | Location::Global(p) => fs::canonicalize(AsRef::<Path>::as_ref(p))
974 .unwrap_or(p.clone())
975 .display()
976 .to_string(),
977 }
978}
979
980pub fn cli_config_file() -> Result<PathBuf, Error> {
983 Ok(global_config_path()?.join("config.toml"))
984}
985
986#[cfg(test)]
987mod error_message_tests {
988 use super::*;
989
990 #[test]
991 fn contract_not_found_plain_alias_has_no_hint() {
992 let err = Error::ContractNotFound("alice".to_string());
993 assert_eq!(err.to_string(), "contract not found: alice");
994 }
995
996 #[test]
997 fn contract_not_found_64_char_lowercase_hex_includes_wasm_hash_hint() {
998 let hash = "5ea0f3d6c880148c8da088809e851732127fc36b7b42bbdde6052fcc6f6253f3";
999 let err = Error::ContractNotFound(hash.to_string());
1000 assert_eq!(
1001 err.to_string(),
1002 format!("contract not found: {hash}; expected a contract address (C...), got a hash"),
1003 );
1004 }
1005
1006 #[test]
1007 fn contract_not_found_64_char_uppercase_hex_includes_wasm_hash_hint() {
1008 let hash = "5EA0F3D6C880148C8DA088809E851732127FC36B7B42BBDDE6052FCC6F6253F3";
1009 let err = Error::ContractNotFound(hash.to_string());
1010 assert!(
1011 err.to_string().contains("got a hash"),
1012 "expected wasm-hash hint for uppercase hex, got: {err}",
1013 );
1014 }
1015
1016 #[test]
1017 fn contract_not_found_64_char_mixed_case_hex_includes_wasm_hash_hint() {
1018 let hash = "5ea0F3d6C880148c8DA088809e851732127fc36b7b42BBDDE6052fcc6F6253F3";
1019 let err = Error::ContractNotFound(hash.to_string());
1020 assert!(
1021 err.to_string().contains("got a hash"),
1022 "expected wasm-hash hint for mixed-case hex, got: {err}",
1023 );
1024 }
1025
1026 #[test]
1027 fn contract_not_found_short_hex_string_has_no_hint() {
1028 let err = Error::ContractNotFound("deadbeef".to_string());
1029 assert_eq!(err.to_string(), "contract not found: deadbeef");
1030 }
1031
1032 #[test]
1033 fn contract_not_found_64_char_non_hex_has_no_hint() {
1034 let value = "z".repeat(64);
1035 let err = Error::ContractNotFound(value.clone());
1036 assert_eq!(err.to_string(), format!("contract not found: {value}"));
1037 }
1038}
1039
1040#[cfg(all(test, unix))]
1041mod tests {
1042 use super::*;
1043 use serial_test::serial;
1044 use std::collections::HashMap;
1045
1046 #[test]
1047 fn overwrite_resets_file_permissions_to_0600() {
1048 use std::os::unix::fs::PermissionsExt;
1049
1050 let dir = tempfile::tempdir().unwrap();
1051 let identity_dir = dir.path().join("identity");
1052 std::fs::create_dir_all(&identity_dir).unwrap();
1053
1054 let alice = identity_dir.join("alice.toml");
1056 std::fs::write(&alice, "seed_phrase = \"old\"\n").unwrap();
1057 std::fs::set_permissions(&alice, std::fs::Permissions::from_mode(0o644)).unwrap();
1058
1059 assert_eq!(
1060 std::fs::metadata(&alice).unwrap().permissions().mode() & 0o777,
1061 0o644,
1062 "setup: alice.toml should start at 0644"
1063 );
1064
1065 let value: HashMap<String, String> = HashMap::new();
1066 KeyType::Identity
1067 .write("alice", &value, dir.path())
1068 .unwrap();
1069
1070 let perms = std::fs::metadata(&alice).unwrap().permissions();
1071 assert_eq!(
1072 perms.mode() & 0o777,
1073 0o600,
1074 "overwritten identity file should be 0600, got {:o}",
1075 perms.mode() & 0o777
1076 );
1077 }
1078
1079 #[test]
1080 fn save_contract_id_rejects_reserved_native_alias() {
1081 let dir = tempfile::tempdir().unwrap();
1082 let args = Args {
1083 config_dir: Some(dir.path().to_path_buf()),
1084 };
1085 let contract = "CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE"
1086 .parse()
1087 .unwrap();
1088 let native = alias::NATIVE;
1089
1090 let err = args
1091 .save_contract_id("Test Network", &contract, native)
1092 .unwrap_err();
1093
1094 assert!(matches!(err, Error::ContractAliasReserved(alias) if alias == native));
1095 }
1096
1097 #[test]
1098 fn get_contract_id_resolves_native_alias() {
1099 let dir = tempfile::tempdir().unwrap();
1100 let args = Args {
1101 config_dir: Some(dir.path().to_path_buf()),
1102 };
1103 let network_passphrase = "Test Network";
1104 let native = alias::NATIVE;
1105
1106 let resolved = args
1107 .get_contract_id(native, network_passphrase)
1108 .unwrap()
1109 .expect("native alias should resolve");
1110 let expected =
1111 crate::utils::contract_id_hash_from_asset(&xdr::Asset::Native, network_passphrase);
1112
1113 assert_eq!(resolved, expected);
1114 }
1115
1116 #[test]
1117 fn get_contract_id_errors_when_native_alias_is_shadowed() {
1118 let dir = tempfile::tempdir().unwrap();
1119 let args = Args {
1120 config_dir: Some(dir.path().to_path_buf()),
1121 };
1122 let network_passphrase = "Test Network";
1123 let native = alias::NATIVE;
1124
1125 let contract_ids = dir.path().join("contract-ids");
1128 std::fs::create_dir_all(&contract_ids).unwrap();
1129 std::fs::write(
1130 contract_ids.join(format!("{native}.json")),
1131 format!(
1132 r#"{{"ids":{{"{network_passphrase}":"CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE"}}}}"#
1133 ),
1134 )
1135 .unwrap();
1136
1137 let err = args
1138 .get_contract_id(native, network_passphrase)
1139 .unwrap_err();
1140
1141 assert!(matches!(err, Error::ShadowedReservedAlias { alias, .. } if alias == native));
1142 }
1143
1144 #[test]
1145 fn write_identity_rejects_reserved_native_name() {
1146 use crate::config::secret::Secret;
1147 use std::str::FromStr;
1148
1149 let dir = tempfile::tempdir().unwrap();
1150 let args = Args {
1151 config_dir: Some(dir.path().to_path_buf()),
1152 };
1153 let secret =
1154 Secret::from_str("SBEQMTXGCLDFQG3OXMRSMGLKJCPROAHB5GZCCGVZERDI645LCCCRLFGY").unwrap();
1155 let native = alias::NATIVE;
1156
1157 let err = args.write_identity(native, &secret).unwrap_err();
1158
1159 assert!(matches!(err, Error::ContractAliasReserved(name) if name == native));
1160 }
1161
1162 #[test]
1163 fn write_key_rejects_reserved_native_name() {
1164 use crate::config::key::Key;
1165 use std::str::FromStr;
1166
1167 let dir = tempfile::tempdir().unwrap();
1168 let args = Args {
1169 config_dir: Some(dir.path().to_path_buf()),
1170 };
1171 let key =
1172 Key::from_str("SBEQMTXGCLDFQG3OXMRSMGLKJCPROAHB5GZCCGVZERDI645LCCCRLFGY").unwrap();
1173 let native = alias::NATIVE;
1174
1175 let err = args.write_key(native, &key).unwrap_err();
1176
1177 assert!(matches!(err, Error::ContractAliasReserved(name) if name == native));
1178 }
1179
1180 #[test]
1181 fn test_write_sets_file_permissions_to_0600() {
1182 use std::os::unix::fs::PermissionsExt;
1183
1184 let dir = tempfile::tempdir().unwrap();
1185 let value: HashMap<String, String> = HashMap::new();
1186 let path = KeyType::Identity
1187 .write("test-key", &value, dir.path())
1188 .unwrap();
1189
1190 let perms = std::fs::metadata(&path).unwrap().permissions();
1191
1192 assert_eq!(
1193 perms.mode() & 0o777,
1194 0o600,
1195 "identity file should be owner-only readable (0600), got {:o}",
1196 perms.mode() & 0o777
1197 );
1198 }
1199
1200 #[test]
1201 fn test_ensure_directory_sets_dir_permissions_to_0700() {
1202 use std::os::unix::fs::PermissionsExt;
1203
1204 let dir = tempfile::tempdir().unwrap();
1205 let target = dir.path().join("sub").join("file.toml");
1206 ensure_directory(target).unwrap();
1207
1208 let perms = std::fs::metadata(dir.path().join("sub"))
1209 .unwrap()
1210 .permissions();
1211
1212 assert_eq!(
1213 perms.mode() & 0o777,
1214 0o700,
1215 "identity directory should be owner-only (0700), got {:o}",
1216 perms.mode() & 0o777
1217 );
1218 }
1219
1220 use crate::test_utils::{with_cwd_guard, with_env_guard};
1221
1222 #[test]
1223 #[serial]
1224 fn local_config_identity_is_not_read() {
1225 use crate::config::key::Key;
1226
1227 let tmp = tempfile::tempdir().unwrap();
1228
1229 with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME"], || {
1230 with_cwd_guard(|| {
1231 let local_identity_dir = tmp.path().join(".stellar/identity");
1232 std::fs::create_dir_all(&local_identity_dir).unwrap();
1233 std::fs::write(
1234 local_identity_dir.join("alice.toml"),
1235 "seed_phrase = \"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\"\n",
1236 )
1237 .unwrap();
1238
1239 let global_cfg = tmp.path().join("global");
1240 std::fs::create_dir_all(&global_cfg).unwrap();
1241 std::env::set_var("STELLAR_CONFIG_HOME", &global_cfg);
1242
1243 std::env::set_current_dir(tmp.path()).unwrap();
1244
1245 let locator = Args { config_dir: None };
1246 let result = locator.read_identity("alice");
1247 assert!(
1248 result.is_err(),
1249 "local config identity should not be read, but got: {:?}",
1250 result.map(|k: Key| format!("{k:?}"))
1251 );
1252 });
1253 });
1254 }
1255
1256 #[test]
1257 #[serial]
1258 fn local_config_contract_alias_is_not_read() {
1259 let tmp = tempfile::tempdir().unwrap();
1260
1261 with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME"], || {
1262 with_cwd_guard(|| {
1263 let local_alias_dir = tmp.path().join(".stellar/contract-ids");
1264 std::fs::create_dir_all(&local_alias_dir).unwrap();
1265 std::fs::write(
1266 local_alias_dir.join("mycontract.json"),
1267 r#"{"ids":{"testnet":"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"}}"#,
1268 )
1269 .unwrap();
1270
1271 let global_cfg = tmp.path().join("global");
1272 std::fs::create_dir_all(&global_cfg).unwrap();
1273 std::env::set_var("STELLAR_CONFIG_HOME", &global_cfg);
1274
1275 std::env::set_current_dir(tmp.path()).unwrap();
1276
1277 let locator = Args { config_dir: None };
1278 let result = locator.load_contract_from_alias("mycontract").unwrap();
1279 assert!(
1280 result.is_none(),
1281 "local config contract alias should not be read"
1282 );
1283 });
1284 });
1285 }
1286
1287 #[test]
1288 #[serial]
1289 fn local_config_identity_not_listed() {
1290 let tmp = tempfile::tempdir().unwrap();
1291
1292 with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME"], || {
1293 with_cwd_guard(|| {
1294 let local_identity_dir = tmp.path().join(".stellar/identity");
1295 std::fs::create_dir_all(&local_identity_dir).unwrap();
1296 std::fs::write(
1297 local_identity_dir.join("alice.toml"),
1298 "seed_phrase = \"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\"\n",
1299 )
1300 .unwrap();
1301
1302 let global_cfg = tmp.path().join("global");
1303 std::fs::create_dir_all(&global_cfg).unwrap();
1304 std::env::set_var("STELLAR_CONFIG_HOME", &global_cfg);
1305
1306 std::env::set_current_dir(tmp.path()).unwrap();
1307
1308 let locator = Args { config_dir: None };
1309 let identities = locator.list_identities().unwrap();
1310 assert!(
1311 !identities.contains(&"alice".to_string()),
1312 "local config identities should not appear in list, got: {identities:?}"
1313 );
1314 });
1315 });
1316 }
1317
1318 #[test]
1319 #[serial]
1320 fn local_config_network_is_not_read() {
1321 let tmp = tempfile::tempdir().unwrap();
1322
1323 with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME"], || {
1324 with_cwd_guard(|| {
1325 let local_network_dir = tmp.path().join(".stellar/network");
1326 std::fs::create_dir_all(&local_network_dir).unwrap();
1327 std::fs::write(
1328 local_network_dir.join("mynet.toml"),
1329 "rpc_url = \"https://127.0.0.1\"\nnetwork_passphrase = \"Local\"\n",
1330 )
1331 .unwrap();
1332
1333 let global_cfg = tmp.path().join("global");
1334 std::fs::create_dir_all(&global_cfg).unwrap();
1335 std::env::set_var("STELLAR_CONFIG_HOME", &global_cfg);
1336
1337 std::env::set_current_dir(tmp.path()).unwrap();
1338
1339 let locator = Args { config_dir: None };
1340 let result = locator.read_network("mynet");
1341 assert!(result.is_err(), "local config network should not be read");
1342 });
1343 });
1344 }
1345
1346 #[test]
1347 #[serial]
1348 fn local_config_network_not_listed() {
1349 let tmp = tempfile::tempdir().unwrap();
1350
1351 with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME"], || {
1352 with_cwd_guard(|| {
1353 let local_network_dir = tmp.path().join(".stellar/network");
1354 std::fs::create_dir_all(&local_network_dir).unwrap();
1355 std::fs::write(
1356 local_network_dir.join("mynet.toml"),
1357 "rpc_url = \"https://127.0.0.1\"\nnetwork_passphrase = \"Local\"\n",
1358 )
1359 .unwrap();
1360
1361 let global_cfg = tmp.path().join("global");
1362 std::fs::create_dir_all(&global_cfg).unwrap();
1363 std::env::set_var("STELLAR_CONFIG_HOME", &global_cfg);
1364
1365 std::env::set_current_dir(tmp.path()).unwrap();
1366
1367 let locator = Args { config_dir: None };
1368 let networks = locator.list_networks().unwrap();
1369 assert!(
1370 !networks.contains(&"mynet".to_string()),
1371 "local config networks should not appear in list, got: {networks:?}"
1372 );
1373 });
1374 });
1375 }
1376
1377 #[test]
1378 #[serial]
1379 fn local_config_contract_alias_not_listed() {
1380 let tmp = tempfile::tempdir().unwrap();
1381
1382 with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME"], || {
1383 with_cwd_guard(|| {
1384 let local_alias_dir = tmp.path().join(".stellar/contract-ids");
1385 std::fs::create_dir_all(&local_alias_dir).unwrap();
1386 std::fs::write(
1387 local_alias_dir.join("mycontract.json"),
1388 r#"{"ids":{"testnet":"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"}}"#,
1389 )
1390 .unwrap();
1391
1392 let global_cfg = tmp.path().join("global");
1393 std::fs::create_dir_all(&global_cfg).unwrap();
1394 std::env::set_var("STELLAR_CONFIG_HOME", &global_cfg);
1395
1396 std::env::set_current_dir(tmp.path()).unwrap();
1397
1398 let locator = Args { config_dir: None };
1399 let [local, global] = locator.local_and_global().unwrap();
1400
1401 assert!(matches!(local, Location::Local(_)));
1403 assert!(matches!(global, Location::Global(_)));
1404 let global_alias_dir = global.as_ref().join("contract-ids");
1405 assert!(
1406 !global_alias_dir.exists(),
1407 "global alias dir should be empty — local alias must not bleed through"
1408 );
1409 });
1410 });
1411 }
1412
1413 #[test]
1414 #[serial]
1415 fn config_dir_does_not_search_ancestors_for_identity() {
1416 use crate::config::key::Key;
1422
1423 let tmp = tempfile::tempdir().unwrap();
1424
1425 with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME"], || {
1426 let ancestor_identity_dir = tmp.path().join(".stellar/identity");
1428 std::fs::create_dir_all(&ancestor_identity_dir).unwrap();
1429 std::fs::write(
1430 ancestor_identity_dir.join("alice.toml"),
1431 "seed_phrase = \"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\"\n",
1432 )
1433 .unwrap();
1434
1435 let isolated = tmp.path().join("sub/deep");
1437 std::fs::create_dir_all(&isolated).unwrap();
1438
1439 let global_cfg = tmp.path().join("global-cfg");
1441 std::fs::create_dir_all(&global_cfg).unwrap();
1442 std::env::set_var("STELLAR_CONFIG_HOME", &global_cfg);
1443
1444 let locator = Args {
1445 config_dir: Some(isolated),
1446 };
1447
1448 let result = locator.read_identity("alice");
1449 assert!(
1450 result.is_err(),
1451 "expected error when alice is absent from --config-dir and global, \
1452 but got: {:?}",
1453 result.map(|k: Key| format!("{k:?}"))
1454 );
1455 });
1456 }
1457
1458 #[test]
1459 #[serial]
1460 fn test_print_deprecation_warning_no_panic_when_global_dir_missing() {
1461 let tmp = tempfile::tempdir().unwrap();
1462
1463 with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME", "HOME"], || {
1464 let fake_home = tmp.path().join("home");
1465 std::fs::create_dir_all(&fake_home).unwrap();
1466 std::env::set_var("HOME", &fake_home);
1467
1468 let local_dir = tmp.path().join("workdir/.stellar");
1469 std::fs::create_dir_all(&local_dir).unwrap();
1470
1471 print_deprecation_warning(&local_dir);
1473 });
1474 }
1475
1476 mod secure_store_cache {
1477 use super::super::*;
1478
1479 const TEST_PUBLIC_KEY: &str = "GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ";
1480 const TEST_SECRET_KEY: &str = "SBF5HLRREHMS36XZNTUSKZ6FTXDZGNXOHF4EXKUL5UCWZLPBX3NGJ4BH";
1481
1482 fn locator_with_tempdir() -> (tempfile::TempDir, Args) {
1483 let dir = tempfile::tempdir().unwrap();
1484 let args = Args {
1485 config_dir: Some(dir.path().to_path_buf()),
1486 };
1487 (dir, args)
1488 }
1489
1490 #[test]
1498 fn passes_through_already_cached_identity_without_keychain_access() {
1499 let (_dir, locator) = locator_with_tempdir();
1500
1501 let already = Secret::SecureStore {
1504 entry_name: "secure_store:org.stellar.cli-no-such-entry".to_string(),
1505 public_key: Some(TEST_PUBLIC_KEY.to_string()),
1506 hd_path: None,
1507 };
1508 locator.write_identity("already", &already).unwrap();
1509
1510 let key = locator
1511 .read_key_with_secure_store_cache("already", None)
1512 .unwrap();
1513 match key {
1514 Key::Secret(Secret::SecureStore {
1515 public_key: Some(pk),
1516 ..
1517 }) => assert_eq!(pk, TEST_PUBLIC_KEY),
1518 other => panic!("expected SecureStore, got {other:?}"),
1519 }
1520 }
1521
1522 #[test]
1523 fn passes_through_non_secure_store_identity() {
1524 let (_dir, locator) = locator_with_tempdir();
1525
1526 let secret = Secret::SecretKey {
1527 secret_key: TEST_SECRET_KEY.to_string(),
1528 };
1529 locator.write_identity("plain", &secret).unwrap();
1530
1531 let key = locator
1532 .read_key_with_secure_store_cache("plain", None)
1533 .unwrap();
1534 assert!(matches!(
1535 key,
1536 Key::Secret(Secret::SecretKey { ref secret_key }) if secret_key == TEST_SECRET_KEY
1537 ));
1538 }
1539
1540 #[test]
1541 fn returns_literal_public_key_without_disk_lookup() {
1542 let (_dir, locator) = locator_with_tempdir();
1543
1544 let key = locator
1545 .read_key_with_secure_store_cache(TEST_PUBLIC_KEY, None)
1546 .unwrap();
1547 assert!(matches!(key, Key::PublicKey(_)));
1548 }
1549 }
1550
1551 mod secret_by_public_key {
1552 use super::super::*;
1553
1554 const TEST_PUBLIC_KEY: &str = "GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ";
1555 const TEST_SECRET_KEY: &str = "SBF5HLRREHMS36XZNTUSKZ6FTXDZGNXOHF4EXKUL5UCWZLPBX3NGJ4BH";
1556 const OTHER_PUBLIC_KEY: &str = "GAKSH6AD2IPJQELTHIOWDAPYX74YELUOWJLI2L4RIPIPZH6YQIFNUSDC";
1557 const TEST_SEED_PHRASE: &str =
1558 "depth decade power loud smile spatial sign movie judge february rate broccoli";
1559
1560 fn locator_with_tempdir() -> (tempfile::TempDir, Args) {
1561 let dir = tempfile::tempdir().unwrap();
1562 let args = Args {
1563 config_dir: Some(dir.path().to_path_buf()),
1564 };
1565 (dir, args)
1566 }
1567
1568 #[test]
1569 fn returns_secret_for_stored_identity() {
1570 let (_dir, locator) = locator_with_tempdir();
1571 let secret = Secret::SecretKey {
1572 secret_key: TEST_SECRET_KEY.to_string(),
1573 };
1574 locator.write_identity("alice", &secret).unwrap();
1575
1576 let target = stellar_strkey::ed25519::PublicKey::from_string(TEST_PUBLIC_KEY).unwrap();
1577 let found = locator.secret_by_public_key(&target, None).unwrap();
1578
1579 assert!(matches!(
1580 found,
1581 Some(Secret::SecretKey { ref secret_key }) if secret_key == TEST_SECRET_KEY
1582 ));
1583 }
1584
1585 #[test]
1586 fn returns_none_for_unknown_public_key() {
1587 let (_dir, locator) = locator_with_tempdir();
1588 let secret = Secret::SecretKey {
1589 secret_key: TEST_SECRET_KEY.to_string(),
1590 };
1591 locator.write_identity("alice", &secret).unwrap();
1592
1593 let target = stellar_strkey::ed25519::PublicKey::from_string(OTHER_PUBLIC_KEY).unwrap();
1594 assert!(locator
1595 .secret_by_public_key(&target, None)
1596 .unwrap()
1597 .is_none());
1598 }
1599
1600 #[test]
1601 fn matches_identity_at_requested_hd_path() {
1602 let (_dir, locator) = locator_with_tempdir();
1603 let secret = Secret::SeedPhrase {
1604 seed_phrase: TEST_SEED_PHRASE.to_string(),
1605 hd_path: None,
1606 };
1607 locator.write_identity("alice", &secret).unwrap();
1608
1609 let at_five = secret.public_key(Some(5)).unwrap();
1612 assert!(locator
1613 .secret_by_public_key(&at_five, Some(5))
1614 .unwrap()
1615 .is_some());
1616 assert!(locator
1617 .secret_by_public_key(&at_five, None)
1618 .unwrap()
1619 .is_none());
1620 }
1621 }
1622}