Skip to main content

openrtc_file_transfer/
lib.rs

1//! Optional file-transfer protocol over application-provided protected routes.
2//!
3//! OpenRTC owns peer admission and the stream. This crate owns only reusable
4//! framing, resumable route frames, and bounded file IO. Consumer applications retain acceptance,
5//! destination, queue, history, billing, and notification policy.
6
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::BTreeMap;
10use std::path::{Path, PathBuf};
11use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWrite, AsyncWriteExt};
12
13pub mod capabilities;
14pub mod flow_control;
15pub use capabilities::{
16    RequiredTransferCapabilities, TransferCapabilities, MAX_CAPABILITY_BYTES, MAX_SAFE_FILE_BYTES,
17    REQUIRED_CAPABILITIES_METADATA_KEY,
18};
19
20const ACK_OK: u8 = 0x01;
21const ACK_REJECTED: u8 = 0x02;
22pub const DEFAULT_CHUNK_BYTES: usize = 64 * 1024;
23pub const DEFAULT_MAX_HEADER_BYTES: usize = 64 * 1024;
24pub const DEFAULT_MAX_FILE_BYTES: u64 = 256 * 1024 * 1024;
25pub const ROUTED_FRAME_MAGIC: &[u8; 4] = b"OFT2";
26pub const DEFAULT_ROUTED_CHUNK_BYTES: usize = 48 * 1024;
27pub const MAX_ROUTED_TRANSFER_ID_BYTES: usize = 512;
28
29const ROUTED_KIND_OFFER: u8 = 0x01;
30const ROUTED_KIND_RESUME: u8 = 0x02;
31const ROUTED_KIND_CHUNK: u8 = 0x03;
32const ROUTED_KIND_ACK: u8 = 0x04;
33const ROUTED_KIND_COMPLETE: u8 = 0x05;
34const ROUTED_KIND_COMPLETED: u8 = 0x06;
35const ROUTED_KIND_REJECT: u8 = 0x07;
36const ROUTED_KIND_WAITING: u8 = 0x08;
37const ROUTED_KIND_CAPABILITIES_REQUEST: u8 = 0x09;
38const ROUTED_KIND_CAPABILITIES: u8 = 0x0a;
39pub const RECEIVER_WAIT_METADATA_KEY: &str = "openrtc.receive-wait";
40pub const MAX_RECEIVER_WAIT_LEASE_MS: u32 = 60_000;
41
42/// One bounded file-transfer message sent through OpenRTC's current-route
43/// selector. A transfer is a sequence of these frames, not one physical
44/// stream, so route promotion or replacement does not discard confirmed work.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum RoutedTransferFrame {
47    Offer(FileTransferHeader),
48    /// A bounded immutable-header probe; never accepts a body or allocates a sink.
49    CapabilitiesRequest {
50        transfer_id: String,
51        header: Option<FileTransferHeader>,
52    },
53    Capabilities {
54        transfer_id: String,
55        capabilities: TransferCapabilities,
56    },
57    Resume {
58        transfer_id: String,
59        offset: u64,
60    },
61    Chunk {
62        transfer_id: String,
63        offset: u64,
64        ack_requested: bool,
65        bytes: Vec<u8>,
66    },
67    Ack {
68        transfer_id: String,
69        offset: u64,
70    },
71    /// A negotiated live policy/processing wait; never an offset ACK or receipt.
72    Waiting {
73        transfer_id: String,
74        offset: u64,
75        lease_ms: u32,
76    },
77    Complete {
78        transfer_id: String,
79        size: u64,
80    },
81    Completed {
82        transfer_id: String,
83        size: u64,
84    },
85    Reject {
86        transfer_id: String,
87        reason: String,
88    },
89}
90
91impl RoutedTransferFrame {
92    pub fn transfer_id(&self) -> &str {
93        match self {
94            Self::Offer(header) => &header.transfer_id,
95            Self::Resume { transfer_id, .. }
96            | Self::Chunk { transfer_id, .. }
97            | Self::Ack { transfer_id, .. }
98            | Self::Waiting { transfer_id, .. }
99            | Self::Complete { transfer_id, .. }
100            | Self::Completed { transfer_id, .. }
101            | Self::Reject { transfer_id, .. }
102            | Self::CapabilitiesRequest { transfer_id, .. }
103            | Self::Capabilities { transfer_id, .. } => transfer_id,
104        }
105    }
106
107    pub fn encode(&self, limits: TransferLimits) -> Result<Vec<u8>, TransferError> {
108        let (kind, transfer_id) = match self {
109            Self::Offer(header) => (ROUTED_KIND_OFFER, header.transfer_id.as_str()),
110            Self::Resume { transfer_id, .. } => (ROUTED_KIND_RESUME, transfer_id.as_str()),
111            Self::Chunk { transfer_id, .. } => (ROUTED_KIND_CHUNK, transfer_id.as_str()),
112            Self::Ack { transfer_id, .. } => (ROUTED_KIND_ACK, transfer_id.as_str()),
113            Self::Waiting { transfer_id, .. } => (ROUTED_KIND_WAITING, transfer_id.as_str()),
114            Self::Complete { transfer_id, .. } => (ROUTED_KIND_COMPLETE, transfer_id.as_str()),
115            Self::Completed { transfer_id, .. } => (ROUTED_KIND_COMPLETED, transfer_id.as_str()),
116            Self::Reject { transfer_id, .. } => (ROUTED_KIND_REJECT, transfer_id.as_str()),
117            Self::CapabilitiesRequest { transfer_id, .. } => {
118                (ROUTED_KIND_CAPABILITIES_REQUEST, transfer_id.as_str())
119            }
120            Self::Capabilities { transfer_id, .. } => {
121                (ROUTED_KIND_CAPABILITIES, transfer_id.as_str())
122            }
123        };
124        let id = transfer_id.as_bytes();
125        if id.is_empty() || id.len() > MAX_ROUTED_TRANSFER_ID_BYTES || id.len() > u16::MAX as usize
126        {
127            return Err(TransferError::Limit(
128                "routed transferId length is invalid".into(),
129            ));
130        }
131        let mut output = Vec::with_capacity(7 + id.len() + 16);
132        output.extend_from_slice(ROUTED_FRAME_MAGIC);
133        output.push(kind);
134        output.extend_from_slice(&(id.len() as u16).to_be_bytes());
135        output.extend_from_slice(id);
136        match self {
137            Self::CapabilitiesRequest { header, .. } => {
138                let encoded = if let Some(header) = header {
139                    header.validate(TransferLimits {
140                        max_file_bytes: MAX_SAFE_FILE_BYTES,
141                        ..limits
142                    })?;
143                    if header.transfer_id != transfer_id {
144                        return Err(TransferError::Protocol(
145                            "routed capability transferId mismatch".into(),
146                        ));
147                    }
148                    serde_json::to_vec(header)?
149                } else {
150                    Vec::new()
151                };
152                if encoded.len() > limits.max_header_bytes || encoded.len() > u32::MAX as usize {
153                    return Err(TransferError::Limit(
154                        "routed capability header is too large".into(),
155                    ));
156                }
157                output.extend_from_slice(&(encoded.len() as u32).to_be_bytes());
158                output.extend_from_slice(&encoded);
159            }
160            Self::Capabilities { capabilities, .. } => {
161                let encoded = serde_json::to_vec(&capabilities.clone().normalized()?)?;
162                if encoded.len() > MAX_CAPABILITY_BYTES {
163                    return Err(TransferError::Limit(
164                        "routed capabilities are too large".into(),
165                    ));
166                }
167                output.extend_from_slice(&(encoded.len() as u32).to_be_bytes());
168                output.extend_from_slice(&encoded);
169            }
170            Self::Offer(header) => {
171                header.validate(limits)?;
172                let encoded = serde_json::to_vec(header)?;
173                if encoded.is_empty()
174                    || encoded.len() > limits.max_header_bytes
175                    || encoded.len() > u32::MAX as usize
176                {
177                    return Err(TransferError::Limit(
178                        "routed offer header is too large".into(),
179                    ));
180                }
181                output.extend_from_slice(&(encoded.len() as u32).to_be_bytes());
182                output.extend_from_slice(&encoded);
183            }
184            Self::Resume { offset, .. } | Self::Ack { offset, .. } => {
185                output.extend_from_slice(&offset.to_be_bytes());
186            }
187            Self::Waiting {
188                offset, lease_ms, ..
189            } => {
190                if *offset > limits.max_file_bytes
191                    || *lease_ms == 0
192                    || *lease_ms > MAX_RECEIVER_WAIT_LEASE_MS
193                {
194                    return Err(TransferError::Limit(
195                        "invalid receiver wait lease or offset".into(),
196                    ));
197                }
198                output.extend_from_slice(&offset.to_be_bytes());
199                output.extend_from_slice(&lease_ms.to_be_bytes());
200            }
201            Self::Chunk {
202                offset,
203                ack_requested,
204                bytes,
205                ..
206            } => {
207                if bytes.is_empty() || bytes.len() > limits.chunk_bytes {
208                    return Err(TransferError::Limit(format!(
209                        "routed chunk must contain 1..={} bytes",
210                        limits.chunk_bytes
211                    )));
212                }
213                output.extend_from_slice(&offset.to_be_bytes());
214                output.push(u8::from(*ack_requested));
215                output.extend_from_slice(bytes);
216            }
217            Self::Complete { size, .. } | Self::Completed { size, .. } => {
218                output.extend_from_slice(&size.to_be_bytes());
219            }
220            Self::Reject { reason, .. } => {
221                let bytes = reason.as_bytes();
222                if bytes.len() > limits.max_header_bytes || bytes.len() > u16::MAX as usize {
223                    return Err(TransferError::Limit(
224                        "routed rejection reason is too large".into(),
225                    ));
226                }
227                output.extend_from_slice(&(bytes.len() as u16).to_be_bytes());
228                output.extend_from_slice(bytes);
229            }
230        }
231        Ok(output)
232    }
233
234    pub fn decode(input: &[u8], limits: TransferLimits) -> Result<Self, TransferError> {
235        if input.len() < 7 || &input[..4] != ROUTED_FRAME_MAGIC {
236            return Err(TransferError::Protocol(
237                "not a routed file-transfer frame".into(),
238            ));
239        }
240        let kind = input[4];
241        let id_len = u16::from_be_bytes([input[5], input[6]]) as usize;
242        if id_len == 0 || id_len > MAX_ROUTED_TRANSFER_ID_BYTES || input.len() < 7 + id_len {
243            return Err(TransferError::Protocol("invalid routed transferId".into()));
244        }
245        let transfer_id = std::str::from_utf8(&input[7..7 + id_len])
246            .map_err(|_| TransferError::Protocol("routed transferId is not UTF-8".into()))?
247            .to_string();
248        let body = &input[7 + id_len..];
249        let read_u64 = |bytes: &[u8]| -> Result<u64, TransferError> {
250            let bytes: [u8; 8] = bytes
251                .get(..8)
252                .ok_or_else(|| TransferError::Protocol("truncated routed frame".into()))?
253                .try_into()
254                .map_err(|_| TransferError::Protocol("truncated routed frame".into()))?;
255            Ok(u64::from_be_bytes(bytes))
256        };
257        match kind {
258            ROUTED_KIND_CAPABILITIES_REQUEST | ROUTED_KIND_CAPABILITIES => {
259                let bytes: [u8; 4] = body
260                    .get(..4)
261                    .ok_or_else(|| TransferError::Protocol("truncated routed capabilities".into()))?
262                    .try_into()
263                    .unwrap();
264                let len = u32::from_be_bytes(bytes) as usize;
265                let bound = if kind == ROUTED_KIND_CAPABILITIES {
266                    MAX_CAPABILITY_BYTES
267                } else {
268                    limits.max_header_bytes
269                };
270                if len > bound || body.len() != 4 + len {
271                    return Err(TransferError::Limit(
272                        "invalid routed capability length".into(),
273                    ));
274                }
275                if kind == ROUTED_KIND_CAPABILITIES {
276                    let capabilities: TransferCapabilities = serde_json::from_slice(&body[4..])?;
277                    return Ok(Self::Capabilities {
278                        transfer_id,
279                        capabilities: capabilities.normalized()?,
280                    });
281                }
282                let header = if len == 0 {
283                    None
284                } else {
285                    let header: FileTransferHeader = serde_json::from_slice(&body[4..])?;
286                    header.validate(TransferLimits {
287                        max_file_bytes: MAX_SAFE_FILE_BYTES,
288                        ..limits
289                    })?;
290                    if header.transfer_id != transfer_id {
291                        return Err(TransferError::Protocol(
292                            "routed capability transferId mismatch".into(),
293                        ));
294                    }
295                    Some(header)
296                };
297                Ok(Self::CapabilitiesRequest {
298                    transfer_id,
299                    header,
300                })
301            }
302            ROUTED_KIND_OFFER => {
303                let len_bytes: [u8; 4] = body
304                    .get(..4)
305                    .ok_or_else(|| TransferError::Protocol("truncated routed offer".into()))?
306                    .try_into()
307                    .map_err(|_| TransferError::Protocol("truncated routed offer".into()))?;
308                let len = u32::from_be_bytes(len_bytes) as usize;
309                if len == 0 || len > limits.max_header_bytes || body.len() != 4 + len {
310                    return Err(TransferError::Limit(
311                        "invalid routed offer header length".into(),
312                    ));
313                }
314                let header: FileTransferHeader = serde_json::from_slice(&body[4..])?;
315                header.validate(limits)?;
316                if header.transfer_id != transfer_id {
317                    return Err(TransferError::Protocol(
318                        "routed offer transferId mismatch".into(),
319                    ));
320                }
321                Ok(Self::Offer(header))
322            }
323            ROUTED_KIND_RESUME if body.len() == 8 => Ok(Self::Resume {
324                transfer_id,
325                offset: read_u64(body)?,
326            }),
327            ROUTED_KIND_ACK if body.len() == 8 => Ok(Self::Ack {
328                transfer_id,
329                offset: read_u64(body)?,
330            }),
331            ROUTED_KIND_COMPLETE if body.len() == 8 => Ok(Self::Complete {
332                transfer_id,
333                size: read_u64(body)?,
334            }),
335            ROUTED_KIND_COMPLETED if body.len() == 8 => Ok(Self::Completed {
336                transfer_id,
337                size: read_u64(body)?,
338            }),
339            ROUTED_KIND_WAITING if body.len() == 12 => {
340                let offset = read_u64(body)?;
341                let lease_ms = u32::from_be_bytes(body[8..12].try_into().unwrap());
342                if offset > limits.max_file_bytes
343                    || lease_ms == 0
344                    || lease_ms > MAX_RECEIVER_WAIT_LEASE_MS
345                {
346                    return Err(TransferError::Limit(
347                        "invalid receiver wait lease or offset".into(),
348                    ));
349                }
350                Ok(Self::Waiting {
351                    transfer_id,
352                    offset,
353                    lease_ms,
354                })
355            }
356            ROUTED_KIND_CHUNK if body.len() > 9 => {
357                let offset = read_u64(body)?;
358                let ack_requested = match body[8] {
359                    0 => false,
360                    1 => true,
361                    _ => {
362                        return Err(TransferError::Protocol(
363                            "invalid routed chunk ACK flag".into(),
364                        ))
365                    }
366                };
367                let bytes = body[9..].to_vec();
368                if bytes.len() > limits.chunk_bytes {
369                    return Err(TransferError::Limit(
370                        "routed chunk exceeds configured limit".into(),
371                    ));
372                }
373                Ok(Self::Chunk {
374                    transfer_id,
375                    offset,
376                    ack_requested,
377                    bytes,
378                })
379            }
380            ROUTED_KIND_REJECT => {
381                let len_bytes: [u8; 2] = body
382                    .get(..2)
383                    .ok_or_else(|| TransferError::Protocol("truncated routed rejection".into()))?
384                    .try_into()
385                    .map_err(|_| TransferError::Protocol("truncated routed rejection".into()))?;
386                let len = u16::from_be_bytes(len_bytes) as usize;
387                if body.len() != 2 + len {
388                    return Err(TransferError::Protocol(
389                        "invalid routed rejection length".into(),
390                    ));
391                }
392                let reason = std::str::from_utf8(&body[2..])
393                    .map_err(|_| TransferError::Protocol("routed rejection is not UTF-8".into()))?
394                    .to_string();
395                Ok(Self::Reject {
396                    transfer_id,
397                    reason,
398                })
399            }
400            _ => Err(TransferError::Protocol(
401                "invalid routed file-transfer frame".into(),
402            )),
403        }
404    }
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
408#[serde(rename_all = "camelCase")]
409pub struct FileTransferHeader {
410    pub version: u8,
411    pub transfer_id: String,
412    pub name: String,
413    pub size: u64,
414    #[serde(default, skip_serializing_if = "is_zero")]
415    pub offset: u64,
416    pub mime_type: String,
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub sha256: Option<String>,
419    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
420    pub metadata: BTreeMap<String, String>,
421}
422
423impl FileTransferHeader {
424    pub fn new(name: impl Into<String>, size: u64, mime_type: impl Into<String>) -> Self {
425        Self {
426            version: 1,
427            transfer_id: uuid::Uuid::new_v4().to_string(),
428            name: name.into(),
429            size,
430            offset: 0,
431            mime_type: mime_type.into(),
432            sha256: None,
433            metadata: BTreeMap::new(),
434        }
435    }
436
437    pub fn validate(&self, limits: TransferLimits) -> Result<(), TransferError> {
438        if self.version != 1 {
439            return Err(TransferError::Protocol(
440                "unsupported file-transfer version".into(),
441            ));
442        }
443        if self.transfer_id.trim().is_empty() {
444            return Err(TransferError::Protocol("transferId is required".into()));
445        }
446        if self.name.trim().is_empty() || self.name.contains('\0') {
447            return Err(TransferError::Protocol("file name is invalid".into()));
448        }
449        if self.size > limits.max_file_bytes {
450            return Err(TransferError::Limit(format!(
451                "declared file size {} exceeds {} bytes",
452                self.size, limits.max_file_bytes
453            )));
454        }
455        if self.offset > self.size {
456            return Err(TransferError::Protocol(
457                "resume offset exceeds declared file size".into(),
458            ));
459        }
460        if let Some(digest) = &self.sha256 {
461            if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
462                return Err(TransferError::Protocol("invalid SHA-256 digest".into()));
463            }
464        }
465        self.required_capabilities()?;
466        Ok(())
467    }
468}
469
470#[derive(Debug, Clone, Copy)]
471pub struct TransferLimits {
472    pub max_header_bytes: usize,
473    pub max_file_bytes: u64,
474    pub chunk_bytes: usize,
475}
476
477impl Default for TransferLimits {
478    fn default() -> Self {
479        Self {
480            max_header_bytes: DEFAULT_MAX_HEADER_BYTES,
481            max_file_bytes: DEFAULT_MAX_FILE_BYTES,
482            chunk_bytes: DEFAULT_CHUNK_BYTES,
483        }
484    }
485}
486
487#[derive(Debug)]
488pub enum TransferError {
489    Io(std::io::Error),
490    Json(serde_json::Error),
491    Protocol(String),
492    Limit(String),
493    Rejected,
494    Integrity,
495}
496
497impl std::fmt::Display for TransferError {
498    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
499        match self {
500            Self::Io(error) => write!(f, "file-transfer IO error: {error}"),
501            Self::Json(error) => write!(f, "file-transfer JSON error: {error}"),
502            Self::Protocol(error) => write!(f, "file-transfer protocol error: {error}"),
503            Self::Limit(error) => write!(f, "file-transfer limit error: {error}"),
504            Self::Rejected => write!(f, "file transfer rejected by receiver"),
505            Self::Integrity => write!(f, "file-transfer SHA-256 verification failed"),
506        }
507    }
508}
509
510impl std::error::Error for TransferError {}
511impl From<std::io::Error> for TransferError {
512    fn from(value: std::io::Error) -> Self {
513        Self::Io(value)
514    }
515}
516impl From<serde_json::Error> for TransferError {
517    fn from(value: serde_json::Error) -> Self {
518        Self::Json(value)
519    }
520}
521impl From<flow_control::BulkFlowError> for TransferError {
522    fn from(value: flow_control::BulkFlowError) -> Self {
523        Self::Protocol(value.to_string())
524    }
525}
526
527/// Shared runtime controller plus the authenticated peer's product-independent
528/// scheduling key. Application accounting remains outside this SDK.
529#[derive(Clone, Copy)]
530pub struct BodyFlow<'a> {
531    pub controller: &'a flow_control::BulkFlowController,
532    pub peer: &'a str,
533}
534
535#[derive(Debug, Clone)]
536pub struct ReceivedFile {
537    pub header: FileTransferHeader,
538    pub path: PathBuf,
539}
540
541pub async fn send_path<S, F>(
542    stream: &mut S,
543    path: &Path,
544    header: FileTransferHeader,
545    limits: TransferLimits,
546    on_progress: F,
547) -> Result<(), TransferError>
548where
549    S: AsyncRead + AsyncWrite + Unpin,
550    F: FnMut(u64, u64),
551{
552    send_path_with_flow(stream, path, header, limits, None, on_progress).await
553}
554
555pub async fn send_path_with_flow<S, F>(
556    stream: &mut S,
557    path: &Path,
558    mut header: FileTransferHeader,
559    limits: TransferLimits,
560    flow: Option<BodyFlow<'_>>,
561    mut on_progress: F,
562) -> Result<(), TransferError>
563where
564    S: AsyncRead + AsyncWrite + Unpin,
565    F: FnMut(u64, u64),
566{
567    if limits.chunk_bytes == 0 {
568        return Err(TransferError::Limit("chunk size must be positive".into()));
569    }
570    if header.required_capabilities()?.is_some() {
571        return Err(TransferError::Protocol(
572            "transfer_capability_required: stream/v1 cannot negotiate required transfer features"
573                .into(),
574        ));
575    }
576    let chunk_bytes = limits.chunk_bytes.min(DEFAULT_CHUNK_BYTES);
577    let mut file = tokio::fs::File::open(path).await?;
578    let size = file.metadata().await?.len();
579    header.size = size;
580    if header.name.trim().is_empty() {
581        header.name = path
582            .file_name()
583            .and_then(|name| name.to_str())
584            .unwrap_or("transfer.bin")
585            .to_string();
586    }
587    if header.sha256.is_some() {
588        header.sha256 = Some(hash_file(&mut file, chunk_bytes).await?);
589        file.rewind().await?;
590    }
591    header.validate(limits)?;
592    write_header(stream, &header, limits.max_header_bytes).await?;
593
594    file.seek(std::io::SeekFrom::Start(header.offset)).await?;
595    let mut buffer = vec![0_u8; chunk_bytes];
596    let mut sent = header.offset;
597    on_progress(sent, size);
598    while sent < size {
599        let remaining = (size - sent).min(buffer.len() as u64) as usize;
600        let read = file.read(&mut buffer[..remaining]).await?;
601        if read == 0 {
602            return Err(TransferError::Protocol(
603                "source file ended before its metadata size".into(),
604            ));
605        }
606        if let Some(flow) = flow {
607            flow.controller
608                .acquire(flow_control::BulkDirection::Upload, flow.peer, read as u64)
609                .await?;
610        }
611        stream.write_all(&buffer[..read]).await?;
612        sent += read as u64;
613        on_progress(sent, size);
614    }
615    stream.flush().await?;
616    let ack = stream.read_u8().await?;
617    match ack {
618        ACK_OK => Ok(()),
619        ACK_REJECTED => Err(TransferError::Rejected),
620        _ => Err(TransferError::Protocol(
621            "invalid file-transfer acknowledgement".into(),
622        )),
623    }
624}
625
626pub async fn receive_to_directory<S, F>(
627    stream: &mut S,
628    directory: &Path,
629    limits: TransferLimits,
630    on_progress: F,
631) -> Result<ReceivedFile, TransferError>
632where
633    S: AsyncRead + AsyncWrite + Unpin,
634    F: FnMut(u64, u64),
635{
636    receive_to_directory_with_flow(stream, directory, limits, None, on_progress).await
637}
638
639pub async fn receive_to_directory_with_flow<S, F>(
640    stream: &mut S,
641    directory: &Path,
642    limits: TransferLimits,
643    flow: Option<BodyFlow<'_>>,
644    mut on_progress: F,
645) -> Result<ReceivedFile, TransferError>
646where
647    S: AsyncRead + AsyncWrite + Unpin,
648    F: FnMut(u64, u64),
649{
650    let result =
651        receive_to_directory_inner(stream, directory, limits, flow, &mut on_progress).await;
652    if result.is_err() {
653        let _ = stream.write_u8(ACK_REJECTED).await;
654        let _ = stream.flush().await;
655    }
656    result
657}
658
659async fn receive_to_directory_inner<S, F>(
660    stream: &mut S,
661    directory: &Path,
662    limits: TransferLimits,
663    flow: Option<BodyFlow<'_>>,
664    on_progress: &mut F,
665) -> Result<ReceivedFile, TransferError>
666where
667    S: AsyncRead + AsyncWrite + Unpin,
668    F: FnMut(u64, u64),
669{
670    if limits.chunk_bytes == 0 {
671        return Err(TransferError::Limit("chunk size must be positive".into()));
672    }
673    let chunk_bytes = limits.chunk_bytes.min(DEFAULT_CHUNK_BYTES);
674    let header = read_header(stream, limits).await?;
675    tokio::fs::create_dir_all(directory).await?;
676    let safe_name = sanitize_file_name(&header.name);
677    let destination = if header.offset > 0 {
678        directory.join(&safe_name)
679    } else {
680        unique_destination(directory, &safe_name).await?
681    };
682    let mut hasher = header.sha256.as_ref().map(|_| Sha256::new());
683    if header.offset > 0 {
684        let metadata = tokio::fs::metadata(&destination)
685            .await
686            .map_err(|_| TransferError::Protocol("resume destination does not exist".into()))?;
687        if metadata.len() != header.offset {
688            return Err(TransferError::Protocol(format!(
689                "resume destination length {} does not match offset {}",
690                metadata.len(),
691                header.offset
692            )));
693        }
694        if let Some(hasher) = hasher.as_mut() {
695            let mut existing = tokio::fs::File::open(&destination).await?;
696            let mut prefix = vec![0_u8; chunk_bytes];
697            loop {
698                let read = existing.read(&mut prefix).await?;
699                if read == 0 {
700                    break;
701                }
702                hasher.update(&prefix[..read]);
703            }
704        }
705    }
706    // A resumed destination is pre-existing receiver data. Stage incoming
707    // bytes separately until the complete payload passes integrity validation;
708    // corrupt or interrupted senders must never delete or modify that file.
709    let resume_stage = if header.offset > 0 {
710        Some(unique_destination(directory, &format!(".{}.openrtc-resume", safe_name)).await?)
711    } else {
712        None
713    };
714    let write_path = resume_stage.as_ref().unwrap_or(&destination);
715    let mut file = tokio::fs::OpenOptions::new()
716        .write(true)
717        .create_new(true)
718        .open(write_path)
719        .await?;
720    let mut buffer = vec![0_u8; chunk_bytes];
721    let mut received = header.offset;
722    on_progress(received, header.size);
723    while received < header.size {
724        let remaining = (header.size - received).min(buffer.len() as u64) as usize;
725        if let Some(flow) = flow {
726            flow.controller
727                .acquire(
728                    flow_control::BulkDirection::Download,
729                    flow.peer,
730                    remaining as u64,
731                )
732                .await?;
733        }
734        stream.read_exact(&mut buffer[..remaining]).await?;
735        file.write_all(&buffer[..remaining]).await?;
736        if let Some(hasher) = hasher.as_mut() {
737            hasher.update(&buffer[..remaining]);
738        }
739        received += remaining as u64;
740        on_progress(received, header.size);
741    }
742    file.flush().await?;
743    if let (Some(expected), Some(hasher)) = (&header.sha256, hasher) {
744        let actual = hex_digest(hasher.finalize().as_slice());
745        if !actual.eq_ignore_ascii_case(expected) {
746            drop(file);
747            // Only discard a file created by this failed transfer. In the
748            // resume case this is the private staging file, never destination.
749            let _ = tokio::fs::remove_file(write_path).await;
750            return Err(TransferError::Integrity);
751        }
752    }
753    if let Some(stage_path) = resume_stage {
754        drop(file);
755        let mut stage = tokio::fs::File::open(&stage_path).await?;
756        let mut destination_file = tokio::fs::OpenOptions::new()
757            .append(true)
758            .open(&destination)
759            .await?;
760        tokio::io::copy(&mut stage, &mut destination_file).await?;
761        destination_file.flush().await?;
762        let _ = tokio::fs::remove_file(stage_path).await;
763    }
764    stream.write_u8(ACK_OK).await?;
765    stream.flush().await?;
766    Ok(ReceivedFile {
767        header,
768        path: destination,
769    })
770}
771
772async fn write_header<S: AsyncWrite + Unpin>(
773    stream: &mut S,
774    header: &FileTransferHeader,
775    max_header_bytes: usize,
776) -> Result<(), TransferError> {
777    let encoded = serde_json::to_vec(header)?;
778    if encoded.is_empty() || encoded.len() > max_header_bytes || encoded.len() > u32::MAX as usize {
779        return Err(TransferError::Limit(format!(
780            "header exceeds {max_header_bytes} bytes"
781        )));
782    }
783    stream.write_u32(encoded.len() as u32).await?;
784    stream.write_all(&encoded).await?;
785    Ok(())
786}
787
788async fn read_header<S: AsyncRead + Unpin>(
789    stream: &mut S,
790    limits: TransferLimits,
791) -> Result<FileTransferHeader, TransferError> {
792    let length = stream.read_u32().await? as usize;
793    if length == 0 || length > limits.max_header_bytes {
794        return Err(TransferError::Limit(format!(
795            "invalid header length {length}"
796        )));
797    }
798    let mut encoded = vec![0_u8; length];
799    stream.read_exact(&mut encoded).await?;
800    let header: FileTransferHeader = serde_json::from_slice(&encoded)?;
801    header.validate(limits)?;
802    if header.required_capabilities()?.is_some() {
803        return Err(TransferError::Protocol(
804            "transfer_capability_required: stream/v1 cannot negotiate required transfer features"
805                .into(),
806        ));
807    }
808    Ok(header)
809}
810
811async fn hash_file(
812    file: &mut tokio::fs::File,
813    chunk_bytes: usize,
814) -> Result<String, TransferError> {
815    let mut hasher = Sha256::new();
816    let mut buffer = vec![0_u8; chunk_bytes];
817    loop {
818        let read = file.read(&mut buffer).await?;
819        if read == 0 {
820            break;
821        }
822        hasher.update(&buffer[..read]);
823    }
824    Ok(hex_digest(hasher.finalize().as_slice()))
825}
826
827fn hex_digest(bytes: &[u8]) -> String {
828    let mut output = String::with_capacity(bytes.len() * 2);
829    for byte in bytes {
830        use std::fmt::Write as _;
831        let _ = write!(output, "{byte:02x}");
832    }
833    output
834}
835
836fn is_zero(value: &u64) -> bool {
837    *value == 0
838}
839
840pub fn sanitize_file_name(value: &str) -> String {
841    let candidate = value.replace('\\', "/");
842    let name = candidate.rsplit('/').next().unwrap_or("").trim();
843    let sanitized: String = name
844        .chars()
845        .map(|character| {
846            if character.is_control() || character == '\0' {
847                '_'
848            } else {
849                character
850            }
851        })
852        .take(240)
853        .collect();
854    match sanitized.as_str() {
855        "" | "." | ".." => "transfer.bin".to_string(),
856        _ => sanitized,
857    }
858}
859
860async fn unique_destination(directory: &Path, file_name: &str) -> Result<PathBuf, TransferError> {
861    let original = Path::new(file_name);
862    let stem = original
863        .file_stem()
864        .and_then(|value| value.to_str())
865        .unwrap_or("transfer");
866    let extension = original.extension().and_then(|value| value.to_str());
867    for suffix in 0..10_000_u32 {
868        let name = if suffix == 0 {
869            file_name.to_string()
870        } else if let Some(extension) = extension {
871            format!("{stem} ({suffix}).{extension}")
872        } else {
873            format!("{stem} ({suffix})")
874        };
875        let candidate = directory.join(name);
876        if !tokio::fs::try_exists(&candidate).await? {
877            return Ok(candidate);
878        }
879    }
880    Err(TransferError::Limit(
881        "unable to allocate a unique destination name".into(),
882    ))
883}
884
885#[cfg(test)]
886mod tests {
887    use super::*;
888
889    #[tokio::test(start_paused = true)]
890    async fn paced_parallel_streams_obey_directional_rates_and_preserve_exact_files() {
891        use flow_control::{BulkDirection, BulkFlowController, BulkRates};
892        use std::sync::{Arc, Mutex};
893        use tokio::time::Instant;
894        let source_dir = tempfile::tempdir().unwrap();
895        let source = source_dir.path().join("source.bin");
896        let payload: Vec<u8> = (0..3 * DEFAULT_CHUNK_BYTES)
897            .map(|index| (index % 251) as u8)
898            .collect();
899        std::fs::write(&source, &payload).unwrap();
900        let flow = BulkFlowController::new(BulkRates {
901            upload_bytes_per_second: Some(65536),
902            download_bytes_per_second: Some(131072),
903        })
904        .unwrap();
905        let events = Arc::new(Mutex::new(Vec::new()));
906        let start = Instant::now();
907        let mut jobs = Vec::new();
908        for peer in ["peer-a", "peer-b"] {
909            let (source, payload, flow, events) = (
910                source.clone(),
911                payload.clone(),
912                flow.clone(),
913                events.clone(),
914            );
915            jobs.push(tokio::spawn(async move {
916                let destination = tempfile::tempdir().unwrap();
917                let (mut sender, mut receiver) = tokio::io::duplex(DEFAULT_CHUNK_BYTES);
918                // A caller's larger preferred chunk cannot allocate an unbounded
919                // transfer buffer or bypass the controller's bounded grants.
920                let limits = TransferLimits {
921                    chunk_bytes: 16 * 1024 * 1024,
922                    ..TransferLimits::default()
923                };
924                let mut header =
925                    FileTransferHeader::new("received.bin", 0, "application/octet-stream");
926                header.sha256 = Some(String::new());
927                let (mut sent_last, mut received_last) = (0, 0);
928                let sent_events = events.clone();
929                let send = send_path_with_flow(
930                    &mut sender,
931                    &source,
932                    header,
933                    limits,
934                    Some(BodyFlow {
935                        controller: &flow,
936                        peer,
937                    }),
938                    |bytes, _| {
939                        if bytes > sent_last {
940                            sent_events.lock().unwrap().push((
941                                BulkDirection::Upload,
942                                bytes - sent_last,
943                                Instant::now().duration_since(start),
944                            ));
945                            sent_last = bytes;
946                        }
947                    },
948                );
949                let receive = receive_to_directory_with_flow(
950                    &mut receiver,
951                    destination.path(),
952                    limits,
953                    Some(BodyFlow {
954                        controller: &flow,
955                        peer,
956                    }),
957                    |bytes, _| {
958                        if bytes > received_last {
959                            events.lock().unwrap().push((
960                                BulkDirection::Download,
961                                bytes - received_last,
962                                Instant::now().duration_since(start),
963                            ));
964                            received_last = bytes;
965                        }
966                    },
967                );
968                let (sent, received) = tokio::join!(send, receive);
969                sent.unwrap();
970                assert_eq!(std::fs::read(received.unwrap().path).unwrap(), payload);
971            }));
972        }
973        for job in jobs {
974            job.await.unwrap();
975        }
976        let events = events.lock().unwrap();
977        for (direction, rate) in [
978            (BulkDirection::Upload, 65536_u128),
979            (BulkDirection::Download, 131072),
980        ] {
981            let mut total = 0_u128;
982            for (_, bytes, elapsed) in events.iter().filter(|(value, _, _)| *value == direction) {
983                assert!(*bytes <= DEFAULT_CHUNK_BYTES as u64);
984                total += u128::from(*bytes);
985                assert!(
986                    total
987                        <= DEFAULT_CHUNK_BYTES as u128 + elapsed.as_nanos() * rate / 1_000_000_000
988                );
989            }
990            assert_eq!(total, (2 * payload.len()) as u128);
991        }
992    }
993
994    #[tokio::test]
995    async fn round_trip_streams_and_verifies_a_file() {
996        let source_dir = tempfile::tempdir().unwrap();
997        let destination_dir = tempfile::tempdir().unwrap();
998        let source = source_dir.path().join("plane.webp");
999        tokio::fs::write(&source, [1_u8, 2, 3, 4, 5]).await.unwrap();
1000        let (mut sender, mut receiver) = tokio::io::duplex(256 * 1024);
1001        let mut header = FileTransferHeader::new("plane.webp", 0, "image/webp");
1002        header.transfer_id = "transfer-1".to_string();
1003        header.sha256 = Some(String::new());
1004        let limits = TransferLimits::default();
1005
1006        let send = send_path(&mut sender, &source, header, limits, |_, _| {});
1007        let receive =
1008            receive_to_directory(&mut receiver, destination_dir.path(), limits, |_, _| {});
1009        let (sent, received) = tokio::join!(send, receive);
1010        sent.unwrap();
1011        let received = received.unwrap();
1012        assert_eq!(received.header.transfer_id, "transfer-1");
1013        assert_eq!(
1014            tokio::fs::read(received.path).await.unwrap(),
1015            [1, 2, 3, 4, 5]
1016        );
1017    }
1018
1019    #[test]
1020    fn sanitizes_cross_platform_path_traversal() {
1021        assert_eq!(sanitize_file_name("../../secret.txt"), "secret.txt");
1022        assert_eq!(
1023            sanitize_file_name(r"C:\\Users\\name\\secret.txt"),
1024            "secret.txt"
1025        );
1026        assert_eq!(sanitize_file_name(".."), "transfer.bin");
1027    }
1028
1029    #[tokio::test]
1030    async fn rejects_legacy_headers_before_creating_a_file() {
1031        for required_feature in [false, true] {
1032            let destination = tempfile::tempdir().unwrap();
1033            let (mut sender, mut receiver) = tokio::io::duplex(1024);
1034            let limits = if required_feature {
1035                let header = br#"{"version":1,"transferId":"legacy-required","name":"empty.bin","size":0,"mimeType":"application/octet-stream","metadata":{"openrtc.required-capabilities.v1":"{\"version\":1,\"durableResume\":false,\"completionReceipt\":false,\"extensions\":[\"example.destination.v1\"]}"}}"#;
1036                sender.write_u32(header.len() as u32).await.unwrap();
1037                sender.write_all(header).await.unwrap();
1038                TransferLimits::default()
1039            } else {
1040                sender.write_u32(1024).await.unwrap();
1041                TransferLimits {
1042                    max_header_bytes: 16,
1043                    ..TransferLimits::default()
1044                }
1045            };
1046            sender.flush().await.unwrap();
1047            let error = receive_to_directory(&mut receiver, destination.path(), limits, |_, _| {})
1048                .await
1049                .unwrap_err();
1050            if required_feature {
1051                assert!(error.to_string().contains("transfer_capability_required"));
1052            } else {
1053                assert!(matches!(error, TransferError::Limit(_)));
1054            }
1055            assert_eq!(std::fs::read_dir(destination.path()).unwrap().count(), 0);
1056        }
1057    }
1058
1059    #[tokio::test]
1060    async fn resumes_an_existing_verified_prefix() {
1061        let source_dir = tempfile::tempdir().unwrap();
1062        let destination_dir = tempfile::tempdir().unwrap();
1063        let source = source_dir.path().join("resume.bin");
1064        let destination = destination_dir.path().join("resume.bin");
1065        tokio::fs::write(&source, [1_u8, 2, 3, 4, 5]).await.unwrap();
1066        tokio::fs::write(&destination, [1_u8, 2]).await.unwrap();
1067        let (mut sender, mut receiver) = tokio::io::duplex(256 * 1024);
1068        let mut header = FileTransferHeader::new("resume.bin", 0, "application/octet-stream");
1069        header.transfer_id = "resume-1".to_string();
1070        header.offset = 2;
1071        header.sha256 = Some(String::new());
1072        let limits = TransferLimits::default();
1073
1074        let send = send_path(&mut sender, &source, header, limits, |_, _| {});
1075        let receive =
1076            receive_to_directory(&mut receiver, destination_dir.path(), limits, |_, _| {});
1077        let (sent, received) = tokio::join!(send, receive);
1078        sent.unwrap();
1079        assert_eq!(received.unwrap().header.offset, 2);
1080        assert_eq!(tokio::fs::read(destination).await.unwrap(), [1, 2, 3, 4, 5]);
1081    }
1082
1083    #[tokio::test]
1084    async fn corrupt_resume_preserves_the_existing_destination() {
1085        let destination_dir = tempfile::tempdir().unwrap();
1086        let destination = destination_dir.path().join("resume.bin");
1087        tokio::fs::write(&destination, [1_u8, 2]).await.unwrap();
1088        let (mut sender, mut receiver) = tokio::io::duplex(256 * 1024);
1089        let mut header = FileTransferHeader::new("resume.bin", 5, "application/octet-stream");
1090        header.transfer_id = "corrupt-resume".to_string();
1091        header.offset = 2;
1092        header.sha256 = Some("00".repeat(32));
1093        let limits = TransferLimits::default();
1094
1095        let send = async {
1096            write_header(&mut sender, &header, limits.max_header_bytes)
1097                .await
1098                .unwrap();
1099            sender.write_all(&[9_u8, 9, 9]).await.unwrap();
1100            sender.flush().await.unwrap();
1101            sender.read_u8().await.unwrap()
1102        };
1103        let receive =
1104            receive_to_directory(&mut receiver, destination_dir.path(), limits, |_, _| {});
1105        let (ack, received) = tokio::join!(send, receive);
1106
1107        assert_eq!(ack, ACK_REJECTED);
1108        assert!(matches!(received, Err(TransferError::Integrity)));
1109        assert_eq!(
1110            tokio::fs::read(&destination).await.unwrap(),
1111            [1, 2],
1112            "a corrupt resume must not delete or append to existing receiver data"
1113        );
1114        assert_eq!(
1115            std::fs::read_dir(destination_dir.path()).unwrap().count(),
1116            1,
1117            "a rejected resume must clean only its private staging file"
1118        );
1119    }
1120
1121    #[test]
1122    fn routed_frames_round_trip_offsets_chunks_and_completion() {
1123        let limits = TransferLimits {
1124            chunk_bytes: DEFAULT_ROUTED_CHUNK_BYTES,
1125            ..TransferLimits::default()
1126        };
1127        let mut header = FileTransferHeader::new("route.bin", 99, "application/octet-stream");
1128        header.transfer_id = "route-transfer-1".to_string();
1129        let frames = vec![
1130            RoutedTransferFrame::Offer(header),
1131            RoutedTransferFrame::Resume {
1132                transfer_id: "route-transfer-1".to_string(),
1133                offset: 24,
1134            },
1135            RoutedTransferFrame::Chunk {
1136                transfer_id: "route-transfer-1".to_string(),
1137                offset: 24,
1138                ack_requested: true,
1139                bytes: vec![1, 2, 3],
1140            },
1141            RoutedTransferFrame::Ack {
1142                transfer_id: "route-transfer-1".to_string(),
1143                offset: 27,
1144            },
1145            RoutedTransferFrame::Complete {
1146                transfer_id: "route-transfer-1".to_string(),
1147                size: 99,
1148            },
1149            RoutedTransferFrame::Completed {
1150                transfer_id: "route-transfer-1".to_string(),
1151                size: 99,
1152            },
1153            RoutedTransferFrame::Reject {
1154                transfer_id: "route-transfer-1".to_string(),
1155                reason: "ACL changed".to_string(),
1156            },
1157        ];
1158
1159        for frame in frames {
1160            let encoded = frame.encode(limits).unwrap();
1161            assert_eq!(
1162                RoutedTransferFrame::decode(&encoded, limits).unwrap(),
1163                frame
1164            );
1165        }
1166    }
1167
1168    #[test]
1169    fn receiver_wait_uses_independent_bounded_wire_contract() {
1170        let wire = [
1171            0x4f, 0x46, 0x54, 0x32, 8, 0, 1, 0x78, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 40,
1172        ];
1173        let limits = TransferLimits::default();
1174        let expected = RoutedTransferFrame::Waiting {
1175            transfer_id: "x".into(),
1176            offset: 2,
1177            lease_ms: 40,
1178        };
1179        assert_eq!(
1180            RoutedTransferFrame::decode(&wire, limits).unwrap(),
1181            expected
1182        );
1183        assert_eq!(expected.encode(limits).unwrap(), wire);
1184        for lease_ms in [0_u32, 60_001] {
1185            let mut malformed = wire;
1186            malformed[16..20].copy_from_slice(&lease_ms.to_be_bytes());
1187            assert!(RoutedTransferFrame::decode(&malformed, limits).is_err());
1188            assert!(RoutedTransferFrame::Waiting {
1189                transfer_id: "x".into(),
1190                offset: 2,
1191                lease_ms
1192            }
1193            .encode(limits)
1194            .is_err());
1195        }
1196        assert!(RoutedTransferFrame::decode(&wire[..19], limits).is_err());
1197        let small = TransferLimits {
1198            max_file_bytes: 1,
1199            ..limits
1200        };
1201        assert!(RoutedTransferFrame::decode(&wire, small).is_err());
1202        assert!(expected.encode(small).is_err());
1203    }
1204
1205    #[test]
1206    fn capabilities_use_independent_bounded_wire_contract() {
1207        let payload = br#"{"version":1,"durableResume":false,"completionReceipt":true,"extensions":["example.destination.v1"],"receiveAvailable":true,"maxFileBytes":107374182400}"#;
1208        let mut wire = vec![0x4f, 0x46, 0x54, 0x32, 10, 0, 1, 0x78, 0, 0, 0, 152];
1209        wire.extend_from_slice(payload);
1210        let limits = TransferLimits {
1211            max_file_bytes: 1,
1212            ..TransferLimits::default()
1213        };
1214        let expected = RoutedTransferFrame::Capabilities {
1215            transfer_id: "x".into(),
1216            capabilities: TransferCapabilities {
1217                version: 1,
1218                durable_resume: false,
1219                completion_receipt: true,
1220                extensions: vec!["example.destination.v1".into()],
1221                receive_available: true,
1222                max_file_bytes: 100 * 1024_u64.pow(3),
1223            },
1224        };
1225        assert_eq!(
1226            RoutedTransferFrame::decode(&wire, limits).unwrap(),
1227            expected
1228        );
1229        assert_eq!(expected.encode(limits).unwrap(), wire);
1230        let request = [0x4f, 0x46, 0x54, 0x32, 9, 0, 1, 0x78, 0, 0, 0, 0];
1231        let generic = RoutedTransferFrame::CapabilitiesRequest {
1232            transfer_id: "x".into(),
1233            header: None,
1234        };
1235        assert_eq!(
1236            RoutedTransferFrame::decode(&request, limits).unwrap(),
1237            generic
1238        );
1239        assert_eq!(generic.encode(limits).unwrap(), request);
1240        let text = std::str::from_utf8(payload).unwrap();
1241        for invalid in [
1242            text.replace("\"version\":1", "\"version\":2"),
1243            text.replace("\"durableResume\":false", "\"durableResume\":0"),
1244            text.replace("\"receiveAvailable\":true,", ""),
1245            text.replace(
1246                "\"extensions\":[\"example.destination.v1\"]",
1247                "\"extensions\":[\"x\",\"x\"]",
1248            ),
1249            text.replace("107374182400", "9007199254740992"),
1250            text.replace("\"version\":1", "\"unexpected\":true,\"version\":1"),
1251        ] {
1252            let mut malformed = wire[..8].to_vec();
1253            malformed.extend_from_slice(&(invalid.len() as u32).to_be_bytes());
1254            malformed.extend_from_slice(invalid.as_bytes());
1255            assert!(RoutedTransferFrame::decode(&malformed, limits).is_err());
1256        }
1257        assert!(RoutedTransferFrame::decode(&wire[..wire.len() - 1], limits).is_err());
1258        let mut trailing = request.to_vec();
1259        trailing.push(0);
1260        assert!(RoutedTransferFrame::decode(&trailing, limits).is_err());
1261        let mut header = FileTransferHeader::new(
1262            "large.bin",
1263            100 * 1024_u64.pow(3),
1264            "application/octet-stream",
1265        );
1266        header.transfer_id = "x".into();
1267        let probe = RoutedTransferFrame::CapabilitiesRequest {
1268            transfer_id: "x".into(),
1269            header: Some(header),
1270        };
1271        let mut encoded = probe.encode(limits).unwrap();
1272        assert_eq!(
1273            RoutedTransferFrame::decode(&encoded, limits).unwrap(),
1274            probe
1275        );
1276        encoded[7] = b'y';
1277        assert!(RoutedTransferFrame::decode(&encoded, limits).is_err());
1278    }
1279
1280    #[test]
1281    fn routed_chunk_rejects_oversized_payloads() {
1282        let limits = TransferLimits {
1283            chunk_bytes: 4,
1284            ..TransferLimits::default()
1285        };
1286        let frame = RoutedTransferFrame::Chunk {
1287            transfer_id: "route-transfer-1".to_string(),
1288            offset: 0,
1289            ack_requested: true,
1290            bytes: vec![0; 5],
1291        };
1292        assert!(matches!(frame.encode(limits), Err(TransferError::Limit(_))));
1293    }
1294}