mod credentials;
mod profiles;
mod secret;
mod settings;
mod storage;
use std::fmt;
use std::path::PathBuf;
use std::sync::{Mutex, PoisonError};
use crate::net::Transport;
use credentials::{KeyringStore, SecretStore};
use storage::Storage;
pub use credentials::ResolvedCredentials;
pub use profiles::{
CertField, CertFieldSource, IdentityMethod, ServerProfile, SigAuto, SigField, SigFieldValue,
TrustAnchors, BUILTIN_PROFILES, EKENG,
};
pub use secret::Secret;
pub use settings::{ConfigLayer, ResolvedServerConfig, SignerInfo, SYSTEM_LANGUAGE};
const CONFIG_DIR_NAME: &str = ".revenant";
#[derive(Debug, Clone)]
struct SessionCredentials {
username: String,
password: Secret,
}
enum EnvSource {
Process,
#[cfg(test)]
Fixed(std::collections::HashMap<String, String>),
}
impl EnvSource {
fn get(&self, key: &str) -> Option<String> {
match self {
EnvSource::Process => std::env::var(key).ok(),
#[cfg(test)]
EnvSource::Fixed(vars) => vars.get(key).cloned(),
}
}
}
impl fmt::Debug for EnvSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EnvSource::Process => f.write_str("Process"),
#[cfg(test)]
EnvSource::Fixed(vars) => write!(f, "Fixed({} vars)", vars.len()),
}
}
}
#[derive(Debug)]
pub struct ConfigStore {
storage: Storage,
secrets: Box<dyn SecretStore>,
env: EnvSource,
session: Mutex<Option<SessionCredentials>>,
}
impl ConfigStore {
#[must_use]
pub fn new() -> Self {
ConfigStore {
storage: Storage::new(default_config_dir()),
secrets: Box::new(KeyringStore::new(credentials::KEYRING_SERVICE)),
env: EnvSource::Process,
session: Mutex::new(None),
}
}
#[must_use]
pub fn config_file(&self) -> &std::path::Path {
self.storage.file()
}
fn env_nonempty(&self, key: &str) -> Option<String> {
self.env
.get(key)
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}
fn session_snapshot(&self) -> Option<SessionCredentials> {
self.session
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
}
impl Default for ConfigStore {
fn default() -> Self {
Self::new()
}
}
fn default_config_dir() -> PathBuf {
if let Some(home) = std::env::home_dir() {
home.join(CONFIG_DIR_NAME)
} else {
log::warn!("Could not determine home directory; using ./{CONFIG_DIR_NAME}");
PathBuf::from(CONFIG_DIR_NAME)
}
}
pub fn register_profile_tls_mode(transport: &Transport, profile: &ServerProfile) {
let Ok(parsed) = url::Url::parse(&profile.url) else {
return;
};
if let Some(host) = parsed.host_str() {
transport.register_host_tls(host, profile.tls_mode.clone());
}
}
pub fn register_active_profile_tls(transport: &Transport, store: &ConfigStore) {
if let Some(profile) = store.active_profile() {
register_profile_tls_mode(transport, &profile);
}
}
#[cfg(test)]
mod test_support {
use super::*;
use credentials::MockSecretStore;
use std::collections::HashMap;
use tempfile::TempDir;
impl ConfigStore {
pub(super) fn with_parts(
dir: PathBuf,
secrets: Box<dyn SecretStore>,
env: HashMap<String, String>,
) -> Self {
ConfigStore {
storage: Storage::new(dir),
secrets,
env: EnvSource::Fixed(env),
session: Mutex::new(None),
}
}
}
pub(crate) fn test_store() -> (TempDir, ConfigStore) {
let dir = tempfile::tempdir().expect("temp dir");
let store = ConfigStore::with_parts(
dir.path().to_path_buf(),
Box::new(MockSecretStore::working()),
HashMap::new(),
);
(dir, store)
}
pub(crate) fn test_store_with_env(env: &[(&str, &str)]) -> (TempDir, ConfigStore) {
let dir = tempfile::tempdir().expect("temp dir");
let vars = env
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect();
let store = ConfigStore::with_parts(
dir.path().to_path_buf(),
Box::new(MockSecretStore::working()),
vars,
);
(dir, store)
}
}
#[cfg(test)]
pub(crate) use test_support::{test_store, test_store_with_env};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_file_path_is_under_config_dir() {
let (dir, store) = test_store();
assert_eq!(store.config_file(), dir.path().join("config.json"));
}
#[test]
fn debug_never_leaks_session_password() {
let (_dir, store) = test_store();
store.set_session_credentials("user", "s3cr3t-pw");
let rendered = format!("{store:?}");
assert!(!rendered.contains("s3cr3t-pw"), "leaked: {rendered}");
}
#[test]
fn default_config_dir_uses_home() {
let dir = default_config_dir();
assert!(dir.ends_with(CONFIG_DIR_NAME));
}
#[test]
fn tls_bridge_registers_profile_mode() {
let transport = Transport::new();
let ekeng = ServerProfile::builtin(EKENG).unwrap();
register_profile_tls_mode(&transport, &ekeng);
assert_eq!(
transport.host_tls_info("ca.gov.am"),
"Legacy TLS (RC4, pinned key)"
);
}
#[test]
fn tls_bridge_registers_active_profile_from_config() {
let (_dir, store) = test_store();
store
.save_server_config(&ServerProfile::builtin(EKENG).unwrap())
.unwrap();
let transport = Transport::new();
register_active_profile_tls(&transport, &store);
assert_eq!(
transport.host_tls_info("ca.gov.am"),
"Legacy TLS (RC4, pinned key)"
);
}
#[test]
fn tls_bridge_registers_standard_for_custom_https() {
let (_dir, store) = test_store();
let custom = ServerProfile::custom_default("https://example.com/DSS.asmx").unwrap();
store.save_server_config(&custom).unwrap();
let transport = Transport::new();
register_active_profile_tls(&transport, &store);
assert_eq!(transport.host_tls_info("example.com"), "Standard HTTPS");
}
}