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