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 unset_default_identity(&self) -> Result<(), Error> {
219        Config::new()?.unset_identity().save()
220    }
221
222    pub fn unset_default_network(&self) -> Result<(), Error> {
223        Config::new()?.unset_network().save()
224    }
225
226    pub fn list_identities(&self) -> Result<Vec<String>, Error> {
227        Ok(KeyType::Identity
228            .list_paths(&self.local_and_global()?)?
229            .into_iter()
230            .map(|(name, _)| name)
231            .collect())
232    }
233
234    pub fn list_identities_long(&self) -> Result<Vec<(String, String)>, Error> {
235        Ok(KeyType::Identity
236            .list_paths(&self.local_and_global()?)
237            .into_iter()
238            .flatten()
239            .map(|(name, location)| {
240                let path = match location {
241                    Location::Local(path) | Location::Global(path) => path,
242                };
243                (name, format!("{}", path.display()))
244            })
245            .collect())
246    }
247
248    pub fn list_networks(&self) -> Result<Vec<String>, Error> {
249        let saved_networks = KeyType::Network
250            .list_paths(&self.local_and_global()?)
251            .into_iter()
252            .flatten()
253            .map(|x| x.0);
254        let default_networks = network::DEFAULTS.keys().map(ToString::to_string);
255        Ok(saved_networks.chain(default_networks).unique().collect())
256    }
257
258    pub fn list_networks_long(&self) -> Result<Vec<(String, Network, String)>, Error> {
259        let saved_networks = KeyType::Network
260            .list_paths(&self.local_and_global()?)
261            .into_iter()
262            .flatten()
263            .filter_map(|(name, location)| {
264                Some((
265                    name,
266                    KeyType::read_from_path::<Network>(location.as_ref()).ok()?,
267                    location.to_string(),
268                ))
269            });
270        let default_networks = network::DEFAULTS
271            .into_iter()
272            .map(|(name, network)| ((*name).to_string(), network.into(), "Default".to_owned()));
273        Ok(saved_networks.chain(default_networks).collect())
274    }
275
276    pub fn read_identity(&self, name: &str) -> Result<Key, Error> {
277        KeyType::Identity.read_with_global(name, self)
278    }
279
280    pub fn read_key(&self, key_or_name: &str) -> Result<Key, Error> {
281        key_or_name
282            .parse()
283            .or_else(|_| self.read_identity(key_or_name))
284    }
285
286    pub fn get_secret_key(&self, key_or_name: &str) -> Result<Secret, Error> {
287        match self.read_key(key_or_name)? {
288            Key::Secret(s) => Ok(s),
289            _ => Err(Error::SecretKeyOnly(key_or_name.to_string())),
290        }
291    }
292
293    pub fn get_public_key(
294        &self,
295        key_or_name: &str,
296        hd_path: Option<usize>,
297    ) -> Result<xdr::MuxedAccount, Error> {
298        Ok(self.read_key(key_or_name)?.muxed_account(hd_path)?)
299    }
300
301    pub fn read_network(&self, name: &str) -> Result<Network, Error> {
302        let res = KeyType::Network.read_with_global(name, self);
303        if let Err(Error::ConfigMissing(_, _)) = &res {
304            let Some(network) = network::DEFAULTS.get(name) else {
305                return res;
306            };
307            return Ok(network.into());
308        }
309        res
310    }
311
312    pub fn remove_identity(&self, name: &str, global_args: &global::Args) -> Result<(), Error> {
313        let print = Print::new(global_args.quiet);
314        let identity = self.read_identity(name)?;
315
316        if let Key::Secret(Secret::SecureStore { entry_name }) = identity {
317            secure_store::delete_secret(&print, &entry_name)?;
318        }
319
320        print.infoln("Removing the key's cli config file");
321        KeyType::Identity.remove(name, &self.config_dir()?)
322    }
323
324    pub fn remove_network(&self, name: &str) -> Result<(), Error> {
325        KeyType::Network.remove(name, &self.config_dir()?)
326    }
327
328    fn load_contract_from_alias(&self, alias: &str) -> Result<Option<alias::Data>, Error> {
329        let file_name = format!("{alias}.json");
330        let config_dirs = self.local_and_global()?;
331        let local = &config_dirs[0];
332        let global = &config_dirs[1];
333
334        match local {
335            Location::Local(config_dir) => {
336                let path = config_dir.join("contract-ids").join(&file_name);
337                if path.exists() {
338                    print_deprecation_warning(config_dir);
339
340                    let content = fs::read_to_string(path)?;
341                    let data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
342
343                    return Ok(Some(data));
344                }
345            }
346            Location::Global(_) => unreachable!(),
347        }
348
349        match global {
350            Location::Global(config_dir) => {
351                let path = config_dir.join("contract-ids").join(&file_name);
352                if !path.exists() {
353                    return Ok(None);
354                }
355
356                let content = fs::read_to_string(path)?;
357                let data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
358
359                Ok(Some(data))
360            }
361            Location::Local(_) => unreachable!(),
362        }
363    }
364
365    fn alias_path(&self, alias: &str) -> Result<PathBuf, Error> {
366        let file_name = format!("{alias}.json");
367        let config_dir = self.config_dir()?;
368        Ok(config_dir.join("contract-ids").join(file_name))
369    }
370
371    pub fn save_contract_id(
372        &self,
373        network_passphrase: &str,
374        contract_id: &stellar_strkey::Contract,
375        alias: &str,
376    ) -> Result<(), Error> {
377        if self.read_identity(alias).is_ok() {
378            return Err(Error::ContractAliasCannotOverlapWithKey(alias.to_owned()));
379        }
380        let path = self.alias_path(alias)?;
381        let dir = path.parent().ok_or(Error::CannotAccessConfigDir)?;
382
383        create_dir_all(dir).map_err(|_| Error::CannotAccessConfigDir)?;
384
385        let content = fs::read_to_string(&path).unwrap_or_default();
386        let mut data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
387
388        let mut to_file = OpenOptions::new()
389            .create(true)
390            .truncate(true)
391            .write(true)
392            .open(path)?;
393
394        data.ids
395            .insert(network_passphrase.into(), contract_id.to_string());
396
397        let content = serde_json::to_string(&data)?;
398
399        Ok(to_file.write_all(content.as_bytes())?)
400    }
401
402    pub fn remove_contract_id(&self, network_passphrase: &str, alias: &str) -> Result<(), Error> {
403        let path = self.alias_path(alias)?;
404
405        if !path.is_file() {
406            return Err(Error::CannotAccessAliasConfigFile);
407        }
408
409        let content = fs::read_to_string(&path).unwrap_or_default();
410        let mut data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
411
412        let mut to_file = OpenOptions::new()
413            .create(true)
414            .truncate(true)
415            .write(true)
416            .open(path)?;
417
418        data.ids.remove::<str>(network_passphrase);
419
420        let content = serde_json::to_string(&data)?;
421
422        Ok(to_file.write_all(content.as_bytes())?)
423    }
424
425    pub fn get_contract_id(
426        &self,
427        alias: &str,
428        network_passphrase: &str,
429    ) -> Result<Option<Contract>, Error> {
430        let Some(alias_data) = self.load_contract_from_alias(alias)? else {
431            return Ok(None);
432        };
433
434        alias_data
435            .ids
436            .get(network_passphrase)
437            .map(|id| id.parse())
438            .transpose()
439            .map_err(|e| Error::CannotParseContractId(alias.to_owned(), e))
440    }
441
442    pub fn resolve_contract_id(
443        &self,
444        alias_or_contract_id: &str,
445        network_passphrase: &str,
446    ) -> Result<Contract, Error> {
447        let Some(contract) = self.get_contract_id(alias_or_contract_id, network_passphrase)? else {
448            return alias_or_contract_id
449                .parse()
450                .map_err(|e| Error::CannotParseContractId(alias_or_contract_id.to_owned(), e));
451        };
452        Ok(contract)
453    }
454
455    pub fn global_config_path(&self) -> Result<PathBuf, Error> {
456        if let Some(config_dir) = &self.config_dir {
457            return Ok(config_dir.clone());
458        }
459
460        global_config_path()
461    }
462}
463
464pub fn print_deprecation_warning(dir: &Path) {
465    let print = Print::new(false);
466    let global_dir = global_config_path().expect("Couldn't retrieve global directory.");
467    let global_dir = fs::canonicalize(&global_dir).expect("Couldn't expand global directory.");
468
469    // No warning if local and global dirs are the same (e.g., both set to STELLAR_CONFIG_HOME)
470    if dir == global_dir {
471        return;
472    }
473
474    print.warnln(format!("A local config was found at {dir:?}."));
475    print.blankln(" Local config is deprecated and will be removed in the future.".to_string());
476    print.blankln(format!(
477        " Run `stellar config migrate` to move the local config into the global config ({global_dir:?})."
478    ));
479}
480
481impl Pwd for Args {
482    fn set_pwd(&mut self, pwd: &Path) {
483        self.config_dir = Some(pwd.to_path_buf());
484    }
485}
486
487pub fn ensure_directory(dir: PathBuf) -> Result<PathBuf, Error> {
488    let parent = dir.parent().ok_or(Error::HomeDirNotFound)?;
489    std::fs::create_dir_all(parent).map_err(|_| dir_creation_failed(parent))?;
490    Ok(dir)
491}
492
493fn dir_creation_failed(p: &Path) -> Error {
494    Error::DirCreationFailed {
495        path: p.to_path_buf(),
496    }
497}
498
499pub enum KeyType {
500    Identity,
501    Network,
502    ContractIds,
503}
504
505impl Display for KeyType {
506    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507        write!(
508            f,
509            "{}",
510            match self {
511                KeyType::Identity => "identity",
512                KeyType::Network => "network",
513                KeyType::ContractIds => "contract-ids",
514            }
515        )
516    }
517}
518
519impl KeyType {
520    pub fn read_from_path<T: DeserializeOwned>(path: &Path) -> Result<T, Error> {
521        let data = fs::read_to_string(path).map_err(|_| Error::NetworkFileRead {
522            path: path.to_path_buf(),
523        })?;
524        Ok(toml::from_str(&data)?)
525    }
526
527    pub fn read_with_global<T: DeserializeOwned>(
528        &self,
529        key: &str,
530        locator: &Args,
531    ) -> Result<T, Error> {
532        for location in locator.local_and_global()? {
533            let path = self.path(location.as_ref(), key);
534
535            if let Ok(t) = Self::read_from_path(&path) {
536                if let Location::Local(config_dir) = location {
537                    print_deprecation_warning(&config_dir);
538                }
539
540                return Ok(t);
541            }
542        }
543        Err(Error::ConfigMissing(self.to_string(), key.to_string()))
544    }
545
546    pub fn write<T: serde::Serialize>(
547        &self,
548        key: &str,
549        value: &T,
550        pwd: &Path,
551    ) -> Result<PathBuf, Error> {
552        let filepath = ensure_directory(self.path(pwd, key))?;
553        let data = toml::to_string(value).map_err(|_| Error::ConfigSerialization)?;
554        std::fs::write(&filepath, data).map_err(|error| Error::IdCreationFailed {
555            filepath: filepath.clone(),
556            error,
557        })?;
558        Ok(filepath)
559    }
560
561    fn root(&self, pwd: &Path) -> PathBuf {
562        pwd.join(self.to_string())
563    }
564
565    fn path(&self, pwd: &Path, key: &str) -> PathBuf {
566        let mut path = self.root(pwd).join(key);
567        path.set_extension("toml");
568        path
569    }
570
571    pub fn list_paths(&self, paths: &[Location]) -> Result<Vec<(String, Location)>, Error> {
572        Ok(paths
573            .iter()
574            .unique_by(|p| location_to_string(p))
575            .flat_map(|p| self.list(p, true).unwrap_or_default())
576            .collect())
577    }
578
579    pub fn list_paths_silent(&self, paths: &[Location]) -> Result<Vec<(String, Location)>, Error> {
580        Ok(paths
581            .iter()
582            .flat_map(|p| self.list(p, false).unwrap_or_default())
583            .collect())
584    }
585
586    #[allow(unused_variables)]
587    pub fn list(
588        &self,
589        pwd: &Location,
590        print_warning: bool,
591    ) -> Result<Vec<(String, Location)>, Error> {
592        let path = self.root(pwd.as_ref());
593        if path.exists() {
594            let mut files = self.read_dir(&path)?;
595            files.sort();
596
597            if let Location::Local(config_dir) = pwd {
598                if files.len() > 1 && print_warning {
599                    print_deprecation_warning(config_dir);
600                }
601            }
602
603            Ok(files
604                .into_iter()
605                .map(|(name, p)| (name, pwd.wrap(p)))
606                .collect())
607        } else {
608            Ok(vec![])
609        }
610    }
611
612    fn read_dir(&self, dir: &Path) -> Result<Vec<(String, PathBuf)>, Error> {
613        let contents = std::fs::read_dir(dir)?;
614        let mut res = vec![];
615        for entry in contents.filter_map(Result::ok) {
616            let path = entry.path();
617            let extension = match self {
618                KeyType::Identity | KeyType::Network => "toml",
619                KeyType::ContractIds => "json",
620            };
621            if let Some(ext) = path.extension().and_then(OsStr::to_str) {
622                if ext == extension {
623                    if let Some(os_str) = path.file_stem() {
624                        res.push((os_str.to_string_lossy().trim().to_string(), path));
625                    }
626                }
627            }
628        }
629        res.sort();
630        Ok(res)
631    }
632
633    pub fn remove(&self, key: &str, pwd: &Path) -> Result<(), Error> {
634        let path = self.path(pwd, key);
635
636        if path.exists() {
637            std::fs::remove_file(&path)
638                .map_err(|_| Error::ConfigRemoval(self.to_string(), key.to_string()))
639        } else {
640            Ok(())
641        }
642    }
643}
644
645fn global_config_path() -> Result<PathBuf, Error> {
646    if let Ok(config_home) = std::env::var("STELLAR_CONFIG_HOME") {
647        return PathBuf::from_str(&config_home).map_err(|_| Error::StellarConfigDir(config_home));
648    }
649
650    let config_dir = if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
651        PathBuf::from_str(&config_home).map_err(|_| Error::XdgConfigHome(config_home))?
652    } else {
653        UserDirs::new()
654            .ok_or(Error::HomeDirNotFound)?
655            .home_dir()
656            .join(".config")
657    };
658
659    let soroban_dir = config_dir.join("soroban");
660    let stellar_dir = config_dir.join("stellar");
661    let soroban_exists = soroban_dir.exists();
662    let stellar_exists = stellar_dir.exists();
663
664    if stellar_exists && soroban_exists {
665        tracing::warn!("the .stellar and .soroban config directories exist at path {config_dir:?}, using the .stellar");
666    }
667
668    if stellar_exists {
669        return Ok(stellar_dir);
670    }
671
672    if soroban_exists {
673        return Ok(soroban_dir);
674    }
675
676    Ok(stellar_dir)
677}
678
679fn location_to_string(location: &Location) -> String {
680    match location {
681        Location::Local(p) | Location::Global(p) => fs::canonicalize(AsRef::<Path>::as_ref(p))
682            .unwrap_or(p.clone())
683            .display()
684            .to_string(),
685    }
686}
687
688// Use locator.global_config_path() to save configurations.
689// This is only to be used to fetch global Stellar config (e.g. to use for defaults)
690pub fn cli_config_file() -> Result<PathBuf, Error> {
691    Ok(global_config_path()?.join("config.toml"))
692}