Skip to main content

krabka_metadata/
kraft_translate.rs

1//! Translate Krabka's internal [`MetadataRecord`] currency to and from the
2//! KIP-631 [`KraftMetadataRecord`] wire shape, so the controller log and
3//! snapshots are genuinely KIP-631-framed.
4//!
5//! The conversion is *not* a pure field rename. Several Krabka records
6//! carry state that the KIP-631 records do not, and the reverse also holds.
7//! This module bridges the gap and defaults the KIP-631 extras that Krabka
8//! does not model. The round-trip
9//! `rec -> to_kraft(&image) -> from_kraft(&image) == rec` is the
10//! correctness bar for every modeled variant.
11//!
12//! ## Variants with no faithful KIP-631 counterpart
13//!
14//! - `V1Voters` and `V1KRaftVersion` are KIP-853 raft *control* records and
15//!   not metadata records. They have no entry in the
16//!   [`KraftMetadataRecord`] dispatch, and nothing submits them through the
17//!   translation boundary, so
18//!   [`to_kraft`] returns [`TranslateError::NoCounterpart`] for them.
19//!
20//! ## Fan-out / image-keyed mappings
21//!
22//! - `V1TopicConfig` is whole-map authoritative, and KIP-631 has only per-key
23//!   `ConfigRecord`s. [`to_kraft_records`] diffs the new map against the
24//!   current overrides of the image. It emits a *set* per present key plus a
25//!   *tombstone* (`value: None`) per dropped key. [`from_kraft`] merges the
26//!   delta of one key back onto the image map and re-emits the whole map, so
27//!   the replace semantics of `MetadataImage::apply` stay unchanged. Records
28//!   must apply in log order against a live image.
29//! - `V1DeleteAccessControlEntry(AclEntryFilter)`: KIP-631's
30//!   `RemoveAccessControlEntryRecord` deletes by `id: Uuid` only. Krabka does
31//!   not model ACL ids, so [`to_kraft_records`] resolves the filter against
32//!   the image to the matching entries and emits one `RemoveAccessControlEntry`
33//!   per match, keyed by a deterministic content-hash id. That is the same id
34//!   that the add path stamps. [`from_kraft`] rescans the image by that hash to
35//!   recover the entry and emit a pinned filter. This is self-consistent within
36//!   Krabka.
37//!
38//! ## Lossy / awkward mappings (documented)
39//!
40//! - `RegisterBroker`: Krabka stores a top-level `(host, port)` plus a
41//!   per-listener `endpoints` list; KIP-631 has only `end_points` (no
42//!   top-level host/port). When listeners exist, their first entry supplies
43//!   the legacy top-level pair on decode. A legacy Krabka record with no
44//!   listeners uses one empty-name compatibility entry. `broker_epoch`,
45//!   `incarnation_id`, `features`, `log_dirs`, `fenced`,
46//!   `in_controlled_shutdown` and `cordoned_log_dirs` are carried; the ZK
47//!   migration flag is dropped.
48//! - `DelegationToken`: `KafkaPrincipal`s map through their `Type:Name`
49//!   string form.
50
51use std::{collections::BTreeMap, vec};
52
53use bytes::Bytes;
54use krabka_protocol::{
55    owned::{
56        access_control_entry_record::AccessControlEntryRecord,
57        broker_registration_change_record::BrokerRegistrationChangeRecord,
58        client_quota_record::{ClientQuotaRecord as KClientQuotaRecord, EntityData},
59        config_record::ConfigRecord,
60        delegation_token_record::DelegationTokenRecord as KDelegationTokenRecord,
61        feature_level_record::FeatureLevelRecord as KFeatureLevelRecord,
62        no_op_record::NoOpRecord,
63        partition_change_record::PartitionChangeRecord as KPartitionChangeRecord,
64        partition_record::PartitionRecord as KPartitionRecord,
65        producer_ids_record::ProducerIdsRecord as KProducerIdsRecord,
66        register_broker_record::{
67            BrokerEndpoint as KBrokerEndpoint, BrokerFeature as KBrokerFeature,
68            RegisterBrokerRecord,
69        },
70        register_controller_record::{
71            ControllerEndpoint as KControllerEndpoint, ControllerFeature as KControllerFeature,
72            RegisterControllerRecord,
73        },
74        remove_access_control_entry_record::RemoveAccessControlEntryRecord,
75        remove_delegation_token_record::RemoveDelegationTokenRecord,
76        remove_topic_record::RemoveTopicRecord,
77        remove_user_scram_credential_record::RemoveUserScramCredentialRecord,
78        topic_record::TopicRecord as KTopicRecord,
79        unregister_broker_record::UnregisterBrokerRecord as KUnregisterBrokerRecord,
80        unregister_controller_record::UnregisterControllerRecord as KUnregisterControllerRecord,
81        user_scram_credential_record::UserScramCredentialRecord,
82    },
83    primitives::uuid::Uuid as KUuid,
84    records::metadata::KraftMetadataRecord,
85    tagged_fields::{UnknownTaggedField, UnknownTaggedFields},
86};
87use krabka_security::{KafkaPrincipal, ListenerProtocol, SaslMechanism};
88use wincode::{SchemaRead, Serialize as _, config::DefaultConfig};
89
90use crate::{
91    MetadataImage,
92    acl::{AclEntry, AclEntryFilter, AclOperation, PatternType, PermissionType, ResourceType},
93    directory_id::MIGRATING,
94    metadata_version::{
95        CORDONED_LOG_DIRS_MIN_LEVEL, DIRECTORY_ASSIGNMENT_MIN_LEVEL, ELR_MIN_LEVEL,
96        METADATA_VERSION_MAX,
97    },
98    records::{
99        BrokerConfigRecord, BrokerEndpoint, BrokerRegistrationChangeRecord as ChangeRecord,
100        BrokerRegistrationRecord, ClientMetricsConfigRecord, ClientQuotaRecord,
101        ControllerRegistrationRecord, DEFAULT_BROKER_CONFIG_NODE_ID, DelegationTokenRecord,
102        DeleteDelegationTokenRecord, DeleteScramCredentialRecord, DeleteTopicRecord,
103        FeatureLevelRecord, FencingChange, GroupConfigRecord, LeaderEpoch, LeaderRecoveryState,
104        MetadataRecord, NodeId, PartitionDirAssignmentRecord, PartitionRecord, ProducerIdsRecord,
105        QuotaEntity, ScramCredentialRecord, TopicConfigRecord, TopicRecord, UnregisterBrokerRecord,
106        UnregisterControllerRecord,
107    },
108};
109
110#[derive(Debug, thiserror::Error, PartialEq, Eq)]
111pub enum TranslateError {
112    #[error("record has no KIP-631 metadata counterpart: {0}")]
113    NoCounterpart(&'static str),
114    #[error("unknown topic id {0} on decode")]
115    UnknownTopicId(uuid::Uuid),
116    #[error("unknown topic name {0} on encode")]
117    UnknownTopicName(String),
118    /// A value that is well-formed on its own but names something the
119    /// metadata image does not hold in the shape the record needs: an unknown
120    /// partition, a broker that is not a replica, a directory list whose
121    /// length does not match the replicas the image holds, or an epoch that
122    /// the image already holds at its maximum. Two racing committed writes can legitimately
123    /// produce one, and every replica sees the same image in log order, so
124    /// every replica skips the record in the same way.
125    #[error("invalid reference in {field}: {detail}")]
126    InvalidReference { field: &'static str, detail: String },
127    /// A value that no build accepts, whatever the image holds: an unknown
128    /// enum discriminant, a negative node id or iteration count, an integer
129    /// that does not fit the wire width, a string that does not parse, or a
130    /// directory list whose length does not match the replicas that the same
131    /// record sets. The bytes alone decide it, and Kafka treats the same content as a
132    /// fatal decode failure.
133    #[error("invalid {field}: {detail}")]
134    InvalidValue { field: &'static str, detail: String },
135    #[error("KIP-631 value encode failed: {0}")]
136    Encode(String),
137    #[error("KIP-631 value decode failed: {0}")]
138    Decode(String),
139    /// A `RemoveAccessControlEntry{{id}}` referenced an ACL id absent from the
140    /// image. On the Krabka-only path the matching add always precedes the
141    /// remove in log order, so this means a corrupt or out-of-order log.
142    #[error("unknown ACL id {0} on decode")]
143    UnknownAclId(uuid::Uuid),
144    /// A `NoOpRecord` carries one tagged field at a tag in the krabka-private
145    /// range (1001 and up) that this build does not assign. 1002 is one: it
146    /// is burned and no build writes it.
147    #[error("unknown krabka-private NoOpRecord tag {0}")]
148    UnknownPrivateTag(u32),
149    /// The body of a krabka-private `NoOpRecord` tag starts with a version
150    /// other than [`PRIVATE_RECORD_VERSION`]. A newer broker wrote it, or the
151    /// metadata log predates krabka 1.0, whose bodies carry no version.
152    #[error(
153        "krabka-private NoOpRecord tag {tag} has record version {version}, and this build reads \
154         only version {PRIVATE_RECORD_VERSION}; a record without a version predates krabka 1.0, \
155         and its node must be reformatted"
156    )]
157    UnknownPrivateRecordVersion { tag: u32, version: i16 },
158    /// A krabka-private `NoOpRecord` tag carries a record that the tag table
159    /// assigns to another tag (`expected_tag`), or to no tag (`None`).
160    #[error(
161        "krabka-private NoOpRecord tag {tag} carries a record that belongs to tag {expected_tag:?}"
162    )]
163    PrivateTagMismatch { tag: u32, expected_tag: Option<u32> },
164    /// The body of a krabka-private `NoOpRecord` tag holds `trailing` bytes
165    /// after its record. A re-encode would drop them, so the body is refused.
166    #[error("krabka-private NoOpRecord tag {tag} has {trailing} bytes after its record")]
167    TrailingPrivateRecordBytes { tag: u32, trailing: usize },
168}
169
170// ----- uuid bridging -----
171
172fn to_kuuid(u: uuid::Uuid) -> KUuid {
173    KUuid(*u.as_bytes())
174}
175
176fn from_kuuid(u: KUuid) -> uuid::Uuid {
177    uuid::Uuid::from_bytes(u.0)
178}
179
180// ----- SCRAM mechanism (Kafka ScramMechanism: SCRAM_SHA_256=1, SCRAM_SHA_512=2) -----
181
182fn scram_mechanism_to_wire(m: SaslMechanism) -> Result<i8, TranslateError> {
183    match m {
184        SaslMechanism::ScramSha256 => Ok(1),
185        SaslMechanism::ScramSha512 => Ok(2),
186        other => Err(TranslateError::InvalidValue {
187            field: "scram mechanism",
188            detail: format!("{other:?} is not a SCRAM mechanism"),
189        }),
190    }
191}
192
193fn scram_mechanism_from_wire(b: i8) -> Result<SaslMechanism, TranslateError> {
194    match b {
195        1 => Ok(SaslMechanism::ScramSha256),
196        2 => Ok(SaslMechanism::ScramSha512),
197        other => Err(TranslateError::InvalidValue {
198            field: "scram mechanism",
199            detail: format!("unknown SCRAM mechanism wire byte {other}"),
200        }),
201    }
202}
203
204// ----- ACL enum <-> Kafka i8 wire discriminants -----
205//
206// These mirror the canonical mappings in krabka-broker's `acl_wire`
207// (Kafka serializes ACL enums as `i8`). Replicated here so the metadata
208// crate does not depend on the broker.
209
210fn resource_type_to_wire(rt: ResourceType) -> i8 {
211    match rt {
212        ResourceType::Topic => 2,
213        ResourceType::Group => 3,
214        ResourceType::Cluster => 4,
215        ResourceType::TransactionalId => 5,
216        ResourceType::DelegationToken => 6,
217        ResourceType::User => 7,
218    }
219}
220
221fn resource_type_from_wire(b: i8) -> Result<ResourceType, TranslateError> {
222    match b {
223        2 => Ok(ResourceType::Topic),
224        3 => Ok(ResourceType::Group),
225        4 => Ok(ResourceType::Cluster),
226        5 => Ok(ResourceType::TransactionalId),
227        6 => Ok(ResourceType::DelegationToken),
228        7 => Ok(ResourceType::User),
229        other => Err(TranslateError::InvalidValue {
230            field: "acl resource_type",
231            detail: format!("unknown wire byte {other}"),
232        }),
233    }
234}
235
236fn pattern_type_to_wire(pt: PatternType) -> i8 {
237    match pt {
238        PatternType::Literal => 3,
239        PatternType::Prefixed => 4,
240    }
241}
242
243fn pattern_type_from_wire(b: i8) -> Result<PatternType, TranslateError> {
244    match b {
245        3 => Ok(PatternType::Literal),
246        4 => Ok(PatternType::Prefixed),
247        other => Err(TranslateError::InvalidValue {
248            field: "acl pattern_type",
249            detail: format!("unknown wire byte {other}"),
250        }),
251    }
252}
253
254fn operation_to_wire(op: AclOperation) -> i8 {
255    match op {
256        AclOperation::All => 2,
257        AclOperation::Read => 3,
258        AclOperation::Write => 4,
259        AclOperation::Create => 5,
260        AclOperation::Delete => 6,
261        AclOperation::Alter => 7,
262        AclOperation::Describe => 8,
263        AclOperation::ClusterAction => 9,
264        AclOperation::DescribeConfigs => 10,
265        AclOperation::AlterConfigs => 11,
266        AclOperation::IdempotentWrite => 12,
267        AclOperation::CreateTokens => 13,
268        AclOperation::DescribeTokens => 14,
269        AclOperation::TwoPhaseCommit => 15,
270    }
271}
272
273fn operation_from_wire(b: i8) -> Result<AclOperation, TranslateError> {
274    match b {
275        2 => Ok(AclOperation::All),
276        3 => Ok(AclOperation::Read),
277        4 => Ok(AclOperation::Write),
278        5 => Ok(AclOperation::Create),
279        6 => Ok(AclOperation::Delete),
280        7 => Ok(AclOperation::Alter),
281        8 => Ok(AclOperation::Describe),
282        9 => Ok(AclOperation::ClusterAction),
283        10 => Ok(AclOperation::DescribeConfigs),
284        11 => Ok(AclOperation::AlterConfigs),
285        12 => Ok(AclOperation::IdempotentWrite),
286        13 => Ok(AclOperation::CreateTokens),
287        14 => Ok(AclOperation::DescribeTokens),
288        15 => Ok(AclOperation::TwoPhaseCommit),
289        other => Err(TranslateError::InvalidValue {
290            field: "acl operation",
291            detail: format!("unknown wire byte {other}"),
292        }),
293    }
294}
295
296fn permission_to_wire(pt: PermissionType) -> i8 {
297    match pt {
298        PermissionType::Deny => 2,
299        PermissionType::Allow => 3,
300    }
301}
302
303fn permission_from_wire(b: i8) -> Result<PermissionType, TranslateError> {
304    match b {
305        2 => Ok(PermissionType::Deny),
306        3 => Ok(PermissionType::Allow),
307        other => Err(TranslateError::InvalidValue {
308            field: "acl permission_type",
309            detail: format!("unknown wire byte {other}"),
310        }),
311    }
312}
313
314// ----- ACL content-hash id (KIP-631 keys ACLs by Uuid; Krabka keys by content) -----
315//
316// KIP-631's `AccessControlEntryRecord` carries a controller-assigned `id`, and
317// `RemoveAccessControlEntryRecord` deletes by that id alone. Krabka's image does
318// not model ACL ids (it keys entries by their full tuple), so to keep the log
319// genuinely id-keyed and round-trippable we synthesize a *deterministic*
320// 128-bit id from the entry's canonical content. The add path stamps this id;
321// the delete path resolves a filter to matching entries and emits one
322// `RemoveAccessControlEntry{id}` each; decode rescans the image by the same hash
323// to recover the entry. The derivation is self-consistent within Krabka — Slice
324// 6 replaces it with Kafka's real id scheme for cross-impl fidelity.
325fn acl_id(e: &AclEntry) -> KUuid {
326    // Canonical byte encoding: the four enum axes (i8) then the three string
327    // axes, each length-prefixed and separated, so distinct entries never alias.
328    let mut buf: Vec<u8> = vec![
329        resource_type_to_wire(e.resource_type).cast_unsigned(),
330        pattern_type_to_wire(e.pattern_type).cast_unsigned(),
331        operation_to_wire(e.operation).cast_unsigned(),
332        permission_to_wire(e.permission_type).cast_unsigned(),
333    ];
334    for s in [&e.resource_name, &e.principal, &e.host] {
335        buf.extend_from_slice(&(s.len() as u64).to_le_bytes());
336        buf.extend_from_slice(s.as_bytes());
337    }
338    KUuid(fnv1a_128(&buf))
339}
340
341/// FNV-1a over 128 bits: two independent 64-bit FNV-1a lanes with distinct
342/// offset bases, concatenated. It is deterministic and dependency-free, unlike
343/// `DefaultHasher`, whose output is not guaranteed stable across toolchains.
344fn fnv1a_128(bytes: &[u8]) -> [u8; 16] {
345    const PRIME: u64 = 0x0000_0100_0000_01b3;
346    let lane = |mut h: u64| -> u64 {
347        for b in bytes {
348            h ^= u64::from(*b);
349            h = h.wrapping_mul(PRIME);
350        }
351        h
352    };
353    let hi = lane(0xcbf2_9ce4_8422_2325);
354    let lo = lane(0x1099_5663_8a1b_c7e9);
355    let mut out = [0u8; 16];
356    out[..8].copy_from_slice(&hi.to_be_bytes());
357    out[8..].copy_from_slice(&lo.to_be_bytes());
358    out
359}
360
361/// A filter pinned to exactly one entry, with every axis set. The decode of a
362/// `RemoveAccessControlEntry` uses it, so `apply` removes precisely the
363/// resolved entry.
364fn exact_filter(e: &AclEntry) -> AclEntryFilter {
365    AclEntryFilter {
366        resource_type: Some(e.resource_type),
367        resource_name: Some(e.resource_name.clone()),
368        pattern_type: Some(e.pattern_type),
369        principal: Some(e.principal.clone()),
370        host: Some(e.host.clone()),
371        operation: Some(e.operation),
372        permission_type: Some(e.permission_type),
373    }
374}
375
376// ----- security_protocol (Kafka SecurityProtocol == ListenerProtocol ordinal) -----
377
378fn protocol_to_wire(p: ListenerProtocol) -> i16 {
379    match p {
380        ListenerProtocol::Plaintext => 0,
381        ListenerProtocol::Ssl => 1,
382        ListenerProtocol::SaslPlaintext => 2,
383        ListenerProtocol::SaslSsl => 3,
384    }
385}
386
387fn protocol_from_wire(b: i16) -> Result<ListenerProtocol, TranslateError> {
388    match b {
389        0 => Ok(ListenerProtocol::Plaintext),
390        1 => Ok(ListenerProtocol::Ssl),
391        2 => Ok(ListenerProtocol::SaslPlaintext),
392        3 => Ok(ListenerProtocol::SaslSsl),
393        other => Err(TranslateError::InvalidValue {
394            field: "security_protocol",
395            detail: format!("unknown wire value {other}"),
396        }),
397    }
398}
399
400/// Convert a single [`MetadataRecord`] to its KIP-631 counterpart.
401///
402/// Whole-map config variants may fan out to N `Config` records; callers that
403/// must preserve every key must use [`to_kraft_records`]. For a fanned record,
404/// this function returns only the first translated key.
405///
406/// # Errors
407/// [`TranslateError::NoCounterpart`] for raft control records and delete-ACL
408/// filters, [`TranslateError::UnknownTopicName`] when a topic/partition
409/// references a topic absent from `image`, [`TranslateError::InvalidValue`]
410/// for out-of-range enum values and node ids that exceed `i32`, and
411/// [`TranslateError::InvalidReference`] for a partition absent from `image` or
412/// a broker that is not one of its replicas.
413pub fn to_kraft(
414    rec: &MetadataRecord,
415    image: &MetadataImage,
416) -> Result<KraftMetadataRecord, TranslateError> {
417    to_kraft_iter(rec, image)?
418        .next()
419        .ok_or(TranslateError::NoCounterpart("empty translation"))
420}
421
422/// Convert a single [`MetadataRecord`] to one or more KIP-631 records.
423///
424/// Whole-map topic, client-metrics, and group configs fan out to one `Config`
425/// record per set key plus one tombstone per removed key. Every other modeled
426/// variant yields exactly one record.
427///
428/// # Errors
429/// Same as [`to_kraft`].
430pub fn to_kraft_records(
431    rec: &MetadataRecord,
432    image: &MetadataImage,
433) -> Result<Vec<KraftMetadataRecord>, TranslateError> {
434    Ok(to_kraft_iter(rec, image)?.collect())
435}
436
437/// The engine/snapshot framing entrypoint: translate one [`MetadataRecord`] to
438/// the KIP-631 value byte blobs to put on the log.
439///
440/// There is one blob per fanned-out record (most records yield one; whole-map
441/// configs yield one per key plus tombstones, and `V1DeleteAccessControlEntry`
442/// yields one per matched ACL).
443///
444/// Multi-version metadata records are framed at the version selected by the
445/// image's finalized `metadata.version`; every other modeled record frames at
446/// apiVersion 0. Yields an empty `Vec` when a record fans out to nothing (an
447/// empty config clear with no prior keys, or a delete-ACL filter matching
448/// nothing) — callers treat an all-empty batch as a committed no-op.
449///
450/// A missing finalized metadata.version is treated as the latest supported
451/// level, matching standalone bootstrap. `Partition` and `PartitionChange` use
452/// v0, v1, or v2 at the Kafka KIP-858/KIP-966 boundaries. `RegisterBroker` uses v1,
453/// v2, v3, or v4 at the migration/KIP-858/KIP-1066 boundaries, as Kafka's
454/// `MetadataVersion.registerBrokerRecordVersion` does. `BrokerRegistrationChange`
455/// uses the lowest of v0 to v3 that carries the fields it sets, as Kafka's
456/// `ReplicationControlManager` writes it, after dropping log dirs below
457/// `3.7-IV2` and cordoned log dirs below `4.3-IV0`.
458///
459/// # Errors
460/// Propagates [`to_kraft_records`] errors, plus [`TranslateError::Encode`] if
461/// the KIP-631 value encoder fails.
462pub fn to_kraft_values(
463    rec: &MetadataRecord,
464    image: &MetadataImage,
465) -> Result<Vec<Bytes>, TranslateError> {
466    let metadata_version = image
467        .finalized_metadata_version()
468        .unwrap_or(METADATA_VERSION_MAX);
469    to_kraft_records(rec, image)?
470        .into_iter()
471        .map(|mut kr| {
472            // A registration change carries log dirs only from 3.7-IV2 and
473            // cordoned log dirs only from 4.3-IV0, like RegisterBroker below.
474            if let KraftMetadataRecord::BrokerRegistrationChange(change) = &mut kr {
475                if metadata_version < DIRECTORY_ASSIGNMENT_MIN_LEVEL {
476                    change.log_dirs.clear();
477                }
478                if metadata_version < CORDONED_LOG_DIRS_MIN_LEVEL {
479                    change.cordoned_log_dirs = None;
480                }
481            }
482            let version: i16 = match &kr {
483                KraftMetadataRecord::BrokerRegistrationChange(change) => {
484                    broker_registration_change_version(change)
485                }
486                KraftMetadataRecord::Partition(_) | KraftMetadataRecord::PartitionChange(_) => {
487                    match metadata_version {
488                        version if version >= ELR_MIN_LEVEL => 2,
489                        version if version >= DIRECTORY_ASSIGNMENT_MIN_LEVEL => 1,
490                        _ => 0,
491                    }
492                }
493                KraftMetadataRecord::RegisterBroker(_) => {
494                    if metadata_version >= CORDONED_LOG_DIRS_MIN_LEVEL {
495                        4
496                    } else if metadata_version >= DIRECTORY_ASSIGNMENT_MIN_LEVEL {
497                        3
498                    } else if metadata_version >= 8 {
499                        2
500                    } else {
501                        1
502                    }
503                }
504                // All other modeled record types frame at the defaulted v0.
505                _ => 0,
506            };
507            // Tagged fields remain syntactically valid on older flexible
508            // record versions, so the generated encoder cannot by itself
509            // express the semantic version gate. Strip fields introduced by
510            // later RegisterBroker versions before encoding a downgraded
511            // image; otherwise a future Krabka reader would resurrect data
512            // that the target Kafka version was required to discard.
513            if let KraftMetadataRecord::RegisterBroker(register) = &mut kr {
514                if version < 3 {
515                    register.log_dirs.clear();
516                }
517                if version < 4 {
518                    register.cordoned_log_dirs = None;
519                }
520            }
521            kr.encode_value(version)
522                .map_err(|e| TranslateError::Encode(e.to_string()))
523        })
524        .collect()
525}
526
527/// The inverse framing entrypoint.
528///
529/// This function decodes one KIP-631 value blob back to a [`MetadataRecord`]
530/// and resolves the image-keyed fields against `image`. Those fields are the
531/// topic ids, the whole-map config merge, and the ACL ids. The caller must
532/// apply decoded records to `image` in log order, so each later decode sees the
533/// up-to-date state.
534///
535/// # Errors
536/// [`TranslateError::Decode`] if the envelope or the body is malformed, plus
537/// any [`from_kraft`] error, such as an unknown topic id, an unknown ACL id, or
538/// an unmodeled record.
539pub fn from_kraft_value(
540    value: &[u8],
541    image: &MetadataImage,
542) -> Result<MetadataRecord, TranslateError> {
543    let (rec, _version) = KraftMetadataRecord::decode_value(value)
544        .map_err(|e| TranslateError::Decode(e.to_string()))?;
545    from_kraft(&rec, image)
546}
547
548fn topic_config_to_kraft(
549    config: &TopicConfigRecord,
550    image: &MetadataImage,
551) -> Vec<KraftMetadataRecord> {
552    let empty = BTreeMap::new();
553    let old = image.topic_config(&config.topic).unwrap_or(&empty);
554    let mut output = Vec::with_capacity(config.overrides.len());
555    for (key, value) in &config.overrides {
556        output.push(KraftMetadataRecord::Config(ConfigRecord {
557            resource_type: 2,
558            resource_name: config.topic.clone(),
559            name: key.clone(),
560            value: Some(value.clone()),
561            ..Default::default()
562        }));
563    }
564    for key in old.keys() {
565        if !config.overrides.contains_key(key) {
566            output.push(KraftMetadataRecord::Config(ConfigRecord {
567                resource_type: 2,
568                resource_name: config.topic.clone(),
569                name: key.clone(),
570                value: None,
571                ..Default::default()
572            }));
573        }
574    }
575    output
576}
577
578fn group_config_to_kraft(
579    config: &GroupConfigRecord,
580    image: &MetadataImage,
581) -> Vec<KraftMetadataRecord> {
582    let empty = BTreeMap::new();
583    let old = image.group_config(&config.group_id).unwrap_or(&empty);
584    let mut output = Vec::with_capacity(config.configs.len());
585    for (key, value) in &config.configs {
586        output.push(KraftMetadataRecord::Config(ConfigRecord {
587            resource_type: 32,
588            resource_name: config.group_id.clone(),
589            name: key.clone(),
590            value: Some(value.clone()),
591            ..Default::default()
592        }));
593    }
594    for key in old.keys() {
595        if !config.configs.contains_key(key) {
596            output.push(KraftMetadataRecord::Config(ConfigRecord {
597                resource_type: 32,
598                resource_name: config.group_id.clone(),
599                name: key.clone(),
600                value: None,
601                ..Default::default()
602            }));
603        }
604    }
605    output
606}
607
608fn client_metrics_config_to_kraft(
609    config: &ClientMetricsConfigRecord,
610    image: &MetadataImage,
611) -> Vec<KraftMetadataRecord> {
612    let empty = BTreeMap::new();
613    let old = image.client_metrics_config(&config.name).unwrap_or(&empty);
614    let mut output = Vec::with_capacity(config.configs.len());
615    for (key, value) in &config.configs {
616        output.push(KraftMetadataRecord::Config(ConfigRecord {
617            resource_type: 16,
618            resource_name: config.name.clone(),
619            name: key.clone(),
620            value: Some(value.clone()),
621            ..Default::default()
622        }));
623    }
624    for key in old.keys() {
625        if !config.configs.contains_key(key) {
626            output.push(KraftMetadataRecord::Config(ConfigRecord {
627                resource_type: 16,
628                resource_name: config.name.clone(),
629                name: key.clone(),
630                value: None,
631                ..Default::default()
632            }));
633        }
634    }
635    output
636}
637
638fn delete_acl_to_kraft(
639    filter: &crate::AclEntryFilter,
640    image: &MetadataImage,
641) -> Vec<KraftMetadataRecord> {
642    image
643        .all_acls()
644        .filter(|entry| filter.matches(entry))
645        .map(|entry| {
646            KraftMetadataRecord::RemoveAccessControlEntry(RemoveAccessControlEntryRecord {
647                id: acl_id(entry),
648                ..Default::default()
649            })
650        })
651        .collect()
652}
653
654fn acl_to_kraft(entry: &crate::AclEntry) -> KraftMetadataRecord {
655    KraftMetadataRecord::AccessControlEntry(AccessControlEntryRecord {
656        id: acl_id(entry),
657        resource_type: resource_type_to_wire(entry.resource_type),
658        resource_name: entry.resource_name.clone(),
659        pattern_type: pattern_type_to_wire(entry.pattern_type),
660        principal: entry.principal.clone(),
661        host: entry.host.clone(),
662        operation: operation_to_wire(entry.operation),
663        permission_type: permission_to_wire(entry.permission_type),
664        ..Default::default()
665    })
666}
667
668fn scram_to_kraft(record: &MetadataRecord) -> Result<Vec<KraftMetadataRecord>, TranslateError> {
669    match record {
670        MetadataRecord::V1ScramCredential(scram) => {
671            let iterations =
672                i32::try_from(scram.iterations).map_err(|_| TranslateError::InvalidValue {
673                    field: "scram iterations",
674                    detail: format!("{} exceeds i32", scram.iterations),
675                })?;
676            Ok(vec![KraftMetadataRecord::UserScramCredential(
677                UserScramCredentialRecord {
678                    name: scram.user.clone(),
679                    mechanism: scram_mechanism_to_wire(scram.mechanism)?,
680                    salt: bytes::Bytes::copy_from_slice(&scram.salt),
681                    stored_key: bytes::Bytes::copy_from_slice(&scram.stored_key),
682                    server_key: bytes::Bytes::copy_from_slice(&scram.server_key),
683                    iterations,
684                    ..Default::default()
685                },
686            )])
687        }
688        MetadataRecord::V1DeleteScramCredential(scram) => {
689            Ok(vec![KraftMetadataRecord::RemoveUserScramCredential(
690                RemoveUserScramCredentialRecord {
691                    name: scram.user.clone(),
692                    mechanism: scram_mechanism_to_wire(scram.mechanism)?,
693                    ..Default::default()
694                },
695            )])
696        }
697        _ => unreachable!("non-SCRAM record routed to scram_to_kraft"),
698    }
699}
700
701// exhaustive match over MetadataRecord
702fn to_kraft_iter(
703    rec: &MetadataRecord,
704    image: &MetadataImage,
705) -> Result<vec::IntoIter<KraftMetadataRecord>, TranslateError> {
706    let recs: Vec<KraftMetadataRecord> = match rec {
707        MetadataRecord::V1FeatureLevel(f) => {
708            vec![KraftMetadataRecord::FeatureLevel(KFeatureLevelRecord {
709                name: f.name.clone(),
710                feature_level: f.level,
711                ..Default::default()
712            })]
713        }
714        MetadataRecord::V1BrokerRegistration(b) => {
715            vec![KraftMetadataRecord::RegisterBroker(
716                register_broker_to_kraft(b)?,
717            )]
718        }
719        MetadataRecord::V1ControllerRegistration(c) => {
720            vec![KraftMetadataRecord::RegisterController(
721                register_controller_to_kraft(c)?,
722            )]
723        }
724        MetadataRecord::V1UnregisterBroker(u) => {
725            vec![KraftMetadataRecord::UnregisterBroker(
726                KUnregisterBrokerRecord {
727                    broker_id: node_id_to_wire(u.node_id, "broker_id")?,
728                    broker_epoch: u.broker_epoch,
729                    ..Default::default()
730                },
731            )]
732        }
733        MetadataRecord::V1BrokerRegistrationChange(change) => {
734            vec![KraftMetadataRecord::BrokerRegistrationChange(
735                broker_registration_change_to_kraft(change)?,
736            )]
737        }
738        MetadataRecord::V1UnregisterController(u) => {
739            vec![KraftMetadataRecord::UnregisterController(
740                KUnregisterControllerRecord {
741                    controller_id: node_id_to_wire(u.node_id, "controller_id")?,
742                    ..Default::default()
743                },
744            )]
745        }
746        MetadataRecord::V1ScramCredential(_) | MetadataRecord::V1DeleteScramCredential(_) => {
747            scram_to_kraft(rec)?
748        }
749        MetadataRecord::V1AccessControlEntry(entry) => vec![acl_to_kraft(entry)],
750        MetadataRecord::V1ClientQuota(q) => {
751            vec![KraftMetadataRecord::ClientQuota(client_quota_to_kraft(q))]
752        }
753        MetadataRecord::V1DelegationToken(t) => {
754            vec![KraftMetadataRecord::DelegationToken(
755                delegation_token_to_kraft(t),
756            )]
757        }
758        MetadataRecord::V1DeleteDelegationToken(t) => {
759            vec![KraftMetadataRecord::RemoveDelegationToken(
760                RemoveDelegationTokenRecord {
761                    token_id: t.token_id.clone(),
762                    ..Default::default()
763                },
764            )]
765        }
766        MetadataRecord::V1BrokerConfig(c) => {
767            vec![KraftMetadataRecord::Config(ConfigRecord {
768                resource_type: 4, // ConfigResource.Type.BROKER
769                resource_name: if c.node_id == DEFAULT_BROKER_CONFIG_NODE_ID {
770                    String::new()
771                } else {
772                    c.node_id.to_string()
773                },
774                name: c.config_name.clone(),
775                value: c.config_value.clone(),
776                ..Default::default()
777            })]
778        }
779        // ----- topic / partition / remove-topic / topic-config -----
780        MetadataRecord::V1Topic(t) => {
781            vec![KraftMetadataRecord::Topic(KTopicRecord {
782                name: t.name.clone(),
783                topic_id: to_kuuid(t.topic_id),
784                ..Default::default()
785            })]
786        }
787        MetadataRecord::V1Partition(p) => {
788            let topic = image
789                .topic(&p.topic)
790                .ok_or_else(|| TranslateError::UnknownTopicName(p.topic.clone()))?;
791            vec![KraftMetadataRecord::Partition(partition_to_kraft(
792                p,
793                topic.topic_id,
794                image,
795            )?)]
796        }
797        MetadataRecord::V1DeleteTopic(d) => {
798            let topic = image
799                .topic(&d.name)
800                .ok_or_else(|| TranslateError::UnknownTopicName(d.name.clone()))?;
801            vec![KraftMetadataRecord::RemoveTopic(RemoveTopicRecord {
802                topic_id: to_kuuid(topic.topic_id),
803                ..Default::default()
804            })]
805        }
806        MetadataRecord::V1TopicConfig(c) => topic_config_to_kraft(c, image),
807        MetadataRecord::V1GroupConfig(c) => group_config_to_kraft(c, image),
808        MetadataRecord::V1ProducerIds(record) => {
809            vec![KraftMetadataRecord::ProducerIds(KProducerIdsRecord {
810                broker_id: i32::try_from(record.broker_id.0).map_err(|_| {
811                    TranslateError::InvalidValue {
812                        field: "producer IDs broker id",
813                        detail: format!("node_id {} exceeds i32", record.broker_id),
814                    }
815                })?,
816                broker_epoch: record.broker_epoch,
817                next_producer_id: record.next_producer_id,
818                ..KProducerIdsRecord::default()
819            })]
820        }
821        MetadataRecord::V1DeleteAccessControlEntry(filter) => delete_acl_to_kraft(filter, image),
822        // KIP-714 CLIENT_METRICS is a standard ConfigRecord resource (type 16).
823        MetadataRecord::V1ClientMetricsConfig(config) => {
824            client_metrics_config_to_kraft(config, image)
825        }
826        // KIP-858 directory-assignment delta. Kafka's standard
827        // PartitionChangeRecord carries the complete directories vector while
828        // leaving every unrelated partition field unset.
829        MetadataRecord::V1PartitionDirAssignment(r) => {
830            vec![KraftMetadataRecord::PartitionChange(
831                partition_dir_assignment_to_kraft(r, image)?,
832            )]
833        }
834        MetadataRecord::V1PartitionElr(r) => vec![KraftMetadataRecord::PartitionChange(
835            partition_state_to_kraft(
836                image,
837                &r.topic,
838                r.partition,
839                -1,
840                Some(&r.eligible_leader_replicas),
841                Some(&r.last_known_elr),
842            )?,
843        )],
844        MetadataRecord::V1PartitionRecovery(r) => vec![KraftMetadataRecord::PartitionChange(
845            partition_state_to_kraft(image, &r.topic, r.partition, r.state as i8, None, None)?,
846        )],
847        MetadataRecord::V1PartitionUpdate(r) => vec![KraftMetadataRecord::PartitionChange(
848            partition_update_to_kraft(r, image)?,
849        )],
850        // Krabka-only records ride the private carrier, at the tag that
851        // `private_tag` assigns.
852        // - `V1FeaturesEpoch` is a snapshot-only state pin. Kafka derives this
853        //   epoch from FeatureLevel record offsets, but a materialized snapshot
854        //   no longer contains those offsets, so the derived value is kept.
855        // - `V1PartitionOffsetAdvance` is a diskless offset delta.
856        // - KFC-9 freeze registry and break-glass proposals. KIP-631 has no
857        //   counterpart for either, and `NoCounterpart` is not an option: the
858        //   controller submits all three through `submit_change`, which
859        //   encodes every record it appends.
860        MetadataRecord::V1FeaturesEpoch(_)
861        | MetadataRecord::V1PartitionOffsetAdvance(_)
862        | MetadataRecord::V1TopicFreeze(_)
863        | MetadataRecord::V1BreakGlassProposal(_)
864        | MetadataRecord::V1DeleteBreakGlassProposal(_) => vec![private_carrier(rec)?],
865        // ----- no KIP-631 metadata counterpart -----
866        MetadataRecord::V1Voters(_) => {
867            return Err(TranslateError::NoCounterpart("V1Voters"));
868        }
869        MetadataRecord::V1KRaftVersion(_) => {
870            return Err(TranslateError::NoCounterpart("V1KRaftVersion"));
871        }
872    };
873    Ok(recs.into_iter())
874}
875
876/// Krabka-private tags for records that KIP-631 has no schema for.
877///
878/// Each one rides as the only tagged field of a `NoOpRecord` (apiKey 20). Kafka
879/// reads the record as a `NoOpRecord`: its generated reader keeps an undeclared
880/// tag in `_unknownTaggedFields` (KIP-482), and `MetadataDelta` and
881/// `QuorumController` skip a `NoOpRecord` on replay. So
882/// `kafka-dump-log --cluster-metadata-decoder` and `kafka-metadata-shell`
883/// decode every krabka log segment and checkpoint.
884///
885/// `NoOpRecord` declares no tagged fields. The tags start at
886/// [`PRIVATE_TAG_FLOOR`], far above any tag a later Kafka schema is likely to
887/// add.
888///
889/// # The 1.x tag table
890///
891/// This table is part of the krabka 1.x on-disk contract. A tag carries exactly
892/// one [`MetadataRecord`] variant, and [`from_private_carrier`] refuses any
893/// other variant under it. Never reassign a tag. A new private record takes a
894/// new tag above 1006.
895///
896/// | Tag  | Variant                                       |
897/// | :--- | :-------------------------------------------- |
898/// | 1001 | [`MetadataRecord::V1FeaturesEpoch`]           |
899/// | 1002 | None. Burned: never written, never reused.    |
900/// | 1003 | [`MetadataRecord::V1PartitionOffsetAdvance`]  |
901/// | 1004 | [`MetadataRecord::V1TopicFreeze`]             |
902/// | 1005 | [`MetadataRecord::V1BreakGlassProposal`]      |
903/// | 1006 | [`MetadataRecord::V1DeleteBreakGlassProposal`]|
904///
905/// # The tag body
906///
907/// A big-endian `i16` [`PRIVATE_RECORD_VERSION`], then the wincode-serialized
908/// [`MetadataRecord`].
909const PRIVATE_FEATURES_EPOCH_TAG: u32 = 1001;
910/// Diskless offset-advance delta carried verbatim so it stays a per-partition
911/// increment on apply (never a full-record replace).
912const PRIVATE_PARTITION_OFFSET_ADVANCE_TAG: u32 = 1003;
913// 1002 is burned. No build writes it, and the reader refuses it as an unknown
914// private tag. Do not reuse it.
915/// KFC-9 write-freeze registry entry.
916const PRIVATE_TOPIC_FREEZE_TAG: u32 = 1004;
917/// KFC-9 break-glass proposal, carried verbatim so the approval list, the
918/// consumption stamp, and the signatures all survive a snapshot.
919const PRIVATE_BREAK_GLASS_PROPOSAL_TAG: u32 = 1005;
920/// KFC-9 break-glass proposal tombstone, which the expiry sweep emits.
921const PRIVATE_DELETE_BREAK_GLASS_PROPOSAL_TAG: u32 = 1006;
922
923/// The lowest tag of the krabka-private range. A lone `NoOpRecord` tag below
924/// it is not krabka's, so it has no counterpart; one at or above it that the
925/// table does not assign is [`TranslateError::UnknownPrivateTag`].
926const PRIVATE_TAG_FLOOR: u32 = 1001;
927
928/// The tags that [`private_carrier`] writes and [`from_private_carrier`] reads.
929const PRIVATE_TAGS: [u32; 5] = [
930    PRIVATE_FEATURES_EPOCH_TAG,
931    PRIVATE_PARTITION_OFFSET_ADVANCE_TAG,
932    PRIVATE_TOPIC_FREEZE_TAG,
933    PRIVATE_BREAK_GLASS_PROPOSAL_TAG,
934    PRIVATE_DELETE_BREAK_GLASS_PROPOSAL_TAG,
935];
936
937/// The version of a krabka-private `NoOpRecord` tag body, the big-endian `i16`
938/// in front of the wincode [`MetadataRecord`]. It is part of the krabka 1.x
939/// on-disk contract.
940pub const PRIVATE_RECORD_VERSION: i16 = 0;
941
942/// The tag that carries `rec`, per the table on
943/// [`PRIVATE_FEATURES_EPOCH_TAG`], or `None` for a variant that does not ride
944/// a private tag.
945fn private_tag(rec: &MetadataRecord) -> Option<u32> {
946    match rec {
947        MetadataRecord::V1FeaturesEpoch(_) => Some(PRIVATE_FEATURES_EPOCH_TAG),
948        MetadataRecord::V1PartitionOffsetAdvance(_) => Some(PRIVATE_PARTITION_OFFSET_ADVANCE_TAG),
949        MetadataRecord::V1TopicFreeze(_) => Some(PRIVATE_TOPIC_FREEZE_TAG),
950        MetadataRecord::V1BreakGlassProposal(_) => Some(PRIVATE_BREAK_GLASS_PROPOSAL_TAG),
951        MetadataRecord::V1DeleteBreakGlassProposal(_) => {
952            Some(PRIVATE_DELETE_BREAK_GLASS_PROPOSAL_TAG)
953        }
954        _ => None,
955    }
956}
957
958/// Wraps `rec` in a `NoOpRecord` as the tagged field that [`private_tag`]
959/// assigns it. See [`PRIVATE_FEATURES_EPOCH_TAG`] for the body layout and why
960/// Kafka's tools can read the result.
961///
962/// # Errors
963/// [`TranslateError::NoCounterpart`] for a variant that has no private tag,
964/// and [`TranslateError::Encode`] if wincode fails.
965fn private_carrier(rec: &MetadataRecord) -> Result<KraftMetadataRecord, TranslateError> {
966    let tag = private_tag(rec).ok_or(TranslateError::NoCounterpart("private carrier"))?;
967    let record = <serde_wincode::SerdeCompat<MetadataRecord>>::serialize(rec)
968        .map_err(|e| TranslateError::Encode(e.to_string()))?;
969    let mut body = Vec::with_capacity(2 + record.len());
970    body.extend_from_slice(&PRIVATE_RECORD_VERSION.to_be_bytes());
971    body.extend_from_slice(&record);
972    Ok(KraftMetadataRecord::NoOp(NoOpRecord {
973        unknown_tagged_fields: UnknownTaggedFields(vec![UnknownTaggedField {
974            tag,
975            bytes: Bytes::from(body),
976        }]),
977    }))
978}
979
980/// Reads the record that [`private_carrier`] wrapped.
981///
982/// # Errors
983/// - [`TranslateError::NoCounterpart`] for a `NoOpRecord` that does not carry
984///   exactly one tagged field at a tag of [`PRIVATE_TAG_FLOOR`] or above, such
985///   as the empty KIP-835 no-op that Kafka writes.
986/// - [`TranslateError::UnknownPrivateTag`] for a lone private-range tag that
987///   the table does not assign, 1002 included.
988/// - [`TranslateError::UnknownPrivateRecordVersion`] for a body whose version
989///   is not [`PRIVATE_RECORD_VERSION`], which includes a 0.x body that has no
990///   version.
991/// - [`TranslateError::PrivateTagMismatch`] for a record that the table
992///   assigns to another tag.
993/// - [`TranslateError::TrailingPrivateRecordBytes`] for a body with bytes
994///   after its record.
995/// - [`TranslateError::Decode`] for a body too short to hold its version, or a
996///   malformed record.
997fn from_private_carrier(no_op: &NoOpRecord) -> Result<MetadataRecord, TranslateError> {
998    let [field] = no_op.unknown_tagged_fields.0.as_slice() else {
999        return Err(TranslateError::NoCounterpart("NoOp"));
1000    };
1001    let tag = field.tag;
1002    if tag < PRIVATE_TAG_FLOOR {
1003        return Err(TranslateError::NoCounterpart("NoOp"));
1004    }
1005    if !PRIVATE_TAGS.contains(&tag) {
1006        return Err(TranslateError::UnknownPrivateTag(tag));
1007    }
1008    let Some((version, record)) = field.bytes.split_first_chunk::<2>() else {
1009        return Err(TranslateError::Decode(format!(
1010            "krabka-private NoOpRecord tag {tag} body is {} bytes, too short for its i16 version",
1011            field.bytes.len()
1012        )));
1013    };
1014    let version = i16::from_be_bytes(*version);
1015    if version != PRIVATE_RECORD_VERSION {
1016        return Err(TranslateError::UnknownPrivateRecordVersion { tag, version });
1017    }
1018    let mut rest: &[u8] = record;
1019    let rec = <serde_wincode::SerdeCompat<MetadataRecord> as SchemaRead<'_, DefaultConfig>>::get(
1020        &mut rest,
1021    )
1022    .map_err(|e| TranslateError::Decode(e.to_string()))?;
1023    if !rest.is_empty() {
1024        return Err(TranslateError::TrailingPrivateRecordBytes {
1025            tag,
1026            trailing: rest.len(),
1027        });
1028    }
1029    let expected_tag = private_tag(&rec);
1030    if expected_tag != Some(tag) {
1031        return Err(TranslateError::PrivateTagMismatch { tag, expected_tag });
1032    }
1033    Ok(rec)
1034}
1035
1036fn register_broker_to_kraft(
1037    b: &BrokerRegistrationRecord,
1038) -> Result<RegisterBrokerRecord, TranslateError> {
1039    // A JVM broker expects every endpoint to carry a valid listener name.
1040    // Encode the real listeners verbatim. Only old single-listener Krabka
1041    // records need the empty-name compatibility carrier for their separate
1042    // top-level host/port fields.
1043    let end_points = if b.endpoints.is_empty() {
1044        vec![KBrokerEndpoint {
1045            host: b.host.clone(),
1046            port: b.port,
1047            ..Default::default()
1048        }]
1049    } else {
1050        b.endpoints
1051            .iter()
1052            .map(|e| KBrokerEndpoint {
1053                name: e.name.clone(),
1054                host: e.host.clone(),
1055                port: e.port,
1056                security_protocol: protocol_to_wire(e.protocol),
1057                ..Default::default()
1058            })
1059            .collect()
1060    };
1061    let features = b
1062        .features
1063        .iter()
1064        .map(
1065            |(name, &(min_supported_version, max_supported_version))| KBrokerFeature {
1066                name: name.clone(),
1067                min_supported_version,
1068                max_supported_version,
1069                ..Default::default()
1070            },
1071        )
1072        .collect();
1073    Ok(RegisterBrokerRecord {
1074        broker_id: i32::try_from(b.node_id.0).map_err(|_| TranslateError::InvalidValue {
1075            field: "broker_id",
1076            detail: format!("node_id {} exceeds i32", b.node_id),
1077        })?,
1078        rack: b.rack.clone(),
1079        end_points,
1080        features,
1081        log_dirs: b.log_dirs.iter().copied().map(to_kuuid).collect(),
1082        cordoned_log_dirs: b
1083            .cordoned_log_dirs
1084            .as_ref()
1085            .map(|dirs| dirs.iter().copied().map(to_kuuid).collect()),
1086        broker_epoch: b.broker_epoch,
1087        incarnation_id: to_kuuid(b.incarnation_id),
1088        fenced: b.fenced,
1089        in_controlled_shutdown: b.in_controlled_shutdown,
1090        ..Default::default()
1091    })
1092}
1093
1094fn register_controller_to_kraft(
1095    controller: &ControllerRegistrationRecord,
1096) -> Result<RegisterControllerRecord, TranslateError> {
1097    let controller_id =
1098        i32::try_from(controller.node_id.0).map_err(|_| TranslateError::InvalidValue {
1099            field: "controller_id",
1100            detail: format!("node_id {} exceeds i32", controller.node_id),
1101        })?;
1102    let end_points = controller
1103        .endpoints
1104        .iter()
1105        .map(|endpoint| KControllerEndpoint {
1106            name: endpoint.name.clone(),
1107            host: endpoint.host.clone(),
1108            port: endpoint.port,
1109            security_protocol: protocol_to_wire(endpoint.protocol),
1110            ..Default::default()
1111        })
1112        .collect();
1113    let features = controller
1114        .features
1115        .iter()
1116        .map(
1117            |(name, &(min_supported_version, max_supported_version))| KControllerFeature {
1118                name: name.clone(),
1119                min_supported_version,
1120                max_supported_version,
1121                ..Default::default()
1122            },
1123        )
1124        .collect();
1125    Ok(RegisterControllerRecord {
1126        controller_id,
1127        incarnation_id: to_kuuid(controller.incarnation_id),
1128        zk_migration_ready: controller.zk_migration_ready,
1129        end_points,
1130        features,
1131        ..Default::default()
1132    })
1133}
1134
1135fn partition_to_kraft(
1136    p: &PartitionRecord,
1137    topic_id: uuid::Uuid,
1138    image: &MetadataImage,
1139) -> Result<KPartitionRecord, TranslateError> {
1140    let cast = |v: &[NodeId], field: &'static str| -> Result<Vec<i32>, TranslateError> {
1141        v.iter()
1142            .map(|n| {
1143                i32::try_from(n.0).map_err(|_| TranslateError::InvalidValue {
1144                    field,
1145                    detail: format!("node id {n} exceeds i32"),
1146                })
1147            })
1148            .collect()
1149    };
1150    let (eligible, last_known) = image.partition_elr(&p.topic, p.partition);
1151    let has_elr = !eligible.is_empty() || !last_known.is_empty();
1152    Ok(KPartitionRecord {
1153        partition_id: p.partition,
1154        topic_id: to_kuuid(topic_id),
1155        replicas: cast(&p.replicas, "partition replicas")?,
1156        isr: cast(&p.isr, "partition isr")?,
1157        removing_replicas: cast(&p.removing_replicas, "partition removing_replicas")?,
1158        adding_replicas: cast(&p.adding_replicas, "partition adding_replicas")?,
1159        leader: partition_leader_to_wire(p.leader, "partition leader")?,
1160        // Wire boundary: KRaft's KPartitionRecord carries leader epoch as a raw
1161        // int32, so unwrap the LeaderEpoch newtype here.
1162        leader_epoch: p.leader_epoch.get(),
1163        partition_epoch: p.partition_epoch,
1164        // KIP-858: per-replica log-directory assignment, carried at KRaft v1+.
1165        directories: p.directories.iter().map(|u| to_kuuid(*u)).collect(),
1166        leader_recovery_state: image.leader_recovery_state(&p.topic, p.partition) as i8,
1167        eligible_leader_replicas: has_elr
1168            .then(|| cast(eligible, "eligible leader replica"))
1169            .transpose()?,
1170        last_known_elr: has_elr
1171            .then(|| cast(last_known, "last known eligible leader replica"))
1172            .transpose()?,
1173        ..Default::default()
1174    })
1175}
1176
1177fn partition_dir_assignment_to_kraft(
1178    assignment: &PartitionDirAssignmentRecord,
1179    image: &MetadataImage,
1180) -> Result<KPartitionChangeRecord, TranslateError> {
1181    let topic = image
1182        .topic(&assignment.topic)
1183        .ok_or_else(|| TranslateError::UnknownTopicName(assignment.topic.clone()))?;
1184    let partition = image
1185        .partition(&assignment.topic, assignment.partition)
1186        .ok_or_else(|| TranslateError::InvalidReference {
1187            field: "partition directory assignment",
1188            detail: format!(
1189                "unknown partition {}-{}",
1190                assignment.topic, assignment.partition
1191            ),
1192        })?;
1193    let replica_slot = partition
1194        .replicas
1195        .iter()
1196        .position(|replica| *replica == assignment.replica)
1197        .ok_or_else(|| TranslateError::InvalidReference {
1198            field: "partition directory assignment",
1199            detail: format!(
1200                "broker {} is not a replica of {}-{}",
1201                assignment.replica, assignment.topic, assignment.partition
1202            ),
1203        })?;
1204
1205    // An empty list is every replica `MIGRATING`, as Kafka's
1206    // `PartitionRegistration.defaultToMigrating` reads it.
1207    let mut directories = partition.directories.clone();
1208    directories.resize(partition.replicas.len(), MIGRATING);
1209    if let Some(directory) = directories.get_mut(replica_slot) {
1210        *directory = assignment.directory;
1211    }
1212
1213    Ok(KPartitionChangeRecord {
1214        partition_id: assignment.partition,
1215        topic_id: to_kuuid(topic.topic_id),
1216        directories: Some(directories.into_iter().map(to_kuuid).collect()),
1217        ..Default::default()
1218    })
1219}
1220
1221fn partition_state_to_kraft(
1222    image: &MetadataImage,
1223    topic: &str,
1224    partition: i32,
1225    leader_recovery_state: i8,
1226    eligible: Option<&[NodeId]>,
1227    last_known: Option<&[NodeId]>,
1228) -> Result<KPartitionChangeRecord, TranslateError> {
1229    let topic_id = image
1230        .topic(topic)
1231        .ok_or_else(|| TranslateError::UnknownTopicName(topic.to_owned()))?
1232        .topic_id;
1233    let nodes = |values: &[NodeId], field| {
1234        values
1235            .iter()
1236            .map(|node| {
1237                i32::try_from(node.0).map_err(|_| TranslateError::InvalidValue {
1238                    field,
1239                    detail: format!("node id {node} exceeds i32"),
1240                })
1241            })
1242            .collect::<Result<Vec<_>, _>>()
1243    };
1244    Ok(KPartitionChangeRecord {
1245        partition_id: partition,
1246        topic_id: to_kuuid(topic_id),
1247        leader_recovery_state,
1248        eligible_leader_replicas: eligible
1249            .map(|values| nodes(values, "eligible leader replica"))
1250            .transpose()?,
1251        last_known_elr: last_known
1252            .map(|values| nodes(values, "last known eligible leader replica"))
1253            .transpose()?,
1254        ..Default::default()
1255    })
1256}
1257
1258fn partition_update_to_kraft(
1259    update: &crate::PartitionUpdateRecord,
1260    image: &MetadataImage,
1261) -> Result<KPartitionChangeRecord, TranslateError> {
1262    let current = image
1263        .partition(&update.partition.topic, update.partition.partition)
1264        .ok_or_else(|| TranslateError::InvalidReference {
1265            field: "partition update",
1266            detail: format!(
1267                "unknown partition {}-{}",
1268                update.partition.topic, update.partition.partition
1269            ),
1270        })?;
1271    let mut change = partition_state_to_kraft(
1272        image,
1273        &update.partition.topic,
1274        update.partition.partition,
1275        update.recovery_state.map_or(-1, |state| state as i8),
1276        update.eligible_leader_replicas.as_deref(),
1277        update.last_known_elr.as_deref(),
1278    )?;
1279    let cast = |values: &[NodeId], field| {
1280        values
1281            .iter()
1282            .map(|node| {
1283                i32::try_from(node.0).map_err(|_| TranslateError::InvalidValue {
1284                    field,
1285                    detail: format!("node id {node} exceeds i32"),
1286                })
1287            })
1288            .collect::<Result<Vec<_>, _>>()
1289    };
1290    if update.partition.replicas != current.replicas {
1291        change.replicas = Some(cast(&update.partition.replicas, "partition replicas")?);
1292    }
1293    if update.partition.isr != current.isr {
1294        change.isr = Some(cast(&update.partition.isr, "partition isr")?);
1295    }
1296    if update.partition.removing_replicas != current.removing_replicas {
1297        change.removing_replicas = Some(cast(
1298            &update.partition.removing_replicas,
1299            "partition removing replicas",
1300        )?);
1301    }
1302    if update.partition.adding_replicas != current.adding_replicas {
1303        change.adding_replicas = Some(cast(
1304            &update.partition.adding_replicas,
1305            "partition adding replicas",
1306        )?);
1307    }
1308    if update.partition.leader != current.leader {
1309        change.leader = partition_leader_to_wire(update.partition.leader, "partition leader")?;
1310    }
1311    if update.partition.directories != current.directories {
1312        change.directories = Some(
1313            update
1314                .partition
1315                .directories
1316                .iter()
1317                .copied()
1318                .map(to_kuuid)
1319                .collect(),
1320        );
1321    }
1322    Ok(change)
1323}
1324
1325fn client_quota_to_kraft(q: &ClientQuotaRecord) -> KClientQuotaRecord {
1326    let entity = q
1327        .entity
1328        .iter()
1329        .map(|e| EntityData {
1330            entity_type: e.entity_type.clone(),
1331            entity_name: e.entity_name.clone(),
1332            ..Default::default()
1333        })
1334        .collect();
1335    // KIP-631 splits Krabka's Option<f64> into (value, remove): None => the
1336    // remove tombstone; Some(v) => value with remove=false.
1337    let (value, remove) = q.config_value.map_or((0.0, true), |v| (v, false));
1338    KClientQuotaRecord {
1339        entity,
1340        key: q.config_key.clone(),
1341        value,
1342        remove,
1343        ..Default::default()
1344    }
1345}
1346
1347fn delegation_token_to_kraft(t: &DelegationTokenRecord) -> KDelegationTokenRecord {
1348    KDelegationTokenRecord {
1349        owner: t.owner.to_string(),
1350        requester: t.requester.to_string(),
1351        renewers: t.renewers.iter().map(ToString::to_string).collect(),
1352        issue_timestamp: t.issue_timestamp_ms,
1353        max_timestamp: t.max_timestamp_ms,
1354        expiration_timestamp: t.expiry_timestamp_ms,
1355        token_id: t.token_id.clone(),
1356        ..Default::default()
1357    }
1358}
1359
1360/// Convert one KIP-631 record back to its [`MetadataRecord`] counterpart.
1361///
1362/// Inverse of [`to_kraft`]. A `Config` record routes back to
1363/// `V1TopicConfig` or `V1BrokerConfig` by `resource_type`. Topic-config
1364/// records decode to single-key singletons that the image merges.
1365///
1366/// # Errors
1367/// [`TranslateError::UnknownTopicId`] when a topic or partition references a
1368/// topic id absent from `image`, [`TranslateError::NoCounterpart`] for
1369/// records this layer does not model, [`TranslateError::InvalidValue`] for
1370/// out-of-range enum values, negative ids, unparsable strings and a directory
1371/// list whose length does not match the replicas in the same record, and
1372/// [`TranslateError::InvalidReference`] for a partition change that the image
1373/// cannot apply: an unknown partition, a directory list whose length does not
1374/// match the image's replicas, or an epoch already at its maximum.
1375// exhaustive match over KraftMetadataRecord
1376pub fn from_kraft(
1377    rec: &KraftMetadataRecord,
1378    image: &MetadataImage,
1379) -> Result<MetadataRecord, TranslateError> {
1380    match rec {
1381        KraftMetadataRecord::FeatureLevel(f) => {
1382            Ok(MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
1383                name: f.name.clone(),
1384                level: f.feature_level,
1385            }))
1386        }
1387        KraftMetadataRecord::RegisterBroker(b) => Ok(MetadataRecord::V1BrokerRegistration(
1388            register_broker_from_kraft(b)?,
1389        )),
1390        KraftMetadataRecord::BrokerRegistrationChange(change) => {
1391            Ok(MetadataRecord::V1BrokerRegistrationChange(
1392                broker_registration_change_from_kraft(change)?,
1393            ))
1394        }
1395        KraftMetadataRecord::RegisterController(c) => Ok(MetadataRecord::V1ControllerRegistration(
1396            register_controller_from_kraft(c)?,
1397        )),
1398        KraftMetadataRecord::UnregisterController(u) => Ok(MetadataRecord::V1UnregisterController(
1399            UnregisterControllerRecord {
1400                node_id: node_id_from_wire(u.controller_id, "unregister controller id")?,
1401            },
1402        )),
1403        KraftMetadataRecord::UnregisterBroker(u) => {
1404            Ok(MetadataRecord::V1UnregisterBroker(UnregisterBrokerRecord {
1405                node_id: node_id_from_wire(u.broker_id, "unregister broker id")?,
1406                broker_epoch: u.broker_epoch,
1407            }))
1408        }
1409        KraftMetadataRecord::UserScramCredential(s) => {
1410            Ok(MetadataRecord::V1ScramCredential(ScramCredentialRecord {
1411                user: s.name.clone(),
1412                mechanism: scram_mechanism_from_wire(s.mechanism)?,
1413                salt: s.salt.to_vec(),
1414                stored_key: s.stored_key.to_vec(),
1415                server_key: s.server_key.to_vec(),
1416                iterations: u32::try_from(s.iterations).map_err(|_| {
1417                    TranslateError::InvalidValue {
1418                        field: "scram iterations",
1419                        detail: format!("{} is negative", s.iterations),
1420                    }
1421                })?,
1422            }))
1423        }
1424        KraftMetadataRecord::RemoveUserScramCredential(s) => Ok(
1425            MetadataRecord::V1DeleteScramCredential(DeleteScramCredentialRecord {
1426                user: s.name.clone(),
1427                mechanism: scram_mechanism_from_wire(s.mechanism)?,
1428            }),
1429        ),
1430        KraftMetadataRecord::AccessControlEntry(e) => {
1431            Ok(MetadataRecord::V1AccessControlEntry(AclEntry {
1432                resource_type: resource_type_from_wire(e.resource_type)?,
1433                resource_name: e.resource_name.clone(),
1434                pattern_type: pattern_type_from_wire(e.pattern_type)?,
1435                principal: e.principal.clone(),
1436                host: e.host.clone(),
1437                operation: operation_from_wire(e.operation)?,
1438                permission_type: permission_from_wire(e.permission_type)?,
1439            }))
1440        }
1441        KraftMetadataRecord::ClientQuota(q) => {
1442            Ok(MetadataRecord::V1ClientQuota(client_quota_from_kraft(q)))
1443        }
1444        KraftMetadataRecord::ProducerIds(record) => {
1445            Ok(MetadataRecord::V1ProducerIds(ProducerIdsRecord {
1446                broker_id: node_id_from_wire(record.broker_id, "producer IDs broker id")?,
1447                broker_epoch: record.broker_epoch,
1448                next_producer_id: record.next_producer_id,
1449            }))
1450        }
1451        KraftMetadataRecord::DelegationToken(t) => Ok(MetadataRecord::V1DelegationToken(
1452            delegation_token_from_kraft(t)?,
1453        )),
1454        KraftMetadataRecord::RemoveDelegationToken(t) => Ok(
1455            MetadataRecord::V1DeleteDelegationToken(DeleteDelegationTokenRecord {
1456                token_id: t.token_id.clone(),
1457            }),
1458        ),
1459        KraftMetadataRecord::Config(c) => config_from_kraft(c, image),
1460        KraftMetadataRecord::Topic(t) => Ok(MetadataRecord::V1Topic(topic_from_kraft(t, image))),
1461        KraftMetadataRecord::Partition(p) => partition_from_kraft(p, image),
1462        KraftMetadataRecord::PartitionChange(p) => partition_change_from_kraft(p, image),
1463        KraftMetadataRecord::RemoveTopic(t) => {
1464            let id = from_kuuid(t.topic_id);
1465            let name = topic_name_for_id(image, id).ok_or(TranslateError::UnknownTopicId(id))?;
1466            Ok(MetadataRecord::V1DeleteTopic(DeleteTopicRecord { name }))
1467        }
1468        KraftMetadataRecord::RemoveAccessControlEntry(r) => {
1469            // Resolve the id back to the concrete entry by rescanning the image
1470            // for the entry whose content-hash matches, then emit a pinned
1471            // (every-axis) filter so `apply` removes exactly that entry. The
1472            // matching add always precedes this remove in log order, so the
1473            // entry is present when this decodes.
1474            let entry = image
1475                .all_acls()
1476                .find(|e| acl_id(e) == r.id)
1477                .ok_or_else(|| TranslateError::UnknownAclId(from_kuuid(r.id)))?;
1478            Ok(MetadataRecord::V1DeleteAccessControlEntry(exact_filter(
1479                entry,
1480            )))
1481        }
1482        // Krabka-private records ride a `NoOpRecord` tagged field (see
1483        // `private_carrier`).
1484        KraftMetadataRecord::NoOp(no_op) => from_private_carrier(no_op),
1485        other => Err(TranslateError::NoCounterpart(kraft_variant_name(other))),
1486    }
1487}
1488
1489fn kraft_variant_name(rec: &KraftMetadataRecord) -> &'static str {
1490    match rec {
1491        KraftMetadataRecord::BrokerRegistrationChange(_) => "BrokerRegistrationChange",
1492        KraftMetadataRecord::PartitionChange(_) => "PartitionChange",
1493        KraftMetadataRecord::NoOp(_) => "NoOp",
1494        KraftMetadataRecord::BeginTransaction(_) => "BeginTransaction",
1495        KraftMetadataRecord::EndTransaction(_) => "EndTransaction",
1496        KraftMetadataRecord::RemoveAccessControlEntry(_) => "RemoveAccessControlEntry",
1497        KraftMetadataRecord::Unknown { .. } => "Unknown",
1498        _ => "unmodeled",
1499    }
1500}
1501
1502fn register_broker_from_kraft(
1503    b: &RegisterBrokerRecord,
1504) -> Result<BrokerRegistrationRecord, TranslateError> {
1505    // Older Krabka logs used an empty-name leading endpoint solely to carry
1506    // the legacy top-level pair. New Krabka and JVM records contain only real
1507    // listener endpoints; in that case the first endpoint supplies the
1508    // legacy fields while every endpoint stays in the listener list.
1509    let (host, port, endpoints_on_wire) = match b.end_points.split_first() {
1510        Some((head, rest)) if head.name.is_empty() => (head.host.clone(), head.port, rest),
1511        Some((head, _)) => (head.host.clone(), head.port, b.end_points.as_slice()),
1512        None => (String::new(), 0, &[][..]),
1513    };
1514    let endpoints = endpoints_on_wire
1515        .iter()
1516        .map(|e| {
1517            Ok(BrokerEndpoint {
1518                name: e.name.clone(),
1519                host: e.host.clone(),
1520                port: e.port,
1521                protocol: protocol_from_wire(e.security_protocol)?,
1522            })
1523        })
1524        .collect::<Result<Vec<_>, TranslateError>>()?;
1525    let features = b
1526        .features
1527        .iter()
1528        .map(|feature| {
1529            (
1530                feature.name.clone(),
1531                (feature.min_supported_version, feature.max_supported_version),
1532            )
1533        })
1534        .collect();
1535    let log_dirs = b.log_dirs.iter().copied().map(from_kuuid).collect();
1536    Ok(BrokerRegistrationRecord {
1537        node_id: node_id_from_wire(b.broker_id, "register broker id")?,
1538        broker_epoch: b.broker_epoch,
1539        incarnation_id: from_kuuid(b.incarnation_id),
1540        host,
1541        port,
1542        rack: b.rack.clone(),
1543        endpoints,
1544        log_dirs,
1545        fenced: b.fenced,
1546        in_controlled_shutdown: b.in_controlled_shutdown,
1547        cordoned_log_dirs: b
1548            .cordoned_log_dirs
1549            .as_ref()
1550            .map(|dirs| dirs.iter().copied().map(from_kuuid).collect()),
1551        features,
1552    })
1553}
1554
1555/// Reads a `BrokerRegistrationChangeRecord`. `fenced` must be -1 (unfence), 0
1556/// (no change) or 1 (fence) and `inControlledShutdown` 0 (no change) or 1, as
1557/// Kafka's `ClusterControlManager.replay` requires. The epoch check against
1558/// the registration belongs to [`MetadataImage::validate`] and
1559/// [`MetadataImage::apply`], which see the image in log order.
1560fn broker_registration_change_from_kraft(
1561    change: &BrokerRegistrationChangeRecord,
1562) -> Result<ChangeRecord, TranslateError> {
1563    let fenced =
1564        FencingChange::from_wire(change.fenced).ok_or_else(|| TranslateError::InvalidValue {
1565            field: "broker registration change fenced",
1566            detail: format!("unknown value {}", change.fenced),
1567        })?;
1568    let in_controlled_shutdown = match change.in_controlled_shutdown {
1569        0 => false,
1570        1 => true,
1571        other => {
1572            return Err(TranslateError::InvalidValue {
1573                field: "broker registration change in_controlled_shutdown",
1574                detail: format!("unknown value {other}"),
1575            });
1576        }
1577    };
1578    Ok(ChangeRecord {
1579        node_id: node_id_from_wire(change.broker_id, "broker registration change id")?,
1580        broker_epoch: change.broker_epoch,
1581        fenced,
1582        in_controlled_shutdown,
1583        log_dirs: change.log_dirs.iter().copied().map(from_kuuid).collect(),
1584        cordoned_log_dirs: change
1585            .cordoned_log_dirs
1586            .as_ref()
1587            .map(|dirs| dirs.iter().copied().map(from_kuuid).collect()),
1588    })
1589}
1590
1591/// Writes a `BrokerRegistrationChangeRecord`. [`to_kraft_values`] frames it at
1592/// the lowest version that carries every field it sets, as Kafka's
1593/// `ReplicationControlManager` does.
1594fn broker_registration_change_to_kraft(
1595    change: &ChangeRecord,
1596) -> Result<BrokerRegistrationChangeRecord, TranslateError> {
1597    Ok(BrokerRegistrationChangeRecord {
1598        broker_id: node_id_to_wire(change.node_id, "broker_id")?,
1599        broker_epoch: change.broker_epoch,
1600        fenced: change.fenced.wire_value(),
1601        in_controlled_shutdown: i8::from(change.in_controlled_shutdown),
1602        log_dirs: change.log_dirs.iter().copied().map(to_kuuid).collect(),
1603        cordoned_log_dirs: change
1604            .cordoned_log_dirs
1605            .as_ref()
1606            .map(|dirs| dirs.iter().copied().map(to_kuuid).collect()),
1607        ..Default::default()
1608    })
1609}
1610
1611/// The `BrokerRegistrationChangeRecord` version Kafka's
1612/// `ReplicationControlManager` frames a change at: v0 for a fencing change,
1613/// v1 once it sets `InControlledShutdown`, v2 once it sets `LogDirs` and v3
1614/// once it sets `CordonedLogDirs`.
1615fn broker_registration_change_version(change: &BrokerRegistrationChangeRecord) -> i16 {
1616    if change.cordoned_log_dirs.is_some() {
1617        3
1618    } else if !change.log_dirs.is_empty() {
1619        2
1620    } else {
1621        i16::from(change.in_controlled_shutdown != 0)
1622    }
1623}
1624
1625fn register_controller_from_kraft(
1626    controller: &RegisterControllerRecord,
1627) -> Result<ControllerRegistrationRecord, TranslateError> {
1628    let endpoints = controller
1629        .end_points
1630        .iter()
1631        .map(|endpoint| {
1632            Ok(BrokerEndpoint {
1633                name: endpoint.name.clone(),
1634                host: endpoint.host.clone(),
1635                port: endpoint.port,
1636                protocol: protocol_from_wire(endpoint.security_protocol)?,
1637            })
1638        })
1639        .collect::<Result<Vec<_>, TranslateError>>()?;
1640    let features = controller
1641        .features
1642        .iter()
1643        .map(|feature| {
1644            (
1645                feature.name.clone(),
1646                (feature.min_supported_version, feature.max_supported_version),
1647            )
1648        })
1649        .collect();
1650    Ok(ControllerRegistrationRecord {
1651        node_id: node_id_from_wire(controller.controller_id, "controller id")?,
1652        incarnation_id: from_kuuid(controller.incarnation_id),
1653        zk_migration_ready: controller.zk_migration_ready,
1654        endpoints,
1655        features,
1656    })
1657}
1658
1659fn topic_from_kraft(t: &KTopicRecord, image: &MetadataImage) -> TopicRecord {
1660    let name = t.name.clone();
1661    // Reconstruct partitions/RF from the image: partition count is derived
1662    // from the partitions map; RF from the first partition's replica count
1663    // (1 if none yet present). These fields are not part of the KIP-631
1664    // record — the image is authoritative.
1665    let partitions = image.topic_partition_count(&name);
1666    let replication_factor = image
1667        .partitions_of(&name)
1668        .next()
1669        .map_or(1, |p| i16::try_from(p.replicas.len()).unwrap_or(1));
1670    TopicRecord {
1671        name,
1672        topic_id: from_kuuid(t.topic_id),
1673        partitions,
1674        replication_factor,
1675    }
1676}
1677
1678fn partition_from_kraft(
1679    p: &KPartitionRecord,
1680    image: &MetadataImage,
1681) -> Result<MetadataRecord, TranslateError> {
1682    let id = from_kuuid(p.topic_id);
1683    let topic = topic_name_for_id(image, id).ok_or(TranslateError::UnknownTopicId(id))?;
1684    let nodes = |values: &[i32], field| {
1685        values
1686            .iter()
1687            .map(|value| node_id_from_wire(*value, field))
1688            .collect::<Result<Vec<_>, _>>()
1689    };
1690    let partition = PartitionRecord {
1691        topic,
1692        partition: p.partition_id,
1693        leader: partition_leader_from_wire(p.leader, "partition leader")?,
1694        replicas: nodes(&p.replicas, "partition replica")?,
1695        isr: nodes(&p.isr, "partition ISR member")?,
1696        // Wire boundary: wrap the raw int32 leader epoch decoded off KRaft.
1697        leader_epoch: LeaderEpoch(p.leader_epoch),
1698        partition_epoch: p.partition_epoch,
1699        adding_replicas: nodes(&p.adding_replicas, "adding replica")?,
1700        removing_replicas: nodes(&p.removing_replicas, "removing replica")?,
1701        // KIP-858: per-replica log-directory assignment, present at KRaft v1+.
1702        directories: p.directories.iter().map(|u| from_kuuid(*u)).collect(),
1703    };
1704    let recovery_state = match p.leader_recovery_state {
1705        0 => LeaderRecoveryState::Recovered,
1706        1 => LeaderRecoveryState::Recovering,
1707        value => {
1708            return Err(TranslateError::InvalidValue {
1709                field: "partition leader recovery state",
1710                detail: format!("unknown state {value}"),
1711            });
1712        }
1713    };
1714    if p.eligible_leader_replicas.is_some()
1715        || p.last_known_elr.is_some()
1716        || recovery_state == LeaderRecoveryState::Recovering
1717    {
1718        Ok(MetadataRecord::V1PartitionUpdate(
1719            crate::PartitionUpdateRecord {
1720                partition,
1721                eligible_leader_replicas: p
1722                    .eligible_leader_replicas
1723                    .as_deref()
1724                    .map(|values| nodes(values, "eligible leader replica"))
1725                    .transpose()?,
1726                last_known_elr: p
1727                    .last_known_elr
1728                    .as_deref()
1729                    .map(|values| nodes(values, "last known eligible leader replica"))
1730                    .transpose()?,
1731                recovery_state: Some(recovery_state),
1732            },
1733        ))
1734    } else {
1735        Ok(MetadataRecord::V1Partition(partition))
1736    }
1737}
1738
1739fn partition_change_from_kraft(
1740    change: &KPartitionChangeRecord,
1741    image: &MetadataImage,
1742) -> Result<MetadataRecord, TranslateError> {
1743    let topic_id = from_kuuid(change.topic_id);
1744    let topic =
1745        topic_name_for_id(image, topic_id).ok_or(TranslateError::UnknownTopicId(topic_id))?;
1746    let has_recovery = change.leader_recovery_state != -1;
1747    let has_elr = change.eligible_leader_replicas.is_some() || change.last_known_elr.is_some();
1748    let nodes = |values: &[i32], field| {
1749        values
1750            .iter()
1751            .map(|value| node_id_from_wire(*value, field))
1752            .collect::<Result<Vec<_>, _>>()
1753    };
1754    let recovery_state = if has_recovery {
1755        Some(match change.leader_recovery_state {
1756            0 => LeaderRecoveryState::Recovered,
1757            1 => LeaderRecoveryState::Recovering,
1758            value => {
1759                return Err(TranslateError::InvalidValue {
1760                    field: "partition change leader recovery state",
1761                    detail: format!("unknown state {value}"),
1762                });
1763            }
1764        })
1765    } else {
1766        None
1767    };
1768    let mut partition = image
1769        .partition(&topic, change.partition_id)
1770        .cloned()
1771        .ok_or_else(|| TranslateError::InvalidReference {
1772            field: "partition change",
1773            detail: format!("unknown partition {topic}-{}", change.partition_id),
1774        })?;
1775    if let Some(replicas) = &change.replicas {
1776        partition.replicas = nodes(replicas, "partition change replica")?;
1777    }
1778    if let Some(isr) = &change.isr {
1779        partition.isr = nodes(isr, "partition change ISR member")?;
1780    }
1781    if let Some(removing) = &change.removing_replicas {
1782        partition.removing_replicas = nodes(removing, "partition change removing replica")?;
1783    }
1784    if let Some(adding) = &change.adding_replicas {
1785        partition.adding_replicas = nodes(adding, "partition change adding replica")?;
1786    }
1787    if change.leader != -2 {
1788        partition.leader = partition_leader_from_wire(change.leader, "partition change leader")?;
1789        // A reference error: the record carries no epoch, so the overflow
1790        // comes from the epoch the image holds, not from the bytes.
1791        partition.leader_epoch =
1792            LeaderEpoch(partition.leader_epoch.get().checked_add(1).ok_or_else(|| {
1793                TranslateError::InvalidReference {
1794                    field: "partition change leader epoch",
1795                    detail: "leader epoch overflow".into(),
1796                }
1797            })?);
1798    }
1799    if let Some(directories) = &change.directories {
1800        if directories.len() != partition.replicas.len() {
1801            let field = "partition change directories";
1802            let detail = format!(
1803                "{} directories for {} replicas",
1804                directories.len(),
1805                partition.replicas.len()
1806            );
1807            // When the same record replaces the replicas, the replicas were
1808            // set from its bytes above, so the bytes alone decide the
1809            // mismatch. Otherwise the length to match is the image's replica
1810            // count, and the mismatch depends on the image.
1811            return Err(if change.replicas.is_some() {
1812                TranslateError::InvalidValue { field, detail }
1813            } else {
1814                TranslateError::InvalidReference { field, detail }
1815            });
1816        }
1817        partition.directories = directories.iter().copied().map(from_kuuid).collect();
1818    }
1819    // A reference error, like the leader epoch: the overflow comes from the
1820    // partition epoch the image holds.
1821    partition.partition_epoch = partition.partition_epoch.checked_add(1).ok_or_else(|| {
1822        TranslateError::InvalidReference {
1823            field: "partition change partition epoch",
1824            detail: "partition epoch overflow".into(),
1825        }
1826    })?;
1827    if has_recovery || has_elr {
1828        Ok(MetadataRecord::V1PartitionUpdate(
1829            crate::PartitionUpdateRecord {
1830                partition,
1831                eligible_leader_replicas: change
1832                    .eligible_leader_replicas
1833                    .as_deref()
1834                    .map(|values| nodes(values, "eligible leader replica"))
1835                    .transpose()?,
1836                last_known_elr: change
1837                    .last_known_elr
1838                    .as_deref()
1839                    .map(|values| nodes(values, "last known eligible leader replica"))
1840                    .transpose()?,
1841                recovery_state,
1842            },
1843        ))
1844    } else {
1845        Ok(MetadataRecord::V1Partition(partition))
1846    }
1847}
1848
1849fn partition_leader_to_wire(leader: NodeId, field: &'static str) -> Result<i32, TranslateError> {
1850    if leader == NodeId(0) {
1851        return Ok(-1);
1852    }
1853    i32::try_from(leader.0).map_err(|_| TranslateError::InvalidValue {
1854        field,
1855        detail: format!("leader {leader} exceeds i32"),
1856    })
1857}
1858
1859fn partition_leader_from_wire(value: i32, field: &'static str) -> Result<NodeId, TranslateError> {
1860    if value == -1 {
1861        return Ok(NodeId(0));
1862    }
1863    node_id_from_wire(value, field)
1864}
1865
1866fn node_id_from_wire(value: i32, field: &'static str) -> Result<NodeId, TranslateError> {
1867    u64::try_from(value)
1868        .map(NodeId)
1869        .map_err(|_| TranslateError::InvalidValue {
1870            field,
1871            detail: format!("{value} is negative"),
1872        })
1873}
1874
1875fn node_id_to_wire(node_id: NodeId, field: &'static str) -> Result<i32, TranslateError> {
1876    i32::try_from(node_id.0).map_err(|_| TranslateError::InvalidValue {
1877        field,
1878        detail: format!("node_id {node_id} exceeds i32"),
1879    })
1880}
1881
1882fn client_quota_from_kraft(q: &KClientQuotaRecord) -> ClientQuotaRecord {
1883    let entity = q
1884        .entity
1885        .iter()
1886        .map(|e| QuotaEntity {
1887            entity_type: e.entity_type.clone(),
1888            entity_name: e.entity_name.clone(),
1889        })
1890        .collect();
1891    let config_value = if q.remove { None } else { Some(q.value) };
1892    ClientQuotaRecord {
1893        entity,
1894        config_key: q.key.clone(),
1895        config_value,
1896    }
1897}
1898
1899fn delegation_token_from_kraft(
1900    t: &KDelegationTokenRecord,
1901) -> Result<DelegationTokenRecord, TranslateError> {
1902    let owner = t
1903        .owner
1904        .parse::<KafkaPrincipal>()
1905        .map_err(|e| TranslateError::InvalidValue {
1906            field: "delegation token owner",
1907            detail: e,
1908        })?;
1909    let requester =
1910        t.requester
1911            .parse::<KafkaPrincipal>()
1912            .map_err(|e| TranslateError::InvalidValue {
1913                field: "delegation token requester",
1914                detail: e,
1915            })?;
1916    let renewers = t
1917        .renewers
1918        .iter()
1919        .map(|r| {
1920            r.parse::<KafkaPrincipal>()
1921                .map_err(|e| TranslateError::InvalidValue {
1922                    field: "delegation token renewer",
1923                    detail: e,
1924                })
1925        })
1926        .collect::<Result<Vec<_>, _>>()?;
1927    Ok(DelegationTokenRecord {
1928        token_id: t.token_id.clone(),
1929        owner,
1930        requester,
1931        issue_timestamp_ms: t.issue_timestamp,
1932        expiry_timestamp_ms: t.expiration_timestamp,
1933        max_timestamp_ms: t.max_timestamp,
1934        renewers,
1935    })
1936}
1937
1938fn config_from_kraft(
1939    c: &ConfigRecord,
1940    image: &MetadataImage,
1941) -> Result<MetadataRecord, TranslateError> {
1942    match c.resource_type {
1943        4 => {
1944            // BROKER — `V1BrokerConfig` is already per-key (Some=set, None=delete),
1945            // so a single `ConfigRecord` maps 1:1 (the image merges per-key).
1946            let node_id = if c.resource_name.is_empty() {
1947                DEFAULT_BROKER_CONFIG_NODE_ID
1948            } else {
1949                NodeId(c.resource_name.parse::<u64>().map_err(|e| {
1950                    TranslateError::InvalidValue {
1951                        field: "broker config resource_name",
1952                        detail: e.to_string(),
1953                    }
1954                })?)
1955            };
1956            Ok(MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
1957                node_id,
1958                config_name: c.name.clone(),
1959                config_value: c.value.clone(),
1960            }))
1961        }
1962        2 => {
1963            // TOPIC — `V1TopicConfig` is whole-map (replace) authoritative, so
1964            // merge this one key's delta onto the image's current overrides and
1965            // emit the resulting whole map. `Some(v)` sets the key; `None` (a
1966            // tombstone) removes it. Applying the emitted record (replace) lands
1967            // the merged map. Decoding successive Config records against the
1968            // live image (engine apply / snapshot read) reproduces the original
1969            // whole map exactly.
1970            let mut overrides = image
1971                .topic_config(&c.resource_name)
1972                .cloned()
1973                .unwrap_or_default();
1974            match &c.value {
1975                Some(v) => {
1976                    overrides.insert(c.name.clone(), v.clone());
1977                }
1978                None => {
1979                    overrides.remove(&c.name);
1980                }
1981            }
1982            Ok(MetadataRecord::V1TopicConfig(TopicConfigRecord {
1983                topic: c.resource_name.clone(),
1984                overrides,
1985            }))
1986        }
1987        16 => {
1988            let mut configs = image
1989                .client_metrics_config(&c.resource_name)
1990                .cloned()
1991                .unwrap_or_default();
1992            match &c.value {
1993                Some(v) => {
1994                    configs.insert(c.name.clone(), v.clone());
1995                }
1996                None => {
1997                    configs.remove(&c.name);
1998                }
1999            }
2000            Ok(MetadataRecord::V1ClientMetricsConfig(
2001                ClientMetricsConfigRecord {
2002                    name: c.resource_name.clone(),
2003                    configs,
2004                },
2005            ))
2006        }
2007        32 => {
2008            let mut configs = image
2009                .group_config(&c.resource_name)
2010                .cloned()
2011                .unwrap_or_default();
2012            match &c.value {
2013                Some(v) => {
2014                    configs.insert(c.name.clone(), v.clone());
2015                }
2016                None => {
2017                    configs.remove(&c.name);
2018                }
2019            }
2020            Ok(MetadataRecord::V1GroupConfig(GroupConfigRecord {
2021                group_id: c.resource_name.clone(),
2022                configs,
2023            }))
2024        }
2025        other => Err(TranslateError::InvalidValue {
2026            field: "config resource_type",
2027            detail: format!("unsupported resource_type {other}"),
2028        }),
2029    }
2030}
2031
2032/// Resolve a topic id to its name with a scan of the topics of the image.
2033fn topic_name_for_id(image: &MetadataImage, id: uuid::Uuid) -> Option<String> {
2034    image
2035        .topics()
2036        .find(|t| t.topic_id == id)
2037        .map(|t| t.name.clone())
2038}
2039
2040#[cfg(test)]
2041mod tests {
2042    use assert2::check;
2043    use krabka_protocol::owned::{
2044        begin_transaction_record::BeginTransactionRecord,
2045        end_transaction_record::EndTransactionRecord,
2046    };
2047
2048    use super::*;
2049    use crate::{
2050        acl::AclEntryFilter,
2051        break_glass::{BreakGlassAction, BreakGlassApproval, BreakGlassProposalRecord},
2052        metadata_version::METADATA_VERSION_FEATURE,
2053        records::{
2054            FeaturesEpochRecord, KRaftVersionRecord, PartitionDirAssignmentRecord, VotersRecord,
2055        },
2056        voters::VoterSet,
2057        write_freeze::TopicFreezeRecord,
2058    };
2059
2060    fn img() -> MetadataImage {
2061        MetadataImage::new(uuid::Uuid::nil())
2062    }
2063
2064    fn round_trip(rec: &MetadataRecord, image: &MetadataImage) {
2065        let k = to_kraft(rec, image).unwrap();
2066        assert2::assert!(from_kraft(&k, image).unwrap() == *rec);
2067    }
2068
2069    // ---------- aligned variants ----------
2070
2071    #[test]
2072    fn feature_level_round_trips() {
2073        let rec = MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2074            name: "metadata.version".into(),
2075            level: 25,
2076        });
2077        round_trip(&rec, &img());
2078    }
2079
2080    #[test]
2081    fn register_broker_no_endpoints_round_trips() {
2082        // Non-zero `broker_epoch` (KIP-903): `round_trip` asserts full equality,
2083        // so the field is dropped iff this fails (it would decode back as 0).
2084        round_trip(
2085            &MetadataRecord::V1BrokerRegistration(BrokerRegistrationRecord {
2086                node_id: NodeId(7),
2087                broker_epoch: 42,
2088                incarnation_id: uuid::Uuid::from_u128(0xdeadbeef_cafe_babe_0123_456789abcdef),
2089                host: "192.168.1.10".into(),
2090                port: 9092,
2091                rack: Some("us-east-1a".into()),
2092                log_dirs: vec![],
2093                fenced: false,
2094                in_controlled_shutdown: false,
2095                cordoned_log_dirs: None,
2096                endpoints: vec![],
2097                features: BTreeMap::new(),
2098            }),
2099            &img(),
2100        );
2101    }
2102
2103    #[test]
2104    fn register_broker_with_endpoints_round_trips() {
2105        // Non-zero `broker_epoch` (KIP-903) carried alongside the endpoints;
2106        // `round_trip`'s full-equality assert covers the field.
2107        round_trip(
2108            &MetadataRecord::V1BrokerRegistration(BrokerRegistrationRecord {
2109                node_id: NodeId(1),
2110                broker_epoch: 7,
2111                incarnation_id: uuid::Uuid::from_u128(0xfeedface_0000_0000_0000_000000000001),
2112                host: "ext.example.com".into(),
2113                port: 9093,
2114                rack: None,
2115                log_dirs: vec![],
2116                fenced: false,
2117                in_controlled_shutdown: false,
2118                cordoned_log_dirs: None,
2119                endpoints: vec![
2120                    BrokerEndpoint {
2121                        name: "EXTERNAL".into(),
2122                        host: "ext.example.com".into(),
2123                        port: 9093,
2124                        protocol: ListenerProtocol::SaslSsl,
2125                    },
2126                    BrokerEndpoint {
2127                        name: "INTERNAL".into(),
2128                        host: "int".into(),
2129                        port: 9094,
2130                        protocol: ListenerProtocol::Plaintext,
2131                    },
2132                ],
2133                features: BTreeMap::new(),
2134            }),
2135            &img(),
2136        );
2137    }
2138
2139    #[test]
2140    fn register_broker_to_kraft_preserves_endpoint_details_and_unfenced_state() {
2141        let rec = MetadataRecord::V1BrokerRegistration(BrokerRegistrationRecord {
2142            node_id: NodeId(1),
2143            broker_epoch: 7,
2144            incarnation_id: uuid::Uuid::from_u128(0xfeedface_0000_0000_0000_000000000001),
2145            host: "broker.local".into(),
2146            port: 9092,
2147            rack: None,
2148            log_dirs: vec![],
2149            fenced: false,
2150            in_controlled_shutdown: false,
2151            cordoned_log_dirs: None,
2152            endpoints: vec![BrokerEndpoint {
2153                name: "EXTERNAL".into(),
2154                host: "ext.example.com".into(),
2155                port: 9093,
2156                protocol: ListenerProtocol::SaslSsl,
2157            }],
2158            features: BTreeMap::new(),
2159        });
2160
2161        let KraftMetadataRecord::RegisterBroker(k) = to_kraft(&rec, &img()).unwrap() else {
2162            panic!("expected RegisterBroker");
2163        };
2164
2165        check!(
2166            (
2167                k.fenced,
2168                k.end_points
2169                    .iter()
2170                    .map(|endpoint| (endpoint.name.as_str(), endpoint.security_protocol))
2171                    .collect::<Vec<_>>(),
2172            ) == (
2173                false,
2174                vec![("EXTERNAL", protocol_to_wire(ListenerProtocol::SaslSsl),)],
2175            )
2176        );
2177    }
2178
2179    #[test]
2180    fn controller_registration_round_trips() {
2181        round_trip(
2182            &MetadataRecord::V1ControllerRegistration(ControllerRegistrationRecord {
2183                node_id: NodeId(3),
2184                incarnation_id: uuid::Uuid::from_u128(3),
2185                zk_migration_ready: false,
2186                endpoints: vec![BrokerEndpoint {
2187                    name: "CONTROLLER".into(),
2188                    host: "controller-3".into(),
2189                    port: 9093,
2190                    protocol: ListenerProtocol::Plaintext,
2191                }],
2192                features: BTreeMap::from([("metadata.version".into(), (7, 25))]),
2193            }),
2194            &img(),
2195        );
2196    }
2197
2198    #[test]
2199    fn register_broker_incarnation_id_survives_round_trip() {
2200        let id = uuid::Uuid::from_u128(0x0102_0304_0506_0708_090a_0b0c_0d0e_0f10);
2201        round_trip(
2202            &MetadataRecord::V1BrokerRegistration(BrokerRegistrationRecord {
2203                node_id: NodeId(3),
2204                broker_epoch: 5,
2205                incarnation_id: id,
2206                host: "127.0.0.1".into(),
2207                port: 9092,
2208                rack: None,
2209                log_dirs: vec![],
2210                fenced: false,
2211                in_controlled_shutdown: false,
2212                cordoned_log_dirs: None,
2213                endpoints: vec![],
2214                features: BTreeMap::new(),
2215            }),
2216            &img(),
2217        );
2218    }
2219
2220    #[test]
2221    fn register_broker_log_dirs_follow_metadata_version_record_gate() {
2222        let directory = uuid::Uuid::from_u128(0xD1);
2223        let record = MetadataRecord::V1BrokerRegistration(BrokerRegistrationRecord {
2224            node_id: NodeId(3),
2225            broker_epoch: 5,
2226            incarnation_id: uuid::Uuid::from_u128(3),
2227            host: "127.0.0.1".into(),
2228            port: 9092,
2229            rack: None,
2230            endpoints: vec![],
2231            log_dirs: vec![directory],
2232            fenced: false,
2233            in_controlled_shutdown: false,
2234            cordoned_log_dirs: None,
2235            features: BTreeMap::new(),
2236        });
2237
2238        let mut kip858_image = img();
2239        kip858_image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2240            name: METADATA_VERSION_FEATURE.into(),
2241            level: DIRECTORY_ASSIGNMENT_MIN_LEVEL,
2242        }));
2243        let value = to_kraft_values(&record, &kip858_image).unwrap().remove(0);
2244        let (wire, version) = KraftMetadataRecord::decode_value(&value).unwrap();
2245        assert2::assert!(version == 3);
2246        assert2::assert!(from_kraft(&wire, &kip858_image).unwrap() == record);
2247
2248        let mut pre_kip858_image = img();
2249        pre_kip858_image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2250            name: METADATA_VERSION_FEATURE.into(),
2251            level: DIRECTORY_ASSIGNMENT_MIN_LEVEL - 1,
2252        }));
2253        let value = to_kraft_values(&record, &pre_kip858_image)
2254            .unwrap()
2255            .remove(0);
2256        let (wire, version) = KraftMetadataRecord::decode_value(&value).unwrap();
2257        assert2::assert!(version == 2);
2258        let MetadataRecord::V1BrokerRegistration(decoded) =
2259            from_kraft(&wire, &pre_kip858_image).unwrap()
2260        else {
2261            panic!("expected broker registration")
2262        };
2263        assert2::assert!(decoded.log_dirs.is_empty());
2264    }
2265
2266    #[test]
2267    fn partition_epoch_survives_round_trip() {
2268        let mut image = img();
2269        let tid = uuid::Uuid::from_u128(99);
2270        image.apply(&MetadataRecord::V1Topic(TopicRecord {
2271            name: "epoch-test".into(),
2272            topic_id: tid,
2273            partitions: 1,
2274            replication_factor: 1,
2275        }));
2276        let rec = MetadataRecord::V1Partition(PartitionRecord {
2277            topic: "epoch-test".into(),
2278            partition: 0,
2279            leader: NodeId(1),
2280            replicas: vec![NodeId(1)],
2281            isr: vec![NodeId(1)],
2282            leader_epoch: LeaderEpoch(7),
2283            adding_replicas: vec![],
2284            removing_replicas: vec![],
2285            directories: vec![],
2286            partition_epoch: 42,
2287        });
2288        image.apply(&rec);
2289        round_trip(&rec, &image);
2290    }
2291
2292    /// `RegisterBroker` frames at the version Kafka's
2293    /// `MetadataVersion.registerBrokerRecordVersion` picks for each level: v4
2294    /// from `4.3-IV0` (KIP-1066 cordoned log dirs), v3 from `3.7-IV2`, v2 from
2295    /// `3.4-IV0`, v1 below.
2296    #[test]
2297    fn register_broker_frames_at_kafkas_record_version() {
2298        use crate::metadata_version::{
2299            CORDONED_LOG_DIRS_MIN_LEVEL, DIRECTORY_ASSIGNMENT_MIN_LEVEL, METADATA_VERSION_MAX,
2300            METADATA_VERSION_MIN,
2301        };
2302
2303        // Every field a registration carries, so each rung shows which of them
2304        // survive: log dirs from v3, cordoned log dirs from v4.
2305        let directory = uuid::Uuid::from_u128(0xD1);
2306        let full = BrokerRegistrationRecord {
2307            node_id: NodeId(3),
2308            broker_epoch: 5,
2309            incarnation_id: uuid::Uuid::from_u128(3),
2310            host: "127.0.0.1".into(),
2311            port: 9092,
2312            rack: None,
2313            endpoints: vec![],
2314            log_dirs: vec![directory],
2315            fenced: true,
2316            in_controlled_shutdown: true,
2317            cordoned_log_dirs: Some(vec![directory]),
2318            features: BTreeMap::new(),
2319        };
2320        let without_cordoned = BrokerRegistrationRecord {
2321            cordoned_log_dirs: None,
2322            ..full.clone()
2323        };
2324        let without_dirs = BrokerRegistrationRecord {
2325            log_dirs: vec![],
2326            ..without_cordoned.clone()
2327        };
2328
2329        for (level, want_version, want) in [
2330            (METADATA_VERSION_MAX, 4_i16, &full),
2331            (CORDONED_LOG_DIRS_MIN_LEVEL, 4, &full),
2332            (CORDONED_LOG_DIRS_MIN_LEVEL - 1, 3, &without_cordoned),
2333            (DIRECTORY_ASSIGNMENT_MIN_LEVEL, 3, &without_cordoned),
2334            (DIRECTORY_ASSIGNMENT_MIN_LEVEL - 1, 2, &without_dirs),
2335            (8, 2, &without_dirs),
2336            (METADATA_VERSION_MIN, 1, &without_dirs),
2337        ] {
2338            let mut image = img();
2339            image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2340                name: METADATA_VERSION_FEATURE.into(),
2341                level,
2342            }));
2343            let record = MetadataRecord::V1BrokerRegistration(full.clone());
2344            let value = to_kraft_values(&record, &image).unwrap().remove(0);
2345            let (wire, version) = KraftMetadataRecord::decode_value(&value).unwrap();
2346            check!(
2347                (version, from_kraft(&wire, &image).unwrap())
2348                    == (
2349                        want_version,
2350                        MetadataRecord::V1BrokerRegistration(want.clone())
2351                    ),
2352                "metadata.version {level}"
2353            );
2354        }
2355    }
2356
2357    /// A `BrokerRegistrationChangeRecord` decodes to a
2358    /// `V1BrokerRegistrationChange`, and Kafka's undefined `Fenced` and
2359    /// `InControlledShutdown` values are refused, as
2360    /// `ClusterControlManager.replay` refuses them.
2361    #[test]
2362    fn broker_registration_change_decodes_to_the_change_record() {
2363        let (d1, d2) = (uuid::Uuid::from_u128(0xD1), uuid::Uuid::from_u128(0xD2));
2364        let wire = |fenced: i8,
2365                    in_controlled_shutdown: i8,
2366                    log_dirs: Vec<uuid::Uuid>,
2367                    cordoned: Option<Vec<uuid::Uuid>>| {
2368            BrokerRegistrationChangeRecord {
2369                broker_id: 3,
2370                broker_epoch: 5,
2371                fenced,
2372                in_controlled_shutdown,
2373                log_dirs: log_dirs.into_iter().map(to_kuuid).collect(),
2374                cordoned_log_dirs: cordoned.map(|dirs| dirs.into_iter().map(to_kuuid).collect()),
2375                ..Default::default()
2376            }
2377        };
2378        let decoded = |edit: fn(&mut ChangeRecord)| {
2379            let mut change = ChangeRecord::no_change(NodeId(3), 5);
2380            edit(&mut change);
2381            Ok(MetadataRecord::V1BrokerRegistrationChange(change))
2382        };
2383        let invalid = |field, detail: &str| {
2384            Err(TranslateError::InvalidValue {
2385                field,
2386                detail: detail.into(),
2387            })
2388        };
2389
2390        for (case, record, want) in [
2391            ("no change", wire(0, 0, vec![], None), decoded(|_| {})),
2392            (
2393                "unfence",
2394                wire(-1, 0, vec![], None),
2395                decoded(|c| c.fenced = FencingChange::Unfence),
2396            ),
2397            (
2398                "fence",
2399                wire(1, 0, vec![], None),
2400                decoded(|c| c.fenced = FencingChange::Fence),
2401            ),
2402            (
2403                "controlled shutdown",
2404                wire(0, 1, vec![], None),
2405                decoded(|c| c.in_controlled_shutdown = true),
2406            ),
2407            (
2408                "new log dirs",
2409                wire(0, 0, vec![d1, d2], None),
2410                decoded(|c| {
2411                    c.log_dirs = vec![uuid::Uuid::from_u128(0xD1), uuid::Uuid::from_u128(0xD2)];
2412                }),
2413            ),
2414            (
2415                "uncordon every dir",
2416                wire(0, 0, vec![], Some(vec![])),
2417                decoded(|c| c.cordoned_log_dirs = Some(vec![])),
2418            ),
2419            (
2420                "unknown fenced value",
2421                wire(2, 0, vec![], None),
2422                invalid("broker registration change fenced", "unknown value 2"),
2423            ),
2424            (
2425                "unknown controlled shutdown value",
2426                wire(0, -1, vec![], None),
2427                invalid(
2428                    "broker registration change in_controlled_shutdown",
2429                    "unknown value -1",
2430                ),
2431            ),
2432            (
2433                "negative broker id",
2434                BrokerRegistrationChangeRecord {
2435                    broker_id: -1,
2436                    ..wire(0, 0, vec![], None)
2437                },
2438                invalid("broker registration change id", "-1 is negative"),
2439            ),
2440        ] {
2441            // Through the v3 value bytes, the version that carries every field.
2442            let value = KraftMetadataRecord::BrokerRegistrationChange(record)
2443                .encode_value(3)
2444                .unwrap();
2445            check!(from_kraft_value(&value, &img()) == want, "case {case}");
2446        }
2447    }
2448
2449    /// A registration change is written as Kafka's `BrokerRegistrationChangeRecord`
2450    /// (apiKey 17), at the version `ReplicationControlManager` picks for the
2451    /// fields it sets, and reads back unchanged.
2452    #[test]
2453    fn broker_registration_change_encodes_at_kafkas_record_version() {
2454        let dir = uuid::Uuid::from_u128(0xD1);
2455        let change = |edit: fn(&mut ChangeRecord)| {
2456            let mut change = ChangeRecord::no_change(NodeId(3), 5);
2457            edit(&mut change);
2458            change
2459        };
2460        // Envelope: frame version 1, apiKey 17 (0x11), then the apiVersion.
2461        // Body: BrokerId int32 3, BrokerEpoch int64 5, then the tagged fields.
2462        let frame = |version: u8, tagged: &[u8]| {
2463            let mut bytes = vec![0x01, 0x11, version, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 5];
2464            bytes.extend_from_slice(tagged);
2465            bytes
2466        };
2467        // One tagged field: tag, size 17 (a compact array length of one, then
2468        // one 16-byte uuid), the length and the uuid.
2469        let dir_bytes = dir.into_bytes();
2470        let mut log_dirs_tagged = vec![0x01, 0x02, 0x11, 0x02];
2471        log_dirs_tagged.extend_from_slice(&dir_bytes);
2472        let mut cordoned_tagged = vec![0x01, 0x03, 0x11, 0x02];
2473        cordoned_tagged.extend_from_slice(&dir_bytes);
2474
2475        for (case, record, wire) in [
2476            (
2477                "fence at v0",
2478                change(|c| c.fenced = FencingChange::Fence),
2479                frame(0, &[0x01, 0x00, 0x01, 0x01]),
2480            ),
2481            (
2482                "unfence at v0",
2483                change(|c| c.fenced = FencingChange::Unfence),
2484                frame(0, &[0x01, 0x00, 0x01, 0xff]),
2485            ),
2486            (
2487                "controlled shutdown at v1",
2488                change(|c| c.in_controlled_shutdown = true),
2489                frame(1, &[0x01, 0x01, 0x01, 0x01]),
2490            ),
2491            (
2492                "log dirs at v2",
2493                change(|c| c.log_dirs = vec![uuid::Uuid::from_u128(0xD1)]),
2494                frame(2, &log_dirs_tagged),
2495            ),
2496            (
2497                "cordoned log dirs at v3",
2498                change(|c| c.cordoned_log_dirs = Some(vec![uuid::Uuid::from_u128(0xD1)])),
2499                frame(3, &cordoned_tagged),
2500            ),
2501        ] {
2502            let record = MetadataRecord::V1BrokerRegistrationChange(record);
2503            let values = to_kraft_values(&record, &img()).unwrap();
2504            check!(
2505                values.iter().map(|v| v.to_vec()).collect::<Vec<_>>() == vec![wire],
2506                "{case}"
2507            );
2508            check!(from_kraft_value(&values[0], &img()) == Ok(record), "{case}");
2509        }
2510    }
2511
2512    /// Below the level that introduced them, a registration change drops its
2513    /// log dirs (`3.7-IV2`) and cordoned log dirs (`4.3-IV0`), as a
2514    /// `RegisterBrokerRecord` does, and frames at the version left.
2515    #[test]
2516    fn broker_registration_change_drops_fields_the_metadata_version_lacks() {
2517        use crate::metadata_version::{
2518            CORDONED_LOG_DIRS_MIN_LEVEL, DIRECTORY_ASSIGNMENT_MIN_LEVEL,
2519        };
2520
2521        let dir = uuid::Uuid::from_u128(0xD1);
2522        let full = ChangeRecord {
2523            fenced: FencingChange::Unfence,
2524            log_dirs: vec![dir],
2525            cordoned_log_dirs: Some(vec![dir]),
2526            ..ChangeRecord::no_change(NodeId(3), 5)
2527        };
2528        for (level, want_version, want) in [
2529            (CORDONED_LOG_DIRS_MIN_LEVEL, 3, full.clone()),
2530            (
2531                CORDONED_LOG_DIRS_MIN_LEVEL - 1,
2532                2,
2533                ChangeRecord {
2534                    cordoned_log_dirs: None,
2535                    ..full.clone()
2536                },
2537            ),
2538            (
2539                DIRECTORY_ASSIGNMENT_MIN_LEVEL - 1,
2540                0,
2541                ChangeRecord {
2542                    log_dirs: vec![],
2543                    cordoned_log_dirs: None,
2544                    ..full.clone()
2545                },
2546            ),
2547        ] {
2548            let mut image = img();
2549            image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2550                name: METADATA_VERSION_FEATURE.into(),
2551                level,
2552            }));
2553            let values = to_kraft_values(
2554                &MetadataRecord::V1BrokerRegistrationChange(full.clone()),
2555                &image,
2556            )
2557            .unwrap();
2558            let (_, version) = KraftMetadataRecord::decode_value(&values[0]).unwrap();
2559            check!(
2560                (version, from_kraft_value(&values[0], &image))
2561                    == (
2562                        want_version,
2563                        Ok(MetadataRecord::V1BrokerRegistrationChange(want))
2564                    ),
2565                "metadata.version {level}"
2566            );
2567        }
2568    }
2569
2570    /// KIP-1312: controller unregistration is Kafka trunk's
2571    /// `UnregisterControllerRecord` (apiKey 29, v0) in both directions.
2572    #[test]
2573    fn unregister_controller_round_trips_as_api_key_29() {
2574        let record = MetadataRecord::V1UnregisterController(UnregisterControllerRecord {
2575            node_id: NodeId(3000),
2576        });
2577        let values = to_kraft_values(&record, &img()).unwrap();
2578        check!(
2579            values.iter().map(|v| v.to_vec()).collect::<Vec<_>>()
2580                == vec![vec![0x01, 0x1d, 0x00, 0x00, 0x00, 0x0b, 0xb8, 0x00]]
2581        );
2582        check!(from_kraft_value(&values[0], &img()) == Ok(record));
2583
2584        let negative = KraftMetadataRecord::UnregisterController(KUnregisterControllerRecord {
2585            controller_id: -1,
2586            ..Default::default()
2587        });
2588        check!(
2589            from_kraft(&negative, &img())
2590                == Err(TranslateError::InvalidValue {
2591                    field: "unregister controller id",
2592                    detail: "-1 is negative".into(),
2593                })
2594        );
2595    }
2596
2597    /// Partition records frame at v2 once ELR is available, v1 once directory
2598    /// assignment is, and v0 below that. Nothing pinned that ladder: with the
2599    /// first guard forced true every image emits v2, and with the second forced
2600    /// either way the middle rung merges into one of its neighbours -- each of
2601    /// which writes a partition record a Kafka at the target version is
2602    /// required not to understand.
2603    #[test]
2604    fn partition_records_frame_at_the_version_the_image_allows() {
2605        use crate::metadata_version::{DIRECTORY_ASSIGNMENT_MIN_LEVEL, ELR_MIN_LEVEL};
2606
2607        let rec = MetadataRecord::V1Partition(PartitionRecord {
2608            topic: "orders".into(),
2609            partition: 0,
2610            leader: NodeId(1),
2611            replicas: vec![NodeId(1)],
2612            isr: vec![NodeId(1)],
2613            leader_epoch: LeaderEpoch(7),
2614            adding_replicas: vec![],
2615            removing_replicas: vec![],
2616            directories: vec![],
2617            partition_epoch: 42,
2618        });
2619
2620        for (level, want) in [
2621            (ELR_MIN_LEVEL, 2_i16),
2622            (ELR_MIN_LEVEL - 1, 1),
2623            (DIRECTORY_ASSIGNMENT_MIN_LEVEL, 1),
2624            (DIRECTORY_ASSIGNMENT_MIN_LEVEL - 1, 0),
2625        ] {
2626            let mut image = img();
2627            image.apply(&MetadataRecord::V1Topic(TopicRecord {
2628                name: "orders".into(),
2629                topic_id: uuid::Uuid::from_u128(1),
2630                partitions: 1,
2631                replication_factor: 1,
2632            }));
2633            image.apply(&MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
2634                name: METADATA_VERSION_FEATURE.into(),
2635                level,
2636            }));
2637
2638            let value = to_kraft_values(&rec, &image).unwrap().remove(0);
2639            let (_, version) = KraftMetadataRecord::decode_value(&value).unwrap();
2640            check!(version == want, "metadata.version {level}");
2641        }
2642    }
2643
2644    /// Broker and controller registrations carry a supported-version map and,
2645    /// for controllers, endpoints and the ZK-migration flag. Every round trip
2646    /// so far left those empty or defaulted, so dropping a feature's name or
2647    /// either version bound, an endpoint's security protocol, or the migration
2648    /// flag changed nothing any test looked at -- and each is what a JVM
2649    /// controller reads to decide what the peer can speak.
2650    #[test]
2651    fn registration_features_and_endpoints_survive_translation() {
2652        let image = img();
2653
2654        let broker = MetadataRecord::V1BrokerRegistration(BrokerRegistrationRecord {
2655            node_id: NodeId(1),
2656            broker_epoch: 5,
2657            incarnation_id: uuid::Uuid::from_u128(3),
2658            host: "127.0.0.1".into(),
2659            port: 9092,
2660            rack: None,
2661            endpoints: vec![],
2662            log_dirs: vec![],
2663            fenced: false,
2664            in_controlled_shutdown: false,
2665            cordoned_log_dirs: None,
2666            features: BTreeMap::from([
2667                ("metadata.version".to_owned(), (7_i16, 25_i16)),
2668                ("kraft.version".to_owned(), (0, 1)),
2669            ]),
2670        });
2671        round_trip(&broker, &image);
2672
2673        let controller = MetadataRecord::V1ControllerRegistration(ControllerRegistrationRecord {
2674            node_id: NodeId(3),
2675            incarnation_id: uuid::Uuid::from_u128(9),
2676            zk_migration_ready: true,
2677            endpoints: vec![BrokerEndpoint {
2678                name: "CONTROLLER".into(),
2679                host: "controller-3".into(),
2680                port: 9093,
2681                protocol: krabka_security::ListenerProtocol::SaslSsl,
2682            }],
2683            features: BTreeMap::from([("kraft.version".to_owned(), (0_i16, 1_i16))]),
2684        });
2685        round_trip(&controller, &image);
2686    }
2687
2688    /// `UnregisterBrokerRecord` carries the epoch of the registration it
2689    /// removes, as Kafka's `ReplicationControlManager.handleBrokerUnregistered`
2690    /// writes it.
2691    #[test]
2692    fn unregister_broker_carries_the_broker_epoch() {
2693        let rec = MetadataRecord::V1UnregisterBroker(UnregisterBrokerRecord {
2694            node_id: NodeId(42),
2695            broker_epoch: 0x0102,
2696        });
2697        let values = to_kraft_values(&rec, &img()).unwrap();
2698        // Frame version 1, apiKey 1, v0; BrokerId int32 42, BrokerEpoch int64
2699        // 0x0102, then an empty tagged-field section.
2700        check!(
2701            values.iter().map(|v| v.to_vec()).collect::<Vec<_>>()
2702                == vec![vec![
2703                    0x01, 0x01, 0x00, 0, 0, 0, 42, 0, 0, 0, 0, 0, 0, 0x01, 0x02, 0x00
2704                ]]
2705        );
2706        check!(from_kraft_value(&values[0], &img()) == Ok(rec));
2707    }
2708
2709    #[test]
2710    fn negative_wire_broker_id_is_rejected() {
2711        let record = KraftMetadataRecord::UnregisterBroker(KUnregisterBrokerRecord {
2712            broker_id: -1,
2713            ..Default::default()
2714        });
2715
2716        let error = from_kraft(&record, &img()).unwrap_err();
2717        assert!(matches!(error, TranslateError::InvalidValue { .. }));
2718    }
2719
2720    #[test]
2721    fn wire_partition_no_leader_sentinel_round_trips() {
2722        let mut image = img();
2723        let topic_id = uuid::Uuid::from_u128(100);
2724        image.apply(&MetadataRecord::V1Topic(TopicRecord {
2725            name: "leaderless-test".into(),
2726            topic_id,
2727            partitions: 1,
2728            replication_factor: 1,
2729        }));
2730        let record = KraftMetadataRecord::Partition(KPartitionRecord {
2731            topic_id: to_kuuid(topic_id),
2732            partition_id: 0,
2733            leader: -1,
2734            ..Default::default()
2735        });
2736
2737        let decoded = from_kraft(&record, &image).unwrap();
2738        let MetadataRecord::V1Partition(decoded) = decoded else {
2739            panic!("expected partition")
2740        };
2741        assert2::assert!(decoded.leader == NodeId(0));
2742
2743        let encoded = to_kraft(&MetadataRecord::V1Partition(decoded), &image).unwrap();
2744        let KraftMetadataRecord::Partition(encoded) = encoded else {
2745            panic!("expected partition")
2746        };
2747        assert2::assert!(encoded.leader == -1);
2748    }
2749
2750    #[test]
2751    fn wire_partition_change_no_leader_updates_epochs() {
2752        let mut image = img();
2753        let topic_id = uuid::Uuid::from_u128(101);
2754        image.apply(&MetadataRecord::V1Topic(TopicRecord {
2755            name: "leaderless-change-test".into(),
2756            topic_id,
2757            partitions: 1,
2758            replication_factor: 1,
2759        }));
2760        image.apply(&MetadataRecord::V1Partition(PartitionRecord {
2761            topic: "leaderless-change-test".into(),
2762            partition: 0,
2763            leader: NodeId(1),
2764            replicas: vec![NodeId(1)],
2765            isr: vec![NodeId(1)],
2766            leader_epoch: LeaderEpoch(7),
2767            partition_epoch: 11,
2768            adding_replicas: vec![],
2769            removing_replicas: vec![],
2770            directories: vec![],
2771        }));
2772        let change = KraftMetadataRecord::PartitionChange(KPartitionChangeRecord {
2773            topic_id: to_kuuid(topic_id),
2774            partition_id: 0,
2775            leader: -1,
2776            ..Default::default()
2777        });
2778
2779        let decoded = from_kraft(&change, &image).unwrap();
2780        let MetadataRecord::V1Partition(decoded) = decoded else {
2781            panic!("expected partition")
2782        };
2783        assert2::assert!(decoded.leader == NodeId(0));
2784        assert2::assert!(decoded.leader_epoch == LeaderEpoch(8));
2785        assert2::assert!(decoded.partition_epoch == 12);
2786    }
2787
2788    #[test]
2789    fn negative_wire_partition_replica_is_rejected() {
2790        let mut image = img();
2791        let topic_id = uuid::Uuid::from_u128(102);
2792        image.apply(&MetadataRecord::V1Topic(TopicRecord {
2793            name: "negative-node-test".into(),
2794            topic_id,
2795            partitions: 1,
2796            replication_factor: 1,
2797        }));
2798        let record = KraftMetadataRecord::Partition(KPartitionRecord {
2799            topic_id: to_kuuid(topic_id),
2800            partition_id: 0,
2801            leader: -1,
2802            replicas: vec![-1],
2803            ..Default::default()
2804        });
2805
2806        let error = from_kraft(&record, &image).unwrap_err();
2807        assert!(matches!(error, TranslateError::InvalidValue { .. }));
2808    }
2809
2810    #[test]
2811    fn scram_credential_round_trips() {
2812        let rec = MetadataRecord::V1ScramCredential(ScramCredentialRecord {
2813            user: "alice".into(),
2814            mechanism: SaslMechanism::ScramSha512,
2815            salt: vec![1u8; 16],
2816            stored_key: vec![2u8; 64],
2817            server_key: vec![3u8; 64],
2818            iterations: 4096,
2819        });
2820        round_trip(&rec, &img());
2821    }
2822
2823    #[test]
2824    fn delete_scram_credential_round_trips() {
2825        let rec = MetadataRecord::V1DeleteScramCredential(DeleteScramCredentialRecord {
2826            user: "alice".into(),
2827            mechanism: SaslMechanism::ScramSha256,
2828        });
2829        round_trip(&rec, &img());
2830    }
2831
2832    /// KIP-966 eligible-leader-replica state is refused, and either field
2833    /// alone is enough to refuse it. Read as `&&`, a change carrying only one
2834    /// Kafka may set either ELR field independently in a partition delta.
2835    #[test]
2836    fn either_elr_field_alone_is_preserved() {
2837        let topic_id = uuid::Uuid::from_u128(1);
2838        let mut image = img();
2839        image.apply(&MetadataRecord::V1Topic(TopicRecord {
2840            name: "orders".into(),
2841            topic_id,
2842            partitions: 1,
2843            replication_factor: 1,
2844        }));
2845        image.apply(&MetadataRecord::V1Partition(PartitionRecord {
2846            topic: "orders".into(),
2847            partition: 0,
2848            leader: NodeId(1),
2849            replicas: vec![NodeId(1)],
2850            isr: vec![NodeId(1)],
2851            leader_epoch: LeaderEpoch(1),
2852            adding_replicas: vec![],
2853            removing_replicas: vec![],
2854            directories: vec![],
2855            partition_epoch: 1,
2856        }));
2857
2858        let change = |elr: Option<Vec<i32>>, last_known: Option<Vec<i32>>| {
2859            KraftMetadataRecord::PartitionChange(KPartitionChangeRecord {
2860                topic_id: to_kuuid(topic_id),
2861                partition_id: 0,
2862                leader_recovery_state: -1,
2863                eligible_leader_replicas: elr,
2864                last_known_elr: last_known,
2865                ..Default::default()
2866            })
2867        };
2868
2869        check!(from_kraft(&change(None, None), &image).is_ok());
2870        check!(matches!(
2871            from_kraft(&change(Some(vec![1]), None), &image),
2872            Ok(MetadataRecord::V1PartitionUpdate(crate::PartitionUpdateRecord {
2873                eligible_leader_replicas: Some(eligible_leader_replicas),
2874                last_known_elr: None,
2875                ..
2876            })) if eligible_leader_replicas == vec![NodeId(1)]
2877        ));
2878        check!(matches!(
2879            from_kraft(&change(None, Some(vec![1])), &image),
2880            Ok(MetadataRecord::V1PartitionUpdate(crate::PartitionUpdateRecord {
2881                eligible_leader_replicas: None,
2882                last_known_elr: Some(last_known_elr),
2883                ..
2884            })) if last_known_elr == vec![NodeId(1)]
2885        ));
2886
2887        let combined = KraftMetadataRecord::PartitionChange(KPartitionChangeRecord {
2888            topic_id: to_kuuid(topic_id),
2889            partition_id: 0,
2890            leader: 2,
2891            isr: Some(vec![2]),
2892            leader_recovery_state: LeaderRecoveryState::Recovering as i8,
2893            eligible_leader_replicas: Some(vec![2]),
2894            last_known_elr: Some(vec![1]),
2895            ..Default::default()
2896        });
2897        let decoded = from_kraft(&combined, &image).expect("combined partition delta");
2898        let MetadataRecord::V1PartitionUpdate(update) = decoded else {
2899            panic!("combined delta must stay atomic");
2900        };
2901        check!(update.partition.leader == NodeId(2));
2902        check!(update.partition.isr == vec![NodeId(2)]);
2903        check!(update.recovery_state == Some(LeaderRecoveryState::Recovering));
2904        check!(update.eligible_leader_replicas == Some(vec![NodeId(2)]));
2905        check!(update.last_known_elr == Some(vec![NodeId(1)]));
2906    }
2907
2908    /// The ACL id is a content hash: it is what a later `RemoveAccessControlEntry`
2909    /// matches against to find the entry it deletes. Dropped, every entry
2910    /// translates with the same defaulted id, so distinct ACLs become
2911    /// indistinguishable -- which the round-trip above does not notice, because
2912    /// it reconstructs the entry from the other fields.
2913    #[test]
2914    fn distinct_acls_translate_to_distinct_ids() {
2915        let entry = |operation, resource_name: &str| crate::AclEntry {
2916            resource_type: ResourceType::Topic,
2917            resource_name: resource_name.into(),
2918            pattern_type: PatternType::Literal,
2919            principal: "User:alice".into(),
2920            host: "*".into(),
2921            operation,
2922            permission_type: PermissionType::Allow,
2923        };
2924        let id_of = |e: &crate::AclEntry| {
2925            let KraftMetadataRecord::AccessControlEntry(r) = acl_to_kraft(e) else {
2926                panic!("expected an access control entry");
2927            };
2928            r.id
2929        };
2930
2931        let read_foo = id_of(&entry(AclOperation::Read, "foo"));
2932        check!(read_foo == id_of(&entry(AclOperation::Read, "foo")));
2933        check!(read_foo != id_of(&entry(AclOperation::Write, "foo")));
2934        check!(read_foo != id_of(&entry(AclOperation::Read, "bar")));
2935    }
2936
2937    #[test]
2938    fn access_control_entry_round_trips() {
2939        let rec = MetadataRecord::V1AccessControlEntry(AclEntry {
2940            resource_type: ResourceType::Topic,
2941            resource_name: "foo".into(),
2942            pattern_type: PatternType::Literal,
2943            principal: "User:alice".into(),
2944            host: "*".into(),
2945            operation: AclOperation::Read,
2946            permission_type: PermissionType::Allow,
2947        });
2948        round_trip(&rec, &img());
2949    }
2950
2951    #[test]
2952    fn access_control_entry_two_phase_commit_round_trips() {
2953        // KIP-939: TWO_PHASE_COMMIT (wire byte 15) must survive the
2954        // record↔wire round-trip on a TransactionalId resource.
2955        let rec = MetadataRecord::V1AccessControlEntry(AclEntry {
2956            resource_type: ResourceType::TransactionalId,
2957            resource_name: "my-txn".into(),
2958            pattern_type: PatternType::Literal,
2959            principal: "User:flink".into(),
2960            host: "*".into(),
2961            operation: AclOperation::TwoPhaseCommit,
2962            permission_type: PermissionType::Allow,
2963        });
2964        round_trip(&rec, &img());
2965    }
2966
2967    /// Every ACL resource type and operation maps to Kafka's `i8`
2968    /// discriminant and back through a full `AccessControlEntryRecord`
2969    /// round trip, KIP-373's `USER` (7), `CREATE_TOKENS` (13) and
2970    /// `DESCRIBE_TOKENS` (14) included.
2971    #[test]
2972    fn acl_enums_map_to_kafka_wire_codes() {
2973        for (resource_type, wire) in [
2974            (ResourceType::Topic, 2_i8),
2975            (ResourceType::Group, 3),
2976            (ResourceType::Cluster, 4),
2977            (ResourceType::TransactionalId, 5),
2978            (ResourceType::DelegationToken, 6),
2979            (ResourceType::User, 7),
2980        ] {
2981            check!(
2982                (
2983                    resource_type_to_wire(resource_type),
2984                    resource_type_from_wire(wire).unwrap(),
2985                ) == (wire, resource_type)
2986            );
2987        }
2988        for (operation, wire) in [
2989            (AclOperation::All, 2_i8),
2990            (AclOperation::Read, 3),
2991            (AclOperation::Write, 4),
2992            (AclOperation::Create, 5),
2993            (AclOperation::Delete, 6),
2994            (AclOperation::Alter, 7),
2995            (AclOperation::Describe, 8),
2996            (AclOperation::ClusterAction, 9),
2997            (AclOperation::DescribeConfigs, 10),
2998            (AclOperation::AlterConfigs, 11),
2999            (AclOperation::IdempotentWrite, 12),
3000            (AclOperation::CreateTokens, 13),
3001            (AclOperation::DescribeTokens, 14),
3002            (AclOperation::TwoPhaseCommit, 15),
3003        ] {
3004            check!(
3005                (
3006                    operation_to_wire(operation),
3007                    operation_from_wire(wire).unwrap()
3008                ) == (wire, operation)
3009            );
3010        }
3011        check!(resource_type_from_wire(8).is_err());
3012        check!(operation_from_wire(16).is_err());
3013    }
3014
3015    /// KIP-373: `CREATE_TOKENS` and `DESCRIBE_TOKENS` granted on a `USER`
3016    /// resource survive the record-to-wire round trip.
3017    #[test]
3018    fn access_control_entry_user_token_operations_round_trip() {
3019        for operation in [AclOperation::CreateTokens, AclOperation::DescribeTokens] {
3020            let rec = MetadataRecord::V1AccessControlEntry(AclEntry {
3021                resource_type: ResourceType::User,
3022                resource_name: "alice".into(),
3023                pattern_type: PatternType::Literal,
3024                principal: "User:token-requester".into(),
3025                host: "*".into(),
3026                operation,
3027                permission_type: PermissionType::Allow,
3028            });
3029            let value = to_kraft_values(&rec, &img()).unwrap().remove(0);
3030            check!(from_kraft_value(&value, &img()).unwrap() == rec);
3031            let KraftMetadataRecord::AccessControlEntry(wire) = to_kraft(&rec, &img()).unwrap()
3032            else {
3033                panic!("expected an access control entry");
3034            };
3035            check!((wire.resource_type, wire.operation) == (7, operation_to_wire(operation)));
3036        }
3037    }
3038
3039    #[test]
3040    fn access_control_entry_prefixed_deny_round_trips() {
3041        let rec = MetadataRecord::V1AccessControlEntry(AclEntry {
3042            resource_type: ResourceType::DelegationToken,
3043            resource_name: "User:bob".into(),
3044            pattern_type: PatternType::Prefixed,
3045            principal: "User:eve".into(),
3046            host: "10.0.0.1".into(),
3047            operation: AclOperation::Describe,
3048            permission_type: PermissionType::Deny,
3049        });
3050        round_trip(&rec, &img());
3051    }
3052
3053    fn acl(rt: ResourceType, name: &str, principal: &str) -> AclEntry {
3054        AclEntry {
3055            resource_type: rt,
3056            resource_name: name.into(),
3057            pattern_type: PatternType::Literal,
3058            principal: principal.into(),
3059            host: "*".into(),
3060            operation: AclOperation::Read,
3061            permission_type: PermissionType::Allow,
3062        }
3063    }
3064
3065    #[test]
3066    fn distinct_acls_get_distinct_ids() {
3067        let a = acl(ResourceType::Topic, "foo", "User:alice");
3068        let b = acl(ResourceType::Topic, "foo", "User:bob");
3069        let c = acl(ResourceType::Group, "foo", "User:alice");
3070        check!(acl_id(&a) != acl_id(&b));
3071        check!(acl_id(&a) != acl_id(&c));
3072        // Deterministic: same entry hashes identically.
3073        check!(acl_id(&a) == acl_id(&a.clone()));
3074    }
3075
3076    #[test]
3077    fn delete_acl_filter_resolves_to_remove_records_and_round_trips() {
3078        // Image holds three ACLs; a Topic-only filter deletes the two Topic
3079        // entries and leaves the Group entry.
3080        let mut image = img();
3081        let e1 = acl(ResourceType::Topic, "foo", "User:alice");
3082        let e2 = acl(ResourceType::Topic, "bar", "User:bob");
3083        let e3 = acl(ResourceType::Group, "cg", "User:alice");
3084        for e in [&e1, &e2, &e3] {
3085            image.apply(&MetadataRecord::V1AccessControlEntry(e.clone()));
3086        }
3087        let del = MetadataRecord::V1DeleteAccessControlEntry(AclEntryFilter {
3088            resource_type: Some(ResourceType::Topic),
3089            ..AclEntryFilter::default()
3090        });
3091        let records = to_kraft_records(&del, &image).unwrap();
3092        assert2::assert!(records.len() == 2); // e1, e2 — not e3
3093        // Each RemoveAccessControlEntry decodes back to a pinned filter that
3094        // removes exactly its entry. Applying both clears e1+e2, keeps e3.
3095        for k in &records {
3096            let back = from_kraft(k, &image).unwrap();
3097            image.apply(&back);
3098        }
3099        assert2::assert!(image.all_acls().count() == 1);
3100        let survivor = image.all_acls().next().unwrap();
3101        assert2::assert!(*survivor == e3);
3102    }
3103
3104    #[test]
3105    fn delete_acl_filter_matching_nothing_yields_no_records() {
3106        let mut image = img();
3107        image.apply(&MetadataRecord::V1AccessControlEntry(acl(
3108            ResourceType::Topic,
3109            "foo",
3110            "User:alice",
3111        )));
3112        let del = MetadataRecord::V1DeleteAccessControlEntry(AclEntryFilter {
3113            resource_type: Some(ResourceType::Group),
3114            ..AclEntryFilter::default()
3115        });
3116        assert2::assert!(to_kraft_records(&del, &image).unwrap().is_empty());
3117    }
3118
3119    #[test]
3120    fn client_quota_round_trips() {
3121        let rec = MetadataRecord::V1ClientQuota(ClientQuotaRecord {
3122            entity: vec![
3123                QuotaEntity {
3124                    entity_type: "client-id".into(),
3125                    entity_name: Some("app1".into()),
3126                },
3127                QuotaEntity {
3128                    entity_type: "user".into(),
3129                    entity_name: Some("alice".into()),
3130                },
3131            ],
3132            config_key: "producer_byte_rate".into(),
3133            config_value: Some(1024.0),
3134        });
3135        round_trip(&rec, &img());
3136    }
3137
3138    #[test]
3139    fn client_quota_remove_round_trips() {
3140        let rec = MetadataRecord::V1ClientQuota(ClientQuotaRecord {
3141            entity: vec![QuotaEntity {
3142                entity_type: "user".into(),
3143                entity_name: None,
3144            }],
3145            config_key: "consumer_byte_rate".into(),
3146            config_value: None,
3147        });
3148        round_trip(&rec, &img());
3149    }
3150
3151    #[test]
3152    fn delegation_token_round_trips() {
3153        let rec = MetadataRecord::V1DelegationToken(DelegationTokenRecord {
3154            token_id: "tok-abc".into(),
3155            owner: KafkaPrincipal {
3156                principal_type: "User".into(),
3157                name: "alice".into(),
3158            },
3159            requester: KafkaPrincipal {
3160                principal_type: "User".into(),
3161                name: "admin".into(),
3162            },
3163            issue_timestamp_ms: 1_700_000_000_000,
3164            expiry_timestamp_ms: 1_700_000_600_000,
3165            max_timestamp_ms: 1_700_604_800_000,
3166            renewers: vec![KafkaPrincipal {
3167                principal_type: "User".into(),
3168                name: "bob".into(),
3169            }],
3170        });
3171        round_trip(&rec, &img());
3172    }
3173
3174    #[test]
3175    fn delete_delegation_token_round_trips() {
3176        let rec = MetadataRecord::V1DeleteDelegationToken(DeleteDelegationTokenRecord {
3177            token_id: "tok-abc".into(),
3178        });
3179        round_trip(&rec, &img());
3180    }
3181
3182    #[test]
3183    fn broker_config_round_trips() {
3184        let rec = MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3185            node_id: NodeId(7),
3186            config_name: "leader.replication.throttled.rate".into(),
3187            config_value: Some("2048".into()),
3188        });
3189        let encoded = to_kraft_records(&rec, &img()).expect("encode broker config");
3190        assert2::assert!(matches!(
3191            encoded.as_slice(),
3192            [KraftMetadataRecord::Config(config)]
3193                if config.resource_type == 4 && config.resource_name == "7"
3194        ));
3195        round_trip(&rec, &img());
3196    }
3197
3198    #[test]
3199    fn default_broker_config_round_trips_with_empty_resource_name() {
3200        let rec = MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3201            node_id: DEFAULT_BROKER_CONFIG_NODE_ID,
3202            config_name: "follower.replication.throttled.rate".into(),
3203            config_value: Some("4096".into()),
3204        });
3205        let encoded = to_kraft_records(&rec, &img()).expect("encode default broker config");
3206        assert2::assert!(matches!(
3207            encoded.as_slice(),
3208            [KraftMetadataRecord::Config(config)]
3209                if config.resource_type == 4 && config.resource_name.is_empty()
3210        ));
3211        round_trip(&rec, &img());
3212    }
3213
3214    #[test]
3215    fn broker_config_delete_round_trips() {
3216        let rec = MetadataRecord::V1BrokerConfig(BrokerConfigRecord {
3217            node_id: NodeId(7),
3218            config_name: "leader.replication.throttled.rate".into(),
3219            config_value: None,
3220        });
3221        round_trip(&rec, &img());
3222    }
3223
3224    #[test]
3225    fn client_metrics_config_uses_standard_kraft_config_records() {
3226        let rec = MetadataRecord::V1ClientMetricsConfig(ClientMetricsConfigRecord {
3227            name: "metrics-sub-1".into(),
3228            configs: [
3229                ("interval.ms".to_owned(), "60000".to_owned()),
3230                ("metrics".to_owned(), "org.apache.kafka".to_owned()),
3231            ]
3232            .into_iter()
3233            .collect(),
3234        });
3235        let mut image = img();
3236        let records = to_kraft_records(&rec, &image).unwrap();
3237        assert2::assert!(records.len() == 2);
3238        assert2::assert!(records.iter().all(|record| matches!(
3239            record,
3240            KraftMetadataRecord::Config(config) if config.resource_type == 16
3241        )));
3242        for record in records {
3243            let decoded = from_kraft(&record, &image).unwrap();
3244            image.apply(&decoded);
3245        }
3246        assert2::assert!(
3247            image.client_metrics_config("metrics-sub-1")
3248                == match &rec {
3249                    MetadataRecord::V1ClientMetricsConfig(config) => Some(&config.configs),
3250                    _ => unreachable!(),
3251                }
3252        );
3253    }
3254
3255    #[test]
3256    fn client_metrics_config_clear_emits_standard_tombstones() {
3257        let mut image = img();
3258        image.apply(&MetadataRecord::V1ClientMetricsConfig(
3259            ClientMetricsConfigRecord {
3260                name: "metrics-sub-1".into(),
3261                configs: BTreeMap::from([
3262                    ("interval.ms".into(), "60000".into()),
3263                    ("metrics".into(), "org.apache.kafka".into()),
3264                ]),
3265            },
3266        ));
3267        let clear = MetadataRecord::V1ClientMetricsConfig(ClientMetricsConfigRecord {
3268            name: "metrics-sub-1".into(),
3269            configs: BTreeMap::new(),
3270        });
3271
3272        let records = to_kraft_records(&clear, &image).unwrap();
3273
3274        assert2::assert!(records.len() == 2);
3275        assert2::assert!(records.iter().all(|record| matches!(
3276            record,
3277            KraftMetadataRecord::Config(config)
3278                if config.resource_type == 16 && config.value.is_none()
3279        )));
3280        for record in records {
3281            let decoded = from_kraft(&record, &image).unwrap();
3282            image.apply(&decoded);
3283        }
3284        assert2::assert!(image.client_metrics_config("metrics-sub-1").is_none());
3285    }
3286
3287    #[test]
3288    fn features_epoch_round_trips_via_private_carrier() {
3289        // The synthetic `V1FeaturesEpoch` epoch pin is Krabka-internal (no KRaft
3290        // record); it round-trips through the same private `NoOpRecord` carrier
3291        // so a snapshot preserves `finalized_features_epoch`.
3292        let rec = MetadataRecord::V1FeaturesEpoch(FeaturesEpochRecord { epoch: 42 });
3293        let k = to_kraft(&rec, &img()).unwrap();
3294        assert2::assert!(matches!(
3295            k,
3296            KraftMetadataRecord::NoOp(no_op)
3297                if no_op.unknown_tagged_fields.0.len() == 1
3298                    && no_op.unknown_tagged_fields.0[0].tag == PRIVATE_FEATURES_EPOCH_TAG
3299        ));
3300        round_trip(&rec, &img());
3301    }
3302
3303    #[test]
3304    fn partition_dir_assignment_uses_standard_partition_change_record() {
3305        let topic_id = uuid::Uuid::from_u128(0x00C0_FFEE);
3306        let mut image = img();
3307        image.apply(&MetadataRecord::V1Topic(TopicRecord {
3308            name: "t".into(),
3309            topic_id,
3310            partitions: 1,
3311            replication_factor: 3,
3312        }));
3313        image.apply(&MetadataRecord::V1Partition(PartitionRecord {
3314            topic: "t".into(),
3315            partition: 0,
3316            leader: NodeId(1),
3317            replicas: vec![NodeId(1), NodeId(2), NodeId(3)],
3318            isr: vec![NodeId(1), NodeId(2)],
3319            leader_epoch: LeaderEpoch(4),
3320            adding_replicas: vec![NodeId(3)],
3321            removing_replicas: vec![],
3322            directories: vec![uuid::Uuid::nil(); 3],
3323            partition_epoch: 9,
3324        }));
3325        let rec = MetadataRecord::V1PartitionDirAssignment(PartitionDirAssignmentRecord {
3326            topic: "t".into(),
3327            partition: 0,
3328            replica: NodeId(2),
3329            directory: uuid::Uuid::from_u128(0xAB),
3330        });
3331        let k = to_kraft(&rec, &image).unwrap();
3332        let KraftMetadataRecord::PartitionChange(change) = &k else {
3333            panic!("expected standard PartitionChangeRecord");
3334        };
3335        assert2::assert!(k.api_key() == 5);
3336        assert2::assert!(change.isr.is_none());
3337        assert2::assert!(change.replicas.is_none());
3338        assert2::assert!(
3339            change.directories.as_ref().unwrap()[1] == to_kuuid(uuid::Uuid::from_u128(0xAB))
3340        );
3341
3342        let values = to_kraft_values(&rec, &image).unwrap();
3343        let (decoded_wire, version) = KraftMetadataRecord::decode_value(&values[0]).unwrap();
3344        assert2::assert!(version == 2);
3345        let decoded = from_kraft(&decoded_wire, &image).unwrap();
3346        image.apply(&decoded);
3347        let partition = image.partition("t", 0).unwrap();
3348        assert2::assert!(partition.directories[1] == uuid::Uuid::from_u128(0xAB));
3349        assert2::assert!(partition.isr == vec![NodeId(1), NodeId(2)]);
3350        assert2::assert!(partition.adding_replicas == vec![NodeId(3)]);
3351        assert2::assert!(partition.leader_epoch == LeaderEpoch(4));
3352        assert2::assert!(partition.partition_epoch == 10);
3353    }
3354
3355    #[test]
3356    fn voters_and_kraft_version_have_no_counterpart() {
3357        let v = MetadataRecord::V1Voters(VotersRecord {
3358            voters: VoterSet::default(),
3359        });
3360        let k = MetadataRecord::V1KRaftVersion(KRaftVersionRecord { kraft_version: 1 });
3361        for (_name, record, expected) in [
3362            ("voter record", v, TranslateError::NoCounterpart("V1Voters")),
3363            (
3364                "KRaft version record",
3365                k,
3366                TranslateError::NoCounterpart("V1KRaftVersion"),
3367            ),
3368        ] {
3369            assert2::assert!(to_kraft(&record, &img()) == Err(expected));
3370        }
3371    }
3372
3373    // ---------- topic / partition / config with image context ----------
3374
3375    #[test]
3376    fn topic_partition_config_round_trip_with_image_context() {
3377        let mut image = img();
3378        let tid = uuid::Uuid::from_u128(7);
3379        let topic = MetadataRecord::V1Topic(TopicRecord {
3380            name: "t".into(),
3381            topic_id: tid,
3382            partitions: 1,
3383            replication_factor: 1,
3384        });
3385        image.apply(&topic);
3386        let part = MetadataRecord::V1Partition(PartitionRecord {
3387            topic: "t".into(),
3388            partition: 0,
3389            leader: NodeId(1),
3390            replicas: vec![NodeId(1)],
3391            isr: vec![NodeId(1)],
3392            leader_epoch: LeaderEpoch(0),
3393            adding_replicas: vec![],
3394            removing_replicas: vec![],
3395            directories: vec![],
3396            partition_epoch: 3,
3397        });
3398        image.apply(&part);
3399        for rec in [&topic, &part] {
3400            let k = to_kraft(rec, &image).unwrap();
3401            assert2::assert!(from_kraft(&k, &image).unwrap() == *rec);
3402        }
3403        let cfg = MetadataRecord::V1TopicConfig(TopicConfigRecord {
3404            topic: "t".into(),
3405            overrides: [("retention.ms".to_owned(), "9".to_owned())].into(),
3406        });
3407        let k = to_kraft(&cfg, &image).unwrap();
3408        assert2::assert!(from_kraft(&k, &image).unwrap() == cfg);
3409    }
3410
3411    #[test]
3412    fn delete_topic_round_trips_with_image_context() {
3413        let mut image = img();
3414        let tid = uuid::Uuid::from_u128(11);
3415        image.apply(&MetadataRecord::V1Topic(TopicRecord {
3416            name: "doomed".into(),
3417            topic_id: tid,
3418            partitions: 1,
3419            replication_factor: 1,
3420        }));
3421        let rec = MetadataRecord::V1DeleteTopic(DeleteTopicRecord {
3422            name: "doomed".into(),
3423        });
3424        round_trip(&rec, &image);
3425    }
3426
3427    #[test]
3428    fn topic_config_two_keys_yields_two_config_records() {
3429        let mut image = img();
3430        image.apply(&MetadataRecord::V1Topic(TopicRecord {
3431            name: "t".into(),
3432            topic_id: uuid::Uuid::from_u128(7),
3433            partitions: 1,
3434            replication_factor: 1,
3435        }));
3436        let cfg = MetadataRecord::V1TopicConfig(TopicConfigRecord {
3437            topic: "t".into(),
3438            overrides: [
3439                ("retention.ms".to_owned(), "9".to_owned()),
3440                ("segment.bytes".to_owned(), "1048576".to_owned()),
3441            ]
3442            .into(),
3443        });
3444        let records = to_kraft_records(&cfg, &image).unwrap();
3445        assert2::assert!(records.len() == 2);
3446        // Each decodes back to a single-key mergeable singleton.
3447        for k in &records {
3448            let back = from_kraft(k, &image).unwrap();
3449            match back {
3450                MetadataRecord::V1TopicConfig(tc) => {
3451                    assert2::assert!(tc.topic.as_str() == "t");
3452                    assert2::assert!(tc.overrides.len() == 1);
3453                }
3454                other => panic!("expected V1TopicConfig singleton, got {other:?}"),
3455            }
3456        }
3457    }
3458
3459    #[test]
3460    fn topic_config_to_kraft_preserves_set_values() {
3461        let mut image = img();
3462        image.apply(&MetadataRecord::V1Topic(TopicRecord {
3463            name: "t".into(),
3464            topic_id: uuid::Uuid::from_u128(7),
3465            partitions: 1,
3466            replication_factor: 1,
3467        }));
3468        let cfg = MetadataRecord::V1TopicConfig(TopicConfigRecord {
3469            topic: "t".into(),
3470            overrides: [("retention.ms".to_owned(), "9".to_owned())].into(),
3471        });
3472        let records = to_kraft_records(&cfg, &image).unwrap();
3473        assert2::assert!(records.len() == 1);
3474        let KraftMetadataRecord::Config(c) = &records[0] else {
3475            panic!("expected Config");
3476        };
3477        assert2::assert!(c.name.as_str() == "retention.ms");
3478        assert2::assert!(c.value.as_deref() == Some("9"));
3479    }
3480
3481    /// Seed `image` with topic `t` and the config map `kv`.
3482    fn seed_topic_config(image: &mut MetadataImage, kv: &[(&str, &str)]) {
3483        image.apply(&MetadataRecord::V1Topic(TopicRecord {
3484            name: "t".into(),
3485            topic_id: uuid::Uuid::from_u128(7),
3486            partitions: 1,
3487            replication_factor: 1,
3488        }));
3489        image.apply(&MetadataRecord::V1TopicConfig(TopicConfigRecord {
3490            topic: "t".into(),
3491            overrides: kv
3492                .iter()
3493                .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
3494                .collect(),
3495        }));
3496    }
3497
3498    #[test]
3499    fn topic_config_change_emits_set_and_tombstone_and_merges_back() {
3500        // Current config {a:1, b:2}; new config {a:9} (a changed, b dropped).
3501        let mut image = img();
3502        seed_topic_config(&mut image, &[("a", "1"), ("b", "2")]);
3503        let submit = MetadataRecord::V1TopicConfig(TopicConfigRecord {
3504            topic: "t".into(),
3505            overrides: [("a".to_owned(), "9".to_owned())].into(),
3506        });
3507        let records = to_kraft_records(&submit, &image).unwrap();
3508        // One set (a=9) + one tombstone (b removed).
3509        assert2::assert!(records.len() == 2);
3510        // Apply the fanned records in order against the live image; the merge
3511        // reproduces the new whole map exactly.
3512        for k in &records {
3513            let back = from_kraft(k, &image).unwrap();
3514            image.apply(&back);
3515        }
3516        let want: BTreeMap<String, String> = [("a".to_owned(), "9".to_owned())].into();
3517        assert2::assert!(image.topic_config("t") == Some(&want));
3518    }
3519
3520    #[test]
3521    fn topic_config_clear_emits_tombstones_for_every_key() {
3522        // Current {a:1, b:2}; an empty new map clears all → two tombstones.
3523        let mut image = img();
3524        seed_topic_config(&mut image, &[("a", "1"), ("b", "2")]);
3525        let submit = MetadataRecord::V1TopicConfig(TopicConfigRecord {
3526            topic: "t".into(),
3527            overrides: BTreeMap::new(),
3528        });
3529        let records = to_kraft_records(&submit, &image).unwrap();
3530        assert2::assert!(records.len() == 2);
3531        for k in &records {
3532            let back = from_kraft(k, &image).unwrap();
3533            image.apply(&back);
3534        }
3535        // All overrides gone (apply of an empty whole-map removes the entry).
3536        assert2::assert!(image.topic_config("t").is_none());
3537    }
3538
3539    #[test]
3540    fn group_config_change_uses_kraft_group_records_and_merges_back() {
3541        let mut image = img();
3542        image.apply(&MetadataRecord::V1GroupConfig(GroupConfigRecord {
3543            group_id: "streams-app".into(),
3544            configs: BTreeMap::from([
3545                ("streams.num.standby.replicas".into(), "0".into()),
3546                ("streams.num.warmup.replicas".into(), "2".into()),
3547            ]),
3548        }));
3549        let submitted = MetadataRecord::V1GroupConfig(GroupConfigRecord {
3550            group_id: "streams-app".into(),
3551            configs: BTreeMap::from([("streams.num.standby.replicas".into(), "1".into())]),
3552        });
3553
3554        let records = to_kraft_records(&submitted, &image).expect("translate group config");
3555        assert2::assert!(records.len() == 2);
3556        assert2::assert!(records.iter().all(|record| matches!(
3557            record,
3558            KraftMetadataRecord::Config(config) if config.resource_type == 32
3559        )));
3560        for record in records {
3561            let decoded = from_kraft(&record, &image).expect("decode group config");
3562            image.apply(&decoded);
3563        }
3564        assert2::assert!(
3565            image.group_config("streams-app")
3566                == match &submitted {
3567                    MetadataRecord::V1GroupConfig(config) => Some(&config.configs),
3568                    _ => unreachable!(),
3569                }
3570        );
3571    }
3572
3573    #[test]
3574    fn producer_ids_use_standard_kraft_record() {
3575        let image = img();
3576        let submitted = MetadataRecord::V1ProducerIds(ProducerIdsRecord {
3577            broker_id: NodeId(3),
3578            broker_epoch: 11,
3579            next_producer_id: 5_000,
3580        });
3581        let values = to_kraft_values(&submitted, &image).expect("encode producer IDs record");
3582        assert2::assert!(values.len() == 1);
3583        let (wire, version) = KraftMetadataRecord::decode_value(&values[0]).unwrap();
3584        assert2::assert!(wire.api_key() == 15);
3585        assert2::assert!(version == 0);
3586        assert2::assert!(from_kraft(&wire, &image).unwrap() == submitted);
3587    }
3588
3589    #[test]
3590    fn to_kraft_values_round_trips_through_value_bytes() {
3591        // The crate framing entrypoints: a record encodes to KIP-631 value
3592        // bytes and decodes back identically (single-output record).
3593        let image = img();
3594        let rec = MetadataRecord::V1FeatureLevel(FeatureLevelRecord {
3595            name: "metadata.version".into(),
3596            level: 25,
3597        });
3598        let values = to_kraft_values(&rec, &image).unwrap();
3599        assert2::assert!(values.len() == 1);
3600        let back = from_kraft_value(&values[0], &image).unwrap();
3601        assert2::assert!(back == rec);
3602    }
3603
3604    #[test]
3605    fn partition_record_directories_survive_kraft_round_trip() {
3606        use crate::{MetadataImage, MetadataRecord, PartitionRecord, TopicRecord};
3607        let topic_id = uuid::Uuid::from_u128(0x11);
3608        let mut image = MetadataImage::new(uuid::Uuid::nil());
3609        image.apply(&MetadataRecord::V1Topic(TopicRecord {
3610            name: "t".into(),
3611            topic_id,
3612            partitions: 1,
3613            replication_factor: 2,
3614        }));
3615        let rec = MetadataRecord::V1Partition(PartitionRecord {
3616            topic: "t".into(),
3617            partition: 0,
3618            leader: NodeId(1),
3619            replicas: vec![NodeId(1), NodeId(2)],
3620            isr: vec![NodeId(1), NodeId(2)],
3621            leader_epoch: LeaderEpoch(4),
3622            adding_replicas: vec![],
3623            removing_replicas: vec![],
3624            directories: vec![uuid::Uuid::from_u128(0xAA), uuid::Uuid::from_u128(0xBB)],
3625            partition_epoch: 0,
3626        });
3627        let values = super::to_kraft_values(&rec, &image).expect("encode");
3628        assert2::assert!(values.len() == 1);
3629        let decoded = super::from_kraft_value(&values[0], &image).expect("decode");
3630        assert2::assert!(decoded == rec);
3631    }
3632
3633    #[test]
3634    fn offset_advance_round_trips_through_carrier() {
3635        let rec = MetadataRecord::V1PartitionOffsetAdvance(crate::PartitionOffsetAdvanceRecord {
3636            topic: "t".into(),
3637            partition: 2,
3638            count: 9,
3639        });
3640
3641        let values = to_kraft_values(&rec, &img()).unwrap();
3642        assert_eq!(values.len(), 1);
3643        let back = from_kraft_value(&values[0], &img()).unwrap();
3644
3645        assert_eq!(back, rec);
3646    }
3647
3648    // ---------- KFC-9 private carriers ----------
3649
3650    fn freeze_records() -> [(&'static str, MetadataRecord, u32); 3] {
3651        let id = uuid::Uuid::from_u128(0xB1);
3652        [
3653            (
3654                "topic freeze",
3655                MetadataRecord::V1TopicFreeze(TopicFreezeRecord {
3656                    scope: "tenant-a.".into(),
3657                    pattern_type: PatternType::Prefixed,
3658                    frozen: true,
3659                    reason: "DR cutover".into(),
3660                    set_by: "User:alice".into(),
3661                    set_at_ms: 1_700_000_000_000,
3662                    proposal_id: id,
3663                    key_id: "alice-yubi".into(),
3664                    signature: vec![0xAB; 64],
3665                }),
3666                PRIVATE_TOPIC_FREEZE_TAG,
3667            ),
3668            (
3669                "break-glass proposal",
3670                MetadataRecord::V1BreakGlassProposal(BreakGlassProposalRecord {
3671                    proposal_id: id,
3672                    action: BreakGlassAction::ThawTopicFreeze,
3673                    target: "literal:orders".into(),
3674                    proposer: "User:alice".into(),
3675                    reason: "incident 4711 closed".into(),
3676                    created_at_ms: 1_700_000_000_000,
3677                    expires_at_ms: 1_700_000_600_000,
3678                    approvals: vec![BreakGlassApproval {
3679                        principal: "User:bob".into(),
3680                        approved_at_ms: 1_700_000_060_000,
3681                        key_id: "bob-yubi".into(),
3682                        signature: vec![0xCD; 64],
3683                    }],
3684                    consumed_at_ms: 1_700_000_120_000,
3685                    withdrawn: false,
3686                }),
3687                PRIVATE_BREAK_GLASS_PROPOSAL_TAG,
3688            ),
3689            (
3690                "break-glass tombstone",
3691                MetadataRecord::V1DeleteBreakGlassProposal(id),
3692                PRIVATE_DELETE_BREAK_GLASS_PROPOSAL_TAG,
3693            ),
3694        ]
3695    }
3696
3697    #[test]
3698    fn freeze_and_break_glass_records_round_trip_through_carriers() {
3699        for (label, rec, _) in freeze_records() {
3700            let values = to_kraft_values(&rec, &img()).unwrap();
3701            check!(values.len() == 1, "{label}");
3702            check!(
3703                from_kraft_value(&values[0], &img()).unwrap() == rec,
3704                "{label}"
3705            );
3706        }
3707    }
3708
3709    /// The tags are a durable on-disk contract, so pin them. 1002 is an
3710    /// unexplained gap in the numbering and stays free.
3711    #[test]
3712    fn freeze_and_break_glass_carriers_use_the_pinned_private_tags() {
3713        for (label, rec, want) in freeze_records() {
3714            let KraftMetadataRecord::NoOp(no_op) = to_kraft(&rec, &img()).unwrap() else {
3715                panic!("{label}: expected a private NoOpRecord carrier");
3716            };
3717            let tags: Vec<u32> = no_op
3718                .unknown_tagged_fields
3719                .0
3720                .iter()
3721                .map(|f| f.tag)
3722                .collect();
3723            check!(tags == vec![want], "{label}");
3724        }
3725        check!(
3726            PRIVATE_TAGS
3727                == [
3728                    PRIVATE_FEATURES_EPOCH_TAG,
3729                    PRIVATE_PARTITION_OFFSET_ADVANCE_TAG,
3730                    PRIVATE_TOPIC_FREEZE_TAG,
3731                    PRIVATE_BREAK_GLASS_PROPOSAL_TAG,
3732                    PRIVATE_DELETE_BREAK_GLASS_PROPOSAL_TAG,
3733                ]
3734        );
3735        check!(PRIVATE_TAGS == [1001, 1003, 1004, 1005, 1006]);
3736    }
3737
3738    /// The apiKeys of Kafka's `MetadataRecordType` table
3739    /// (`metadata/src/main/resources/common/metadata/*Record.json`, trunk).
3740    /// `MetadataRecordSerde.apiMessageFor` throws for any other id, so
3741    /// `kafka-dump-log` and `kafka-metadata-shell` fail on it.
3742    const KAFKA_METADATA_RECORD_API_KEYS: [u32; 27] = [
3743        0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 14, 15, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
3744        28, 29,
3745    ];
3746
3747    /// One record per assigned private tag, in tag order.
3748    fn private_rows() -> Vec<(&'static str, MetadataRecord, u32)> {
3749        let mut rows: Vec<(&'static str, MetadataRecord, u32)> = vec![
3750            (
3751                "features epoch",
3752                MetadataRecord::V1FeaturesEpoch(FeaturesEpochRecord { epoch: 7 }),
3753                PRIVATE_FEATURES_EPOCH_TAG,
3754            ),
3755            (
3756                "diskless offset advance",
3757                MetadataRecord::V1PartitionOffsetAdvance(crate::PartitionOffsetAdvanceRecord {
3758                    topic: "t".into(),
3759                    partition: 2,
3760                    count: 9,
3761                }),
3762                PRIVATE_PARTITION_OFFSET_ADVANCE_TAG,
3763            ),
3764        ];
3765        rows.extend(freeze_records());
3766        rows
3767    }
3768
3769    /// The version-0 body of a private tag: the big-endian `i16` version,
3770    /// then the wincode record.
3771    fn versioned_body(version: i16, rec: &MetadataRecord) -> Vec<u8> {
3772        let mut body = version.to_be_bytes().to_vec();
3773        body.extend(<serde_wincode::SerdeCompat<MetadataRecord>>::serialize(rec).unwrap());
3774        body
3775    }
3776
3777    /// Every Krabka-only record frames as a Kafka `NoOpRecord` v0 whose body is
3778    /// exactly one tagged field and no trailing bytes. Kafka's reader accepts
3779    /// that shape: the undeclared tag goes to `_unknownTaggedFields`, and
3780    /// `AbstractApiMessageSerde.read` finds no garbage after the record.
3781    #[test]
3782    fn private_records_frame_as_a_kafka_no_op_record() {
3783        for (label, rec, tag) in private_rows() {
3784            let values = to_kraft_values(&rec, &img()).unwrap();
3785            check!(values.len() == 1, "{label}");
3786            let (decoded, version) = KraftMetadataRecord::decode_value(&values[0]).unwrap();
3787            check!(
3788                KAFKA_METADATA_RECORD_API_KEYS.contains(&decoded.api_key()),
3789                "{label}: apiKey {} is not in Kafka's table",
3790                decoded.api_key()
3791            );
3792            check!(
3793                (decoded, version)
3794                    == (
3795                        KraftMetadataRecord::NoOp(NoOpRecord {
3796                            unknown_tagged_fields: UnknownTaggedFields(vec![UnknownTaggedField {
3797                                tag,
3798                                bytes: Bytes::from(versioned_body(PRIVATE_RECORD_VERSION, &rec)),
3799                            }]),
3800                        }),
3801                        0
3802                    ),
3803                "{label}"
3804            );
3805            check!(
3806                from_kraft_value(&values[0], &img()).unwrap() == rec,
3807                "{label}"
3808            );
3809        }
3810    }
3811
3812    /// The full `__cluster_metadata` record value of each row of
3813    /// [`private_rows`], in the same order. These bytes are the krabka 1.x
3814    /// on-disk contract: the KIP-631 frame, the `NoOpRecord` tagged field, the
3815    /// `i16` record version 0, then the wincode record.
3816    const PRIVATE_GOLDEN_VALUES: [&str; 5] = [
3817        // features epoch (tag 1001)
3818        "01140001e9070e0000130000000700000000000000",
3819        // diskless offset advance (tag 1003)
3820        "01140001eb071b000015000000010000000000000074020000000900000000000000",
3821        // topic freeze (tag 1004)
3822        "01140001ec07ba01000018000000090000000000000074656e616e742d612e01000000010a000000000000004452206375746f7665720a00000000000000557365723a616c6963650068e5cf8b0100001000000000000000000000000000000000000000000000b10a00000000000000616c6963652d797562694000000000000000abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab",
3823        // break-glass proposal (tag 1005)
3824        "01140001ed07f7010000190000001000000000000000000000000000000000000000000000b1000000000e000000000000006c69746572616c3a6f72646572730a00000000000000557365723a616c6963651400000000000000696e636964656e74203437313120636c6f7365640068e5cf8b010000c08feecf8b01000001000000000000000800000000000000557365723a626f626052e6cf8b0100000800000000000000626f622d797562694000000000000000cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdc03ce7cf8b01000000",
3825        // break-glass tombstone (tag 1006)
3826        "01140001ee071e00001a0000001000000000000000000000000000000000000000000000b1",
3827    ];
3828
3829    #[test]
3830    fn private_records_match_the_golden_bytes() {
3831        use crate::wincode_contract::{hex, unhex};
3832
3833        for ((label, rec, _), golden) in private_rows().into_iter().zip(PRIVATE_GOLDEN_VALUES) {
3834            let values = to_kraft_values(&rec, &img()).unwrap();
3835            check!(
3836                values.iter().map(|v| hex(v)).collect::<Vec<_>>() == vec![golden.to_owned()],
3837                "{label}"
3838            );
3839            check!(
3840                from_kraft_value(&unhex(golden), &img()) == Ok(rec),
3841                "{label}"
3842            );
3843        }
3844    }
3845
3846    #[test]
3847    fn private_carrier_rejects_a_bad_tag_or_body() {
3848        let epoch = MetadataRecord::V1FeaturesEpoch(FeaturesEpochRecord { epoch: 7 });
3849        let topic = MetadataRecord::V1Topic(TopicRecord {
3850            name: "t".into(),
3851            topic_id: uuid::Uuid::from_u128(0x7),
3852            partitions: 1,
3853            replication_factor: 1,
3854        });
3855        let unversioned = <serde_wincode::SerdeCompat<MetadataRecord>>::serialize(&epoch).unwrap();
3856        for (label, tag, body, want) in [
3857            (
3858                "version 1",
3859                1001,
3860                versioned_body(1, &epoch),
3861                TranslateError::UnknownPrivateRecordVersion {
3862                    tag: 1001,
3863                    version: 1,
3864                },
3865            ),
3866            (
3867                "future version",
3868                1001,
3869                versioned_body(i16::MAX, &epoch),
3870                TranslateError::UnknownPrivateRecordVersion {
3871                    tag: 1001,
3872                    version: i16::MAX,
3873                },
3874            ),
3875            (
3876                "0.x body without a version",
3877                1001,
3878                unversioned,
3879                TranslateError::UnknownPrivateRecordVersion {
3880                    tag: 1001,
3881                    version: 0x1300,
3882                },
3883            ),
3884            (
3885                "record of another tag",
3886                1004,
3887                versioned_body(0, &epoch),
3888                TranslateError::PrivateTagMismatch {
3889                    tag: 1004,
3890                    expected_tag: Some(1001),
3891                },
3892            ),
3893            (
3894                "record of no tag",
3895                1001,
3896                versioned_body(0, &topic),
3897                TranslateError::PrivateTagMismatch {
3898                    tag: 1001,
3899                    expected_tag: None,
3900                },
3901            ),
3902            (
3903                "trailing byte after the record",
3904                1001,
3905                {
3906                    let mut body = versioned_body(0, &epoch);
3907                    body.push(0);
3908                    body
3909                },
3910                TranslateError::TrailingPrivateRecordBytes {
3911                    tag: 1001,
3912                    trailing: 1,
3913                },
3914            ),
3915            (
3916                "burned tag 1002",
3917                1002,
3918                versioned_body(0, &epoch),
3919                TranslateError::UnknownPrivateTag(1002),
3920            ),
3921            (
3922                "unassigned tag 1007",
3923                1007,
3924                versioned_body(0, &epoch),
3925                TranslateError::UnknownPrivateTag(1007),
3926            ),
3927            (
3928                "one-byte body",
3929                1001,
3930                vec![0],
3931                TranslateError::Decode(
3932                    "krabka-private NoOpRecord tag 1001 body is 1 bytes, too short for its i16 \
3933                     version"
3934                        .into(),
3935                ),
3936            ),
3937        ] {
3938            let no_op = KraftMetadataRecord::NoOp(NoOpRecord {
3939                unknown_tagged_fields: UnknownTaggedFields(vec![UnknownTaggedField {
3940                    tag,
3941                    bytes: Bytes::from(body),
3942                }]),
3943            });
3944            check!(from_kraft(&no_op, &img()) == Err(want), "{label}");
3945        }
3946    }
3947
3948    /// A `NoOpRecord` that does not carry exactly one private tag is Kafka's
3949    /// own no-op. It has no Krabka counterpart.
3950    #[test]
3951    fn no_op_without_one_private_tag_has_no_counterpart() {
3952        let field = |tag: u32| UnknownTaggedField {
3953            tag,
3954            bytes: Bytes::from_static(b"x"),
3955        };
3956        for (label, fields) in [
3957            ("empty", vec![]),
3958            ("foreign tag", vec![field(7)]),
3959            (
3960                "two private tags",
3961                vec![
3962                    field(PRIVATE_TOPIC_FREEZE_TAG),
3963                    field(PRIVATE_BREAK_GLASS_PROPOSAL_TAG),
3964                ],
3965            ),
3966        ] {
3967            let no_op = KraftMetadataRecord::NoOp(NoOpRecord {
3968                unknown_tagged_fields: UnknownTaggedFields(fields),
3969            });
3970            check!(
3971                from_kraft(&no_op, &img()) == Err(TranslateError::NoCounterpart("NoOp")),
3972                "{label}"
3973            );
3974        }
3975    }
3976
3977    #[test]
3978    fn partition_to_kraft_preserves_reassignment_vectors() {
3979        let topic_id = uuid::Uuid::from_u128(0x11);
3980        let mut image = MetadataImage::new(uuid::Uuid::nil());
3981        image.apply(&MetadataRecord::V1Topic(TopicRecord {
3982            name: "t".into(),
3983            topic_id,
3984            partitions: 1,
3985            replication_factor: 3,
3986        }));
3987        let rec = MetadataRecord::V1Partition(PartitionRecord {
3988            topic: "t".into(),
3989            partition: 0,
3990            leader: NodeId(1),
3991            replicas: vec![NodeId(1), NodeId(2), NodeId(3)],
3992            isr: vec![NodeId(1), NodeId(2)],
3993            leader_epoch: LeaderEpoch(4),
3994            adding_replicas: vec![NodeId(3)],
3995            removing_replicas: vec![NodeId(2)],
3996            directories: vec![],
3997            partition_epoch: 9,
3998        });
3999
4000        let KraftMetadataRecord::Partition(k) = to_kraft(&rec, &image).unwrap() else {
4001            panic!("expected Partition");
4002        };
4003
4004        assert2::assert!(k.adding_replicas == vec![3]);
4005        assert2::assert!(k.removing_replicas == vec![2]);
4006    }
4007
4008    #[test]
4009    fn non_private_unknown_has_no_counterpart() {
4010        let err = from_kraft(
4011            &KraftMetadataRecord::Unknown {
4012                api_key: 999_999,
4013                api_version: 0,
4014                body: bytes::Bytes::from_static(b"not a private carrier"),
4015            },
4016            &img(),
4017        )
4018        .unwrap_err();
4019
4020        assert2::assert!(err == TranslateError::NoCounterpart("Unknown"));
4021    }
4022
4023    #[test]
4024    fn remove_acl_with_unknown_id_is_rejected() {
4025        let remove_unknown =
4026            KraftMetadataRecord::RemoveAccessControlEntry(RemoveAccessControlEntryRecord {
4027                id: to_kuuid(uuid::Uuid::from_u128(0xA11CE)),
4028                ..Default::default()
4029            });
4030        let err = from_kraft(&remove_unknown, &img()).unwrap_err();
4031
4032        assert2::assert!(matches!(err, TranslateError::UnknownAclId(_)));
4033
4034        let mut image_with_different_acl = img();
4035        image_with_different_acl.apply(&MetadataRecord::V1AccessControlEntry(acl(
4036            ResourceType::Topic,
4037            "topic-a",
4038            "User:alice",
4039        )));
4040        let err = from_kraft(&remove_unknown, &image_with_different_acl).unwrap_err();
4041
4042        assert2::assert!(matches!(err, TranslateError::UnknownAclId(_)));
4043    }
4044
4045    #[test]
4046    fn kraft_variant_name_reports_unmodeled_variants() {
4047        for (record, want) in [
4048            (
4049                KraftMetadataRecord::BrokerRegistrationChange(
4050                    BrokerRegistrationChangeRecord::default(),
4051                ),
4052                "BrokerRegistrationChange",
4053            ),
4054            (KraftMetadataRecord::NoOp(NoOpRecord::default()), "NoOp"),
4055            (
4056                KraftMetadataRecord::BeginTransaction(BeginTransactionRecord::default()),
4057                "BeginTransaction",
4058            ),
4059            (
4060                KraftMetadataRecord::EndTransaction(EndTransactionRecord::default()),
4061                "EndTransaction",
4062            ),
4063            (
4064                KraftMetadataRecord::RemoveAccessControlEntry(
4065                    RemoveAccessControlEntryRecord::default(),
4066                ),
4067                "RemoveAccessControlEntry",
4068            ),
4069            (
4070                KraftMetadataRecord::PartitionChange(KPartitionChangeRecord::default()),
4071                "PartitionChange",
4072            ),
4073            (
4074                KraftMetadataRecord::Unknown {
4075                    api_key: 42,
4076                    api_version: 0,
4077                    body: bytes::Bytes::new(),
4078                },
4079                "Unknown",
4080            ),
4081        ] {
4082            assert2::assert!(kraft_variant_name(&record) == want);
4083        }
4084    }
4085
4086    #[test]
4087    fn image_derives_partition_count_from_partitions_map() {
4088        let mut image = img();
4089        image.apply(&MetadataRecord::V1Topic(TopicRecord {
4090            name: "t".into(),
4091            topic_id: uuid::Uuid::from_u128(7),
4092            partitions: 3,
4093            replication_factor: 1,
4094        }));
4095        for p in 0..3 {
4096            image.apply(&MetadataRecord::V1Partition(PartitionRecord {
4097                topic: "t".into(),
4098                partition: p,
4099                leader: NodeId(1),
4100                replicas: vec![NodeId(1)],
4101                isr: vec![NodeId(1)],
4102                leader_epoch: LeaderEpoch(0),
4103                adding_replicas: vec![],
4104                removing_replicas: vec![],
4105                directories: vec![],
4106                partition_epoch: 0,
4107            }));
4108        }
4109        assert2::assert!(image.topic_partition_count("t") == 3);
4110    }
4111
4112    /// An image with topic `t` and three one-replica partitions: 0 is
4113    /// ordinary, 1 holds its leader epoch at the maximum and 2 its partition
4114    /// epoch.
4115    fn classification_image() -> (MetadataImage, uuid::Uuid) {
4116        let topic_id = uuid::Uuid::from_u128(0x7E);
4117        let mut image = img();
4118        image.apply(&MetadataRecord::V1Topic(TopicRecord {
4119            name: "t".into(),
4120            topic_id,
4121            partitions: 3,
4122            replication_factor: 1,
4123        }));
4124        for (partition, leader_epoch, partition_epoch) in [
4125            (0, LeaderEpoch(7), 3),
4126            (1, LeaderEpoch(i32::MAX), 3),
4127            (2, LeaderEpoch(7), i32::MAX),
4128        ] {
4129            image.apply(&MetadataRecord::V1Partition(PartitionRecord {
4130                topic: "t".into(),
4131                partition,
4132                leader: NodeId(1),
4133                replicas: vec![NodeId(1)],
4134                isr: vec![NodeId(1)],
4135                leader_epoch,
4136                partition_epoch,
4137                adding_replicas: vec![],
4138                removing_replicas: vec![],
4139                directories: vec![],
4140            }));
4141        }
4142        (image, topic_id)
4143    }
4144
4145    /// Pins which `Invalid*` variant a representative of each kind of bad
4146    /// input produces. A reference error depends on what the image holds; a
4147    /// value error is decided by the record's bytes alone.
4148    #[test]
4149    fn invalid_errors_split_into_reference_and_value() {
4150        let (image, topic_id) = classification_image();
4151        let change = |partition_id: i32, edit: fn(&mut KPartitionChangeRecord)| {
4152            let mut record = KPartitionChangeRecord {
4153                topic_id: to_kuuid(topic_id),
4154                partition_id,
4155                ..Default::default()
4156            };
4157            edit(&mut record);
4158            from_kraft(&KraftMetadataRecord::PartitionChange(record), &image)
4159        };
4160        let reference = |field, detail: &str| {
4161            Err(TranslateError::InvalidReference {
4162                field,
4163                detail: detail.into(),
4164            })
4165        };
4166        let value = |field, detail: &str| {
4167            Err(TranslateError::InvalidValue {
4168                field,
4169                detail: detail.into(),
4170            })
4171        };
4172
4173        for (case, got, want) in [
4174            (
4175                "decode: unknown partition",
4176                change(5, |_| {}),
4177                reference("partition change", "unknown partition t-5"),
4178            ),
4179            (
4180                "decode: directories do not match the image's replicas",
4181                change(0, |c| {
4182                    c.directories = Some(vec![KUuid([1; 16]), KUuid([2; 16])]);
4183                }),
4184                reference(
4185                    "partition change directories",
4186                    "2 directories for 1 replicas",
4187                ),
4188            ),
4189            (
4190                "decode: directories do not match replicas in the same record",
4191                change(0, |c| {
4192                    c.replicas = Some(vec![1, 2]);
4193                    c.directories = Some(vec![KUuid([1; 16])]);
4194                }),
4195                value(
4196                    "partition change directories",
4197                    "1 directories for 2 replicas",
4198                ),
4199            ),
4200            (
4201                "decode: image leader epoch at its maximum",
4202                change(1, |c| c.leader = 1),
4203                reference("partition change leader epoch", "leader epoch overflow"),
4204            ),
4205            (
4206                "encode: directory assignment for an unknown partition",
4207                to_kraft(
4208                    &MetadataRecord::V1PartitionDirAssignment(PartitionDirAssignmentRecord {
4209                        topic: "t".into(),
4210                        partition: 9,
4211                        replica: NodeId(1),
4212                        directory: uuid::Uuid::from_u128(0xD1),
4213                    }),
4214                    &image,
4215                )
4216                .map(|_| unreachable!()),
4217                reference("partition directory assignment", "unknown partition t-9"),
4218            ),
4219            (
4220                "decode: image partition epoch at its maximum",
4221                change(2, |_| {}),
4222                reference(
4223                    "partition change partition epoch",
4224                    "partition epoch overflow",
4225                ),
4226            ),
4227            (
4228                "decode: unknown leader recovery state on a partition record",
4229                from_kraft(
4230                    &KraftMetadataRecord::Partition(KPartitionRecord {
4231                        topic_id: to_kuuid(topic_id),
4232                        partition_id: 0,
4233                        leader: 1,
4234                        replicas: vec![1],
4235                        isr: vec![1],
4236                        leader_recovery_state: 9,
4237                        ..Default::default()
4238                    }),
4239                    &image,
4240                ),
4241                value("partition leader recovery state", "unknown state 9"),
4242            ),
4243            (
4244                "decode: unknown leader recovery state on a known partition",
4245                change(0, |c| c.leader_recovery_state = 9),
4246                value("partition change leader recovery state", "unknown state 9"),
4247            ),
4248            (
4249                "decode: unknown fenced value",
4250                from_kraft(
4251                    &KraftMetadataRecord::BrokerRegistrationChange(
4252                        BrokerRegistrationChangeRecord {
4253                            broker_id: 1,
4254                            fenced: 7,
4255                            ..Default::default()
4256                        },
4257                    ),
4258                    &image,
4259                ),
4260                value("broker registration change fenced", "unknown value 7"),
4261            ),
4262            (
4263                "decode: negative node id",
4264                from_kraft(
4265                    &KraftMetadataRecord::UnregisterBroker(KUnregisterBrokerRecord {
4266                        broker_id: -1,
4267                        ..Default::default()
4268                    }),
4269                    &image,
4270                ),
4271                value("unregister broker id", "-1 is negative"),
4272            ),
4273            (
4274                "encode: node id that exceeds i32",
4275                to_kraft(
4276                    &MetadataRecord::V1ProducerIds(ProducerIdsRecord {
4277                        broker_id: NodeId(1 << 31),
4278                        broker_epoch: 0,
4279                        next_producer_id: 0,
4280                    }),
4281                    &image,
4282                )
4283                .map(|_| unreachable!()),
4284                value("producer IDs broker id", "node_id 2147483648 exceeds i32"),
4285            ),
4286        ] {
4287            check!(got == want, "{case}");
4288        }
4289    }
4290
4291    /// Every byte-content check names its field and the offending value, and
4292    /// reports it as `InvalidValue`, whatever the image holds.
4293    #[test]
4294    fn byte_content_errors_are_invalid_values() {
4295        let image = img();
4296        let decode = |record: KraftMetadataRecord| from_kraft(&record, &image).map(drop);
4297        let encode = |record: MetadataRecord| to_kraft(&record, &image).map(drop);
4298        let scram = |mechanism: i8, iterations: i32| {
4299            decode(KraftMetadataRecord::UserScramCredential(
4300                UserScramCredentialRecord {
4301                    name: "alice".into(),
4302                    mechanism,
4303                    iterations,
4304                    ..Default::default()
4305                },
4306            ))
4307        };
4308        let token = |owner: &str, requester: &str, renewer: &str| {
4309            decode(KraftMetadataRecord::DelegationToken(
4310                KDelegationTokenRecord {
4311                    owner: owner.into(),
4312                    requester: requester.into(),
4313                    renewers: vec![renewer.into()],
4314                    ..Default::default()
4315                },
4316            ))
4317        };
4318        let config = |resource_type: i8, resource_name: &str| {
4319            decode(KraftMetadataRecord::Config(ConfigRecord {
4320                resource_type,
4321                resource_name: resource_name.into(),
4322                name: "k".into(),
4323                ..Default::default()
4324            }))
4325        };
4326        let value = |field, detail: &str| {
4327            Err(TranslateError::InvalidValue {
4328                field,
4329                detail: detail.into(),
4330            })
4331        };
4332
4333        for (case, got, want) in [
4334            (
4335                "a non-SCRAM mechanism on encode",
4336                scram_mechanism_to_wire(SaslMechanism::Plain).map(drop),
4337                value("scram mechanism", "Plain is not a SCRAM mechanism"),
4338            ),
4339            (
4340                "an unknown SCRAM mechanism byte",
4341                scram(9, 4096),
4342                value("scram mechanism", "unknown SCRAM mechanism wire byte 9"),
4343            ),
4344            (
4345                "negative SCRAM iterations",
4346                scram(1, -1),
4347                value("scram iterations", "-1 is negative"),
4348            ),
4349            (
4350                "SCRAM iterations that exceed i32 on encode",
4351                encode(MetadataRecord::V1ScramCredential(ScramCredentialRecord {
4352                    user: "alice".into(),
4353                    mechanism: SaslMechanism::ScramSha256,
4354                    salt: vec![],
4355                    stored_key: vec![],
4356                    server_key: vec![],
4357                    iterations: u32::MAX,
4358                })),
4359                value("scram iterations", "4294967295 exceeds i32"),
4360            ),
4361            (
4362                "an unknown ACL resource type",
4363                resource_type_from_wire(9).map(drop),
4364                value("acl resource_type", "unknown wire byte 9"),
4365            ),
4366            (
4367                "an unknown ACL pattern type",
4368                pattern_type_from_wire(9).map(drop),
4369                value("acl pattern_type", "unknown wire byte 9"),
4370            ),
4371            (
4372                "an unknown ACL operation",
4373                operation_from_wire(99).map(drop),
4374                value("acl operation", "unknown wire byte 99"),
4375            ),
4376            (
4377                "an unknown ACL permission type",
4378                permission_from_wire(9).map(drop),
4379                value("acl permission_type", "unknown wire byte 9"),
4380            ),
4381            (
4382                "an unknown security protocol",
4383                protocol_from_wire(9).map(drop),
4384                value("security_protocol", "unknown wire value 9"),
4385            ),
4386            (
4387                "an unregistered broker id that exceeds i32 on encode",
4388                encode(MetadataRecord::V1UnregisterBroker(UnregisterBrokerRecord {
4389                    node_id: NodeId(1 << 31),
4390                    broker_epoch: 0,
4391                })),
4392                value("broker_id", "node_id 2147483648 exceeds i32"),
4393            ),
4394            (
4395                "a registered broker id that exceeds i32 on encode",
4396                encode(MetadataRecord::V1BrokerRegistration(
4397                    BrokerRegistrationRecord {
4398                        node_id: NodeId(1 << 31),
4399                        broker_epoch: 0,
4400                        incarnation_id: uuid::Uuid::nil(),
4401                        host: "localhost".into(),
4402                        port: 9092,
4403                        rack: None,
4404                        log_dirs: vec![],
4405                        fenced: false,
4406                        in_controlled_shutdown: false,
4407                        cordoned_log_dirs: None,
4408                        endpoints: vec![],
4409                        features: BTreeMap::new(),
4410                    },
4411                )),
4412                value("broker_id", "node_id 2147483648 exceeds i32"),
4413            ),
4414            (
4415                "a registered controller id that exceeds i32 on encode",
4416                encode(MetadataRecord::V1ControllerRegistration(
4417                    ControllerRegistrationRecord {
4418                        node_id: NodeId(1 << 31),
4419                        incarnation_id: uuid::Uuid::nil(),
4420                        zk_migration_ready: false,
4421                        endpoints: vec![],
4422                        features: BTreeMap::new(),
4423                    },
4424                )),
4425                value("controller_id", "node_id 2147483648 exceeds i32"),
4426            ),
4427            (
4428                "a delegation token owner that does not parse",
4429                token("alice", "User:bob", "User:carol"),
4430                value("delegation token owner", "invalid principal \"alice\""),
4431            ),
4432            (
4433                "a delegation token requester that does not parse",
4434                token("User:alice", "bob", "User:carol"),
4435                value("delegation token requester", "invalid principal \"bob\""),
4436            ),
4437            (
4438                "a delegation token renewer that does not parse",
4439                token("User:alice", "User:bob", "carol"),
4440                value("delegation token renewer", "invalid principal \"carol\""),
4441            ),
4442            (
4443                "a broker config resource name that is not a node id",
4444                config(4, "broker-one"),
4445                value(
4446                    "broker config resource_name",
4447                    "invalid digit found in string",
4448                ),
4449            ),
4450            (
4451                "an unsupported config resource type",
4452                config(99, "x"),
4453                value("config resource_type", "unsupported resource_type 99"),
4454            ),
4455        ] {
4456            check!(got == want, "{case}");
4457        }
4458    }
4459
4460    /// The encode-side checks of partition records and partition updates:
4461    /// a lookup in the image is a reference error, a node id that exceeds
4462    /// `i32` a value error.
4463    #[test]
4464    fn partition_encode_errors_split_into_reference_and_value() {
4465        let (image, _) = classification_image();
4466        let encode = |record: MetadataRecord| to_kraft(&record, &image).map(drop);
4467        let update = |partition: i32, edit: fn(&mut crate::PartitionUpdateRecord)| {
4468            let mut record = crate::PartitionUpdateRecord {
4469                partition: image.partition("t", 0).unwrap().clone(),
4470                eligible_leader_replicas: None,
4471                last_known_elr: None,
4472                recovery_state: None,
4473            };
4474            record.partition.partition = partition;
4475            edit(&mut record);
4476            encode(MetadataRecord::V1PartitionUpdate(record))
4477        };
4478        let reference = |field, detail: &str| {
4479            Err(TranslateError::InvalidReference {
4480                field,
4481                detail: detail.into(),
4482            })
4483        };
4484        let value = |field, detail: &str| {
4485            Err(TranslateError::InvalidValue {
4486                field,
4487                detail: detail.into(),
4488            })
4489        };
4490
4491        for (case, got, want) in [
4492            (
4493                "encode: directory assignment for a broker that is not a replica",
4494                encode(MetadataRecord::V1PartitionDirAssignment(
4495                    PartitionDirAssignmentRecord {
4496                        topic: "t".into(),
4497                        partition: 0,
4498                        replica: NodeId(2),
4499                        directory: uuid::Uuid::from_u128(0xD1),
4500                    },
4501                )),
4502                reference(
4503                    "partition directory assignment",
4504                    "broker 2 is not a replica of t-0",
4505                ),
4506            ),
4507            (
4508                "encode: partition update for an unknown partition",
4509                update(9, |_| {}),
4510                reference("partition update", "unknown partition t-9"),
4511            ),
4512            (
4513                "encode: partition update with a replica that exceeds i32",
4514                update(0, |u| u.partition.replicas = vec![NodeId(1 << 31)]),
4515                value("partition replicas", "node id 2147483648 exceeds i32"),
4516            ),
4517            (
4518                "encode: partition update with an eligible leader that exceeds i32",
4519                update(0, |u| {
4520                    u.eligible_leader_replicas = Some(vec![NodeId(1 << 31)]);
4521                }),
4522                value("eligible leader replica", "node id 2147483648 exceeds i32"),
4523            ),
4524            (
4525                "encode: partition with a replica that exceeds i32",
4526                encode(MetadataRecord::V1Partition(PartitionRecord {
4527                    replicas: vec![NodeId(1 << 31)],
4528                    ..image.partition("t", 0).unwrap().clone()
4529                })),
4530                value("partition replicas", "node id 2147483648 exceeds i32"),
4531            ),
4532            (
4533                "encode: partition with a leader that exceeds i32",
4534                encode(MetadataRecord::V1Partition(PartitionRecord {
4535                    leader: NodeId(1 << 31),
4536                    ..image.partition("t", 0).unwrap().clone()
4537                })),
4538                value("partition leader", "leader 2147483648 exceeds i32"),
4539            ),
4540        ] {
4541            check!(got == want, "{case}");
4542        }
4543    }
4544}