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