Skip to main content

pv_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: `PV_KAFKA_CA_FILE` pins a root CA (PEM) and enables
7//! TLS on both the admin and event paths; `PV_KAFKA_SASL_USERNAME` /
8//! `PV_KAFKA_SASL_PASSWORD` enable SASL with the mechanism from
9//! `PV_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                "PV_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("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    /// 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    /// Process-local idempotency memory for caller-supplied event UIDs —
183    /// the Kafka analogue of the embedded broker's `seen_event_uids`.
184    /// `publish_idempotent`'s contract is "same UID → original ack", but
185    /// rskafka `produce` has no native dedupe and the cold-outbox
186    /// staging check only fires when a prior attempt CRASHED mid-publish
187    /// (intent still staged); after a clean success a retried same-UID
188    /// call appended a duplicate record — the embedded and Kafka
189    /// backends disagreed on the trait's core promise. Bounded FIFO so
190    /// an unbounded UID stream can't grow memory (dedup across restarts
191    /// is still covered by the staged-intent + `find_event_by_uid`
192    /// recovery path).
193    recent_uids: parking_lot::Mutex<RecentUids>,
194}
195
196/// Bounded FIFO of `topic\0uid → (partition, offset)` (see
197/// `KafkaBus::recent_uids`).
198struct RecentUids {
199    map: HashMap<String, (u32, u64)>,
200    order: std::collections::VecDeque<String>,
201}
202
203/// 64Ki UIDs ≈ a few MB worst case: covers hours of same-process retry
204/// windows while staying trivially bounded.
205const 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    /// Connect to `broker` (host:port) and provision topics per the manifest.
234    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        // `broker` is a bootstrap list (`host:port[,host:port]`, the same
266        // format rdkafka's `bootstrap.servers` takes above). rskafka wants
267        // one address per element — passing the joined string as a single
268        // entry made every multi-broker list fail to connect.
269        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    /// Cached per-partition client (built at provision; see struct docs).
341    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        // Idempotency fast path for caller-supplied UIDs: after a clean
366        // success the cold intent is gone, so without this a same-UID
367        // retry re-produced a duplicate record (see `recent_uids` docs).
368        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                // A prior attempt for this UID staged the intent and may
391                // have produced to the broker before its commit step
392                // failed (ENOSPC persisting the resolved offset, MAC
393                // refusal, crash). rskafka `produce` has no native dedup,
394                // so blindly re-producing would append a second record
395                // for the same logical event — a duplicate in the audit
396                // stream. Consult the partition first, exactly like the
397                // maintenance retry path does.
398                if let Some(existing) = self.find_event_by_uid(topic, key, &event_uid)? {
399                    // The event is durably on the broker; resolving the
400                    // cold intent is best-effort here. On failure the
401                    // durable intent stays queued for the maintenance
402                    // pass — do not fail a publish that succeeded.
403                    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        // Same uid under the pre-rename name, for consumers that dedupe on
454        // it. Deprecated; drop together with the read fallback below.
455        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                // One record in => exactly one offset out. An empty response
471                // is a broker anomaly; fabricating offset 0 here would persist
472                // a legitimate-looking ack that dedupe/recovery then trusts.
473                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        // One page per `executor.run` call: the executor imposes a hard
576        // 10 s timeout per operation, so running the whole earliest→latest
577        // scan inside a single call made lookups on partitions larger than
578        // one timeout's worth of fetches time out on every attempt, forever
579        // (each retry restarted from earliest under the same cap). Paging
580        // gives each bounded step its own budget — the same shape as the
581        // NATS implementation and the default trait implementation's
582        // per-`fetch` paging.
583        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                        // `publish_with_uid` stamps the (possibly freshly
623                        // generated) uid into the `proofvisor-event-uid`
624                        // header even when the payload lacks
625                        // `metadata.uid`. Crash recovery for such an
626                        // event previously scanned only the payload
627                        // field, could never find the record it had in
628                        // fact produced, and re-published a duplicate.
629                        // Consult the header first, then the payload.
630                        // Records produced before the ProofVisor rename carry
631                        // the same uid under `agentvisor-event-uid`.
632                        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                    // `offset < latest` proves the broker still holds
669                    // records we have not seen; an empty response here is
670                    // a timing artifact (the 500 ms max_wait expired on a
671                    // loaded/rebalancing broker), NOT proof of absence.
672                    // Returning Ok(None) would let maintenance re-produce
673                    // an already-committed event — a duplicate in the
674                    // audit stream, the exact outcome this lookup exists
675                    // to prevent. Retry bounded, then fail the lookup so
676                    // the caller retries the whole pass later (the cold
677                    // intent is durable).
678                    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        // Reject offsets above
712        // `i64::MAX` explicitly rather than let `as i64` sign-wrap
713        // into a negative Kafka offset. The Kafka wire protocol
714        // treats -1 as "latest" and -2 as "earliest" (its own
715        // sentinel semantics), so a wrap could silently redirect
716        // the fetch to the log tail instead of the requested
717        // offset — a hard-to-diagnose replay divergence. In
718        // practice `u64` offsets that survive our provisioning
719        // stay well below 2^63, but crash recovery from a
720        // manifest whose offset field was tampered on disk could
721        // still supply a value in `(i64::MAX, u64::MAX]`.
722        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        // Contract: "read up to `max` events". The loop below checks the
729        // cap only AFTER pushing, so `max == 0` returned one event —
730        // diverging from the embedded broker, which returns an empty vec.
731        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                    // Surface the error instead of silently
743                    // dropping a corrupt record. Parity with NatsBus and
744                    // EmbeddedBroker. An auditor or reconciler that sees a
745                    // shorter list than expected — with no error and no
746                    // offset gap — is an evidence gap. Because reconcilers
747                    // advance offset by `events.last().offset + 1`, a
748                    // silently-skipped corrupt record at offset N causes
749                    // the caller to bypass it entirely; keep the record on
750                    // the partition as forensic evidence.
751                    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                    // Kafka offsets are non-negative by contract; a broken
758                    // or hostile broker returning a negative i64 must fail
759                    // the fetch instead of sign-wrapping into a huge u64
760                    // that corrupts the caller's offset ledger.
761                    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                // A crash/timeout between a successful produce and
790                // `commit()` leaves the intent offset-None while the event
791                // IS already on the partition; blindly re-producing here
792                // would append a duplicate to the audit stream. Consult the
793                // partition first and only publish when the UID is
794                // genuinely absent.
795                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            // No `unwrap_err`: that would require `Debug` on `KafkaSecurity`,
847            // and a derived `Debug` would make the SASL password printable.
848            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        // SCRAM-SHA-256 is the default mechanism (Redpanda's native store).
888        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    /// Every supported mechanism maps consistently onto both client stacks,
900    /// and unknown mechanisms fail loudly instead of downgrading.
901    #[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}