use common::CliError; use url::Url; use crate::model::{ConnectionSource, Engine}; pub(crate) fn source_from_parts( engine: Option, raw_url: String, ) -> Result { let source_redacted = redact_source(&raw_url); let engine = engine.or_else(|| infer_engine(&raw_url)).ok_or_else(|| { CliError::usage(format!( "--engine is required for ambiguous SQL source '{source_redacted}'" )) })?; Ok(ConnectionSource { engine, raw_url, source_redacted, }) } pub(crate) fn infer_engine(raw_url: &str) -> Option { Url::parse(raw_url) .ok() .and_then(|url| Engine::parse(url.scheme())) } pub(crate) fn redact_source(raw: &str) -> String { if let Ok(mut url) = Url::parse(raw) { if url.password().is_some() { let _ = url.set_password(Some("***")); } if url .query_pairs() .any(|(key, _)| is_sensitive_connection_key(&key)) { let pairs = url .query_pairs() .map(|(key, value)| { let rendered = if is_sensitive_connection_key(&key) { "***".into() } else { value }; (key.into_owned(), rendered.into_owned()) }) .collect::>(); url.query_pairs_mut().clear().extend_pairs(pairs); } return url.to_string(); } redact_key_value_passwords(raw) } fn redact_key_value_passwords(raw: &str) -> String { let mut changed = false; let parts = raw .split(';') .map(|part| { let Some((key, value)) = part.split_once('=') else { return part.to_string(); }; let key_trimmed = key.trim(); if is_sensitive_connection_key(key_trimmed) { changed = true; format!("{key}=***") } else { format!("{key}={value}") } }) .collect::>(); if changed { parts.join(";") } else { raw.to_string() } } fn is_sensitive_connection_key(key: &str) -> bool { let normalized = key .chars() .filter(|character| !matches!(character, '_' | '-' | ' ')) .flat_map(char::to_lowercase) .collect::(); matches!( normalized.as_str(), "password" | "pwd" | "token" | "accesstoken" | "apikey" | "secret" | "clientsecret" | "credential" ) } #[cfg(test)] mod tests { use super::*; #[test] fn redacts_url_and_ado_passwords() { assert_eq!( redact_source("postgres://user:secret@example.test/db"), "postgres://user:***@example.test/db" ); assert_eq!( redact_source("server=tcp:host,1433;User ID=sa;Password=secret;Database=db"), "server=tcp:host,1433;User ID=sa;Password=***;Database=db" ); } #[test] fn redacts_url_query_and_key_value_secrets() { assert_eq!( redact_source( "postgres://example.test/db?password=secret&access_token=abc&sslmode=require" ), "postgres://example.test/db?password=***&access_token=***&sslmode=require" ); assert_eq!( redact_source("server=tcp:host,1433;User ID=sa;Access Token=abc;ApiKey=key"), "server=tcp:host,1433;User ID=sa;Access Token=***;ApiKey=***" ); } #[test] fn infers_engines_from_url_schemes_only() { assert_eq!( infer_engine("postgres://localhost/db"), Some(Engine::Postgres) ); assert_eq!(infer_engine("duckdb://local/path"), Some(Engine::DuckDb)); assert_eq!(infer_engine(r".\local.db"), None); } }