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 "PV_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("PV_KAFKA_CA_FILE").map(std::path::PathBuf::from);
76 let credentials = match (get("PV_KAFKA_SASL_USERNAME"), get("PV_KAFKA_SASL_PASSWORD")) {
77 (Some(username), Some(password)) => Some((username, password)),
78 (None, None) => None,
79 _ => {
80 return Err(BusError::Backend(
81 "PV_KAFKA_SASL_USERNAME and PV_KAFKA_SASL_PASSWORD must be set together".to_owned(),
82 ))
83 }
84 };
85 let mechanism = match get("PV_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 PV_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 recent_uids: parking_lot::Mutex<RecentUids>,
194}
195
196struct RecentUids {
199 map: HashMap<String, (u32, u64)>,
200 order: std::collections::VecDeque<String>,
201}
202
203const RECENT_UIDS_CAP: usize = 65_536;
206
207impl RecentUids {
208 fn new() -> Self {
209 Self {
210 map: HashMap::new(),
211 order: std::collections::VecDeque::new(),
212 }
213 }
214
215 fn get(&self, topic: &str, uid: &str) -> Option<(u32, u64)> {
216 self.map.get(&format!("{topic}\0{uid}")).copied()
217 }
218
219 fn insert(&mut self, topic: &str, uid: &str, partition: u32, offset: u64) {
220 let key = format!("{topic}\0{uid}");
221 if self.map.insert(key.clone(), (partition, offset)).is_none() {
222 self.order.push_back(key);
223 while self.order.len() > RECENT_UIDS_CAP {
224 if let Some(evicted) = self.order.pop_front() {
225 self.map.remove(&evicted);
226 }
227 }
228 }
229 }
230}
231
232impl KafkaBus {
233 pub fn provision(broker: &str, manifest: &BridgeManifest) -> Result<Self, BusError> {
235 manifest
236 .validate()
237 .map_err(|e| BusError::Backend(e.to_string()))?;
238 let validators = crate::manifest::compile_topic_validators(manifest)?;
239 let cold_archive = crate::cold_store::ColdArchive::from_manifest(manifest)?;
240 let executor = crate::bus::ConnectorExecutor::new("proofvisor-kafka")?;
241 let security = KafkaSecurity::from_env()?;
242 let mut admin_config = ClientConfig::new();
243 admin_config
244 .set("bootstrap.servers", broker)
245 .set("socket.timeout.ms", "10000");
246 security.apply_admin(&mut admin_config);
247 let admin: AdminClient<DefaultClientContext> = admin_config
248 .create()
249 .map_err(|error| BusError::Backend(error.to_string()))?;
250 let admin = Arc::new(admin);
251 for topic in &manifest.topics {
252 let admin = Arc::clone(&admin);
253 let name = topic.name.clone();
254 let partitions = topic.partitions;
255 let replication_factor = manifest.replication_factor;
256 let retention_ms = u64::from(topic.retention.hot_hours)
257 .checked_mul(pv_core::units::MS_PER_HOUR)
258 .ok_or_else(|| BusError::Backend("Kafka retention overflow".to_owned()))?
259 .to_string();
260 executor
261 .run(move || provision_topic(admin, name, partitions, replication_factor, retention_ms))?
262 .map_err(BusError::Backend)?;
263 }
264 drop(admin);
265 let brokers: Vec<String> = broker
270 .split(',')
271 .map(str::trim)
272 .filter(|entry| !entry.is_empty())
273 .map(str::to_owned)
274 .collect();
275 if brokers.is_empty() {
276 return Err(BusError::Backend("Kafka bootstrap list is empty".to_owned()));
277 }
278 let tls_config = security.rskafka_tls()?;
279 let sasl_config = security.rskafka_sasl();
280 let client = executor
281 .run(move || async move {
282 let mut builder = ClientBuilder::new(brokers);
283 if let Some(tls) = tls_config {
284 builder = builder.tls_config(tls);
285 }
286 if let Some(sasl) = sasl_config {
287 builder = builder.sasl_config(sasl);
288 }
289 builder.build().await
290 })?
291 .map_err(|e| BusError::Backend(e.to_string()))?;
292 let client = Arc::new(client);
293 let mut topics = HashMap::new();
294 for t in &manifest.topics {
295 topics.insert(t.name.clone(), t.partitions);
296 }
297 let metadata_client = Arc::clone(&client);
298 let metadata = executor
299 .run(move || async move { metadata_client.list_topics().await })?
300 .map_err(|error| BusError::Backend(error.to_string()))?;
301 for expected in &manifest.topics {
302 let actual = metadata
303 .iter()
304 .find(|topic| topic.name == expected.name)
305 .ok_or_else(|| BusError::Backend(format!("Kafka topic {:?} is missing", expected.name)))?;
306 if actual.partitions.len() != expected.partitions as usize {
307 return Err(BusError::Backend(format!(
308 "Kafka topic {:?} has {} partitions, manifest requires {}",
309 expected.name,
310 actual.partitions.len(),
311 expected.partitions
312 )));
313 }
314 }
315 let mut partition_clients = HashMap::new();
316 for t in &manifest.topics {
317 for p in 0..t.partitions {
318 let pc_client = Arc::clone(&client);
319 let name = t.name.clone();
320 let pc = executor
321 .run(move || async move {
322 pc_client
323 .partition_client(name, p as i32, UnknownTopicHandling::Error)
324 .await
325 })?
326 .map_err(|error| BusError::Backend(error.to_string()))?;
327 partition_clients.insert((t.name.clone(), p), Arc::new(pc));
328 }
329 }
330 Ok(Self {
331 cold_archive,
332 executor,
333 partition_clients,
334 topics,
335 validators,
336 recent_uids: parking_lot::Mutex::new(RecentUids::new()),
337 })
338 }
339
340 fn partition_client(
342 &self,
343 topic: &str,
344 partition: u32,
345 ) -> Result<Arc<rskafka::client::partition::PartitionClient>, BusError> {
346 self.partition_clients
347 .get(&(topic.to_owned(), partition))
348 .cloned()
349 .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))
350 }
351
352 fn publish_with_uid(
353 &self,
354 topic: &str,
355 key: &str,
356 value: &serde_json::Value,
357 event_uid: Option<&str>,
358 ) -> Result<PublishAck, BusError> {
359 crate::manifest::validate_topic_event(&self.validators, topic, value)?;
360 let partitions = *self
361 .topics
362 .get(topic)
363 .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))?;
364 let partition = partition_for(key, partitions);
365 let caller_uid = event_uid.is_some();
369 if let Some(uid) = event_uid {
370 if let Some((cached_partition, offset)) = self.recent_uids.lock().get(topic, uid) {
371 return Ok(PublishAck {
372 topic: topic.to_owned(),
373 partition: cached_partition,
374 offset,
375 });
376 }
377 }
378 let event_uid = event_uid.map_or_else(pv_core::new_event_uid, str::to_owned);
379 let stored_at = pv_core::time::now_ms();
380 let record = StoredEvent {
381 partition,
382 offset: 0,
383 key: key.to_owned(),
384 value: value.clone(),
385 stored_at,
386 };
387 if let Some(archive) = &self.cold_archive {
388 let already_staged = archive.stage(topic, &record, &event_uid)?;
389 if already_staged {
390 if let Some(existing) = self.find_event_by_uid(topic, key, &event_uid)? {
399 if let Err(error) = archive.commit(topic, &event_uid, existing.offset) {
404 tracing::warn!(
405 %error,
406 topic,
407 event_uid = %event_uid,
408 "cold intent commit failed after idempotent dedup hit; left for maintenance retry"
409 );
410 }
411 return Ok(existing);
412 }
413 }
414 }
415 let ack = self.publish_broker_only(topic, key, value, stored_at, &event_uid)?;
416 if let Some(archive) = &self.cold_archive {
417 archive.commit(topic, &event_uid, ack.offset)?;
418 }
419 if caller_uid {
420 self.recent_uids
421 .lock()
422 .insert(topic, &event_uid, ack.partition, ack.offset);
423 }
424 Ok(ack)
425 }
426
427 fn publish_broker_only(
428 &self,
429 topic: &str,
430 key: &str,
431 value: &serde_json::Value,
432 stored_at: u64,
433 event_uid: &str,
434 ) -> Result<PublishAck, BusError> {
435 crate::manifest::validate_topic_event(&self.validators, topic, value)?;
436 let partitions = *self
437 .topics
438 .get(topic)
439 .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))?;
440 let partition = partition_for(key, partitions);
441 let record = StoredEvent {
442 partition,
443 offset: 0,
444 key: key.to_owned(),
445 value: value.clone(),
446 stored_at,
447 };
448 let payload = serde_json::to_vec(&record)?;
449 let pc = self.partition_client(topic, partition)?;
450 let key_bytes = event_uid.as_bytes().to_vec();
451 let mut headers = std::collections::BTreeMap::new();
452 headers.insert("proofvisor-event-uid".to_owned(), event_uid.as_bytes().to_vec());
453 headers.insert("agentvisor-event-uid".to_owned(), event_uid.as_bytes().to_vec());
456 let offset = self
457 .executor
458 .run(move || async move {
459 let offsets = pc
460 .produce(
461 vec![Record {
462 key: Some(key_bytes),
463 value: Some(payload),
464 headers,
465 timestamp: chrono_now(),
466 }],
467 Compression::NoCompression,
468 )
469 .await?;
470 Ok::<_, rskafka::client::error::Error>(offsets.first().copied())
474 })?
475 .map_err(|e| BusError::Backend(e.to_string()))?;
476 let offset = offset.ok_or_else(|| {
477 BusError::Backend("Kafka produce succeeded but returned no offset for the record".to_owned())
478 })?;
479 let offset = u64::try_from(offset)
480 .map_err(|_| BusError::Backend(format!("Kafka returned negative offset {offset}")))?;
481 Ok(PublishAck {
482 topic: topic.to_owned(),
483 partition,
484 offset,
485 })
486 }
487}
488
489async fn provision_topic(
490 admin: Arc<AdminClient<DefaultClientContext>>,
491 name: String,
492 partitions: u32,
493 replication_factor: u32,
494 retention_ms: String,
495) -> Result<(), String> {
496 let partitions = i32::try_from(partitions)
497 .map_err(|_| format!("partition count for Kafka topic {name:?} exceeds i32"))?;
498 let replication_factor = i32::try_from(replication_factor)
499 .map_err(|_| format!("replication factor for Kafka topic {name:?} exceeds i32"))?;
500 let topic = NewTopic::new(&name, partitions, TopicReplication::Fixed(replication_factor))
501 .set("retention.ms", &retention_ms);
502 let results = admin
503 .create_topics(
504 [&topic],
505 &AdminOptions::new().operation_timeout(Some(std::time::Duration::from_secs(5))),
506 )
507 .await
508 .map_err(|error| error.to_string())?;
509 match results.into_iter().next() {
510 Some(Ok(_)) | Some(Err((_, RDKafkaErrorCode::TopicAlreadyExists))) => {}
511 Some(Err((_, error))) => return Err(format!("create Kafka topic {name:?}: {error}")),
512 None => return Err(format!("create Kafka topic {name:?} returned no result")),
513 }
514
515 let resource = ResourceSpecifier::Topic(&name);
516 let results = admin
517 .describe_configs([&resource], &AdminOptions::new())
518 .await
519 .map_err(|error| error.to_string())?;
520 let configuration = results
521 .into_iter()
522 .next()
523 .ok_or_else(|| format!("describe Kafka topic {name:?} returned no result"))?
524 .map_err(|error| format!("describe Kafka topic {name:?}: {error}"))?;
525 let actual = configuration
526 .get("retention.ms")
527 .and_then(|entry| entry.value.as_deref());
528 if actual != Some(retention_ms.as_str()) {
529 return Err(format!(
530 "Kafka topic {name:?} retention.ms is {actual:?}, manifest requires {retention_ms:?}"
531 ));
532 }
533 Ok(())
534}
535
536impl EventBus for KafkaBus {
537 fn set_control_key(&self, key: [u8; 32]) -> Result<(), BusError> {
538 if let Some(archive) = &self.cold_archive {
539 archive.set_control_key(key)?;
540 }
541 Ok(())
542 }
543
544 fn publish(&self, topic: &str, key: &str, value: &serde_json::Value) -> Result<PublishAck, BusError> {
545 let event_uid = value
546 .get("metadata")
547 .and_then(|metadata| metadata.get("uid"))
548 .and_then(serde_json::Value::as_str);
549 self.publish_with_uid(topic, key, value, event_uid)
550 }
551
552 fn publish_idempotent(
553 &self,
554 topic: &str,
555 key: &str,
556 value: &serde_json::Value,
557 event_uid: &str,
558 ) -> Result<PublishAck, BusError> {
559 self.publish_with_uid(topic, key, value, Some(event_uid))
560 }
561
562 fn find_event_by_uid(
563 &self,
564 topic: &str,
565 key: &str,
566 event_uid: &str,
567 ) -> Result<Option<PublishAck>, BusError> {
568 let partitions = *self
569 .topics
570 .get(topic)
571 .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))?;
572 let partition = partition_for(key, partitions);
573 let partition_client = self.partition_client(topic, partition)?;
574 let event_uid = event_uid.to_owned();
575 enum Page {
584 Found(i64),
585 Advanced(i64),
586 Empty,
587 }
588 let (mut offset, latest) = {
589 let client = Arc::clone(&partition_client);
590 self.executor
591 .run(move || async move {
592 let earliest = client
593 .get_offset(OffsetAt::Earliest)
594 .await
595 .map_err(|error| error.to_string())?;
596 let latest = client
597 .get_offset(OffsetAt::Latest)
598 .await
599 .map_err(|error| error.to_string())?;
600 Ok::<_, String>((earliest, latest))
601 })?
602 .map_err(BusError::Backend)?
603 };
604 let mut empty_fetches = 0u32;
605 let found = loop {
606 if offset >= latest {
607 break None;
608 }
609 let client = Arc::clone(&partition_client);
610 let page_uid = event_uid.clone();
611 let page = self
612 .executor
613 .run(move || async move {
614 let (records, _) = client
615 .fetch_records(offset, 1..(16 * 1024 * 1024), 500)
616 .await
617 .map_err(|error| error.to_string())?;
618 if records.is_empty() {
619 return Ok::<_, String>(Page::Empty);
620 }
621 for record in &records {
622 let headers = &record.record.headers;
633 if headers
634 .get("proofvisor-event-uid")
635 .or_else(|| headers.get("agentvisor-event-uid"))
636 .is_some_and(|uid| uid.as_slice() == page_uid.as_bytes())
637 {
638 return Ok(Page::Found(record.offset));
639 }
640 if let Some(payload) = record.record.value.as_deref() {
641 let stored: StoredEvent =
642 serde_json::from_slice(payload).map_err(|error| error.to_string())?;
643 if stored
644 .value
645 .get("metadata")
646 .and_then(|metadata| metadata.get("uid"))
647 .and_then(serde_json::Value::as_str)
648 == Some(page_uid.as_str())
649 {
650 return Ok(Page::Found(record.offset));
651 }
652 }
653 }
654 let next = records
655 .last()
656 .and_then(|record| record.offset.checked_add(1))
657 .ok_or_else(|| "Kafka event lookup offset overflow".to_owned())?;
658 Ok(Page::Advanced(next))
659 })?
660 .map_err(BusError::Backend)?;
661 match page {
662 Page::Found(found_offset) => break Some(found_offset),
663 Page::Advanced(next) => {
664 empty_fetches = 0;
665 offset = next;
666 }
667 Page::Empty => {
668 empty_fetches += 1;
679 if empty_fetches >= 8 {
680 return Err(BusError::Backend(format!(
681 "Kafka event lookup stalled: empty fetch at offset {offset} below latest {latest}"
682 )));
683 }
684 }
685 }
686 };
687 found
688 .map(|offset| {
689 u64::try_from(offset)
690 .map(|offset| PublishAck {
691 topic: topic.to_owned(),
692 partition,
693 offset,
694 })
695 .map_err(|_| BusError::Backend(format!("Kafka returned negative offset {offset}")))
696 })
697 .transpose()
698 }
699
700 fn fetch(
701 &self,
702 topic: &str,
703 partition: u32,
704 offset: u64,
705 max: usize,
706 ) -> Result<Vec<StoredEvent>, BusError> {
707 if !self.topics.contains_key(topic) {
708 return Err(BusError::UnknownTopic(topic.to_owned()));
709 }
710 let pc = self.partition_client(topic, partition)?;
711 let signed_offset = i64::try_from(offset).map_err(|_| {
723 BusError::Backend(format!(
724 "kafka fetch offset {offset} exceeds i64::MAX and would wrap to a negative \
725 sentinel; refusing the fetch"
726 ))
727 })?;
728 if max == 0 {
732 return Ok(Vec::new());
733 }
734 self.executor
735 .run(move || async move {
736 let (records, _high_watermark) = pc
737 .fetch_records(signed_offset, 1..(16 * 1024 * 1024), 500)
738 .await
739 .map_err(|e| e.to_string())?;
740 let mut out = Vec::new();
741 for r in records {
742 let value = r
752 .record
753 .value
754 .ok_or_else(|| format!("fetch: null record value at offset {}", r.offset))?;
755 let mut ev: StoredEvent = serde_json::from_slice(&value)
756 .map_err(|e| format!("fetch decode at offset {}: {e}", r.offset))?;
757 ev.offset = u64::try_from(r.offset)
762 .map_err(|_| format!("fetch: negative broker offset {}", r.offset))?;
763 out.push(ev);
764 if out.len() >= max {
765 break;
766 }
767 }
768 Ok::<_, String>(out)
769 })?
770 .map_err(BusError::Backend)
771 }
772
773 fn partitions(&self, topic: &str) -> Result<u32, BusError> {
774 self.topics
775 .get(topic)
776 .copied()
777 .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))
778 }
779
780 fn topics(&self) -> Vec<String> {
781 let mut t: Vec<String> = self.topics.keys().cloned().collect();
782 t.sort();
783 t
784 }
785
786 fn maintenance(&self, _now_ms: u64) -> Result<u64, BusError> {
787 self.cold_archive.as_ref().map_or(Ok(0), |archive| {
788 archive.retry_pending_with(|pending| {
789 if let Some(ack) = self.find_event_by_uid(&pending.topic, &pending.key, &pending.event_uid)? {
796 return Ok(ack);
797 }
798 self.publish_broker_only(
799 &pending.topic,
800 &pending.key,
801 &pending.value,
802 pending.stored_at,
803 &pending.event_uid,
804 )
805 })
806 })
807 }
808}
809
810fn chrono_now() -> chrono::DateTime<chrono::Utc> {
811 chrono::Utc::now()
812}
813
814#[cfg(test)]
815mod tests {
816 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
817
818 use super::*;
819
820 fn lookup<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
821 move |name| {
822 pairs
823 .iter()
824 .find(|(key, _)| *key == name)
825 .map(|(_, value)| (*value).to_owned())
826 }
827 }
828
829 #[test]
830 fn no_security_env_means_plaintext_with_no_admin_overrides() {
831 let security = KafkaSecurity::from_lookup(lookup(&[])).unwrap();
832 assert!(security.ca_file.is_none() && security.credentials.is_none());
833 let mut config = ClientConfig::new();
834 security.apply_admin(&mut config);
835 assert!(config.get("security.protocol").is_none());
836 assert!(security.rskafka_tls().unwrap().is_none());
837 assert!(security.rskafka_sasl().is_none());
838 }
839
840 #[test]
841 fn partial_credentials_fail_loudly_not_anonymously() {
842 for pairs in [
843 &[("PV_KAFKA_SASL_USERNAME", "u")][..],
844 &[("PV_KAFKA_SASL_PASSWORD", "p")][..],
845 ] {
846 let Err(error) = KafkaSecurity::from_lookup(lookup(pairs)) else {
849 panic!("partial credentials must be refused");
850 };
851 assert!(
852 error.to_string().contains("must be set together"),
853 "wrong error: {error}"
854 );
855 }
856 }
857
858 #[test]
859 fn sasl_plain_without_tls_is_refused() {
860 let Err(error) = KafkaSecurity::from_lookup(lookup(&[
861 ("PV_KAFKA_SASL_USERNAME", "u"),
862 ("PV_KAFKA_SASL_PASSWORD", "p"),
863 ])) else {
864 panic!("SASL without TLS must be refused");
865 };
866 assert!(error.to_string().contains("PV_KAFKA_CA_FILE"), "{error}");
867 }
868
869 #[test]
870 fn ca_only_selects_ssl_and_ca_plus_credentials_selects_sasl_ssl() {
871 let ssl_only = KafkaSecurity::from_lookup(lookup(&[("PV_KAFKA_CA_FILE", "/tmp/ca.crt")])).unwrap();
872 let mut config = ClientConfig::new();
873 ssl_only.apply_admin(&mut config);
874 assert_eq!(config.get("security.protocol"), Some("ssl"));
875 assert_eq!(config.get("ssl.ca.location"), Some("/tmp/ca.crt"));
876 assert!(config.get("sasl.mechanism").is_none());
877
878 let full = KafkaSecurity::from_lookup(lookup(&[
879 ("PV_KAFKA_CA_FILE", "/tmp/ca.crt"),
880 ("PV_KAFKA_SASL_USERNAME", "u"),
881 ("PV_KAFKA_SASL_PASSWORD", "p"),
882 ]))
883 .unwrap();
884 let mut config = ClientConfig::new();
885 full.apply_admin(&mut config);
886 assert_eq!(config.get("security.protocol"), Some("sasl_ssl"));
887 assert_eq!(config.get("sasl.mechanism"), Some("SCRAM-SHA-256"));
889 assert_eq!(config.get("sasl.username"), Some("u"));
890 match full.rskafka_sasl() {
891 Some(SaslConfig::ScramSha256(credentials)) => {
892 assert_eq!(credentials.username, "u");
893 assert_eq!(credentials.password, "p");
894 }
895 other => panic!("expected SCRAM-SHA-256 sasl config, got {other:?}"),
896 }
897 }
898
899 #[test]
902 fn sasl_mechanism_selection_is_explicit_and_validated() {
903 for (name, is_match) in [
904 (
905 "PLAIN",
906 (|s| matches!(s, Some(SaslConfig::Plain(_)))) as fn(Option<SaslConfig>) -> bool,
907 ),
908 ("SCRAM-SHA-256", |s| matches!(s, Some(SaslConfig::ScramSha256(_)))),
909 ("SCRAM-SHA-512", |s| matches!(s, Some(SaslConfig::ScramSha512(_)))),
910 ] {
911 let security = KafkaSecurity::from_lookup(lookup(&[
912 ("PV_KAFKA_CA_FILE", "/tmp/ca.crt"),
913 ("PV_KAFKA_SASL_USERNAME", "u"),
914 ("PV_KAFKA_SASL_PASSWORD", "p"),
915 ("PV_KAFKA_SASL_MECHANISM", name),
916 ]))
917 .unwrap();
918 let mut config = ClientConfig::new();
919 security.apply_admin(&mut config);
920 assert_eq!(config.get("sasl.mechanism"), Some(name));
921 assert!(is_match(security.rskafka_sasl()), "{name} must map on rskafka");
922 }
923 let Err(error) = KafkaSecurity::from_lookup(lookup(&[
924 ("PV_KAFKA_CA_FILE", "/tmp/ca.crt"),
925 ("PV_KAFKA_SASL_USERNAME", "u"),
926 ("PV_KAFKA_SASL_PASSWORD", "p"),
927 ("PV_KAFKA_SASL_MECHANISM", "GSSAPI"),
928 ])) else {
929 panic!("unsupported mechanism must be refused");
930 };
931 assert!(error.to_string().contains("not supported"), "{error}");
932 }
933
934 #[test]
935 fn missing_or_empty_ca_file_fails_loudly() {
936 let missing = KafkaSecurity {
937 ca_file: Some(std::path::PathBuf::from("/nonexistent/pv-ca.crt")),
938 credentials: None,
939 mechanism: SaslMechanism::ScramSha256,
940 };
941 assert!(missing.rskafka_tls().is_err(), "missing CA file must error");
942
943 let dir = tempfile::tempdir().unwrap();
944 let empty = dir.path().join("empty.pem");
945 std::fs::write(&empty, b"not a pem").unwrap();
946 let security = KafkaSecurity {
947 ca_file: Some(empty),
948 credentials: None,
949 mechanism: SaslMechanism::ScramSha256,
950 };
951 let error = security.rskafka_tls().unwrap_err();
952 assert!(
953 error.to_string().contains("no PEM certificates"),
954 "wrong error: {error}"
955 );
956 }
957}