1use crate::bus::{partition_for, BusError, EventBus, PublishAck, StoredEvent};
18use crate::manifest::BridgeManifest;
19use rdkafka::admin::{AdminClient, AdminOptions, NewTopic, ResourceSpecifier, TopicReplication};
20use rdkafka::client::DefaultClientContext;
21use rdkafka::config::ClientConfig;
22use rdkafka::types::RDKafkaErrorCode;
23use rskafka::client::partition::{Compression, OffsetAt, UnknownTopicHandling};
24use rskafka::client::{ClientBuilder, SaslConfig};
25use rskafka::record::Record;
26use std::collections::HashMap;
27use std::sync::Arc;
28
29struct KafkaSecurity {
31 ca_file: Option<std::path::PathBuf>,
32 credentials: Option<(String, String)>,
33 mechanism: SaslMechanism,
34}
35
36#[derive(Clone, Copy, PartialEq, Eq)]
37enum SaslMechanism {
38 Plain,
39 ScramSha256,
40 ScramSha512,
41}
42
43impl SaslMechanism {
44 fn parse(value: &str) -> Result<Self, BusError> {
45 match value {
46 "PLAIN" => Ok(Self::Plain),
47 "SCRAM-SHA-256" => Ok(Self::ScramSha256),
48 "SCRAM-SHA-512" => Ok(Self::ScramSha512),
49 other => Err(BusError::Backend(format!(
50 "AV_KAFKA_SASL_MECHANISM {other:?} is not supported \
51 (use PLAIN, SCRAM-SHA-256, or SCRAM-SHA-512)"
52 ))),
53 }
54 }
55
56 fn librdkafka_name(self) -> &'static str {
58 match self {
59 Self::Plain => "PLAIN",
60 Self::ScramSha256 => "SCRAM-SHA-256",
61 Self::ScramSha512 => "SCRAM-SHA-512",
62 }
63 }
64}
65
66impl KafkaSecurity {
67 fn from_env() -> Result<Self, BusError> {
68 Self::from_lookup(|name| std::env::var(name).ok())
69 }
70
71 fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self, BusError> {
75 let ca_file = get("AV_KAFKA_CA_FILE").map(std::path::PathBuf::from);
76 let credentials = match (get("AV_KAFKA_SASL_USERNAME"), get("AV_KAFKA_SASL_PASSWORD")) {
77 (Some(username), Some(password)) => Some((username, password)),
78 (None, None) => None,
79 _ => {
80 return Err(BusError::Backend(
81 "AV_KAFKA_SASL_USERNAME and AV_KAFKA_SASL_PASSWORD must be set together".to_owned(),
82 ))
83 }
84 };
85 let mechanism = match get("AV_KAFKA_SASL_MECHANISM") {
86 Some(value) => SaslMechanism::parse(&value)?,
87 None => SaslMechanism::ScramSha256,
88 };
89 if credentials.is_some() && ca_file.is_none() {
90 return Err(BusError::Backend(
91 "Kafka SASL credentials require AV_KAFKA_CA_FILE: PLAIN ships the password in \
92 the clear and even SCRAM without TLS is exposed to MITM relay"
93 .to_owned(),
94 ));
95 }
96 Ok(Self {
97 ca_file,
98 credentials,
99 mechanism,
100 })
101 }
102
103 fn apply_admin(&self, config: &mut ClientConfig) {
105 let protocol = match (&self.ca_file, &self.credentials) {
106 (Some(_), Some(_)) => "sasl_ssl",
107 (Some(_), None) => "ssl",
108 (None, _) => return,
109 };
110 config.set("security.protocol", protocol);
111 if let Some(ca) = &self.ca_file {
112 config.set("ssl.ca.location", ca.to_string_lossy().as_ref());
113 }
114 if let Some((username, password)) = &self.credentials {
115 config.set("sasl.mechanism", self.mechanism.librdkafka_name());
116 config.set("sasl.username", username);
117 config.set("sasl.password", password);
118 }
119 }
120
121 fn rskafka_tls(&self) -> Result<Option<Arc<rustls_tls::ClientConfig>>, BusError> {
123 let Some(ca) = &self.ca_file else {
124 return Ok(None);
125 };
126 let _ = rustls_tls::crypto::ring::default_provider().install_default();
130 let pem = std::fs::read(ca)
131 .map_err(|error| BusError::Backend(format!("Kafka CA file {}: {error}", ca.display())))?;
132 use rustls_pki_types::pem::PemObject as _;
133 let mut roots = rustls_tls::RootCertStore::empty();
134 let mut certs = 0usize;
135 for cert in rustls_pki_types::CertificateDer::pem_slice_iter(&pem) {
136 let cert = cert
137 .map_err(|error| BusError::Backend(format!("Kafka CA file {}: {error:?}", ca.display())))?;
138 roots
139 .add(cert)
140 .map_err(|error| BusError::Backend(format!("Kafka CA file {}: {error}", ca.display())))?;
141 certs = certs.saturating_add(1);
142 }
143 if certs == 0 {
144 return Err(BusError::Backend(format!(
145 "Kafka CA file {} contains no PEM certificates",
146 ca.display()
147 )));
148 }
149 let config = rustls_tls::ClientConfig::builder()
150 .with_root_certificates(roots)
151 .with_no_client_auth();
152 Ok(Some(Arc::new(config)))
153 }
154
155 fn rskafka_sasl(&self) -> Option<SaslConfig> {
156 self.credentials.as_ref().map(|(username, password)| {
157 let credentials = rskafka::client::Credentials::new(username.clone(), password.clone());
158 match self.mechanism {
159 SaslMechanism::Plain => SaslConfig::Plain(credentials),
160 SaslMechanism::ScramSha256 => SaslConfig::ScramSha256(credentials),
161 SaslMechanism::ScramSha512 => SaslConfig::ScramSha512(credentials),
162 }
163 })
164 }
165}
166
167pub struct KafkaBus {
169 cold_archive: Option<crate::cold_store::ColdArchive>,
170 executor: crate::bus::ConnectorExecutor,
171 partition_clients: HashMap<(String, u32), Arc<rskafka::client::partition::PartitionClient>>,
180 topics: HashMap<String, u32>,
181 validators: HashMap<String, jsonschema::Validator>,
182}
183
184impl KafkaBus {
185 pub fn provision(broker: &str, manifest: &BridgeManifest) -> Result<Self, BusError> {
187 manifest
188 .validate()
189 .map_err(|e| BusError::Backend(e.to_string()))?;
190 let validators = crate::manifest::compile_topic_validators(manifest)?;
191 let cold_archive = crate::cold_store::ColdArchive::from_manifest(manifest)?;
192 let executor = crate::bus::ConnectorExecutor::new("agentvisor-ai-kafka")?;
193 let security = KafkaSecurity::from_env()?;
194 let mut admin_config = ClientConfig::new();
195 admin_config
196 .set("bootstrap.servers", broker)
197 .set("socket.timeout.ms", "10000");
198 security.apply_admin(&mut admin_config);
199 let admin: AdminClient<DefaultClientContext> = admin_config
200 .create()
201 .map_err(|error| BusError::Backend(error.to_string()))?;
202 let admin = Arc::new(admin);
203 for topic in &manifest.topics {
204 let admin = Arc::clone(&admin);
205 let name = topic.name.clone();
206 let partitions = topic.partitions;
207 let replication_factor = manifest.replication_factor;
208 let retention_ms = u64::from(topic.retention.hot_hours)
209 .checked_mul(av_core::units::MS_PER_HOUR)
210 .ok_or_else(|| BusError::Backend("Kafka retention overflow".to_owned()))?
211 .to_string();
212 executor
213 .run(move || provision_topic(admin, name, partitions, replication_factor, retention_ms))?
214 .map_err(BusError::Backend)?;
215 }
216 drop(admin);
217 let brokers: Vec<String> = broker
222 .split(',')
223 .map(str::trim)
224 .filter(|entry| !entry.is_empty())
225 .map(str::to_owned)
226 .collect();
227 if brokers.is_empty() {
228 return Err(BusError::Backend("Kafka bootstrap list is empty".to_owned()));
229 }
230 let tls_config = security.rskafka_tls()?;
231 let sasl_config = security.rskafka_sasl();
232 let client = executor
233 .run(move || async move {
234 let mut builder = ClientBuilder::new(brokers);
235 if let Some(tls) = tls_config {
236 builder = builder.tls_config(tls);
237 }
238 if let Some(sasl) = sasl_config {
239 builder = builder.sasl_config(sasl);
240 }
241 builder.build().await
242 })?
243 .map_err(|e| BusError::Backend(e.to_string()))?;
244 let client = Arc::new(client);
245 let mut topics = HashMap::new();
246 for t in &manifest.topics {
247 topics.insert(t.name.clone(), t.partitions);
248 }
249 let metadata_client = Arc::clone(&client);
250 let metadata = executor
251 .run(move || async move { metadata_client.list_topics().await })?
252 .map_err(|error| BusError::Backend(error.to_string()))?;
253 for expected in &manifest.topics {
254 let actual = metadata
255 .iter()
256 .find(|topic| topic.name == expected.name)
257 .ok_or_else(|| BusError::Backend(format!("Kafka topic {:?} is missing", expected.name)))?;
258 if actual.partitions.len() != expected.partitions as usize {
259 return Err(BusError::Backend(format!(
260 "Kafka topic {:?} has {} partitions, manifest requires {}",
261 expected.name,
262 actual.partitions.len(),
263 expected.partitions
264 )));
265 }
266 }
267 let mut partition_clients = HashMap::new();
268 for t in &manifest.topics {
269 for p in 0..t.partitions {
270 let pc_client = Arc::clone(&client);
271 let name = t.name.clone();
272 let pc = executor
273 .run(move || async move {
274 pc_client
275 .partition_client(name, p as i32, UnknownTopicHandling::Error)
276 .await
277 })?
278 .map_err(|error| BusError::Backend(error.to_string()))?;
279 partition_clients.insert((t.name.clone(), p), Arc::new(pc));
280 }
281 }
282 Ok(Self {
283 cold_archive,
284 executor,
285 partition_clients,
286 topics,
287 validators,
288 })
289 }
290
291 fn partition_client(
293 &self,
294 topic: &str,
295 partition: u32,
296 ) -> Result<Arc<rskafka::client::partition::PartitionClient>, BusError> {
297 self.partition_clients
298 .get(&(topic.to_owned(), partition))
299 .cloned()
300 .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))
301 }
302
303 fn publish_with_uid(
304 &self,
305 topic: &str,
306 key: &str,
307 value: &serde_json::Value,
308 event_uid: Option<&str>,
309 ) -> Result<PublishAck, BusError> {
310 crate::manifest::validate_topic_event(&self.validators, topic, value)?;
311 let partitions = *self
312 .topics
313 .get(topic)
314 .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))?;
315 let partition = partition_for(key, partitions);
316 let event_uid = event_uid.map_or_else(av_core::new_event_uid, str::to_owned);
317 let stored_at = av_core::time::now_ms();
318 let record = StoredEvent {
319 partition,
320 offset: 0,
321 key: key.to_owned(),
322 value: value.clone(),
323 stored_at,
324 };
325 if let Some(archive) = &self.cold_archive {
326 archive.stage(topic, &record, &event_uid)?;
327 }
328 let ack = self.publish_broker_only(topic, key, value, stored_at, &event_uid)?;
329 if let Some(archive) = &self.cold_archive {
330 archive.commit(topic, &event_uid, ack.offset)?;
331 }
332 Ok(ack)
333 }
334
335 fn publish_broker_only(
336 &self,
337 topic: &str,
338 key: &str,
339 value: &serde_json::Value,
340 stored_at: u64,
341 event_uid: &str,
342 ) -> Result<PublishAck, BusError> {
343 crate::manifest::validate_topic_event(&self.validators, topic, value)?;
344 let partitions = *self
345 .topics
346 .get(topic)
347 .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))?;
348 let partition = partition_for(key, partitions);
349 let record = StoredEvent {
350 partition,
351 offset: 0,
352 key: key.to_owned(),
353 value: value.clone(),
354 stored_at,
355 };
356 let payload = serde_json::to_vec(&record)?;
357 let pc = self.partition_client(topic, partition)?;
358 let key_bytes = event_uid.as_bytes().to_vec();
359 let mut headers = std::collections::BTreeMap::new();
360 headers.insert("agentvisor-event-uid".to_owned(), event_uid.as_bytes().to_vec());
361 let offset = self
362 .executor
363 .run(move || async move {
364 let offsets = pc
365 .produce(
366 vec![Record {
367 key: Some(key_bytes),
368 value: Some(payload),
369 headers,
370 timestamp: chrono_now(),
371 }],
372 Compression::NoCompression,
373 )
374 .await?;
375 Ok::<_, rskafka::client::error::Error>(offsets.first().copied())
379 })?
380 .map_err(|e| BusError::Backend(e.to_string()))?;
381 let offset = offset.ok_or_else(|| {
382 BusError::Backend("Kafka produce succeeded but returned no offset for the record".to_owned())
383 })?;
384 let offset = u64::try_from(offset)
385 .map_err(|_| BusError::Backend(format!("Kafka returned negative offset {offset}")))?;
386 Ok(PublishAck {
387 topic: topic.to_owned(),
388 partition,
389 offset,
390 })
391 }
392}
393
394async fn provision_topic(
395 admin: Arc<AdminClient<DefaultClientContext>>,
396 name: String,
397 partitions: u32,
398 replication_factor: u32,
399 retention_ms: String,
400) -> Result<(), String> {
401 let partitions = i32::try_from(partitions)
402 .map_err(|_| format!("partition count for Kafka topic {name:?} exceeds i32"))?;
403 let replication_factor = i32::try_from(replication_factor)
404 .map_err(|_| format!("replication factor for Kafka topic {name:?} exceeds i32"))?;
405 let topic = NewTopic::new(&name, partitions, TopicReplication::Fixed(replication_factor))
406 .set("retention.ms", &retention_ms);
407 let results = admin
408 .create_topics(
409 [&topic],
410 &AdminOptions::new().operation_timeout(Some(std::time::Duration::from_secs(5))),
411 )
412 .await
413 .map_err(|error| error.to_string())?;
414 match results.into_iter().next() {
415 Some(Ok(_)) | Some(Err((_, RDKafkaErrorCode::TopicAlreadyExists))) => {}
416 Some(Err((_, error))) => return Err(format!("create Kafka topic {name:?}: {error}")),
417 None => return Err(format!("create Kafka topic {name:?} returned no result")),
418 }
419
420 let resource = ResourceSpecifier::Topic(&name);
421 let results = admin
422 .describe_configs([&resource], &AdminOptions::new())
423 .await
424 .map_err(|error| error.to_string())?;
425 let configuration = results
426 .into_iter()
427 .next()
428 .ok_or_else(|| format!("describe Kafka topic {name:?} returned no result"))?
429 .map_err(|error| format!("describe Kafka topic {name:?}: {error}"))?;
430 let actual = configuration
431 .get("retention.ms")
432 .and_then(|entry| entry.value.as_deref());
433 if actual != Some(retention_ms.as_str()) {
434 return Err(format!(
435 "Kafka topic {name:?} retention.ms is {actual:?}, manifest requires {retention_ms:?}"
436 ));
437 }
438 Ok(())
439}
440
441impl EventBus for KafkaBus {
442 fn set_control_key(&self, key: [u8; 32]) -> Result<(), BusError> {
443 if let Some(archive) = &self.cold_archive {
444 archive.set_control_key(key)?;
445 }
446 Ok(())
447 }
448
449 fn publish(&self, topic: &str, key: &str, value: &serde_json::Value) -> Result<PublishAck, BusError> {
450 let event_uid = value
451 .get("metadata")
452 .and_then(|metadata| metadata.get("uid"))
453 .and_then(serde_json::Value::as_str);
454 self.publish_with_uid(topic, key, value, event_uid)
455 }
456
457 fn publish_idempotent(
458 &self,
459 topic: &str,
460 key: &str,
461 value: &serde_json::Value,
462 event_uid: &str,
463 ) -> Result<PublishAck, BusError> {
464 self.publish_with_uid(topic, key, value, Some(event_uid))
465 }
466
467 fn find_event_by_uid(
468 &self,
469 topic: &str,
470 key: &str,
471 event_uid: &str,
472 ) -> Result<Option<PublishAck>, BusError> {
473 let partitions = *self
474 .topics
475 .get(topic)
476 .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))?;
477 let partition = partition_for(key, partitions);
478 let partition_client = self.partition_client(topic, partition)?;
479 let event_uid = event_uid.to_owned();
480 let found = self
481 .executor
482 .run(move || async move {
483 let mut offset = partition_client
484 .get_offset(OffsetAt::Earliest)
485 .await
486 .map_err(|error| error.to_string())?;
487 let latest = partition_client
488 .get_offset(OffsetAt::Latest)
489 .await
490 .map_err(|error| error.to_string())?;
491 while offset < latest {
492 let (records, _) = partition_client
493 .fetch_records(offset, 1..(16 * 1024 * 1024), 500)
494 .await
495 .map_err(|error| error.to_string())?;
496 if records.is_empty() {
497 break;
498 }
499 for record in &records {
500 if let Some(payload) = record.record.value.as_deref() {
501 let stored: StoredEvent =
502 serde_json::from_slice(payload).map_err(|error| error.to_string())?;
503 if stored
504 .value
505 .get("metadata")
506 .and_then(|metadata| metadata.get("uid"))
507 .and_then(serde_json::Value::as_str)
508 == Some(event_uid.as_str())
509 {
510 return Ok::<_, String>(Some(record.offset));
511 }
512 }
513 }
514 offset = records
515 .last()
516 .and_then(|record| record.offset.checked_add(1))
517 .ok_or_else(|| "Kafka event lookup offset overflow".to_owned())?;
518 }
519 Ok::<_, String>(None)
520 })?
521 .map_err(BusError::Backend)?;
522 found
523 .map(|offset| {
524 u64::try_from(offset)
525 .map(|offset| PublishAck {
526 topic: topic.to_owned(),
527 partition,
528 offset,
529 })
530 .map_err(|_| BusError::Backend(format!("Kafka returned negative offset {offset}")))
531 })
532 .transpose()
533 }
534
535 fn fetch(
536 &self,
537 topic: &str,
538 partition: u32,
539 offset: u64,
540 max: usize,
541 ) -> Result<Vec<StoredEvent>, BusError> {
542 if !self.topics.contains_key(topic) {
543 return Err(BusError::UnknownTopic(topic.to_owned()));
544 }
545 let pc = self.partition_client(topic, partition)?;
546 self.executor
547 .run(move || async move {
548 #[allow(clippy::cast_possible_wrap)]
549 let (records, _high_watermark) = pc
550 .fetch_records(offset as i64, 1..(16 * 1024 * 1024), 500)
551 .await
552 .map_err(|e| e.to_string())?;
553 let mut out = Vec::new();
554 for r in records {
555 let value = r
565 .record
566 .value
567 .ok_or_else(|| format!("fetch: null record value at offset {}", r.offset))?;
568 let mut ev: StoredEvent = serde_json::from_slice(&value)
569 .map_err(|e| format!("fetch decode at offset {}: {e}", r.offset))?;
570 #[allow(clippy::cast_sign_loss)]
571 {
572 ev.offset = r.offset as u64;
573 }
574 out.push(ev);
575 if out.len() >= max {
576 break;
577 }
578 }
579 Ok::<_, String>(out)
580 })?
581 .map_err(BusError::Backend)
582 }
583
584 fn partitions(&self, topic: &str) -> Result<u32, BusError> {
585 self.topics
586 .get(topic)
587 .copied()
588 .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))
589 }
590
591 fn topics(&self) -> Vec<String> {
592 let mut t: Vec<String> = self.topics.keys().cloned().collect();
593 t.sort();
594 t
595 }
596
597 fn maintenance(&self, _now_ms: u64) -> Result<u64, BusError> {
598 self.cold_archive.as_ref().map_or(Ok(0), |archive| {
599 archive.retry_pending_with(|pending| {
600 self.publish_broker_only(
601 &pending.topic,
602 &pending.key,
603 &pending.value,
604 pending.stored_at,
605 &pending.event_uid,
606 )
607 })
608 })
609 }
610}
611
612fn chrono_now() -> chrono::DateTime<chrono::Utc> {
613 chrono::Utc::now()
614}
615
616#[cfg(test)]
617mod tests {
618 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
619
620 use super::*;
621
622 fn lookup<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
623 move |name| {
624 pairs
625 .iter()
626 .find(|(key, _)| *key == name)
627 .map(|(_, value)| (*value).to_owned())
628 }
629 }
630
631 #[test]
632 fn no_security_env_means_plaintext_with_no_admin_overrides() {
633 let security = KafkaSecurity::from_lookup(lookup(&[])).unwrap();
634 assert!(security.ca_file.is_none() && security.credentials.is_none());
635 let mut config = ClientConfig::new();
636 security.apply_admin(&mut config);
637 assert!(config.get("security.protocol").is_none());
638 assert!(security.rskafka_tls().unwrap().is_none());
639 assert!(security.rskafka_sasl().is_none());
640 }
641
642 #[test]
643 fn partial_credentials_fail_loudly_not_anonymously() {
644 for pairs in [
645 &[("AV_KAFKA_SASL_USERNAME", "u")][..],
646 &[("AV_KAFKA_SASL_PASSWORD", "p")][..],
647 ] {
648 let Err(error) = KafkaSecurity::from_lookup(lookup(pairs)) else {
651 panic!("partial credentials must be refused");
652 };
653 assert!(
654 error.to_string().contains("must be set together"),
655 "wrong error: {error}"
656 );
657 }
658 }
659
660 #[test]
661 fn sasl_plain_without_tls_is_refused() {
662 let Err(error) = KafkaSecurity::from_lookup(lookup(&[
663 ("AV_KAFKA_SASL_USERNAME", "u"),
664 ("AV_KAFKA_SASL_PASSWORD", "p"),
665 ])) else {
666 panic!("SASL without TLS must be refused");
667 };
668 assert!(error.to_string().contains("AV_KAFKA_CA_FILE"), "{error}");
669 }
670
671 #[test]
672 fn ca_only_selects_ssl_and_ca_plus_credentials_selects_sasl_ssl() {
673 let ssl_only = KafkaSecurity::from_lookup(lookup(&[("AV_KAFKA_CA_FILE", "/tmp/ca.crt")])).unwrap();
674 let mut config = ClientConfig::new();
675 ssl_only.apply_admin(&mut config);
676 assert_eq!(config.get("security.protocol"), Some("ssl"));
677 assert_eq!(config.get("ssl.ca.location"), Some("/tmp/ca.crt"));
678 assert!(config.get("sasl.mechanism").is_none());
679
680 let full = KafkaSecurity::from_lookup(lookup(&[
681 ("AV_KAFKA_CA_FILE", "/tmp/ca.crt"),
682 ("AV_KAFKA_SASL_USERNAME", "u"),
683 ("AV_KAFKA_SASL_PASSWORD", "p"),
684 ]))
685 .unwrap();
686 let mut config = ClientConfig::new();
687 full.apply_admin(&mut config);
688 assert_eq!(config.get("security.protocol"), Some("sasl_ssl"));
689 assert_eq!(config.get("sasl.mechanism"), Some("SCRAM-SHA-256"));
691 assert_eq!(config.get("sasl.username"), Some("u"));
692 match full.rskafka_sasl() {
693 Some(SaslConfig::ScramSha256(credentials)) => {
694 assert_eq!(credentials.username, "u");
695 assert_eq!(credentials.password, "p");
696 }
697 other => panic!("expected SCRAM-SHA-256 sasl config, got {other:?}"),
698 }
699 }
700
701 #[test]
704 fn sasl_mechanism_selection_is_explicit_and_validated() {
705 for (name, is_match) in [
706 (
707 "PLAIN",
708 (|s| matches!(s, Some(SaslConfig::Plain(_)))) as fn(Option<SaslConfig>) -> bool,
709 ),
710 ("SCRAM-SHA-256", |s| matches!(s, Some(SaslConfig::ScramSha256(_)))),
711 ("SCRAM-SHA-512", |s| matches!(s, Some(SaslConfig::ScramSha512(_)))),
712 ] {
713 let security = KafkaSecurity::from_lookup(lookup(&[
714 ("AV_KAFKA_CA_FILE", "/tmp/ca.crt"),
715 ("AV_KAFKA_SASL_USERNAME", "u"),
716 ("AV_KAFKA_SASL_PASSWORD", "p"),
717 ("AV_KAFKA_SASL_MECHANISM", name),
718 ]))
719 .unwrap();
720 let mut config = ClientConfig::new();
721 security.apply_admin(&mut config);
722 assert_eq!(config.get("sasl.mechanism"), Some(name));
723 assert!(is_match(security.rskafka_sasl()), "{name} must map on rskafka");
724 }
725 let Err(error) = KafkaSecurity::from_lookup(lookup(&[
726 ("AV_KAFKA_CA_FILE", "/tmp/ca.crt"),
727 ("AV_KAFKA_SASL_USERNAME", "u"),
728 ("AV_KAFKA_SASL_PASSWORD", "p"),
729 ("AV_KAFKA_SASL_MECHANISM", "GSSAPI"),
730 ])) else {
731 panic!("unsupported mechanism must be refused");
732 };
733 assert!(error.to_string().contains("not supported"), "{error}");
734 }
735
736 #[test]
737 fn missing_or_empty_ca_file_fails_loudly() {
738 let missing = KafkaSecurity {
739 ca_file: Some(std::path::PathBuf::from("/nonexistent/ab-ca.crt")),
740 credentials: None,
741 mechanism: SaslMechanism::ScramSha256,
742 };
743 assert!(missing.rskafka_tls().is_err(), "missing CA file must error");
744
745 let dir = tempfile::tempdir().unwrap();
746 let empty = dir.path().join("empty.pem");
747 std::fs::write(&empty, b"not a pem").unwrap();
748 let security = KafkaSecurity {
749 ca_file: Some(empty),
750 credentials: None,
751 mechanism: SaslMechanism::ScramSha256,
752 };
753 let error = security.rskafka_tls().unwrap_err();
754 assert!(
755 error.to_string().contains("no PEM certificates"),
756 "wrong error: {error}"
757 );
758 }
759}