feat(usable): integrate current dbtool implementation snapshot
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
use db_config::ConnectionProfile;
|
||||
use db_config::{
|
||||
ConfigError, ConnectionProfile, ConnectionProfileCatalog, ConnectionProfileStore,
|
||||
SessionSecretStore,
|
||||
};
|
||||
use db_core::{
|
||||
ColumnSummary, CoreError, ExportFormat, ExportRequest, InspectRequest, InspectResult,
|
||||
QueryRequest, QueryResult, QuerySource, SchemaSummary, TableSummary,
|
||||
QueryRequest, QueryResult, QuerySource, SchemaAvailability as CoreSchemaAvailability,
|
||||
SchemaSummary, TableSummary,
|
||||
};
|
||||
use db_drivers::{DriverError, DriverRegistry};
|
||||
use serde::Serialize;
|
||||
@@ -14,6 +18,10 @@ pub enum AppOperation {
|
||||
Inspect,
|
||||
Query,
|
||||
Export,
|
||||
LoadProfiles,
|
||||
SaveProfile,
|
||||
DeleteProfile,
|
||||
ActivateProfile,
|
||||
}
|
||||
|
||||
impl AppOperation {
|
||||
@@ -23,6 +31,10 @@ impl AppOperation {
|
||||
Self::Inspect => "inspect",
|
||||
Self::Query => "query",
|
||||
Self::Export => "export",
|
||||
Self::LoadProfiles => "load_profiles",
|
||||
Self::SaveProfile => "save_profile",
|
||||
Self::DeleteProfile => "delete_profile",
|
||||
Self::ActivateProfile => "activate_profile",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +44,10 @@ impl AppOperation {
|
||||
"inspect" => Self::Inspect,
|
||||
"query" => Self::Query,
|
||||
"export" => Self::Export,
|
||||
"load_profiles" => Self::LoadProfiles,
|
||||
"save_profile" => Self::SaveProfile,
|
||||
"delete_profile" => Self::DeleteProfile,
|
||||
"activate_profile" => Self::ActivateProfile,
|
||||
other => panic!("unsupported app operation: {other}"),
|
||||
}
|
||||
}
|
||||
@@ -115,6 +131,41 @@ impl ConnectResponse {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SecretSource {
|
||||
None,
|
||||
Session,
|
||||
EnvVar,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct StoredProfileSummary {
|
||||
pub target: ConnectionSummary,
|
||||
pub is_active: bool,
|
||||
pub secret_source: SecretSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct ProfileCatalogResponse {
|
||||
pub active_profile_name: Option<String>,
|
||||
pub profiles: Vec<StoredProfileSummary>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SessionSecretInput {
|
||||
Preserve,
|
||||
Set(String),
|
||||
Clear,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SaveProfileRequest {
|
||||
pub profile: ConnectionProfile,
|
||||
pub set_active: bool,
|
||||
pub session_secret: SessionSecretInput,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct InspectScope {
|
||||
pub schema: Option<String>,
|
||||
@@ -164,14 +215,37 @@ impl InspectResponse {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SchemaAvailability {
|
||||
Ready,
|
||||
Restricted,
|
||||
}
|
||||
|
||||
impl From<CoreSchemaAvailability> for SchemaAvailability {
|
||||
fn from(value: CoreSchemaAvailability) -> Self {
|
||||
match value {
|
||||
CoreSchemaAvailability::Ready => Self::Ready,
|
||||
CoreSchemaAvailability::Restricted => Self::Restricted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct SchemaItem {
|
||||
pub name: String,
|
||||
pub availability: SchemaAvailability,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
impl From<SchemaSummary> for SchemaItem {
|
||||
fn from(value: SchemaSummary) -> Self {
|
||||
Self { name: value.name }
|
||||
Self {
|
||||
name: value.name,
|
||||
availability: SchemaAvailability::from(value.availability),
|
||||
note: value.note,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +423,7 @@ pub enum AppErrorKind {
|
||||
Inspect,
|
||||
Query,
|
||||
Export,
|
||||
Config,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
@@ -397,6 +472,120 @@ pub fn connect(profile: &ConnectionProfile) -> Result<ConnectResponse, AppError>
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_profile_catalog(
|
||||
store: &ConnectionProfileStore,
|
||||
secrets: &SessionSecretStore,
|
||||
) -> Result<ProfileCatalogResponse, AppError> {
|
||||
let catalog = load_catalog(store, "load_profiles")?;
|
||||
Ok(profile_catalog_response(&catalog, secrets))
|
||||
}
|
||||
|
||||
pub fn save_profile(
|
||||
store: &ConnectionProfileStore,
|
||||
secrets: &mut SessionSecretStore,
|
||||
request: SaveProfileRequest,
|
||||
) -> Result<ProfileCatalogResponse, AppError> {
|
||||
validate_profile("save_profile", &request.profile)?;
|
||||
|
||||
let inline_secret = request.profile.target.password.clone();
|
||||
let mut catalog = load_catalog(store, "save_profile")?;
|
||||
catalog
|
||||
.upsert_profile(request.profile.clone())
|
||||
.map_err(|error| map_config_error("save_profile", Some(&request.profile), error))?;
|
||||
|
||||
if request.set_active {
|
||||
catalog
|
||||
.set_active_profile(Some(&request.profile.name))
|
||||
.map_err(|error| map_config_error("save_profile", Some(&request.profile), error))?;
|
||||
}
|
||||
|
||||
apply_session_secret_change(
|
||||
secrets,
|
||||
&request.profile,
|
||||
request.session_secret,
|
||||
inline_secret,
|
||||
)
|
||||
.map_err(|error| map_config_error("save_profile", Some(&request.profile), error))?;
|
||||
|
||||
save_catalog(store, "save_profile", Some(&request.profile), &catalog)?;
|
||||
Ok(profile_catalog_response(&catalog, secrets))
|
||||
}
|
||||
|
||||
pub fn delete_profile(
|
||||
store: &ConnectionProfileStore,
|
||||
secrets: &mut SessionSecretStore,
|
||||
profile_name: &str,
|
||||
) -> Result<ProfileCatalogResponse, AppError> {
|
||||
let mut catalog = load_catalog(store, "delete_profile")?;
|
||||
if catalog.remove_profile(profile_name).is_none() {
|
||||
return Err(map_config_error(
|
||||
"delete_profile",
|
||||
None,
|
||||
ConfigError::ProfileNotFound(profile_name.to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
secrets.remove(profile_name);
|
||||
save_catalog(store, "delete_profile", None, &catalog)?;
|
||||
|
||||
Ok(profile_catalog_response(&catalog, secrets))
|
||||
}
|
||||
|
||||
pub fn activate_profile(
|
||||
store: &ConnectionProfileStore,
|
||||
secrets: &SessionSecretStore,
|
||||
profile_name: &str,
|
||||
) -> Result<ProfileCatalogResponse, AppError> {
|
||||
let mut catalog = load_catalog(store, "activate_profile")?;
|
||||
catalog
|
||||
.set_active_profile(Some(profile_name))
|
||||
.map_err(|error| map_config_error("activate_profile", None, error))?;
|
||||
save_catalog(
|
||||
store,
|
||||
"activate_profile",
|
||||
catalog.profile(profile_name),
|
||||
&catalog,
|
||||
)?;
|
||||
|
||||
Ok(profile_catalog_response(&catalog, secrets))
|
||||
}
|
||||
|
||||
pub fn resolve_profile(
|
||||
store: &ConnectionProfileStore,
|
||||
secrets: &SessionSecretStore,
|
||||
profile_name: &str,
|
||||
) -> Result<ConnectionProfile, AppError> {
|
||||
let catalog = load_catalog(store, "load_profiles")?;
|
||||
let profile = catalog.profile(profile_name).ok_or_else(|| {
|
||||
map_config_error(
|
||||
"load_profiles",
|
||||
None,
|
||||
ConfigError::ProfileNotFound(profile_name.to_string()),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(secrets.materialize_profile(profile))
|
||||
}
|
||||
|
||||
pub fn resolve_active_profile(
|
||||
store: &ConnectionProfileStore,
|
||||
secrets: &SessionSecretStore,
|
||||
) -> Result<Option<ConnectionProfile>, AppError> {
|
||||
let catalog = load_catalog(store, "load_profiles")?;
|
||||
Ok(catalog
|
||||
.active_profile()
|
||||
.map(|profile| secrets.materialize_profile(profile)))
|
||||
}
|
||||
|
||||
pub fn connect_saved_profile(
|
||||
store: &ConnectionProfileStore,
|
||||
secrets: &SessionSecretStore,
|
||||
profile_name: &str,
|
||||
) -> Result<ConnectResponse, AppError> {
|
||||
let profile = resolve_profile(store, secrets, profile_name)?;
|
||||
connect(&profile)
|
||||
}
|
||||
|
||||
pub fn inspect(
|
||||
profile: &ConnectionProfile,
|
||||
request: &InspectRequest,
|
||||
@@ -570,6 +759,93 @@ fn map_driver_error(
|
||||
}
|
||||
}
|
||||
|
||||
fn load_catalog(
|
||||
store: &ConnectionProfileStore,
|
||||
operation: &'static str,
|
||||
) -> Result<ConnectionProfileCatalog, AppError> {
|
||||
store
|
||||
.load()
|
||||
.map_err(|error| map_config_error(operation, None, error))
|
||||
}
|
||||
|
||||
fn save_catalog(
|
||||
store: &ConnectionProfileStore,
|
||||
operation: &'static str,
|
||||
profile: Option<&ConnectionProfile>,
|
||||
catalog: &ConnectionProfileCatalog,
|
||||
) -> Result<(), AppError> {
|
||||
store
|
||||
.save(catalog)
|
||||
.map_err(|error| map_config_error(operation, profile, error))
|
||||
}
|
||||
|
||||
fn map_config_error(
|
||||
operation: &'static str,
|
||||
profile: Option<&ConnectionProfile>,
|
||||
error: ConfigError,
|
||||
) -> AppError {
|
||||
AppError {
|
||||
operation,
|
||||
kind: AppErrorKind::Config,
|
||||
message: error.to_string(),
|
||||
target: profile.map(ConnectionSummary::from),
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_catalog_response(
|
||||
catalog: &ConnectionProfileCatalog,
|
||||
secrets: &SessionSecretStore,
|
||||
) -> ProfileCatalogResponse {
|
||||
ProfileCatalogResponse {
|
||||
active_profile_name: catalog.active_profile.clone(),
|
||||
profiles: catalog
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|profile| StoredProfileSummary {
|
||||
target: ConnectionSummary::from(profile),
|
||||
is_active: catalog.active_profile.as_deref() == Some(profile.name.as_str()),
|
||||
secret_source: profile_secret_source(profile, secrets),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_secret_source(
|
||||
profile: &ConnectionProfile,
|
||||
secrets: &SessionSecretStore,
|
||||
) -> SecretSource {
|
||||
if secrets.contains(&profile.name) {
|
||||
SecretSource::Session
|
||||
} else if profile.password_env_var.is_some() {
|
||||
SecretSource::EnvVar
|
||||
} else {
|
||||
SecretSource::None
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_session_secret_change(
|
||||
secrets: &mut SessionSecretStore,
|
||||
profile: &ConnectionProfile,
|
||||
session_secret: SessionSecretInput,
|
||||
inline_secret: Option<String>,
|
||||
) -> Result<(), ConfigError> {
|
||||
match session_secret {
|
||||
SessionSecretInput::Preserve => {
|
||||
if let Some(secret) = inline_secret {
|
||||
secrets.set(profile.name.clone(), secret)?;
|
||||
}
|
||||
}
|
||||
SessionSecretInput::Set(secret) => {
|
||||
secrets.set(profile.name.clone(), secret)?;
|
||||
}
|
||||
SessionSecretInput::Clear => {
|
||||
secrets.remove(&profile.name);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn export_format_name(format: ExportFormat) -> &'static str {
|
||||
match format {
|
||||
ExportFormat::Csv => "csv",
|
||||
@@ -701,6 +977,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_item_preserves_restricted_availability_and_note() {
|
||||
let item = SchemaItem::from(SchemaSummary::restricted(
|
||||
"restricted_probe",
|
||||
"schema access is restricted: restricted_probe",
|
||||
));
|
||||
|
||||
assert_eq!(item.name, "restricted_probe");
|
||||
assert_eq!(item.availability, SchemaAvailability::Restricted);
|
||||
assert_eq!(
|
||||
item.note.as_deref(),
|
||||
Some("schema access is restricted: restricted_probe")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_response_uses_empty_state_for_empty_payload() {
|
||||
let response = InspectResponse {
|
||||
@@ -796,6 +1087,110 @@ mod tests {
|
||||
assert!(json.get("data").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_profile_persists_redacted_catalog_and_keeps_session_secret_only_in_memory() {
|
||||
let path = temp_store_path("dbtool-app-profile-save");
|
||||
let store = ConnectionProfileStore::new(&path);
|
||||
let mut secrets = SessionSecretStore::default();
|
||||
let profile = ConnectionProfile::new(
|
||||
"reporting-postgres",
|
||||
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")
|
||||
.with_password(Some(String::from("super-secret")));
|
||||
|
||||
let response = save_profile(
|
||||
&store,
|
||||
&mut secrets,
|
||||
SaveProfileRequest {
|
||||
profile,
|
||||
set_active: true,
|
||||
session_secret: SessionSecretInput::Preserve,
|
||||
},
|
||||
)
|
||||
.expect("profile should save");
|
||||
let raw = fs::read_to_string(&path).expect("store file should exist");
|
||||
let resolved =
|
||||
resolve_active_profile(&store, &secrets).expect("active profile should resolve");
|
||||
|
||||
assert_eq!(
|
||||
response.active_profile_name.as_deref(),
|
||||
Some("reporting-postgres")
|
||||
);
|
||||
assert_eq!(response.profiles.len(), 1);
|
||||
assert_eq!(response.profiles[0].secret_source, SecretSource::Session);
|
||||
assert!(!raw.contains("super-secret"));
|
||||
assert!(!raw.contains("\"password\":"));
|
||||
assert_eq!(
|
||||
resolved
|
||||
.expect("active profile should exist")
|
||||
.target
|
||||
.password
|
||||
.as_deref(),
|
||||
Some("super-secret")
|
||||
);
|
||||
|
||||
fs::remove_file(path).expect("temporary store should be removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_saved_profile_uses_persisted_sqlite_target() {
|
||||
let db_path = temp_sqlite_db_path("dbtool-app-connect-saved");
|
||||
let connection =
|
||||
SqliteConnection::open(&db_path).expect("temporary sqlite database should open");
|
||||
connection
|
||||
.execute_batch(include_str!("../../../examples/fixtures/sqlite/init.sql"))
|
||||
.expect("sqlite fixture should seed");
|
||||
drop(connection);
|
||||
|
||||
let store_path = temp_store_path("dbtool-app-profile-connect");
|
||||
let store = ConnectionProfileStore::new(&store_path);
|
||||
let mut catalog = ConnectionProfileCatalog::empty();
|
||||
catalog
|
||||
.upsert_profile(
|
||||
ConnectionProfile::new(
|
||||
"sqlite-local",
|
||||
ConnectionTarget::new(
|
||||
DatabaseKind::Sqlite,
|
||||
ConnectionTransport::File {
|
||||
path: db_path.clone(),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("target should be valid"),
|
||||
None,
|
||||
)
|
||||
.expect("profile should be valid"),
|
||||
)
|
||||
.expect("profile should be inserted");
|
||||
catalog
|
||||
.set_active_profile(Some("sqlite-local"))
|
||||
.expect("active profile should be set");
|
||||
store.save(&catalog).expect("catalog should save");
|
||||
|
||||
let response =
|
||||
connect_saved_profile(&store, &SessionSecretStore::default(), "sqlite-local")
|
||||
.expect("saved sqlite profile should connect");
|
||||
|
||||
assert_eq!(response.target.profile_name, "sqlite-local");
|
||||
assert_eq!(response.target.driver, "sqlite");
|
||||
assert_eq!(response.status, "connected");
|
||||
|
||||
fs::remove_file(store_path).expect("temporary store should be removed");
|
||||
fs::remove_file(db_path).expect("temporary sqlite database should be removed");
|
||||
}
|
||||
|
||||
fn connection_summary() -> ConnectionSummary {
|
||||
ConnectionSummary {
|
||||
profile_name: String::from("qa-demo"),
|
||||
@@ -812,4 +1207,12 @@ mod tests {
|
||||
.as_nanos();
|
||||
env::temp_dir().join(format!("{prefix}-{unique}.sqlite"))
|
||||
}
|
||||
|
||||
fn temp_store_path(prefix: &str) -> std::path::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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
db-core.workspace = true
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,11 @@ edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1.0.145"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DatabaseKind {
|
||||
Postgres,
|
||||
Mysql,
|
||||
@@ -18,18 +20,20 @@ impl DatabaseKind {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ConnectionTransport {
|
||||
Tcp { host: String, port: u16 },
|
||||
File { path: PathBuf },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConnectionTarget {
|
||||
pub kind: DatabaseKind,
|
||||
pub transport: ConnectionTransport,
|
||||
pub database: Option<String>,
|
||||
pub username: Option<String>,
|
||||
#[serde(skip, default)]
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
@@ -142,9 +146,35 @@ impl InspectRequest {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SchemaAvailability {
|
||||
Ready,
|
||||
Restricted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SchemaSummary {
|
||||
pub name: String,
|
||||
pub availability: SchemaAvailability,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
impl SchemaSummary {
|
||||
pub fn ready(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
availability: SchemaAvailability::Ready,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restricted(name: impl Into<String>, note: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
availability: SchemaAvailability::Restricted,
|
||||
note: Some(note.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -381,4 +411,30 @@ mod tests {
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_target_serialization_omits_password() {
|
||||
let mut target = ConnectionTarget::new(
|
||||
DatabaseKind::Postgres,
|
||||
ConnectionTransport::Tcp {
|
||||
host: String::from("127.0.0.1"),
|
||||
port: 5432,
|
||||
},
|
||||
Some(String::from("qa_demo")),
|
||||
Some(String::from("dbtool")),
|
||||
)
|
||||
.expect("target should be valid");
|
||||
target.password = Some(String::from("secret"));
|
||||
|
||||
let json = serde_json::to_string(&target).expect("target should serialize");
|
||||
|
||||
assert!(!json.contains("secret"));
|
||||
assert!(!json.contains("password"));
|
||||
|
||||
let restored: ConnectionTarget =
|
||||
serde_json::from_str(&json).expect("target should deserialize");
|
||||
|
||||
assert_eq!(restored.password, None);
|
||||
assert_eq!(restored.kind, DatabaseKind::Postgres);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,23 +102,27 @@ impl DatabaseDriver for PostgresDriver {
|
||||
(None, None) => {
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT schema_name \
|
||||
FROM information_schema.schemata \
|
||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \
|
||||
ORDER BY schema_name",
|
||||
"SELECT n.nspname AS schema_name,
|
||||
has_schema_privilege(n.nspname, 'USAGE') AS has_usage
|
||||
FROM pg_namespace n
|
||||
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
||||
AND n.nspname NOT LIKE 'pg_toast%'
|
||||
AND n.nspname NOT LIKE 'pg_temp_%'
|
||||
ORDER BY n.nspname",
|
||||
&[],
|
||||
)
|
||||
.map_err(map_postgres_error)?;
|
||||
|
||||
Ok(InspectResult::Schemas(
|
||||
rows.into_iter()
|
||||
.map(|row| SchemaSummary {
|
||||
name: row.get::<_, String>(0),
|
||||
.map(|row| {
|
||||
postgres_schema_summary(row.get::<_, String>(0), row.get::<_, bool>(1))
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
(Some(schema), None) => {
|
||||
ensure_postgres_schema_access(&mut client, schema)?;
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT table_schema, table_name, table_type \
|
||||
@@ -140,6 +144,7 @@ impl DatabaseDriver for PostgresDriver {
|
||||
))
|
||||
}
|
||||
(Some(schema), Some(table)) => {
|
||||
ensure_postgres_schema_access(&mut client, schema)?;
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT c.column_name,
|
||||
@@ -256,7 +261,7 @@ impl DatabaseDriver for MysqlDriver {
|
||||
|
||||
Ok(InspectResult::Schemas(
|
||||
rows.into_iter()
|
||||
.map(|(name,)| SchemaSummary { name })
|
||||
.map(|(name,)| SchemaSummary::ready(name))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
@@ -516,11 +521,7 @@ fn inspect_sqlite_schemas(connection: &SqliteConnection) -> Result<InspectResult
|
||||
.prepare("PRAGMA database_list")
|
||||
.map_err(map_sqlite_inspect_error)?;
|
||||
let schemas = statement
|
||||
.query_map([], |row| {
|
||||
Ok(SchemaSummary {
|
||||
name: row.get::<_, String>(1)?,
|
||||
})
|
||||
})
|
||||
.query_map([], |row| Ok(SchemaSummary::ready(row.get::<_, String>(1)?)))
|
||||
.map_err(map_sqlite_inspect_error)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(map_sqlite_inspect_error)?;
|
||||
@@ -651,6 +652,41 @@ fn simple_query_result(client: &mut Client, sql: &str) -> Result<QueryResult, Dr
|
||||
}
|
||||
}
|
||||
|
||||
fn postgres_schema_summary(name: String, has_usage: bool) -> SchemaSummary {
|
||||
if has_usage {
|
||||
SchemaSummary::ready(name)
|
||||
} else {
|
||||
SchemaSummary::restricted(name.clone(), restricted_schema_message(&name))
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_postgres_schema_access(client: &mut Client, schema: &str) -> Result<(), DriverError> {
|
||||
let row = client
|
||||
.query_one(
|
||||
"SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_namespace
|
||||
WHERE nspname = $1
|
||||
) AS schema_exists,
|
||||
has_schema_privilege($1, 'USAGE') AS has_usage",
|
||||
&[&schema],
|
||||
)
|
||||
.map_err(map_postgres_error)?;
|
||||
|
||||
let schema_exists = row.get::<_, bool>(0);
|
||||
let has_usage = row.get::<_, bool>(1);
|
||||
|
||||
if schema_exists && !has_usage {
|
||||
return Err(DriverError::Inspect(restricted_schema_message(schema)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restricted_schema_message(schema: &str) -> String {
|
||||
format!("schema access is restricted: {schema}")
|
||||
}
|
||||
|
||||
fn consume_mysql_query_result<T: MySqlProtocol>(
|
||||
mut result: MySqlQueryResult<'_, '_, '_, T>,
|
||||
) -> Result<QueryResult, DriverError> {
|
||||
@@ -964,6 +1000,27 @@ mod tests {
|
||||
assert_eq!(rendered, "张敏😀");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_schema_summary_marks_restricted_entries() {
|
||||
let schema = postgres_schema_summary(String::from("restricted_probe"), false);
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
SchemaSummary::restricted(
|
||||
"restricted_probe",
|
||||
"schema access is restricted: restricted_probe"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_schema_message_is_stable() {
|
||||
assert_eq!(
|
||||
restricted_schema_message("restricted_probe"),
|
||||
"schema access is restricted: restricted_probe"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_driver_supports_connect_inspect_and_query() {
|
||||
let unique = SystemTime::now()
|
||||
@@ -995,9 +1052,7 @@ mod tests {
|
||||
.expect("sqlite schemas should load");
|
||||
assert_eq!(
|
||||
schemas,
|
||||
InspectResult::Schemas(vec![SchemaSummary {
|
||||
name: String::from("main"),
|
||||
}])
|
||||
InspectResult::Schemas(vec![SchemaSummary::ready("main")])
|
||||
);
|
||||
|
||||
let tables = DriverRegistry::inspect(
|
||||
|
||||
Reference in New Issue
Block a user