Skip to main content

soroban_cli/config/
locator.rs

1use directories::UserDirs;
2use itertools::Itertools;
3use serde::de::DeserializeOwned;
4use std::{
5    ffi::OsStr,
6    fmt::Display,
7    fs::{self, 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    utils, 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    #[error(transparent)]
107    InvalidName(#[from] utils::Error),
108}
109
110#[derive(Debug, clap::Args, Default, Clone)]
111#[group(skip)]
112pub struct Args {
113    /// Location of config directory. By default, it uses `$XDG_CONFIG_HOME/stellar` if set, falling back to `~/.config/stellar` otherwise.
114    /// Contains configuration files, aliases, and other persistent settings.
115    #[arg(long, global = true, help_heading = HEADING_GLOBAL)]
116    pub config_dir: Option<PathBuf>,
117}
118
119#[derive(Clone)]
120pub enum Location {
121    Local(PathBuf),
122    Global(PathBuf),
123}
124
125impl Display for Location {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        write!(
128            f,
129            "{} {:?}",
130            match self {
131                Location::Local(_) => "Local",
132                Location::Global(_) => "Global",
133            },
134            self.as_ref()
135        )
136    }
137}
138
139impl AsRef<Path> for Location {
140    fn as_ref(&self) -> &Path {
141        match self {
142            Location::Local(p) | Location::Global(p) => p.as_path(),
143        }
144    }
145}
146
147impl Location {
148    #[must_use]
149    pub fn wrap(&self, p: PathBuf) -> Self {
150        match self {
151            Location::Local(_) => Location::Local(p),
152            Location::Global(_) => Location::Global(p),
153        }
154    }
155}
156
157impl Args {
158    pub fn config_dir(&self) -> Result<PathBuf, Error> {
159        self.global_config_path()
160    }
161
162    pub fn local_and_global(&self) -> Result<[Location; 2], Error> {
163        Ok([
164            Location::Local(self.local_config()?),
165            Location::Global(self.global_config_path()?),
166        ])
167    }
168
169    pub fn local_config(&self) -> Result<PathBuf, Error> {
170        let pwd = self.current_dir()?;
171        Ok(find_config_dir(pwd.clone()).unwrap_or_else(|_| pwd.join(".stellar")))
172    }
173
174    pub fn current_dir(&self) -> Result<PathBuf, Error> {
175        self.config_dir.as_ref().map_or_else(
176            || std::env::current_dir().map_err(|_| Error::CurrentDirNotFound),
177            |pwd| Ok(pwd.clone()),
178        )
179    }
180
181    pub fn write_identity(&self, name: &str, secret: &Secret) -> Result<PathBuf, Error> {
182        if let Ok(Some(_)) = self.load_contract_from_alias(name) {
183            return Err(Error::KeyCannotOverlapWithContractAlias(name.to_owned()));
184        }
185        KeyType::Identity.write(name, secret, &self.config_dir()?)
186    }
187
188    pub fn write_public_key(
189        &self,
190        name: &str,
191        public_key: &stellar_strkey::ed25519::PublicKey,
192    ) -> Result<PathBuf, Error> {
193        self.write_key(name, &public_key.into())
194    }
195
196    pub fn write_key(&self, name: &str, key: &Key) -> Result<PathBuf, Error> {
197        KeyType::Identity.write(name, key, &self.config_dir()?)
198    }
199
200    pub fn write_network(&self, name: &str, network: &Network) -> Result<PathBuf, Error> {
201        KeyType::Network.write(name, network, &self.config_dir()?)
202    }
203
204    pub fn write_default_network(&self, name: &str) -> Result<(), Error> {
205        Config::new()?.set_network(name).save()
206    }
207
208    pub fn write_default_identity(&self, name: &str) -> Result<(), Error> {
209        Config::new()?.set_identity(name).save()
210    }
211
212    pub fn write_default_inclusion_fee(&self, inclusion_fee: u32) -> Result<(), Error> {
213        Config::new()?.set_inclusion_fee(inclusion_fee).save()
214    }
215
216    pub fn unset_default_identity(&self) -> Result<(), Error> {
217        Config::new()?.unset_identity().save()
218    }
219
220    pub fn unset_default_network(&self) -> Result<(), Error> {
221        Config::new()?.unset_network().save()
222    }
223
224    pub fn unset_default_inclusion_fee(&self) -> Result<(), Error> {
225        Config::new()?.unset_inclusion_fee().save()
226    }
227
228    pub fn list_identities(&self) -> Result<Vec<String>, Error> {
229        Ok(KeyType::Identity
230            .list_paths(&self.local_and_global()?)?
231            .into_iter()
232            .map(|(name, _)| name)
233            .collect())
234    }
235
236    pub fn list_identities_long(&self) -> Result<Vec<(String, String)>, Error> {
237        Ok(KeyType::Identity
238            .list_paths(&self.local_and_global()?)
239            .into_iter()
240            .flatten()
241            .map(|(name, location)| {
242                let path = match location {
243                    Location::Local(path) | Location::Global(path) => path,
244                };
245                (name, format!("{}", path.display()))
246            })
247            .collect())
248    }
249
250    pub fn list_networks(&self) -> Result<Vec<String>, Error> {
251        let saved_networks = KeyType::Network
252            .list_paths(&self.local_and_global()?)
253            .into_iter()
254            .flatten()
255            .map(|x| x.0);
256        let default_networks = network::DEFAULTS.keys().map(ToString::to_string);
257        Ok(saved_networks.chain(default_networks).unique().collect())
258    }
259
260    pub fn list_networks_long(&self) -> Result<Vec<(String, Network, String)>, Error> {
261        let saved_networks = KeyType::Network
262            .list_paths(&self.local_and_global()?)
263            .into_iter()
264            .flatten()
265            .filter_map(|(name, location)| {
266                Some((
267                    name,
268                    KeyType::read_from_path::<Network>(location.as_ref()).ok()?,
269                    location.to_string(),
270                ))
271            });
272        let default_networks = network::DEFAULTS
273            .into_iter()
274            .map(|(name, network)| ((*name).to_string(), network.into(), "Default".to_owned()));
275        Ok(saved_networks.chain(default_networks).collect())
276    }
277
278    pub fn read_identity(&self, name: &str) -> Result<Key, Error> {
279        utils::validate_name(name)?;
280        KeyType::Identity.read_with_global(name, self)
281    }
282
283    // TODO: Remove once local storage is no longer supported
284    pub fn read_identity_with_location(&self, name: &str) -> Result<(Key, Location), Error> {
285        utils::validate_name(name)?;
286        KeyType::Identity.read_with_global_with_location(name, self)
287    }
288
289    pub fn read_key(&self, key_or_name: &str) -> Result<Key, Error> {
290        key_or_name
291            .parse()
292            .or_else(|_| self.read_identity(key_or_name))
293    }
294
295    pub fn get_secret_key(&self, key_or_name: &str) -> Result<Secret, Error> {
296        match self.read_key(key_or_name)? {
297            Key::Secret(s) => Ok(s),
298            _ => Err(Error::SecretKeyOnly(key_or_name.to_string())),
299        }
300    }
301
302    pub fn get_public_key(
303        &self,
304        key_or_name: &str,
305        hd_path: Option<usize>,
306    ) -> Result<xdr::MuxedAccount, Error> {
307        Ok(self.read_key(key_or_name)?.muxed_account(hd_path)?)
308    }
309
310    pub fn read_network(&self, name: &str) -> Result<Network, Error> {
311        utils::validate_name(name)?;
312        let res = KeyType::Network.read_with_global(name, self);
313        if let Err(Error::ConfigMissing(_, _)) = &res {
314            let Some(network) = network::DEFAULTS.get(name) else {
315                return res;
316            };
317            return Ok(network.into());
318        }
319        res
320    }
321
322    pub fn remove_identity(&self, name: &str, global_args: &global::Args) -> Result<(), Error> {
323        let print = Print::new(global_args.quiet);
324        let identity = self.read_identity(name)?;
325
326        if let Key::Secret(Secret::SecureStore { entry_name }) = identity {
327            secure_store::delete_secret(&print, &entry_name)?;
328        }
329
330        print.infoln("Removing the key's cli config file");
331        KeyType::Identity.remove(name, &self.config_dir()?)
332    }
333
334    pub fn remove_network(&self, name: &str) -> Result<(), Error> {
335        KeyType::Network.remove(name, &self.config_dir()?)
336    }
337
338    fn load_contract_from_alias(&self, alias: &str) -> Result<Option<alias::Data>, Error> {
339        utils::validate_name(alias)?;
340        let file_name = format!("{alias}.json");
341        let config_dirs = self.local_and_global()?;
342        let local = &config_dirs[0];
343        let global = &config_dirs[1];
344
345        match local {
346            Location::Local(config_dir) => {
347                let path = config_dir.join("contract-ids").join(&file_name);
348                if path.exists() {
349                    print_deprecation_warning(config_dir);
350
351                    let content = fs::read_to_string(path)?;
352                    let data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
353
354                    return Ok(Some(data));
355                }
356            }
357            Location::Global(_) => unreachable!(),
358        }
359
360        match global {
361            Location::Global(config_dir) => {
362                let path = config_dir.join("contract-ids").join(&file_name);
363                if !path.exists() {
364                    return Ok(None);
365                }
366
367                let content = fs::read_to_string(path)?;
368                let data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
369
370                Ok(Some(data))
371            }
372            Location::Local(_) => unreachable!(),
373        }
374    }
375
376    fn alias_path(&self, alias: &str) -> Result<PathBuf, Error> {
377        utils::validate_name(alias)?;
378        let file_name = format!("{alias}.json");
379        let config_dir = self.config_dir()?;
380        Ok(config_dir.join("contract-ids").join(file_name))
381    }
382
383    pub fn save_contract_id(
384        &self,
385        network_passphrase: &str,
386        contract_id: &stellar_strkey::Contract,
387        alias: &str,
388    ) -> Result<(), Error> {
389        if self.read_identity(alias).is_ok() {
390            return Err(Error::ContractAliasCannotOverlapWithKey(alias.to_owned()));
391        }
392        let path = self.alias_path(alias)?;
393        let dir = path.parent().ok_or(Error::CannotAccessConfigDir)?;
394
395        #[cfg(unix)]
396        {
397            use std::os::unix::fs::DirBuilderExt;
398            std::fs::DirBuilder::new()
399                .recursive(true)
400                .mode(0o700)
401                .create(dir)
402                .map_err(|_| Error::CannotAccessConfigDir)?;
403        }
404
405        #[cfg(not(unix))]
406        std::fs::create_dir_all(dir).map_err(|_| Error::CannotAccessConfigDir)?;
407
408        let content = fs::read_to_string(&path).unwrap_or_default();
409        let mut data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
410
411        data.ids
412            .insert(network_passphrase.into(), contract_id.to_string());
413
414        let content = serde_json::to_string(&data)?;
415
416        #[cfg(unix)]
417        {
418            use std::io::Write as _;
419            use std::os::unix::fs::OpenOptionsExt;
420            let mut to_file = OpenOptions::new()
421                .create(true)
422                .truncate(true)
423                .write(true)
424                .mode(0o600)
425                .open(&path)?;
426            to_file.write_all(content.as_bytes())?;
427            fix_config_permissions();
428        }
429
430        #[cfg(not(unix))]
431        {
432            let mut to_file = OpenOptions::new()
433                .create(true)
434                .truncate(true)
435                .write(true)
436                .open(path)?;
437            to_file.write_all(content.as_bytes())?;
438        }
439
440        Ok(())
441    }
442
443    pub fn remove_contract_id(&self, network_passphrase: &str, alias: &str) -> Result<(), Error> {
444        let path = self.alias_path(alias)?;
445
446        if !path.is_file() {
447            return Err(Error::CannotAccessAliasConfigFile);
448        }
449
450        let content = fs::read_to_string(&path).unwrap_or_default();
451        let mut data: alias::Data = serde_json::from_str(&content).unwrap_or_default();
452
453        let mut to_file = OpenOptions::new()
454            .create(true)
455            .truncate(true)
456            .write(true)
457            .open(path)?;
458
459        data.ids.remove::<str>(network_passphrase);
460
461        let content = serde_json::to_string(&data)?;
462
463        Ok(to_file.write_all(content.as_bytes())?)
464    }
465
466    pub fn get_contract_id(
467        &self,
468        alias: &str,
469        network_passphrase: &str,
470    ) -> Result<Option<Contract>, Error> {
471        let Some(alias_data) = self.load_contract_from_alias(alias)? else {
472            return Ok(None);
473        };
474
475        alias_data
476            .ids
477            .get(network_passphrase)
478            .map(|id| id.parse())
479            .transpose()
480            .map_err(|e| Error::CannotParseContractId(alias.to_owned(), e))
481    }
482
483    pub fn resolve_contract_id(
484        &self,
485        alias_or_contract_id: &str,
486        network_passphrase: &str,
487    ) -> Result<Contract, Error> {
488        let Some(contract) = self.get_contract_id(alias_or_contract_id, network_passphrase)? else {
489            return alias_or_contract_id
490                .parse()
491                .map_err(|e| Error::CannotParseContractId(alias_or_contract_id.to_owned(), e));
492        };
493        Ok(contract)
494    }
495
496    pub fn global_config_path(&self) -> Result<PathBuf, Error> {
497        if let Some(config_dir) = &self.config_dir {
498            return Ok(config_dir.clone());
499        }
500
501        global_config_path()
502    }
503}
504
505pub fn print_deprecation_warning(dir: &Path) {
506    let print = Print::new(false);
507    let global_dir = global_config_path().expect("Couldn't retrieve global directory.");
508    let global_dir = fs::canonicalize(&global_dir).expect("Couldn't expand global directory.");
509
510    // No warning if local and global dirs are the same (e.g., both set to STELLAR_CONFIG_HOME)
511    if dir == global_dir {
512        return;
513    }
514
515    print.warnln(format!("A local config was found at {dir:?}."));
516    print.blankln(" Local config is deprecated and will be removed in the future.".to_string());
517    print.blankln(format!(
518        " Run `stellar config migrate` to move the local config into the global config ({global_dir:?})."
519    ));
520}
521
522impl Pwd for Args {
523    fn set_pwd(&mut self, pwd: &Path) {
524        self.config_dir = Some(pwd.to_path_buf());
525    }
526}
527
528#[cfg(unix)]
529fn fix_config_permissions() {
530    use std::os::unix::fs::PermissionsExt;
531
532    let Ok(root) = global_config_path() else {
533        return;
534    };
535
536    let mut bad_dirs = Vec::new();
537    let mut bad_files = Vec::new();
538    let mut stack = vec![root];
539
540    while let Some(dir) = stack.pop() {
541        if let Ok(meta) = std::fs::metadata(&dir) {
542            if meta.permissions().mode() & 0o777 != 0o700 {
543                bad_dirs.push(dir.clone());
544            }
545        }
546
547        if let Ok(entries) = std::fs::read_dir(&dir) {
548            for entry in entries.filter_map(Result::ok) {
549                let path = entry.path();
550
551                if path.is_dir() {
552                    stack.push(path);
553                } else if let Ok(meta) = std::fs::metadata(&path) {
554                    if meta.permissions().mode() & 0o777 != 0o600 {
555                        bad_files.push(path);
556                    }
557                }
558            }
559        }
560    }
561
562    let print = Print::new(false);
563
564    if !bad_dirs.is_empty() {
565        print.warnln("Updated config directories permissions to 0700.");
566
567        for dir in bad_dirs {
568            let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
569        }
570    }
571
572    if !bad_files.is_empty() {
573        print.warnln("Updated config files permissions to 0600.");
574
575        for file in bad_files {
576            let _ = std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600));
577        }
578    }
579}
580
581pub fn ensure_directory(dir: PathBuf) -> Result<PathBuf, Error> {
582    let parent = dir.parent().ok_or(Error::HomeDirNotFound)?;
583
584    #[cfg(unix)]
585    {
586        use std::os::unix::fs::DirBuilderExt;
587        std::fs::DirBuilder::new()
588            .recursive(true)
589            .mode(0o700)
590            .create(parent)
591            .map_err(|_| dir_creation_failed(parent))?;
592        fix_config_permissions();
593    }
594
595    #[cfg(not(unix))]
596    std::fs::create_dir_all(parent).map_err(|_| dir_creation_failed(parent))?;
597
598    Ok(dir)
599}
600
601fn dir_creation_failed(p: &Path) -> Error {
602    Error::DirCreationFailed {
603        path: p.to_path_buf(),
604    }
605}
606
607pub enum KeyType {
608    Identity,
609    Network,
610    ContractIds,
611}
612
613impl Display for KeyType {
614    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
615        write!(
616            f,
617            "{}",
618            match self {
619                KeyType::Identity => "identity",
620                KeyType::Network => "network",
621                KeyType::ContractIds => "contract-ids",
622            }
623        )
624    }
625}
626
627impl KeyType {
628    pub fn read_from_path<T: DeserializeOwned>(path: &Path) -> Result<T, Error> {
629        let data = fs::read_to_string(path).map_err(|_| Error::NetworkFileRead {
630            path: path.to_path_buf(),
631        })?;
632
633        #[cfg(unix)]
634        fix_config_permissions();
635
636        Ok(toml::from_str(&data)?)
637    }
638
639    pub fn read_with_global<T: DeserializeOwned>(
640        &self,
641        key: &str,
642        locator: &Args,
643    ) -> Result<T, Error> {
644        Ok(self.read_with_global_with_location(key, locator)?.0)
645    }
646
647    pub fn read_with_global_with_location<T: DeserializeOwned>(
648        &self,
649        key: &str,
650        locator: &Args,
651    ) -> Result<(T, Location), Error> {
652        for location in locator.local_and_global()? {
653            let path = self.path(location.as_ref(), key);
654
655            if let Ok(t) = Self::read_from_path(&path) {
656                if let Location::Local(config_dir) = location.clone() {
657                    print_deprecation_warning(&config_dir);
658                }
659
660                return Ok((t, location));
661            }
662        }
663        Err(Error::ConfigMissing(self.to_string(), key.to_string()))
664    }
665
666    pub fn write<T: serde::Serialize>(
667        &self,
668        key: &str,
669        value: &T,
670        pwd: &Path,
671    ) -> Result<PathBuf, Error> {
672        let filepath = ensure_directory(self.path(pwd, key))?;
673        let data = toml::to_string(value).map_err(|_| Error::ConfigSerialization)?;
674        #[cfg(unix)]
675        {
676            use std::io::Write as _;
677            use std::os::unix::fs::OpenOptionsExt;
678            let mut file = std::fs::OpenOptions::new()
679                .write(true)
680                .create(true)
681                .truncate(true)
682                .mode(0o600)
683                .open(&filepath)
684                .map_err(|error| Error::IdCreationFailed {
685                    filepath: filepath.clone(),
686                    error,
687                })?;
688            file.write_all(data.as_bytes())
689                .map_err(|error| Error::IdCreationFailed {
690                    filepath: filepath.clone(),
691                    error,
692                })?;
693        }
694
695        #[cfg(not(unix))]
696        std::fs::write(&filepath, data).map_err(|error| Error::IdCreationFailed {
697            filepath: filepath.clone(),
698            error,
699        })?;
700
701        #[cfg(unix)]
702        fix_config_permissions();
703
704        Ok(filepath)
705    }
706
707    fn root(&self, pwd: &Path) -> PathBuf {
708        pwd.join(self.to_string())
709    }
710
711    pub fn path(&self, pwd: &Path, key: &str) -> PathBuf {
712        let mut path = self.root(pwd).join(key);
713        match self {
714            KeyType::Identity | KeyType::Network => path.set_extension("toml"),
715            KeyType::ContractIds => path.set_extension("json"),
716        };
717        path
718    }
719
720    pub fn list_paths(&self, paths: &[Location]) -> Result<Vec<(String, Location)>, Error> {
721        Ok(paths
722            .iter()
723            .unique_by(|p| location_to_string(p))
724            .flat_map(|p| self.list(p, true).unwrap_or_default())
725            .collect())
726    }
727
728    pub fn list_paths_silent(&self, paths: &[Location]) -> Result<Vec<(String, Location)>, Error> {
729        Ok(paths
730            .iter()
731            .flat_map(|p| self.list(p, false).unwrap_or_default())
732            .collect())
733    }
734
735    #[allow(unused_variables)]
736    pub fn list(
737        &self,
738        pwd: &Location,
739        print_warning: bool,
740    ) -> Result<Vec<(String, Location)>, Error> {
741        let path = self.root(pwd.as_ref());
742        if path.exists() {
743            let mut files = self.read_dir(&path)?;
744            files.sort();
745
746            if let Location::Local(config_dir) = pwd {
747                if files.len() > 1 && print_warning {
748                    print_deprecation_warning(config_dir);
749                }
750            }
751
752            Ok(files
753                .into_iter()
754                .map(|(name, p)| (name, pwd.wrap(p)))
755                .collect())
756        } else {
757            Ok(vec![])
758        }
759    }
760
761    fn read_dir(&self, dir: &Path) -> Result<Vec<(String, PathBuf)>, Error> {
762        let contents = std::fs::read_dir(dir)?;
763        let mut res = vec![];
764        for entry in contents.filter_map(Result::ok) {
765            let path = entry.path();
766            let extension = match self {
767                KeyType::Identity | KeyType::Network => "toml",
768                KeyType::ContractIds => "json",
769            };
770            if let Some(ext) = path.extension().and_then(OsStr::to_str) {
771                if ext == extension {
772                    if let Some(os_str) = path.file_stem() {
773                        res.push((os_str.to_string_lossy().trim().to_string(), path));
774                    }
775                }
776            }
777        }
778        res.sort();
779        Ok(res)
780    }
781
782    pub fn remove(&self, key: &str, pwd: &Path) -> Result<(), Error> {
783        let path = self.path(pwd, key);
784
785        if path.exists() {
786            std::fs::remove_file(&path)
787                .map_err(|_| Error::ConfigRemoval(self.to_string(), key.to_string()))
788        } else {
789            Ok(())
790        }
791    }
792}
793
794fn global_config_path() -> Result<PathBuf, Error> {
795    if let Ok(config_home) = std::env::var("STELLAR_CONFIG_HOME") {
796        return PathBuf::from_str(&config_home).map_err(|_| Error::StellarConfigDir(config_home));
797    }
798
799    let config_dir = if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
800        PathBuf::from_str(&config_home).map_err(|_| Error::XdgConfigHome(config_home))?
801    } else {
802        UserDirs::new()
803            .ok_or(Error::HomeDirNotFound)?
804            .home_dir()
805            .join(".config")
806    };
807
808    let soroban_dir = config_dir.join("soroban");
809    let stellar_dir = config_dir.join("stellar");
810    let soroban_exists = soroban_dir.exists();
811    let stellar_exists = stellar_dir.exists();
812
813    if stellar_exists && soroban_exists {
814        tracing::warn!("the .stellar and .soroban config directories exist at path {config_dir:?}, using the .stellar");
815    }
816
817    if stellar_exists {
818        return Ok(stellar_dir);
819    }
820
821    if soroban_exists {
822        return Ok(soroban_dir);
823    }
824
825    Ok(stellar_dir)
826}
827
828fn location_to_string(location: &Location) -> String {
829    match location {
830        Location::Local(p) | Location::Global(p) => fs::canonicalize(AsRef::<Path>::as_ref(p))
831            .unwrap_or(p.clone())
832            .display()
833            .to_string(),
834    }
835}
836
837// Use locator.global_config_path() to save configurations.
838// This is only to be used to fetch global Stellar config (e.g. to use for defaults)
839pub fn cli_config_file() -> Result<PathBuf, Error> {
840    Ok(global_config_path()?.join("config.toml"))
841}
842
843#[cfg(all(test, unix))]
844mod tests {
845    use super::*;
846    use std::collections::HashMap;
847
848    #[test]
849    fn test_write_sets_file_permissions_to_0600() {
850        use std::os::unix::fs::PermissionsExt;
851
852        let dir = tempfile::tempdir().unwrap();
853        let value: HashMap<String, String> = HashMap::new();
854        let path = KeyType::Identity
855            .write("test-key", &value, dir.path())
856            .unwrap();
857
858        let perms = std::fs::metadata(&path).unwrap().permissions();
859
860        assert_eq!(
861            perms.mode() & 0o777,
862            0o600,
863            "identity file should be owner-only readable (0600), got {:o}",
864            perms.mode() & 0o777
865        );
866    }
867
868    #[test]
869    fn test_ensure_directory_sets_dir_permissions_to_0700() {
870        use std::os::unix::fs::PermissionsExt;
871
872        let dir = tempfile::tempdir().unwrap();
873        let target = dir.path().join("sub").join("file.toml");
874        ensure_directory(target).unwrap();
875
876        let perms = std::fs::metadata(dir.path().join("sub"))
877            .unwrap()
878            .permissions();
879
880        assert_eq!(
881            perms.mode() & 0o777,
882            0o700,
883            "identity directory should be owner-only (0700), got {:o}",
884            perms.mode() & 0o777
885        );
886    }
887}