feat(usable): integrate current dbtool implementation snapshot
Some checks failed
release-smoke / macos-13 / x86_64-apple-darwin (push) Has been cancelled
release-smoke / ubuntu-latest / x86_64-unknown-linux-gnu (push) Has been cancelled
release-smoke / windows-latest / x86_64-pc-windows-msvc (push) Has been cancelled

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip CTO
2026-04-02 08:26:18 +00:00
parent a28dab4cd9
commit d5f69462b0
73 changed files with 5895 additions and 322 deletions

View File

@@ -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"))
}
}

View File

@@ -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

View File

@@ -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"))
}
}

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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(