Skip to main content

krabka_metadata/
image.rs

1//! Immutable snapshot of the cluster's metadata state. Only
2//! [`MetadataImage::apply`] mutates it, and the Raft state machine calls that
3//! method. Everywhere else reads it through shared references and `Arc` clones.
4
5use std::{
6    collections::{BTreeMap, HashMap},
7    ops::Bound,
8};
9
10use krabka_security::{KafkaPrincipal, SaslMechanism, ScramCredential};
11use krabka_units::{ByteRate, convert::ByteRateExt};
12use uuid::Uuid;
13
14use crate::{
15    acl::{AclEntry, PatternType, ResourceType},
16    break_glass::{BreakGlassApproval, BreakGlassProposalRecord},
17    error::MetadataError,
18    records::{
19        BrokerConfigRecord, BrokerRegistrationRecord, ClientMetricsConfigRecord, ClientQuotaRecord,
20        ControllerRegistrationRecord, DEFAULT_BROKER_CONFIG_NODE_ID, DelegationTokenRecord,
21        FeatureLevelRecord, FeaturesEpochRecord, GroupConfigRecord, KRaftVersionRecord,
22        MetadataRecord, NodeId, PartitionElrRecord, PartitionOffsetAdvanceRecord, PartitionRecord,
23        ProducerIdsRecord, QuotaEntity, ScramCredentialRecord, TopicConfigRecord, TopicRecord,
24        VotersRecord,
25    },
26    write_freeze::TopicFreezeRecord,
27};
28
29pub type EntityKey = Vec<(String, Option<String>)>;
30
31/// In-memory image type for a single delegation
32/// token (KIP-48). Mirrors [`DelegationTokenRecord`] without any tombstone
33/// concerns. The apply path handles tombstones as removals.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct DelegationToken {
36    pub token_id: String,
37    pub owner: KafkaPrincipal,
38    pub requester: KafkaPrincipal,
39    pub issue_timestamp_ms: i64,
40    pub expiry_timestamp_ms: i64,
41    pub max_timestamp_ms: i64,
42    pub renewers: Vec<KafkaPrincipal>,
43}
44
45impl DelegationToken {
46    #[must_use]
47    pub fn from_record(rec: &DelegationTokenRecord) -> Self {
48        Self {
49            token_id: rec.token_id.clone(),
50            owner: rec.owner.clone(),
51            requester: rec.requester.clone(),
52            issue_timestamp_ms: rec.issue_timestamp_ms,
53            expiry_timestamp_ms: rec.expiry_timestamp_ms,
54            max_timestamp_ms: rec.max_timestamp_ms,
55            renewers: rec.renewers.clone(),
56        }
57    }
58
59    /// The record that recreates this token.
60    #[must_use]
61    pub fn to_record(&self) -> DelegationTokenRecord {
62        DelegationTokenRecord {
63            token_id: self.token_id.clone(),
64            owner: self.owner.clone(),
65            requester: self.requester.clone(),
66            issue_timestamp_ms: self.issue_timestamp_ms,
67            expiry_timestamp_ms: self.expiry_timestamp_ms,
68            max_timestamp_ms: self.max_timestamp_ms,
69            renewers: self.renewers.clone(),
70        }
71    }
72
73    /// Kafka's `TokenInformation.ownerOrRenewer`: whether `principal` is the
74    /// token's owner, the principal that created it, or a listed renewer.
75    #[must_use]
76    pub fn owner_or_renewer(&self, principal: &KafkaPrincipal) -> bool {
77        &self.owner == principal
78            || &self.requester == principal
79            || self.renewers.contains(principal)
80    }
81}
82
83#[must_use]
84pub fn canonicalize_entity(mut tuple: Vec<(String, Option<String>)>) -> EntityKey {
85    tuple.sort_by(|a, b| a.0.cmp(&b.0));
86    tuple
87}
88
89/// Static discriminant name for a [`MetadataRecord`], used as tracing span
90/// context. The span skips the record itself to keep spans cheap and free of
91/// PII.
92fn record_variant(rec: &MetadataRecord) -> &'static str {
93    match rec {
94        MetadataRecord::V1Topic(_) => "V1Topic",
95        MetadataRecord::V1Partition(_) => "V1Partition",
96        MetadataRecord::V1BrokerRegistration(_) => "V1BrokerRegistration",
97        MetadataRecord::V1DeleteTopic(_) => "V1DeleteTopic",
98        MetadataRecord::V1TopicConfig(_) => "V1TopicConfig",
99        MetadataRecord::V1ScramCredential(_) => "V1ScramCredential",
100        MetadataRecord::V1DeleteScramCredential(_) => "V1DeleteScramCredential",
101        MetadataRecord::V1AccessControlEntry(_) => "V1AccessControlEntry",
102        MetadataRecord::V1DeleteAccessControlEntry(_) => "V1DeleteAccessControlEntry",
103        MetadataRecord::V1BrokerConfig(_) => "V1BrokerConfig",
104        MetadataRecord::V1ClientQuota(_) => "V1ClientQuota",
105        MetadataRecord::V1ProducerIds(_) => "V1ProducerIds",
106        MetadataRecord::V1DelegationToken(_) => "V1DelegationToken",
107        MetadataRecord::V1DeleteDelegationToken(_) => "V1DeleteDelegationToken",
108        MetadataRecord::V1UnregisterBroker(_) => "V1UnregisterBroker",
109        MetadataRecord::V1KRaftVersion(_) => "V1KRaftVersion",
110        MetadataRecord::V1Voters(_) => "V1Voters",
111        MetadataRecord::V1FeatureLevel(_) => "V1FeatureLevel",
112        MetadataRecord::V1ClientMetricsConfig(_) => "V1ClientMetricsConfig",
113        MetadataRecord::V1FeaturesEpoch(_) => "V1FeaturesEpoch",
114        MetadataRecord::V1PartitionDirAssignment(_) => "V1PartitionDirAssignment",
115        MetadataRecord::V1PartitionOffsetAdvance(_) => "V1PartitionOffsetAdvance",
116        MetadataRecord::V1GroupConfig(_) => "V1GroupConfig",
117        MetadataRecord::V1ControllerRegistration(_) => "V1ControllerRegistration",
118        MetadataRecord::V1TopicFreeze(_) => "V1TopicFreeze",
119        MetadataRecord::V1BreakGlassProposal(_) => "V1BreakGlassProposal",
120        MetadataRecord::V1DeleteBreakGlassProposal(_) => "V1DeleteBreakGlassProposal",
121        MetadataRecord::V1PartitionElr(_) => "V1PartitionElr",
122        MetadataRecord::V1PartitionRecovery(_) => "V1PartitionRecovery",
123        MetadataRecord::V1PartitionUpdate(_) => "V1PartitionUpdate",
124        MetadataRecord::V1BrokerRegistrationChange(_) => "V1BrokerRegistrationChange",
125        MetadataRecord::V1UnregisterController(_) => "V1UnregisterController",
126    }
127}
128
129#[derive(Debug, Clone, Default, PartialEq)]
130pub struct MetadataImage {
131    cluster_id: Uuid,
132    topics: HashMap<String, TopicRecord>,
133    /// KIP-516 reverse index: topic UUID -> topic name. `apply()` maintains it
134    /// next to `topics`. A snapshot replay rebuilds it, because every record
135    /// flows through `apply()`, including snapshot installs.
136    topic_ids: HashMap<Uuid, String>,
137    /// Partition records grouped by owning topic. The by-topic grouping is
138    /// the scaling-critical index: [`Self::partitions_of`] resolves a topic
139    /// in O(1) and touches only that topic's partitions. Per-topic hot paths
140    /// such as Metadata responses and reconcile loops therefore stay
141    /// O(total partitions) and not O(topics × partitions). The inner
142    /// `BTreeMap` keys by partition index, so per-topic iteration runs in
143    /// ascending-index order.
144    partitions: HashMap<String, BTreeMap<i32, PartitionRecord>>,
145    partition_next_offsets: HashMap<(String, i32), i64>,
146    partition_elr: HashMap<(String, i32), (Vec<NodeId>, Vec<NodeId>)>,
147    partition_recovery: HashMap<(String, i32), crate::LeaderRecoveryState>,
148    brokers: HashMap<NodeId, BrokerRegistrationRecord>,
149    controllers: HashMap<NodeId, ControllerRegistrationRecord>,
150    topic_configs: HashMap<String, BTreeMap<String, String>>,
151    broker_configs: HashMap<NodeId, BTreeMap<String, String>>,
152    client_metrics_configs: HashMap<String, BTreeMap<String, String>>,
153    group_configs: HashMap<String, BTreeMap<String, String>>,
154    scram_credentials: HashMap<(String, SaslMechanism), ScramCredential>,
155    acls_literal: HashMap<(ResourceType, String), Vec<AclEntry>>,
156    acls_prefixed: HashMap<ResourceType, Vec<AclEntry>>,
157    /// KFC-9 write-freeze registry, split by pattern type. The prefixed side
158    /// is a `BTreeMap` and not the `Vec` that `acls_prefixed` uses, because
159    /// the resolve runs one hop from the produce path and needs the ordered
160    /// reverse walk that finds the longest matching prefix.
161    freezes_literal: HashMap<String, TopicFreezeRecord>,
162    freezes_prefixed: BTreeMap<String, TopicFreezeRecord>,
163    /// KFC-9 break-glass proposals, keyed by proposal id.
164    break_glass: HashMap<Uuid, BreakGlassProposalRecord>,
165    client_quotas: HashMap<EntityKey, BTreeMap<String, f64>>,
166    producer_ids: Option<ProducerIdsRecord>,
167    delegation_tokens: HashMap<String, DelegationToken>,
168    kraft_version: u16,
169    voters: crate::voters::VoterSet,
170    feature_levels: BTreeMap<String, i16>,
171    /// KIP-584 finalized-features epoch. It is `-1` until the first
172    /// `V1FeatureLevel` record applies, and it then increases monotonically,
173    /// once per applied record. It is deterministic across replicas, because
174    /// records apply in committed-log order on every node.
175    features_epoch: i64,
176}
177
178/// True when `candidate` is `stored` plus zero or more appended entries.
179///
180/// This is the concurrent-approval rule of KFC-9 in one predicate: an
181/// approver may add its own entry to the list it read, and may do nothing
182/// else to that list.
183fn extends(stored: &[BreakGlassApproval], candidate: &[BreakGlassApproval]) -> bool {
184    candidate.len() >= stored.len() && candidate[..stored.len()] == *stored
185}
186
187/// Selects which broker byte-rate config key to read.
188#[derive(Debug, Clone, Copy)]
189pub enum ThrottleKind {
190    Leader,
191    Follower,
192    AlterLogDirs,
193}
194
195impl MetadataImage {
196    #[must_use]
197    pub fn new(cluster_id: Uuid) -> Self {
198        Self {
199            cluster_id,
200            topics: HashMap::new(),
201            topic_ids: HashMap::new(),
202            partitions: HashMap::new(),
203            partition_next_offsets: HashMap::new(),
204            partition_elr: HashMap::new(),
205            partition_recovery: HashMap::new(),
206            brokers: HashMap::new(),
207            controllers: HashMap::new(),
208            topic_configs: HashMap::new(),
209            broker_configs: HashMap::new(),
210            client_metrics_configs: HashMap::new(),
211            group_configs: HashMap::new(),
212            scram_credentials: HashMap::new(),
213            acls_literal: HashMap::new(),
214            acls_prefixed: HashMap::new(),
215            freezes_literal: HashMap::new(),
216            freezes_prefixed: BTreeMap::new(),
217            break_glass: HashMap::new(),
218            client_quotas: HashMap::new(),
219            producer_ids: None,
220            delegation_tokens: HashMap::new(),
221            kraft_version: 0,
222            voters: crate::voters::VoterSet::default(),
223            feature_levels: BTreeMap::new(),
224            features_epoch: -1,
225        }
226    }
227
228    #[must_use]
229    pub fn cluster_id(&self) -> Uuid {
230        self.cluster_id
231    }
232
233    pub fn topics(&self) -> impl Iterator<Item = &TopicRecord> {
234        self.topics.values()
235    }
236
237    #[tracing::instrument(level = "debug", skip_all, fields(topic = %name))]
238    #[must_use]
239    pub fn topic(&self, name: &str) -> Option<&TopicRecord> {
240        self.topics.get(name)
241    }
242
243    /// KIP-516: resolve a topic by its UUID. O(1) through the `topic_ids` index.
244    #[tracing::instrument(level = "debug", skip_all, fields(topic_id = %id))]
245    #[must_use]
246    pub fn topic_by_id(&self, id: &Uuid) -> Option<&TopicRecord> {
247        let name = self.topic_ids.get(id)?;
248        self.topics.get(name)
249    }
250
251    /// KIP-516: resolve a topic name by its UUID.
252    #[must_use]
253    pub fn topic_name_by_id(&self, id: &Uuid) -> Option<&str> {
254        self.topic_ids.get(id).map(String::as_str)
255    }
256
257    #[tracing::instrument(level = "debug", skip_all, fields(topic = %topic, partition = idx))]
258    #[must_use]
259    pub fn partition(&self, topic: &str, idx: i32) -> Option<&PartitionRecord> {
260        self.partitions.get(topic)?.get(&idx)
261    }
262
263    /// All partitions of `topic`, in ascending partition-index order.
264    /// The by-topic index gives an O(1) topic lookup and an
265    /// O(own partitions) iteration. The method never scans the partitions of
266    /// other topics, so a per-topic caller stays proportional to the topic it
267    /// asks about, even in images with tens of thousands of topics.
268    pub fn partitions_of(&self, topic: &str) -> impl Iterator<Item = &PartitionRecord> {
269        self.partitions
270            .get(topic)
271            .into_iter()
272            .flat_map(BTreeMap::values)
273    }
274
275    /// The committed next-offset for a diskless partition, if any advance has
276    /// been applied.
277    #[must_use]
278    pub fn partition_next_offset(&self, topic: &str, partition: i32) -> Option<i64> {
279        self.partition_next_offsets
280            .get(&(topic.to_string(), partition))
281            .copied()
282    }
283
284    #[must_use]
285    pub fn partition_elr(&self, topic: &str, partition: i32) -> (&[NodeId], &[NodeId]) {
286        self.partition_elr
287            .get(&(topic.to_string(), partition))
288            .map_or((&[], &[]), |(eligible, last_known)| {
289                (eligible.as_slice(), last_known.as_slice())
290            })
291    }
292
293    #[must_use]
294    pub fn leader_recovery_state(&self, topic: &str, partition: i32) -> crate::LeaderRecoveryState {
295        self.partition_recovery
296            .get(&(topic.to_string(), partition))
297            .copied()
298            .unwrap_or_default()
299    }
300
301    /// The live partition count for `topic`, derived from the partitions
302    /// map and not from the stored `TopicRecord.partitions` field. This is
303    /// the authoritative count for the KIP-631 round-trip, because the
304    /// KIP-631 `TopicRecord` carries no partition count. It is also the
305    /// authoritative count for the `validate` partition-count-grew check.
306    /// Returns 0 for an unknown topic, or for a topic with no applied
307    /// partition records. O(1).
308    #[must_use]
309    pub fn topic_partition_count(&self, topic: &str) -> i32 {
310        self.partitions
311            .get(topic)
312            .map_or(0, |parts| i32::try_from(parts.len()).unwrap_or(i32::MAX))
313    }
314
315    /// Total partition count across every topic. O(topics).
316    fn total_partitions(&self) -> usize {
317        self.partitions.values().map(BTreeMap::len).sum()
318    }
319
320    /// Single-pass iterator over every partition in the image. Each record
321    /// carries its own `topic` and `partition` fields. Topic order is
322    /// unspecified. Within a topic the order is ascending partition index.
323    /// The iterator is O(P) in total partition count. The cluster-wide
324    /// maintenance loops for failover, rebalance, reassignment, and metrics
325    /// use this instead of a per-topic loop.
326    pub fn all_partitions(&self) -> impl Iterator<Item = &PartitionRecord> {
327        self.partitions.values().flat_map(BTreeMap::values)
328    }
329
330    /// All partitions where a reassignment is currently in flight, that is,
331    /// where `adding_replicas` or `removing_replicas` is non-empty.
332    pub fn reassignments_in_flight(&self) -> impl Iterator<Item = &PartitionRecord> + '_ {
333        self.all_partitions()
334            .filter(|p| !p.adding_replicas.is_empty() || !p.removing_replicas.is_empty())
335    }
336
337    /// Currently-effective config overrides for `topic`, or `None` if no
338    /// `V1TopicConfig` record has applied for this topic since the last
339    /// `V1DeleteTopic`, or since image creation.
340    #[must_use]
341    pub fn topic_config(&self, topic: &str) -> Option<&BTreeMap<String, String>> {
342        self.topic_configs.get(topic)
343    }
344
345    /// Per-broker config overrides for `node_id`, or `None` if no
346    /// `V1BrokerConfig` record has been applied for this broker.
347    #[must_use]
348    pub fn broker_config(&self, node_id: NodeId) -> Option<&BTreeMap<String, String>> {
349        self.broker_configs.get(&node_id)
350    }
351
352    /// Cluster-wide dynamic broker-config defaults, or `None` when no default
353    /// broker config has been applied.
354    #[must_use]
355    pub fn default_broker_config(&self) -> Option<&BTreeMap<String, String>> {
356        self.broker_config(DEFAULT_BROKER_CONFIG_NODE_ID)
357    }
358
359    /// Returns the KIP-73 throttle rate for `node_id` and `kind`.
360    /// Returns `None` if the config key is absent, unparseable, or negative.
361    /// `-1` is Kafka's convention for "disabled" or "unlimited".
362    #[must_use]
363    pub fn broker_throttle_rate(&self, node_id: NodeId, kind: ThrottleKind) -> Option<ByteRate> {
364        let key = match kind {
365            ThrottleKind::Leader => "leader.replication.throttled.rate",
366            ThrottleKind::Follower => "follower.replication.throttled.rate",
367            ThrottleKind::AlterLogDirs => "replica.alter.log.dirs.io.max.bytes.per.second",
368        };
369        let raw = self
370            .broker_config(node_id)
371            .and_then(|configs| configs.get(key))
372            .or_else(|| self.default_broker_config()?.get(key))?;
373        let v: i64 = raw.parse().ok()?;
374        (v >= 0).then(|| ByteRate::from_bytes_per_sec(v))
375    }
376
377    /// Override map for a single KIP-714 client-metrics subscription.
378    #[must_use]
379    pub fn client_metrics_config(&self, name: &str) -> Option<&BTreeMap<String, String>> {
380        self.client_metrics_configs.get(name)
381    }
382
383    /// All configured client-metrics subscriptions, `(name, overrides)`.
384    pub fn client_metrics_subscriptions(
385        &self,
386    ) -> impl Iterator<Item = (&String, &BTreeMap<String, String>)> {
387        self.client_metrics_configs.iter()
388    }
389
390    /// Dynamic KIP-1071 overrides for one GROUP config resource.
391    #[must_use]
392    pub fn group_config(&self, group_id: &str) -> Option<&BTreeMap<String, String>> {
393        self.group_configs.get(group_id)
394    }
395
396    /// All configured GROUP resources.
397    pub fn group_configs(&self) -> impl Iterator<Item = (&String, &BTreeMap<String, String>)> {
398        self.group_configs.iter()
399    }
400
401    #[must_use]
402    pub fn client_quotas(&self) -> &HashMap<EntityKey, BTreeMap<String, f64>> {
403        &self.client_quotas
404    }
405
406    #[must_use]
407    pub fn scram_credential(
408        &self,
409        user: &str,
410        mechanism: SaslMechanism,
411    ) -> Option<&ScramCredential> {
412        self.scram_credentials.get(&(user.to_string(), mechanism))
413    }
414
415    /// All distinct users with at least one SCRAM credential. Order is
416    /// unspecified.
417    #[must_use]
418    pub fn scram_credentials_users(&self) -> Vec<String> {
419        self.scram_credentials
420            .keys()
421            .map(|(u, _)| u.clone())
422            .collect::<std::collections::HashSet<_>>()
423            .into_iter()
424            .collect()
425    }
426
427    /// All `(mechanism, iterations)` pairs for `user`. Empty if user has
428    /// no SCRAM credentials. Order is unspecified.
429    #[must_use]
430    pub fn scram_credentials_for_user(&self, user: &str) -> Vec<(SaslMechanism, u32)> {
431        self.scram_credentials
432            .iter()
433            .filter(|((u, _), _)| u == user)
434            .map(|((_, mech), cred)| (*mech, cred.iterations))
435            .collect()
436    }
437
438    #[must_use]
439    pub fn broker(&self, node_id: NodeId) -> Option<&BrokerRegistrationRecord> {
440        self.brokers.get(&node_id)
441    }
442
443    #[must_use]
444    pub fn controller(&self, node_id: NodeId) -> Option<&ControllerRegistrationRecord> {
445        self.controllers.get(&node_id)
446    }
447
448    /// KIP-903: the broker epoch (registration commit offset) for `node_id`,
449    /// or `None` if the broker is not registered in this image.
450    #[must_use]
451    pub fn broker_epoch(&self, node_id: NodeId) -> Option<i64> {
452        self.brokers.get(&node_id).map(|b| b.broker_epoch)
453    }
454
455    /// First producer ID not covered by any committed allocation block.
456    #[must_use]
457    pub fn next_producer_id(&self) -> i64 {
458        self.producer_ids.as_ref().map_or(0, |r| r.next_producer_id)
459    }
460
461    pub fn brokers(&self) -> impl Iterator<Item = &BrokerRegistrationRecord> {
462        self.brokers.values()
463    }
464
465    pub fn controllers(&self) -> impl Iterator<Item = &ControllerRegistrationRecord> {
466        self.controllers.values()
467    }
468
469    #[must_use]
470    pub fn kraft_version(&self) -> u16 {
471        self.kraft_version
472    }
473
474    #[must_use]
475    pub fn voters(&self) -> &crate::voters::VoterSet {
476        &self.voters
477    }
478
479    /// Iterate every ACL that could possibly match `(rt, rn)`:
480    /// - all literal entries at `(rt, rn)`
481    /// - all literal entries at `(rt, "*")`, the `WILDCARD_RESOURCE`, which
482    ///   matches every resource of that type. See Kafka's `AclAuthorizer` and
483    ///   `StandardAuthorizer`. This is what `kafka-acls --topic '*'` produces.
484    /// - all prefixed entries whose `resource_name` is a prefix of `rn`
485    pub fn matching_acls<'a>(
486        &'a self,
487        rt: ResourceType,
488        rn: &'a str,
489    ) -> impl Iterator<Item = &'a AclEntry> + 'a {
490        let literal_iter = self
491            .acls_literal
492            .get(&(rt, rn.to_string()))
493            .into_iter()
494            .flatten();
495        // Literal "*" is the WILDCARD_RESOURCE and matches every resource of
496        // this type. Skip it when `rn` is already "*" to avoid duplicating the
497        // entries already produced by `literal_iter`.
498        let wildcard_iter = (rn != "*")
499            .then(|| self.acls_literal.get(&(rt, "*".to_string())))
500            .into_iter()
501            .flatten()
502            .flatten();
503        let prefixed_iter = self
504            .acls_prefixed
505            .get(&rt)
506            .into_iter()
507            .flatten()
508            .filter(move |e| rn.starts_with(&e.resource_name));
509        literal_iter.chain(wildcard_iter).chain(prefixed_iter)
510    }
511
512    /// All ACL entries, both literal and prefixed, across all resource types.
513    /// `DescribeAcls` uses this.
514    pub fn all_acls(&self) -> impl Iterator<Item = &AclEntry> {
515        self.acls_literal
516            .values()
517            .flatten()
518            .chain(self.acls_prefixed.values().flatten())
519    }
520
521    /// The write-freeze entry that covers `topic`, or `None` when the topic
522    /// accepts writes (KFC-9).
523    ///
524    /// A cluster with no freeze costs two `is_empty` tests, because the
525    /// produce path calls this once per topic per request. A literal entry
526    /// beats every prefix entry, and the longest matching prefix wins among
527    /// the prefix entries.
528    ///
529    /// An internal topic is never frozen. The `__` name convention is the
530    /// test, so a prefix scope of `""` or `"_"` cannot take
531    /// `__consumer_offsets` down with the rest of the cluster.
532    #[must_use]
533    pub fn topic_freeze(&self, topic: &str) -> Option<&TopicFreezeRecord> {
534        if self.freezes_literal.is_empty() && self.freezes_prefixed.is_empty() {
535            return None;
536        }
537        if topic.starts_with("__") {
538            return None;
539        }
540        if let Some(freeze) = self.freezes_literal.get(topic) {
541            return Some(freeze);
542        }
543        // Keys that sort at or below `topic`, walked downwards. Among the
544        // prefixes of `topic`, the longest one sorts highest, so the first
545        // match is the winner. The bound pair is what keeps the lookup key a
546        // borrowed `str`: `RangeBounds<str>` has no impl for `..=&str`.
547        self.freezes_prefixed
548            .range::<str, _>((Bound::Unbounded, Bound::Included(topic)))
549            .rev()
550            .find(|(scope, _)| topic.starts_with(scope.as_str()))
551            .map(|(_, freeze)| freeze)
552    }
553
554    /// Every live write-freeze entry, literal and prefixed (KFC-9). A thawed
555    /// entry is removed on apply, so every entry here has `frozen: true`.
556    /// Order is unspecified.
557    pub fn topic_freezes(&self) -> impl Iterator<Item = &TopicFreezeRecord> {
558        self.freezes_literal
559            .values()
560            .chain(self.freezes_prefixed.values())
561    }
562
563    /// Look up a break-glass proposal by its id (KFC-9).
564    #[must_use]
565    pub fn break_glass_proposal(&self, id: Uuid) -> Option<&BreakGlassProposalRecord> {
566        self.break_glass.get(&id)
567    }
568
569    /// Every break-glass proposal still in the image (KFC-9), including the
570    /// consumed and withdrawn ones that the expiry sweep has not yet removed.
571    /// Order is unspecified.
572    pub fn break_glass_proposals(&self) -> impl Iterator<Item = &BreakGlassProposalRecord> {
573        self.break_glass.values()
574    }
575
576    /// Look up a delegation token by its `token_id` (KIP-48).
577    #[must_use]
578    pub fn delegation_token_by_id(&self, token_id: &str) -> Option<&DelegationToken> {
579        self.delegation_tokens.get(token_id)
580    }
581
582    /// All tokens owned by `owner` (KIP-48). The match on the owning
583    /// [`KafkaPrincipal`] is exact. Order is unspecified.
584    #[must_use]
585    pub fn delegation_tokens_by_owner(&self, owner: &KafkaPrincipal) -> Vec<&DelegationToken> {
586        self.delegation_tokens
587            .values()
588            .filter(|t| &t.owner == owner)
589            .collect()
590    }
591
592    /// Tokens that `principal` may see through `DescribeDelegationToken`
593    /// without `DescribeToken` permission: those it owns, created or may
594    /// renew ([`DelegationToken::owner_or_renewer`]). Order is unspecified.
595    #[must_use]
596    pub fn delegation_tokens_visible_to(
597        &self,
598        principal: &KafkaPrincipal,
599    ) -> Vec<&DelegationToken> {
600        self.delegation_tokens
601            .values()
602            .filter(|t| t.owner_or_renewer(principal))
603            .collect()
604    }
605
606    /// Every delegation token currently in the
607    /// image (KIP-48). `DescribeDelegationToken` uses this for callers with
608    /// `DescribeToken` permission on the cluster.
609    pub fn all_delegation_tokens(&self) -> impl Iterator<Item = &DelegationToken> {
610        self.delegation_tokens.values()
611    }
612
613    /// KIP-48: the delegation token whose HMAC under `secret_key` is `hmac`.
614    ///
615    /// `RenewDelegationToken` and `ExpireDelegationToken` identify a token by
616    /// HMAC on the wire and not by `token_id`. The image keeps no HMAC, as
617    /// Kafka's metadata does not, so each token's HMAC is recomputed with
618    /// [`krabka_security::compute_token_hmac`]. The scan is linear over the
619    /// small in-memory token map.
620    #[must_use]
621    pub fn delegation_token_by_hmac(
622        &self,
623        secret_key: &[u8],
624        hmac: &[u8],
625    ) -> Option<&DelegationToken> {
626        self.delegation_tokens
627            .values()
628            .find(|t| krabka_security::compute_token_hmac(secret_key, &t.token_id) == hmac)
629    }
630
631    /// KIP-584: finalized feature levels, keyed by feature name. Empty
632    /// until an `UpdateFeatures` call lands a `V1FeatureLevel` record.
633    #[must_use]
634    pub fn finalized_features(&self) -> &BTreeMap<String, i16> {
635        &self.feature_levels
636    }
637
638    /// KIP-584 finalized-features epoch. `-1` ("unknown") until the first
639    /// feature is finalized.
640    #[must_use]
641    pub fn finalized_features_epoch(&self) -> i64 {
642        self.features_epoch
643    }
644
645    /// The finalized `metadata.version` level, or `None` if no
646    /// `V1FeatureLevel` for `metadata.version` has applied. That is a
647    /// pre-bootstrap or legacy image, which is `MetadataVersion.UNKNOWN`.
648    #[must_use]
649    pub fn finalized_metadata_version(&self) -> Option<i16> {
650        self.feature_levels
651            .get(crate::metadata_version::METADATA_VERSION_FEATURE)
652            .copied()
653    }
654
655    /// KIP-584: the finalized level for an arbitrary feature, or `None` if it
656    /// has not been finalized. Generic counterpart to
657    /// [`Self::finalized_metadata_version`].
658    #[must_use]
659    pub fn finalized_feature(&self, name: &str) -> Option<i16> {
660        self.feature_levels.get(name).copied()
661    }
662
663    /// The minimum `metadata.version` level that the live image needs: the
664    /// floor that a downgrade must not drop below. It rises with feature-gated
665    /// state present in the image: `KRaft` SCRAM creds, delegation tokens, and
666    /// KIP-1276 CIDR ACL hosts. The baseline is `METADATA_VERSION_MIN`.
667    #[must_use]
668    pub fn min_required_metadata_version(&self) -> i16 {
669        use crate::metadata_version::{
670            CIDR_ACL_MIN_LEVEL, DELEGATION_TOKEN_MIN_LEVEL, METADATA_VERSION_MIN, SCRAM_MIN_LEVEL,
671        };
672        let mut floor = METADATA_VERSION_MIN;
673        if !self.scram_credentials.is_empty() {
674            floor = floor.max(SCRAM_MIN_LEVEL);
675        }
676        if !self.delegation_tokens.is_empty() {
677            floor = floor.max(DELEGATION_TOKEN_MIN_LEVEL);
678        }
679        if self.all_acls().any(|acl| is_cidr_acl_host(&acl.host)) {
680            floor = floor.max(CIDR_ACL_MIN_LEVEL);
681        }
682        floor
683    }
684
685    /// Metadata removals needed to materialize this image at `target`.
686    /// An empty result means the downgrade is lossless. The controller emits
687    /// these records before the trailing metadata.version feature record only
688    /// for an explicitly unsafe downgrade.
689    #[must_use]
690    pub fn metadata_version_downgrade_records(&self, target: i16) -> Vec<MetadataRecord> {
691        use crate::{
692            metadata_version::{
693                DELEGATION_TOKEN_MIN_LEVEL, DIRECTORY_ASSIGNMENT_MIN_LEVEL, SCRAM_MIN_LEVEL,
694            },
695            records::{DeleteDelegationTokenRecord, DeleteScramCredentialRecord},
696        };
697
698        let mut records = Vec::new();
699        if target < crate::metadata_version::CIDR_ACL_MIN_LEVEL {
700            let mut cidr_acls: Vec<&AclEntry> = self
701                .all_acls()
702                .filter(|acl| is_cidr_acl_host(&acl.host))
703                .collect();
704            cidr_acls.sort_by(|a, b| {
705                (&a.resource_name, &a.principal, &a.host).cmp(&(
706                    &b.resource_name,
707                    &b.principal,
708                    &b.host,
709                ))
710            });
711            records.extend(cidr_acls.into_iter().map(|acl| {
712                MetadataRecord::V1DeleteAccessControlEntry(crate::AclEntryFilter {
713                    resource_type: Some(acl.resource_type),
714                    resource_name: Some(acl.resource_name.clone()),
715                    pattern_type: Some(acl.pattern_type),
716                    principal: Some(acl.principal.clone()),
717                    host: Some(acl.host.clone()),
718                    operation: Some(acl.operation),
719                    permission_type: Some(acl.permission_type),
720                })
721            }));
722        }
723        if target < SCRAM_MIN_LEVEL {
724            records.extend(self.scram_credentials.keys().map(|(user, mechanism)| {
725                MetadataRecord::V1DeleteScramCredential(DeleteScramCredentialRecord {
726                    user: user.clone(),
727                    mechanism: *mechanism,
728                })
729            }));
730        }
731        if target < DELEGATION_TOKEN_MIN_LEVEL {
732            records.extend(self.delegation_tokens.keys().map(|token_id| {
733                MetadataRecord::V1DeleteDelegationToken(DeleteDelegationTokenRecord {
734                    token_id: token_id.clone(),
735                })
736            }));
737        }
738        records.extend(self.brokers.values().filter_map(|broker| {
739            let mut projected = broker.clone();
740            if target < DIRECTORY_ASSIGNMENT_MIN_LEVEL {
741                projected.log_dirs.clear();
742            }
743            if target < crate::metadata_version::CORDONED_LOG_DIRS_MIN_LEVEL {
744                projected.cordoned_log_dirs = None;
745            }
746            (projected != *broker).then_some(MetadataRecord::V1BrokerRegistration(projected))
747        }));
748        // Below `3.7-IV2` a partition record carries no directories, and Kafka
749        // reads it back as every replica `MIGRATING`.
750        if target < DIRECTORY_ASSIGNMENT_MIN_LEVEL {
751            for partition in self.all_partitions() {
752                records.extend(
753                    partition
754                        .replicas
755                        .iter()
756                        .zip(&partition.directories)
757                        .filter(|(_, directory)| **directory != crate::directory_id::MIGRATING)
758                        .map(|(replica, _)| {
759                            MetadataRecord::V1PartitionDirAssignment(
760                                crate::records::PartitionDirAssignmentRecord {
761                                    topic: partition.topic.clone(),
762                                    partition: partition.partition,
763                                    replica: *replica,
764                                    directory: crate::directory_id::MIGRATING,
765                                },
766                            )
767                        }),
768                );
769            }
770        }
771        records
772    }
773
774    /// Apply one record. Returns the previous record, for `V1Topic` and
775    /// `V1BrokerRegistration`, so the caller can observe overwrite cases.
776    /// The method is infallible. The controller pre-validates against the
777    /// current image before it submits to Raft. Apply must never fail on a
778    /// committed entry.
779    #[tracing::instrument(level = "info", skip_all, fields(record = record_variant(rec)))]
780    // exhaustive match over MetadataRecord
781    pub fn apply(&mut self, rec: &MetadataRecord) {
782        match rec {
783            MetadataRecord::V1Topic(t) => {
784                // If a topic with this name already exists under a different
785                // id, drop the stale id entry before re-indexing.
786                if let Some(prev) = self.topics.get(&t.name)
787                    && prev.topic_id != t.topic_id
788                {
789                    self.topic_ids.remove(&prev.topic_id);
790                }
791                self.topic_ids.insert(t.topic_id, t.name.clone());
792                // The denormalized `partitions` count is a derived cache of the
793                // partitions map (KIP-631 `TopicRecord` carries no partition
794                // count), maintained incrementally as `V1Partition` records
795                // apply. A brand-new topic starts at 0 and the partition records
796                // that follow it in log order restore the real count; a
797                // re-registration of an existing topic keeps its partitions in
798                // the map, so preserve the running count rather than trusting
799                // the (KIP-631-zeroed) record field.
800                let mut rec = t.clone();
801                rec.partitions = self.topics.get(&t.name).map_or(0, |prev| prev.partitions);
802                self.topics.insert(t.name.clone(), rec);
803            }
804            MetadataRecord::V1Partition(p) => self.apply_partition(p),
805            MetadataRecord::V1BrokerRegistration(b) => {
806                self.brokers.insert(b.node_id, b.clone());
807            }
808            MetadataRecord::V1BrokerRegistrationChange(change) => {
809                self.apply_registration_change(change);
810            }
811            MetadataRecord::V1UnregisterBroker(rec) => self.apply_unregister_broker(rec),
812            MetadataRecord::V1ControllerRegistration(c) => {
813                self.controllers.insert(c.node_id, c.clone());
814            }
815            MetadataRecord::V1UnregisterController(rec) => {
816                self.controllers.remove(&rec.node_id);
817            }
818            MetadataRecord::V1DeleteTopic(d) => {
819                if let Some(prev) = self.topics.get(&d.name) {
820                    self.topic_ids.remove(&prev.topic_id);
821                }
822                self.topics.remove(&d.name);
823                self.partitions.remove(&d.name);
824                self.partition_next_offsets.retain(|(t, _), _| t != &d.name);
825                self.partition_elr.retain(|(t, _), _| t != &d.name);
826                self.partition_recovery.retain(|(t, _), _| t != &d.name);
827                self.topic_configs.remove(&d.name);
828            }
829            MetadataRecord::V1TopicConfig(c) => {
830                if c.overrides.is_empty() {
831                    self.topic_configs.remove(&c.topic);
832                } else {
833                    self.topic_configs
834                        .insert(c.topic.clone(), c.overrides.clone());
835                }
836            }
837            MetadataRecord::V1ScramCredential(r) => {
838                self.scram_credentials.insert(
839                    (r.user.clone(), r.mechanism),
840                    ScramCredential {
841                        mechanism: r.mechanism,
842                        salt: r.salt.clone(),
843                        stored_key: r.stored_key.clone(),
844                        server_key: r.server_key.clone(),
845                        iterations: r.iterations,
846                    },
847                );
848            }
849            MetadataRecord::V1DeleteScramCredential(r) => {
850                self.scram_credentials
851                    .remove(&(r.user.clone(), r.mechanism));
852            }
853            MetadataRecord::V1AccessControlEntry(entry) => {
854                let bucket = match entry.pattern_type {
855                    PatternType::Literal => self
856                        .acls_literal
857                        .entry((entry.resource_type, entry.resource_name.clone()))
858                        .or_default(),
859                    PatternType::Prefixed => {
860                        self.acls_prefixed.entry(entry.resource_type).or_default()
861                    }
862                };
863                // Last-write-wins on full-tuple equality.
864                bucket.retain(|e| e != entry);
865                bucket.push(entry.clone());
866            }
867            MetadataRecord::V1DeleteAccessControlEntry(filter) => {
868                self.acls_literal.retain(|_, v| {
869                    v.retain(|e| !filter.matches(e));
870                    !v.is_empty()
871                });
872                self.acls_prefixed.retain(|_, v| {
873                    v.retain(|e| !filter.matches(e));
874                    !v.is_empty()
875                });
876            }
877            MetadataRecord::V1BrokerConfig(rec) => {
878                let entry = self.broker_configs.entry(rec.node_id).or_default();
879                match &rec.config_value {
880                    Some(v) => {
881                        entry.insert(rec.config_name.clone(), v.clone());
882                    }
883                    None => {
884                        entry.remove(&rec.config_name);
885                    }
886                }
887            }
888            MetadataRecord::V1ClientQuota(rec) => {
889                let key = canonicalize_entity(
890                    rec.entity
891                        .iter()
892                        .map(|e| (e.entity_type.clone(), e.entity_name.clone()))
893                        .collect(),
894                );
895                let configs = self.client_quotas.entry(key).or_default();
896                match rec.config_value {
897                    Some(v) => {
898                        configs.insert(rec.config_key.clone(), v);
899                    }
900                    None => {
901                        configs.remove(&rec.config_key);
902                    }
903                }
904            }
905            MetadataRecord::V1ProducerIds(rec) => {
906                if rec.next_producer_id > self.next_producer_id() {
907                    self.producer_ids = Some(*rec);
908                } else {
909                    tracing::error!(
910                        current = self.next_producer_id(),
911                        proposed = rec.next_producer_id,
912                        "ignoring non-monotonic producer ID allocation record"
913                    );
914                }
915            }
916            // Replacement semantics (KIP-48) — same
917            // `token_id` overwrites the prior entry (used by Create
918            // and Renew). Tombstone removes by `token_id`.
919            MetadataRecord::V1DelegationToken(rec) => {
920                self.delegation_tokens
921                    .insert(rec.token_id.clone(), DelegationToken::from_record(rec));
922            }
923            MetadataRecord::V1DeleteDelegationToken(rec) => {
924                self.delegation_tokens.remove(&rec.token_id);
925            }
926            MetadataRecord::V1KRaftVersion(r) => {
927                self.kraft_version = r.kraft_version;
928            }
929            MetadataRecord::V1Voters(r) => {
930                self.voters = r.voters.clone();
931            }
932            MetadataRecord::V1FeatureLevel(rec) => {
933                if rec.level == 0 {
934                    self.feature_levels.remove(&rec.name);
935                } else {
936                    self.feature_levels.insert(rec.name.clone(), rec.level);
937                }
938                // Monotonic epoch: -1 -> 0 on the first record, then +1.
939                self.features_epoch = self.features_epoch.saturating_add(1).max(0);
940            }
941            MetadataRecord::V1ClientMetricsConfig(c) => {
942                if c.configs.is_empty() {
943                    self.client_metrics_configs.remove(&c.name);
944                } else {
945                    self.client_metrics_configs
946                        .insert(c.name.clone(), c.configs.clone());
947                }
948            }
949            MetadataRecord::V1GroupConfig(c) => {
950                if c.configs.is_empty() {
951                    self.group_configs.remove(&c.group_id);
952                } else {
953                    self.group_configs
954                        .insert(c.group_id.clone(), c.configs.clone());
955                }
956            }
957            // Snapshot-only: restore the epoch verbatim rather than bumping.
958            // Emitted last by `to_records` so it overrides the value the
959            // preceding `V1FeatureLevel` replays accumulated. Never reaches
960            // the live log (not a controller-submittable change).
961            MetadataRecord::V1FeaturesEpoch(rec) => {
962                self.features_epoch = rec.epoch;
963            }
964            // KIP-858 directory-assignment delta: merge ONLY the reporting
965            // replica's `directories` slot. Never touches leader/isr/replicas/
966            // adding/removing, so a concurrent reassignment or ISR change is
967            // preserved regardless of commit order.
968            MetadataRecord::V1PartitionDirAssignment(r) => {
969                if let Some(pr) = self
970                    .partitions
971                    .get_mut(&r.topic)
972                    .and_then(|parts| parts.get_mut(&r.partition))
973                    && let Some(slot) = pr.replicas.iter().position(|n| *n == r.replica)
974                {
975                    // An empty list is every replica `MIGRATING`, as Kafka's
976                    // `PartitionRegistration.defaultToMigrating` reads it.
977                    if pr.directories.len() < pr.replicas.len() {
978                        pr.directories
979                            .resize(pr.replicas.len(), crate::directory_id::MIGRATING);
980                    }
981                    pr.directories[slot] = r.directory;
982                }
983            }
984            // Diskless offset delta: bump the partition's committed next-offset.
985            MetadataRecord::V1PartitionOffsetAdvance(r) => {
986                *self
987                    .partition_next_offsets
988                    .entry((r.topic.clone(), r.partition))
989                    .or_insert(0) += r.count;
990            }
991            MetadataRecord::V1PartitionElr(r) => self.apply_partition_elr(r),
992            MetadataRecord::V1PartitionRecovery(r) => {
993                self.apply_partition_recovery(&r.topic, r.partition, r.state);
994            }
995            MetadataRecord::V1PartitionUpdate(r) => self.apply_partition_update(r),
996            // KFC-9 write-freeze registry.
997            MetadataRecord::V1TopicFreeze(r) => self.apply_topic_freeze(r),
998            // KFC-9 break-glass proposal. Replacement semantics on the id:
999            // an approval and a consumption each write the whole record back.
1000            // `validate` is what stops a replacement from losing an approval.
1001            MetadataRecord::V1BreakGlassProposal(r) => {
1002                self.break_glass.insert(r.proposal_id, r.clone());
1003            }
1004            MetadataRecord::V1DeleteBreakGlassProposal(id) => {
1005                self.break_glass.remove(id);
1006            }
1007        }
1008    }
1009
1010    /// Kafka's `ClusterControlManager.replayRegistrationChange` applies the
1011    /// change only to the registration at the record's epoch. An unknown
1012    /// broker or another epoch is a no-op.
1013    fn apply_registration_change(
1014        &mut self,
1015        change: &crate::records::BrokerRegistrationChangeRecord,
1016    ) {
1017        if let Some(broker) = self.brokers.get_mut(&change.node_id)
1018            && broker.broker_epoch == change.broker_epoch
1019        {
1020            *broker = change.applied_to(broker);
1021        }
1022    }
1023
1024    /// Kafka's `ClusterControlManager.replay(UnregisterBrokerRecord)` removes
1025    /// only the registration at the record's epoch. An unknown broker or
1026    /// another epoch is a no-op.
1027    fn apply_unregister_broker(&mut self, rec: &crate::records::UnregisterBrokerRecord) {
1028        if self.broker_epoch(rec.node_id) == Some(rec.broker_epoch) {
1029            self.brokers.remove(&rec.node_id);
1030        }
1031    }
1032
1033    fn apply_partition(&mut self, partition: &PartitionRecord) {
1034        let is_new = self
1035            .partitions
1036            .entry(partition.topic.clone())
1037            .or_default()
1038            .insert(partition.partition, partition.clone())
1039            .is_none();
1040        if let Some(topic) = self.topics.get_mut(&partition.topic) {
1041            if is_new {
1042                topic.partitions = topic.partitions.saturating_add(1);
1043            }
1044            topic.replication_factor =
1045                i16::try_from(partition.replicas.len()).unwrap_or(topic.replication_factor);
1046        }
1047    }
1048
1049    fn apply_partition_update(&mut self, update: &crate::PartitionUpdateRecord) {
1050        let key = (update.partition.topic.clone(), update.partition.partition);
1051        self.apply_partition(&update.partition);
1052        if let (Some(eligible), Some(last_known)) =
1053            (&update.eligible_leader_replicas, &update.last_known_elr)
1054        {
1055            self.apply_partition_elr(&PartitionElrRecord {
1056                topic: key.0.clone(),
1057                partition: key.1,
1058                eligible_leader_replicas: eligible.clone(),
1059                last_known_elr: last_known.clone(),
1060            });
1061        }
1062        if let Some(state) = update.recovery_state {
1063            self.apply_partition_recovery(&key.0, key.1, state);
1064        }
1065    }
1066
1067    fn apply_partition_elr(&mut self, record: &PartitionElrRecord) {
1068        let key = (record.topic.clone(), record.partition);
1069        if record.eligible_leader_replicas.is_empty() && record.last_known_elr.is_empty() {
1070            self.partition_elr.remove(&key);
1071        } else {
1072            self.partition_elr.insert(
1073                key,
1074                (
1075                    record.eligible_leader_replicas.clone(),
1076                    record.last_known_elr.clone(),
1077                ),
1078            );
1079        }
1080    }
1081
1082    fn apply_partition_recovery(
1083        &mut self,
1084        topic: &str,
1085        partition: i32,
1086        state: crate::LeaderRecoveryState,
1087    ) {
1088        let key = (topic.to_owned(), partition);
1089        if state == crate::LeaderRecoveryState::Recovered {
1090            self.partition_recovery.remove(&key);
1091        } else {
1092            self.partition_recovery.insert(key, state);
1093        }
1094    }
1095
1096    /// Upsert or remove one write-freeze registry entry (KFC-9).
1097    ///
1098    /// `frozen` is the sentinel: a `true` record upserts the entry into the
1099    /// registry that its pattern type selects, and a `false` record removes
1100    /// it. That keeps the thaw attributed in the raft log and needs no second
1101    /// record type.
1102    fn apply_topic_freeze(&mut self, rec: &TopicFreezeRecord) {
1103        match (rec.pattern_type, rec.frozen) {
1104            (PatternType::Literal, true) => {
1105                self.freezes_literal.insert(rec.scope.clone(), rec.clone());
1106            }
1107            (PatternType::Literal, false) => {
1108                self.freezes_literal.remove(&rec.scope);
1109            }
1110            (PatternType::Prefixed, true) => {
1111                self.freezes_prefixed.insert(rec.scope.clone(), rec.clone());
1112            }
1113            (PatternType::Prefixed, false) => {
1114                self.freezes_prefixed.remove(&rec.scope);
1115            }
1116        }
1117    }
1118
1119    /// Serialize the image into the minimal sequence of [`MetadataRecord`]s
1120    /// whose in-order [`apply`](Self::apply) reproduces this image exactly.
1121    /// This is the inverse of `apply` over a sequence of non-tombstone
1122    /// records: each stored entry maps to the record that would create it.
1123    ///
1124    /// The method deliberately never emits tombstone or removal records:
1125    /// `V1DeleteTopic`, `V1DeleteScramCredential`,
1126    /// `V1DeleteAccessControlEntry`, `V1DeleteDelegationToken`, and
1127    /// `V1UnregisterBroker`. A snapshot captures resulting *state*, not
1128    /// deletion history. `V1BrokerConfig` deletes vanish in the same way. The
1129    /// method emits only the surviving key and value pairs, as `Some(value)`
1130    /// sets.
1131    ///
1132    /// The method emits records in dependency order, so a fresh image that
1133    /// `apply`s them never sees a dangling reference: brokers,
1134    /// broker-configs, topics, partitions, topic-configs, SCRAM creds,
1135    /// ACLs, client quotas, delegation tokens.
1136    #[tracing::instrument(
1137        level = "debug",
1138        skip_all,
1139        fields(topics = self.topics.len(), partitions = self.total_partitions())
1140    )]
1141    #[must_use]
1142    pub fn to_records(&self) -> Vec<MetadataRecord> {
1143        let mut out = Vec::new();
1144
1145        // Brokers before their configs.
1146        for b in self.brokers.values() {
1147            out.push(MetadataRecord::V1BrokerRegistration(b.clone()));
1148        }
1149        for controller in self.controllers.values() {
1150            out.push(MetadataRecord::V1ControllerRegistration(controller.clone()));
1151        }
1152        for (node_id, configs) in &self.broker_configs {
1153            for (config_name, config_value) in configs {
1154                out.push(MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
1155                    node_id: *node_id,
1156                    config_name: config_name.clone(),
1157                    config_value: Some(config_value.clone()),
1158                }));
1159            }
1160        }
1161
1162        // Topics before partitions and topic-configs (apply-side validate
1163        // and the snapshot's own DeleteTopic-clears-configs invariant both
1164        // assume the topic exists first).
1165        for t in self.topics.values() {
1166            out.push(MetadataRecord::V1Topic(t.clone()));
1167        }
1168        for p in self.all_partitions() {
1169            out.push(MetadataRecord::V1Partition(p.clone()));
1170        }
1171        self.push_partition_offset_advances(&mut out);
1172        for ((topic, partition), (eligible, last_known)) in &self.partition_elr {
1173            out.push(MetadataRecord::V1PartitionElr(crate::PartitionElrRecord {
1174                topic: topic.clone(),
1175                partition: *partition,
1176                eligible_leader_replicas: eligible.clone(),
1177                last_known_elr: last_known.clone(),
1178            }));
1179        }
1180        for ((topic, partition), state) in &self.partition_recovery {
1181            out.push(MetadataRecord::V1PartitionRecovery(
1182                crate::PartitionRecoveryRecord {
1183                    topic: topic.clone(),
1184                    partition: *partition,
1185                    state: *state,
1186                },
1187            ));
1188        }
1189        for (topic, overrides) in &self.topic_configs {
1190            out.push(MetadataRecord::V1TopicConfig(TopicConfigRecord {
1191                topic: topic.clone(),
1192                overrides: overrides.clone(),
1193            }));
1194        }
1195
1196        // SCRAM credentials.
1197        for ((user, mechanism), cred) in &self.scram_credentials {
1198            out.push(MetadataRecord::V1ScramCredential(ScramCredentialRecord {
1199                user: user.clone(),
1200                mechanism: *mechanism,
1201                salt: cred.salt.clone(),
1202                stored_key: cred.stored_key.clone(),
1203                server_key: cred.server_key.clone(),
1204                iterations: cred.iterations,
1205            }));
1206        }
1207
1208        // ACLs (literal + prefixed). Each stored entry already carries its
1209        // own pattern_type, so re-applying routes it back to the right
1210        // bucket.
1211        for entry in self.all_acls() {
1212            out.push(MetadataRecord::V1AccessControlEntry(entry.clone()));
1213        }
1214
1215        // Client quotas. The key is the canonicalized entity tuple; rebuild
1216        // a QuotaEntity list from it and emit one record per config pair.
1217        // apply re-canonicalizes, so the round-trip is stable.
1218        for (entity_key, configs) in &self.client_quotas {
1219            let entity: Vec<QuotaEntity> = entity_key
1220                .iter()
1221                .map(|(entity_type, entity_name)| QuotaEntity {
1222                    entity_type: entity_type.clone(),
1223                    entity_name: entity_name.clone(),
1224                })
1225                .collect();
1226            for (config_key, config_value) in configs {
1227                out.push(MetadataRecord::V1ClientQuota(ClientQuotaRecord {
1228                    entity: entity.clone(),
1229                    config_key: config_key.clone(),
1230                    config_value: Some(*config_value),
1231                }));
1232            }
1233        }
1234
1235        if let Some(record) = self.producer_ids {
1236            out.push(MetadataRecord::V1ProducerIds(record));
1237        }
1238
1239        // Delegation tokens.
1240        for tok in self.delegation_tokens.values() {
1241            out.push(MetadataRecord::V1DelegationToken(DelegationTokenRecord {
1242                token_id: tok.token_id.clone(),
1243                owner: tok.owner.clone(),
1244                requester: tok.requester.clone(),
1245                issue_timestamp_ms: tok.issue_timestamp_ms,
1246                expiry_timestamp_ms: tok.expiry_timestamp_ms,
1247                max_timestamp_ms: tok.max_timestamp_ms,
1248                renewers: tok.renewers.clone(),
1249            }));
1250        }
1251
1252        // Client-metrics subscriptions (KIP-714).
1253        for (name, configs) in &self.client_metrics_configs {
1254            out.push(MetadataRecord::V1ClientMetricsConfig(
1255                ClientMetricsConfigRecord {
1256                    name: name.clone(),
1257                    configs: configs.clone(),
1258                },
1259            ));
1260        }
1261
1262        // KIP-1071 dynamic GROUP resources.
1263        for (group_id, configs) in &self.group_configs {
1264            out.push(MetadataRecord::V1GroupConfig(GroupConfigRecord {
1265                group_id: group_id.clone(),
1266                configs: configs.clone(),
1267            }));
1268        }
1269
1270        // KFC-9 write-freeze registry and break-glass proposals. Both are
1271        // independent of every other record, and both are lost without this:
1272        // a snapshot install would thaw the cluster and drop every pending
1273        // approval. Every stored freeze has `frozen: true`, so re-applying it
1274        // routes it back to the same registry.
1275        for freeze in self.topic_freezes() {
1276            out.push(MetadataRecord::V1TopicFreeze(freeze.clone()));
1277        }
1278        for proposal in self.break_glass.values() {
1279            out.push(MetadataRecord::V1BreakGlassProposal(proposal.clone()));
1280        }
1281
1282        // KIP-584 finalized features: one record per live feature, in the
1283        // BTreeMap's deterministic key order. (level == 0 is the delete
1284        // sentinel and is never stored, so every emitted level is >= 1 and
1285        // re-applies as a set.) The trailing V1FeaturesEpoch then pins the
1286        // epoch verbatim — the preceding feature records each bump it, but the
1287        // bumped value reflects the live feature count, not the original apply
1288        // history, so it must be overwritten. Emitted whenever any feature was
1289        // ever finalized (epoch left -1 only on a pristine image), so an image
1290        // that finalized then removed every feature still restores its epoch.
1291        for (name, level) in &self.feature_levels {
1292            out.push(MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
1293                name: name.clone(),
1294                level: *level,
1295            }));
1296        }
1297        if self.features_epoch >= 0 {
1298            out.push(MetadataRecord::V1FeaturesEpoch(FeaturesEpochRecord {
1299                epoch: self.features_epoch,
1300            }));
1301        }
1302
1303        // KIP-853 controller-quorum state. Both MUST be emitted or a snapshot
1304        // recovery / learner install silently drops them: the voter set goes
1305        // empty (breaking `DescribeQuorum` and KIP-853 auto-join) and the
1306        // cluster `kraft.version` reverts to 0. The cluster `kraft_version`
1307        // in particular has no other persistence path — unlike the openraft
1308        // membership (restored from `SnapshotMeta.last_membership`), it is not
1309        // carried anywhere outside the metadata records, so the image is its
1310        // only source of truth. Independent of every other record, so order
1311        // here is irrelevant. The reconfig coordinator commits the openraft
1312        // membership change and the authoritative `V1Voters` record under one
1313        // lock, so the emitted voter set mirrors `last_membership` for the
1314        // same committed prefix (any transient one-reconfig skew self-heals as
1315        // the trailing entry replays from the retained log — the same
1316        // eventual lockstep that holds in live operation).
1317        if self.kraft_version != 0 {
1318            out.push(MetadataRecord::V1KRaftVersion(KRaftVersionRecord {
1319                kraft_version: self.kraft_version,
1320            }));
1321        }
1322        if !self.voters.is_empty() {
1323            out.push(MetadataRecord::V1Voters(VotersRecord {
1324                voters: self.voters.clone(),
1325            }));
1326        }
1327
1328        out
1329    }
1330
1331    fn push_partition_offset_advances(&self, out: &mut Vec<MetadataRecord>) {
1332        for ((topic, partition), count) in &self.partition_next_offsets {
1333            out.push(MetadataRecord::V1PartitionOffsetAdvance(
1334                PartitionOffsetAdvanceRecord {
1335                    topic: topic.clone(),
1336                    partition: *partition,
1337                    count: *count,
1338                },
1339            ));
1340        }
1341    }
1342
1343    /// Reconstruct an image from a `cluster_id` and a record sequence,
1344    /// typically the output of [`Self::to_records`] read back from a snapshot.
1345    /// The method `new`s an empty image and `apply`s each record in order.
1346    #[tracing::instrument(level = "info", skip_all, fields(records = records.len()))]
1347    #[must_use]
1348    pub fn from_records(cluster_id: Uuid, records: &[MetadataRecord]) -> Self {
1349        let mut image = Self::new(cluster_id);
1350        for rec in records {
1351            image.apply(rec);
1352        }
1353        image
1354    }
1355
1356    /// Synchronous pre-validation: returns `Ok` if the record would be a
1357    /// no-conflict apply, otherwise the appropriate error.
1358    /// `Controller::submit_change` uses this before it forwards to openraft.
1359    ///
1360    /// # Errors
1361    /// Returns the conflict that would prevent the record from being applied.
1362    #[tracing::instrument(level = "debug", skip_all, fields(record = record_variant(rec)), err)]
1363    pub fn validate(&self, rec: &MetadataRecord) -> Result<(), MetadataError> {
1364        match rec {
1365            MetadataRecord::V1Topic(t) => {
1366                if let Some(existing) = self.topics.get(&t.name) {
1367                    // Updating an existing topic is allowed only if it's a
1368                    // strict partition-count expansion that preserves
1369                    // identity: same topic_id, same replication_factor,
1370                    // partitions strictly growing. CreatePartitions emits
1371                    // exactly this. Identical re-submits stay rejected so
1372                    // CreateTopics' idempotency contract still holds.
1373                    // Partition count derives from the partitions map (the
1374                    // KIP-631 round-trip does not carry the stored
1375                    // `TopicRecord.partitions`), so the expansion check reads
1376                    // the live count rather than `existing.partitions`.
1377                    if existing.topic_id != t.topic_id
1378                        || existing.replication_factor != t.replication_factor
1379                        || t.partitions <= self.topic_partition_count(&t.name)
1380                    {
1381                        return Err(MetadataError::TopicExists(t.name.clone()));
1382                    }
1383                    return Ok(());
1384                }
1385                // A new topic is valid regardless of the `TopicRecord.partitions`
1386                // count: KIP-631 framing does not carry that field, so a
1387                // freshly-decoded `V1Topic` round-trips back as partitions=0 with
1388                // the real count arriving via the following `V1Partition` records.
1389                // Partition-count validity (`> 0`) is enforced at the CreateTopics
1390                // request boundary, not on the metadata-record apply path.
1391                Ok(())
1392            }
1393            MetadataRecord::V1Partition(p) => {
1394                if !self.topics.contains_key(&p.topic) {
1395                    return Err(MetadataError::UnknownTopic(p.topic.clone()));
1396                }
1397                Ok(())
1398            }
1399            MetadataRecord::V1PartitionUpdate(update) => {
1400                if !self.topics.contains_key(&update.partition.topic) {
1401                    return Err(MetadataError::UnknownTopic(update.partition.topic.clone()));
1402                }
1403                Ok(())
1404            }
1405            MetadataRecord::V1PartitionElr(record) => {
1406                if self.partition(&record.topic, record.partition).is_none() {
1407                    return Err(MetadataError::UnknownTopic(record.topic.clone()));
1408                }
1409                Ok(())
1410            }
1411            MetadataRecord::V1PartitionRecovery(record) => {
1412                if self.partition(&record.topic, record.partition).is_none() {
1413                    return Err(MetadataError::UnknownTopic(record.topic.clone()));
1414                }
1415                Ok(())
1416            }
1417            MetadataRecord::V1DeleteTopic(d) => {
1418                if !self.topics.contains_key(&d.name) {
1419                    return Err(MetadataError::UnknownTopic(d.name.clone()));
1420                }
1421                Ok(())
1422            }
1423            MetadataRecord::V1TopicConfig(c) => {
1424                if !self.topics.contains_key(&c.topic) {
1425                    return Err(MetadataError::UnknownTopic(c.topic.clone()));
1426                }
1427                Ok(())
1428            }
1429            // Kafka's `ClusterControlManager` refuses to replay a registration
1430            // change or an unregistration unless the broker is registered at
1431            // the record's epoch, and a controller unregistration unless the
1432            // controller is registered. Apply treats those as no-ops, so they
1433            // are refused here, where the submitter still learns of it.
1434            MetadataRecord::V1BrokerRegistrationChange(change) => {
1435                if self.broker_epoch(change.node_id) != Some(change.broker_epoch) {
1436                    return Err(MetadataError::InvalidRecord(
1437                        "broker registration change for an epoch the broker is not registered at",
1438                    ));
1439                }
1440                Ok(())
1441            }
1442            MetadataRecord::V1UnregisterBroker(rec) => {
1443                if self.broker_epoch(rec.node_id) != Some(rec.broker_epoch) {
1444                    return Err(MetadataError::InvalidRecord(
1445                        "broker unregistration for an epoch the broker is not registered at",
1446                    ));
1447                }
1448                Ok(())
1449            }
1450            MetadataRecord::V1UnregisterController(rec) => {
1451                if !self.controllers.contains_key(&rec.node_id) {
1452                    return Err(MetadataError::InvalidRecord(
1453                        "controller unregistration for a controller that is not registered",
1454                    ));
1455                }
1456                Ok(())
1457            }
1458            MetadataRecord::V1BrokerRegistration(_)
1459            | MetadataRecord::V1ControllerRegistration(_)
1460            | MetadataRecord::V1ScramCredential(_)
1461            | MetadataRecord::V1DeleteScramCredential(_)
1462            | MetadataRecord::V1AccessControlEntry(_)
1463            | MetadataRecord::V1DeleteAccessControlEntry(_)
1464            | MetadataRecord::V1BrokerConfig(_)
1465            | MetadataRecord::V1ClientQuota(_)
1466            // Delegation-token records have no
1467            // topic-store concerns and admission is gated by the
1468            // handler-side checks (KIP-48 §protocol errors), so the
1469            // image-level validate is unconditional Ok.
1470            | MetadataRecord::V1DelegationToken(_)
1471            | MetadataRecord::V1DeleteDelegationToken(_)
1472            // KIP-853: voter-set / kraft.version records are validated by
1473            // the reconfiguration coordinator before submission; the
1474            // image-level apply is an unconditional replacement.
1475            | MetadataRecord::V1KRaftVersion(_)
1476            | MetadataRecord::V1Voters(_)
1477            // KIP-584: feature-level admission is fully gated by the
1478            // UpdateFeatures handler (supported-range + downgrade checks);
1479            // image-level apply is an idempotent map upsert.
1480            | MetadataRecord::V1FeatureLevel(_)
1481            // KIP-714: client-metrics subscription config. The
1482            // IncrementalAlterConfigs handler validates the subscription
1483            // name and config keys before submission; image-level apply
1484            // is an idempotent map upsert (or removal on empty map).
1485            | MetadataRecord::V1ClientMetricsConfig(_)
1486            // KIP-1071: GROUP config keys and values are validated by the
1487            // IncrementalAlterConfigs handler before this whole-map upsert.
1488            | MetadataRecord::V1GroupConfig(_)
1489            // Snapshot-only epoch carrier: only ever produced by `to_records`
1490            // and replayed on snapshot install, never submitted as a change.
1491            // Validated permissively for match-exhaustiveness.
1492            | MetadataRecord::V1FeaturesEpoch(_)
1493            // KIP-858 directory-assignment delta: a merge into one replica's
1494            // `directories` slot. The handler already resolved topic/partition/
1495            // replica against the image; apply is an idempotent slot upsert
1496            // (a no-op if the partition or replica is unknown).
1497            | MetadataRecord::V1PartitionDirAssignment(_)
1498            // Diskless offset-sequencer delta. The handler supplies a positive
1499            // count; image-level apply is an unconditional increment.
1500            | MetadataRecord::V1PartitionOffsetAdvance(_)
1501            // KFC-9: the expiry sweep removes a proposal by id, and apply is
1502            // idempotent against an id that is already gone.
1503            | MetadataRecord::V1DeleteBreakGlassProposal(_) => Ok(()),
1504            // KFC-9: a freeze scope must not be empty, and that is the only
1505            // check. The scope deliberately does NOT have to name an existing
1506            // topic: a prefix scope names none, and a freeze put on a
1507            // namespace BEFORE a restore writes into it is the
1508            // disaster-recovery case the feature exists for.
1509            MetadataRecord::V1TopicFreeze(freeze) => {
1510                if freeze.scope.is_empty() {
1511                    return Err(MetadataError::EmptyFreezeScope);
1512                }
1513                Ok(())
1514            }
1515            // KFC-9: the concurrent-approval rule. Two approvers read the
1516            // same image and each submits the approval list it read plus its
1517            // own entry. Without this check the second write overwrites the
1518            // first, and the proposal never reaches two principals. So an
1519            // incoming list must be the stored list plus zero or more
1520            // appended entries, and a settled proposal takes no change at
1521            // all.
1522            MetadataRecord::V1BreakGlassProposal(proposal) => {
1523                let Some(stored) = self.break_glass.get(&proposal.proposal_id) else {
1524                    return Ok(());
1525                };
1526                if stored.consumed_at_ms != 0 {
1527                    return Err(MetadataError::BreakGlassProposalConsumed(
1528                        proposal.proposal_id,
1529                    ));
1530                }
1531                if stored.withdrawn {
1532                    return Err(MetadataError::BreakGlassProposalWithdrawn(
1533                        proposal.proposal_id,
1534                    ));
1535                }
1536                if !extends(&stored.approvals, &proposal.approvals) {
1537                    return Err(MetadataError::BreakGlassApprovalsNotAnExtension(
1538                        proposal.proposal_id,
1539                    ));
1540                }
1541                Ok(())
1542            }
1543            MetadataRecord::V1ProducerIds(record) => {
1544                if record.next_producer_id <= self.next_producer_id() {
1545                    return Err(MetadataError::InvalidRecord(
1546                        "next producer ID must advance monotonically",
1547                    ));
1548                }
1549                if self.broker_epoch(record.broker_id) != Some(record.broker_epoch) {
1550                    return Err(MetadataError::InvalidRecord(
1551                        "producer ID allocation has a stale broker epoch",
1552                    ));
1553                }
1554                Ok(())
1555            }
1556        }
1557    }
1558}
1559
1560/// KIP-1276: Kafka's `AclControlManager.validateHostPattern` treats any ACL
1561/// host containing `/` as a CIDR block, which needs `metadata.version`
1562/// `4.4-IV1`.
1563fn is_cidr_acl_host(host: &str) -> bool {
1564    host.contains('/')
1565}
1566
1567#[cfg(test)]
1568mod tests {
1569    use krabka_units::bytes_per_sec;
1570
1571    use super::*;
1572
1573    /// `record_variant` maps each enum variant to its exact discriminant
1574    /// name. Assertions on concrete mappings kill whole-fn replacements, for
1575    /// example a return of `""` or of a constant.
1576    /// Three readers on the image had no caller in any test: a
1577    /// `group_configs` or `controllers` replaced by an empty iterator, and a
1578    /// `controller` replaced by `None`, all describe a cluster with nothing
1579    /// registered -- which is what an image looks like before any record is
1580    /// applied, so nothing else notices.
1581    #[test]
1582    fn group_config_and_controller_readers_report_applied_records() {
1583        let mut image = MetadataImage::new(Uuid::nil());
1584        image.apply(&MetadataRecord::V1GroupConfig(
1585            crate::records::GroupConfigRecord {
1586                group_id: "streams-app".into(),
1587                configs: BTreeMap::from([("streams.num.standby.replicas".into(), "1".into())]),
1588            },
1589        ));
1590        image.apply(&MetadataRecord::V1ControllerRegistration(
1591            crate::records::ControllerRegistrationRecord {
1592                node_id: NodeId(3),
1593                incarnation_id: Uuid::from_u128(7),
1594                zk_migration_ready: false,
1595                endpoints: Vec::new(),
1596                features: BTreeMap::new(),
1597            },
1598        ));
1599
1600        let groups: Vec<_> = image
1601            .group_configs()
1602            .map(|(id, cfg)| (id.clone(), cfg.clone()))
1603            .collect();
1604        check!(
1605            groups
1606                == vec![(
1607                    "streams-app".to_string(),
1608                    BTreeMap::from([("streams.num.standby.replicas".to_string(), "1".to_string())])
1609                )]
1610        );
1611
1612        let ids: Vec<_> = image.controllers().map(|c| c.node_id).collect();
1613        check!(ids == vec![NodeId(3)]);
1614
1615        // Present and absent, so a reader stuck on `None` is not merely right
1616        // about the node that is missing.
1617        check!(image.controller(NodeId(3)).map(|c| c.incarnation_id) == Some(Uuid::from_u128(7)));
1618        check!(image.controller(NodeId(99)).is_none());
1619    }
1620
1621    fn registered_broker(node_id: u64, broker_epoch: i64) -> BrokerRegistrationRecord {
1622        BrokerRegistrationRecord {
1623            node_id: NodeId(node_id),
1624            broker_epoch,
1625            incarnation_id: Uuid::from_u128(u128::from(node_id)),
1626            host: format!("broker-{node_id}"),
1627            port: 9092,
1628            rack: None,
1629            endpoints: vec![],
1630            log_dirs: vec![],
1631            fenced: true,
1632            in_controlled_shutdown: false,
1633            cordoned_log_dirs: None,
1634            features: BTreeMap::new(),
1635        }
1636    }
1637
1638    /// Kafka's `ClusterControlManager` replays a `BrokerRegistrationChangeRecord`
1639    /// or an `UnregisterBrokerRecord` only against the registration at the
1640    /// record's epoch. Validate refuses the others and apply ignores them.
1641    #[test]
1642    fn registration_change_and_unregister_apply_only_at_the_registered_epoch() {
1643        use crate::records::{
1644            BrokerRegistrationChangeRecord, FencingChange, UnregisterBrokerRecord,
1645        };
1646
1647        let registered = registered_broker(4, 12);
1648        let unfenced = BrokerRegistrationRecord {
1649            fenced: false,
1650            ..registered.clone()
1651        };
1652        let unfence = |node_id, broker_epoch| {
1653            MetadataRecord::V1BrokerRegistrationChange(BrokerRegistrationChangeRecord {
1654                fenced: FencingChange::Unfence,
1655                ..BrokerRegistrationChangeRecord::no_change(NodeId(node_id), broker_epoch)
1656            })
1657        };
1658        let unregister = |node_id, broker_epoch| {
1659            MetadataRecord::V1UnregisterBroker(UnregisterBrokerRecord {
1660                node_id: NodeId(node_id),
1661                broker_epoch,
1662            })
1663        };
1664        let stale_change = Err(MetadataError::InvalidRecord(
1665            "broker registration change for an epoch the broker is not registered at",
1666        ));
1667        let stale_unregister = Err(MetadataError::InvalidRecord(
1668            "broker unregistration for an epoch the broker is not registered at",
1669        ));
1670        for (case, record, want_validate, want_broker) in [
1671            (
1672                "unfence at the epoch",
1673                unfence(4, 12),
1674                Ok(()),
1675                Some(unfenced),
1676            ),
1677            (
1678                "unfence at a stale epoch",
1679                unfence(4, 11),
1680                stale_change.clone(),
1681                Some(registered.clone()),
1682            ),
1683            (
1684                "unfence an unknown broker",
1685                unfence(5, 12),
1686                stale_change,
1687                Some(registered.clone()),
1688            ),
1689            ("unregister at the epoch", unregister(4, 12), Ok(()), None),
1690            (
1691                "unregister at a stale epoch",
1692                unregister(4, 11),
1693                stale_unregister.clone(),
1694                Some(registered.clone()),
1695            ),
1696            (
1697                "unregister an unknown broker",
1698                unregister(5, 12),
1699                stale_unregister,
1700                Some(registered.clone()),
1701            ),
1702        ] {
1703            let mut image = MetadataImage::new(Uuid::nil());
1704            image.apply(&MetadataRecord::V1BrokerRegistration(registered.clone()));
1705            check!(image.validate(&record) == want_validate, "{case}");
1706            image.apply(&record);
1707            check!(image.broker(NodeId(4)).cloned() == want_broker, "{case}");
1708        }
1709    }
1710
1711    /// KIP-1312: `UnregisterControllerRecord` removes the controller's
1712    /// registration. Kafka refuses to replay it for an unregistered id.
1713    #[test]
1714    fn unregister_controller_removes_the_registration() {
1715        use crate::records::{ControllerRegistrationRecord, UnregisterControllerRecord};
1716
1717        let mut image = MetadataImage::new(Uuid::nil());
1718        for node_id in [3000, 3001] {
1719            image.apply(&MetadataRecord::V1ControllerRegistration(
1720                ControllerRegistrationRecord {
1721                    node_id: NodeId(node_id),
1722                    incarnation_id: Uuid::from_u128(u128::from(node_id)),
1723                    zk_migration_ready: false,
1724                    endpoints: Vec::new(),
1725                    features: BTreeMap::new(),
1726                },
1727            ));
1728        }
1729        let unregister = |node_id| {
1730            MetadataRecord::V1UnregisterController(UnregisterControllerRecord {
1731                node_id: NodeId(node_id),
1732            })
1733        };
1734
1735        check!(image.validate(&unregister(3000)) == Ok(()));
1736        image.apply(&unregister(3000));
1737        let ids: Vec<_> = image.controllers().map(|c| c.node_id).collect();
1738        check!(ids == vec![NodeId(3001)]);
1739
1740        check!(
1741            image.validate(&unregister(3000))
1742                == Err(MetadataError::InvalidRecord(
1743                    "controller unregistration for a controller that is not registered",
1744                ))
1745        );
1746        image.apply(&unregister(3000));
1747        let ids: Vec<_> = image.controllers().map(|c| c.node_id).collect();
1748        check!(ids == vec![NodeId(3001)]);
1749    }
1750
1751    /// Deleting a topic drops that topic's offset sequencer state and leaves
1752    /// every other topic's alone. The retain predicate is what decides which is
1753    /// which: inverted, deleting one topic keeps only that topic's offsets and
1754    /// discards the rest of the cluster's.
1755    #[test]
1756    fn deleting_a_topic_drops_only_its_own_partition_offsets() {
1757        let mut image = MetadataImage::new(Uuid::nil());
1758        for topic in ["orders", "payments"] {
1759            image.apply(&MetadataRecord::V1PartitionOffsetAdvance(
1760                crate::records::PartitionOffsetAdvanceRecord {
1761                    topic: topic.into(),
1762                    partition: 0,
1763                    count: 5,
1764                },
1765            ));
1766        }
1767        check!(
1768            (
1769                image.partition_next_offset("orders", 0),
1770                image.partition_next_offset("payments", 0),
1771            ) == (Some(5), Some(5))
1772        );
1773
1774        image.apply(&MetadataRecord::V1DeleteTopic(
1775            crate::records::DeleteTopicRecord {
1776                name: "orders".into(),
1777            },
1778        ));
1779        check!(
1780            (
1781                image.partition_next_offset("orders", 0),
1782                image.partition_next_offset("payments", 0),
1783            ) == (None, Some(5))
1784        );
1785    }
1786
1787    fn broker_at(node_id: NodeId, epoch: i64) -> MetadataRecord {
1788        MetadataRecord::V1BrokerRegistration(crate::records::BrokerRegistrationRecord {
1789            node_id,
1790            broker_epoch: epoch,
1791            incarnation_id: Uuid::nil(),
1792            host: "h".into(),
1793            port: 9092,
1794            rack: None,
1795            log_dirs: vec![],
1796            fenced: false,
1797            in_controlled_shutdown: false,
1798            cordoned_log_dirs: None,
1799            endpoints: vec![],
1800            features: BTreeMap::new(),
1801        })
1802    }
1803
1804    /// `validate` gates producer-ID allocation on two conditions, and both
1805    /// boundaries were unasserted: the id must strictly advance, and the
1806    /// record's broker epoch must be the epoch the image holds. Inverted, the
1807    /// first accepts exactly the replays it exists to reject, and the second
1808    /// accepts only the stale epochs.
1809    #[test]
1810    fn producer_id_allocation_must_advance_and_carry_the_live_epoch() {
1811        let mut image = MetadataImage::new(Uuid::nil());
1812        image.apply(&broker_at(NodeId(1), 11));
1813        image.apply(&MetadataRecord::V1ProducerIds(
1814            crate::records::ProducerIdsRecord {
1815                broker_id: NodeId(1),
1816                broker_epoch: 11,
1817                next_producer_id: 1000,
1818            },
1819        ));
1820
1821        let allocation = |next: i64, epoch: i64| {
1822            MetadataRecord::V1ProducerIds(crate::records::ProducerIdsRecord {
1823                broker_id: NodeId(1),
1824                broker_epoch: epoch,
1825                next_producer_id: next,
1826            })
1827        };
1828
1829        check!(image.validate(&allocation(2000, 11)).is_ok());
1830        // Equal is not an advance, and neither is going backwards.
1831        check!(image.validate(&allocation(1000, 11)).is_err());
1832        check!(image.validate(&allocation(999, 11)).is_err());
1833        // A stale epoch is refused even when the id does advance.
1834        check!(image.validate(&allocation(2000, 10)).is_err());
1835    }
1836
1837    /// `apply` keeps the higher allocation and drops the rest. At equality it
1838    /// keeps what it has, so a record carrying the same id from another broker
1839    /// must not displace the one already recorded -- which only the snapshot
1840    /// shows, since the id itself is unchanged either way.
1841    #[test]
1842    fn an_equal_producer_id_allocation_does_not_replace_the_recorded_one() {
1843        let mut image = MetadataImage::new(Uuid::nil());
1844        image.apply(&broker_at(NodeId(1), 11));
1845        image.apply(&broker_at(NodeId(2), 22));
1846        let original = crate::records::ProducerIdsRecord {
1847            broker_id: NodeId(1),
1848            broker_epoch: 11,
1849            next_producer_id: 1000,
1850        };
1851        image.apply(&MetadataRecord::V1ProducerIds(original));
1852        image.apply(&MetadataRecord::V1ProducerIds(
1853            crate::records::ProducerIdsRecord {
1854                broker_id: NodeId(2),
1855                broker_epoch: 22,
1856                next_producer_id: 1000,
1857            },
1858        ));
1859
1860        let recorded: Vec<_> = image
1861            .to_records()
1862            .into_iter()
1863            .filter(|r| matches!(r, MetadataRecord::V1ProducerIds(_)))
1864            .collect();
1865        check!(recorded == vec![MetadataRecord::V1ProducerIds(original)]);
1866    }
1867
1868    #[test]
1869    fn record_variant_returns_exact_discriminant_names() {
1870        let topic = MetadataRecord::V1Topic(TopicRecord {
1871            name: "orders".into(),
1872            topic_id: Uuid::new_v4(),
1873            partitions: 3,
1874            replication_factor: 2,
1875        });
1876        assert2::assert!(record_variant(&topic) == "V1Topic");
1877
1878        let feature = MetadataRecord::V1FeatureLevel(crate::records::FeatureLevelRecord {
1879            name: "metadata.version".into(),
1880            level: 1,
1881        });
1882        assert2::assert!(record_variant(&feature) == "V1FeatureLevel");
1883    }
1884
1885    use assert2::check;
1886
1887    use crate::{
1888        acl::{AclEntryFilter, AclOperation, PermissionType},
1889        records::{
1890            BrokerConfigRecord, ClientQuotaRecord, DeleteDelegationTokenRecord,
1891            DeleteScramCredentialRecord, DeleteTopicRecord, FeatureLevelRecord, LeaderEpoch,
1892            PartitionOffsetAdvanceRecord, QuotaEntity, ScramCredentialRecord,
1893        },
1894    };
1895
1896    fn img() -> MetadataImage {
1897        MetadataImage::new(Uuid::nil())
1898    }
1899
1900    #[test]
1901    fn fresh_image_has_no_features_and_unknown_epoch() {
1902        let m = img();
1903        assert2::assert!(m.finalized_features().is_empty());
1904        assert2::assert!(m.finalized_features_epoch() == -1);
1905    }
1906
1907    #[test]
1908    fn finalized_feature_reads_arbitrary_name() {
1909        let mut m = MetadataImage::new(uuid::Uuid::nil());
1910        assert2::assert!(m.finalized_feature("transaction.version").is_none());
1911        m.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
1912            name: "transaction.version".into(),
1913            level: 2,
1914        }));
1915        assert2::assert!(m.finalized_feature("transaction.version") == Some(2));
1916    }
1917
1918    #[test]
1919    fn apply_feature_level_sets_level_and_bumps_epoch() {
1920        let mut m = img();
1921        m.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
1922            name: "metadata.version".into(),
1923            level: 1,
1924        }));
1925        assert2::assert!(m.finalized_features().get("metadata.version") == Some(&1));
1926        assert2::assert!(m.finalized_features_epoch() == 0);
1927
1928        // A second apply bumps the epoch again (monotonic).
1929        m.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
1930            name: "metadata.version".into(),
1931            level: 1,
1932        }));
1933        assert2::assert!(m.finalized_features_epoch() == 1);
1934    }
1935
1936    #[test]
1937    fn apply_feature_level_zero_deletes_entry() {
1938        let mut m = img();
1939        m.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
1940            name: "metadata.version".into(),
1941            level: 1,
1942        }));
1943        m.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
1944            name: "metadata.version".into(),
1945            level: 0,
1946        }));
1947        assert2::assert!(
1948            (
1949                m.finalized_features().get("metadata.version"),
1950                m.finalized_features_epoch(),
1951            ) == (None, 1)
1952        );
1953    }
1954
1955    #[test]
1956    fn to_records_from_records_round_trips() {
1957        let cid = Uuid::new_v4();
1958        let mut image = MetadataImage::new(cid);
1959        image.apply(&MetadataRecord::V1Topic(TopicRecord {
1960            name: "orders".into(),
1961            topic_id: Uuid::new_v4(),
1962            partitions: 3,
1963            replication_factor: 2,
1964        }));
1965        assert2::assert!(MetadataImage::from_records(cid, &image.to_records()) == image);
1966    }
1967
1968    #[test]
1969    fn offset_advance_applies_as_monotonic_delta() {
1970        let mut image = img();
1971        let advance = |count: i64| {
1972            MetadataRecord::V1PartitionOffsetAdvance(PartitionOffsetAdvanceRecord {
1973                topic: "t".into(),
1974                partition: 0,
1975                count,
1976            })
1977        };
1978
1979        image.apply(&advance(3));
1980        image.apply(&advance(2));
1981        image.apply(&MetadataRecord::V1PartitionOffsetAdvance(
1982            PartitionOffsetAdvanceRecord {
1983                topic: "t".into(),
1984                partition: 1,
1985                count: 7,
1986            },
1987        ));
1988
1989        assert2::assert!(image.partition_next_offset("t", 0) == Some(5));
1990        assert2::assert!(image.partition_next_offset("t", 1) == Some(7));
1991    }
1992
1993    #[test]
1994    fn offset_advance_survives_snapshot_round_trip() {
1995        let mut image = img();
1996        image.apply(&MetadataRecord::V1PartitionOffsetAdvance(
1997            PartitionOffsetAdvanceRecord {
1998                topic: "t".into(),
1999                partition: 0,
2000                count: 5,
2001            },
2002        ));
2003
2004        let restored = MetadataImage::from_records(Uuid::nil(), &image.to_records());
2005
2006        assert2::assert!(restored.partition_next_offset("t", 0) == Some(5));
2007    }
2008
2009    fn apply_broker_fixture(image: &mut MetadataImage) {
2010        for record in [
2011            MetadataRecord::V1BrokerRegistration(BrokerRegistrationRecord {
2012                node_id: NodeId(1),
2013                broker_epoch: 0,
2014                incarnation_id: Uuid::nil(),
2015                host: "h1".into(),
2016                port: 9092,
2017                rack: Some("r1".into()),
2018                log_dirs: vec![],
2019                fenced: false,
2020                in_controlled_shutdown: false,
2021                cordoned_log_dirs: None,
2022                endpoints: vec![crate::records::BrokerEndpoint {
2023                    name: "EXTERNAL".into(),
2024                    host: "ext".into(),
2025                    port: 9093,
2026                    protocol: krabka_security::ListenerProtocol::SaslSsl,
2027                }],
2028                features: std::collections::BTreeMap::new(),
2029            }),
2030            MetadataRecord::V1BrokerRegistration(BrokerRegistrationRecord {
2031                node_id: NodeId(2),
2032                broker_epoch: 0,
2033                incarnation_id: Uuid::nil(),
2034                host: "h2".into(),
2035                port: 9092,
2036                rack: None,
2037                log_dirs: vec![],
2038                fenced: false,
2039                in_controlled_shutdown: false,
2040                cordoned_log_dirs: None,
2041                endpoints: vec![],
2042                features: std::collections::BTreeMap::new(),
2043            }),
2044            MetadataRecord::V1UnregisterBroker(crate::records::UnregisterBrokerRecord {
2045                node_id: NodeId(2),
2046                broker_epoch: 0,
2047            }),
2048            MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
2049                node_id: NodeId(1),
2050                config_name: "leader.replication.throttled.rate".into(),
2051                config_value: Some("2048".into()),
2052            }),
2053            MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
2054                node_id: NodeId(1),
2055                config_name: "follower.replication.throttled.rate".into(),
2056                config_value: Some("4096".into()),
2057            }),
2058            MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
2059                node_id: NodeId(1),
2060                config_name: "follower.replication.throttled.rate".into(),
2061                config_value: None,
2062            }),
2063        ] {
2064            image.apply(&record);
2065        }
2066    }
2067
2068    /// Exercises every stored variant that the image can hold, that is, the 9
2069    /// state-producing records. It also exercises tombstones whose effects
2070    /// must NOT leak into the snapshot. The round-trip must reproduce the
2071    /// image exactly.
2072    #[test]
2073    // exhaustive fixture over every stored variant
2074    fn to_records_round_trips_all_variants() {
2075        use krabka_security::{KafkaPrincipal, SaslMechanism};
2076
2077        let cid = Uuid::new_v4();
2078        let mut image = MetadataImage::new(cid);
2079
2080        // One broker survives and one is unregistered; one config survives
2081        // and one is deleted back to empty.
2082        apply_broker_fixture(&mut image);
2083
2084        // Topics + partitions (one topic deleted → topic, partitions and
2085        // its config must vanish).
2086        image.apply(&MetadataRecord::V1Topic(TopicRecord {
2087            name: "orders".into(),
2088            topic_id: Uuid::new_v4(),
2089            partitions: 2,
2090            replication_factor: 2,
2091        }));
2092        image.apply(&MetadataRecord::V1Topic(TopicRecord {
2093            name: "doomed".into(),
2094            topic_id: Uuid::new_v4(),
2095            partitions: 1,
2096            replication_factor: 1,
2097        }));
2098        image.apply(&MetadataRecord::V1Partition(PartitionRecord {
2099            topic: "orders".into(),
2100            partition: 0,
2101            leader: NodeId(1),
2102            replicas: vec![NodeId(1), NodeId(2)],
2103            isr: vec![NodeId(1), NodeId(2)],
2104            leader_epoch: LeaderEpoch(4),
2105            adding_replicas: vec![NodeId(2)],
2106            removing_replicas: vec![],
2107            directories: vec![],
2108            partition_epoch: 0,
2109        }));
2110        image.apply(&MetadataRecord::V1Partition(PartitionRecord {
2111            topic: "orders".into(),
2112            partition: 1,
2113            leader: NodeId(2),
2114            replicas: vec![NodeId(2), NodeId(1)],
2115            isr: vec![NodeId(2)],
2116            leader_epoch: LeaderEpoch(7),
2117            adding_replicas: vec![],
2118            removing_replicas: vec![NodeId(1)],
2119            directories: vec![],
2120            partition_epoch: 0,
2121        }));
2122        image.apply(&MetadataRecord::V1Partition(PartitionRecord {
2123            topic: "doomed".into(),
2124            partition: 0,
2125            leader: NodeId(1),
2126            replicas: vec![NodeId(1)],
2127            isr: vec![NodeId(1)],
2128            leader_epoch: LeaderEpoch(0),
2129            adding_replicas: vec![],
2130            removing_replicas: vec![],
2131            directories: vec![],
2132            partition_epoch: 0,
2133        }));
2134
2135        // Topic config.
2136        let mut overrides = std::collections::BTreeMap::new();
2137        overrides.insert("retention.ms".to_string(), "60000".to_string());
2138        overrides.insert("segment.bytes".to_string(), "1048576".to_string());
2139        image.apply(&MetadataRecord::V1TopicConfig(TopicConfigRecord {
2140            topic: "orders".into(),
2141            overrides,
2142        }));
2143
2144        // Delete the doomed topic (clears its partition + config too).
2145        image.apply(&MetadataRecord::V1DeleteTopic(DeleteTopicRecord {
2146            name: "doomed".into(),
2147        }));
2148
2149        // SCRAM creds (one survives, one deleted).
2150        image.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
2151            user: "alice".into(),
2152            mechanism: SaslMechanism::ScramSha512,
2153            salt: vec![1; 16],
2154            stored_key: vec![2; 64],
2155            server_key: vec![3; 64],
2156            iterations: 8192,
2157        }));
2158        image.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
2159            user: "bob".into(),
2160            mechanism: SaslMechanism::ScramSha256,
2161            salt: vec![4; 16],
2162            stored_key: vec![5; 32],
2163            server_key: vec![6; 32],
2164            iterations: 4096,
2165        }));
2166        image.apply(&MetadataRecord::V1DeleteScramCredential(
2167            DeleteScramCredentialRecord {
2168                user: "bob".into(),
2169                mechanism: SaslMechanism::ScramSha256,
2170            },
2171        ));
2172
2173        // ACLs: literal + prefixed survive; a deleted one must not.
2174        let literal = AclEntry {
2175            resource_type: ResourceType::Topic,
2176            resource_name: "orders".into(),
2177            pattern_type: PatternType::Literal,
2178            principal: "User:alice".into(),
2179            host: "*".into(),
2180            operation: AclOperation::Read,
2181            permission_type: PermissionType::Allow,
2182        };
2183        let prefixed = AclEntry {
2184            resource_type: ResourceType::Group,
2185            resource_name: "cg-".into(),
2186            pattern_type: PatternType::Prefixed,
2187            principal: "User:alice".into(),
2188            host: "*".into(),
2189            operation: AclOperation::Read,
2190            permission_type: PermissionType::Allow,
2191        };
2192        let doomed_acl = AclEntry {
2193            resource_type: ResourceType::Cluster,
2194            resource_name: "kafka-cluster".into(),
2195            pattern_type: PatternType::Literal,
2196            principal: "User:eve".into(),
2197            host: "*".into(),
2198            operation: AclOperation::Alter,
2199            permission_type: PermissionType::Deny,
2200        };
2201        image.apply(&MetadataRecord::V1AccessControlEntry(literal));
2202        image.apply(&MetadataRecord::V1AccessControlEntry(prefixed));
2203        image.apply(&MetadataRecord::V1AccessControlEntry(doomed_acl));
2204        image.apply(&MetadataRecord::V1DeleteAccessControlEntry(
2205            AclEntryFilter {
2206                resource_type: Some(ResourceType::Cluster),
2207                ..AclEntryFilter::default()
2208            },
2209        ));
2210
2211        // Client quotas.
2212        image.apply(&MetadataRecord::V1ClientQuota(ClientQuotaRecord {
2213            entity: vec![
2214                QuotaEntity {
2215                    entity_type: "user".into(),
2216                    entity_name: Some("alice".into()),
2217                },
2218                QuotaEntity {
2219                    entity_type: "client-id".into(),
2220                    entity_name: Some("app1".into()),
2221                },
2222            ],
2223            config_key: "producer_byte_rate".into(),
2224            config_value: Some(1024.0),
2225        }));
2226        image.apply(&MetadataRecord::V1ClientQuota(ClientQuotaRecord {
2227            entity: vec![QuotaEntity {
2228                entity_type: "user".into(),
2229                entity_name: None,
2230            }],
2231            config_key: "consumer_byte_rate".into(),
2232            config_value: Some(2048.0),
2233        }));
2234
2235        // Delegation tokens (one survives, one deleted).
2236        let alice = KafkaPrincipal {
2237            principal_type: "User".into(),
2238            name: "alice".into(),
2239        };
2240        let bob = KafkaPrincipal {
2241            principal_type: "User".into(),
2242            name: "bob".into(),
2243        };
2244        image.apply(&MetadataRecord::V1DelegationToken(DelegationTokenRecord {
2245            token_id: "tok-1".into(),
2246            owner: alice.clone(),
2247            requester: alice,
2248            issue_timestamp_ms: 1_000,
2249            expiry_timestamp_ms: 5_000,
2250            max_timestamp_ms: 10_000,
2251            renewers: vec![bob.clone()],
2252        }));
2253        image.apply(&MetadataRecord::V1DelegationToken(DelegationTokenRecord {
2254            token_id: "tok-2".into(),
2255            owner: bob.clone(),
2256            requester: bob,
2257            issue_timestamp_ms: 1_000,
2258            expiry_timestamp_ms: 5_000,
2259            max_timestamp_ms: 10_000,
2260            renewers: vec![],
2261        }));
2262        image.apply(&MetadataRecord::V1DeleteDelegationToken(
2263            DeleteDelegationTokenRecord {
2264                token_id: "tok-2".into(),
2265            },
2266        ));
2267
2268        // KIP-584 finalized features: two live features at a non-trivial epoch
2269        // (the metadata.version re-finalize bumps the epoch past the live
2270        // count), so the trailing V1FeaturesEpoch carrier is exercised too.
2271        image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2272            name: "metadata.version".into(),
2273            level: 24,
2274        }));
2275        image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2276            name: "metadata.version".into(),
2277            level: 25,
2278        }));
2279        image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2280            name: "group.version".into(),
2281            level: 1,
2282        }));
2283        assert2::assert!(image.finalized_features_epoch() == 2);
2284
2285        let rebuilt = MetadataImage::from_records(cid, &image.to_records());
2286        assert2::assert!(rebuilt == image);
2287    }
2288
2289    /// Finalized features and their epoch must survive a `to_records` and
2290    /// `from_records` round-trip exactly. A trailing snapshot-only record
2291    /// carries the epoch verbatim, and apply does NOT recompute it by a bump.
2292    /// That epoch is the history of `UpdateFeatures` applies, so an image whose
2293    /// epoch exceeds its live feature count still reproduces exactly. This test
2294    /// guards the regression where `to_records` emitted no feature records at
2295    /// all and the snapshot path silently dropped every finalized feature.
2296    #[test]
2297    fn to_records_round_trips_features_and_epoch() {
2298        let cid = Uuid::new_v4();
2299        let mut image = MetadataImage::new(cid);
2300        // Four applies → epoch climbs -1 → 0 → 1 → 2 → 3, while only two
2301        // features remain live (metadata.version re-finalized once).
2302        image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2303            name: "metadata.version".into(),
2304            level: 24,
2305        }));
2306        image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2307            name: "metadata.version".into(),
2308            level: 25,
2309        }));
2310        image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2311            name: "group.version".into(),
2312            level: 1,
2313        }));
2314        image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2315            name: "metadata.version".into(),
2316            level: 25,
2317        }));
2318        assert2::assert!(image.finalized_features_epoch() == 3);
2319
2320        let rebuilt = MetadataImage::from_records(cid, &image.to_records());
2321        assert2::assert!(rebuilt == image);
2322        // Belt-and-suspenders beyond derived PartialEq: the two fields the
2323        // snapshot path used to lose.
2324        check!(
2325            (
2326                rebuilt.finalized_features().get("metadata.version"),
2327                rebuilt.finalized_features().get("group.version"),
2328                rebuilt.finalized_features_epoch(),
2329            ) == (Some(&25), Some(&1), 3)
2330        );
2331    }
2332
2333    /// The KIP-853 voter set and the finalized cluster `kraft.version` must
2334    /// survive `to_records` and `from_records`. If `to_records` drops both, a
2335    /// snapshot recovery or a learner install rebuilds the image with an EMPTY
2336    /// voter set and `kraft_version = 0`. That breaks `DescribeQuorum` and
2337    /// KIP-853 auto-join. The cluster `kraft_version` has no other persistence
2338    /// source, because it is not part of openraft's membership, so it can only
2339    /// round-trip through a `V1KRaftVersion` record.
2340    #[test]
2341    fn to_records_preserves_voters_and_kraft_version() {
2342        use crate::{
2343            records::{KRaftVersionRecord, VotersRecord},
2344            voters::{KRaftVersionRange, Voter, VoterEndpoint, VoterSet},
2345        };
2346
2347        let cid = Uuid::new_v4();
2348        let mut image = MetadataImage::new(cid);
2349        image.apply(&MetadataRecord::V1KRaftVersion(KRaftVersionRecord {
2350            kraft_version: 1,
2351        }));
2352        let voters = VoterSet::from_voters([
2353            Voter {
2354                id: NodeId(1),
2355                directory_id: Uuid::from_u128(1),
2356                endpoints: vec![VoterEndpoint {
2357                    name: "CONTROLLER".into(),
2358                    host: "127.0.0.1".into(),
2359                    port: 9093,
2360                }],
2361                kraft_version: KRaftVersionRange::default(),
2362            },
2363            Voter {
2364                id: NodeId(2),
2365                directory_id: Uuid::from_u128(2),
2366                endpoints: vec![VoterEndpoint {
2367                    name: "CONTROLLER".into(),
2368                    host: "127.0.0.1".into(),
2369                    port: 9094,
2370                }],
2371                kraft_version: KRaftVersionRange { min: 0, max: 1 },
2372            },
2373        ]);
2374        image.apply(&MetadataRecord::V1Voters(VotersRecord {
2375            voters: voters.clone(),
2376        }));
2377
2378        let rebuilt = MetadataImage::from_records(cid, &image.to_records());
2379
2380        assert2::assert!(rebuilt == image);
2381    }
2382
2383    fn topic(name: &str, partitions: i32) -> MetadataRecord {
2384        MetadataRecord::V1Topic(TopicRecord {
2385            name: name.into(),
2386            topic_id: Uuid::new_v4(),
2387            partitions,
2388            replication_factor: 1,
2389        })
2390    }
2391
2392    #[test]
2393    fn apply_topic_inserts() {
2394        let mut m = img();
2395        m.apply(&topic("t", 3));
2396        assert2::assert!(m.topic("t").is_some());
2397    }
2398
2399    #[test]
2400    fn apply_dir_assignment_merges_one_slot_without_clobbering_reassignment() {
2401        // Regression guard for the KIP-858 dir-assignment clobber: a directory
2402        // report applied as a delta must set only the reporting replica's slot
2403        // and leave a concurrent reassignment's `adding_replicas` intact.
2404        let mut m = img();
2405        m.apply(&topic("t", 1));
2406        m.apply(&MetadataRecord::V1Partition(PartitionRecord {
2407            topic: "t".into(),
2408            partition: 0,
2409            leader: NodeId(1),
2410            replicas: vec![NodeId(1), NodeId(2), NodeId(3)],
2411            isr: vec![NodeId(1), NodeId(2)],
2412            leader_epoch: LeaderEpoch(0),
2413            adding_replicas: vec![NodeId(3)],
2414            removing_replicas: vec![],
2415            directories: vec![uuid::Uuid::nil(), uuid::Uuid::nil(), uuid::Uuid::nil()],
2416            partition_epoch: 0,
2417        }));
2418        let dir = uuid::Uuid::from_u128(0xABCD);
2419        m.apply(&MetadataRecord::V1PartitionDirAssignment(
2420            crate::records::PartitionDirAssignmentRecord {
2421                topic: "t".into(),
2422                partition: 0,
2423                replica: NodeId(2),
2424                directory: dir,
2425            },
2426        ));
2427        let pr = m.partition("t", 0).unwrap();
2428        // Slot 1 (replica 2) set; slots 0 and 2 untouched. The delta did NOT
2429        // clobber the in-flight reassignment.
2430        assert2::assert!(
2431            *pr == PartitionRecord {
2432                topic: "t".to_string(),
2433                partition: 0,
2434                leader: NodeId(1),
2435                replicas: vec![NodeId(1), NodeId(2), NodeId(3)],
2436                isr: vec![NodeId(1), NodeId(2)],
2437                leader_epoch: krabka_ids::LeaderEpoch(0),
2438                adding_replicas: vec![NodeId(3)],
2439                removing_replicas: vec![],
2440                directories: vec![uuid::Uuid::nil(), dir, uuid::Uuid::nil()],
2441                partition_epoch: 0,
2442            }
2443        );
2444    }
2445
2446    #[test]
2447    fn apply_delete_clears_partitions() {
2448        let mut m = img();
2449        m.apply(&topic("t", 2));
2450        m.apply(&MetadataRecord::V1Partition(PartitionRecord {
2451            topic: "t".into(),
2452            partition: 0,
2453            leader: NodeId(1),
2454            replicas: vec![NodeId(1)],
2455            isr: vec![NodeId(1)],
2456            leader_epoch: LeaderEpoch(0),
2457            adding_replicas: vec![],
2458            removing_replicas: vec![],
2459            directories: vec![],
2460            partition_epoch: 0,
2461        }));
2462        m.apply(&MetadataRecord::V1Partition(PartitionRecord {
2463            topic: "t".into(),
2464            partition: 1,
2465            leader: NodeId(1),
2466            replicas: vec![NodeId(1)],
2467            isr: vec![NodeId(1)],
2468            leader_epoch: LeaderEpoch(0),
2469            adding_replicas: vec![],
2470            removing_replicas: vec![],
2471            directories: vec![],
2472            partition_epoch: 0,
2473        }));
2474        assert2::assert!(m.partitions_of("t").count() == 2);
2475        m.apply(&MetadataRecord::V1DeleteTopic(DeleteTopicRecord {
2476            name: "t".into(),
2477        }));
2478        assert2::assert!(m.topic("t").is_none());
2479        assert2::assert!(m.partitions_of("t").count() == 0);
2480    }
2481
2482    #[test]
2483    fn validate_topic_exists_rejected() {
2484        let mut m = img();
2485        m.apply(&topic("t", 1));
2486        let err = m.validate(&topic("t", 1)).unwrap_err();
2487        assert2::assert!(matches!(err, MetadataError::TopicExists(_)));
2488    }
2489
2490    /// Apply `count` partition records, with indices `0..count`, for `topic`,
2491    /// so the derived partition count of the image matches a realistic topic.
2492    /// The `validate` partition-count check reads this derived count and not
2493    /// the stored `TopicRecord.partitions`.
2494    fn apply_partitions(m: &mut MetadataImage, topic: &str, count: i32) {
2495        for p in 0..count {
2496            m.apply(&MetadataRecord::V1Partition(PartitionRecord {
2497                topic: topic.into(),
2498                partition: p,
2499                leader: NodeId(1),
2500                replicas: vec![NodeId(1)],
2501                isr: vec![NodeId(1)],
2502                leader_epoch: LeaderEpoch(0),
2503                adding_replicas: vec![],
2504                removing_replicas: vec![],
2505                directories: vec![],
2506                partition_epoch: 0,
2507            }));
2508        }
2509    }
2510
2511    #[test]
2512    fn validate_topic_partition_count_increase_allowed() {
2513        let mut m = img();
2514        m.apply(&topic("t", 1));
2515        apply_partitions(&mut m, "t", 1);
2516        let existing = m.topic("t").unwrap().clone();
2517        let updated = MetadataRecord::V1Topic(TopicRecord {
2518            name: "t".into(),
2519            topic_id: existing.topic_id,
2520            partitions: 3,
2521            replication_factor: existing.replication_factor,
2522        });
2523        assert2::assert!(m.validate(&updated).is_ok());
2524    }
2525
2526    #[test]
2527    fn validate_topic_partition_count_decrease_rejected() {
2528        let mut m = img();
2529        m.apply(&topic("t", 3));
2530        apply_partitions(&mut m, "t", 3);
2531        let existing = m.topic("t").unwrap().clone();
2532        let updated = MetadataRecord::V1Topic(TopicRecord {
2533            name: "t".into(),
2534            topic_id: existing.topic_id,
2535            partitions: 1,
2536            replication_factor: existing.replication_factor,
2537        });
2538        let err = m.validate(&updated).unwrap_err();
2539        assert2::assert!(matches!(err, MetadataError::TopicExists(_)));
2540    }
2541
2542    #[test]
2543    fn validate_delete_unknown_topic_rejected() {
2544        let m = img();
2545        let err = m
2546            .validate(&MetadataRecord::V1DeleteTopic(DeleteTopicRecord {
2547                name: "ghost".into(),
2548            }))
2549            .unwrap_err();
2550        assert2::assert!(matches!(err, MetadataError::UnknownTopic(_)));
2551    }
2552
2553    #[test]
2554    fn validate_partition_for_unknown_topic_rejected() {
2555        let m = img();
2556        let p = MetadataRecord::V1Partition(PartitionRecord {
2557            topic: "ghost".into(),
2558            partition: 0,
2559            leader: NodeId(1),
2560            replicas: vec![NodeId(1)],
2561            isr: vec![NodeId(1)],
2562            leader_epoch: LeaderEpoch(0),
2563            adding_replicas: vec![],
2564            removing_replicas: vec![],
2565            directories: vec![],
2566            partition_epoch: 0,
2567        });
2568        let err = m.validate(&p).unwrap_err();
2569        assert2::assert!(matches!(err, MetadataError::UnknownTopic(_)));
2570    }
2571
2572    #[test]
2573    fn broker_registration_is_idempotent() {
2574        let mut m = img();
2575        let b = MetadataRecord::V1BrokerRegistration(BrokerRegistrationRecord {
2576            node_id: NodeId(1),
2577            broker_epoch: 0,
2578            incarnation_id: Uuid::nil(),
2579            host: "h".into(),
2580            port: 9092,
2581            rack: None,
2582            log_dirs: vec![],
2583            fenced: false,
2584            in_controlled_shutdown: false,
2585            cordoned_log_dirs: None,
2586            endpoints: vec![],
2587            features: std::collections::BTreeMap::new(),
2588        });
2589        m.apply(&b);
2590        m.apply(&b);
2591        assert2::assert!(m.brokers().count() == 1);
2592    }
2593
2594    #[test]
2595    fn apply_topic_config_inserts() {
2596        let mut m = img();
2597        m.apply(&topic("t", 1));
2598        let mut overrides = std::collections::BTreeMap::new();
2599        overrides.insert("retention.ms".to_string(), "60000".to_string());
2600        m.apply(&MetadataRecord::V1TopicConfig(
2601            crate::records::TopicConfigRecord {
2602                topic: "t".into(),
2603                overrides: overrides.clone(),
2604            },
2605        ));
2606        assert2::assert!(m.topic_config("t") == Some(&overrides));
2607    }
2608
2609    #[test]
2610    fn apply_topic_config_replaces_previous() {
2611        let mut m = img();
2612        m.apply(&topic("t", 1));
2613
2614        let mut first = std::collections::BTreeMap::new();
2615        first.insert("retention.ms".to_string(), "60000".to_string());
2616        first.insert("segment.bytes".to_string(), "1024".to_string());
2617        m.apply(&MetadataRecord::V1TopicConfig(
2618            crate::records::TopicConfigRecord {
2619                topic: "t".into(),
2620                overrides: first,
2621            },
2622        ));
2623
2624        let mut second = std::collections::BTreeMap::new();
2625        second.insert("retention.ms".to_string(), "120000".to_string());
2626        m.apply(&MetadataRecord::V1TopicConfig(
2627            crate::records::TopicConfigRecord {
2628                topic: "t".into(),
2629                overrides: second.clone(),
2630            },
2631        ));
2632
2633        // segment.bytes is GONE — last-write-wins is authoritative.
2634        assert2::assert!(m.topic_config("t") == Some(&second));
2635    }
2636
2637    #[test]
2638    fn delete_topic_clears_configs() {
2639        let mut m = img();
2640        m.apply(&topic("t", 1));
2641        let mut overrides = std::collections::BTreeMap::new();
2642        overrides.insert("retention.ms".to_string(), "60000".to_string());
2643        m.apply(&MetadataRecord::V1TopicConfig(
2644            crate::records::TopicConfigRecord {
2645                topic: "t".into(),
2646                overrides,
2647            },
2648        ));
2649        m.apply(&MetadataRecord::V1DeleteTopic(
2650            crate::records::DeleteTopicRecord { name: "t".into() },
2651        ));
2652        assert2::assert!(m.topic_config("t").is_none());
2653    }
2654
2655    #[test]
2656    fn validate_topic_config_for_unknown_topic_rejected() {
2657        let m = img();
2658        let r = MetadataRecord::V1TopicConfig(crate::records::TopicConfigRecord {
2659            topic: "ghost".into(),
2660            overrides: std::collections::BTreeMap::new(),
2661        });
2662        let err = m.validate(&r).unwrap_err();
2663        assert2::assert!(matches!(err, MetadataError::UnknownTopic(_)));
2664    }
2665
2666    #[test]
2667    fn apply_scram_credential_stores() {
2668        let mut m = img();
2669        m.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
2670            user: "alice".into(),
2671            mechanism: krabka_security::SaslMechanism::ScramSha512,
2672            salt: vec![1; 16],
2673            stored_key: vec![2; 64],
2674            server_key: vec![3; 64],
2675            iterations: 4096,
2676        }));
2677        let got = m.scram_credential("alice", krabka_security::SaslMechanism::ScramSha512);
2678        assert2::assert!(got.map(|credential| credential.iterations) == Some(4096));
2679    }
2680
2681    #[test]
2682    fn apply_scram_credential_last_write_wins() {
2683        let mut m = img();
2684        let mech = krabka_security::SaslMechanism::ScramSha512;
2685        m.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
2686            user: "alice".into(),
2687            mechanism: mech,
2688            salt: vec![1; 16],
2689            stored_key: vec![2; 64],
2690            server_key: vec![3; 64],
2691            iterations: 4096,
2692        }));
2693        m.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
2694            user: "alice".into(),
2695            mechanism: mech,
2696            salt: vec![9; 16],
2697            stored_key: vec![9; 64],
2698            server_key: vec![9; 64],
2699            iterations: 8192,
2700        }));
2701        let got = m.scram_credential("alice", mech).unwrap();
2702        assert2::assert!((got.iterations, &got.salt) == (8192, &vec![9; 16]));
2703    }
2704
2705    #[test]
2706    fn delete_scram_credential_removes() {
2707        let mut m = img();
2708        let mech = krabka_security::SaslMechanism::ScramSha512;
2709        m.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
2710            user: "alice".into(),
2711            mechanism: mech,
2712            salt: vec![1; 16],
2713            stored_key: vec![2; 64],
2714            server_key: vec![3; 64],
2715            iterations: 4096,
2716        }));
2717        m.apply(&MetadataRecord::V1DeleteScramCredential(
2718            DeleteScramCredentialRecord {
2719                user: "alice".into(),
2720                mechanism: mech,
2721            },
2722        ));
2723        assert2::assert!(m.scram_credential("alice", mech).is_none());
2724    }
2725
2726    fn topic_read_for_alice() -> AclEntry {
2727        AclEntry {
2728            resource_type: ResourceType::Topic,
2729            resource_name: "foo".into(),
2730            pattern_type: PatternType::Literal,
2731            principal: "User:alice".into(),
2732            host: "*".into(),
2733            operation: AclOperation::Read,
2734            permission_type: PermissionType::Allow,
2735        }
2736    }
2737
2738    fn topic_prefixed_team() -> AclEntry {
2739        AclEntry {
2740            resource_type: ResourceType::Topic,
2741            resource_name: "team-".into(),
2742            pattern_type: PatternType::Prefixed,
2743            principal: "User:alice".into(),
2744            host: "*".into(),
2745            operation: AclOperation::Read,
2746            permission_type: PermissionType::Allow,
2747        }
2748    }
2749
2750    #[test]
2751    fn apply_v1_access_control_entry_literal_stores_in_literal_map() {
2752        let mut m = img();
2753        m.apply(&MetadataRecord::V1AccessControlEntry(topic_read_for_alice()));
2754        let hits: Vec<_> = m.matching_acls(ResourceType::Topic, "foo").collect();
2755        assert2::assert!(
2756            hits.iter()
2757                .map(|entry| entry.resource_name.as_str())
2758                .collect::<Vec<_>>()
2759                == vec!["foo"]
2760        );
2761    }
2762
2763    #[test]
2764    fn apply_v1_access_control_entry_replaces_identical_entry() {
2765        let mut m = img();
2766        let acl = topic_read_for_alice();
2767        m.apply(&MetadataRecord::V1AccessControlEntry(acl.clone()));
2768        m.apply(&MetadataRecord::V1AccessControlEntry(acl));
2769
2770        let hits: Vec<_> = m.matching_acls(ResourceType::Topic, "foo").collect();
2771        assert2::assert!(hits.len() == 1);
2772    }
2773
2774    #[test]
2775    fn apply_v1_access_control_entry_prefixed_stores_in_prefixed_vec() {
2776        let mut m = img();
2777        m.apply(&MetadataRecord::V1AccessControlEntry(topic_prefixed_team()));
2778        let hits: Vec<_> = m.matching_acls(ResourceType::Topic, "team-foo").collect();
2779        assert2::assert!(
2780            hits.iter()
2781                .map(|entry| entry.resource_name.as_str())
2782                .collect::<Vec<_>>()
2783                == vec!["team-"]
2784        );
2785        // Non-matching resource: empty.
2786        let none: Vec<_> = m.matching_acls(ResourceType::Topic, "other").collect();
2787        assert2::assert!(none.is_empty());
2788    }
2789
2790    #[test]
2791    fn matching_acls_combines_literal_and_prefixed() {
2792        let mut m = img();
2793        m.apply(&MetadataRecord::V1AccessControlEntry(topic_read_for_alice()));
2794        m.apply(&MetadataRecord::V1AccessControlEntry(topic_prefixed_team()));
2795        let hits_foo: Vec<_> = m.matching_acls(ResourceType::Topic, "foo").collect();
2796        let hits_team: Vec<_> = m.matching_acls(ResourceType::Topic, "team-x").collect();
2797        assert2::assert!((hits_foo.len(), hits_team.len()) == (1, 1));
2798    }
2799
2800    fn topic_wildcard_allow() -> AclEntry {
2801        AclEntry {
2802            resource_type: ResourceType::Topic,
2803            resource_name: "*".into(),
2804            pattern_type: PatternType::Literal,
2805            principal: "User:alice".into(),
2806            host: "*".into(),
2807            operation: AclOperation::Read,
2808            permission_type: PermissionType::Allow,
2809        }
2810    }
2811
2812    fn topic_wildcard_deny() -> AclEntry {
2813        AclEntry {
2814            resource_type: ResourceType::Topic,
2815            resource_name: "*".into(),
2816            pattern_type: PatternType::Literal,
2817            principal: "User:alice".into(),
2818            host: "*".into(),
2819            operation: AclOperation::Read,
2820            permission_type: PermissionType::Deny,
2821        }
2822    }
2823
2824    #[test]
2825    fn matching_acls_literal_wildcard_allow_matches_arbitrary_topic() {
2826        let mut m = img();
2827        m.apply(&MetadataRecord::V1AccessControlEntry(topic_wildcard_allow()));
2828        // An arbitrarily-named topic the grant never mentions by name.
2829        let hits: Vec<_> = m
2830            .matching_acls(ResourceType::Topic, "some-random-topic")
2831            .collect();
2832        assert2::assert!(hits == vec![&topic_wildcard_allow()]);
2833        // Requesting "*" itself must not double-count the wildcard entry.
2834        let star: Vec<_> = m.matching_acls(ResourceType::Topic, "*").collect();
2835        assert2::assert!(star.len() == 1);
2836    }
2837
2838    #[test]
2839    fn matching_acls_literal_wildcard_deny_matches_arbitrary_topic() {
2840        let mut m = img();
2841        m.apply(&MetadataRecord::V1AccessControlEntry(topic_wildcard_deny()));
2842        let hits: Vec<_> = m
2843            .matching_acls(ResourceType::Topic, "another-random-topic")
2844            .collect();
2845        assert2::assert!(hits == vec![&topic_wildcard_deny()]);
2846    }
2847
2848    #[test]
2849    fn apply_v1_delete_access_control_entry_removes_matching() {
2850        let mut m = img();
2851        m.apply(&MetadataRecord::V1AccessControlEntry(topic_read_for_alice()));
2852        m.apply(&MetadataRecord::V1AccessControlEntry(topic_prefixed_team()));
2853        let filter = AclEntryFilter {
2854            resource_type: Some(ResourceType::Topic),
2855            pattern_type: Some(PatternType::Literal),
2856            ..AclEntryFilter::default()
2857        };
2858        m.apply(&MetadataRecord::V1DeleteAccessControlEntry(filter));
2859        let hits_foo: Vec<_> = m.matching_acls(ResourceType::Topic, "foo").collect();
2860        let hits_team: Vec<_> = m.matching_acls(ResourceType::Topic, "team-x").collect();
2861        assert2::assert!((hits_foo.len(), hits_team.len()) == (0, 1));
2862    }
2863
2864    #[test]
2865    fn apply_v1_delete_access_control_entry_no_match_is_noop() {
2866        let mut m = img();
2867        m.apply(&MetadataRecord::V1AccessControlEntry(topic_read_for_alice()));
2868        let filter = AclEntryFilter {
2869            resource_type: Some(ResourceType::Group),
2870            ..AclEntryFilter::default()
2871        };
2872        m.apply(&MetadataRecord::V1DeleteAccessControlEntry(filter));
2873        let hits: Vec<_> = m.matching_acls(ResourceType::Topic, "foo").collect();
2874        assert2::assert!(hits.len() == 1);
2875    }
2876
2877    #[test]
2878    fn all_acls_returns_every_entry() {
2879        let mut m = img();
2880        m.apply(&MetadataRecord::V1AccessControlEntry(topic_read_for_alice()));
2881        m.apply(&MetadataRecord::V1AccessControlEntry(topic_prefixed_team()));
2882        assert2::assert!(m.all_acls().count() == 2);
2883    }
2884
2885    #[test]
2886    fn all_partitions_count_matches_map_size() {
2887        let mut m = img();
2888        m.apply(&topic("t", 3));
2889        for p in 0..3 {
2890            m.apply(&MetadataRecord::V1Partition(PartitionRecord {
2891                topic: "t".into(),
2892                partition: p,
2893                leader: NodeId(1),
2894                replicas: vec![NodeId(1)],
2895                isr: vec![NodeId(1)],
2896                leader_epoch: LeaderEpoch(0),
2897                adding_replicas: vec![],
2898                removing_replicas: vec![],
2899                directories: vec![],
2900                partition_epoch: 0,
2901            }));
2902        }
2903        m.apply(&topic("u", 1));
2904        m.apply(&MetadataRecord::V1Partition(PartitionRecord {
2905            topic: "u".into(),
2906            partition: 0,
2907            leader: NodeId(1),
2908            replicas: vec![NodeId(1)],
2909            isr: vec![NodeId(1)],
2910            leader_epoch: LeaderEpoch(0),
2911            adding_replicas: vec![],
2912            removing_replicas: vec![],
2913            directories: vec![],
2914            partition_epoch: 0,
2915        }));
2916        // 3 partitions for "t" + 1 for "u" = 4, one walk, no per-topic filter.
2917        assert2::assert!(m.all_partitions().count() == 4);
2918        assert2::assert!(
2919            m.all_partitions().count()
2920                == m.partitions_of("t").count() + m.partitions_of("u").count()
2921        );
2922    }
2923
2924    /// `total_partitions` sums the by-topic index across every topic. It backs
2925    /// the `to_records` snapshot tracing field, whose span is inert under the
2926    /// test subscriber. This test therefore pins its value directly. Without
2927    /// that pin, a constant-return mutant survives and no behavioral test
2928    /// catches it.
2929    #[test]
2930    fn total_partitions_sums_across_topics() {
2931        let mut m = img();
2932        // Empty image: no topics, no partitions.
2933        check!(m.total_partitions() == 0);
2934
2935        // "a" gets 2 partitions, "b" gets 1 → 3 total across the index.
2936        m.apply(&topic("a", 2));
2937        m.apply(&topic("b", 1));
2938        for (name, idx) in [("a", 0), ("a", 1), ("b", 0)] {
2939            m.apply(&MetadataRecord::V1Partition(PartitionRecord {
2940                topic: name.into(),
2941                partition: idx,
2942                leader: NodeId(1),
2943                replicas: vec![NodeId(1)],
2944                isr: vec![NodeId(1)],
2945                leader_epoch: LeaderEpoch(0),
2946                adding_replicas: vec![],
2947                removing_replicas: vec![],
2948                directories: vec![],
2949                partition_epoch: 0,
2950            }));
2951        }
2952        check!(m.total_partitions() == 3);
2953        // Matches an independent full walk, and is not the mutant constant 1.
2954        check!(m.total_partitions() == m.all_partitions().count());
2955    }
2956
2957    /// Pins the `partitions_of` ordering contract: ascending partition-index
2958    /// order regardless of apply order. The `Metadata` and
2959    /// `DescribeTopicPartitions` rows and the cursor-based pagination rely on
2960    /// it.
2961    #[test]
2962    fn partitions_of_yields_ascending_partition_index_order() {
2963        let mut m = img();
2964        m.apply(&topic("t", 3));
2965        for p in [2, 0, 1] {
2966            m.apply(&MetadataRecord::V1Partition(PartitionRecord {
2967                topic: "t".into(),
2968                partition: p,
2969                leader: NodeId(1),
2970                replicas: vec![NodeId(1)],
2971                isr: vec![NodeId(1)],
2972                leader_epoch: LeaderEpoch(0),
2973                adding_replicas: vec![],
2974                removing_replicas: vec![],
2975                directories: vec![],
2976                partition_epoch: 0,
2977            }));
2978        }
2979        let idxs: Vec<i32> = m.partitions_of("t").map(|p| p.partition).collect();
2980        assert2::assert!(idxs == vec![0, 1, 2]);
2981    }
2982
2983    /// A delete of one topic must not disturb the partitions of another topic
2984    /// in the by-topic index. `partitions_of` for the deleted topic goes
2985    /// empty, and its sibling stays intact.
2986    #[test]
2987    fn delete_topic_leaves_other_topics_partitions_intact() {
2988        let mut m = img();
2989        m.apply(&topic("doomed", 1));
2990        m.apply(&topic("survivor", 1));
2991        for name in ["doomed", "survivor"] {
2992            m.apply(&MetadataRecord::V1Partition(PartitionRecord {
2993                topic: name.into(),
2994                partition: 0,
2995                leader: NodeId(1),
2996                replicas: vec![NodeId(1)],
2997                isr: vec![NodeId(1)],
2998                leader_epoch: LeaderEpoch(0),
2999                adding_replicas: vec![],
3000                removing_replicas: vec![],
3001                directories: vec![],
3002                partition_epoch: 0,
3003            }));
3004        }
3005        m.apply(&MetadataRecord::V1DeleteTopic(DeleteTopicRecord {
3006            name: "doomed".into(),
3007        }));
3008        check!(
3009            (
3010                m.partitions_of("doomed").count(),
3011                m.topic_partition_count("doomed"),
3012                m.partitions_of("survivor").count(),
3013                m.partition("survivor", 0).is_some(),
3014                m.all_partitions().count(),
3015            ) == (0, 0, 1, true, 1)
3016        );
3017    }
3018
3019    #[test]
3020    fn reassignments_in_flight_excludes_idle_partitions() {
3021        let mut img = MetadataImage::new(uuid::Uuid::nil());
3022        img.apply(&MetadataRecord::V1Topic(TopicRecord {
3023            name: "foo".into(),
3024            topic_id: uuid::Uuid::nil(),
3025            partitions: 1,
3026            replication_factor: 3,
3027        }));
3028        img.apply(&MetadataRecord::V1Partition(PartitionRecord {
3029            topic: "foo".into(),
3030            partition: 0,
3031            leader: NodeId(1),
3032            replicas: vec![NodeId(1), NodeId(2), NodeId(3)],
3033            isr: vec![NodeId(1), NodeId(2), NodeId(3)],
3034            leader_epoch: LeaderEpoch(0),
3035            adding_replicas: vec![],
3036            removing_replicas: vec![],
3037            directories: vec![],
3038            partition_epoch: 0,
3039        }));
3040        assert2::assert!(img.reassignments_in_flight().count() == 0);
3041    }
3042
3043    #[test]
3044    fn reassignments_in_flight_returns_partitions_with_adding() {
3045        let mut img = MetadataImage::new(uuid::Uuid::nil());
3046        img.apply(&MetadataRecord::V1Topic(TopicRecord {
3047            name: "foo".into(),
3048            topic_id: uuid::Uuid::nil(),
3049            partitions: 1,
3050            replication_factor: 3,
3051        }));
3052        img.apply(&MetadataRecord::V1Partition(PartitionRecord {
3053            topic: "foo".into(),
3054            partition: 0,
3055            leader: NodeId(1),
3056            replicas: vec![NodeId(1), NodeId(2), NodeId(3), NodeId(4)],
3057            isr: vec![NodeId(1), NodeId(2), NodeId(3)],
3058            leader_epoch: LeaderEpoch(0),
3059            adding_replicas: vec![NodeId(4)],
3060            removing_replicas: vec![],
3061            directories: vec![],
3062            partition_epoch: 0,
3063        }));
3064        let rows: Vec<_> = img.reassignments_in_flight().collect();
3065        assert2::assert!(
3066            rows.iter()
3067                .map(|row| row.adding_replicas.clone())
3068                .collect::<Vec<_>>()
3069                == vec![vec![NodeId(4)]]
3070        );
3071    }
3072
3073    #[test]
3074    fn reassignments_in_flight_returns_partitions_with_removing() {
3075        let mut img = MetadataImage::new(uuid::Uuid::nil());
3076        img.apply(&MetadataRecord::V1Topic(TopicRecord {
3077            name: "foo".into(),
3078            topic_id: uuid::Uuid::nil(),
3079            partitions: 1,
3080            replication_factor: 3,
3081        }));
3082        img.apply(&MetadataRecord::V1Partition(PartitionRecord {
3083            topic: "foo".into(),
3084            partition: 0,
3085            leader: NodeId(1),
3086            replicas: vec![NodeId(1), NodeId(2), NodeId(3)],
3087            isr: vec![NodeId(1), NodeId(2), NodeId(3)],
3088            leader_epoch: LeaderEpoch(0),
3089            adding_replicas: vec![],
3090            removing_replicas: vec![NodeId(3)],
3091            directories: vec![],
3092            partition_epoch: 0,
3093        }));
3094        let rows: Vec<_> = img.reassignments_in_flight().collect();
3095        assert2::assert!(
3096            rows.iter()
3097                .map(|row| row.removing_replicas.clone())
3098                .collect::<Vec<_>>()
3099                == vec![vec![NodeId(3)]]
3100        );
3101    }
3102
3103    #[test]
3104    fn reassignments_in_flight_covers_multiple_topics() {
3105        let mut img = MetadataImage::new(uuid::Uuid::nil());
3106        for name in ["foo", "bar"] {
3107            img.apply(&MetadataRecord::V1Topic(TopicRecord {
3108                name: name.into(),
3109                topic_id: uuid::Uuid::nil(),
3110                partitions: 1,
3111                replication_factor: 3,
3112            }));
3113            img.apply(&MetadataRecord::V1Partition(PartitionRecord {
3114                topic: name.into(),
3115                partition: 0,
3116                leader: NodeId(1),
3117                replicas: vec![NodeId(1), NodeId(2), NodeId(3), NodeId(4)],
3118                isr: vec![NodeId(1), NodeId(2), NodeId(3)],
3119                leader_epoch: LeaderEpoch(0),
3120                adding_replicas: vec![NodeId(4)],
3121                removing_replicas: vec![],
3122                directories: vec![],
3123                partition_epoch: 0,
3124            }));
3125        }
3126        assert2::assert!(img.reassignments_in_flight().count() == 2);
3127    }
3128
3129    #[test]
3130    fn broker_config_set_inserts_into_image() {
3131        let mut img = MetadataImage::new(uuid::Uuid::nil());
3132        img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3133            node_id: NodeId(1),
3134            config_name: "leader.replication.throttled.rate".into(),
3135            config_value: Some("2048".into()),
3136        }));
3137        let bc = img.broker_config(NodeId(1)).expect("broker config");
3138        assert2::assert!(bc.get("leader.replication.throttled.rate") == Some(&"2048".to_string()));
3139    }
3140
3141    #[test]
3142    fn broker_config_delete_removes_from_image() {
3143        let mut img = MetadataImage::new(uuid::Uuid::nil());
3144        img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3145            node_id: NodeId(1),
3146            config_name: "leader.replication.throttled.rate".into(),
3147            config_value: Some("2048".into()),
3148        }));
3149        img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3150            node_id: NodeId(1),
3151            config_name: "leader.replication.throttled.rate".into(),
3152            config_value: None,
3153        }));
3154        let bc = img
3155            .broker_config(NodeId(1))
3156            .expect("broker_configs entry retained");
3157        assert2::assert!(bc.get("leader.replication.throttled.rate").is_none());
3158    }
3159
3160    #[test]
3161    fn broker_throttle_rate_parses_positive_value() {
3162        let mut img = MetadataImage::new(uuid::Uuid::nil());
3163        img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3164            node_id: NodeId(1),
3165            config_name: "leader.replication.throttled.rate".into(),
3166            config_value: Some("2048".into()),
3167        }));
3168        assert2::assert!(
3169            img.broker_throttle_rate(NodeId(1), ThrottleKind::Leader) == Some(bytes_per_sec(2048))
3170        );
3171
3172        img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3173            node_id: NodeId(1),
3174            config_name: "replica.alter.log.dirs.io.max.bytes.per.second".into(),
3175            config_value: Some("4096".into()),
3176        }));
3177        assert2::assert!(
3178            img.broker_throttle_rate(NodeId(1), ThrottleKind::AlterLogDirs)
3179                == Some(bytes_per_sec(4096))
3180        );
3181    }
3182
3183    /// The config value is a `String` holding Kafka's `int64` quota, and the
3184    /// quantity stores `f64`. Every magnitude below 2^53 round-trips exactly,
3185    /// which covers the whole range the config key can express in practice.
3186    /// This test pins that, so the accessor is not quietly lossy at the top
3187    /// end.
3188    #[test]
3189    fn broker_throttle_rate_round_trips_large_quotas_exactly() {
3190        for raw in ["0", "1", "1073741824", "9007199254740992"] {
3191            let mut img = MetadataImage::new(uuid::Uuid::nil());
3192            img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3193                node_id: NodeId(7),
3194                config_name: "follower.replication.throttled.rate".into(),
3195                config_value: Some(raw.into()),
3196            }));
3197            let rate = img
3198                .broker_throttle_rate(NodeId(7), ThrottleKind::Follower)
3199                .expect("configured rate");
3200            assert2::check!(rate.bytes_per_sec_i64().to_string() == raw);
3201        }
3202    }
3203
3204    #[test]
3205    fn broker_throttle_rate_returns_none_for_negative_one() {
3206        let mut img = MetadataImage::new(uuid::Uuid::nil());
3207        img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3208            node_id: NodeId(1),
3209            config_name: "leader.replication.throttled.rate".into(),
3210            config_value: Some("-1".into()),
3211        }));
3212        assert2::assert!(
3213            img.broker_throttle_rate(NodeId(1), ThrottleKind::Leader)
3214                .is_none()
3215        );
3216    }
3217
3218    #[test]
3219    fn broker_throttle_rate_uses_cluster_default() {
3220        let mut img = MetadataImage::new(uuid::Uuid::nil());
3221        img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3222            node_id: DEFAULT_BROKER_CONFIG_NODE_ID,
3223            config_name: "leader.replication.throttled.rate".into(),
3224            config_value: Some("2048".into()),
3225        }));
3226
3227        assert2::assert!(
3228            img.default_broker_config()
3229                .and_then(|configs| configs.get("leader.replication.throttled.rate"))
3230                .map(String::as_str)
3231                == Some("2048")
3232        );
3233        assert2::assert!(
3234            img.broker_throttle_rate(NodeId(7), ThrottleKind::Leader) == Some(bytes_per_sec(2048))
3235        );
3236    }
3237
3238    #[test]
3239    fn per_broker_disabled_rate_overrides_cluster_default() {
3240        let mut img = MetadataImage::new(uuid::Uuid::nil());
3241        for (node_id, value) in [(DEFAULT_BROKER_CONFIG_NODE_ID, "2048"), (NodeId(7), "-1")] {
3242            img.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3243                node_id,
3244                config_name: "leader.replication.throttled.rate".into(),
3245                config_value: Some(value.into()),
3246            }));
3247        }
3248
3249        assert2::assert!(
3250            img.broker_throttle_rate(NodeId(7), ThrottleKind::Leader)
3251                .is_none()
3252        );
3253    }
3254
3255    #[test]
3256    fn client_quota_apply_inserts_canonicalized() {
3257        let mut img = MetadataImage::new(uuid::Uuid::nil());
3258        // Input order: (user, client-id) — should canonicalize to (client-id, user).
3259        img.apply(&MetadataRecord::V1ClientQuota(ClientQuotaRecord {
3260            entity: vec![
3261                QuotaEntity {
3262                    entity_type: "user".into(),
3263                    entity_name: Some("alice".into()),
3264                },
3265                QuotaEntity {
3266                    entity_type: "client-id".into(),
3267                    entity_name: Some("app1".into()),
3268                },
3269            ],
3270            config_key: "producer_byte_rate".into(),
3271            config_value: Some(1024.0),
3272        }));
3273        let key: EntityKey = vec![
3274            ("client-id".into(), Some("app1".into())),
3275            ("user".into(), Some("alice".into())),
3276        ];
3277        let configs = img
3278            .client_quotas()
3279            .get(&key)
3280            .expect("entry under canonical key");
3281        assert2::assert!(configs.get("producer_byte_rate") == Some(&1024.0));
3282    }
3283
3284    #[test]
3285    fn client_quota_apply_delete_removes_key() {
3286        let mut img = MetadataImage::new(uuid::Uuid::nil());
3287        img.apply(&MetadataRecord::V1ClientQuota(ClientQuotaRecord {
3288            entity: vec![QuotaEntity {
3289                entity_type: "user".into(),
3290                entity_name: Some("alice".into()),
3291            }],
3292            config_key: "producer_byte_rate".into(),
3293            config_value: Some(1024.0),
3294        }));
3295        img.apply(&MetadataRecord::V1ClientQuota(ClientQuotaRecord {
3296            entity: vec![QuotaEntity {
3297                entity_type: "user".into(),
3298                entity_name: Some("alice".into()),
3299            }],
3300            config_key: "producer_byte_rate".into(),
3301            config_value: None,
3302        }));
3303        let key: EntityKey = vec![("user".into(), Some("alice".into()))];
3304        let configs = img.client_quotas().get(&key).expect("entry retained");
3305        assert2::assert!(configs.get("producer_byte_rate").is_none());
3306    }
3307
3308    #[test]
3309    fn client_quota_default_entity_uses_none_name() {
3310        let mut img = MetadataImage::new(uuid::Uuid::nil());
3311        img.apply(&MetadataRecord::V1ClientQuota(ClientQuotaRecord {
3312            entity: vec![QuotaEntity {
3313                entity_type: "user".into(),
3314                entity_name: None,
3315            }],
3316            config_key: "producer_byte_rate".into(),
3317            config_value: Some(512.0),
3318        }));
3319        let key: EntityKey = vec![("user".into(), None)];
3320        assert2::assert!(img.client_quotas().contains_key(&key));
3321    }
3322
3323    #[test]
3324    fn canonicalize_sorts_alphabetically_by_entity_type() {
3325        let input = vec![
3326            ("user".to_string(), Some("alice".to_string())),
3327            ("client-id".to_string(), Some("app1".to_string())),
3328        ];
3329        let canon = canonicalize_entity(input);
3330        assert2::assert!(
3331            canon
3332                .iter()
3333                .map(|(kind, _)| kind.as_str())
3334                .collect::<Vec<_>>()
3335                == vec!["client-id", "user"]
3336        );
3337    }
3338
3339    #[test]
3340    fn scram_credentials_users_returns_distinct_users() {
3341        let mut img = MetadataImage::new(uuid::Uuid::nil());
3342        img.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
3343            user: "alice".into(),
3344            mechanism: SaslMechanism::ScramSha512,
3345            salt: vec![1, 2, 3],
3346            stored_key: vec![4, 5, 6],
3347            server_key: vec![7, 8, 9],
3348            iterations: 4096,
3349        }));
3350        img.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
3351            user: "bob".into(),
3352            mechanism: SaslMechanism::ScramSha512,
3353            salt: vec![1, 2, 3],
3354            stored_key: vec![4, 5, 6],
3355            server_key: vec![7, 8, 9],
3356            iterations: 4096,
3357        }));
3358        let mut users = img.scram_credentials_users();
3359        users.sort();
3360        assert2::assert!(users == vec!["alice".to_string(), "bob".to_string()]);
3361    }
3362
3363    fn principal(pt: &str, name: &str) -> KafkaPrincipal {
3364        KafkaPrincipal {
3365            principal_type: pt.into(),
3366            name: name.into(),
3367        }
3368    }
3369
3370    fn dt_record(
3371        token_id: &str,
3372        owner: KafkaPrincipal,
3373        expiry_timestamp_ms: i64,
3374        renewers: Vec<KafkaPrincipal>,
3375    ) -> MetadataRecord {
3376        MetadataRecord::V1DelegationToken(DelegationTokenRecord {
3377            token_id: token_id.into(),
3378            owner: owner.clone(),
3379            requester: owner,
3380            issue_timestamp_ms: 1_000,
3381            expiry_timestamp_ms,
3382            max_timestamp_ms: 10_000,
3383            renewers,
3384        })
3385    }
3386
3387    #[test]
3388    fn apply_delegation_token_insert_and_replace() {
3389        let mut img = MetadataImage::new(uuid::Uuid::nil());
3390        let alice = principal("User", "alice");
3391
3392        img.apply(&dt_record("tok-1", alice.clone(), 5_000, vec![]));
3393        let got = img.delegation_token_by_id("tok-1").expect("token present");
3394        assert2::assert!(got.expiry_timestamp_ms == 5_000);
3395        assert2::assert!(&got.owner == &alice);
3396
3397        // Same token_id, different expiry — replace, not duplicate.
3398        img.apply(&dt_record("tok-1", alice.clone(), 7_500, vec![]));
3399        let got = img.delegation_token_by_id("tok-1").expect("token present");
3400        assert2::assert!(got.expiry_timestamp_ms == 7_500);
3401        assert2::assert!(img.all_delegation_tokens().count() == 1);
3402    }
3403
3404    #[test]
3405    fn apply_delete_delegation_token_removes_from_image() {
3406        let mut img = MetadataImage::new(uuid::Uuid::nil());
3407        let alice = principal("User", "alice");
3408
3409        img.apply(&dt_record("tok-1", alice, 5_000, vec![]));
3410        assert2::assert!(img.delegation_token_by_id("tok-1").is_some());
3411
3412        img.apply(&MetadataRecord::V1DeleteDelegationToken(
3413            DeleteDelegationTokenRecord {
3414                token_id: "tok-1".into(),
3415            },
3416        ));
3417        assert2::assert!(img.delegation_token_by_id("tok-1").is_none());
3418        assert2::assert!(img.all_delegation_tokens().count() == 0);
3419    }
3420
3421    #[test]
3422    fn delegation_token_by_hmac_recomputes_each_tokens_hmac() {
3423        let mut img = MetadataImage::new(uuid::Uuid::nil());
3424        img.apply(&dt_record(
3425            "tok-a",
3426            principal("User", "alice"),
3427            5_000,
3428            vec![],
3429        ));
3430        img.apply(&dt_record("tok-b", principal("User", "bob"), 5_000, vec![]));
3431        let hmac_b = krabka_security::compute_token_hmac(b"key", "tok-b");
3432
3433        // (secret key, presented hmac, token found)
3434        let cases: [(&[u8], &[u8], Option<&str>); 3] = [
3435            (b"key", &hmac_b, Some("tok-b")),
3436            // The same HMAC under another key names no token.
3437            (b"other", &hmac_b, None),
3438            (b"key", &[0xCC; 64], None),
3439        ];
3440        for (secret_key, hmac, expected) in cases {
3441            check!(
3442                img.delegation_token_by_hmac(secret_key, hmac)
3443                    .map(|t| t.token_id.as_str())
3444                    == expected
3445            );
3446        }
3447    }
3448
3449    #[test]
3450    fn delegation_tokens_by_owner_filters_correctly() {
3451        let mut img = MetadataImage::new(uuid::Uuid::nil());
3452        let alice = principal("User", "alice");
3453        let bob = principal("User", "bob");
3454
3455        img.apply(&dt_record("a-1", alice.clone(), 5_000, vec![]));
3456        img.apply(&dt_record("a-2", alice.clone(), 6_000, vec![bob.clone()]));
3457        img.apply(&dt_record("b-1", bob.clone(), 7_000, vec![]));
3458
3459        let alice_tokens = img.delegation_tokens_by_owner(&alice);
3460        assert2::assert!(alice_tokens.len() == 2);
3461        assert2::assert!(alice_tokens.iter().all(|token| token.owner == alice));
3462
3463        let bob_tokens = img.delegation_tokens_by_owner(&bob);
3464        assert2::assert!(
3465            bob_tokens
3466                .iter()
3467                .map(|token| token.token_id.as_str())
3468                .collect::<Vec<_>>()
3469                == vec!["b-1"]
3470        );
3471
3472        // visible_to: bob owns b-1 and is renewer on a-2 → 2 tokens.
3473        let bob_visible = img.delegation_tokens_visible_to(&bob);
3474        assert2::assert!(bob_visible.len() == 2);
3475        let mut ids: Vec<&str> = bob_visible.iter().map(|t| t.token_id.as_str()).collect();
3476        ids.sort_unstable();
3477        assert2::assert!(ids == vec!["a-2", "b-1"]);
3478    }
3479
3480    /// Kafka's `TokenInformation.ownerOrRenewer` counts the requester too, so
3481    /// an administrator sees a token it created on another owner's behalf.
3482    #[test]
3483    fn delegation_tokens_are_visible_to_their_requester() {
3484        let mut img = MetadataImage::new(uuid::Uuid::nil());
3485        let alice = principal("User", "alice");
3486        let admin = principal("User", "admin");
3487        img.apply(&MetadataRecord::V1DelegationToken(DelegationTokenRecord {
3488            requester: admin.clone(),
3489            ..match dt_record("a-1", alice.clone(), 5_000, vec![]) {
3490                MetadataRecord::V1DelegationToken(record) => record,
3491                _ => unreachable!("dt_record builds a token record"),
3492            }
3493        }));
3494        img.apply(&dt_record("a-2", alice.clone(), 5_000, vec![]));
3495
3496        // (principal, visible token ids)
3497        let cases = [
3498            (&admin, vec!["a-1"]),
3499            (&alice, vec!["a-1", "a-2"]),
3500            (&principal("User", "carol"), vec![]),
3501        ];
3502        for (who, expected) in cases {
3503            let mut ids: Vec<&str> = img
3504                .delegation_tokens_visible_to(who)
3505                .iter()
3506                .map(|t| t.token_id.as_str())
3507                .collect();
3508            ids.sort_unstable();
3509            check!(ids == expected, "{who}");
3510        }
3511    }
3512
3513    #[test]
3514    fn applies_voters_and_version() {
3515        let mut image = MetadataImage::default();
3516        image.apply(&MetadataRecord::V1KRaftVersion(
3517            crate::records::KRaftVersionRecord { kraft_version: 1 },
3518        ));
3519        image.apply(&MetadataRecord::V1Voters(crate::records::VotersRecord {
3520            voters: crate::voters::VoterSet::from_voters([crate::voters::Voter {
3521                id: NodeId(1),
3522                directory_id: uuid::Uuid::nil(),
3523                endpoints: vec![],
3524                kraft_version: crate::voters::KRaftVersionRange::default(),
3525            }]),
3526        }));
3527        assert2::assert!(image.kraft_version() == 1);
3528        assert2::assert!(image.voters().contains(NodeId(1)));
3529    }
3530
3531    #[test]
3532    fn scram_credentials_for_user_returns_pairs() {
3533        let mut img = MetadataImage::new(uuid::Uuid::nil());
3534        img.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
3535            user: "alice".into(),
3536            mechanism: SaslMechanism::ScramSha512,
3537            salt: vec![1, 2, 3],
3538            stored_key: vec![4, 5, 6],
3539            server_key: vec![7, 8, 9],
3540            iterations: 8192,
3541        }));
3542        let pairs = img.scram_credentials_for_user("alice");
3543        assert2::assert!(pairs == vec![(SaslMechanism::ScramSha512, 8192)]);
3544        assert2::assert!(img.scram_credentials_for_user("ghost").is_empty());
3545    }
3546
3547    #[test]
3548    fn finalized_metadata_version_reads_feature_map() {
3549        use crate::records::FeatureLevelRecord;
3550        let mut m = img();
3551        assert2::assert!(m.finalized_metadata_version() == None);
3552        m.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
3553            name: "metadata.version".into(),
3554            level: 19,
3555        }));
3556        assert2::assert!(m.finalized_metadata_version() == Some(19));
3557    }
3558
3559    #[test]
3560    fn min_required_metadata_version_baseline_is_min() {
3561        use crate::metadata_version::METADATA_VERSION_MIN;
3562        let m = img();
3563        assert2::assert!(m.min_required_metadata_version() == METADATA_VERSION_MIN);
3564    }
3565
3566    #[test]
3567    fn client_metrics_config_apply_and_clear() {
3568        use crate::records::ClientMetricsConfigRecord;
3569        let mut img = MetadataImage::new(uuid::Uuid::nil());
3570        let mut cfgs = std::collections::BTreeMap::new();
3571        cfgs.insert("interval.ms".to_string(), "60000".to_string());
3572        img.apply(&MetadataRecord::V1ClientMetricsConfig(
3573            ClientMetricsConfigRecord {
3574                name: "sub-a".into(),
3575                configs: cfgs,
3576            },
3577        ));
3578        assert2::assert!(
3579            img.client_metrics_config("sub-a")
3580                .and_then(|m| m.get("interval.ms"))
3581                .map(String::as_str)
3582                == Some("60000")
3583        );
3584        assert2::assert!(img.client_metrics_subscriptions().count() == 1);
3585        img.apply(&MetadataRecord::V1ClientMetricsConfig(
3586            ClientMetricsConfigRecord {
3587                name: "sub-a".into(),
3588                configs: std::collections::BTreeMap::new(),
3589            },
3590        ));
3591        assert2::assert!(img.client_metrics_config("sub-a").is_none());
3592        assert2::assert!(img.client_metrics_subscriptions().count() == 0);
3593    }
3594
3595    #[test]
3596    fn group_config_apply_snapshot_and_clear() {
3597        let mut image = MetadataImage::new(Uuid::nil());
3598        let record = MetadataRecord::V1GroupConfig(GroupConfigRecord {
3599            group_id: "streams-app".into(),
3600            configs: BTreeMap::from([("streams.num.standby.replicas".into(), "1".into())]),
3601        });
3602        image.apply(&record);
3603        assert2::assert!(
3604            image
3605                .group_config("streams-app")
3606                .and_then(|configs| configs.get("streams.num.standby.replicas"))
3607                .map(String::as_str)
3608                == Some("1")
3609        );
3610        assert2::assert!(image.to_records().iter().any(|item| item == &record));
3611
3612        image.apply(&MetadataRecord::V1GroupConfig(GroupConfigRecord {
3613            group_id: "streams-app".into(),
3614            configs: BTreeMap::new(),
3615        }));
3616        assert2::assert!(image.group_config("streams-app").is_none());
3617    }
3618
3619    #[test]
3620    fn producer_id_boundary_is_monotonic_and_survives_snapshot() {
3621        let mut image = MetadataImage::new(Uuid::nil());
3622        image.apply(&MetadataRecord::V1ProducerIds(ProducerIdsRecord {
3623            broker_id: NodeId(1),
3624            broker_epoch: 4,
3625            next_producer_id: 1_000,
3626        }));
3627        image.apply(&MetadataRecord::V1ProducerIds(ProducerIdsRecord {
3628            broker_id: NodeId(2),
3629            broker_epoch: 8,
3630            next_producer_id: 2_000,
3631        }));
3632        image.apply(&MetadataRecord::V1ProducerIds(ProducerIdsRecord {
3633            broker_id: NodeId(1),
3634            broker_epoch: 4,
3635            next_producer_id: 1_000,
3636        }));
3637        assert2::assert!(image.next_producer_id() == 2_000);
3638
3639        let restored = MetadataImage::from_records(Uuid::nil(), &image.to_records());
3640        assert2::assert!(restored.next_producer_id() == 2_000);
3641    }
3642
3643    /// KIP-1276: an ACL whose host is a CIDR block raises the floor to
3644    /// `4.4-IV1`, and a downgrade below it removes exactly those ACLs.
3645    #[test]
3646    fn cidr_acl_hosts_follow_the_kip_1276_gate() {
3647        use crate::{
3648            AclEntryFilter,
3649            acl::{AclOperation, PermissionType},
3650            metadata_version::{CIDR_ACL_MIN_LEVEL, METADATA_VERSION_MIN},
3651        };
3652
3653        let acl = |host: &str| AclEntry {
3654            resource_type: ResourceType::Topic,
3655            resource_name: "orders".into(),
3656            pattern_type: PatternType::Literal,
3657            principal: "User:alice".into(),
3658            host: host.into(),
3659            operation: AclOperation::Read,
3660            permission_type: PermissionType::Allow,
3661        };
3662        let delete = |host: &str| {
3663            let entry = acl(host);
3664            MetadataRecord::V1DeleteAccessControlEntry(AclEntryFilter {
3665                resource_type: Some(entry.resource_type),
3666                resource_name: Some(entry.resource_name),
3667                pattern_type: Some(entry.pattern_type),
3668                principal: Some(entry.principal),
3669                host: Some(entry.host),
3670                operation: Some(entry.operation),
3671                permission_type: Some(entry.permission_type),
3672            })
3673        };
3674        for (case, hosts, floor, cidr_hosts) in [
3675            ("no ACLs", vec![], METADATA_VERSION_MIN, vec![]),
3676            (
3677                "wildcard and address",
3678                vec!["*", "10.0.0.1"],
3679                METADATA_VERSION_MIN,
3680                vec![],
3681            ),
3682            (
3683                "IPv4 CIDR",
3684                vec!["*", "10.0.0.0/8"],
3685                CIDR_ACL_MIN_LEVEL,
3686                vec!["10.0.0.0/8"],
3687            ),
3688            (
3689                "IPv6 CIDR",
3690                vec!["2001:db8::/32"],
3691                CIDR_ACL_MIN_LEVEL,
3692                vec!["2001:db8::/32"],
3693            ),
3694        ] {
3695            let mut image = img();
3696            for host in &hosts {
3697                image.apply(&MetadataRecord::V1AccessControlEntry(acl(host)));
3698            }
3699            let want_cleanup: Vec<_> = cidr_hosts.into_iter().map(delete).collect();
3700            assert2::check!(
3701                (
3702                    image.min_required_metadata_version(),
3703                    image.metadata_version_downgrade_records(CIDR_ACL_MIN_LEVEL),
3704                    image.metadata_version_downgrade_records(CIDR_ACL_MIN_LEVEL - 1),
3705                ) == (floor, vec![], want_cleanup.clone()),
3706                "case {case}"
3707            );
3708            for record in &want_cleanup {
3709                image.apply(record);
3710            }
3711            assert2::check!(
3712                image.min_required_metadata_version() == METADATA_VERSION_MIN,
3713                "case {case} after cleanup"
3714            );
3715        }
3716    }
3717
3718    #[test]
3719    fn min_required_metadata_version_rises_with_scram_and_tokens() {
3720        use krabka_security::{KafkaPrincipal, SaslMechanism};
3721
3722        use crate::metadata_version::{DELEGATION_TOKEN_MIN_LEVEL, SCRAM_MIN_LEVEL};
3723        let mut m = img();
3724        m.apply(&MetadataRecord::V1ScramCredential(
3725            crate::records::ScramCredentialRecord {
3726                user: "alice".into(),
3727                mechanism: SaslMechanism::ScramSha512,
3728                salt: vec![1; 16],
3729                stored_key: vec![2; 64],
3730                server_key: vec![3; 64],
3731                iterations: 4096,
3732            },
3733        ));
3734        assert2::assert!(m.min_required_metadata_version() == SCRAM_MIN_LEVEL);
3735        m.apply(&MetadataRecord::V1DelegationToken(
3736            crate::records::DelegationTokenRecord {
3737                token_id: "t1".into(),
3738                owner: KafkaPrincipal {
3739                    principal_type: "User".into(),
3740                    name: "alice".into(),
3741                },
3742                requester: KafkaPrincipal {
3743                    principal_type: "User".into(),
3744                    name: "alice".into(),
3745                },
3746                issue_timestamp_ms: 1,
3747                expiry_timestamp_ms: 5,
3748                max_timestamp_ms: 10,
3749                renewers: vec![],
3750            },
3751        ));
3752        assert2::assert!(m.min_required_metadata_version() == DELEGATION_TOKEN_MIN_LEVEL);
3753    }
3754
3755    #[test]
3756    fn downgrade_projection_removes_scram_and_tokens_below_their_record_gates() {
3757        use krabka_security::{KafkaPrincipal, SaslMechanism};
3758
3759        use crate::{
3760            metadata_version::{METADATA_VERSION_MIN, SCRAM_MIN_LEVEL},
3761            records::{
3762                DelegationTokenRecord, DeleteDelegationTokenRecord, DeleteScramCredentialRecord,
3763                ScramCredentialRecord,
3764            },
3765        };
3766
3767        let mut image = img();
3768        image.apply(&MetadataRecord::V1ScramCredential(ScramCredentialRecord {
3769            user: "alice".into(),
3770            mechanism: SaslMechanism::ScramSha512,
3771            salt: vec![1; 16],
3772            stored_key: vec![2; 64],
3773            server_key: vec![3; 64],
3774            iterations: 4096,
3775        }));
3776        image.apply(&MetadataRecord::V1DelegationToken(DelegationTokenRecord {
3777            token_id: "token-1".into(),
3778            owner: KafkaPrincipal {
3779                principal_type: "User".into(),
3780                name: "alice".into(),
3781            },
3782            requester: KafkaPrincipal {
3783                principal_type: "User".into(),
3784                name: "alice".into(),
3785            },
3786            issue_timestamp_ms: 1,
3787            expiry_timestamp_ms: 5,
3788            max_timestamp_ms: 10,
3789            renewers: vec![],
3790        }));
3791
3792        // At the level that introduced each feature the state is still
3793        // supported, so nothing is cleaned up. Read as `target <= MIN_LEVEL`,
3794        // a downgrade *to* the introducing level deletes the very credentials
3795        // and tokens that level exists to support.
3796        assert2::assert!(
3797            image
3798                .metadata_version_downgrade_records(SCRAM_MIN_LEVEL)
3799                .iter()
3800                .all(|r| !matches!(r, MetadataRecord::V1DeleteScramCredential(_)))
3801        );
3802        assert2::assert!(
3803            image
3804                .metadata_version_downgrade_records(
3805                    crate::metadata_version::DELEGATION_TOKEN_MIN_LEVEL
3806                )
3807                .iter()
3808                .all(|r| !matches!(r, MetadataRecord::V1DeleteDelegationToken(_)))
3809        );
3810
3811        let cleanup = image.metadata_version_downgrade_records(SCRAM_MIN_LEVEL - 1);
3812        assert2::assert!(
3813            cleanup
3814                == vec![
3815                    MetadataRecord::V1DeleteScramCredential(DeleteScramCredentialRecord {
3816                        user: "alice".into(),
3817                        mechanism: SaslMechanism::ScramSha512,
3818                    }),
3819                    MetadataRecord::V1DeleteDelegationToken(DeleteDelegationTokenRecord {
3820                        token_id: "token-1".into(),
3821                    }),
3822                ]
3823        );
3824
3825        for record in &cleanup {
3826            image.apply(record);
3827        }
3828        assert2::assert!(image.scram_credentials_for_user("alice").is_empty());
3829        assert2::assert!(image.all_delegation_tokens().next().is_none());
3830        assert2::assert!(image.min_required_metadata_version() == METADATA_VERSION_MIN);
3831    }
3832
3833    #[test]
3834    fn downgrade_projection_clears_directory_fields_below_kip_858() {
3835        use crate::metadata_version::DIRECTORY_ASSIGNMENT_MIN_LEVEL;
3836
3837        let directory = Uuid::from_u128(0xD1);
3838        let mut image = img();
3839        let broker = BrokerRegistrationRecord {
3840            node_id: NodeId(1),
3841            broker_epoch: 7,
3842            incarnation_id: Uuid::from_u128(1),
3843            host: "broker-1".into(),
3844            port: 9092,
3845            rack: None,
3846            endpoints: vec![],
3847            log_dirs: vec![directory],
3848            fenced: false,
3849            in_controlled_shutdown: false,
3850            cordoned_log_dirs: None,
3851            features: std::collections::BTreeMap::new(),
3852        };
3853        image.apply(&MetadataRecord::V1BrokerRegistration(broker.clone()));
3854        image.apply(&MetadataRecord::V1Partition(PartitionRecord {
3855            topic: "orders".into(),
3856            partition: 0,
3857            leader: NodeId(1),
3858            replicas: vec![NodeId(1), NodeId(2)],
3859            isr: vec![NodeId(1), NodeId(2)],
3860            directories: vec![directory, crate::directory_id::MIGRATING],
3861            ..Default::default()
3862        }));
3863
3864        assert2::assert!(
3865            image
3866                .metadata_version_downgrade_records(DIRECTORY_ASSIGNMENT_MIN_LEVEL)
3867                .is_empty()
3868        );
3869        let cleanup = image.metadata_version_downgrade_records(DIRECTORY_ASSIGNMENT_MIN_LEVEL - 1);
3870        let mut projected_broker = broker;
3871        projected_broker.log_dirs.clear();
3872        assert2::assert!(
3873            cleanup
3874                == vec![
3875                    MetadataRecord::V1BrokerRegistration(projected_broker),
3876                    MetadataRecord::V1PartitionDirAssignment(
3877                        crate::records::PartitionDirAssignmentRecord {
3878                            topic: "orders".into(),
3879                            partition: 0,
3880                            replica: NodeId(1),
3881                            directory: crate::directory_id::MIGRATING,
3882                        },
3883                    ),
3884                ]
3885        );
3886
3887        for record in &cleanup {
3888            image.apply(record);
3889        }
3890        assert2::assert!(
3891            image.partition("orders", 0).expect("partition").directories
3892                == vec![crate::directory_id::MIGRATING; 2]
3893        );
3894        assert2::assert!(image.broker(NodeId(1)).expect("broker").log_dirs.is_empty());
3895    }
3896
3897    /// KIP-1066 cordoned log dirs exist from `4.3-IV0`: a downgrade below it
3898    /// re-registers the broker without them, and below `3.7-IV2` without its
3899    /// log dirs as well.
3900    #[test]
3901    fn downgrade_projection_clears_cordoned_log_dirs_below_kip_1066() {
3902        use crate::metadata_version::{
3903            CORDONED_LOG_DIRS_MIN_LEVEL, DIRECTORY_ASSIGNMENT_MIN_LEVEL,
3904        };
3905
3906        let directory = Uuid::from_u128(0xD1);
3907        let broker = BrokerRegistrationRecord {
3908            node_id: NodeId(1),
3909            broker_epoch: 7,
3910            incarnation_id: Uuid::from_u128(1),
3911            host: "broker-1".into(),
3912            port: 9092,
3913            rack: None,
3914            endpoints: vec![],
3915            log_dirs: vec![directory],
3916            fenced: false,
3917            in_controlled_shutdown: false,
3918            cordoned_log_dirs: Some(vec![directory]),
3919            features: std::collections::BTreeMap::new(),
3920        };
3921        let mut image = img();
3922        image.apply(&MetadataRecord::V1BrokerRegistration(broker.clone()));
3923
3924        let uncordoned = BrokerRegistrationRecord {
3925            cordoned_log_dirs: None,
3926            ..broker.clone()
3927        };
3928        let without_dirs = BrokerRegistrationRecord {
3929            log_dirs: vec![],
3930            ..uncordoned.clone()
3931        };
3932        for (target, want) in [
3933            (CORDONED_LOG_DIRS_MIN_LEVEL, vec![]),
3934            (
3935                CORDONED_LOG_DIRS_MIN_LEVEL - 1,
3936                vec![MetadataRecord::V1BrokerRegistration(uncordoned)],
3937            ),
3938            (
3939                DIRECTORY_ASSIGNMENT_MIN_LEVEL - 1,
3940                vec![MetadataRecord::V1BrokerRegistration(without_dirs)],
3941            ),
3942        ] {
3943            assert2::check!(
3944                image.metadata_version_downgrade_records(target) == want,
3945                "target {target}"
3946            );
3947        }
3948    }
3949
3950    #[test]
3951    fn topic_by_id_resolves_and_drops_on_delete() {
3952        use crate::records::{MetadataRecord, TopicRecord};
3953        let mut img = MetadataImage::new(Uuid::nil());
3954        let id = Uuid::from_u128(0x1234_5678_9abc_def0_1122_3344_5566_7788);
3955        img.apply(&MetadataRecord::V1Topic(TopicRecord {
3956            name: "orders".into(),
3957            topic_id: id,
3958            partitions: 1,
3959            replication_factor: 1,
3960        }));
3961
3962        // Resolves by id to the same record `topic(name)` returns.
3963        assert2::assert!(img.topic_by_id(&id).map(|t| t.name.as_str()) == Some("orders"));
3964        assert2::assert!(img.topic_name_by_id(&id) == Some("orders"));
3965
3966        // After delete the id no longer resolves and the name index is gone.
3967        img.apply(&MetadataRecord::V1DeleteTopic(
3968            crate::records::DeleteTopicRecord {
3969                name: "orders".into(),
3970            },
3971        ));
3972        assert2::assert!(img.topic_by_id(&id).is_none());
3973        assert2::assert!(img.topic_name_by_id(&id).is_none());
3974    }
3975
3976    #[test]
3977    fn broker_epoch_reads_back_registered_epoch() {
3978        let mut image = MetadataImage::new(Uuid::nil());
3979        image.apply(&MetadataRecord::V1BrokerRegistration(
3980            BrokerRegistrationRecord {
3981                node_id: NodeId(5),
3982                broker_epoch: 99,
3983                incarnation_id: Uuid::nil(),
3984                host: "h".into(),
3985                port: 9092,
3986                rack: None,
3987                log_dirs: vec![],
3988                fenced: false,
3989                in_controlled_shutdown: false,
3990                cordoned_log_dirs: None,
3991                endpoints: vec![],
3992                features: std::collections::BTreeMap::new(),
3993            },
3994        ));
3995        assert2::assert!(image.broker_epoch(NodeId(5)) == Some(99));
3996        assert2::assert!(image.broker_epoch(NodeId(404)) == None);
3997    }
3998
3999    // --- mutation-coverage tests --------------------------------------------
4000
4001    #[test]
4002    fn fresh_image_accessors() {
4003        let id = Uuid::from_u128(0xABCD);
4004        let image = MetadataImage::new(id);
4005        assert2::assert!(image.cluster_id() == id);
4006        assert2::assert!(image.kraft_version() == 0);
4007    }
4008
4009    #[test]
4010    fn broker_lookup_returns_registered_record() {
4011        let mut image = MetadataImage::new(Uuid::nil());
4012        image.apply(&MetadataRecord::V1BrokerRegistration(
4013            BrokerRegistrationRecord {
4014                node_id: NodeId(7),
4015                broker_epoch: 1,
4016                incarnation_id: Uuid::nil(),
4017                host: "h".into(),
4018                port: 9092,
4019                rack: None,
4020                log_dirs: vec![],
4021                fenced: false,
4022                in_controlled_shutdown: false,
4023                cordoned_log_dirs: None,
4024                endpoints: vec![],
4025                features: std::collections::BTreeMap::new(),
4026            },
4027        ));
4028        let b = image
4029            .broker(NodeId(7))
4030            .expect("registered broker resolvable");
4031        assert2::assert!((b.node_id, image.broker(NodeId(404)).is_none()) == (NodeId(7), true));
4032    }
4033
4034    #[test]
4035    fn reapplying_topic_with_new_id_drops_stale_id_index() {
4036        let mut image = MetadataImage::new(Uuid::nil());
4037        let old_id = Uuid::from_u128(1);
4038        let new_id = Uuid::from_u128(2);
4039        let topic = |id| {
4040            MetadataRecord::V1Topic(TopicRecord {
4041                name: "t".into(),
4042                topic_id: id,
4043                partitions: 1,
4044                replication_factor: 1,
4045            })
4046        };
4047        image.apply(&topic(old_id));
4048        assert2::assert!(image.topic_by_id(&old_id).is_some());
4049        // Re-apply the same NAME with a different id: the stale id index entry
4050        // must be dropped (the `prev.topic_id != t.topic_id` guard).
4051        image.apply(&topic(new_id));
4052        assert2::assert!(image.topic_by_id(&new_id).is_some());
4053        assert2::assert!(image.topic_by_id(&old_id).is_none());
4054    }
4055
4056    #[test]
4057    fn broker_throttle_rate_allows_zero() {
4058        // A configured rate of 0 is a real (zero) value, distinct from absent
4059        // or -1 (disabled). The cap check is `v < 0` (not `<=`), so 0 -> Some(0).
4060        let mut image = MetadataImage::new(Uuid::nil());
4061        image.apply(&MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
4062            node_id: NodeId(3),
4063            config_name: "leader.replication.throttled.rate".into(),
4064            config_value: Some("0".into()),
4065        }));
4066        assert2::assert!(
4067            image.broker_throttle_rate(NodeId(3), ThrottleKind::Leader)
4068                == Some(<ByteRate as ByteRateExt>::ZERO)
4069        );
4070    }
4071
4072    #[test]
4073    fn dir_assignment_resizes_directories_to_replica_count() {
4074        // KIP-858: a dir-assignment delta for a partition whose `directories`
4075        // vec is shorter than `replicas` must grow it (to `replicas.len()`,
4076        // padded with `MIGRATING`, which is how Kafka reads an empty list)
4077        // before writing the reporting replica's slot. Without the resize the
4078        // slot write would index out of bounds.
4079        let mut image = MetadataImage::new(Uuid::nil());
4080        image.apply(&MetadataRecord::V1Partition(PartitionRecord {
4081            topic: "t".into(),
4082            partition: 0,
4083            leader: NodeId(10),
4084            replicas: vec![NodeId(10), NodeId(20)],
4085            isr: vec![NodeId(10), NodeId(20)],
4086            directories: vec![], // not yet reported
4087            ..Default::default()
4088        }));
4089        let dir = Uuid::from_u128(0xD15);
4090        image.apply(&MetadataRecord::V1PartitionDirAssignment(
4091            crate::records::PartitionDirAssignmentRecord {
4092                topic: "t".into(),
4093                partition: 0,
4094                replica: NodeId(10),
4095                directory: dir,
4096            },
4097        ));
4098        let pr = image.partition("t", 0).expect("partition present");
4099        // Replica 10 is slot 0; replica 20 stays `MIGRATING`.
4100        assert2::assert!(pr.directories == vec![dir, crate::directory_id::MIGRATING]);
4101    }
4102
4103    #[test]
4104    fn dir_assignment_updates_existing_full_length_directories() {
4105        let mut image = MetadataImage::new(Uuid::nil());
4106        let old = Uuid::from_u128(0xAAA);
4107        image.apply(&MetadataRecord::V1Partition(PartitionRecord {
4108            topic: "t".into(),
4109            partition: 0,
4110            leader: NodeId(10),
4111            replicas: vec![NodeId(10), NodeId(20)],
4112            isr: vec![NodeId(10), NodeId(20)],
4113            directories: vec![old, Uuid::nil()],
4114            ..Default::default()
4115        }));
4116        let dir = Uuid::from_u128(0xD15);
4117        image.apply(&MetadataRecord::V1PartitionDirAssignment(
4118            crate::records::PartitionDirAssignmentRecord {
4119                topic: "t".into(),
4120                partition: 0,
4121                replica: NodeId(20),
4122                directory: dir,
4123            },
4124        ));
4125
4126        let pr = image.partition("t", 0).expect("partition present");
4127        assert2::assert!(pr.directories == vec![old, dir]);
4128    }
4129    // ---------- KFC-9 write-freeze registry ----------
4130
4131    fn freeze(scope: &str, pattern_type: PatternType) -> TopicFreezeRecord {
4132        TopicFreezeRecord {
4133            scope: scope.into(),
4134            pattern_type,
4135            frozen: true,
4136            reason: "DR cutover".into(),
4137            set_by: "User:alice".into(),
4138            set_at_ms: 1_700_000_000_000,
4139            proposal_id: Uuid::nil(),
4140            key_id: "alice-yubi".into(),
4141            signature: vec![0xAB; 64],
4142        }
4143    }
4144
4145    #[test]
4146    fn an_unfrozen_cluster_resolves_no_freeze() {
4147        check!(img().topic_freeze("orders").is_none());
4148        check!(img().topic_freezes().count() == 0);
4149    }
4150
4151    #[test]
4152    fn a_literal_freeze_beats_a_prefix_and_the_longest_prefix_wins() {
4153        let mut image = img();
4154        for (scope, pattern_type) in [
4155            ("tenant-a.", PatternType::Prefixed),
4156            ("tenant-a.orders.", PatternType::Prefixed),
4157            ("tenant-a.orders.eu", PatternType::Literal),
4158        ] {
4159            image.apply(&MetadataRecord::V1TopicFreeze(freeze(scope, pattern_type)));
4160        }
4161
4162        for (label, topic, want) in [
4163            (
4164                "a literal entry beats every prefix entry",
4165                "tenant-a.orders.eu",
4166                Some("tenant-a.orders.eu"),
4167            ),
4168            (
4169                "the longest matching prefix wins",
4170                "tenant-a.orders.us",
4171                Some("tenant-a.orders."),
4172            ),
4173            (
4174                "a shorter prefix still matches",
4175                "tenant-a.billing",
4176                Some("tenant-a."),
4177            ),
4178            ("no scope covers the topic", "tenant-b.orders", None),
4179            ("a topic that sorts below every scope", "aaa", None),
4180        ] {
4181            check!(
4182                image.topic_freeze(topic).map(|f| f.scope.as_str()) == want,
4183                "{label}"
4184            );
4185        }
4186    }
4187
4188    #[test]
4189    fn an_internal_topic_is_never_frozen() {
4190        for (label, scope, pattern_type, covered) in [
4191            (
4192                "an empty prefix scope",
4193                "",
4194                PatternType::Prefixed,
4195                Some("orders"),
4196            ),
4197            (
4198                "a single-underscore prefix scope",
4199                "_",
4200                PatternType::Prefixed,
4201                Some("_metrics"),
4202            ),
4203            (
4204                "a literal internal name",
4205                "__consumer_offsets",
4206                PatternType::Literal,
4207                None,
4208            ),
4209        ] {
4210            let mut image = img();
4211            image.apply(&MetadataRecord::V1TopicFreeze(freeze(scope, pattern_type)));
4212
4213            check!(
4214                image.topic_freeze("__consumer_offsets").is_none(),
4215                "{label}"
4216            );
4217            if let Some(topic) = covered {
4218                // The registry is live, so the exemption comes from the name
4219                // and not from an empty registry.
4220                check!(image.topic_freeze(topic).is_some(), "{label}");
4221            }
4222        }
4223    }
4224
4225    #[test]
4226    fn a_thaw_removes_the_registry_entry() {
4227        for (label, pattern_type, topic) in [
4228            ("a literal scope", PatternType::Literal, "orders"),
4229            ("a prefixed scope", PatternType::Prefixed, "orders-eu"),
4230        ] {
4231            let mut image = img();
4232            let frozen = freeze("orders", pattern_type);
4233            image.apply(&MetadataRecord::V1TopicFreeze(frozen.clone()));
4234            check!(image.topic_freeze(topic).is_some(), "{label}");
4235
4236            image.apply(&MetadataRecord::V1TopicFreeze(TopicFreezeRecord {
4237                frozen: false,
4238                proposal_id: Uuid::from_u128(0xB1),
4239                ..frozen
4240            }));
4241            check!(image.topic_freeze(topic).is_none(), "{label}");
4242            check!(image.topic_freezes().count() == 0, "{label}");
4243        }
4244    }
4245
4246    #[test]
4247    fn a_later_freeze_replaces_the_entry_at_the_same_scope() {
4248        let mut image = img();
4249        image.apply(&MetadataRecord::V1TopicFreeze(freeze(
4250            "orders",
4251            PatternType::Literal,
4252        )));
4253        let replacement = TopicFreezeRecord {
4254            reason: "tenant offboarding".into(),
4255            set_by: "User:bob".into(),
4256            set_at_ms: 1_700_000_060_000,
4257            ..freeze("orders", PatternType::Literal)
4258        };
4259        image.apply(&MetadataRecord::V1TopicFreeze(replacement.clone()));
4260
4261        check!(image.topic_freeze("orders") == Some(&replacement));
4262        check!(image.topic_freezes().count() == 1);
4263    }
4264
4265    // ---------- KFC-9 break-glass proposals ----------
4266
4267    fn approval(principal: &str) -> BreakGlassApproval {
4268        BreakGlassApproval {
4269            principal: principal.into(),
4270            approved_at_ms: 1_700_000_060_000,
4271            key_id: format!("{principal}-yubi"),
4272            signature: vec![0xCD; 64],
4273        }
4274    }
4275
4276    fn proposal(id: Uuid) -> BreakGlassProposalRecord {
4277        BreakGlassProposalRecord {
4278            proposal_id: id,
4279            action: crate::break_glass::BreakGlassAction::ThawTopicFreeze,
4280            target: "literal:orders".into(),
4281            proposer: "User:alice".into(),
4282            reason: "incident 4711 closed".into(),
4283            created_at_ms: 1_700_000_000_000,
4284            expires_at_ms: 1_700_000_600_000,
4285            approvals: Vec::new(),
4286            consumed_at_ms: 0,
4287            withdrawn: false,
4288        }
4289    }
4290
4291    #[test]
4292    fn a_break_glass_proposal_upserts_and_deletes() {
4293        let id = Uuid::from_u128(0xB1);
4294        let mut image = img();
4295        let record = proposal(id);
4296        image.apply(&MetadataRecord::V1BreakGlassProposal(record.clone()));
4297        check!(image.break_glass_proposal(id) == Some(&record));
4298        check!(image.break_glass_proposals().count() == 1);
4299
4300        let approved = BreakGlassProposalRecord {
4301            approvals: vec![approval("User:bob")],
4302            ..record
4303        };
4304        image.apply(&MetadataRecord::V1BreakGlassProposal(approved.clone()));
4305        check!(image.break_glass_proposal(id) == Some(&approved));
4306
4307        image.apply(&MetadataRecord::V1DeleteBreakGlassProposal(id));
4308        check!(image.break_glass_proposal(id).is_none());
4309        check!(image.break_glass_proposals().count() == 0);
4310    }
4311
4312    #[test]
4313    fn to_records_preserves_both_freeze_registries_and_the_proposals() {
4314        let cid = Uuid::new_v4();
4315        let mut image = MetadataImage::new(cid);
4316        let literal = freeze("orders", PatternType::Literal);
4317        let prefixed = freeze("tenant-a.", PatternType::Prefixed);
4318        let pending = BreakGlassProposalRecord {
4319            approvals: vec![approval("User:bob")],
4320            ..proposal(Uuid::from_u128(0xB1))
4321        };
4322        image.apply(&MetadataRecord::V1TopicFreeze(literal.clone()));
4323        image.apply(&MetadataRecord::V1TopicFreeze(prefixed.clone()));
4324        image.apply(&MetadataRecord::V1BreakGlassProposal(pending.clone()));
4325
4326        let rebuilt = MetadataImage::from_records(cid, &image.to_records());
4327
4328        check!(rebuilt == image);
4329        check!(rebuilt.topic_freeze("orders") == Some(&literal));
4330        check!(rebuilt.topic_freeze("tenant-a.billing") == Some(&prefixed));
4331        check!(rebuilt.break_glass_proposal(pending.proposal_id) == Some(&pending));
4332    }
4333
4334    // ---------- KFC-9 validate ----------
4335
4336    #[test]
4337    fn validate_rejects_only_an_empty_freeze_scope() {
4338        let image = img();
4339        for (label, scope, pattern_type, want) in [
4340            (
4341                "a literal scope naming a topic that does not exist",
4342                "orders",
4343                PatternType::Literal,
4344                Ok(()),
4345            ),
4346            (
4347                "a prefix scope, which names no topic by construction",
4348                "tenant-a.",
4349                PatternType::Prefixed,
4350                Ok(()),
4351            ),
4352            (
4353                "an empty literal scope",
4354                "",
4355                PatternType::Literal,
4356                Err(MetadataError::EmptyFreezeScope),
4357            ),
4358            (
4359                "an empty prefix scope",
4360                "",
4361                PatternType::Prefixed,
4362                Err(MetadataError::EmptyFreezeScope),
4363            ),
4364        ] {
4365            let rec = MetadataRecord::V1TopicFreeze(freeze(scope, pattern_type));
4366            check!(image.validate(&rec) == want, "{label}");
4367        }
4368    }
4369
4370    #[test]
4371    fn validate_accepts_a_break_glass_proposal_that_is_not_yet_stored() {
4372        let image = img();
4373        let rec = MetadataRecord::V1BreakGlassProposal(proposal(Uuid::from_u128(0xB1)));
4374        check!(image.validate(&rec) == Ok(()));
4375    }
4376
4377    #[test]
4378    fn validate_accepts_only_an_appended_approval() {
4379        let id = Uuid::from_u128(0xB1);
4380        let (alice, bob, carol) = (
4381            approval("User:alice"),
4382            approval("User:bob"),
4383            approval("User:carol"),
4384        );
4385        let mut image = img();
4386        let stored = BreakGlassProposalRecord {
4387            approvals: vec![alice.clone(), bob.clone()],
4388            ..proposal(id)
4389        };
4390        image.apply(&MetadataRecord::V1BreakGlassProposal(stored.clone()));
4391
4392        for (label, approvals, want) in [
4393            (
4394                "the stored list unchanged, which a consumption writes back",
4395                vec![alice.clone(), bob.clone()],
4396                Ok(()),
4397            ),
4398            (
4399                "one appended approval",
4400                vec![alice.clone(), bob.clone(), carol.clone()],
4401                Ok(()),
4402            ),
4403            (
4404                "a reordered list",
4405                vec![bob.clone(), alice.clone()],
4406                Err(MetadataError::BreakGlassApprovalsNotAnExtension(id)),
4407            ),
4408            (
4409                "a truncated list",
4410                vec![alice.clone()],
4411                Err(MetadataError::BreakGlassApprovalsNotAnExtension(id)),
4412            ),
4413            (
4414                "an empty list",
4415                Vec::new(),
4416                Err(MetadataError::BreakGlassApprovalsNotAnExtension(id)),
4417            ),
4418            (
4419                "a replaced approval",
4420                vec![alice.clone(), carol.clone()],
4421                Err(MetadataError::BreakGlassApprovalsNotAnExtension(id)),
4422            ),
4423        ] {
4424            let candidate = MetadataRecord::V1BreakGlassProposal(BreakGlassProposalRecord {
4425                approvals,
4426                ..stored.clone()
4427            });
4428            check!(image.validate(&candidate) == want, "{label}");
4429        }
4430    }
4431
4432    /// Two approvers read the same image and each submits the list it read
4433    /// plus its own entry. Without the extension rule the second write drops
4434    /// the first approval, and the proposal never reaches two principals.
4435    #[test]
4436    fn a_concurrent_approval_cannot_drop_the_stored_one() {
4437        let id = Uuid::from_u128(0xB1);
4438        let mut image = img();
4439        image.apply(&MetadataRecord::V1BreakGlassProposal(proposal(id)));
4440
4441        let by_bob = BreakGlassProposalRecord {
4442            approvals: vec![approval("User:bob")],
4443            ..proposal(id)
4444        };
4445        let by_carol = BreakGlassProposalRecord {
4446            approvals: vec![approval("User:carol")],
4447            ..proposal(id)
4448        };
4449        check!(image.validate(&MetadataRecord::V1BreakGlassProposal(by_bob.clone())) == Ok(()));
4450        image.apply(&MetadataRecord::V1BreakGlassProposal(by_bob));
4451
4452        check!(
4453            image.validate(&MetadataRecord::V1BreakGlassProposal(by_carol))
4454                == Err(MetadataError::BreakGlassApprovalsNotAnExtension(id))
4455        );
4456
4457        let both = BreakGlassProposalRecord {
4458            approvals: vec![approval("User:bob"), approval("User:carol")],
4459            ..proposal(id)
4460        };
4461        check!(image.validate(&MetadataRecord::V1BreakGlassProposal(both)) == Ok(()));
4462    }
4463
4464    #[test]
4465    fn validate_rejects_every_change_to_a_settled_proposal() {
4466        let id = Uuid::from_u128(0xB1);
4467        for (label, settled, want) in [
4468            (
4469                "a consumed proposal",
4470                BreakGlassProposalRecord {
4471                    consumed_at_ms: 1_700_000_120_000,
4472                    ..proposal(id)
4473                },
4474                MetadataError::BreakGlassProposalConsumed(id),
4475            ),
4476            (
4477                "a withdrawn proposal",
4478                BreakGlassProposalRecord {
4479                    withdrawn: true,
4480                    ..proposal(id)
4481                },
4482                MetadataError::BreakGlassProposalWithdrawn(id),
4483            ),
4484        ] {
4485            let mut image = img();
4486            image.apply(&MetadataRecord::V1BreakGlassProposal(settled.clone()));
4487
4488            for (change_label, candidate) in [
4489                (
4490                    "an appended approval",
4491                    BreakGlassProposalRecord {
4492                        approvals: vec![approval("User:bob")],
4493                        ..settled.clone()
4494                    },
4495                ),
4496                (
4497                    "a second consumption",
4498                    BreakGlassProposalRecord {
4499                        consumed_at_ms: 1_700_000_180_000,
4500                        ..settled.clone()
4501                    },
4502                ),
4503                ("the record unchanged", settled.clone()),
4504            ] {
4505                let rec = MetadataRecord::V1BreakGlassProposal(candidate);
4506                check!(
4507                    image.validate(&rec) == Err(want.clone()),
4508                    "{label}, {change_label}"
4509                );
4510            }
4511        }
4512    }
4513
4514    #[test]
4515    fn validate_accepts_a_break_glass_tombstone_for_any_id() {
4516        let image = img();
4517        let rec = MetadataRecord::V1DeleteBreakGlassProposal(Uuid::from_u128(0xB1));
4518        check!(image.validate(&rec) == Ok(()));
4519    }
4520}