574 lines
17 KiB
Rust
574 lines
17 KiB
Rust
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<String>,
|
|
}
|
|
|
|
impl ConnectionProfile {
|
|
pub fn new(
|
|
name: impl Into<String>,
|
|
target: ConnectionTarget,
|
|
password_env_var: Option<String>,
|
|
) -> Result<Self, CoreError> {
|
|
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<String>) -> 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<String>,
|
|
#[serde(default)]
|
|
pub profiles: Vec<ConnectionProfile>,
|
|
}
|
|
|
|
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<ConnectionProfile> {
|
|
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<String, String>,
|
|
}
|
|
|
|
impl SessionSecretStore {
|
|
pub fn set(
|
|
&mut self,
|
|
profile_name: impl Into<String>,
|
|
secret: impl Into<String>,
|
|
) -> 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<String> {
|
|
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<String> {
|
|
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<PathBuf>) -> Self {
|
|
Self { path: path.into() }
|
|
}
|
|
|
|
pub fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
|
|
pub fn load(&self) -> Result<ConnectionProfileCatalog, ConfigError> {
|
|
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::<ConnectionProfileCatalog>(&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"))
|
|
}
|
|
}
|