Skip to main content

dig_download/
onion.rs

1//! Onion mode — carrying a transfer back through the same layered hops that carried the ask (#30).
2//!
3//! In **direct mode** the requestor learns a holder's dial address and fetches from it itself. In
4//! **onion mode** the bytes travel the other way: back up the hop path, each hop handing them to its
5//! predecessor, so the requestor never dials the holder and the holder never sees the requestor.
6//!
7//! This module owns exactly two things, and deliberately not a third:
8//!
9//! 1. **[`decide_relay_stream`] — the admission decision a hop makes about carrying BYTES.** Relaying
10//!    a question and relaying a transfer are different costs, so they are different decisions.
11//! 2. **[`OnionRangeTransport`] — the seam that plugs a hop-carried transfer into this crate's
12//!    existing verified-assembly engine**, unchanged, so onion-delivered bytes face exactly the same
13//!    per-range and whole-resource checks as directly-fetched ones.
14//!
15//! It does **not** implement onion cryptography, circuit construction, cells, or relay selection.
16//! Those belong to `dig-onion` and are reached through the [`OnionChannel`] seam
17//! ([see below](#why-a-seam-instead-of-a-dependency)).
18//!
19//! # How this composes with NC-1 / §5.4 (the question #30 says to settle first)
20//!
21//! NC-1 requires a **directed message** to be end-to-end sealed to its recipient, so an intermediary
22//! that terminates transport sees ciphertext only. Streaming content *through* intermediaries does not
23//! weaken that, because the two things an intermediary could learn are separately sealed:
24//!
25//! - **The request and response payloads** are onion-layered: each hop can peel exactly its own
26//!   layer, which tells it where to pass the cell next and nothing about the payload beneath. The
27//!   innermost layer is sealed to the exit, and the exit is the only hop that learns *which content*
28//!   is being fetched (`dig-onion` SPEC §6.2 calls this the disclosure radius — it is a property of
29//!   onion routing, not a gap in it).
30//! - **The content bytes** are `.dig` capsule ciphertext independently of any transport. A relay that
31//!   peeled every onion layer it is entitled to peel still holds store ciphertext it has no
32//!   retrieval key for.
33//!
34//! So the answer is that onion mode **satisfies** NC-1 by construction rather than trading against
35//! it: no hop is a recipient, and no hop holds plaintext. What must not be inferred from that is
36//! *trust*: an intermediary cannot READ the bytes, and it also cannot be prevented from CORRUPTING,
37//! withholding, or truncating them. That is why every byte arriving through a hop enters the ordinary
38//! verification path (NC-12) — accepted because it verifies against the chain-anchored merkle root,
39//! never because of who relayed it. A hostile hop can deny a transfer; it cannot forge one.
40//!
41//! Two properties the composition does **not** give, stated so nobody assumes them: onion mode hides
42//! the requestor from the holder, not the *fact of a transfer* from an on-path observer (padding is
43//! `dig-onion`'s concern), and it makes no safety claim about the content — verified content is not
44//! safe content.
45//!
46//! # What bounds the bandwidth a relay spends on someone else's transfer
47//!
48//! `dig-sex`'s ask policy bounds a forwarded *question*: a hop budget carried in the request, a
49//! fan-out, a separate relay allowance, off by default, refusing rather than forwarding when the
50//! budget cannot be read. Reusing that budget for a stream would be wrong by orders of magnitude — a
51//! forwarded ask costs a hop a few hundred bytes, and a forwarded `.dig` transfer costs it the whole
52//! capsule, twice (in and out). So a stream draws on a **byte-denominated allowance of its own**
53//! ([`StreamRelayConfig`]), and:
54//!
55//! - **A hop may relay asks while refusing to relay streams** ([`StreamRelayConfig::relays_asks_only`]),
56//!   which is the honest configuration for a node with cheap CPU and metered bandwidth. The refusal
57//!   is its own named reason ([`StreamRelayRefusal::AsksOnly`]) so it can never be reported as, or
58//!   mistaken for, "nobody holds this content".
59//! - **A transfer whose declared length cannot be read is refused, not carried optimistically.** An
60//!   unreadable length is an unbounded byte cost in the same way an unreadable hop budget is an
61//!   unbounded reach, and `dig-sex` already settled that class: refuse.
62//! - **A transfer that does not fit is refused whole, never silently truncated.** A truncated relay
63//!   looks to the requestor exactly like a mid-stream disconnect, so it would spend the requestor's
64//!   retry budget to discover a limit the relay already knew. The requestor can then ask for smaller
65//!   ranges — this engine is range-based, so a smaller window is always available.
66//! - **Off by default.** Enabling relay-on-behalf-of-others is an operator decision, and this module
67//!   takes `enabled` as a value rather than parsing it, so a node that already parses the recursion
68//!   switch fail-closed (`dig_sex::discovery::parse_enabled`) has exactly one such parser.
69//!
70//! # Why a seam instead of a dependency {#why-a-seam-instead-of-a-dependency}
71//!
72//! `dig-onion` owns the circuits, cells, ntor handshake and privacy-aware path selection, and this
73//! module must not grow a second copy of any of them. It cannot simply depend on it either: both
74//! crates sit at **level 30**, and the crate hierarchy forbids a same-level edge. So the layered
75//! transport arrives as an injected [`OnionChannel`], implemented above both crates (dig-node) over
76//! `dig_onion::Circuit`. The same seam keeps this crate testable over an in-memory hop path with no
77//! network, exactly like every other boundary here.
78
79use std::sync::Arc;
80
81use async_trait::async_trait;
82use dig_dht::ProviderRecord;
83use dig_nat::{AvailabilityItem, AvailabilityResponse, RangeRequest};
84
85use crate::error::DownloadError;
86use crate::source::{FetchedRange, RangeTransport};
87
88/// The longest hop path this crate will carry a transfer over.
89///
90/// A bound is needed because path length multiplies the bandwidth every relay spends: a transfer over
91/// `n` hops costs the network `n` times the content. The value is deliberately generous relative to
92/// `dig-onion`'s 3-hop default — this is a refusal ceiling, not a recommendation.
93pub const MAX_HOP_PATH: usize = 8;
94
95/// Why a hop will not carry a transfer, or why a requestor will not start one.
96///
97/// Every variant is distinguishable from "the content was not found", because conflating a refusal to
98/// carry with an absence of content teaches a requestor that content does not exist when in truth
99/// nobody would relay it.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
101pub enum StreamRelayRefusal {
102    /// Onion mode is switched off on this node — as originator and as relay alike.
103    #[error("onion mode is disabled on this node")]
104    Disabled,
105    /// This node relays asks but not streams: a deliberate, legal configuration.
106    #[error("this node relays asks but not streams")]
107    AsksOnly,
108    /// The hop budget could not be read from the request. Refused rather than carried optimistically:
109    /// a transfer whose remaining path is unknown is a transfer whose cost is unknown.
110    #[error("the stream's hop budget could not be read")]
111    UnreadableHopBudget,
112    /// The hop budget is exhausted — this node is the end of the permitted path.
113    #[error("the stream's hop budget is exhausted")]
114    HopBudgetSpent,
115    /// The transfer declared no length, or one that could not be read. An unbounded byte cost is
116    /// refused for the same reason an unbounded reach is.
117    #[error("the stream declared no readable length")]
118    UnreadableLength,
119    /// The transfer is larger than this node will carry for anyone, however much allowance remains.
120    #[error("the stream is larger than this node will relay ({declared} > {ceiling} bytes)")]
121    StreamTooLarge {
122        /// The length the transfer declared.
123        declared: u64,
124        /// This node's per-stream ceiling.
125        ceiling: u64,
126    },
127    /// This node's allowance for bytes carried on others' behalf is spent for now.
128    #[error("the relay byte allowance is spent ({declared} needed, {available} left)")]
129    RelayByteBudgetSpent {
130        /// The length the transfer declared.
131        declared: u64,
132        /// The allowance left in the current window.
133        available: u64,
134    },
135}
136
137/// A hop's decision about an inbound transfer.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum StreamRelayDecision {
140    /// Carry the transfer, decrementing the hop budget and holding it to `byte_ceiling` bytes.
141    Carry {
142        /// The budget to carry onward, already decremented.
143        hops_remaining: u8,
144        /// The exact number of bytes admitted. A transfer exceeding it is a protocol violation by the
145        /// peer that declared a smaller length, not a limit to discover by truncation.
146        byte_ceiling: u64,
147    },
148    /// Do not carry it, for this reason.
149    Refuse(StreamRelayRefusal),
150}
151
152/// An inbound transfer as a hop received it: what it declared about itself, nothing more.
153///
154/// Both fields are `Option` on purpose. They come off an untrusted wire, and "the field was
155/// unreadable" is a different fact from any particular value — [`decide_relay_stream`] refuses on
156/// either being absent rather than substituting a default, because every plausible default is either
157/// unbounded or a silent policy the operator never chose.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub struct InboundStream {
160    /// Hops the transfer may still travel, as carried IN the request. `None` means unreadable.
161    pub hops_remaining: Option<u8>,
162    /// The transfer's declared total length in bytes. `None` means unreadable.
163    pub declared_len: Option<u64>,
164}
165
166/// A node's policy for carrying transfers — its own and other people's.
167///
168/// Constructed with `StreamRelayConfig { enabled: true, ..Default::default() }`; the default is a
169/// node that carries nothing.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub struct StreamRelayConfig {
172    /// Whether this node participates in onion mode at all, as originator or relay. **Off by
173    /// default.** Parse the switch with `dig_sex::discovery::parse_enabled` (fail-closed) rather than
174    /// adding a second parser.
175    pub enabled: bool,
176    /// Relay asks but refuse streams. The honest setting for a node with cheap CPU and expensive
177    /// bandwidth: it stays useful to discovery without underwriting other people's transfers.
178    pub relays_asks_only: bool,
179    /// The largest single transfer this node will carry for someone else.
180    pub max_bytes_per_stream: u64,
181}
182
183/// 16 MiB — the default per-stream ceiling, which is one range window rather than one capsule.
184///
185/// A relay should be able to help without underwriting an arbitrarily large `.dig`, and a requestor
186/// that needs more can ask for more windows: refusing per stream costs a requestor one extra request,
187/// while admitting per capsule costs a relay the whole capsule.
188pub const DEFAULT_MAX_BYTES_PER_STREAM: u64 = 16 * 1024 * 1024;
189
190/// 256 MiB — the suggested size of a relay's per-window byte allowance.
191///
192/// Deliberately NOT a [`StreamRelayConfig`] field: this crate holds no clock, so it can neither open,
193/// close nor refill a window, and a configured window it could never enforce would be a knob that
194/// misstates a bound. A caller that owns the clock initialises its own counter from this value and
195/// passes what remains of it to [`decide_relay_stream`] as `relay_bytes_available`.
196pub const DEFAULT_RELAY_BYTES_PER_WINDOW: u64 = 256 * 1024 * 1024;
197
198impl Default for StreamRelayConfig {
199    /// A node that carries nothing for anyone else: onion mode off, and if switched on, OTHER
200    /// peers' streams refused until the operator says otherwise. `relays_asks_only` withholds
201    /// relaying only; the node's own originated transfers are gated by `enabled` alone.
202    fn default() -> Self {
203        StreamRelayConfig {
204            enabled: false,
205            relays_asks_only: true,
206            max_bytes_per_stream: DEFAULT_MAX_BYTES_PER_STREAM,
207        }
208    }
209}
210
211/// Decide whether this hop carries an inbound transfer.
212///
213/// One bound is enforced here — [`StreamRelayConfig::max_bytes_per_stream`], the per-stream ceiling.
214/// `relay_bytes_available` is the second bound and it is the CALLER's: what remains of this node's
215/// per-window allowance (see [`DEFAULT_RELAY_BYTES_PER_WINDOW`]), supplied per call because the caller
216/// owns the clock, the window and its refill. This module only compares against it. It is a separate allowance from anything this node spends on
217/// its OWN transfers, for the reason `dig-sex` records for asks: billing relayed work to the victim's
218/// own budget lets one admitted request spend a stranger's allowance.
219///
220/// The order of the checks is part of the contract. Cheap, unconditional refusals come first, so a
221/// disabled node never reveals anything about its allowances by the shape of its refusal.
222#[must_use]
223pub fn decide_relay_stream(
224    config: &StreamRelayConfig,
225    inbound: &InboundStream,
226    relay_bytes_available: u64,
227) -> StreamRelayDecision {
228    if !config.enabled {
229        return StreamRelayDecision::Refuse(StreamRelayRefusal::Disabled);
230    }
231    if config.relays_asks_only {
232        return StreamRelayDecision::Refuse(StreamRelayRefusal::AsksOnly);
233    }
234    let Some(hops_remaining) = inbound.hops_remaining else {
235        return StreamRelayDecision::Refuse(StreamRelayRefusal::UnreadableHopBudget);
236    };
237    if hops_remaining == 0 {
238        return StreamRelayDecision::Refuse(StreamRelayRefusal::HopBudgetSpent);
239    }
240    let Some(declared) = inbound.declared_len else {
241        return StreamRelayDecision::Refuse(StreamRelayRefusal::UnreadableLength);
242    };
243    if declared > config.max_bytes_per_stream {
244        return StreamRelayDecision::Refuse(StreamRelayRefusal::StreamTooLarge {
245            declared,
246            ceiling: config.max_bytes_per_stream,
247        });
248    }
249    if declared > relay_bytes_available {
250        return StreamRelayDecision::Refuse(StreamRelayRefusal::RelayByteBudgetSpent {
251            declared,
252            available: relay_bytes_available,
253        });
254    }
255    StreamRelayDecision::Carry {
256        hops_remaining: hops_remaining - 1,
257        byte_ceiling: declared,
258    }
259}
260
261/// Why a hop path is not usable.
262#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
263pub enum HopPathError {
264    /// An empty path. Onion mode with no hops is direct mode wearing onion mode's name — the
265    /// requestor would dial the holder itself while believing it had not, which is worse than a
266    /// refusal because the privacy loss is silent.
267    #[error("an onion hop path must contain at least one hop")]
268    Empty,
269    /// The same peer appears more than once. One peer occupying two positions is one hop presenting
270    /// itself as two, which inflates the apparent path length while learning both ends of it.
271    #[error("hop {0} appears more than once in the path")]
272    DuplicateHop(String),
273    /// Longer than [`MAX_HOP_PATH`].
274    #[error("an onion hop path may not exceed {MAX_HOP_PATH} hops (got {0})")]
275    TooLong(usize),
276}
277
278impl From<HopPathError> for DownloadError {
279    fn from(e: HopPathError) -> Self {
280        DownloadError::state(e)
281    }
282}
283
284/// An ordered, validated onion hop path: entry hop first, exit hop last.
285///
286/// Validation is in the constructor so an invalid path cannot exist. The peers are `peer_id` strings
287/// (64-hex), matching the identity every other seam in this crate names a peer by.
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct HopPath {
290    hops: Vec<String>,
291}
292
293impl HopPath {
294    /// Validate an ordered hop list into a path.
295    ///
296    /// # Errors
297    /// [`HopPathError`] when the path is empty, longer than [`MAX_HOP_PATH`], or names a peer twice.
298    pub fn try_new(hops: Vec<String>) -> Result<Self, HopPathError> {
299        if hops.is_empty() {
300            return Err(HopPathError::Empty);
301        }
302        if hops.len() > MAX_HOP_PATH {
303            return Err(HopPathError::TooLong(hops.len()));
304        }
305        for (index, hop) in hops.iter().enumerate() {
306            if hops[..index].contains(hop) {
307                return Err(HopPathError::DuplicateHop(hop.clone()));
308            }
309        }
310        Ok(HopPath { hops })
311    }
312
313    /// The hops in order, entry first.
314    #[must_use]
315    pub fn hops(&self) -> &[String] {
316        &self.hops
317    }
318
319    /// How many hops the transfer travels — and therefore the multiple of the content size the
320    /// network as a whole pays for it.
321    #[must_use]
322    pub fn len(&self) -> usize {
323        self.hops.len()
324    }
325
326    /// Whether the path is empty. Always `false`: [`try_new`](Self::try_new) refuses an empty path.
327    /// Present because clippy requires it beside [`len`](Self::len).
328    #[must_use]
329    pub fn is_empty(&self) -> bool {
330        false
331    }
332}
333
334/// The layered transport an onion transfer rides — the seam over `dig-onion`'s circuits.
335///
336/// An implementation carries the request up the path and the answer back down it, peeling and wrapping
337/// one layer per hop. It owes the caller **nothing about the bytes' truthfulness**: the answer is
338/// verified by this crate's ordinary integrity path, so an implementation must never filter, repair,
339/// or vouch for what a hop returned.
340#[async_trait]
341pub trait OnionChannel: Send + Sync {
342    /// Carry a `dig.getAvailability` ask to `provider` along `path` and bring the answer back.
343    ///
344    /// # Errors
345    /// A recoverable [`DownloadError::Transport`] when a hop drops, refuses, or times out — the
346    /// caller treats it exactly as it treats a direct transport failure.
347    async fn ask_availability_through(
348        &self,
349        path: &HopPath,
350        provider: &ProviderRecord,
351        items: Vec<AvailabilityItem>,
352    ) -> Result<AvailabilityResponse, DownloadError>;
353
354    /// Carry a `dig.fetchRange` request to `provider` along `path` and stream the range back down it.
355    ///
356    /// # Errors
357    /// A recoverable [`DownloadError::Transport`] when a hop drops the transfer mid-stream. A partial
358    /// transfer is a failure here, never a short success: the requestor's resume machinery re-requests
359    /// the missing window, and it can only do that if the failure is reported as one.
360    async fn fetch_range_through(
361        &self,
362        path: &HopPath,
363        provider: &ProviderRecord,
364        req: &RangeRequest,
365    ) -> Result<FetchedRange, DownloadError>;
366}
367
368/// A [`RangeTransport`] that carries every request through a fixed onion [`HopPath`].
369///
370/// This is the whole of onion mode from the download engine's point of view. Swapping it in changes
371/// how bytes arrive and nothing about how they are trusted: the orchestrator verifies each range
372/// against the resource commitment and the whole assembly against the chain-anchored root exactly as
373/// it does for a direct fetch, so a hostile hop can cost a transfer a retry and never a false success.
374pub struct OnionRangeTransport {
375    channel: Arc<dyn OnionChannel>,
376    path: HopPath,
377    config: StreamRelayConfig,
378}
379
380impl OnionRangeTransport {
381    /// Build the transport over an injected channel, hop path, and policy.
382    #[must_use]
383    pub fn new(channel: Arc<dyn OnionChannel>, path: HopPath, config: StreamRelayConfig) -> Self {
384        OnionRangeTransport {
385            channel,
386            path,
387            config,
388        }
389    }
390
391    /// The path every request on this transport travels.
392    #[must_use]
393    pub fn path(&self) -> &HopPath {
394        &self.path
395    }
396
397    /// Refuse to originate anything while onion mode is off, so a misconfigured node fails closed
398    /// rather than quietly falling back to a direct dial that would expose the requestor it was
399    /// chosen to hide.
400    fn require_enabled(&self) -> Result<(), DownloadError> {
401        if self.config.enabled {
402            Ok(())
403        } else {
404            Err(DownloadError::state(StreamRelayRefusal::Disabled))
405        }
406    }
407}
408
409#[async_trait]
410impl RangeTransport for OnionRangeTransport {
411    async fn query_availability(
412        &self,
413        provider: &ProviderRecord,
414        items: Vec<AvailabilityItem>,
415    ) -> Result<AvailabilityResponse, DownloadError> {
416        self.require_enabled()?;
417        self.channel
418            .ask_availability_through(&self.path, provider, items)
419            .await
420    }
421
422    async fn fetch_range(
423        &self,
424        provider: &ProviderRecord,
425        req: &RangeRequest,
426    ) -> Result<FetchedRange, DownloadError> {
427        self.require_enabled()?;
428        // Hold an ORIGINATED request to the same per-stream ceiling a hop would apply to it. The
429        // asymmetry is where amplification lives: a requestor free to ask for a window every hop on
430        // the path is bound to refuse spends the network `n` transfers to deliver nothing, and the
431        // requestor is the one node that could have known in advance.
432        if req.length > self.config.max_bytes_per_stream {
433            return Err(DownloadError::state(StreamRelayRefusal::StreamTooLarge {
434                declared: req.length,
435                ceiling: self.config.max_bytes_per_stream,
436            }));
437        }
438        self.channel
439            .fetch_range_through(&self.path, provider, req)
440            .await
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    fn relaying() -> StreamRelayConfig {
449        StreamRelayConfig {
450            enabled: true,
451            relays_asks_only: false,
452            ..Default::default()
453        }
454    }
455
456    fn inbound(hops: u8, len: u64) -> InboundStream {
457        InboundStream {
458            hops_remaining: Some(hops),
459            declared_len: Some(len),
460        }
461    }
462
463    /// The public config surface offers NO knob that claims a per-window byte bound.
464    ///
465    /// This crate holds no clock, so a configured window would be a value nothing here could open,
466    /// close, refill or enforce across calls — exactly the shape of a field that misstates a bound.
467    /// The window is the caller's, supplied per call as `relay_bytes_available`.
468    ///
469    /// The destructuring pattern is the assertion, and it is deliberately exhaustive (no `..`): it
470    /// stops compiling the moment a field is ADDED to `StreamRelayConfig`, so re-introducing a
471    /// window knob — or any other unenforced knob — trips this test rather than passing silently.
472    /// An `assert!(true)`-shaped runtime check could not do that, because a dead field has no
473    /// runtime effect to observe.
474    #[test]
475    fn the_config_surface_declares_no_window_bound_it_cannot_enforce() {
476        let StreamRelayConfig {
477            enabled,
478            relays_asks_only,
479            max_bytes_per_stream,
480        } = StreamRelayConfig::default();
481        assert!(!enabled);
482        assert!(relays_asks_only);
483        assert_eq!(max_bytes_per_stream, DEFAULT_MAX_BYTES_PER_STREAM);
484
485        // The window's default survives as a free const for a caller to initialise its OWN counter
486        // from, which is the only place it can honestly live.
487        let mut caller_owned_window = DEFAULT_RELAY_BYTES_PER_WINDOW;
488        caller_owned_window -= 1024;
489        assert_eq!(
490            decide_relay_stream(&relaying(), &inbound(2, 1024), caller_owned_window),
491            StreamRelayDecision::Carry {
492                hops_remaining: 1,
493                byte_ceiling: 1024
494            },
495            "the bound that applies is the one the caller passed, never a configured field"
496        );
497    }
498
499    #[test]
500    fn a_node_carries_nothing_by_default() {
501        let config = StreamRelayConfig::default();
502        assert!(
503            !config.enabled,
504            "onion mode is off until an operator says so"
505        );
506        assert!(
507            config.relays_asks_only,
508            "and even switched on, streams are refused until an operator opts in"
509        );
510        assert_eq!(
511            decide_relay_stream(&config, &inbound(2, 1024), u64::MAX),
512            StreamRelayDecision::Refuse(StreamRelayRefusal::Disabled)
513        );
514    }
515
516    #[test]
517    fn a_hop_may_relay_asks_while_refusing_streams() {
518        // The distinct configuration #30 asks for: useful to discovery, not underwriting transfers.
519        let asks_only = StreamRelayConfig {
520            enabled: true,
521            relays_asks_only: true,
522            ..Default::default()
523        };
524        assert_eq!(
525            decide_relay_stream(&asks_only, &inbound(2, 1024), u64::MAX),
526            StreamRelayDecision::Refuse(StreamRelayRefusal::AsksOnly),
527            "refusing to carry bytes is its own answer, distinguishable from being switched off"
528        );
529        // The SAME node, same budget, carries the stream once the operator opts in — so the refusal
530        // above is attributable to this switch and not to some other bound in the fixture.
531        assert_eq!(
532            decide_relay_stream(&relaying(), &inbound(2, 1024), u64::MAX),
533            StreamRelayDecision::Carry {
534                hops_remaining: 1,
535                byte_ceiling: 1024
536            }
537        );
538    }
539
540    #[test]
541    fn an_unreadable_declared_length_is_refused_not_carried_optimistically() {
542        // The bound that matters most: a length nobody can read is an unbounded byte cost. The
543        // fixture keeps EVERY other input admissible — enabled, streams allowed, hops left, an
544        // unlimited allowance — so only the unreadable length can produce a refusal.
545        let unreadable = InboundStream {
546            hops_remaining: Some(2),
547            declared_len: None,
548        };
549        assert_eq!(
550            decide_relay_stream(&relaying(), &unreadable, u64::MAX),
551            StreamRelayDecision::Refuse(StreamRelayRefusal::UnreadableLength)
552        );
553    }
554
555    #[test]
556    fn an_unreadable_hop_budget_is_refused() {
557        let unreadable = InboundStream {
558            hops_remaining: None,
559            declared_len: Some(1024),
560        };
561        assert_eq!(
562            decide_relay_stream(&relaying(), &unreadable, u64::MAX),
563            StreamRelayDecision::Refuse(StreamRelayRefusal::UnreadableHopBudget)
564        );
565    }
566
567    #[test]
568    fn an_exhausted_hop_budget_ends_the_path_here() {
569        assert_eq!(
570            decide_relay_stream(&relaying(), &inbound(0, 1024), u64::MAX),
571            StreamRelayDecision::Refuse(StreamRelayRefusal::HopBudgetSpent)
572        );
573    }
574
575    #[test]
576    fn the_per_stream_ceiling_is_pinned_from_both_sides() {
577        // A bound tested only from below can only confirm itself. At the ceiling it must carry; one
578        // byte over it must refuse.
579        let config = StreamRelayConfig {
580            max_bytes_per_stream: 1_000,
581            ..relaying()
582        };
583        assert_eq!(
584            decide_relay_stream(&config, &inbound(2, 1_000), u64::MAX),
585            StreamRelayDecision::Carry {
586                hops_remaining: 1,
587                byte_ceiling: 1_000
588            },
589            "at the ceiling exactly, the transfer is admitted"
590        );
591        assert_eq!(
592            decide_relay_stream(&config, &inbound(2, 1_001), u64::MAX),
593            StreamRelayDecision::Refuse(StreamRelayRefusal::StreamTooLarge {
594                declared: 1_001,
595                ceiling: 1_000
596            }),
597            "one byte over it, refused whole rather than truncated"
598        );
599    }
600
601    #[test]
602    fn the_window_allowance_is_pinned_from_both_sides() {
603        let config = StreamRelayConfig {
604            max_bytes_per_stream: 10_000,
605            ..relaying()
606        };
607        assert_eq!(
608            decide_relay_stream(&config, &inbound(2, 500), 500),
609            StreamRelayDecision::Carry {
610                hops_remaining: 1,
611                byte_ceiling: 500
612            },
613            "a transfer that exactly exhausts the remaining allowance still fits in it"
614        );
615        assert_eq!(
616            decide_relay_stream(&config, &inbound(2, 501), 500),
617            StreamRelayDecision::Refuse(StreamRelayRefusal::RelayByteBudgetSpent {
618                declared: 501,
619                available: 500
620            }),
621            "one byte past it is refused — and named as an allowance, not as a size limit"
622        );
623    }
624
625    #[test]
626    fn a_refusal_is_never_an_absence_of_content() {
627        // Every refusal carries its own reason. A caller that collapsed them into "not found" would
628        // teach a requestor that content does not exist when in truth nobody would carry it.
629        let reasons = [
630            decide_relay_stream(&StreamRelayConfig::default(), &inbound(2, 1), 0),
631            decide_relay_stream(&relaying(), &inbound(0, 1), 0),
632            decide_relay_stream(
633                &relaying(),
634                &InboundStream {
635                    hops_remaining: Some(2),
636                    declared_len: None,
637                },
638                0,
639            ),
640        ];
641        for reason in reasons {
642            let StreamRelayDecision::Refuse(refusal) = reason else {
643                panic!("expected a refusal, got {reason:?}");
644            };
645            assert!(
646                !refusal.to_string().is_empty(),
647                "a refusal states why this node would not carry the transfer"
648            );
649        }
650    }
651
652    #[test]
653    fn an_empty_hop_path_is_refused_because_the_privacy_loss_would_be_silent() {
654        assert_eq!(HopPath::try_new(Vec::new()), Err(HopPathError::Empty));
655    }
656
657    #[test]
658    fn a_peer_may_not_occupy_two_positions_on_one_path() {
659        // A duplicate hop is one peer presenting itself as two: the path looks longer than it is
660        // while that peer sees both of its own positions.
661        let repeated = HopPath::try_new(vec!["a".into(), "b".into(), "a".into()]);
662        assert_eq!(repeated, Err(HopPathError::DuplicateHop("a".into())));
663        // A distinct path of the same length is accepted, so the rejection above is attributable to
664        // the duplicate and not to the length.
665        let distinct = HopPath::try_new(vec!["a".into(), "b".into(), "c".into()])
666            .expect("three distinct hops are a valid path");
667        assert_eq!(distinct.len(), 3);
668        assert_eq!(distinct.hops(), ["a", "b", "c"]);
669    }
670
671    #[test]
672    fn the_hop_path_length_bound_is_pinned_from_both_sides() {
673        let at_bound: Vec<String> = (0..MAX_HOP_PATH).map(|i| i.to_string()).collect();
674        assert!(HopPath::try_new(at_bound).is_ok(), "MAX_HOP_PATH hops fit");
675        let over: Vec<String> = (0..=MAX_HOP_PATH).map(|i| i.to_string()).collect();
676        assert_eq!(
677            HopPath::try_new(over),
678            Err(HopPathError::TooLong(MAX_HOP_PATH + 1))
679        );
680    }
681}