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