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