Skip to main content

decdn_protocol/
dht.rs

1//! Wire message types for the `cdn/dht/v1` Kademlia content DHT (ADR 022).
2//!
3//! `cdn/dht/v1` is the primary content discovery mechanism: nodes publish
4//! `(hash → NodeId)` records to the K closest peers in keyspace, and clients
5//! iteratively look up providers in O(log N) hops. This module defines the
6//! wire surface only — routing-table, record-store, scheduler, and rate-limit
7//! logic live in `decdn-node`.
8//!
9//! # Authentication model
10//!
11//! [`StoreRequest`] has no per-record signature. The receiver instead checks
12//! that `holder` equals the authenticated `NodeId` of the inbound QUIC
13//! connection (`NodeId`s are 32-byte Ed25519 public keys; iroh's QUIC handshake
14//! binds the connection to that key). This depends on records being pushed
15//! directly by the holder to the K-closest nodes and never relayed
16//! peer-to-peer — if a future scheme introduces peer relay of records, a
17//! per-record signature MUST be reintroduced together with an in-scope sender
18//! timestamp in the signed body (ADR 022 §STORE Flow).
19//!
20//! # `BatchStore` wire types
21//!
22//! `BatchStoreRequest` / `BatchStoreAck` are part of `cdn/dht/v1` at
23//! [`DhtMessage`] discriminants 4 and 5, ahead of `FindNode` /
24//! `FindNodeResponse` at 6 and 7 to match ADR 022's variant order. They
25//! carry the bandwidth optimization in which one holder publishes many
26//! hashes to one receiver in a single RPC (ADR 022 §STORE Flow Batched
27//! STORE). The receiver-side admission and two-stage rate-limit
28//! accounting live in `decdn-node`; every DHT node implements them, so
29//! there is no per-hash fallback or support negotiation.
30//!
31//! # Bounded-Vec deserialization
32//!
33//! Network responses with `Vec` fields are bounded at decode time via
34//! `#[serde(deserialize_with = ...)]` hooks that enforce the wire-level
35//! caps below. The framing layer caps the whole message at
36//! [`MAX_DHT_FRAME`], but the per-field caps stop peers from forcing the
37//! responder to allocate thousands of 32-byte `NodeId`s (or tens of thousands
38//! of booleans) inside one frame.
39//! Pattern mirrors `ProbeResponseBody::rate_per_mb`.
40
41use serde::de::{self, Deserializer};
42use serde::{Deserialize, Serialize};
43
44use crate::coverage::Coverage;
45use crate::identity::{ContentHash, NodeId};
46
47/// Wire-level maximum number of providers a [`FindValueResponse`] may carry
48/// (ADR 022 §Content Records and TTL). With 200-byte coverage bitmaps it bounds
49/// a full response to the 12,375 bytes in ADR 022 §DHT Bandwidth Analysis,
50/// inside [`MAX_DHT_FRAME`].
51///
52/// Enforced at the wire boundary by `deserialize_providers`; oversize values
53/// fail decode rather than being silently truncated.
54pub const MAX_PROVIDERS_PER_HASH: usize = 50;
55
56/// Wire-level maximum number of `NodeId`s in a `closer_nodes` field, equal to
57/// the Kademlia bucket size K=20 (ADR 022 §Routing Table). Applies to both
58/// [`FindValueResponse::closer_nodes`] and [`FindNodeResponse::closer_nodes`].
59/// Enforced by the [`CloserNodes`] type — at construction
60/// ([`CloserNodes::try_new`]) and at the wire boundary (its [`Deserialize`]).
61pub const MAX_CLOSER_NODES: usize = 20;
62
63/// Wire-level maximum number of hashes a [`BatchStoreRequest`] may carry
64/// (ADR 022 §STORE Flow — "Batch size is bounded at 256 hashes"). Applies
65/// symmetrically to [`BatchStoreAck::results`] (one bool per request hash,
66/// same cap so a malicious responder can't reply with an oversize ack).
67/// Enforced at the wire boundary; ADR 022 spells out that oversize batches
68/// close the stream with `MALFORMED_MESSAGE` (`0x03`) — the decode-time
69/// reject here surfaces as that error code via the framing layer.
70pub const MAX_BATCH_STORE_HASHES: usize = 256;
71
72/// Frame ceiling for `cdn/dht/v1`, in payload bytes (ADR 013 § Wire Framing).
73///
74/// Every DHT read passes this to [`crate::read_frame`], for requests and
75/// responses alike. The largest legitimate message is a full
76/// [`BatchStoreRequest`]: [`MAX_BATCH_STORE_HASHES`] entries, each a 32-byte
77/// hash and a [`Coverage`] of up to 202 bytes, plus the holder — 59,939 bytes.
78/// The largest response is a full [`FindValueResponse`] at 12,375 bytes. The
79/// `dht_frame_ceiling_holds_the_largest_message` test pins the margin.
80pub const MAX_DHT_FRAME: u32 = 64 * 1024;
81
82/// A bounded list of the K closest [`NodeId`]s a responder returns in a
83/// `closer_nodes` field (ADR 022 §Routing Table). Wraps `Vec<NodeId>` and
84/// enforces the [`MAX_CLOSER_NODES`] invariant **at construction** — both
85/// [`CloserNodes::try_new`] and the bounded [`Deserialize`] impl reject
86/// oversize lists, so an over-cap `closer_nodes` cannot exist in memory or on
87/// the wire. `#[repr(transparent)]` + `#[serde(transparent)]` keep the encoding
88/// byte-identical to a bare `Vec<NodeId>`.
89#[repr(transparent)]
90#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
91#[serde(transparent)]
92pub struct CloserNodes(Vec<NodeId>);
93
94/// Error returned by [`CloserNodes::try_new`] when the list exceeds
95/// [`MAX_CLOSER_NODES`].
96#[derive(Debug, thiserror::Error)]
97#[error("closer_nodes length {len} exceeds MAX_CLOSER_NODES ({MAX_CLOSER_NODES})")]
98pub struct CloserNodesError {
99    /// The rejected list length.
100    pub len: usize,
101}
102
103impl CloserNodes {
104    /// Wrap `nodes`, rejecting lists longer than [`MAX_CLOSER_NODES`].
105    ///
106    /// # Errors
107    /// Returns [`CloserNodesError`] when `nodes.len() > MAX_CLOSER_NODES`.
108    pub fn try_new(nodes: Vec<NodeId>) -> Result<Self, CloserNodesError> {
109        if nodes.len() > MAX_CLOSER_NODES {
110            return Err(CloserNodesError { len: nodes.len() });
111        }
112        Ok(Self(nodes))
113    }
114
115    /// Borrow the closer nodes as a slice.
116    #[must_use]
117    pub fn as_slice(&self) -> &[NodeId] {
118        &self.0
119    }
120
121    /// Iterate over the closer nodes.
122    pub fn iter(&self) -> std::slice::Iter<'_, NodeId> {
123        self.0.iter()
124    }
125
126    /// Consume into the inner `Vec<NodeId>`.
127    #[must_use]
128    pub fn into_inner(self) -> Vec<NodeId> {
129        self.0
130    }
131
132    /// Number of closer nodes (always `<= MAX_CLOSER_NODES`).
133    #[must_use]
134    pub const fn len(&self) -> usize {
135        self.0.len()
136    }
137
138    /// True if no closer nodes are present.
139    #[must_use]
140    pub const fn is_empty(&self) -> bool {
141        self.0.is_empty()
142    }
143}
144
145impl<'de> Deserialize<'de> for CloserNodes {
146    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
147    where
148        D: Deserializer<'de>,
149    {
150        // Reuses the shared bounded-vec hook so the decode-time cap and error
151        // message match the other `closer_nodes`-shaped fields exactly.
152        Ok(Self(deserialize_bounded_vec(
153            deserializer,
154            MAX_CLOSER_NODES,
155            "closer_nodes",
156        )?))
157    }
158}
159
160impl<'a> IntoIterator for &'a CloserNodes {
161    type Item = &'a NodeId;
162    type IntoIter = std::slice::Iter<'a, NodeId>;
163    fn into_iter(self) -> Self::IntoIter {
164        self.0.iter()
165    }
166}
167
168/// Top-level protocol enum for `cdn/dht/v1`. Variant order matches ADR 022
169/// §Message Types and is frozen per ADR 013.
170///
171/// ⚠️ **VARIANT ORDER FROZEN — ADR 013 §Protocol Enums**
172/// Postcard encodes each variant as its declaration-order index. Reordering,
173/// inserting, or removing a variant is a wire-breaking change requiring an
174/// ALPN version bump (`cdn/dht/v2`). Discriminants are pinned by the
175/// `dht_message_*_discriminant_is_*` tests in this module — if you change this
176/// enum, those tests will fail and tell you why.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub enum DhtMessage {
179    /// discriminant 0 — asserted by `dht_message_find_value_discriminant_is_zero`
180    FindValue(FindValueRequest),
181    /// discriminant 1 — asserted by `dht_message_find_value_response_discriminant_is_one`
182    FindValueResponse(FindValueResponse),
183    /// discriminant 2 — asserted by `dht_message_store_discriminant_is_two`
184    Store(StoreRequest),
185    /// discriminant 3 — asserted by `dht_message_store_ack_discriminant_is_three`
186    StoreAck(StoreAck),
187    /// discriminant 4 — asserted by `dht_message_batch_store_discriminant_is_four`.
188    /// Batched publish; handled by `decdn-node` (see module docs).
189    BatchStore(BatchStoreRequest),
190    /// discriminant 5 — asserted by `dht_message_batch_store_ack_discriminant_is_five`.
191    /// Per-hash ack for [`Self::BatchStore`].
192    BatchStoreAck(BatchStoreAck),
193    /// discriminant 6 — asserted by `dht_message_find_node_discriminant_is_six`
194    FindNode(FindNodeRequest),
195    /// discriminant 7 — asserted by `dht_message_find_node_response_discriminant_is_seven`
196    FindNodeResponse(FindNodeResponse),
197}
198
199impl crate::framing::TopLevelEnum for DhtMessage {
200    /// `FindValue` (0) … `FindNodeResponse` (7). Pinned by
201    /// `dht_message_variant_count_matches_discriminants`.
202    const VARIANT_COUNT: u32 = 8;
203}
204
205/// Query for providers of a specific content hash (ADR 022 §Message Types).
206///
207/// The responder returns any known providers for `hash` and the K closest
208/// `NodeId`s it knows toward the hash in keyspace.
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210pub struct FindValueRequest {
211    /// BLAKE3 hash of the content being sought (iroh `Hash`, 32 bytes).
212    pub hash: ContentHash,
213    /// The caller's self-declared `NodeId` (32-byte Ed25519 public key).
214    ///
215    /// **The responder MUST NOT use this wire value to update its routing
216    /// table.** It is attacker-controlled and trusting it reintroduces the
217    /// routing-table-poisoning bug class (an authenticated peer seeding
218    /// arbitrary, possibly unreachable ids it does not own). The responder
219    /// refreshes only the *authenticated* QUIC peer id (`conn.remote_id()`);
220    /// this field is carried for protocol symmetry and possible future use
221    /// (which would require a per-request signature first).
222    pub requester: NodeId,
223}
224
225/// A known holder of a content hash, plus which [`Coverage`] blocks it
226/// reports being able to serve (range-keyed discovery). Carried in
227/// [`FindValueResponse::providers`].
228///
229/// `coverage` is unsigned and responder-controlled, like the rest of a DHT
230/// record — see the module docs' authentication-model note. A lying
231/// responder can only ever cost the requester a wasted probe against a
232/// provider that turns out not to hold what it claimed; it cannot forge a
233/// record for a `node` it doesn't control (the STORE-time `holder ==
234/// authenticated NodeId` check still applies).
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236pub struct Provider {
237    /// The holder's `NodeId`.
238    pub node: NodeId,
239    /// Discovery blocks this holder reports covering.
240    pub coverage: Coverage,
241}
242
243/// Response to a [`FindValueRequest`] (ADR 022 §Message Types).
244///
245/// `providers` is the responder's known holders for `hash` (may be empty);
246/// `closer_nodes` is up to K of the responder's nearest `NodeId`s toward `hash`
247/// in XOR keyspace, used by the requester to continue iterative lookup.
248///
249/// Responders MUST NOT emit more than [`MAX_PROVIDERS_PER_HASH`] providers or
250/// more than [`MAX_CLOSER_NODES`] closer nodes — the 12,375-byte worst case in
251/// ADR 022 §DHT Bandwidth Analysis, and so [`MAX_DHT_FRAME`], depend on these
252/// bounds.
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254pub struct FindValueResponse {
255    /// Echoes the requested hash.
256    pub hash: ContentHash,
257    /// Nodes known to hold this hash, with per-holder coverage. May be
258    /// empty. Capped at [`MAX_PROVIDERS_PER_HASH`]; oversize values fail
259    /// decode.
260    #[serde(deserialize_with = "deserialize_providers")]
261    pub providers: Vec<Provider>,
262    /// K closest nodes to `hash` in the responder's routing table. The
263    /// [`CloserNodes`] type enforces the [`MAX_CLOSER_NODES`] cap at
264    /// construction and decode; oversize values fail decode.
265    pub closer_nodes: CloserNodes,
266}
267
268/// Publish a content record: "I hold this hash" (ADR 022 §Message Types,
269/// §STORE Flow). Sent by the holder to each of the K+3 nodes closest to
270/// `hash` in keyspace.
271///
272/// There is no per-record signature: the receiver MUST instead check that
273/// `holder` equals the authenticated `NodeId` of the inbound QUIC connection.
274/// Records are never relayed peer-to-peer; if that changes, a per-record
275/// signature MUST be reintroduced (see module docs).
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct StoreRequest {
278    /// BLAKE3 hash of the content being advertised.
279    pub hash: ContentHash,
280    /// The `NodeId` claiming to hold this hash. MUST equal the authenticated
281    /// QUIC `NodeId` of the connection; the receiver rejects mismatches.
282    pub holder: NodeId,
283    /// Discovery blocks the holder reports covering for `hash`.
284    pub coverage: Coverage,
285}
286
287/// Optional [`FindValueResponse`] extension fields, carried as trailing bytes
288/// after the message via the two-phase pattern (ADR 013 §Tier 1; see
289/// [`encode_find_value_response`] / [`parse_find_value_response_ext`]).
290///
291/// Empty today. It names the append site for the first field a responder needs —
292/// a coverage hint, a record age, a freshness bound — so that field lands as a
293/// pure append rather than a `cdn/dht/v2` bump. Postcard fills no defaults for
294/// absent trailing fields, which is why the field must go in a separately encoded
295/// extension rather than onto [`FindValueResponse`] itself.
296///
297/// Anything added here is UNSIGNED and responder-controlled. DHT records carry
298/// no per-record signature (see the module docs), so an extension field is a
299/// hint the requester may act on only where a lying responder gains nothing.
300#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
301pub struct FindValueResponseExt {}
302
303/// Optional [`StoreRequest`] extension fields, carried as trailing bytes after
304/// the message via the two-phase pattern (ADR 013 §Tier 1; see
305/// [`encode_store_request`] / [`parse_store_request_ext`]).
306///
307/// Empty today, for the same reason as [`FindValueResponseExt`]: the publisher
308/// side is where a record would gain a TTL hint or a partial-holding range, and
309/// that append must stay Tier-1 after launch.
310#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
311pub struct StoreRequestExt {}
312
313/// Encode a [`DhtMessage::FindValueResponse`] with its optional trailing
314/// [`FindValueResponseExt`] (ADR 013 §Tier 1, two-phase).
315///
316/// Typed per variant rather than taking pre-serialized bytes, so an extension
317/// cannot be attached to a message it does not belong to.
318///
319/// # Errors
320///
321/// Propagates a [`postcard::Error`] if serialization fails.
322pub fn encode_find_value_response(
323    resp: &FindValueResponse,
324    ext: Option<&FindValueResponseExt>,
325) -> Result<Vec<u8>, postcard::Error> {
326    let mut buf = postcard::to_allocvec(&DhtMessage::FindValueResponse(resp.clone()))?;
327    if let Some(ext) = ext {
328        buf.extend_from_slice(&postcard::to_allocvec(ext)?);
329    }
330    Ok(buf)
331}
332
333/// Encode a [`DhtMessage::Store`] with its optional trailing [`StoreRequestExt`]
334/// (ADR 013 §Tier 1, two-phase). Typed per variant, as above.
335///
336/// # Errors
337///
338/// Propagates a [`postcard::Error`] if serialization fails.
339pub fn encode_store_request(
340    req: &StoreRequest,
341    ext: Option<&StoreRequestExt>,
342) -> Result<Vec<u8>, postcard::Error> {
343    let mut buf = postcard::to_allocvec(&DhtMessage::Store(req.clone()))?;
344    if let Some(ext) = ext {
345        buf.extend_from_slice(&postcard::to_allocvec(ext)?);
346    }
347    Ok(buf)
348}
349
350/// Parse the trailing [`FindValueResponseExt`] bytes returned as the remainder
351/// by [`crate::decode_message`] after a `DhtMessage::FindValueResponse`.
352///
353/// An empty remainder ⇒ [`FindValueResponseExt::default`]. Trailing bytes beyond
354/// the known fields are tolerated for forward compatibility (ADR 013 §Tier 1).
355///
356/// # Errors
357///
358/// Returns a [`postcard::Error`] if a non-empty remainder is not a valid
359/// `FindValueResponseExt` prefix. While [`FindValueResponseExt`] holds no fields it consumes
360/// no bytes and cannot fail; the signature carries the error so that adding the
361/// first field is a pure append here too.
362pub fn parse_find_value_response_ext(
363    remainder: &[u8],
364) -> Result<FindValueResponseExt, postcard::Error> {
365    if remainder.is_empty() {
366        Ok(FindValueResponseExt::default())
367    } else {
368        Ok(postcard::take_from_bytes::<FindValueResponseExt>(remainder)?.0)
369    }
370}
371
372/// Parse the trailing [`StoreRequestExt`] bytes returned as the remainder by
373/// [`crate::decode_message`] after a `DhtMessage::Store`.
374///
375/// An empty remainder ⇒ [`StoreRequestExt::default`]. Trailing bytes beyond the
376/// known fields are tolerated for forward compatibility (ADR 013 §Tier 1).
377///
378/// # Errors
379///
380/// Returns a [`postcard::Error`] if a non-empty remainder is not a valid
381/// `StoreRequestExt` prefix. While [`StoreRequestExt`] holds no fields it consumes
382/// no bytes and cannot fail; the signature carries the error so that adding the
383/// first field is a pure append here too.
384pub fn parse_store_request_ext(remainder: &[u8]) -> Result<StoreRequestExt, postcard::Error> {
385    if remainder.is_empty() {
386        Ok(StoreRequestExt::default())
387    } else {
388        Ok(postcard::take_from_bytes::<StoreRequestExt>(remainder)?.0)
389    }
390}
391
392/// Acknowledgement for a [`StoreRequest`] (ADR 022 §Message Types).
393///
394/// `accepted == false` means the record was rejected — most commonly the
395/// holder is over per-publisher quota or is not in the receiver's cached
396/// active-staker set (ADR 022 §Content Records and TTL, §STORE Flow).
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
398pub struct StoreAck {
399    /// Echoes the published hash.
400    pub hash: ContentHash,
401    /// True iff the record was admitted to the receiver's record store.
402    pub accepted: bool,
403}
404
405/// Kademlia node lookup — used during routing-table bootstrap and bucket
406/// refresh (ADR 022 §Bootstrap, §Routing Table). Distinct from
407/// [`FindValueRequest`] in that the target is a `NodeId`, not a content hash,
408/// and no provider lookup is performed.
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
410pub struct FindNodeRequest {
411    /// `NodeId` being sought.
412    pub target: NodeId,
413    /// The caller's self-declared `NodeId`.
414    ///
415    /// **The responder MUST NOT use this wire value to update its routing
416    /// table** — it is attacker-controlled, and trusting it reintroduces the
417    /// routing-table-poisoning bug class. The responder refreshes only the
418    /// *authenticated* QUIC peer id (`conn.remote_id()`); this field is carried
419    /// for protocol symmetry and possible future use (which would require a
420    /// per-request signature first).
421    pub requester: NodeId,
422}
423
424/// Response to a [`FindNodeRequest`] (ADR 022 §Message Types).
425///
426/// Responders MUST NOT emit more than [`MAX_CLOSER_NODES`] entries; oversize
427/// values fail decode at the wire boundary.
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
429pub struct FindNodeResponse {
430    /// Echoes the requested target.
431    pub target: NodeId,
432    /// K closest `NodeId`s to `target` from the responder's routing table.
433    /// [`CloserNodes`] enforces the [`MAX_CLOSER_NODES`] cap.
434    pub closer_nodes: CloserNodes,
435}
436
437/// Batched publication of multiple content records from one holder to one
438/// receiver (ADR 022 §STORE Flow). A bandwidth optimization over `n`
439/// separate [`StoreRequest`]s: the re-publish scheduler groups due hashes
440/// by receiver and sends each its set in one of these. Admission and
441/// two-stage rate-limit accounting live in the `decdn-node` handler.
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443pub struct BatchStoreRequest {
444    /// `(hash, coverage)` pairs being published in this batch. Capped at
445    /// [`MAX_BATCH_STORE_HASHES`] (ADR 022 §STORE Flow); oversize values fail
446    /// decode (the handler maps that to `MALFORMED_MESSAGE` 0x03 per
447    /// ADR 013).
448    #[serde(deserialize_with = "deserialize_batch_entries")]
449    pub entries: Vec<(ContentHash, Coverage)>,
450    /// Single holder for the batch. Per ADR 022 §STORE Flow the receiver
451    /// checks this once against the authenticated QUIC `NodeId`; the
452    /// per-hash check is skipped because the field is batch-level.
453    pub holder: NodeId,
454}
455
456/// Per-hash acknowledgement for a [`BatchStoreRequest`] (ADR 022 §STORE
457/// Flow). `results[i]` corresponds to `BatchStoreRequest.entries[i]`.
458#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
459pub struct BatchStoreAck {
460    /// One `bool` per request hash, in request order. Capped at
461    /// [`MAX_BATCH_STORE_HASHES`] (matches the request cap so a malicious
462    /// responder can't reply with an oversize ack).
463    #[serde(deserialize_with = "deserialize_batch_results")]
464    pub results: Vec<bool>,
465}
466
467// --- Bounded-Vec deserialization hooks (see module docs).
468//
469// Pattern follows `ProbeResponseBody::rate_per_mb`'s field-level
470// `deserialize_with` so the postcard positional layout stays
471// derive-driven — no mirror struct, no `Deserialize` impl drift risk.
472
473fn deserialize_providers<'de, D>(d: D) -> Result<Vec<Provider>, D::Error>
474where
475    D: Deserializer<'de>,
476{
477    deserialize_bounded_vec(d, MAX_PROVIDERS_PER_HASH, "providers")
478}
479
480fn deserialize_batch_entries<'de, D>(d: D) -> Result<Vec<(ContentHash, Coverage)>, D::Error>
481where
482    D: Deserializer<'de>,
483{
484    deserialize_bounded_vec(d, MAX_BATCH_STORE_HASHES, "entries")
485}
486
487fn deserialize_batch_results<'de, D>(d: D) -> Result<Vec<bool>, D::Error>
488where
489    D: Deserializer<'de>,
490{
491    let v = Vec::<bool>::deserialize(d)?;
492    if v.len() > MAX_BATCH_STORE_HASHES {
493        return Err(de::Error::custom(format!(
494            "BatchStoreAck.results length {} exceeds MAX_BATCH_STORE_HASHES ({})",
495            v.len(),
496            MAX_BATCH_STORE_HASHES,
497        )));
498    }
499    Ok(v)
500}
501
502fn deserialize_bounded_vec<'de, D, T>(
503    d: D,
504    max: usize,
505    field: &'static str,
506) -> Result<Vec<T>, D::Error>
507where
508    D: Deserializer<'de>,
509    T: Deserialize<'de>,
510{
511    // `T` is either `#[serde(transparent)]` over `[u8; 32]` (a bare `NodeId`
512    // or `ContentHash`) or a small fixed-shape struct built from such types
513    // (`Provider`, `(ContentHash, Coverage)`), so the decoded length and
514    // per-element bytes are identical to the raw wire form; the cap is the
515    // only thing this hook adds.
516    //
517    // Allocation safety (#845): the cap is enforced *after* `Vec::deserialize`,
518    // so a malicious peer's length prefix is untrusted at the point of decode.
519    // This is sound because the up-front allocation is bounded independently of
520    // that prefix — serde's `Vec` impl uses `size_hint::cautious`, which caps
521    // `with_capacity` to a small byte budget, and postcard streams elements
522    // (a giant length prefix with a truncated body errors at EOF long before
523    // the cap check). See `bounded_vec_giant_length_prefix_truncated_body_*`.
524    let v = Vec::<T>::deserialize(d)?;
525    if v.len() > max {
526        return Err(de::Error::custom(format!(
527            "{field} length {} exceeds wire cap ({max})",
528            v.len(),
529        )));
530    }
531    Ok(v)
532}
533
534#[cfg(test)]
535#[allow(
536    clippy::unwrap_used,
537    clippy::expect_used,
538    clippy::panic,
539    clippy::indexing_slicing
540)]
541mod tests;