feat(usable): integrate current dbtool implementation snapshot
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
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};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub const PROFILE_STORE_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConnectionProfile {
|
||||
pub name: String,
|
||||
pub target: ConnectionTarget,
|
||||
@@ -46,11 +53,350 @@ impl ConnectionProfile {
|
||||
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::{ConnectionTarget, ConnectionTransport, DatabaseKind};
|
||||
use db_core::{ConnectionTransport, DatabaseKind};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -120,4 +466,108 @@ mod tests {
|
||||
"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"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user