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}