use crate::core::providers::{ContextProvider, ProviderItem, ProviderParams, ProviderResult};
pub struct PostgresProvider {
available: bool,
}
impl Default for PostgresProvider {
fn default() -> Self {
Self::new()
}
}
impl PostgresProvider {
pub fn new() -> Self {
let available =
std::env::var("DATABASE_URL").is_ok() || std::env::var("PGDATABASE").is_ok();
Self { available }
}
}
impl ContextProvider for PostgresProvider {
fn id(&self) -> &'static str {
"postgres"
}
fn display_name(&self) -> &'static str {
"PostgreSQL"
}
fn supported_actions(&self) -> &[&str] {
&["schemas", "tables"]
}
fn execute(&self, action: &str, params: &ProviderParams) -> Result<ProviderResult, String> {
if !self.available {
return Err("PostgreSQL not configured (need DATABASE_URL or PGDATABASE)".into());
}
match action {
"schemas" | "tables" => list_tables(params),
_ => Err(format!("Unsupported action: {action}")),
}
}
fn cache_ttl_secs(&self) -> u64 {
300
}
fn requires_auth(&self) -> bool {
true
}
fn is_available(&self) -> bool {
self.available
}
}
fn validate_pg_identifier(name: &str) -> Result<(), String> {
let valid_start = name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
let valid_rest = name
.chars()
.skip(1)
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$');
if name.is_empty() || name.len() > 63 || !valid_start || !valid_rest {
return Err(format!(
"Invalid PostgreSQL schema identifier: {name:?} (allowed: [A-Za-z_][A-Za-z0-9_$]*, max 63 chars)"
));
}
Ok(())
}
fn list_tables(params: &ProviderParams) -> Result<ProviderResult, String> {
let schema = params.state.as_deref().unwrap_or("public");
validate_pg_identifier(schema)?;
let limit = params.limit.unwrap_or(50);
let query = format!(
"SELECT table_name, column_name, data_type, is_nullable \
FROM information_schema.columns \
WHERE table_schema = '{schema}' \
ORDER BY table_name, ordinal_position \
LIMIT {limit_cols};",
limit_cols = limit * 20, );
let mut cmd = std::process::Command::new("psql");
if let Ok(url) = std::env::var("DATABASE_URL") {
cmd.arg(&url);
}
let output = cmd
.args(["-t", "-A", "-F", "|", "-c", &query])
.output()
.map_err(|e| format!("Failed to run psql: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("psql error: {stderr}"));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut tables: std::collections::BTreeMap<String, Vec<String>> =
std::collections::BTreeMap::new();
for line in stdout.lines() {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() >= 3 {
let table = parts[0].trim();
let col = parts[1].trim();
let dtype = parts[2].trim();
let nullable = parts.get(3).map_or("", |s| s.trim());
let null_marker = if nullable == "YES" { "?" } else { "" };
tables
.entry(table.to_string())
.or_default()
.push(format!(" {col}: {dtype}{null_marker}"));
}
}
let items: Vec<ProviderItem> = tables
.iter()
.take(limit)
.map(|(table, columns)| {
let body = format!("{schema}.{table}\n{}", columns.join("\n"));
ProviderItem {
id: table.clone(),
title: format!("{schema}.{table}"),
state: Some("active".into()),
author: None,
created_at: None,
updated_at: None,
url: None,
labels: vec![schema.to_string()],
body: Some(body),
..Default::default()
}
})
.collect();
Ok(ProviderResult {
provider: "postgres".into(),
resource_type: "schemas".into(),
items,
total_count: Some(tables.len()),
truncated: tables.len() > limit,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn postgres_provider_unavailable_without_env() {
crate::test_env::remove_var("DATABASE_URL");
crate::test_env::remove_var("PGDATABASE");
let provider = PostgresProvider::new();
assert!(!provider.is_available());
assert_eq!(provider.id(), "postgres");
assert!(provider.requires_auth());
}
#[test]
fn postgres_provider_supported_actions() {
let provider = PostgresProvider::new();
assert!(provider.supported_actions().contains(&"schemas"));
assert!(provider.supported_actions().contains(&"tables"));
}
#[test]
fn valid_pg_identifiers_pass() {
for ok in ["public", "my_schema", "_internal", "Schema1", "a$b"] {
assert!(validate_pg_identifier(ok).is_ok(), "{ok} should be valid");
}
}
#[test]
fn sql_injection_payloads_are_rejected() {
for evil in [
"public' UNION SELECT usename, passwd, '', '' FROM pg_shadow --",
"public'; DROP TABLE users; --",
"a\"b",
"a b",
"a;b",
"schema\n--",
"",
"1starts_with_digit",
] {
assert!(
validate_pg_identifier(evil).is_err(),
"{evil:?} must be rejected"
);
}
}
#[test]
fn overlong_identifier_is_rejected() {
let too_long = "a".repeat(64);
assert!(validate_pg_identifier(&too_long).is_err());
let max_ok = "a".repeat(63);
assert!(validate_pg_identifier(&max_ok).is_ok());
}
#[test]
fn injection_via_params_state_fails_closed() {
let params = ProviderParams {
state: Some("public' OR '1'='1".into()),
..Default::default()
};
let err = list_tables(¶ms).unwrap_err();
assert!(err.contains("Invalid PostgreSQL schema identifier"));
}
}