1use crate::ber;
33use crate::protocol::{
34 LdapMessage, LdapResult, Operation, ProtocolOp, SearchOutcome, SearchRequest,
35};
36use rustlavel_core::{Error, Result};
37use std::sync::Arc;
38use std::time::Duration;
39use tokio::io::{AsyncReadExt, AsyncWriteExt};
40use tokio::net::TcpStream;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum Encryption {
45 None,
49 StartTls,
52 #[default]
54 Ldaps,
55}
56
57#[derive(Debug, Clone)]
62pub struct LdapConfig {
63 pub host: String,
64 pub port: u16,
65 pub encryption: Encryption,
66 pub verify_certificate: bool,
69 pub allow_plaintext_password: bool,
76 pub connect_timeout: Duration,
77 pub timeout: Duration,
79 pub max_message_size: usize,
84}
85
86impl LdapConfig {
87 pub fn parse(url: &str) -> Result<LdapConfig> {
95 let (scheme, rest) = url.split_once("://").ok_or_else(|| {
96 Error::msg(format!(
97 "`{url}` is not an LDAP URL. It should start with `ldap://` or `ldaps://` — the \
98 scheme is what decides whether the password is encrypted, so it is not optional."
99 ))
100 })?;
101
102 let (encryption, default_port) = match scheme.to_ascii_lowercase().as_str() {
103 "ldap" => (Encryption::StartTls, 389),
104 "ldaps" => (Encryption::Ldaps, 636),
105 other => {
106 return Err(Error::msg(format!(
107 "`{other}` is not an LDAP scheme. Use `ldaps://` for TLS from the first byte, \
108 or `ldap://` for port 389 with StartTLS."
109 )));
110 }
111 };
112
113 let authority = rest.trim_end_matches('/');
117 if authority.contains('/') {
118 return Err(Error::msg(format!(
119 "`{url}` has a path. This package takes the search base and filter as arguments, \
120 not as part of the URL — give it just `{scheme}://host:port`."
121 )));
122 }
123 if authority.is_empty() {
124 return Err(Error::msg(format!("`{url}` names no host")));
125 }
126
127 let (host, port) = split_authority(authority, default_port)?;
128
129 Ok(LdapConfig {
130 host,
131 port,
132 encryption,
133 verify_certificate: true,
134 allow_plaintext_password: false,
135 connect_timeout: Duration::from_secs(10),
136 timeout: Duration::from_secs(30),
137 max_message_size: 8 * 1024 * 1024,
138 })
139 }
140
141 pub fn plaintext(mut self) -> LdapConfig {
148 self.encryption = Encryption::None;
149 self
150 }
151
152 pub fn start_tls(mut self) -> LdapConfig {
153 self.encryption = Encryption::StartTls;
154 self
155 }
156
157 pub fn ldaps(mut self) -> LdapConfig {
158 self.encryption = Encryption::Ldaps;
159 self
160 }
161
162 pub fn port(mut self, port: u16) -> LdapConfig {
163 self.port = port;
164 self
165 }
166
167 pub fn allow_plaintext_password(mut self) -> LdapConfig {
173 self.allow_plaintext_password = true;
174 self
175 }
176
177 pub fn dangerously_accept_any_certificate(mut self) -> LdapConfig {
183 self.verify_certificate = false;
184 self
185 }
186
187 pub fn timeout(mut self, timeout: Duration) -> LdapConfig {
188 self.timeout = timeout;
189 self
190 }
191
192 pub fn connect_timeout(mut self, timeout: Duration) -> LdapConfig {
193 self.connect_timeout = timeout;
194 self
195 }
196
197 pub fn max_message_size(mut self, bytes: usize) -> LdapConfig {
198 self.max_message_size = bytes;
199 self
200 }
201
202 pub fn address(&self) -> String {
203 if self.host.contains(':') {
204 return format!("[{}]:{}", self.host, self.port);
205 }
206 format!("{}:{}", self.host, self.port)
207 }
208}
209
210fn split_authority(authority: &str, default_port: u16) -> Result<(String, u16)> {
212 if let Some(rest) = authority.strip_prefix('[') {
213 let (host, tail) = rest.split_once(']').ok_or_else(|| {
214 Error::msg(format!("`{authority}` opens a bracketed IPv6 address but never closes it"))
215 })?;
216 let port = match tail.strip_prefix(':') {
217 Some(port) => parse_port(port)?,
218 None if tail.is_empty() => default_port,
219 None => return Err(Error::msg(format!("`{authority}` has junk after the address"))),
220 };
221 return Ok((host.to_string(), port));
222 }
223
224 match authority.rsplit_once(':') {
225 Some((host, port)) => Ok((host.to_string(), parse_port(port)?)),
226 None => Ok((authority.to_string(), default_port)),
227 }
228}
229
230fn parse_port(port: &str) -> Result<u16> {
231 port.parse().map_err(|_| Error::msg(format!("`{port}` is not a port number")))
232}
233
234enum LdapStream {
242 Plain(TcpStream),
243 Tls(Box<tokio_rustls::client::TlsStream<TcpStream>>),
244 Upgrading,
245}
246
247impl LdapStream {
248 fn is_encrypted(&self) -> bool {
249 matches!(self, LdapStream::Tls(_))
250 }
251
252 async fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
253 match self {
254 LdapStream::Plain(stream) => stream.write_all(bytes).await,
255 LdapStream::Tls(stream) => stream.write_all(bytes).await,
256 LdapStream::Upgrading => Err(mid_upgrade()),
257 }
258 }
259
260 async fn flush(&mut self) -> std::io::Result<()> {
261 match self {
262 LdapStream::Plain(stream) => stream.flush().await,
263 LdapStream::Tls(stream) => stream.flush().await,
264 LdapStream::Upgrading => Err(mid_upgrade()),
265 }
266 }
267
268 async fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
269 match self {
270 LdapStream::Plain(stream) => stream.read(buffer).await,
271 LdapStream::Tls(stream) => stream.read(buffer).await,
272 LdapStream::Upgrading => Err(mid_upgrade()),
273 }
274 }
275
276 async fn shutdown(&mut self) -> std::io::Result<()> {
277 match self {
278 LdapStream::Plain(stream) => stream.shutdown().await,
279 LdapStream::Tls(stream) => stream.shutdown().await,
280 LdapStream::Upgrading => Ok(()),
281 }
282 }
283}
284
285fn mid_upgrade() -> std::io::Error {
286 std::io::Error::other("the connection was left mid-upgrade; this is a bug in this client")
287}
288
289pub struct LdapConnection {
294 stream: LdapStream,
295 config: LdapConfig,
296 buffer: Vec<u8>,
299 next_id: i64,
300 bound_as: Option<String>,
301}
302
303impl LdapConnection {
304 pub async fn connect(config: &LdapConfig) -> Result<LdapConnection> {
306 let address = config.address();
307
308 let tcp = tokio::time::timeout(config.connect_timeout, TcpStream::connect(&address))
309 .await
310 .map_err(|_| {
311 Error::msg(format!(
312 "connecting to the directory at {address} timed out after {:?}",
313 config.connect_timeout
314 ))
315 })?
316 .map_err(|error| {
317 Error::msg(format!("cannot reach the directory at {address}: {error}"))
318 })?;
319
320 let _ = tcp.set_nodelay(true);
323
324 let mut connection = LdapConnection {
325 stream: LdapStream::Plain(tcp),
326 config: config.clone(),
327 buffer: Vec::with_capacity(4096),
328 next_id: 1,
329 bound_as: None,
330 };
331
332 match config.encryption {
333 Encryption::None => {}
334 Encryption::Ldaps => connection.upgrade().await?,
335 Encryption::StartTls => connection.start_tls().await?,
336 }
337
338 Ok(connection)
339 }
340
341 pub fn is_encrypted(&self) -> bool {
343 self.stream.is_encrypted()
344 }
345
346 pub fn bound_as(&self) -> Option<&str> {
348 self.bound_as.as_deref()
349 }
350
351 pub fn config(&self) -> &LdapConfig {
352 &self.config
353 }
354
355 pub async fn bind(&mut self, dn: &str, password: &str) -> Result<LdapResult> {
362 let operation = Operation::simple_bind(dn, password)?;
363 let result = self.bind_with(operation).await?;
364 if result.is_success() {
365 self.bound_as = Some(dn.to_string());
366 }
367 Ok(result)
368 }
369
370 pub async fn bind_anonymous(&mut self) -> Result<LdapResult> {
374 let result = self.bind_with(Operation::anonymous_bind()).await?;
375 if result.is_success() {
376 self.bound_as = None;
377 }
378 Ok(result)
379 }
380
381 async fn bind_with(&mut self, operation: Operation) -> Result<LdapResult> {
382 let id = self.send(&operation).await?;
383 let message = self.receive(id).await?;
384
385 match message.op {
386 ProtocolOp::BindResponse { result, .. } => Ok(result),
387 other => Err(Error::msg(format!(
388 "asked the directory to bind and it answered with {}",
389 other.name()
390 ))),
391 }
392 }
393
394 pub async fn search(&mut self, request: &SearchRequest) -> Result<SearchOutcome> {
401 let operation = Operation::search(request);
402 let id = self.send(&operation).await?;
403
404 let mut entries = Vec::new();
405 let mut referrals = Vec::new();
406
407 loop {
408 let message = self.receive(id).await?;
409 match message.op {
410 ProtocolOp::SearchResultEntry(entry) => entries.push(entry),
411 ProtocolOp::SearchResultReference(uris) => referrals.push(uris),
412 ProtocolOp::SearchResultDone(result) => {
413 return Ok(SearchOutcome { entries, referrals, result });
414 }
415 ProtocolOp::IntermediateResponse => {}
419 other => {
420 return Err(Error::msg(format!(
421 "the directory sent {} in the middle of a search",
422 other.name()
423 )));
424 }
425 }
426 }
427 }
428
429 pub async fn unbind(mut self) -> Result<()> {
435 let operation = Operation::unbind();
436 let id = self.next_message_id()?;
437 let bytes = operation.encode(id);
438
439 let _ = self.stream.write_all(&bytes).await;
443 let _ = self.stream.flush().await;
444 let _ = self.stream.shutdown().await;
445 Ok(())
446 }
447
448 pub async fn send(&mut self, operation: &Operation) -> Result<i64> {
453 if operation.carries_password()
454 && !self.is_encrypted()
455 && !self.config.allow_plaintext_password
456 {
457 return Err(Error::msg(format!(
458 "refusing to send a simple bind to {} over an unencrypted connection: the \
459 password would go out in the clear, readable by anything on the path. Use \
460 `ldaps://` (TLS from the first byte) or StartTLS. If this really is a local test \
461 directory, say so with `LdapConfig::allow_plaintext_password`.",
462 self.config.address()
463 )));
464 }
465
466 let id = self.next_message_id()?;
467 let bytes = operation.encode(id);
468
469 tokio::time::timeout(self.config.timeout, self.stream.write_all(&bytes))
470 .await
471 .map_err(|_| self.timed_out(operation.kind()))?
472 .map_err(|error| {
473 Error::msg(format!(
474 "writing the {} to {} failed: {error}",
475 operation.kind(),
476 self.config.address()
477 ))
478 })?;
479
480 self.stream.flush().await.map_err(Error::Io)?;
481 Ok(id)
482 }
483
484 pub async fn receive(&mut self, expected_id: i64) -> Result<LdapMessage> {
492 let message = self.read_message().await?;
493
494 if message.id == expected_id {
495 return Ok(message);
496 }
497
498 if message.id == 0 {
502 let reason = message
503 .op
504 .result()
505 .map(|result| format!("{}: {}", result.code, result.diagnostic))
506 .unwrap_or_else(|| message.op.name().to_string());
507 return Err(Error::msg(format!(
508 "the directory at {} sent an unsolicited notification and is closing the \
509 connection — {reason}",
510 self.config.address()
511 )));
512 }
513
514 Err(Error::msg(format!(
515 "the directory answered message {} while message {expected_id} was the one \
516 outstanding. Refusing to treat one operation's answer as another's.",
517 message.id
518 )))
519 }
520
521 async fn read_message(&mut self) -> Result<LdapMessage> {
522 loop {
523 if let Some(size) = ber::element_size(&self.buffer, self.config.max_message_size)? {
524 let bytes: Vec<u8> = self.buffer.drain(..size).collect();
525 return LdapMessage::parse(&bytes);
526 }
527
528 let mut chunk = [0u8; 8192];
529 let read = tokio::time::timeout(self.config.timeout, self.stream.read(&mut chunk))
530 .await
531 .map_err(|_| self.timed_out("a response"))?
532 .map_err(|error| {
533 Error::msg(format!(
534 "reading from the directory at {} failed: {error}",
535 self.config.address()
536 ))
537 })?;
538
539 if read == 0 {
540 return Err(Error::msg(format!(
541 "the directory at {} closed the connection with {} bytes of a message \
542 outstanding",
543 self.config.address(),
544 self.buffer.len()
545 )));
546 }
547
548 self.buffer.extend_from_slice(&chunk[..read]);
549 }
550 }
551
552 fn timed_out(&self, what: &str) -> Error {
553 Error::msg(format!(
554 "the directory at {} did not finish {what} within {:?}",
555 self.config.address(),
556 self.config.timeout
557 ))
558 }
559
560 fn next_message_id(&mut self) -> Result<i64> {
561 if self.next_id > i64::from(i32::MAX) {
566 return Err(Error::msg(
567 "this connection has used every message id; open a new one",
568 ));
569 }
570 let id = self.next_id;
571 self.next_id += 1;
572 Ok(id)
573 }
574
575 async fn start_tls(&mut self) -> Result<()> {
577 let operation = Operation::start_tls();
578 let id = self.send(&operation).await?;
579 let message = self.receive(id).await?;
580
581 let result = match message.op {
582 ProtocolOp::ExtendedResponse { result, .. } => result,
583 other => {
584 return Err(Error::msg(format!(
585 "asked for StartTLS and the directory answered with {}",
586 other.name()
587 )));
588 }
589 };
590
591 if !result.is_success() {
592 return Err(Error::msg(format!(
593 "the directory at {} refused StartTLS: {}{}. It is not configured for TLS on \
594 this port. Connecting anyway would send the password in the clear, so this \
595 stops here rather than downgrading.",
596 self.config.address(),
597 result.code,
598 match result.diagnostic.as_str() {
599 "" => String::new(),
600 diagnostic => format!(" — {diagnostic}"),
601 }
602 )));
603 }
604
605 if !self.buffer.is_empty() {
610 return Err(Error::msg(
611 "the directory sent data after agreeing to StartTLS but before the handshake. \
612 Those bytes are unauthenticated, so the connection is abandoned rather than \
613 trusted.",
614 ));
615 }
616
617 self.upgrade().await
618 }
619
620 async fn upgrade(&mut self) -> Result<()> {
622 let plain = match std::mem::replace(&mut self.stream, LdapStream::Upgrading) {
623 LdapStream::Plain(stream) => stream,
624 LdapStream::Tls(stream) => {
625 self.stream = LdapStream::Tls(stream);
626 return Err(Error::msg("this connection is already encrypted"));
627 }
628 LdapStream::Upgrading => return Err(Error::msg(mid_upgrade().to_string())),
629 };
630
631 let connector = tokio_rustls::TlsConnector::from(client_config(&self.config));
632
633 let name = rustls::pki_types::ServerName::try_from(self.config.host.clone())
637 .map_err(|_| {
638 Error::msg(format!("`{}` is not a valid TLS server name", self.config.host))
639 })?;
640
641 let tls = tokio::time::timeout(self.config.connect_timeout, connector.connect(name, plain))
642 .await
643 .map_err(|_| {
644 Error::msg(format!(
645 "the TLS handshake with {} timed out",
646 self.config.address()
647 ))
648 })?
649 .map_err(|error| {
650 Error::msg(format!(
651 "the TLS handshake with {} failed: {error}. If this is a development \
652 directory with a self-signed certificate, say so explicitly with \
653 `dangerously_accept_any_certificate` rather than turning encryption off.",
654 self.config.address()
655 ))
656 })?;
657
658 self.stream = LdapStream::Tls(Box::new(tls));
659 Ok(())
660 }
661}
662
663fn client_config(config: &LdapConfig) -> Arc<rustls::ClientConfig> {
675 use std::sync::OnceLock;
676 static VERIFYING: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
677 static TRUSTING: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
678
679 if config.verify_certificate {
680 return VERIFYING
681 .get_or_init(|| {
682 let roots =
683 rustls::RootCertStore { roots: webpki_roots::TLS_SERVER_ROOTS.to_vec() };
684 Arc::new(
685 rustls::ClientConfig::builder()
686 .with_root_certificates(roots)
687 .with_no_client_auth(),
688 )
689 })
690 .clone();
691 }
692
693 TRUSTING
694 .get_or_init(|| {
695 Arc::new(
696 rustls::ClientConfig::builder()
697 .dangerous()
698 .with_custom_certificate_verifier(Arc::new(TrustAnyCertificate::new()))
699 .with_no_client_auth(),
700 )
701 })
702 .clone()
703}
704
705#[derive(Debug)]
712struct TrustAnyCertificate(Arc<rustls::crypto::CryptoProvider>);
713
714impl TrustAnyCertificate {
715 fn new() -> TrustAnyCertificate {
716 let provider = rustls::crypto::CryptoProvider::get_default()
717 .cloned()
718 .unwrap_or_else(|| Arc::new(rustls::crypto::aws_lc_rs::default_provider()));
719 TrustAnyCertificate(provider)
720 }
721}
722
723impl rustls::client::danger::ServerCertVerifier for TrustAnyCertificate {
724 fn verify_server_cert(
725 &self,
726 _end_entity: &rustls::pki_types::CertificateDer<'_>,
727 _intermediates: &[rustls::pki_types::CertificateDer<'_>],
728 _server_name: &rustls::pki_types::ServerName<'_>,
729 _ocsp_response: &[u8],
730 _now: rustls::pki_types::UnixTime,
731 ) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
732 Ok(rustls::client::danger::ServerCertVerified::assertion())
733 }
734
735 fn verify_tls12_signature(
736 &self,
737 message: &[u8],
738 cert: &rustls::pki_types::CertificateDer<'_>,
739 dss: &rustls::DigitallySignedStruct,
740 ) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
741 rustls::crypto::verify_tls12_signature(
742 message,
743 cert,
744 dss,
745 &self.0.signature_verification_algorithms,
746 )
747 }
748
749 fn verify_tls13_signature(
750 &self,
751 message: &[u8],
752 cert: &rustls::pki_types::CertificateDer<'_>,
753 dss: &rustls::DigitallySignedStruct,
754 ) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
755 rustls::crypto::verify_tls13_signature(
756 message,
757 cert,
758 dss,
759 &self.0.signature_verification_algorithms,
760 )
761 }
762
763 fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
764 self.0.signature_verification_algorithms.supported_schemes()
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771
772 #[test]
773 fn urls_carry_the_security_decision_in_their_scheme() {
774 let ldaps = LdapConfig::parse("ldaps://dc.example.test").unwrap();
775 assert_eq!(ldaps.host, "dc.example.test");
776 assert_eq!(ldaps.port, 636);
777 assert_eq!(ldaps.encryption, Encryption::Ldaps);
778
779 let ldap = LdapConfig::parse("ldap://dc.example.test").unwrap();
783 assert_eq!(ldap.port, 389);
784 assert_eq!(ldap.encryption, Encryption::StartTls);
785
786 assert_eq!(LdapConfig::parse("ldap://dc.example.test:3389").unwrap().port, 3389);
787 assert_eq!(LdapConfig::parse("LDAPS://Host").unwrap().encryption, Encryption::Ldaps);
788 assert_eq!(LdapConfig::parse("ldaps://[::1]:1636").unwrap().host, "::1");
789 assert_eq!(LdapConfig::parse("ldaps://[::1]").unwrap().port, 636);
790 assert_eq!(LdapConfig::parse("ldaps://[::1]:1636").unwrap().address(), "[::1]:1636");
791 }
792
793 #[test]
794 fn the_defaults_are_the_careful_ones() {
795 let config = LdapConfig::parse("ldaps://dc.example.test").unwrap();
796 assert!(config.verify_certificate, "certificates are checked unless told otherwise");
797 assert!(!config.allow_plaintext_password, "and passwords do not go out in the clear");
798 }
799
800 #[test]
801 fn a_url_without_a_scheme_says_why_the_scheme_matters() {
802 let error = LdapConfig::parse("dc.example.test").unwrap_err().to_string();
803 assert!(error.contains("ldaps://"), "got {error}");
804 assert!(error.contains("encrypted"), "the error should say what is at stake: {error}");
805
806 assert!(LdapConfig::parse("http://dc.example.test").is_err());
807 assert!(LdapConfig::parse("ldap://dc.example.test/dc=example").is_err());
808 assert!(LdapConfig::parse("ldap://").is_err());
809 assert!(LdapConfig::parse("ldap://host:not-a-port").is_err());
810 assert!(LdapConfig::parse("ldaps://[::1").is_err());
811 }
812
813 #[tokio::test]
814 async fn a_simple_bind_over_plain_tcp_is_refused_and_the_error_says_why() {
815 let config = LdapConfig::parse("ldap://127.0.0.1:1").unwrap().plaintext();
823 let mut connection = LdapConnection {
824 stream: LdapStream::Upgrading,
825 config,
826 buffer: Vec::new(),
827 next_id: 1,
828 bound_as: None,
829 };
830
831 let error = connection.bind("cn=admin", "secret").await.unwrap_err().to_string();
832 assert!(error.contains("unencrypted"), "got {error}");
833 assert!(error.contains("ldaps://"), "the error should name the fix: {error}");
834 assert!(error.contains("allow_plaintext_password"), "and the opt-in: {error}");
835 }
836
837 #[tokio::test]
838 async fn an_empty_password_never_reaches_the_transport_check_or_the_socket() {
839 let config = LdapConfig::parse("ldap://127.0.0.1:1")
844 .unwrap()
845 .plaintext()
846 .allow_plaintext_password();
847 let mut connection = LdapConnection {
848 stream: LdapStream::Upgrading,
849 config,
850 buffer: Vec::new(),
851 next_id: 1,
852 bound_as: None,
853 };
854
855 let error = connection.bind("cn=admin", "").await.unwrap_err().to_string();
856 assert!(error.contains("anonymous"), "got {error}");
857 }
858
859 #[tokio::test]
860 async fn an_unreachable_directory_names_the_address() {
861 let config = LdapConfig::parse("ldaps://127.0.0.1:1")
862 .unwrap()
863 .connect_timeout(Duration::from_secs(2));
864 let error = match LdapConnection::connect(&config).await {
867 Ok(_) => panic!("something is listening on port 1"),
868 Err(error) => error.to_string(),
869 };
870 assert!(error.contains("127.0.0.1:1"), "got {error}");
871 }
872
873 #[test]
874 fn message_ids_start_at_one_and_never_repeat() {
875 let mut connection = LdapConnection {
879 stream: LdapStream::Upgrading,
880 config: LdapConfig::parse("ldaps://host").unwrap(),
881 buffer: Vec::new(),
882 next_id: 1,
883 bound_as: None,
884 };
885
886 assert_eq!(connection.next_message_id().unwrap(), 1);
887 assert_eq!(connection.next_message_id().unwrap(), 2);
888 assert_eq!(connection.next_message_id().unwrap(), 3);
889
890 connection.next_id = i64::from(i32::MAX) + 1;
891 assert!(connection.next_message_id().is_err(), "exhaustion is an error, not a wrap");
892 }
893
894 #[test]
907 fn the_key_exchange_leads_with_a_post_quantum_hybrid() {
908 let config = client_config(&LdapConfig::parse("ldaps://host").unwrap());
909
910 let offered: Vec<String> = config
911 .crypto_provider()
912 .kx_groups
913 .iter()
914 .map(|group| format!("{:?}", group.name()))
915 .collect();
916
917 assert_eq!(
918 offered.first().map(String::as_str),
919 Some("X25519MLKEM768"),
920 "the post-quantum hybrid must lead the ClientHello; offered: {offered:?}"
921 );
922 assert!(
923 offered.iter().any(|name| name == "X25519"),
924 "a classical group must remain, for directories that have not caught up: {offered:?}"
925 );
926 }
927}