Skip to main content

av_bridge/
kafka_bus.rs

1//! Kafka-wire connector (feature `kafka`), targeting Redpanda as the reference
2//! self-hosted broker (brief Module F). The event path uses rskafka; a statically
3//! linked librdkafka admin client provisions and verifies topic retention.
4//!
5//! TLS/SASL comes from the environment so secured endpoints need no new
6//! constructor surface: `AV_KAFKA_CA_FILE` pins a root CA (PEM) and enables
7//! TLS on both the admin and event paths; `AV_KAFKA_SASL_USERNAME` /
8//! `AV_KAFKA_SASL_PASSWORD` enable SASL with the mechanism from
9//! `AV_KAFKA_SASL_MECHANISM` (`SCRAM-SHA-256` by default — Redpanda's
10//! native credential store — or `SCRAM-SHA-512` / `PLAIN`). Credentials
11//! are only accepted together with TLS: PLAIN would ship the password in
12//! the clear, and even SCRAM without TLS is exposed to MITM relay. Use
13//! hostname (not bare-IP) broker endpoints with TLS: certificate
14//! verification runs against the dialed name, and IP-SAN support varies
15//! across rustls versions.
16
17use 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
29/// Broker security material resolved from the environment (module docs).
30struct 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    /// librdkafka's `sasl.mechanism` spelling.
57    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    /// Testable core of [`Self::from_env`] (same pattern as the harness
72    /// config's `apply_env_overrides_from`): `get` returns the value of a
73    /// named environment variable, or `None` when unset.
74    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    /// librdkafka admin-client settings mirroring the rskafka event path.
104    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    /// Root-pinned rustls config for the rskafka event path.
122    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        // Same provider discipline as NatsBus::provision: rustls 0.23
127        // resolves its process CryptoProvider lazily and panics when both
128        // `ring` and `aws-lc-rs` are compiled in with none installed.
129        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
167/// Kafka/Redpanda bus.
168pub struct KafkaBus {
169    cold_archive: Option<crate::cold_store::ColdArchive>,
170    executor: crate::bus::ConnectorExecutor,
171    /// Per-(topic, partition) clients built once at provision. rskafka's
172    /// `Client::partition_client` performs metadata discovery and broker
173    /// connection setup; constructing one per publish flooded the broker
174    /// under 10k-connection load until audit publishes stalled admission
175    /// past the upstream timeout (observed as 502s in the 10k SLA gate).
176    /// The `Client` itself is not retained: partition clients hold their
177    /// own broker references, and every post-provision operation goes
178    /// through this cache.
179    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    /// Connect to `broker` (host:port) and provision topics per the manifest.
186    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        // `broker` is a bootstrap list (`host:port[,host:port]`, the same
218        // format rdkafka's `bootstrap.servers` takes above). rskafka wants
219        // one address per element — passing the joined string as a single
220        // entry made every multi-broker list fail to connect.
221        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    /// Cached per-partition client (built at provision; see struct docs).
292    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                // One record in => exactly one offset out. An empty response
376                // is a broker anomaly; fabricating offset 0 here would persist
377                // a legitimate-looking ack that dedupe/recovery then trusts.
378                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                    // Round-22 F1: surface the error instead of silently
556                    // dropping a corrupt record. Parity with NatsBus and
557                    // EmbeddedBroker. An auditor or reconciler that sees a
558                    // shorter list than expected — with no error and no
559                    // offset gap — is an evidence gap. Because reconcilers
560                    // advance offset by `events.last().offset + 1`, a
561                    // silently-skipped corrupt record at offset N causes
562                    // the caller to bypass it entirely; keep the record on
563                    // the partition as forensic evidence.
564                    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            // No `unwrap_err`: that would require `Debug` on `KafkaSecurity`,
649            // and a derived `Debug` would make the SASL password printable.
650            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        // SCRAM-SHA-256 is the default mechanism (Redpanda's native store).
690        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    /// Every supported mechanism maps consistently onto both client stacks,
702    /// and unknown mechanisms fail loudly instead of downgrading.
703    #[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}