Skip to main content

soroban_cli/config/
locator.rs

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 deserialize")]
60    // CannotReadConfigFile,
61    #[error("Config file failed to serialize")]
62    ConfigSerialization,
63    // #[error("Config file failed write")]
64    // CannotWriteConfigFile,
65    #[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    /// Location of config directory. By default, it uses `$XDG_CONFIG_HOME/stellar` if set, falling back to `~/.config/stellar` otherwise.
127    /// Contains configuration files, aliases, and other persistent settings.
128    #[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        // Always use the real process cwd for local-config discovery, regardless
184        // of whether --config-dir is set.  This prevents ancestor-walking outside
185        // the selected profile.
186        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    /// Like [`Args::read_key`], but for a `SecureStore` identity loaded from disk
328    /// that lacks a cached public key, derive one via the keychain (one prompt)
329    /// and persist it back so subsequent reads avoid the keychain.
330    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            // Honor the persisted hd_path when the caller passes None. Without
346            // this the cache gets populated at index 0 even when the identity
347            // was added with `--hd-path N`, which silently locks every later
348            // read to the wrong account.
349            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            // Best-effort write-back: if persistence fails we still return the
357            // freshly-derived value so the current call succeeds.
358            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    /// Like [`Args::get_secret_key`], but if the secret is a `SecureStore`
376    /// identity loaded from disk without a cached public key, derive it for the
377    /// given `hd_path` and persist the cache. Use from signing paths so the
378    /// returned `Secret` already carries the data signing needs for the hint.
379    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    /// Find a stored identity whose public key matches `target`, returning its
405    /// secret. Each identity is derived at `hd_path` (falling back to its own
406    /// persisted path when `hd_path` is `None`), so a key looked up by strkey
407    /// resolves the same way it would by alias under the same `--hd-path`.
408    /// Best-effort: identities whose public key can't be derived without error
409    /// (e.g. a disconnected ledger) are skipped rather than failing the lookup.
410    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        // Reserved aliases cannot be added or overwritten, but a stored file
538        // that shadows one (created before it became reserved) may be removed.
539        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        // A reserved alias (e.g. `native`) always resolves to its built-in
561        // contract. If a stored (shadowed) alias file points somewhere else,
562        // refuse to silently override it: resolving to the built-in anyway
563        // would misdirect the command to the wrong contract. The stored file
564        // can still be removed with `contract alias remove <name>`.
565        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    /// Reads a contract id from a stored alias file, ignoring built-in reserved
582    /// aliases. Returns `None` when no matching alias file entry exists.
583    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    // No warning if local and global dirs are the same (e.g., both set to STELLAR_CONFIG_HOME)
630    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
708/// Writes `contents` to `path`, creating the file with `0600` on Unix and
709/// resetting the mode to exactly `0600` afterwards regardless of any
710/// pre-existing permissions. Falls back to `std::fs::write` on non-Unix
711/// platforms.
712pub(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
980// Use locator.global_config_path() to save configurations.
981// This is only to be used to fetch global Stellar config (e.g. to use for defaults)
982pub 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        // Pre-create alice.toml at 0644 to simulate an inherited insecure mode.
1055        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        // A native alias stored before the name became reserved, pointing at a
1126        // different contract than the native asset SAC.
1127        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                // Verify the alias ls logic: local must be skipped, global has no aliases.
1402                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        // Regression test for: --config-dir ancestor search discloses secrets
1417        // outside the selected profile (security finding 004).
1418        //
1419        // Place alice.toml in an ancestor of the explicit --config-dir.
1420        // The command should fail to find alice, not read the ancestor file.
1421        use crate::config::key::Key;
1422
1423        let tmp = tempfile::tempdir().unwrap();
1424
1425        with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME"], || {
1426            // Ancestor .stellar with alice.toml
1427            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            // Explicit --config-dir is a descendant of the ancestor, and is empty.
1436            let isolated = tmp.path().join("sub/deep");
1437            std::fs::create_dir_all(&isolated).unwrap();
1438
1439            // Global config is also separate and empty.
1440            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            // Must not panic even though ~/.config/stellar does not exist
1472            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        // The legacy-file -> derive-via-keychain -> write-back path is
1491        // exercised end-to-end by the soroban-test integration test
1492        // `secure_store_key_management`. The keyring crate's mock builder
1493        // assigns each `Entry` instance its own in-memory credential
1494        // (CredentialPersistence::EntryOnly), which makes the read-after-write
1495        // round trip impossible to simulate in pure unit tests.
1496
1497        #[test]
1498        fn passes_through_already_cached_identity_without_keychain_access() {
1499            let (_dir, locator) = locator_with_tempdir();
1500
1501            // Entry name points to a non-existent keychain entry, so any
1502            // keychain access would fail the test.
1503            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            // The account derived at index 5 is only found when the lookup uses
1610            // the same hd_path; the default (index 0) path must not match it.
1611            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}