casper-cli 0.2.1

CLI wallet manager for Casper: create/recover wallets, derive accounts, and deploy smart contracts.
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};

use crate::network;
use crate::secure_storage::SecureStorage;
use crate::secure_storage::file::FileSecureStorage;
use crate::secure_storage::keyring::KeyringSecureStorage;

const CONFIG_DIR_NAME: &str = "casper-cli";
const SECRETS_DIR_NAME: &str = "secrets";

#[derive(Clone, Debug)]
pub enum StorageBackend {
    File { base_dir: PathBuf },
    Keyring,
}

#[derive(Clone, Debug)]
pub struct StorageConfig {
    backend: StorageBackend,
}

impl StorageConfig {
    pub(crate) fn from_config(context: &network::ConfigContext) -> Result<Self> {
        network::ensure_default_config_with_options(context.path(), context.options())?;
        Self::from_config_path(context.path(), context.options().storage_override.as_ref())
    }

    pub fn base_dir(&self) -> Result<PathBuf> {
        match &self.backend {
            StorageBackend::File { base_dir } => Ok(base_dir.clone()),
            StorageBackend::Keyring => default_base_dir(),
        }
    }

    pub fn is_keyring(&self) -> bool {
        matches!(self.backend, StorageBackend::Keyring)
    }

    pub fn secret_storage(&self) -> Result<Box<dyn SecureStorage>> {
        match &self.backend {
            StorageBackend::File { base_dir } => Ok(Box::new(FileSecureStorage::new(
                base_dir.join(SECRETS_DIR_NAME),
            ))),
            StorageBackend::Keyring => Ok(Box::new(KeyringSecureStorage::new())),
        }
    }

    pub fn secret_location(&self, wallet_name: &str) -> Result<String> {
        match &self.backend {
            StorageBackend::File { base_dir } => Ok(base_dir
                .join(SECRETS_DIR_NAME)
                .join(format!("{}.enc", wallet_name))
                .display()
                .to_string()),
            StorageBackend::Keyring => Ok("keyring".to_string()),
        }
    }

    fn from_config_path(
        path: &Path,
        storage_override: Option<&network::StorageOverride>,
    ) -> Result<Self> {
        if let Some(override_choice) = storage_override {
            return Ok(Self {
                backend: match override_choice {
                    network::StorageOverride::Keyring => StorageBackend::Keyring,
                    network::StorageOverride::File { root_path } => {
                        if root_path.trim().is_empty() {
                            bail!("--file-storage root_path is empty");
                        }
                        StorageBackend::File {
                            base_dir: file_base_dir_from_path(root_path)?,
                        }
                    }
                },
            });
        }
        let data = fs::read_to_string(path)
            .with_context(|| format!("failed to read {}", path.display()))?;
        let config: StorageConfigToml =
            toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))?;
        let storage = config
            .storage
            .ok_or_else(|| anyhow::anyhow!("config.toml missing [storage] section"))?;
        match storage {
            StorageSection::File { root_path } => {
                if root_path.trim().is_empty() {
                    bail!("config.toml storage.file.root_path is empty");
                }
                let base_dir = file_base_dir_from_path(&root_path)?;
                Ok(Self {
                    backend: StorageBackend::File { base_dir },
                })
            }
            StorageSection::Keyring => Ok(Self {
                backend: StorageBackend::Keyring,
            }),
        }
    }
}

#[derive(Deserialize)]
struct StorageConfigToml {
    storage: Option<StorageSection>,
}

#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum StorageSection {
    File { root_path: String },
    Keyring,
}

fn file_base_dir_from_path(path: &str) -> Result<PathBuf> {
    let path = PathBuf::from(path);
    let is_json = path
        .extension()
        .and_then(|ext| ext.to_str())
        .map(|ext| ext.eq_ignore_ascii_case("json"))
        .unwrap_or(false);
    if is_json || path.is_file() {
        return path
            .parent()
            .map(Path::to_path_buf)
            .ok_or_else(|| anyhow::anyhow!("storage path has no parent directory"));
    }
    Ok(path)
}

fn default_base_dir() -> Result<PathBuf> {
    Ok(dirs::config_dir()
        .or_else(|| std::env::current_dir().ok())
        .context("unable to determine config directory")?
        .join(CONFIG_DIR_NAME))
}