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)]
107pub struct Args {
108    /// Use global config
109    #[arg(long, global = true, help_heading = HEADING_GLOBAL)]
110    pub global: bool,
111
112    /// Location of config directory, default is "."
113    #[arg(long, global = true, help_heading = HEADING_GLOBAL)]
114    pub config_dir: Option<PathBuf>,
115}
116
117pub enum Location {
118    Local(PathBuf),
119    Global(PathBuf),
120}
121
122impl Display for Location {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        write!(
125            f,
126            "{} {:?}",
127            match self {
128                Location::Local(_) => "Local",
129                Location::Global(_) => "Global",
130            },
131            self.as_ref()
132        )
133    }
134}
135
136impl AsRef<Path> for Location {
137    fn as_ref(&self) -> &Path {
138        match self {
139            Location::Local(p) | Location::Global(p) => p.as_path(),
140        }
141    }
142}
143
144impl Location {
145    #[must_use]
146    pub fn wrap(&self, p: PathBuf) -> Self {
147        match self {
148            Location::Local(_) => Location::Local(p),
149            Location::Global(_) => Location::Global(p),
150        }
151    }
152}
153
154impl Args {
155    pub fn config_dir(&self) -> Result<PathBuf, Error> {
156        if self.global {
157            global_config_path()
158        } else {
159            self.local_config()
160        }
161    }
162
163    pub fn local_and_global(&self) -> Result<[Location; 2], Error> {
164        Ok([
165            Location::Local(self.local_config()?),
166            Location::Global(global_config_path()?),
167        ])
168    }
169
170    pub fn local_config(&self) -> Result<PathBuf, Error> {
171        let pwd = self.current_dir()?;
172        Ok(find_config_dir(pwd.clone()).unwrap_or_else(|_| pwd.join(".stellar")))
173    }
174
175    pub fn current_dir(&self) -> Result<PathBuf, Error> {
176        self.config_dir.as_ref().map_or_else(
177            || std::env::current_dir().map_err(|_| Error::CurrentDirNotFound),
178            |pwd| Ok(pwd.clone()),
179        )
180    }
181
182    pub fn write_identity(&self, name: &str, secret: &Secret) -> Result<PathBuf, Error> {
183        if let Ok(Some(_)) = self.load_contract_from_alias(name) {
184            return Err(Error::KeyCannotOverlapWithContractAlias(name.to_owned()));
185        }
186        KeyType::Identity.write(name, secret, &self.config_dir()?)
187    }
188
189    pub fn write_public_key(
190        &self,
191        name: &str,
192        public_key: &stellar_strkey::ed25519::PublicKey,
193    ) -> Result<PathBuf, Error> {
194        self.write_key(name, &public_key.into())
195    }
196
197    pub fn write_key(&self, name: &str, key: &Key) -> Result<PathBuf, Error> {
198        KeyType::Identity.write(name, key, &self.config_dir()?)
199    }
200
201    pub fn write_network(&self, name: &str, network: &Network) -> Result<PathBuf, Error> {
202        KeyType::Network.write(name, network, &self.config_dir()?)
203    }
204
205    pub fn write_default_network(&self, name: &str) -> Result<(), Error> {
206        Config::new()?.set_network(name).save()
207    }
208
209    pub fn write_default_identity(&self, name: &str) -> Result<(), Error> {
210        Config::new()?.set_identity(name).save()
211    }
212
213    pub fn list_identities(&self) -> Result<Vec<String>, Error> {
214        Ok(KeyType::Identity
215            .list_paths(&self.local_and_global()?)?
216            .into_iter()
217            .map(|(name, _)| name)
218            .collect())
219    }
220
221    pub fn list_identities_long(&self) -> Result<Vec<(String, String)>, Error> {
222        Ok(KeyType::Identity
223            .list_paths(&self.local_and_global()?)
224            .into_iter()
225            .flatten()
226            .map(|(name, location)| {
227                let path = match location {
228                    Location::Local(path) | Location::Global(path) => path,
229                };
230                (name, format!("{}", path.display()))
231            })
232            .collect())
233    }
234
235    pub fn list_networks(&self) -> Result<Vec<String>, Error> {
236        let saved_networks = KeyType::Network
237            .list_paths(&self.local_and_global()?)
238            .into_iter()
239            .flatten()
240            .map(|x| x.0);
241        let default_networks = network::DEFAULTS.keys().map(ToString::to_string);
242        Ok(saved_networks.chain(default_networks).unique().collect())
243    }
244
245    pub fn list_networks_long(&self) -> Result<Vec<(String, Network, String)>, Error> {
246        let saved_networks = KeyType::Network
247            .list_paths(&self.local_and_global()?)
248            .into_iter()
249            .flatten()
250            .filter_map(|(name, location)| {
251                Some((
252                    name,
253                    KeyType::read_from_path::<Network>(location.as_ref()).ok()?,
254                    location.to_string(),
255                ))
256            });
257        let default_networks = network::DEFAULTS
258            .into_iter()
259            .map(|(name, network)| ((*name).to_string(), network.into(), "Default".to_owned()));
260        Ok(saved_networks.chain(default_networks).collect())
261    }
262
263    pub fn read_identity(&self, name: &str) -> Result<Key, Error> {
264        KeyType::Identity.read_with_global(name, &self.local_config()?)
265    }
266
267    pub fn read_key(&self, key_or_name: &str) -> Result<Key, Error> {
268        key_or_name
269            .parse()
270            .or_else(|_| self.read_identity(key_or_name))
271    }
272
273    pub fn get_secret_key(&self, key_or_name: &str) -> Result<Secret, Error> {
274        match self.read_key(key_or_name)? {
275            Key::Secret(s) => Ok(s),
276            _ => Err(Error::SecretKeyOnly(key_or_name.to_string())),
277        }
278    }
279
280    pub fn get_public_key(
281        &self,
282        key_or_name: &str,
283        hd_path: Option<usize>,
284    ) -> Result<xdr::MuxedAccount, Error> {
285        Ok(self.read_key(key_or_name)?.muxed_account(hd_path)?)
286    }
287
288    pub fn read_network(&self, name: &str) -> Result<Network, Error> {
289        let res = KeyType::Network.read_with_global(name, &self.local_config()?);
290        if let Err(Error::ConfigMissing(_, _)) = &res {
291            let Some(network) = network::DEFAULTS.get(name) else {
292                return res;
293            };
294            return Ok(network.into());
295        }
296        res
297    }
298
299    pub fn remove_identity(&self, name: &str, global_args: &global::Args) -> Result<(), Error> {
300        let print = Print::new(global_args.quiet);
301        let identity = self.read_identity(name)?;
302
303        if let Key::Secret(Secret::SecureStore { entry_name }) = identity {
304            secure_store::delete_secret(&print, &entry_name)?;
305        }
306
307        print.infoln("Removing the key's cli config file");
308        KeyType::Identity.remove(name, &self.config_dir()?)
309    }
310
311    pub fn remove_network(&self, name: &str) -> Result<(), Error> {
312        KeyType::Network.remove(name, &self.config_dir()?)
313    }
314
315    fn load_contract_from_alias(&self, alias: &str) -> Result<Option<alias::Data>, Error> {
316        let path = self.alias_path(alias)?;
317
318        if !path.exists() {
319            return Ok(None);
320        }
321
322        let content = fs::read_to_string(path)?;
323        let data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
324
325        Ok(Some(data))
326    }
327
328    fn alias_path(&self, alias: &str) -> Result<PathBuf, Error> {
329        let file_name = format!("{alias}.json");
330        let config_dir = self.config_dir()?;
331        Ok(config_dir.join("contract-ids").join(file_name))
332    }
333
334    pub fn save_contract_id(
335        &self,
336        network_passphrase: &str,
337        contract_id: &stellar_strkey::Contract,
338        alias: &str,
339    ) -> Result<(), Error> {
340        if self.read_identity(alias).is_ok() {
341            return Err(Error::ContractAliasCannotOverlapWithKey(alias.to_owned()));
342        }
343        let path = self.alias_path(alias)?;
344        let dir = path.parent().ok_or(Error::CannotAccessConfigDir)?;
345
346        create_dir_all(dir).map_err(|_| Error::CannotAccessConfigDir)?;
347
348        let content = fs::read_to_string(&path).unwrap_or_default();
349        let mut data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
350
351        let mut to_file = OpenOptions::new()
352            .create(true)
353            .truncate(true)
354            .write(true)
355            .open(path)?;
356
357        data.ids
358            .insert(network_passphrase.into(), contract_id.to_string());
359
360        let content = serde_json::to_string(&data)?;
361
362        Ok(to_file.write_all(content.as_bytes())?)
363    }
364
365    pub fn remove_contract_id(&self, network_passphrase: &str, alias: &str) -> Result<(), Error> {
366        let path = self.alias_path(alias)?;
367
368        if !path.is_file() {
369            return Err(Error::CannotAccessAliasConfigFile);
370        }
371
372        let content = fs::read_to_string(&path).unwrap_or_default();
373        let mut data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
374
375        let mut to_file = OpenOptions::new()
376            .create(true)
377            .truncate(true)
378            .write(true)
379            .open(path)?;
380
381        data.ids.remove::<str>(network_passphrase);
382
383        let content = serde_json::to_string(&data)?;
384
385        Ok(to_file.write_all(content.as_bytes())?)
386    }
387
388    pub fn get_contract_id(
389        &self,
390        alias: &str,
391        network_passphrase: &str,
392    ) -> Result<Option<Contract>, Error> {
393        let Some(alias_data) = self.load_contract_from_alias(alias)? else {
394            return Ok(None);
395        };
396
397        alias_data
398            .ids
399            .get(network_passphrase)
400            .map(|id| id.parse())
401            .transpose()
402            .map_err(|e| Error::CannotParseContractId(alias.to_owned(), e))
403    }
404
405    pub fn resolve_contract_id(
406        &self,
407        alias_or_contract_id: &str,
408        network_passphrase: &str,
409    ) -> Result<Contract, Error> {
410        let Some(contract) = self.get_contract_id(alias_or_contract_id, network_passphrase)? else {
411            return alias_or_contract_id
412                .parse()
413                .map_err(|e| Error::CannotParseContractId(alias_or_contract_id.to_owned(), e));
414        };
415        Ok(contract)
416    }
417}
418
419impl Pwd for Args {
420    fn set_pwd(&mut self, pwd: &Path) {
421        self.config_dir = Some(pwd.to_path_buf());
422    }
423}
424
425pub fn ensure_directory(dir: PathBuf) -> Result<PathBuf, Error> {
426    let parent = dir.parent().ok_or(Error::HomeDirNotFound)?;
427    std::fs::create_dir_all(parent).map_err(|_| dir_creation_failed(parent))?;
428    Ok(dir)
429}
430
431fn dir_creation_failed(p: &Path) -> Error {
432    Error::DirCreationFailed {
433        path: p.to_path_buf(),
434    }
435}
436
437fn read_dir(dir: &Path) -> Result<Vec<(String, PathBuf)>, Error> {
438    let contents = std::fs::read_dir(dir)?;
439    let mut res = vec![];
440    for entry in contents.filter_map(Result::ok) {
441        let path = entry.path();
442        if let Some("toml") = path.extension().and_then(OsStr::to_str) {
443            if let Some(os_str) = path.file_stem() {
444                res.push((os_str.to_string_lossy().trim().to_string(), path));
445            }
446        }
447    }
448    res.sort();
449    Ok(res)
450}
451
452pub enum KeyType {
453    Identity,
454    Network,
455}
456
457impl Display for KeyType {
458    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
459        write!(
460            f,
461            "{}",
462            match self {
463                KeyType::Identity => "identity",
464                KeyType::Network => "network",
465            }
466        )
467    }
468}
469
470impl KeyType {
471    pub fn read<T: DeserializeOwned>(&self, key: &str, pwd: &Path) -> Result<T, Error> {
472        let path = self.path(pwd, key);
473        Self::read_from_path(&path)
474    }
475
476    pub fn read_from_path<T: DeserializeOwned>(path: &Path) -> Result<T, Error> {
477        let data = fs::read_to_string(path).map_err(|_| Error::NetworkFileRead {
478            path: path.to_path_buf(),
479        })?;
480        Ok(toml::from_str(&data)?)
481    }
482
483    pub fn read_with_global<T: DeserializeOwned>(&self, key: &str, pwd: &Path) -> Result<T, Error> {
484        for path in [pwd, global_config_path()?.as_path()] {
485            if let Ok(t) = self.read(key, path) {
486                return Ok(t);
487            }
488        }
489        Err(Error::ConfigMissing(self.to_string(), key.to_string()))
490    }
491
492    pub fn write<T: serde::Serialize>(
493        &self,
494        key: &str,
495        value: &T,
496        pwd: &Path,
497    ) -> Result<PathBuf, Error> {
498        let filepath = ensure_directory(self.path(pwd, key))?;
499        let data = toml::to_string(value).map_err(|_| Error::ConfigSerialization)?;
500        std::fs::write(&filepath, data).map_err(|error| Error::IdCreationFailed {
501            filepath: filepath.clone(),
502            error,
503        })?;
504        Ok(filepath)
505    }
506
507    fn root(&self, pwd: &Path) -> PathBuf {
508        pwd.join(self.to_string())
509    }
510
511    fn path(&self, pwd: &Path, key: &str) -> PathBuf {
512        let mut path = self.root(pwd).join(key);
513        path.set_extension("toml");
514        path
515    }
516
517    pub fn list_paths(&self, paths: &[Location]) -> Result<Vec<(String, Location)>, Error> {
518        Ok(paths
519            .iter()
520            .flat_map(|p| self.list(p).unwrap_or_default())
521            .collect())
522    }
523
524    pub fn list(&self, pwd: &Location) -> Result<Vec<(String, Location)>, Error> {
525        let path = self.root(pwd.as_ref());
526        if path.exists() {
527            let mut files = read_dir(&path)?;
528            files.sort();
529
530            Ok(files
531                .into_iter()
532                .map(|(name, p)| (name, pwd.wrap(p)))
533                .collect())
534        } else {
535            Ok(vec![])
536        }
537    }
538
539    pub fn remove(&self, key: &str, pwd: &Path) -> Result<(), Error> {
540        let path = self.path(pwd, key);
541        if path.exists() {
542            std::fs::remove_file(&path)
543                .map_err(|_| Error::ConfigRemoval(self.to_string(), key.to_string()))
544        } else {
545            Ok(())
546        }
547    }
548}
549
550pub fn global_config_path() -> Result<PathBuf, Error> {
551    let config_dir = if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
552        PathBuf::from_str(&config_home).map_err(|_| Error::XdgConfigHome(config_home))?
553    } else {
554        UserDirs::new()
555            .ok_or(Error::HomeDirNotFound)?
556            .home_dir()
557            .join(".config")
558    };
559
560    let soroban_dir = config_dir.join("soroban");
561    let stellar_dir = config_dir.join("stellar");
562    let soroban_exists = soroban_dir.exists();
563    let stellar_exists = stellar_dir.exists();
564
565    if stellar_exists && soroban_exists {
566        tracing::warn!("the .stellar and .soroban config directories exist at path {config_dir:?}, using the .stellar");
567    }
568
569    if stellar_exists {
570        return Ok(stellar_dir);
571    }
572
573    if soroban_exists {
574        return Ok(soroban_dir);
575    }
576
577    Ok(stellar_dir)
578}
579
580pub fn config_file() -> Result<PathBuf, Error> {
581    Ok(global_config_path()?.join("config.toml"))
582}