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 serialize")]
64 ConfigSerialization,
65 #[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 #[arg(long, global = true, help_heading = HEADING_GLOBAL)]
110 pub global: bool,
111
112 #[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}