soroban_cli/config/
locator.rs

1use clap::arg;
2use directories::UserDirs;
3use itertools::Itertools;
4use serde::de::DeserializeOwned;
5use std::{
6    ffi::OsStr,
7    fmt::Display,
8    fs::{self, create_dir_all, OpenOptions},
9    io::{self, Write},
10    path::{Path, PathBuf},
11    str::FromStr,
12};
13use stellar_strkey::{Contract, DecodeError};
14
15use crate::{
16    commands::{global, HEADING_GLOBAL},
17    print::Print,
18    signer::secure_store,
19    utils::find_config_dir,
20    xdr, Pwd,
21};
22
23use super::{
24    alias,
25    key::{self, Key},
26    network::{self, Network},
27    secret::Secret,
28    Config,
29};
30
31#[derive(thiserror::Error, Debug)]
32pub enum Error {
33    #[error(transparent)]
34    TomlSerialize(#[from] toml::ser::Error),
35    #[error("Failed to find home directory")]
36    HomeDirNotFound,
37    #[error("Failed read current directory")]
38    CurrentDirNotFound,
39    #[error("Failed read current directory and no SOROBAN_CONFIG_HOME is set")]
40    NoConfigEnvVar,
41    #[error("Failed to create directory: {path:?}")]
42    DirCreationFailed { path: PathBuf },
43    #[error("Failed to read secret's file: {path}.\nProbably need to use `stellar keys add`")]
44    SecretFileRead { path: PathBuf },
45    #[error("Failed to read network file: {path};\nProbably need to use `stellar network add`")]
46    NetworkFileRead { path: PathBuf },
47    #[error("Failed to read file: {path}")]
48    FileRead { path: PathBuf },
49    #[error(transparent)]
50    Toml(#[from] toml::de::Error),
51    #[error("Secret file failed to deserialize")]
52    Deserialization,
53    #[error("Failed to write identity file:{filepath}: {error}")]
54    IdCreationFailed { filepath: PathBuf, error: io::Error },
55    #[error("Secret file failed to deserialize")]
56    NetworkDeserialization,
57    #[error("Failed to write network file: {0}")]
58    NetworkCreationFailed(std::io::Error),
59    #[error("Error Identity directory is invalid: {name}")]
60    IdentityList { name: String },
61    // #[error("Config file failed to deserialize")]
62    // CannotReadConfigFile,
63    #[error("Config file failed to serialize")]
64    ConfigSerialization,
65    // #[error("Config file failed write")]
66    // CannotWriteConfigFile,
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}")]
88    ContractNotFound(String),
89    #[error("Failed to read upgrade check file: {path}: {error}")]
90    UpgradeCheckReadFailed { path: PathBuf, error: io::Error },
91    #[error("Failed to write upgrade check file: {path}: {error}")]
92    UpgradeCheckWriteFailed { path: PathBuf, error: io::Error },
93    #[error("Contract alias {0}, cannot overlap with key")]
94    ContractAliasCannotOverlapWithKey(String),
95    #[error("Key cannot {0} cannot overlap with contract alias")]
96    KeyCannotOverlapWithContractAlias(String),
97    #[error(transparent)]
98    SecureStore(#[from] secure_store::Error),
99    #[error("Only private keys and seed phrases are supported for getting private keys {0}")]
100    SecretKeyOnly(String),
101    #[error(transparent)]
102    Key(#[from] key::Error),
103}
104
105#[derive(Debug, clap::Args, Default, Clone)]
106#[group(skip)]
107#[cfg(feature = "version_lt_23")]
108pub struct Args {
109    /// Use global config
110    #[arg(long, global = true, help_heading = HEADING_GLOBAL)]
111    pub global: bool,
112
113    /// Location of config directory, default is "."
114    #[arg(long, global = true, help_heading = HEADING_GLOBAL)]
115    pub config_dir: Option<PathBuf>,
116}
117
118#[derive(Debug, clap::Args, Default, Clone)]
119#[group(skip)]
120#[cfg(not(feature = "version_lt_23"))]
121#[cfg(feature = "version_gte_23")]
122pub struct Args {
123    /// ⚠️ Deprecated: global config is always on
124    #[arg(long, global = true, help_heading = HEADING_GLOBAL)]
125    pub global: bool,
126
127    /// Location of config directory. By default, it uses `$XDG_CONFIG_HOME/stellar` if set, falling back to `~/.config/stellar` otherwise.
128    /// Contains configuration files, aliases, and other persistent settings.
129    #[arg(long, global = true, help_heading = HEADING_GLOBAL)]
130    pub config_dir: Option<PathBuf>,
131}
132
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    #[cfg(feature = "version_lt_23")]
172    pub fn config_dir(&self) -> Result<PathBuf, Error> {
173        if self.global {
174            self.global_config_path()
175        } else {
176            self.local_config()
177        }
178    }
179
180    #[cfg(not(feature = "version_lt_23"))]
181    #[cfg(feature = "version_gte_23")]
182    pub fn config_dir(&self) -> Result<PathBuf, Error> {
183        if self.global {
184            let print = Print::new(false);
185            print.warnln("Flag --global is deprecated: global config is always used");
186        }
187
188        self.global_config_path()
189    }
190
191    pub fn local_and_global(&self) -> Result<[Location; 2], Error> {
192        Ok([
193            Location::Local(self.local_config()?),
194            Location::Global(self.global_config_path()?),
195        ])
196    }
197
198    pub fn local_config(&self) -> Result<PathBuf, Error> {
199        let pwd = self.current_dir()?;
200        Ok(find_config_dir(pwd.clone()).unwrap_or_else(|_| pwd.join(".stellar")))
201    }
202
203    pub fn current_dir(&self) -> Result<PathBuf, Error> {
204        self.config_dir.as_ref().map_or_else(
205            || std::env::current_dir().map_err(|_| Error::CurrentDirNotFound),
206            |pwd| Ok(pwd.clone()),
207        )
208    }
209
210    pub fn write_identity(&self, name: &str, secret: &Secret) -> Result<PathBuf, Error> {
211        if let Ok(Some(_)) = self.load_contract_from_alias(name) {
212            return Err(Error::KeyCannotOverlapWithContractAlias(name.to_owned()));
213        }
214        KeyType::Identity.write(name, secret, &self.config_dir()?)
215    }
216
217    pub fn write_public_key(
218        &self,
219        name: &str,
220        public_key: &stellar_strkey::ed25519::PublicKey,
221    ) -> Result<PathBuf, Error> {
222        self.write_key(name, &public_key.into())
223    }
224
225    pub fn write_key(&self, name: &str, key: &Key) -> Result<PathBuf, Error> {
226        KeyType::Identity.write(name, key, &self.config_dir()?)
227    }
228
229    pub fn write_network(&self, name: &str, network: &Network) -> Result<PathBuf, Error> {
230        KeyType::Network.write(name, network, &self.config_dir()?)
231    }
232
233    pub fn write_default_network(&self, name: &str) -> Result<(), Error> {
234        Config::new()?.set_network(name).save()
235    }
236
237    pub fn write_default_identity(&self, name: &str) -> Result<(), Error> {
238        Config::new()?.set_identity(name).save()
239    }
240
241    pub fn list_identities(&self) -> Result<Vec<String>, Error> {
242        Ok(KeyType::Identity
243            .list_paths(&self.local_and_global()?)?
244            .into_iter()
245            .map(|(name, _)| name)
246            .collect())
247    }
248
249    pub fn list_identities_long(&self) -> Result<Vec<(String, String)>, Error> {
250        Ok(KeyType::Identity
251            .list_paths(&self.local_and_global()?)
252            .into_iter()
253            .flatten()
254            .map(|(name, location)| {
255                let path = match location {
256                    Location::Local(path) | Location::Global(path) => path,
257                };
258                (name, format!("{}", path.display()))
259            })
260            .collect())
261    }
262
263    pub fn list_networks(&self) -> Result<Vec<String>, Error> {
264        let saved_networks = KeyType::Network
265            .list_paths(&self.local_and_global()?)
266            .into_iter()
267            .flatten()
268            .map(|x| x.0);
269        let default_networks = network::DEFAULTS.keys().map(ToString::to_string);
270        Ok(saved_networks.chain(default_networks).unique().collect())
271    }
272
273    pub fn list_networks_long(&self) -> Result<Vec<(String, Network, String)>, Error> {
274        let saved_networks = KeyType::Network
275            .list_paths(&self.local_and_global()?)
276            .into_iter()
277            .flatten()
278            .filter_map(|(name, location)| {
279                Some((
280                    name,
281                    KeyType::read_from_path::<Network>(location.as_ref()).ok()?,
282                    location.to_string(),
283                ))
284            });
285        let default_networks = network::DEFAULTS
286            .into_iter()
287            .map(|(name, network)| ((*name).to_string(), network.into(), "Default".to_owned()));
288        Ok(saved_networks.chain(default_networks).collect())
289    }
290
291    pub fn read_identity(&self, name: &str) -> Result<Key, Error> {
292        KeyType::Identity.read_with_global(name, self)
293    }
294
295    pub fn read_key(&self, key_or_name: &str) -> Result<Key, Error> {
296        key_or_name
297            .parse()
298            .or_else(|_| self.read_identity(key_or_name))
299    }
300
301    pub fn get_secret_key(&self, key_or_name: &str) -> Result<Secret, Error> {
302        match self.read_key(key_or_name)? {
303            Key::Secret(s) => Ok(s),
304            _ => Err(Error::SecretKeyOnly(key_or_name.to_string())),
305        }
306    }
307
308    pub fn get_public_key(
309        &self,
310        key_or_name: &str,
311        hd_path: Option<usize>,
312    ) -> Result<xdr::MuxedAccount, Error> {
313        Ok(self.read_key(key_or_name)?.muxed_account(hd_path)?)
314    }
315
316    pub fn read_network(&self, name: &str) -> Result<Network, Error> {
317        let res = KeyType::Network.read_with_global(name, self);
318        if let Err(Error::ConfigMissing(_, _)) = &res {
319            let Some(network) = network::DEFAULTS.get(name) else {
320                return res;
321            };
322            return Ok(network.into());
323        }
324        res
325    }
326
327    pub fn remove_identity(&self, name: &str, global_args: &global::Args) -> Result<(), Error> {
328        let print = Print::new(global_args.quiet);
329        let identity = self.read_identity(name)?;
330
331        if let Key::Secret(Secret::SecureStore { entry_name }) = identity {
332            secure_store::delete_secret(&print, &entry_name)?;
333        }
334
335        print.infoln("Removing the key's cli config file");
336        KeyType::Identity.remove(name, &self.config_dir()?)
337    }
338
339    pub fn remove_network(&self, name: &str) -> Result<(), Error> {
340        KeyType::Network.remove(name, &self.config_dir()?)
341    }
342
343    #[cfg(feature = "version_lt_23")]
344    fn load_contract_from_alias(&self, alias: &str) -> Result<Option<alias::Data>, Error> {
345        let path = self.alias_path(alias)?;
346
347        if !path.exists() {
348            return Ok(None);
349        }
350
351        let content = fs::read_to_string(path)?;
352        let data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
353
354        Ok(Some(data))
355    }
356
357    #[cfg(not(feature = "version_lt_23"))]
358    #[cfg(feature = "version_gte_23")]
359    fn load_contract_from_alias(&self, alias: &str) -> Result<Option<alias::Data>, Error> {
360        let file_name = format!("{alias}.json");
361        let config_dirs = self.local_and_global()?;
362        let local = &config_dirs[0];
363        let global = &config_dirs[1];
364
365        match local {
366            Location::Local(config_dir) => {
367                let path = config_dir.join("contract-ids").join(&file_name);
368                if path.exists() {
369                    print_deprecation_warning(config_dir);
370
371                    let content = fs::read_to_string(path)?;
372                    let data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
373
374                    return Ok(Some(data));
375                }
376            }
377            Location::Global(_) => unreachable!(),
378        }
379
380        match global {
381            Location::Global(config_dir) => {
382                let path = config_dir.join("contract-ids").join(&file_name);
383                if !path.exists() {
384                    return Ok(None);
385                }
386
387                let content = fs::read_to_string(path)?;
388                let data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
389
390                Ok(Some(data))
391            }
392            Location::Local(_) => unreachable!(),
393        }
394    }
395
396    fn alias_path(&self, alias: &str) -> Result<PathBuf, Error> {
397        let file_name = format!("{alias}.json");
398        let config_dir = self.config_dir()?;
399        Ok(config_dir.join("contract-ids").join(file_name))
400    }
401
402    pub fn save_contract_id(
403        &self,
404        network_passphrase: &str,
405        contract_id: &stellar_strkey::Contract,
406        alias: &str,
407    ) -> Result<(), Error> {
408        if self.read_identity(alias).is_ok() {
409            return Err(Error::ContractAliasCannotOverlapWithKey(alias.to_owned()));
410        }
411        let path = self.alias_path(alias)?;
412        let dir = path.parent().ok_or(Error::CannotAccessConfigDir)?;
413
414        create_dir_all(dir).map_err(|_| Error::CannotAccessConfigDir)?;
415
416        let content = fs::read_to_string(&path).unwrap_or_default();
417        let mut data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
418
419        let mut to_file = OpenOptions::new()
420            .create(true)
421            .truncate(true)
422            .write(true)
423            .open(path)?;
424
425        data.ids
426            .insert(network_passphrase.into(), contract_id.to_string());
427
428        let content = serde_json::to_string(&data)?;
429
430        Ok(to_file.write_all(content.as_bytes())?)
431    }
432
433    pub fn remove_contract_id(&self, network_passphrase: &str, alias: &str) -> Result<(), Error> {
434        let path = self.alias_path(alias)?;
435
436        if !path.is_file() {
437            return Err(Error::CannotAccessAliasConfigFile);
438        }
439
440        let content = fs::read_to_string(&path).unwrap_or_default();
441        let mut data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
442
443        let mut to_file = OpenOptions::new()
444            .create(true)
445            .truncate(true)
446            .write(true)
447            .open(path)?;
448
449        data.ids.remove::<str>(network_passphrase);
450
451        let content = serde_json::to_string(&data)?;
452
453        Ok(to_file.write_all(content.as_bytes())?)
454    }
455
456    pub fn get_contract_id(
457        &self,
458        alias: &str,
459        network_passphrase: &str,
460    ) -> Result<Option<Contract>, Error> {
461        let Some(alias_data) = self.load_contract_from_alias(alias)? else {
462            return Ok(None);
463        };
464
465        alias_data
466            .ids
467            .get(network_passphrase)
468            .map(|id| id.parse())
469            .transpose()
470            .map_err(|e| Error::CannotParseContractId(alias.to_owned(), e))
471    }
472
473    pub fn resolve_contract_id(
474        &self,
475        alias_or_contract_id: &str,
476        network_passphrase: &str,
477    ) -> Result<Contract, Error> {
478        let Some(contract) = self.get_contract_id(alias_or_contract_id, network_passphrase)? else {
479            return alias_or_contract_id
480                .parse()
481                .map_err(|e| Error::CannotParseContractId(alias_or_contract_id.to_owned(), e));
482        };
483        Ok(contract)
484    }
485
486    pub fn global_config_path(&self) -> Result<PathBuf, Error> {
487        #[cfg(feature = "version_gte_23")]
488        if let Some(config_dir) = &self.config_dir {
489            return Ok(config_dir.clone());
490        }
491
492        global_config_path()
493    }
494}
495
496#[cfg(feature = "version_gte_23")]
497pub fn print_deprecation_warning(dir: &Path) {
498    let print = Print::new(false);
499    let global_dir = global_config_path().expect("Couldn't retrieve global directory.");
500
501    print.warnln(format!("A local config was found at {dir:?}."));
502    print.blankln(" Local config is deprecated and will be removed in the future.".to_string());
503    print.blankln(format!(
504        " Run `stellar config migrate` to move the local config into the global config ({global_dir:?})."
505    ));
506}
507
508impl Pwd for Args {
509    fn set_pwd(&mut self, pwd: &Path) {
510        self.config_dir = Some(pwd.to_path_buf());
511    }
512}
513
514pub fn ensure_directory(dir: PathBuf) -> Result<PathBuf, Error> {
515    let parent = dir.parent().ok_or(Error::HomeDirNotFound)?;
516    std::fs::create_dir_all(parent).map_err(|_| dir_creation_failed(parent))?;
517    Ok(dir)
518}
519
520fn dir_creation_failed(p: &Path) -> Error {
521    Error::DirCreationFailed {
522        path: p.to_path_buf(),
523    }
524}
525
526pub enum KeyType {
527    Identity,
528    Network,
529    ContractIds,
530}
531
532impl Display for KeyType {
533    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
534        write!(
535            f,
536            "{}",
537            match self {
538                KeyType::Identity => "identity",
539                KeyType::Network => "network",
540                KeyType::ContractIds => "contract-ids",
541            }
542        )
543    }
544}
545
546impl KeyType {
547    pub fn read_from_path<T: DeserializeOwned>(path: &Path) -> Result<T, Error> {
548        let data = fs::read_to_string(path).map_err(|_| Error::NetworkFileRead {
549            path: path.to_path_buf(),
550        })?;
551        Ok(toml::from_str(&data)?)
552    }
553
554    pub fn read_with_global<T: DeserializeOwned>(
555        &self,
556        key: &str,
557        locator: &Args,
558    ) -> Result<T, Error> {
559        for location in locator.local_and_global()? {
560            let path = self.path(location.as_ref(), key);
561
562            if let Ok(t) = Self::read_from_path(&path) {
563                #[cfg(feature = "version_gte_23")]
564                if let Location::Local(config_dir) = location {
565                    print_deprecation_warning(&config_dir);
566                }
567
568                return Ok(t);
569            }
570        }
571        Err(Error::ConfigMissing(self.to_string(), key.to_string()))
572    }
573
574    pub fn write<T: serde::Serialize>(
575        &self,
576        key: &str,
577        value: &T,
578        pwd: &Path,
579    ) -> Result<PathBuf, Error> {
580        let filepath = ensure_directory(self.path(pwd, key))?;
581        let data = toml::to_string(value).map_err(|_| Error::ConfigSerialization)?;
582        std::fs::write(&filepath, data).map_err(|error| Error::IdCreationFailed {
583            filepath: filepath.clone(),
584            error,
585        })?;
586        Ok(filepath)
587    }
588
589    fn root(&self, pwd: &Path) -> PathBuf {
590        pwd.join(self.to_string())
591    }
592
593    fn path(&self, pwd: &Path, key: &str) -> PathBuf {
594        let mut path = self.root(pwd).join(key);
595        path.set_extension("toml");
596        path
597    }
598
599    pub fn list_paths(&self, paths: &[Location]) -> Result<Vec<(String, Location)>, Error> {
600        Ok(paths
601            .iter()
602            .flat_map(|p| self.list(p, true).unwrap_or_default())
603            .collect())
604    }
605
606    pub fn list_paths_silent(&self, paths: &[Location]) -> Result<Vec<(String, Location)>, Error> {
607        Ok(paths
608            .iter()
609            .flat_map(|p| self.list(p, false).unwrap_or_default())
610            .collect())
611    }
612
613    #[allow(unused_variables)]
614    pub fn list(
615        &self,
616        pwd: &Location,
617        print_warning: bool,
618    ) -> Result<Vec<(String, Location)>, Error> {
619        let path = self.root(pwd.as_ref());
620        if path.exists() {
621            let mut files = self.read_dir(&path)?;
622            files.sort();
623
624            #[cfg(feature = "version_gte_23")]
625            if let Location::Local(config_dir) = pwd {
626                if files.len() > 1 && print_warning {
627                    print_deprecation_warning(config_dir);
628                }
629            }
630
631            Ok(files
632                .into_iter()
633                .map(|(name, p)| (name, pwd.wrap(p)))
634                .collect())
635        } else {
636            Ok(vec![])
637        }
638    }
639
640    fn read_dir(&self, dir: &Path) -> Result<Vec<(String, PathBuf)>, Error> {
641        let contents = std::fs::read_dir(dir)?;
642        let mut res = vec![];
643        for entry in contents.filter_map(Result::ok) {
644            let path = entry.path();
645            let extension = match self {
646                KeyType::Identity | KeyType::Network => "toml",
647                KeyType::ContractIds => "json",
648            };
649            if let Some(ext) = path.extension().and_then(OsStr::to_str) {
650                if ext == extension {
651                    if let Some(os_str) = path.file_stem() {
652                        res.push((os_str.to_string_lossy().trim().to_string(), path));
653                    }
654                }
655            }
656        }
657        res.sort();
658        Ok(res)
659    }
660
661    pub fn remove(&self, key: &str, pwd: &Path) -> Result<(), Error> {
662        let path = self.path(pwd, key);
663
664        if path.exists() {
665            std::fs::remove_file(&path)
666                .map_err(|_| Error::ConfigRemoval(self.to_string(), key.to_string()))
667        } else {
668            Ok(())
669        }
670    }
671}
672
673fn global_config_path() -> Result<PathBuf, Error> {
674    let config_dir = if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
675        PathBuf::from_str(&config_home).map_err(|_| Error::XdgConfigHome(config_home))?
676    } else {
677        UserDirs::new()
678            .ok_or(Error::HomeDirNotFound)?
679            .home_dir()
680            .join(".config")
681    };
682
683    let soroban_dir = config_dir.join("soroban");
684    let stellar_dir = config_dir.join("stellar");
685    let soroban_exists = soroban_dir.exists();
686    let stellar_exists = stellar_dir.exists();
687
688    if stellar_exists && soroban_exists {
689        tracing::warn!("the .stellar and .soroban config directories exist at path {config_dir:?}, using the .stellar");
690    }
691
692    if stellar_exists {
693        return Ok(stellar_dir);
694    }
695
696    if soroban_exists {
697        return Ok(soroban_dir);
698    }
699
700    Ok(stellar_dir)
701}
702
703// Use locator.global_config_path() to save configurations.
704// This is only to be used to fetch global Stellar config (e.g. to use for defaults)
705pub fn cli_config_file() -> Result<PathBuf, Error> {
706    Ok(global_config_path()?.join("config.toml"))
707}