iroh_db_core/
commit.rs

1use std::fmt;
2
3use minicbor::{Decoder, Encoder};
4
5use crate::{AuthorId, CapabilityId, CollectionId, CommitId, DomainId, SchemaId, TransactionId};
6
7const MAGIC: &str = "iroh-db";
8const FORMAT_MAJOR: u16 = 1;
9const FORMAT_MINOR: u16 = 0;
10const ENVELOPE_KIND: u8 = 2;
11const BODY_KIND: u8 = 3;
12const MAX_DEPENDENCIES: usize = 4_096;
13const MAX_SCHEMAS: usize = 4_096;
14const MAX_OPERATIONS: usize = 65_536;
15const MAX_RECORD_ID: usize = 1_024;
16const MAX_OPERATION_PAYLOAD: usize = 16 * 1024 * 1024;
17const MAX_CIPHERTEXT: usize = 64 * 1024 * 1024;
18
19/// The Ed25519 signature attached to a commit envelope.
20#[derive(Clone, Copy, PartialEq, Eq, Hash)]
21pub struct CommitSignature([u8; 64]);
22
23impl CommitSignature {
24    /// Constructs a signature from its exact wire bytes.
25    pub const fn from_bytes(bytes: [u8; 64]) -> Self {
26        Self(bytes)
27    }
28
29    /// Returns the exact signature bytes.
30    pub const fn as_bytes(&self) -> &[u8; 64] {
31        &self.0
32    }
33
34    /// Consumes this signature and returns its exact bytes.
35    pub const fn to_bytes(self) -> [u8; 64] {
36        self.0
37    }
38}
39
40impl fmt::Debug for CommitSignature {
41    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42        formatter.write_str("CommitSignature([redacted])")
43    }
44}
45
46/// Public authenticated metadata for an encrypted commit body.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct CommitHeader {
49    domain_id: DomainId,
50    epoch: u64,
51    author: AuthorId,
52    author_sequence: u64,
53    dependencies: Vec<CommitId>,
54    capability_id: CapabilityId,
55    schema_ids: Vec<SchemaId>,
56    nonce: [u8; 24],
57}
58
59impl CommitHeader {
60    /// Validates and constructs a canonical commit header.
61    #[allow(clippy::too_many_arguments)]
62    pub fn new(
63        domain_id: DomainId,
64        epoch: u64,
65        author: AuthorId,
66        author_sequence: u64,
67        mut dependencies: Vec<CommitId>,
68        capability_id: CapabilityId,
69        mut schema_ids: Vec<SchemaId>,
70        nonce: [u8; 24],
71    ) -> Result<Self, CommitCodecError> {
72        if dependencies.len() > MAX_DEPENDENCIES {
73            return Err(CommitCodecError::TooManyDependencies(dependencies.len()));
74        }
75        if schema_ids.len() > MAX_SCHEMAS {
76            return Err(CommitCodecError::TooManySchemas(schema_ids.len()));
77        }
78        dependencies.sort_unstable();
79        dependencies.dedup();
80        schema_ids.sort_unstable();
81        schema_ids.dedup();
82        Ok(Self {
83            domain_id,
84            epoch,
85            author,
86            author_sequence,
87            dependencies,
88            capability_id,
89            schema_ids,
90            nonce,
91        })
92    }
93
94    /// Returns the domain this transaction mutates.
95    pub const fn domain_id(&self) -> DomainId {
96        self.domain_id
97    }
98
99    /// Returns the encryption and authorization epoch.
100    pub const fn epoch(&self) -> u64 {
101        self.epoch
102    }
103
104    /// Returns the authenticated endpoint author.
105    pub const fn author(&self) -> AuthorId {
106        self.author
107    }
108
109    /// Returns the author's contiguous domain-local sequence.
110    pub const fn author_sequence(&self) -> u64 {
111        self.author_sequence
112    }
113
114    /// Returns the sorted, unique causal frontier dependencies.
115    pub fn dependencies(&self) -> &[CommitId] {
116        &self.dependencies
117    }
118
119    /// Returns the capability authorizing the transaction.
120    pub const fn capability_id(&self) -> CapabilityId {
121        self.capability_id
122    }
123
124    /// Returns the sorted, unique schemas used by operations.
125    pub fn schema_ids(&self) -> &[SchemaId] {
126        &self.schema_ids
127    }
128
129    /// Returns the XChaCha20-Poly1305 nonce.
130    pub const fn nonce(&self) -> &[u8; 24] {
131        &self.nonce
132    }
133}
134
135/// An immutable signed commit blob.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct CommitEnvelope {
138    header: CommitHeader,
139    ciphertext: Vec<u8>,
140    signature: CommitSignature,
141}
142
143impl CommitEnvelope {
144    /// Constructs a bounded commit envelope.
145    pub fn new(
146        header: CommitHeader,
147        ciphertext: Vec<u8>,
148        signature: CommitSignature,
149    ) -> Result<Self, CommitCodecError> {
150        if ciphertext.len() > MAX_CIPHERTEXT {
151            return Err(CommitCodecError::CiphertextTooLarge(ciphertext.len()));
152        }
153        Ok(Self {
154            header,
155            ciphertext,
156            signature,
157        })
158    }
159
160    /// Returns the authenticated public header.
161    pub const fn header(&self) -> &CommitHeader {
162        &self.header
163    }
164
165    /// Returns the encrypted commit body and AEAD tag.
166    pub fn ciphertext(&self) -> &[u8] {
167        &self.ciphertext
168    }
169
170    /// Returns the author signature.
171    pub const fn signature(&self) -> CommitSignature {
172        self.signature
173    }
174
175    /// Returns the exact canonical bytes covered by the signature.
176    pub fn signing_bytes(&self) -> Vec<u8> {
177        Self::signing_bytes_for(&self.header, &self.ciphertext)
178            .expect("an existing envelope is already size-validated")
179    }
180
181    /// Encodes a prospective header and ciphertext for signing.
182    pub fn signing_bytes_for(
183        header: &CommitHeader,
184        ciphertext: &[u8],
185    ) -> Result<Vec<u8>, CommitCodecError> {
186        if ciphertext.len() > MAX_CIPHERTEXT {
187            return Err(CommitCodecError::CiphertextTooLarge(ciphertext.len()));
188        }
189        Ok(encode_envelope(header, ciphertext, None))
190    }
191
192    /// Encodes the complete durable commit blob.
193    pub fn encode_canonical(&self) -> Vec<u8> {
194        encode_envelope(&self.header, &self.ciphertext, Some(self.signature))
195    }
196
197    /// Strictly decodes the unique durable commit representation.
198    pub fn decode_canonical(bytes: &[u8]) -> Result<Self, CommitCodecError> {
199        let envelope = decode_envelope(bytes)?;
200        if envelope.encode_canonical() != bytes {
201            return Err(CommitCodecError::NonCanonicalEncoding);
202        }
203        Ok(envelope)
204    }
205
206    /// Returns the iroh-blobs-compatible BLAKE3 hash of the exact blob bytes.
207    pub fn commit_id(&self) -> CommitId {
208        CommitId::from_bytes(*blake3::hash(&self.encode_canonical()).as_bytes())
209    }
210}
211
212/// A decrypted atomic domain transaction.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct CommitBody {
215    transaction_id: TransactionId,
216    operations: Vec<Operation>,
217}
218
219impl CommitBody {
220    /// Constructs a bounded transaction body.
221    pub fn new(
222        transaction_id: TransactionId,
223        operations: Vec<Operation>,
224    ) -> Result<Self, CommitCodecError> {
225        if operations.len() > MAX_OPERATIONS {
226            return Err(CommitCodecError::TooManyOperations(operations.len()));
227        }
228        Ok(Self {
229            transaction_id,
230            operations,
231        })
232    }
233
234    /// Returns the idempotency identity of the local transaction.
235    pub const fn transaction_id(&self) -> TransactionId {
236        self.transaction_id
237    }
238
239    /// Returns operations in transaction order.
240    pub fn operations(&self) -> &[Operation] {
241        &self.operations
242    }
243
244    /// Encodes the transaction body before encryption.
245    pub fn encode_canonical(&self) -> Vec<u8> {
246        let mut encoder = Encoder::new(Vec::new());
247        encode_body(&mut encoder, self).expect("encoding into Vec cannot fail");
248        encoder.into_writer()
249    }
250
251    /// Strictly decodes a decrypted transaction body.
252    pub fn decode_canonical(bytes: &[u8]) -> Result<Self, CommitCodecError> {
253        let body = decode_body(bytes)?;
254        if body.encode_canonical() != bytes {
255            return Err(CommitCodecError::NonCanonicalEncoding);
256        }
257        Ok(body)
258    }
259}
260
261/// The stable operation opcode applied by the materializer.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263#[repr(u8)]
264pub enum OperationKind {
265    /// Adds a record incarnation.
266    RecordCreate = 1,
267    /// Removes observed record incarnations.
268    RecordDelete = 2,
269    /// Assigns a causal LWW register.
270    LwwAssign = 3,
271    /// Adds an observed-remove set element.
272    OrSetAdd = 4,
273    /// Removes observed set additions.
274    OrSetRemove = 5,
275    /// Adds a grow-only set element.
276    GrowOnlySetAdd = 6,
277    /// Increments a PN counter component.
278    CounterIncrement = 7,
279    /// Decrements a PN counter component.
280    CounterDecrement = 8,
281    /// Assigns a multi-value register.
282    MultiValueAssign = 9,
283    /// Sets an immutable value.
284    ImmutableSet = 10,
285    /// Inserts an ordered-list element.
286    OrderedListInsert = 11,
287    /// Removes an observed ordered-list element.
288    OrderedListRemove = 12,
289    /// Merges a complete canonical CRDT field state.
290    FieldStateMerge = 13,
291    /// Stages one target-schema record without changing visible state.
292    SchemaStage = 14,
293    /// Atomically activates a completely staged schema transition.
294    SchemaActivate = 15,
295}
296
297impl TryFrom<u8> for OperationKind {
298    type Error = CommitCodecError;
299
300    fn try_from(value: u8) -> Result<Self, Self::Error> {
301        match value {
302            1 => Ok(Self::RecordCreate),
303            2 => Ok(Self::RecordDelete),
304            3 => Ok(Self::LwwAssign),
305            4 => Ok(Self::OrSetAdd),
306            5 => Ok(Self::OrSetRemove),
307            6 => Ok(Self::GrowOnlySetAdd),
308            7 => Ok(Self::CounterIncrement),
309            8 => Ok(Self::CounterDecrement),
310            9 => Ok(Self::MultiValueAssign),
311            10 => Ok(Self::ImmutableSet),
312            11 => Ok(Self::OrderedListInsert),
313            12 => Ok(Self::OrderedListRemove),
314            13 => Ok(Self::FieldStateMerge),
315            14 => Ok(Self::SchemaStage),
316            15 => Ok(Self::SchemaActivate),
317            value => Err(CommitCodecError::UnknownOperationKind(value)),
318        }
319    }
320}
321
322/// One field or record operation in an atomic commit.
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub struct Operation {
325    collection_id: CollectionId,
326    record_id: Vec<u8>,
327    field_id: u32,
328    kind: OperationKind,
329    payload: Vec<u8>,
330}
331
332impl Operation {
333    /// Constructs a size-bounded operation.
334    pub fn new(
335        collection_id: CollectionId,
336        record_id: Vec<u8>,
337        field_id: u32,
338        kind: OperationKind,
339        payload: Vec<u8>,
340    ) -> Result<Self, CommitCodecError> {
341        if record_id.len() > MAX_RECORD_ID {
342            return Err(CommitCodecError::RecordIdTooLarge(record_id.len()));
343        }
344        if payload.len() > MAX_OPERATION_PAYLOAD {
345            return Err(CommitCodecError::OperationPayloadTooLarge(payload.len()));
346        }
347        Ok(Self {
348            collection_id,
349            record_id,
350            field_id,
351            kind,
352            payload,
353        })
354    }
355
356    /// Returns the target collection.
357    pub const fn collection_id(&self) -> CollectionId {
358        self.collection_id
359    }
360
361    /// Returns the canonical application record key.
362    pub fn record_id(&self) -> &[u8] {
363        &self.record_id
364    }
365
366    /// Returns the stable target field ID, or zero for record operations.
367    pub const fn field_id(&self) -> u32 {
368        self.field_id
369    }
370
371    /// Returns the deterministic operation opcode.
372    pub const fn kind(&self) -> OperationKind {
373        self.kind
374    }
375
376    /// Returns the operation-specific canonical payload.
377    pub fn payload(&self) -> &[u8] {
378        &self.payload
379    }
380}
381
382/// Commit format validation or decoding failure.
383#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
384pub enum CommitCodecError {
385    /// A commit declared too many causal dependencies.
386    #[error("too many commit dependencies: {0}")]
387    TooManyDependencies(usize),
388    /// A commit declared too many schema references.
389    #[error("too many schema references: {0}")]
390    TooManySchemas(usize),
391    /// A transaction contained too many operations.
392    #[error("too many operations: {0}")]
393    TooManyOperations(usize),
394    /// A record key exceeded the protocol limit.
395    #[error("record id is too large: {0} bytes")]
396    RecordIdTooLarge(usize),
397    /// An operation payload exceeded the protocol limit.
398    #[error("operation payload is too large: {0} bytes")]
399    OperationPayloadTooLarge(usize),
400    /// Encrypted commit bytes exceeded the protocol limit.
401    #[error("commit ciphertext is too large: {0} bytes")]
402    CiphertextTooLarge(usize),
403    /// An unknown operation opcode was encountered.
404    #[error("unknown operation kind {0}")]
405    UnknownOperationKind(u8),
406    /// The object format major, minor, or kind is unsupported.
407    #[error("unsupported commit format")]
408    UnsupportedFormat,
409    /// The binary object was malformed or violated a resource bound.
410    #[error("invalid commit encoding: {0}")]
411    InvalidEncoding(String),
412    /// The object was valid CBOR but not the unique canonical representation.
413    #[error("commit encoding is not canonical")]
414    NonCanonicalEncoding,
415}
416
417fn encode_envelope(
418    header: &CommitHeader,
419    ciphertext: &[u8],
420    signature: Option<CommitSignature>,
421) -> Vec<u8> {
422    let mut encoder = Encoder::new(Vec::new());
423    let field_count = if signature.is_some() { 14 } else { 13 };
424    encoder
425        .map(field_count)
426        .and_then(|encoder| encoder.u8(0))
427        .and_then(|encoder| encoder.str(MAGIC))
428        .and_then(|encoder| encoder.u8(1))
429        .and_then(|encoder| encoder.u16(FORMAT_MAJOR))
430        .and_then(|encoder| encoder.u8(2))
431        .and_then(|encoder| encoder.u16(FORMAT_MINOR))
432        .and_then(|encoder| encoder.u8(3))
433        .and_then(|encoder| encoder.u8(ENVELOPE_KIND))
434        .and_then(|encoder| encoder.u8(4))
435        .and_then(|encoder| encoder.bytes(header.domain_id().as_bytes()))
436        .and_then(|encoder| encoder.u8(5))
437        .and_then(|encoder| encoder.u64(header.epoch()))
438        .and_then(|encoder| encoder.u8(6))
439        .and_then(|encoder| encoder.bytes(header.author().as_bytes()))
440        .and_then(|encoder| encoder.u8(7))
441        .and_then(|encoder| encoder.u64(header.author_sequence()))
442        .expect("encoding into Vec cannot fail");
443    encoder
444        .u8(8)
445        .and_then(|encoder| encoder.array(header.dependencies().len() as u64))
446        .expect("encoding into Vec cannot fail");
447    for dependency in header.dependencies() {
448        encoder
449            .bytes(dependency.as_bytes())
450            .expect("encoding into Vec cannot fail");
451    }
452    encoder
453        .u8(9)
454        .and_then(|encoder| encoder.bytes(header.capability_id().as_bytes()))
455        .and_then(|encoder| encoder.u8(10))
456        .and_then(|encoder| encoder.array(header.schema_ids().len() as u64))
457        .expect("encoding into Vec cannot fail");
458    for schema_id in header.schema_ids() {
459        encoder
460            .bytes(schema_id.as_bytes())
461            .expect("encoding into Vec cannot fail");
462    }
463    encoder
464        .u8(11)
465        .and_then(|encoder| encoder.bytes(header.nonce()))
466        .and_then(|encoder| encoder.u8(12))
467        .and_then(|encoder| encoder.bytes(ciphertext))
468        .expect("encoding into Vec cannot fail");
469    if let Some(signature) = signature {
470        encoder
471            .u8(13)
472            .and_then(|encoder| encoder.bytes(signature.as_bytes()))
473            .expect("encoding into Vec cannot fail");
474    }
475    encoder.into_writer()
476}
477
478fn encode_body(
479    encoder: &mut Encoder<Vec<u8>>,
480    body: &CommitBody,
481) -> Result<(), minicbor::encode::Error<std::convert::Infallible>> {
482    encoder.map(6)?;
483    encoder.u8(0)?.str(MAGIC)?;
484    encoder.u8(1)?.u16(FORMAT_MAJOR)?;
485    encoder.u8(2)?.u16(FORMAT_MINOR)?;
486    encoder.u8(3)?.u8(BODY_KIND)?;
487    encoder.u8(4)?.bytes(body.transaction_id().as_bytes())?;
488    encoder.u8(5)?.array(body.operations().len() as u64)?;
489    for operation in body.operations() {
490        encoder.map(5)?;
491        encoder.u8(0)?.bytes(operation.collection_id().as_bytes())?;
492        encoder.u8(1)?.bytes(operation.record_id())?;
493        encoder.u8(2)?.u32(operation.field_id())?;
494        encoder.u8(3)?.u8(operation.kind() as u8)?;
495        encoder.u8(4)?.bytes(operation.payload())?;
496    }
497    Ok(())
498}
499
500fn decode_envelope(bytes: &[u8]) -> Result<CommitEnvelope, CommitCodecError> {
501    let mut decoder = Decoder::new(bytes);
502    expect_map_len(&mut decoder, 14)?;
503    decode_preamble(&mut decoder, ENVELOPE_KIND)?;
504    expect_key(&mut decoder, 4)?;
505    let domain_id = DomainId::from_bytes(read_fixed(&mut decoder, "domain id")?);
506    expect_key(&mut decoder, 5)?;
507    let epoch = decoder.u64().map_err(decode_error)?;
508    expect_key(&mut decoder, 6)?;
509    let author = AuthorId::from_bytes(read_fixed(&mut decoder, "author")?);
510    expect_key(&mut decoder, 7)?;
511    let author_sequence = decoder.u64().map_err(decode_error)?;
512    expect_key(&mut decoder, 8)?;
513    let dependency_count = read_array_len(&mut decoder, MAX_DEPENDENCIES, "dependencies")?;
514    let mut dependencies = Vec::with_capacity(dependency_count);
515    for _ in 0..dependency_count {
516        dependencies.push(CommitId::from_bytes(read_fixed(
517            &mut decoder,
518            "dependency",
519        )?));
520    }
521    expect_key(&mut decoder, 9)?;
522    let capability_id = CapabilityId::from_bytes(read_fixed(&mut decoder, "capability id")?);
523    expect_key(&mut decoder, 10)?;
524    let schema_count = read_array_len(&mut decoder, MAX_SCHEMAS, "schema ids")?;
525    let mut schema_ids = Vec::with_capacity(schema_count);
526    for _ in 0..schema_count {
527        schema_ids.push(SchemaId::from_bytes(read_fixed(&mut decoder, "schema id")?));
528    }
529    expect_key(&mut decoder, 11)?;
530    let nonce = read_fixed(&mut decoder, "nonce")?;
531    expect_key(&mut decoder, 12)?;
532    let ciphertext = read_bounded_bytes(&mut decoder, MAX_CIPHERTEXT, "ciphertext")?;
533    expect_key(&mut decoder, 13)?;
534    let signature = CommitSignature::from_bytes(read_fixed(&mut decoder, "signature")?);
535    if decoder.position() != bytes.len() {
536        return Err(CommitCodecError::InvalidEncoding("trailing data".into()));
537    }
538    let header = CommitHeader::new(
539        domain_id,
540        epoch,
541        author,
542        author_sequence,
543        dependencies,
544        capability_id,
545        schema_ids,
546        nonce,
547    )?;
548    CommitEnvelope::new(header, ciphertext, signature)
549}
550
551fn decode_body(bytes: &[u8]) -> Result<CommitBody, CommitCodecError> {
552    let mut decoder = Decoder::new(bytes);
553    expect_map_len(&mut decoder, 6)?;
554    decode_preamble(&mut decoder, BODY_KIND)?;
555    expect_key(&mut decoder, 4)?;
556    let transaction_id = TransactionId::from_bytes(read_fixed(&mut decoder, "transaction id")?);
557    expect_key(&mut decoder, 5)?;
558    let operation_count = read_array_len(&mut decoder, MAX_OPERATIONS, "operations")?;
559    let mut operations = Vec::with_capacity(operation_count);
560    for _ in 0..operation_count {
561        expect_map_len(&mut decoder, 5)?;
562        expect_key(&mut decoder, 0)?;
563        let collection_id = CollectionId::from_bytes(read_fixed(&mut decoder, "collection id")?);
564        expect_key(&mut decoder, 1)?;
565        let record_id = read_bounded_bytes(&mut decoder, MAX_RECORD_ID, "record id")?;
566        expect_key(&mut decoder, 2)?;
567        let field_id = decoder.u32().map_err(decode_error)?;
568        expect_key(&mut decoder, 3)?;
569        let kind = OperationKind::try_from(decoder.u8().map_err(decode_error)?)?;
570        expect_key(&mut decoder, 4)?;
571        let payload = read_bounded_bytes(&mut decoder, MAX_OPERATION_PAYLOAD, "operation payload")?;
572        operations.push(Operation::new(
573            collection_id,
574            record_id,
575            field_id,
576            kind,
577            payload,
578        )?);
579    }
580    if decoder.position() != bytes.len() {
581        return Err(CommitCodecError::InvalidEncoding("trailing data".into()));
582    }
583    CommitBody::new(transaction_id, operations)
584}
585
586fn decode_preamble(decoder: &mut Decoder<'_>, kind: u8) -> Result<(), CommitCodecError> {
587    expect_key(decoder, 0)?;
588    if decoder.str().map_err(decode_error)? != MAGIC {
589        return Err(CommitCodecError::UnsupportedFormat);
590    }
591    expect_key(decoder, 1)?;
592    if decoder.u16().map_err(decode_error)? != FORMAT_MAJOR {
593        return Err(CommitCodecError::UnsupportedFormat);
594    }
595    expect_key(decoder, 2)?;
596    if decoder.u16().map_err(decode_error)? != FORMAT_MINOR {
597        return Err(CommitCodecError::UnsupportedFormat);
598    }
599    expect_key(decoder, 3)?;
600    if decoder.u8().map_err(decode_error)? != kind {
601        return Err(CommitCodecError::UnsupportedFormat);
602    }
603    Ok(())
604}
605
606fn expect_map_len(decoder: &mut Decoder<'_>, expected: u64) -> Result<(), CommitCodecError> {
607    let actual = decoder
608        .map()
609        .map_err(decode_error)?
610        .ok_or_else(|| CommitCodecError::InvalidEncoding("indefinite map".into()))?;
611    if actual != expected {
612        return Err(CommitCodecError::InvalidEncoding(format!(
613            "expected map length {expected}, got {actual}"
614        )));
615    }
616    Ok(())
617}
618
619fn expect_key(decoder: &mut Decoder<'_>, expected: u8) -> Result<(), CommitCodecError> {
620    let actual = decoder.u8().map_err(decode_error)?;
621    if actual != expected {
622        return Err(CommitCodecError::InvalidEncoding(format!(
623            "expected key {expected}, got {actual}"
624        )));
625    }
626    Ok(())
627}
628
629fn read_array_len(
630    decoder: &mut Decoder<'_>,
631    maximum: usize,
632    name: &str,
633) -> Result<usize, CommitCodecError> {
634    let count = decoder
635        .array()
636        .map_err(decode_error)?
637        .ok_or_else(|| CommitCodecError::InvalidEncoding(format!("indefinite {name} array")))?;
638    let count = usize::try_from(count)
639        .map_err(|_| CommitCodecError::InvalidEncoding(format!("{name} count overflow")))?;
640    if count > maximum {
641        return Err(CommitCodecError::InvalidEncoding(format!(
642            "{name} count exceeds {maximum}"
643        )));
644    }
645    Ok(count)
646}
647
648fn read_bounded_bytes(
649    decoder: &mut Decoder<'_>,
650    maximum: usize,
651    name: &str,
652) -> Result<Vec<u8>, CommitCodecError> {
653    let bytes = decoder.bytes().map_err(decode_error)?;
654    if bytes.len() > maximum {
655        return Err(CommitCodecError::InvalidEncoding(format!(
656            "{name} exceeds {maximum} bytes"
657        )));
658    }
659    Ok(bytes.to_vec())
660}
661
662fn read_fixed<const LENGTH: usize>(
663    decoder: &mut Decoder<'_>,
664    name: &str,
665) -> Result<[u8; LENGTH], CommitCodecError> {
666    let bytes = decoder.bytes().map_err(decode_error)?;
667    bytes
668        .try_into()
669        .map_err(|_| CommitCodecError::InvalidEncoding(format!("{name} must be {LENGTH} bytes")))
670}
671
672// `Result::map_err` requires ownership even though formatting only borrows the error.
673#[allow(clippy::needless_pass_by_value)]
674fn decode_error(error: minicbor::decode::Error) -> CommitCodecError {
675    CommitCodecError::InvalidEncoding(error.to_string())
676}