use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::ConnError;
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ConfigFile {
pub default_profile: Option<String>,
#[serde(default)]
pub profiles: HashMap<String, ProfileConfig>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ProfileConfig {
pub url: Option<String>,
pub host: Option<String>,
pub port: Option<u16>,
pub dbname: Option<String>,
pub user: Option<String>,
pub password: Option<String>,
pub password_command: Option<String>,
pub password_file: Option<String>,
pub sslmode: Option<String>,
pub sslcert: Option<String>,
pub sslkey: Option<String>,
pub sslrootcert: Option<String>,
pub access_mode: Option<String>,
#[serde(default)]
pub schemas: Vec<String>,
#[serde(default)]
pub deny_schemas: Vec<String>,
#[serde(default)]
pub deny_tables: Vec<String>,
#[serde(default)]
pub pii_columns: Vec<String>,
pub max_rows: Option<u32>,
pub statement_timeout_ms: Option<u32>,
pub credential_provider: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
pub struct ProjectConfigFile {
pub default_profile: Option<String>,
pub access_mode: Option<String>,
#[serde(default)]
pub schemas: Vec<String>,
#[serde(default)]
pub deny_schemas: Vec<String>,
#[serde(default)]
pub deny_tables: Vec<String>,
#[serde(default)]
pub pii_columns: Vec<String>,
pub max_rows: Option<u32>,
pub statement_timeout_ms: Option<u32>,
pub index_dir: Option<String>,
}
const PROJECT_FORBIDDEN_KEYS: &[&str] = &[
"url",
"host",
"port",
"dbname",
"user",
"password",
"password_command",
"password_file",
"sslcert",
"sslkey",
"sslrootcert",
"credential_provider",
];
pub fn load_project_config(path: &Path) -> Result<(ProjectConfigFile, Vec<String>), ConnError> {
let raw = std::fs::read_to_string(path)?;
let raw_value: toml::Value = toml::from_str(&raw)
.map_err(|e| ConnError::Config(format!("Failed to parse project TOML: {e}")))?;
let mut warnings = Vec::new();
if let Some(table) = raw_value.as_table() {
for &key in PROJECT_FORBIDDEN_KEYS {
if table.contains_key(key) {
warnings.push(format!(
"security: project config '{}' contains forbidden field '{}' — stripped",
path.display(),
key
));
}
}
if table.contains_key("profiles") {
warnings.push(format!(
"security: project config '{}' contains [profiles] section — project configs cannot define connection profiles, only select them via default_profile",
path.display()
));
}
}
let config: ProjectConfigFile = toml::from_str(&raw)
.map_err(|e| ConnError::Config(format!("Failed to deserialize project config: {e}")))?;
Ok((config, warnings))
}
pub fn find_project_config(start_dir: &Path) -> Option<PathBuf> {
let mut dir = start_dir.to_path_buf();
loop {
let candidate = dir.join(".nexql").join("config.toml");
if candidate.is_file() {
return Some(candidate);
}
let flat = dir.join(".nexql-mcp.toml");
if flat.is_file() {
return Some(flat);
}
if dir.join(".git").exists() {
return None;
}
if !dir.pop() {
return None;
}
}
}
impl ProjectConfigFile {
pub fn tighten_access_mode(&self, base: &str) -> String {
let Some(ref project_mode) = self.access_mode else {
return base.to_string();
};
let rank = |m: &str| match m.to_lowercase().as_str() {
"admin" => 3,
"write" => 2,
"read" | "read_only" => 1,
_ => 0,
};
if rank(project_mode) < rank(base) {
project_mode.clone()
} else {
base.to_string()
}
}
}
impl ConfigFile {
pub fn parse_str(s: &str) -> Result<Self, ConnError> {
toml::from_str(s).map_err(|e| ConnError::Config(e.to_string()))
}
pub fn load_path(path: &Path) -> Result<Self, ConnError> {
let raw = std::fs::read_to_string(path)?;
Self::parse_str(&raw)
}
pub fn load_path_migrated(path: &Path) -> Result<(Self, SecretMigrationReport), ConnError> {
crate::config::load_path_migrated(path)
}
pub fn default_path() -> Option<PathBuf> {
if let Ok(p) = std::env::var("NEXQL_MCP_CONFIG") {
return Some(PathBuf::from(p));
}
dirs_config().map(|d| d.join("nexql-mcp").join("config.toml"))
}
pub fn upsert_profile(&mut self, name: impl Into<String>, profile: ProfileConfig) {
let name = name.into();
if self.default_profile.is_none() {
self.default_profile = Some(name.clone());
}
self.profiles.insert(name, profile);
}
pub fn upsert_profile_prepared(
&mut self,
name: impl Into<String>,
profile: ProfileConfig,
) -> Result<(), ConnError> {
let name = name.into();
let prepared = prepare_profile_for_persist(&name, profile)?;
self.upsert_profile(name, prepared);
Ok(())
}
pub fn remove_profile(&mut self, name: &str) -> Option<ProfileConfig> {
if self.default_profile.as_deref() == Some(name) {
self.default_profile = self.profiles.keys().find(|k| *k != name).cloned();
}
self.profiles.remove(name)
}
pub fn to_toml_string(&self) -> Result<String, ConnError> {
toml::to_string_pretty(self).map_err(|e| ConnError::Config(e.to_string()))
}
pub fn save(&self, path: &Path) -> Result<Option<PathBuf>, ConnError> {
let rendered = self.to_toml_string()?;
write_with_backup(path, &rendered)
}
pub fn export_shareable(&self) -> ProjectConfigFile {
let policy_source = self
.default_profile
.as_ref()
.and_then(|name| self.profiles.get(name))
.or_else(|| self.profiles.values().next());
ProjectConfigFile {
default_profile: self.default_profile.clone(),
access_mode: policy_source.and_then(|p| p.access_mode.clone()),
schemas: policy_source
.map(|p| p.schemas.clone())
.unwrap_or_default(),
deny_schemas: policy_source
.map(|p| p.deny_schemas.clone())
.unwrap_or_default(),
deny_tables: policy_source
.map(|p| p.deny_tables.clone())
.unwrap_or_default(),
pii_columns: policy_source
.map(|p| p.pii_columns.clone())
.unwrap_or_default(),
max_rows: policy_source.and_then(|p| p.max_rows),
statement_timeout_ms: policy_source.and_then(|p| p.statement_timeout_ms),
index_dir: None,
}
}
pub fn export_full_sanitized(&self) -> Self {
let mut sanitized = self.clone();
for profile in sanitized.profiles.values_mut() {
profile.password = None;
profile.password_command = None;
profile.password_file = None;
profile.credential_provider = None;
if let Some(ref url) = profile.url
&& let Ok(mut parsed) = url::Url::parse(url)
&& (parsed.password().is_some() || !parsed.username().is_empty())
{
let _ = parsed.set_username("");
let _ = parsed.set_password(None);
profile.url = Some(parsed.to_string());
}
}
sanitized
}
}
pub fn write_with_backup(path: &Path, content: &str) -> Result<Option<PathBuf>, ConnError> {
let backup = if path.exists() {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut backup_os = path.as_os_str().to_os_string();
backup_os.push(format!(".bak-{ts}"));
let backup_path = PathBuf::from(backup_os);
std::fs::copy(path, &backup_path)?;
Some(backup_path)
} else {
None
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut tmp_os = path.as_os_str().to_os_string();
tmp_os.push(".tmp");
let tmp_path = PathBuf::from(tmp_os);
std::fs::write(&tmp_path, content)?;
std::fs::rename(&tmp_path, path)?;
Ok(backup)
}
pub fn profile_has_plaintext_secret(profile: &ProfileConfig) -> bool {
if profile.password.is_some() {
return true;
}
if let Some(ref url) = profile.url
&& let Ok(parsed) = url::Url::parse(url)
&& parsed.password().is_some()
{
return true;
}
false
}
pub fn config_plaintext_secret_warnings(config: &ConfigFile) -> Vec<String> {
config
.profiles
.iter()
.filter(|(_, profile)| profile_has_plaintext_secret(profile))
.map(|(name, _)| {
format!(
"security: profile '{name}' stores credentials in plaintext — run \
`nexql-mcp profile migrate-secrets` or re-save the profile to migrate to the OS \
keyring, or use password_command / password_file"
)
})
.collect()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SecretMigrationReport {
pub migrated: Vec<String>,
pub failed: Vec<(String, String)>,
pub backup: Option<PathBuf>,
}
impl SecretMigrationReport {
pub fn any_changes(&self) -> bool {
!self.migrated.is_empty() || !self.failed.is_empty()
}
}
pub fn migrate_plaintext_secrets(config: &mut ConfigFile) -> SecretMigrationReport {
let mut report = SecretMigrationReport::default();
let names: Vec<String> = config.profiles.keys().cloned().collect();
for name in names {
let Some(profile) = config.profiles.get(&name) else {
continue;
};
if !profile_has_plaintext_secret(profile) {
continue;
}
let profile = profile.clone();
match prepare_profile_for_persist(&name, profile) {
Ok(prepared) => {
config.profiles.insert(name.clone(), prepared);
report.migrated.push(name);
}
Err(err) => report.failed.push((name, err.to_string())),
}
}
report
}
pub fn load_path_migrated(path: &Path) -> Result<(ConfigFile, SecretMigrationReport), ConnError> {
if !path.exists() {
return Ok((ConfigFile::default(), SecretMigrationReport::default()));
}
let mut config = ConfigFile::load_path(path)?;
let mut report = migrate_plaintext_secrets(&mut config);
if !report.migrated.is_empty() {
report.backup = config.save(path)?;
}
if report.any_changes() {
log_secret_migration_report(&report);
}
Ok((config, report))
}
pub fn log_secret_migration_report(report: &SecretMigrationReport) {
for name in &report.migrated {
tracing::info!(profile = %name, "migrated plaintext credentials to OS keyring");
}
for (name, err) in &report.failed {
tracing::warn!(
profile = %name,
error = %err,
"could not migrate plaintext credentials; password left in config"
);
}
if let Some(ref backup) = report.backup {
tracing::info!(path = %backup.display(), "config backup written before secret migration");
}
}
pub fn prepare_profile_for_persist(
profile_name: &str,
mut profile: ProfileConfig,
) -> Result<ProfileConfig, ConnError> {
let routed =
crate::secret::route_password_to_keyring(profile_name, profile.password.as_deref())?;
if routed.credential_provider.is_some() {
profile.password = routed.password;
profile.credential_provider = routed.credential_provider;
profile.password_file = routed.password_file;
}
if let Some(ref url) = profile.url
&& let Ok(mut parsed) = url::Url::parse(url)
&& let Some(url_pw) = parsed.password()
&& !url_pw.is_empty()
{
let stored = crate::secret::store_profile_password(profile_name, url_pw)?;
let _ = parsed.set_password(None);
profile.url = Some(parsed.to_string());
profile.credential_provider = Some(stored.provider);
profile.password_file = stored.password_file;
}
Ok(profile)
}
fn dirs_config() -> Option<PathBuf> {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
return Some(PathBuf::from(xdg));
}
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_example_shaped_config() {
let cfg = ConfigFile::parse_str(
r#"
default_profile = "local"
[profiles.local]
url = "postgres://dev@localhost:5432/appdb"
access_mode = "read"
[profiles.prod]
host = "prod.example.com"
dbname = "app"
user = "readonly_agent"
password_command = "op read op://vault/pg/password"
sslmode = "verify-full"
schemas = ["public", "billing"]
deny_tables = ["auth.*"]
pii_columns = ["public.users.ssn"]
max_rows = 200
"#,
)
.unwrap();
assert_eq!(cfg.default_profile.as_deref(), Some("local"));
assert_eq!(
cfg.profiles["local"].url.as_deref(),
Some("postgres://dev@localhost:5432/appdb")
);
assert_eq!(cfg.profiles["prod"].max_rows, Some(200));
assert_eq!(cfg.profiles["prod"].deny_tables, vec!["auth.*"]);
}
#[test]
fn upsert_sets_default_profile_when_empty() {
let mut cfg = ConfigFile::default();
cfg.upsert_profile(
"local",
ProfileConfig {
url: Some("postgres://dev@localhost:5432/appdb".into()),
..Default::default()
},
);
assert_eq!(cfg.default_profile.as_deref(), Some("local"));
assert!(cfg.profiles.contains_key("local"));
}
#[test]
fn upsert_does_not_override_existing_default() {
let mut cfg = ConfigFile {
default_profile: Some("prod".into()),
..Default::default()
};
cfg.upsert_profile("local", ProfileConfig::default());
assert_eq!(cfg.default_profile.as_deref(), Some("prod"));
}
#[test]
fn save_then_load_round_trips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut cfg = ConfigFile::default();
cfg.upsert_profile(
"local",
ProfileConfig {
url: Some("postgres://dev@localhost:5432/appdb".into()),
max_rows: Some(500),
..Default::default()
},
);
let backup = cfg.save(&path).unwrap();
assert!(backup.is_none(), "no prior file — no backup expected");
let loaded = ConfigFile::load_path(&path).unwrap();
assert_eq!(loaded.default_profile.as_deref(), Some("local"));
assert_eq!(loaded.profiles["local"].max_rows, Some(500));
}
#[test]
fn save_backs_up_existing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "default_profile = \"old\"\n").unwrap();
let cfg = ConfigFile::default();
let backup = cfg.save(&path).unwrap();
let backup = backup.expect("existing file must be backed up");
assert!(backup.exists());
let backed_up = std::fs::read_to_string(&backup).unwrap();
assert!(backed_up.contains("old"));
}
#[test]
fn remove_profile_reassigns_default() {
let mut cfg = ConfigFile::default();
cfg.upsert_profile("local", ProfileConfig::default());
cfg.upsert_profile("prod", ProfileConfig::default());
cfg.default_profile = Some("local".into());
cfg.remove_profile("local");
assert_eq!(cfg.default_profile.as_deref(), Some("prod"));
assert!(!cfg.profiles.contains_key("local"));
}
#[test]
fn find_project_config_ascending() {
let root = tempfile::tempdir().unwrap();
let nested = root.path().join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
let nexql_dir = root.path().join(".nexql");
std::fs::create_dir_all(&nexql_dir).unwrap();
let config_path = nexql_dir.join("config.toml");
std::fs::write(&config_path, "default_profile = \"staging\"\n").unwrap();
let found = find_project_config(&nested);
assert_eq!(found, Some(config_path));
}
#[test]
fn find_project_config_stops_at_git() {
let root = tempfile::tempdir().unwrap();
let git_dir = root.path().join("repo").join(".git");
let nested = root.path().join("repo").join("sub");
std::fs::create_dir_all(&git_dir).unwrap();
std::fs::create_dir_all(&nested).unwrap();
let outer_nexql = root.path().join(".nexql");
std::fs::create_dir_all(&outer_nexql).unwrap();
std::fs::write(
outer_nexql.join("config.toml"),
"default_profile = \"root\"\n",
)
.unwrap();
let found = find_project_config(&nested);
assert!(found.is_none());
}
#[test]
fn project_config_strips_secrets() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
default_profile = "staging"
password = "hacked"
host = "attacker.com"
access_mode = "read"
deny_tables = ["users.*"]
"#,
)
.unwrap();
let (cfg, warnings) = load_project_config(&path).unwrap();
assert_eq!(cfg.default_profile.as_deref(), Some("staging"));
assert_eq!(cfg.access_mode.as_deref(), Some("read"));
assert_eq!(cfg.deny_tables, vec!["users.*"]);
assert!(
warnings
.iter()
.any(|w| w.contains("forbidden field 'password'"))
);
assert!(
warnings
.iter()
.any(|w| w.contains("forbidden field 'host'"))
);
}
#[test]
fn project_config_rejects_profiles() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
default_profile = "staging"
[profiles.evil]
url = "postgres://evil.com/db"
"#,
)
.unwrap();
let (cfg, warnings) = load_project_config(&path).unwrap();
assert_eq!(cfg.default_profile.as_deref(), Some("staging"));
assert!(warnings.iter().any(|w| w.contains("[profiles] section")));
}
#[test]
fn access_mode_tightens_only() {
let proj = ProjectConfigFile {
access_mode: Some("read".into()),
..Default::default()
};
assert_eq!(proj.tighten_access_mode("write"), "read");
assert_eq!(proj.tighten_access_mode("admin"), "read");
let proj2 = ProjectConfigFile {
access_mode: Some("admin".into()),
..Default::default()
};
assert_eq!(proj2.tighten_access_mode("read"), "read");
}
#[test]
fn export_shareable_includes_default_profile_policy() {
let mut cfg = ConfigFile {
default_profile: Some("team".into()),
..Default::default()
};
cfg.upsert_profile(
"team",
ProfileConfig {
deny_schemas: vec!["auth".into()],
pii_columns: vec!["public.users.ssn".into()],
max_rows: Some(500),
..Default::default()
},
);
let proj = cfg.export_shareable();
assert_eq!(proj.default_profile.as_deref(), Some("team"));
assert_eq!(proj.deny_schemas, vec!["auth"]);
assert_eq!(proj.pii_columns, vec!["public.users.ssn"]);
assert_eq!(proj.max_rows, Some(500));
}
#[test]
fn export_full_sanitized_strips_passwords() {
let mut cfg = ConfigFile::default();
cfg.upsert_profile(
"prod",
ProfileConfig {
url: Some("postgres://user:secret@prod.host:5432/appdb".into()),
password: Some("secret".into()),
password_command: Some("op read ...".into()),
..Default::default()
},
);
let exported = cfg.export_full_sanitized();
let p = &exported.profiles["prod"];
assert!(p.password.is_none());
assert!(p.password_command.is_none());
assert_eq!(p.url.as_deref(), Some("postgres://prod.host:5432/appdb"));
}
#[test]
fn profile_has_plaintext_secret_detects_field_and_url() {
assert!(profile_has_plaintext_secret(&ProfileConfig {
password: Some("x".into()),
..Default::default()
}));
assert!(profile_has_plaintext_secret(&ProfileConfig {
url: Some("postgres://u:pw@localhost/db".into()),
..Default::default()
}));
assert!(!profile_has_plaintext_secret(&ProfileConfig {
credential_provider: Some("keyring".into()),
..Default::default()
}));
}
#[test]
fn config_plaintext_secret_warnings_lists_profiles() {
let mut cfg = ConfigFile::default();
cfg.upsert_profile(
"prod",
ProfileConfig {
password: Some("secret".into()),
..Default::default()
},
);
let warnings = config_plaintext_secret_warnings(&cfg);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("prod"));
}
#[test]
fn migrate_plaintext_secrets_skips_clean_profiles() {
let mut cfg = ConfigFile::default();
cfg.upsert_profile(
"local",
ProfileConfig {
host: Some("localhost".into()),
credential_provider: Some("keyring".into()),
..Default::default()
},
);
let report = migrate_plaintext_secrets(&mut cfg);
assert!(report.migrated.is_empty());
assert!(report.failed.is_empty());
}
#[test]
fn migrate_plaintext_secrets_moves_password_to_keyring_or_reports_failure() {
const SECRET: &str = "correct-horse-battery-staple";
let mut cfg = ConfigFile::default();
cfg.upsert_profile(
"prod",
ProfileConfig {
password: Some(SECRET.into()),
..Default::default()
},
);
let report = migrate_plaintext_secrets(&mut cfg);
if report.migrated.is_empty() {
assert_eq!(report.failed.len(), 1);
assert_eq!(report.failed[0].0, "prod");
assert_eq!(
cfg.profiles["prod"].password.as_deref(),
Some(SECRET)
);
} else {
assert_eq!(report.migrated, vec!["prod".to_string()]);
assert!(cfg.profiles["prod"].password.is_none());
assert!(
matches!(
cfg.profiles["prod"].credential_provider.as_deref(),
Some("keyring") | Some("file")
),
"expected keyring or file provider, got {:?}",
cfg.profiles["prod"].credential_provider
);
}
}
#[test]
fn load_path_migrated_never_leaves_plaintext_password_on_disk_when_keyring_works() {
const SECRET: &str = "correct-horse-battery-staple";
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut cfg = ConfigFile::default();
cfg.upsert_profile(
"prod",
ProfileConfig {
password: Some(SECRET.into()),
..Default::default()
},
);
cfg.save(&path).unwrap();
let (_loaded, report) = load_path_migrated(&path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
if !report.migrated.is_empty() {
assert!(
!raw.contains(SECRET),
"migrated config must not contain plaintext password: {raw}"
);
}
}
}