use db_core::{ConnectionTarget, CoreError}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use std::{env, error::Error, fmt, fs, io}; pub const PROFILE_STORE_VERSION: u32 = 1; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ConnectionProfile { pub name: String, pub target: ConnectionTarget, pub password_env_var: Option, } impl ConnectionProfile { pub fn new( name: impl Into, target: ConnectionTarget, password_env_var: Option, ) -> Result { let profile = Self { name: name.into(), target, password_env_var, }; profile.validate()?; Ok(profile) } pub fn validate(&self) -> Result<(), CoreError> { if self.name.trim().is_empty() { return Err(CoreError::InvalidConnection("profile name is empty")); } self.target.validate() } pub fn redacted_summary(&self) -> String { match &self.password_env_var { Some(password_env_var) => format!( "{} via password env {}", self.target.redacted_endpoint(), password_env_var ), None => self.target.redacted_endpoint(), } } pub fn with_password(mut self, password: Option) -> Self { self.target.password = password; self } pub fn without_password(mut self) -> Self { self.target.password = None; self } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ConnectionProfileCatalog { #[serde(default = "profile_store_version")] pub version: u32, #[serde(default)] pub active_profile: Option, #[serde(default)] pub profiles: Vec, } impl Default for ConnectionProfileCatalog { fn default() -> Self { Self::empty() } } impl ConnectionProfileCatalog { pub fn empty() -> Self { Self { version: PROFILE_STORE_VERSION, active_profile: None, profiles: Vec::new(), } } pub fn validate(&self) -> Result<(), ConfigError> { if self.version != PROFILE_STORE_VERSION { return Err(ConfigError::UnsupportedVersion(self.version)); } let mut seen_names = BTreeSet::new(); for profile in &self.profiles { profile.validate().map_err(ConfigError::Core)?; if profile.target.password.is_some() { return Err(ConfigError::SecretPersistenceForbidden( profile.name.clone(), )); } if !seen_names.insert(profile.name.clone()) { return Err(ConfigError::DuplicateProfileName(profile.name.clone())); } } if let Some(active_profile) = &self.active_profile { if self.profile(active_profile).is_none() { return Err(ConfigError::MissingActiveProfile(active_profile.clone())); } } Ok(()) } pub fn profile(&self, name: &str) -> Option<&ConnectionProfile> { self.profiles.iter().find(|profile| profile.name == name) } pub fn active_profile(&self) -> Option<&ConnectionProfile> { self.active_profile .as_deref() .and_then(|name| self.profile(name)) } pub fn upsert_profile(&mut self, profile: ConnectionProfile) -> Result<(), ConfigError> { let sanitized = profile.without_password(); sanitized.validate().map_err(ConfigError::Core)?; if let Some(existing) = self .profiles .iter_mut() .find(|existing| existing.name == sanitized.name) { *existing = sanitized; } else { self.profiles.push(sanitized); } self.validate() } pub fn remove_profile(&mut self, name: &str) -> Option { let index = self .profiles .iter() .position(|profile| profile.name == name)?; let removed = self.profiles.remove(index); if self.active_profile.as_deref() == Some(name) { self.active_profile = None; } Some(removed) } pub fn set_active_profile(&mut self, name: Option<&str>) -> Result<(), ConfigError> { match name { Some(name) => { if self.profile(name).is_none() { return Err(ConfigError::ProfileNotFound(name.to_string())); } self.active_profile = Some(name.to_string()); } None => { self.active_profile = None; } } self.validate() } } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct SessionSecretStore { secrets: BTreeMap, } impl SessionSecretStore { pub fn set( &mut self, profile_name: impl Into, secret: impl Into, ) -> Result<(), ConfigError> { let profile_name = profile_name.into(); let secret = secret.into(); if profile_name.trim().is_empty() { return Err(ConfigError::InvalidSessionSecret( "profile name is empty".into(), )); } if secret.is_empty() { return Err(ConfigError::InvalidSessionSecret( "session secret is empty".into(), )); } self.secrets.insert(profile_name, secret); Ok(()) } pub fn remove(&mut self, profile_name: &str) -> Option { self.secrets.remove(profile_name) } pub fn contains(&self, profile_name: &str) -> bool { self.secrets.contains_key(profile_name) } pub fn resolve_password(&self, profile: &ConnectionProfile) -> Option { self.secrets.get(&profile.name).cloned().or_else(|| { profile .password_env_var .as_ref() .and_then(|env_name| env::var(env_name).ok()) }) } pub fn materialize_profile(&self, profile: &ConnectionProfile) -> ConnectionProfile { profile .clone() .with_password(self.resolve_password(profile)) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ConnectionProfileStore { path: PathBuf, } impl ConnectionProfileStore { pub fn new(path: impl Into) -> Self { Self { path: path.into() } } pub fn path(&self) -> &Path { &self.path } pub fn load(&self) -> Result { if !self.path.exists() { return Ok(ConnectionProfileCatalog::empty()); } let body = fs::read_to_string(&self.path).map_err(|source| ConfigError::Io { path: self.path.clone(), operation: "read", source, })?; let catalog = serde_json::from_str::(&body).map_err(|source| { ConfigError::Parse { path: self.path.clone(), source, } })?; catalog.validate()?; Ok(catalog) } pub fn save(&self, catalog: &ConnectionProfileCatalog) -> Result<(), ConfigError> { catalog.validate()?; if let Some(parent) = self.path.parent() { fs::create_dir_all(parent).map_err(|source| ConfigError::Io { path: parent.to_path_buf(), operation: "create_dir_all", source, })?; } let body = serde_json::to_string_pretty(catalog).map_err(ConfigError::Serialize)?; let temp_path = self.temporary_path(); fs::write(&temp_path, body).map_err(|source| ConfigError::Io { path: temp_path.clone(), operation: "write", source, })?; if self.path.exists() { fs::remove_file(&self.path).map_err(|source| ConfigError::Io { path: self.path.clone(), operation: "remove", source, })?; } fs::rename(&temp_path, &self.path).map_err(|source| ConfigError::Io { path: self.path.clone(), operation: "rename", source, })?; Ok(()) } fn temporary_path(&self) -> PathBuf { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time should be after epoch") .as_nanos(); self.path.with_extension(format!("tmp-{unique}")) } } fn profile_store_version() -> u32 { PROFILE_STORE_VERSION } #[derive(Debug)] pub enum ConfigError { Core(CoreError), DuplicateProfileName(String), MissingActiveProfile(String), ProfileNotFound(String), SecretPersistenceForbidden(String), InvalidSessionSecret(String), UnsupportedVersion(u32), Io { path: PathBuf, operation: &'static str, source: io::Error, }, Parse { path: PathBuf, source: serde_json::Error, }, Serialize(serde_json::Error), } impl fmt::Display for ConfigError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Core(error) => error.fmt(formatter), Self::DuplicateProfileName(name) => { write!(formatter, "connection profile `{name}` is duplicated") } Self::MissingActiveProfile(name) => { write!( formatter, "active profile `{name}` is missing from the catalog" ) } Self::ProfileNotFound(name) => { write!(formatter, "connection profile `{name}` was not found") } Self::SecretPersistenceForbidden(name) => write!( formatter, "connection profile `{name}` contains an in-memory secret and cannot be persisted" ), Self::InvalidSessionSecret(message) => formatter.write_str(message), Self::UnsupportedVersion(version) => write!( formatter, "connection profile store version `{version}` is unsupported" ), Self::Io { path, operation, source, } => write!( formatter, "failed to {operation} connection profile store {}: {source}", path.display() ), Self::Parse { path, source } => write!( formatter, "failed to parse connection profile store {}: {source}", path.display() ), Self::Serialize(source) => write!( formatter, "failed to serialize connection profile store: {source}" ), } } } impl Error for ConfigError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { Self::Core(error) => Some(error), Self::Io { source, .. } => Some(source), Self::Parse { source, .. } => Some(source), Self::Serialize(source) => Some(source), _ => None, } } } #[cfg(test)] mod tests { use db_core::{ConnectionTransport, DatabaseKind}; use std::time::{SystemTime, UNIX_EPOCH}; use super::*; #[test] fn rejects_empty_profile_name() { let target = ConnectionTarget::new( DatabaseKind::Sqlite, ConnectionTransport::File { path: "demo.sqlite".into(), }, None, None, ) .expect("target should be valid"); let result = ConnectionProfile::new(" ", target, None); assert_eq!( result, Err(CoreError::InvalidConnection("profile name is empty")) ); } #[test] fn summary_mentions_password_env_without_secret() { let target = ConnectionTarget::new( DatabaseKind::Postgres, ConnectionTransport::Tcp { host: String::from("127.0.0.1"), port: 55432, }, Some(String::from("dbtool_demo")), Some(String::from("dbtool")), ) .expect("target should be valid"); let profile = ConnectionProfile::new("local", target, Some(String::from("DBTOOL_PASSWORD"))) .expect("profile should be valid"); assert_eq!( profile.redacted_summary(), "postgres://dbtool@127.0.0.1:55432/dbtool_demo via password env DBTOOL_PASSWORD" ); } #[test] fn injects_password_without_changing_redacted_output() { let target = ConnectionTarget::new( DatabaseKind::Postgres, ConnectionTransport::Tcp { host: String::from("127.0.0.1"), port: 55432, }, Some(String::from("dbtool_demo")), Some(String::from("dbtool")), ) .expect("target should be valid"); let profile = ConnectionProfile::new("local", target, Some(String::from("DBTOOL_PASSWORD"))) .expect("profile should be valid") .with_password(Some(String::from("secret"))); assert_eq!(profile.target.password.as_deref(), Some("secret")); assert_eq!( profile.redacted_summary(), "postgres://dbtool@127.0.0.1:55432/dbtool_demo via password env DBTOOL_PASSWORD" ); } #[test] fn catalog_rejects_profiles_with_in_memory_passwords() { let profile = sample_postgres_profile().with_password(Some(String::from("secret"))); let catalog = ConnectionProfileCatalog { version: PROFILE_STORE_VERSION, active_profile: Some(String::from("local")), profiles: vec![profile], }; let error = catalog .validate() .expect_err("catalog should reject secrets"); assert!(matches!( error, ConfigError::SecretPersistenceForbidden(name) if name == "local" )); } #[test] fn store_roundtrip_preserves_active_profile_without_password() { let path = temp_store_path("db-config-store-roundtrip"); let store = ConnectionProfileStore::new(&path); let mut catalog = ConnectionProfileCatalog::empty(); catalog .upsert_profile(sample_postgres_profile()) .expect("profile should be inserted"); catalog .set_active_profile(Some("local")) .expect("active profile should be valid"); store.save(&catalog).expect("catalog should save"); let raw = fs::read_to_string(&path).expect("store file should exist"); let restored = store.load().expect("catalog should load"); assert!(raw.contains("\"active_profile\": \"local\"")); assert!(!raw.contains("\"password\":")); assert_eq!(restored.active_profile.as_deref(), Some("local")); assert_eq!(restored.profiles.len(), 1); assert_eq!(restored.profiles[0].target.password, None); fs::remove_file(path).expect("temporary store should be removed"); } #[test] fn session_secret_materializes_runtime_profile() { let profile = sample_postgres_profile(); let mut secrets = SessionSecretStore::default(); secrets .set("local", "from-session") .expect("session secret should be set"); let materialized = secrets.materialize_profile(&profile); assert_eq!( materialized.target.password.as_deref(), Some("from-session") ); } #[test] fn removing_active_profile_clears_active_pointer() { let mut catalog = ConnectionProfileCatalog::empty(); catalog .upsert_profile(sample_postgres_profile()) .expect("profile should be inserted"); catalog .set_active_profile(Some("local")) .expect("active profile should be set"); let removed = catalog.remove_profile("local"); assert!(removed.is_some()); assert_eq!(catalog.active_profile, None); } fn sample_postgres_profile() -> ConnectionProfile { ConnectionProfile::new( "local", ConnectionTarget::new( DatabaseKind::Postgres, ConnectionTransport::Tcp { host: String::from("127.0.0.1"), port: 55432, }, Some(String::from("dbtool_demo")), Some(String::from("dbtool")), ) .expect("target should be valid"), Some(String::from("DBTOOL_PASSWORD")), ) .expect("profile should be valid") } fn temp_store_path(prefix: &str) -> PathBuf { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system time should be after epoch") .as_nanos(); env::temp_dir().join(format!("{prefix}-{unique}.json")) } }