#![deny(missing_docs, missing_debug_implementations)]
pub use bb8;
pub use ldap3;
use ldap3::{LdapConnAsync, LdapConnSettings, Scope};
use std::fmt;
use std::time::Duration;
use url::Url;
#[derive(Clone)]
pub struct LdapConnectionManager {
url: String,
settings: LdapConnSettings,
bind_dn: Option<String>,
bind_password: Option<String>,
connect_timeout: Option<Duration>,
validation_timeout: Duration,
validation_base_dn: String,
validation_filter: String,
validation_scope: Scope,
validation_attributes: Vec<String>,
}
impl fmt::Debug for LdapConnectionManager {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LdapConnectionManager")
.field("url", &self.url)
.finish()
}
}
impl LdapConnectionManager {
pub fn new<S: Into<String>>(ldap_url: S) -> Result<Self, ldap3::LdapError> {
let url = ldap_url.into();
let parsed = Url::parse(&url).map_err(ldap3::LdapError::from)?;
match parsed.scheme() {
"ldap" | "ldapi" => Ok(LdapConnectionManager {
url,
settings: LdapConnSettings::new(),
bind_dn: None,
bind_password: None,
connect_timeout: None,
validation_timeout: Duration::from_secs(1),
validation_base_dn: String::new(),
validation_filter: "(objectClass=*)".to_string(),
validation_scope: Scope::Base,
validation_attributes: vec!["1.1".to_string()],
}),
_ => Err(ldap3::LdapError::UnknownScheme(
parsed.scheme().to_string(),
)),
}
}
pub fn with_connection_settings(mut self, settings: LdapConnSettings) -> Self {
self.settings = settings;
self
}
pub fn with_bind_credentials<S: Into<String>>(mut self, bind_dn: S, bind_password: S) -> Self {
self.bind_dn = Some(bind_dn.into());
self.bind_password = Some(bind_password.into());
self
}
pub fn with_validation_timeout(mut self, timeout: Duration) -> Self {
self.validation_timeout = timeout;
self
}
pub fn with_validation_search<S: Into<String>>(
mut self,
base_dn: S,
scope: Scope,
filter: S,
attributes: Vec<S>,
) -> Self {
self.validation_base_dn = base_dn.into();
self.validation_scope = scope;
self.validation_filter = filter.into();
self.validation_attributes = attributes.into_iter().map(Into::into).collect();
self
}
pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = Some(timeout);
self
}
}
impl bb8::ManageConnection for LdapConnectionManager {
type Connection = ldap3::Ldap;
type Error = ldap3::LdapError;
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
let settings = match self.connect_timeout {
Some(timeout) => self.settings.clone().set_conn_timeout(timeout),
None => self.settings.clone(),
};
let (conn, ldap) = LdapConnAsync::with_settings(settings, &self.url).await?;
ldap3::drive!(conn);
let mut ldap = ldap;
if let (Some(bind_dn), Some(bind_password)) = (&self.bind_dn, &self.bind_password) {
ldap.simple_bind(bind_dn, bind_password).await?.success()?;
}
Ok(ldap)
}
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
conn.with_timeout(self.validation_timeout)
.search(
&self.validation_base_dn,
self.validation_scope,
&self.validation_filter,
self.validation_attributes.clone(),
)
.await?
.success()?;
Ok(())
}
fn has_broken(&self, conn: &mut Self::Connection) -> bool {
conn.is_closed()
}
}
#[cfg(test)]
mod tests {
use super::*;
use bb8::ManageConnection;
use testcontainers::{
core::{client::ClientError, error::TestcontainersError},
runners::AsyncRunner,
};
use testcontainers_modules::openldap::OpenLDAP;
#[test]
fn new_sets_default_settings() {
let manager = LdapConnectionManager::new("ldap://example.com").unwrap();
assert_eq!(manager.url, "ldap://example.com");
assert!(
!manager.settings.starttls(),
"starttls should be disabled by default"
);
}
#[test]
fn with_connection_settings_overrides_settings() {
let manager = LdapConnectionManager::new("ldap://example.com").unwrap();
assert!(
!manager.settings.starttls(),
"control: default settings keep starttls disabled"
);
let updated_settings = LdapConnSettings::new().set_starttls(true);
let updated_manager = manager.clone().with_connection_settings(updated_settings);
assert_eq!(updated_manager.url, manager.url);
assert!(
updated_manager.settings.starttls(),
"starttls should be enabled after overriding settings"
);
}
#[test]
fn with_connection_settings_leaves_original_untouched() {
let manager = LdapConnectionManager::new("ldap://example.com").unwrap();
let updated_manager = manager
.clone()
.with_connection_settings(LdapConnSettings::new().set_starttls(true));
assert!(
!manager.settings.starttls(),
"original manager should keep default settings"
);
assert!(
updated_manager.settings.starttls(),
"updated manager should reflect overrides"
);
}
#[test]
fn clone_preserves_custom_settings() {
let manager = LdapConnectionManager::new("ldap://example.com")
.unwrap()
.with_connection_settings(LdapConnSettings::new().set_starttls(true));
let cloned = manager.clone();
assert_eq!(cloned.url, manager.url);
assert!(
cloned.settings.starttls(),
"clone should maintain customized settings"
);
}
#[test]
fn new_accepts_owned_strings() {
let url = "ldap://example.com".to_string();
let manager = LdapConnectionManager::new(url).unwrap();
assert_eq!(manager.url, "ldap://example.com");
}
#[test]
fn with_bind_credentials_sets_values() {
let manager = LdapConnectionManager::new("ldap://example.com")
.unwrap()
.with_bind_credentials("cn=admin", "secret");
assert_eq!(manager.bind_dn.as_deref(), Some("cn=admin"));
assert_eq!(manager.bind_password.as_deref(), Some("secret"));
}
#[test]
fn with_validation_timeout_updates_value() {
let manager = LdapConnectionManager::new("ldap://example.com")
.unwrap()
.with_validation_timeout(Duration::from_secs(10));
assert_eq!(manager.validation_timeout, Duration::from_secs(10));
}
#[test]
fn with_validation_search_updates_values() {
let manager = LdapConnectionManager::new("ldap://example.com")
.unwrap()
.with_validation_search(
"dc=example,dc=org",
Scope::Subtree,
"(cn=alice)",
vec!["cn", "mail"],
);
assert_eq!(manager.validation_base_dn, "dc=example,dc=org");
assert_eq!(manager.validation_scope, Scope::Subtree);
assert_eq!(manager.validation_filter, "(cn=alice)");
assert_eq!(manager.validation_attributes, vec!["cn", "mail"]);
}
#[test]
fn with_connect_timeout_updates_settings() {
let manager = LdapConnectionManager::new("ldap://example.com")
.unwrap()
.with_connect_timeout(Duration::from_secs(5));
assert_eq!(manager.connect_timeout, Some(Duration::from_secs(5)));
}
#[test]
fn new_validates_urls() {
let manager =
LdapConnectionManager::new("ldap://example.com").expect("valid ldap URL should parse");
assert_eq!(manager.url, "ldap://example.com");
let err =
LdapConnectionManager::new("not a url").expect_err("invalid URLs should be rejected");
match err {
ldap3::LdapError::UrlParsing { .. } => {}
other => panic!("unexpected error: {other:?}"),
}
let err = LdapConnectionManager::new("http://example.com")
.expect_err("unsupported schemes should be rejected");
match err {
ldap3::LdapError::UnknownScheme(_) => {}
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn connection_pool() -> anyhow::Result<()> {
let node = match OpenLDAP::default()
.with_user("test_user", "test_password")
.start()
.await
{
Ok(node) => node,
Err(err @ TestcontainersError::Client(ClientError::PullImage { .. }))
| Err(err @ TestcontainersError::Client(ClientError::Init(_))) => {
eprintln!("skipping connection_pool test: {err}");
return Ok(());
}
Err(err) => return Err(err.into()),
};
let url = format!("ldap://127.0.0.1:{}", node.get_host_port_ipv4(1389).await?);
let conn_mgr = LdapConnectionManager::new(url)?
.with_bind_credentials("cn=admin,dc=example,dc=org", "adminpassword");
let mut conn = conn_mgr.connect().await?;
let search_res = conn
.search(
"ou=users,dc=example,dc=org",
ldap3::Scope::Subtree,
"(cn=*)",
vec!["cn"],
)
.await;
assert_eq!(search_res.iter().len(), 1);
assert!(
!conn_mgr.has_broken(&mut conn),
"freshly connected session should be healthy"
);
conn.unbind().await?;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(
conn_mgr.has_broken(&mut conn),
"connection should be flagged as broken after unbind"
);
Ok(())
}
}