1#![deny(missing_docs, missing_debug_implementations)]
2
3pub use bb8;
75pub use ldap3;
77
78use ldap3::{LdapConnAsync, LdapConnSettings, Scope};
79use std::fmt;
80use std::time::Duration;
81use url::Url;
82
83#[derive(Clone)]
85pub struct LdapConnectionManager {
86 url: String,
87 settings: LdapConnSettings,
88 bind_dn: Option<String>,
89 bind_password: Option<String>,
90 connect_timeout: Option<Duration>,
91 validation_timeout: Duration,
92 validation_base_dn: String,
93 validation_filter: String,
94 validation_scope: Scope,
95 validation_attributes: Vec<String>,
96}
97
98impl fmt::Debug for LdapConnectionManager {
99 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100 f.debug_struct("LdapConnectionManager")
101 .field("url", &self.url)
102 .finish()
103 }
104}
105
106impl LdapConnectionManager {
107 pub fn new<S: Into<String>>(ldap_url: S) -> Result<Self, ldap3::LdapError> {
119 let url = ldap_url.into();
120 let parsed = Url::parse(&url).map_err(ldap3::LdapError::from)?;
121
122 match parsed.scheme() {
123 "ldap" | "ldapi" => Ok(LdapConnectionManager {
124 url,
125 settings: LdapConnSettings::new(),
126 bind_dn: None,
127 bind_password: None,
128 connect_timeout: None,
129 validation_timeout: Duration::from_secs(1),
130 validation_base_dn: String::new(),
131 validation_filter: "(objectClass=*)".to_string(),
132 validation_scope: Scope::Base,
133 validation_attributes: vec!["1.1".to_string()],
134 }),
135 _ => Err(ldap3::LdapError::UnknownScheme(
136 parsed.scheme().to_string(),
137 )),
138 }
139 }
140
141 pub fn with_connection_settings(mut self, settings: LdapConnSettings) -> Self {
143 self.settings = settings;
144 self
145 }
146
147 pub fn with_bind_credentials<S: Into<String>>(mut self, bind_dn: S, bind_password: S) -> Self {
155 self.bind_dn = Some(bind_dn.into());
156 self.bind_password = Some(bind_password.into());
157 self
158 }
159
160 pub fn with_validation_timeout(mut self, timeout: Duration) -> Self {
165 self.validation_timeout = timeout;
166 self
167 }
168
169 pub fn with_validation_search<S: Into<String>>(
176 mut self,
177 base_dn: S,
178 scope: Scope,
179 filter: S,
180 attributes: Vec<S>,
181 ) -> Self {
182 self.validation_base_dn = base_dn.into();
183 self.validation_scope = scope;
184 self.validation_filter = filter.into();
185 self.validation_attributes = attributes.into_iter().map(Into::into).collect();
186 self
187 }
188
189 pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
194 self.connect_timeout = Some(timeout);
195 self
196 }
197}
198
199impl bb8::ManageConnection for LdapConnectionManager {
205 type Connection = ldap3::Ldap;
206 type Error = ldap3::LdapError;
207
208 async fn connect(&self) -> Result<Self::Connection, Self::Error> {
209 let settings = match self.connect_timeout {
210 Some(timeout) => self.settings.clone().set_conn_timeout(timeout),
211 None => self.settings.clone(),
212 };
213 let (conn, ldap) = LdapConnAsync::with_settings(settings, &self.url).await?;
214
215 ldap3::drive!(conn);
216 let mut ldap = ldap;
217 if let (Some(bind_dn), Some(bind_password)) = (&self.bind_dn, &self.bind_password) {
218 ldap.simple_bind(bind_dn, bind_password).await?.success()?;
219 }
220 Ok(ldap)
221 }
222
223 async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
224 conn.with_timeout(self.validation_timeout)
226 .search(
227 &self.validation_base_dn,
228 self.validation_scope,
229 &self.validation_filter,
230 self.validation_attributes.clone(),
231 )
232 .await?
233 .success()?;
234 Ok(())
235 }
236
237 fn has_broken(&self, conn: &mut Self::Connection) -> bool {
238 conn.is_closed()
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use bb8::ManageConnection;
248 use testcontainers::{
249 core::{client::ClientError, error::TestcontainersError},
250 runners::AsyncRunner,
251 };
252 use testcontainers_modules::openldap::OpenLDAP;
253
254 #[test]
255 fn new_sets_default_settings() {
256 let manager = LdapConnectionManager::new("ldap://example.com").unwrap();
257 assert_eq!(manager.url, "ldap://example.com");
258 assert!(
259 !manager.settings.starttls(),
260 "starttls should be disabled by default"
261 );
262 }
263
264 #[test]
265 fn with_connection_settings_overrides_settings() {
266 let manager = LdapConnectionManager::new("ldap://example.com").unwrap();
267 assert!(
268 !manager.settings.starttls(),
269 "control: default settings keep starttls disabled"
270 );
271
272 let updated_settings = LdapConnSettings::new().set_starttls(true);
273 let updated_manager = manager.clone().with_connection_settings(updated_settings);
274
275 assert_eq!(updated_manager.url, manager.url);
276 assert!(
277 updated_manager.settings.starttls(),
278 "starttls should be enabled after overriding settings"
279 );
280 }
281
282 #[test]
283 fn with_connection_settings_leaves_original_untouched() {
284 let manager = LdapConnectionManager::new("ldap://example.com").unwrap();
285 let updated_manager = manager
286 .clone()
287 .with_connection_settings(LdapConnSettings::new().set_starttls(true));
288
289 assert!(
290 !manager.settings.starttls(),
291 "original manager should keep default settings"
292 );
293 assert!(
294 updated_manager.settings.starttls(),
295 "updated manager should reflect overrides"
296 );
297 }
298
299 #[test]
300 fn clone_preserves_custom_settings() {
301 let manager = LdapConnectionManager::new("ldap://example.com")
302 .unwrap()
303 .with_connection_settings(LdapConnSettings::new().set_starttls(true));
304
305 let cloned = manager.clone();
306
307 assert_eq!(cloned.url, manager.url);
308 assert!(
309 cloned.settings.starttls(),
310 "clone should maintain customized settings"
311 );
312 }
313
314 #[test]
315 fn new_accepts_owned_strings() {
316 let url = "ldap://example.com".to_string();
317 let manager = LdapConnectionManager::new(url).unwrap();
318 assert_eq!(manager.url, "ldap://example.com");
319 }
320
321 #[test]
322 fn with_bind_credentials_sets_values() {
323 let manager = LdapConnectionManager::new("ldap://example.com")
324 .unwrap()
325 .with_bind_credentials("cn=admin", "secret");
326
327 assert_eq!(manager.bind_dn.as_deref(), Some("cn=admin"));
328 assert_eq!(manager.bind_password.as_deref(), Some("secret"));
329 }
330
331 #[test]
332 fn with_validation_timeout_updates_value() {
333 let manager = LdapConnectionManager::new("ldap://example.com")
334 .unwrap()
335 .with_validation_timeout(Duration::from_secs(10));
336
337 assert_eq!(manager.validation_timeout, Duration::from_secs(10));
338 }
339
340 #[test]
341 fn with_validation_search_updates_values() {
342 let manager = LdapConnectionManager::new("ldap://example.com")
343 .unwrap()
344 .with_validation_search(
345 "dc=example,dc=org",
346 Scope::Subtree,
347 "(cn=alice)",
348 vec!["cn", "mail"],
349 );
350
351 assert_eq!(manager.validation_base_dn, "dc=example,dc=org");
352 assert_eq!(manager.validation_scope, Scope::Subtree);
353 assert_eq!(manager.validation_filter, "(cn=alice)");
354 assert_eq!(manager.validation_attributes, vec!["cn", "mail"]);
355 }
356
357 #[test]
358 fn with_connect_timeout_updates_settings() {
359 let manager = LdapConnectionManager::new("ldap://example.com")
360 .unwrap()
361 .with_connect_timeout(Duration::from_secs(5));
362
363 assert_eq!(manager.connect_timeout, Some(Duration::from_secs(5)));
364 }
365
366 #[test]
367 fn new_validates_urls() {
368 let manager =
369 LdapConnectionManager::new("ldap://example.com").expect("valid ldap URL should parse");
370 assert_eq!(manager.url, "ldap://example.com");
371
372 let err =
373 LdapConnectionManager::new("not a url").expect_err("invalid URLs should be rejected");
374 match err {
375 ldap3::LdapError::UrlParsing { .. } => {}
376 other => panic!("unexpected error: {other:?}"),
377 }
378
379 let err = LdapConnectionManager::new("http://example.com")
380 .expect_err("unsupported schemes should be rejected");
381 match err {
382 ldap3::LdapError::UnknownScheme(_) => {}
383 other => panic!("unexpected error: {other:?}"),
384 }
385 }
386
387 #[tokio::test]
388 async fn connection_pool() -> anyhow::Result<()> {
389 let node = match OpenLDAP::default()
390 .with_user("test_user", "test_password")
391 .start()
392 .await
393 {
394 Ok(node) => node,
395 Err(err @ TestcontainersError::Client(ClientError::PullImage { .. }))
396 | Err(err @ TestcontainersError::Client(ClientError::Init(_))) => {
397 eprintln!("skipping connection_pool test: {err}");
398 return Ok(());
399 }
400 Err(err) => return Err(err.into()),
401 };
402
403 let url = format!("ldap://127.0.0.1:{}", node.get_host_port_ipv4(1389).await?);
404 let conn_mgr = LdapConnectionManager::new(url)?
405 .with_bind_credentials("cn=admin,dc=example,dc=org", "adminpassword");
406
407 let mut conn = conn_mgr.connect().await?;
408
409 let search_res = conn
410 .search(
411 "ou=users,dc=example,dc=org",
412 ldap3::Scope::Subtree,
413 "(cn=*)",
414 vec!["cn"],
415 )
416 .await;
417
418 assert_eq!(search_res.iter().len(), 1);
419
420 assert!(
421 !conn_mgr.has_broken(&mut conn),
422 "freshly connected session should be healthy"
423 );
424
425 conn.unbind().await?;
426 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
428 assert!(
429 conn_mgr.has_broken(&mut conn),
430 "connection should be flagged as broken after unbind"
431 );
432
433 Ok(())
434 }
435}