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
13const ACK_OK: u8 = 0x01;
14const ACK_REJECTED: u8 = 0x02;
15pub const DEFAULT_CHUNK_BYTES: usize = 64 * 1024;
16pub const DEFAULT_MAX_HEADER_BYTES: usize = 64 * 1024;
17pub const DEFAULT_MAX_FILE_BYTES: u64 = 256 * 1024 * 1024;
18pub const ROUTED_FRAME_MAGIC: &[u8; 4] = b"OFT2";
19pub const DEFAULT_ROUTED_CHUNK_BYTES: usize = 48 * 1024;
20pub const MAX_ROUTED_TRANSFER_ID_BYTES: usize = 512;
21
22const ROUTED_KIND_OFFER: u8 = 0x01;
23const ROUTED_KIND_RESUME: u8 = 0x02;
24const ROUTED_KIND_CHUNK: u8 = 0x03;
25const ROUTED_KIND_ACK: u8 = 0x04;
26const ROUTED_KIND_COMPLETE: u8 = 0x05;
27const ROUTED_KIND_COMPLETED: u8 = 0x06;
28const ROUTED_KIND_REJECT: u8 = 0x07;
29
30/// One bounded file-transfer message sent through OpenRTC's current-route
31/// selector. A transfer is a sequence of these frames, not one physical
32/// stream, so route promotion or replacement does not discard confirmed work.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum RoutedTransferFrame {
35    Offer(FileTransferHeader),
36    Resume {
37        transfer_id: String,
38        offset: u64,
39    },
40    Chunk {
41        transfer_id: String,
42        offset: u64,
43        ack_requested: bool,
44        bytes: Vec<u8>,
45    },
46    Ack {
47        transfer_id: String,
48        offset: u64,
49    },
50    Complete {
51        transfer_id: String,
52        size: u64,
53    },
54    Completed {
55        transfer_id: String,
56        size: u64,
57    },
58    Reject {
59        transfer_id: String,
60        reason: String,
61    },
62}
63
64impl RoutedTransferFrame {
65    pub fn transfer_id(&self) -> &str {
66        match self {
67            Self::Offer(header) => &header.transfer_id,
68            Self::Resume { transfer_id, .. }
69            | Self::Chunk { transfer_id, .. }
70            | Self::Ack { transfer_id, .. }
71            | Self::Complete { transfer_id, .. }
72            | Self::Completed { transfer_id, .. }
73            | Self::Reject { transfer_id, .. } => transfer_id,
74        }
75    }
76
77    pub fn encode(&self, limits: TransferLimits) -> Result<Vec<u8>, TransferError> {
78        let (kind, transfer_id) = match self {
79            Self::Offer(header) => (ROUTED_KIND_OFFER, header.transfer_id.as_str()),
80            Self::Resume { transfer_id, .. } => (ROUTED_KIND_RESUME, transfer_id.as_str()),
81            Self::Chunk { transfer_id, .. } => (ROUTED_KIND_CHUNK, transfer_id.as_str()),
82            Self::Ack { transfer_id, .. } => (ROUTED_KIND_ACK, transfer_id.as_str()),
83            Self::Complete { transfer_id, .. } => (ROUTED_KIND_COMPLETE, transfer_id.as_str()),
84            Self::Completed { transfer_id, .. } => (ROUTED_KIND_COMPLETED, transfer_id.as_str()),
85            Self::Reject { transfer_id, .. } => (ROUTED_KIND_REJECT, transfer_id.as_str()),
86        };
87        let id = transfer_id.as_bytes();
88        if id.is_empty() || id.len() > MAX_ROUTED_TRANSFER_ID_BYTES || id.len() > u16::MAX as usize
89        {
90            return Err(TransferError::Limit(
91                "routed transferId length is invalid".into(),
92            ));
93        }
94        let mut output = Vec::with_capacity(7 + id.len() + 16);
95        output.extend_from_slice(ROUTED_FRAME_MAGIC);
96        output.push(kind);
97        output.extend_from_slice(&(id.len() as u16).to_be_bytes());
98        output.extend_from_slice(id);
99        match self {
100            Self::Offer(header) => {
101                header.validate(limits)?;
102                let encoded = serde_json::to_vec(header)?;
103                if encoded.is_empty()
104                    || encoded.len() > limits.max_header_bytes
105                    || encoded.len() > u32::MAX as usize
106                {
107                    return Err(TransferError::Limit(
108                        "routed offer header is too large".into(),
109                    ));
110                }
111                output.extend_from_slice(&(encoded.len() as u32).to_be_bytes());
112                output.extend_from_slice(&encoded);
113            }
114            Self::Resume { offset, .. } | Self::Ack { offset, .. } => {
115                output.extend_from_slice(&offset.to_be_bytes());
116            }
117            Self::Chunk {
118                offset,
119                ack_requested,
120                bytes,
121                ..
122            } => {
123                if bytes.is_empty() || bytes.len() > limits.chunk_bytes {
124                    return Err(TransferError::Limit(format!(
125                        "routed chunk must contain 1..={} bytes",
126                        limits.chunk_bytes
127                    )));
128                }
129                output.extend_from_slice(&offset.to_be_bytes());
130                output.push(u8::from(*ack_requested));
131                output.extend_from_slice(bytes);
132            }
133            Self::Complete { size, .. } | Self::Completed { size, .. } => {
134                output.extend_from_slice(&size.to_be_bytes());
135            }
136            Self::Reject { reason, .. } => {
137                let bytes = reason.as_bytes();
138                if bytes.len() > limits.max_header_bytes || bytes.len() > u16::MAX as usize {
139                    return Err(TransferError::Limit(
140                        "routed rejection reason is too large".into(),
141                    ));
142                }
143                output.extend_from_slice(&(bytes.len() as u16).to_be_bytes());
144                output.extend_from_slice(bytes);
145            }
146        }
147        Ok(output)
148    }
149
150    pub fn decode(input: &[u8], limits: TransferLimits) -> Result<Self, TransferError> {
151        if input.len() < 7 || &input[..4] != ROUTED_FRAME_MAGIC {
152            return Err(TransferError::Protocol(
153                "not a routed file-transfer frame".into(),
154            ));
155        }
156        let kind = input[4];
157        let id_len = u16::from_be_bytes([input[5], input[6]]) as usize;
158        if id_len == 0 || id_len > MAX_ROUTED_TRANSFER_ID_BYTES || input.len() < 7 + id_len {
159            return Err(TransferError::Protocol("invalid routed transferId".into()));
160        }
161        let transfer_id = std::str::from_utf8(&input[7..7 + id_len])
162            .map_err(|_| TransferError::Protocol("routed transferId is not UTF-8".into()))?
163            .to_string();
164        let body = &input[7 + id_len..];
165        let read_u64 = |bytes: &[u8]| -> Result<u64, TransferError> {
166            let bytes: [u8; 8] = bytes
167                .get(..8)
168                .ok_or_else(|| TransferError::Protocol("truncated routed frame".into()))?
169                .try_into()
170                .map_err(|_| TransferError::Protocol("truncated routed frame".into()))?;
171            Ok(u64::from_be_bytes(bytes))
172        };
173        match kind {
174            ROUTED_KIND_OFFER => {
175                let len_bytes: [u8; 4] = body
176                    .get(..4)
177                    .ok_or_else(|| TransferError::Protocol("truncated routed offer".into()))?
178                    .try_into()
179                    .map_err(|_| TransferError::Protocol("truncated routed offer".into()))?;
180                let len = u32::from_be_bytes(len_bytes) as usize;
181                if len == 0 || len > limits.max_header_bytes || body.len() != 4 + len {
182                    return Err(TransferError::Limit(
183                        "invalid routed offer header length".into(),
184                    ));
185                }
186                let header: FileTransferHeader = serde_json::from_slice(&body[4..])?;
187                header.validate(limits)?;
188                if header.transfer_id != transfer_id {
189                    return Err(TransferError::Protocol(
190                        "routed offer transferId mismatch".into(),
191                    ));
192                }
193                Ok(Self::Offer(header))
194            }
195            ROUTED_KIND_RESUME if body.len() == 8 => Ok(Self::Resume {
196                transfer_id,
197                offset: read_u64(body)?,
198            }),
199            ROUTED_KIND_ACK if body.len() == 8 => Ok(Self::Ack {
200                transfer_id,
201                offset: read_u64(body)?,
202            }),
203            ROUTED_KIND_COMPLETE if body.len() == 8 => Ok(Self::Complete {
204                transfer_id,
205                size: read_u64(body)?,
206            }),
207            ROUTED_KIND_COMPLETED if body.len() == 8 => Ok(Self::Completed {
208                transfer_id,
209                size: read_u64(body)?,
210            }),
211            ROUTED_KIND_CHUNK if body.len() > 9 => {
212                let offset = read_u64(body)?;
213                let ack_requested = match body[8] {
214                    0 => false,
215                    1 => true,
216                    _ => {
217                        return Err(TransferError::Protocol(
218                            "invalid routed chunk ACK flag".into(),
219                        ))
220                    }
221                };
222                let bytes = body[9..].to_vec();
223                if bytes.len() > limits.chunk_bytes {
224                    return Err(TransferError::Limit(
225                        "routed chunk exceeds configured limit".into(),
226                    ));
227                }
228                Ok(Self::Chunk {
229                    transfer_id,
230                    offset,
231                    ack_requested,
232                    bytes,
233                })
234            }
235            ROUTED_KIND_REJECT => {
236                let len_bytes: [u8; 2] = body
237                    .get(..2)
238                    .ok_or_else(|| TransferError::Protocol("truncated routed rejection".into()))?
239                    .try_into()
240                    .map_err(|_| TransferError::Protocol("truncated routed rejection".into()))?;
241                let len = u16::from_be_bytes(len_bytes) as usize;
242                if body.len() != 2 + len {
243                    return Err(TransferError::Protocol(
244                        "invalid routed rejection length".into(),
245                    ));
246                }
247                let reason = std::str::from_utf8(&body[2..])
248                    .map_err(|_| TransferError::Protocol("routed rejection is not UTF-8".into()))?
249                    .to_string();
250                Ok(Self::Reject {
251                    transfer_id,
252                    reason,
253                })
254            }
255            _ => Err(TransferError::Protocol(
256                "invalid routed file-transfer frame".into(),
257            )),
258        }
259    }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
263#[serde(rename_all = "camelCase")]
264pub struct FileTransferHeader {
265    pub version: u8,
266    pub transfer_id: String,
267    pub name: String,
268    pub size: u64,
269    #[serde(default, skip_serializing_if = "is_zero")]
270    pub offset: u64,
271    pub mime_type: String,
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub sha256: Option<String>,
274    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
275    pub metadata: BTreeMap<String, String>,
276}
277
278impl FileTransferHeader {
279    pub fn new(name: impl Into<String>, size: u64, mime_type: impl Into<String>) -> Self {
280        Self {
281            version: 1,
282            transfer_id: uuid::Uuid::new_v4().to_string(),
283            name: name.into(),
284            size,
285            offset: 0,
286            mime_type: mime_type.into(),
287            sha256: None,
288            metadata: BTreeMap::new(),
289        }
290    }
291
292    pub fn validate(&self, limits: TransferLimits) -> Result<(), TransferError> {
293        if self.version != 1 {
294            return Err(TransferError::Protocol(
295                "unsupported file-transfer version".into(),
296            ));
297        }
298        if self.transfer_id.trim().is_empty() {
299            return Err(TransferError::Protocol("transferId is required".into()));
300        }
301        if self.name.trim().is_empty() || self.name.contains('\0') {
302            return Err(TransferError::Protocol("file name is invalid".into()));
303        }
304        if self.size > limits.max_file_bytes {
305            return Err(TransferError::Limit(format!(
306                "declared file size {} exceeds {} bytes",
307                self.size, limits.max_file_bytes
308            )));
309        }
310        if self.offset > self.size {
311            return Err(TransferError::Protocol(
312                "resume offset exceeds declared file size".into(),
313            ));
314        }
315        if let Some(digest) = &self.sha256 {
316            if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
317                return Err(TransferError::Protocol("invalid SHA-256 digest".into()));
318            }
319        }
320        Ok(())
321    }
322}
323
324#[derive(Debug, Clone, Copy)]
325pub struct TransferLimits {
326    pub max_header_bytes: usize,
327    pub max_file_bytes: u64,
328    pub chunk_bytes: usize,
329}
330
331impl Default for TransferLimits {
332    fn default() -> Self {
333        Self {
334            max_header_bytes: DEFAULT_MAX_HEADER_BYTES,
335            max_file_bytes: DEFAULT_MAX_FILE_BYTES,
336            chunk_bytes: DEFAULT_CHUNK_BYTES,
337        }
338    }
339}
340
341#[derive(Debug)]
342pub enum TransferError {
343    Io(std::io::Error),
344    Json(serde_json::Error),
345    Protocol(String),
346    Limit(String),
347    Rejected,
348    Integrity,
349}
350
351impl std::fmt::Display for TransferError {
352    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353        match self {
354            Self::Io(error) => write!(f, "file-transfer IO error: {error}"),
355            Self::Json(error) => write!(f, "file-transfer JSON error: {error}"),
356            Self::Protocol(error) => write!(f, "file-transfer protocol error: {error}"),
357            Self::Limit(error) => write!(f, "file-transfer limit error: {error}"),
358            Self::Rejected => write!(f, "file transfer rejected by receiver"),
359            Self::Integrity => write!(f, "file-transfer SHA-256 verification failed"),
360        }
361    }
362}
363
364impl std::error::Error for TransferError {}
365impl From<std::io::Error> for TransferError {
366    fn from(value: std::io::Error) -> Self {
367        Self::Io(value)
368    }
369}
370impl From<serde_json::Error> for TransferError {
371    fn from(value: serde_json::Error) -> Self {
372        Self::Json(value)
373    }
374}
375
376#[derive(Debug, Clone)]
377pub struct ReceivedFile {
378    pub header: FileTransferHeader,
379    pub path: PathBuf,
380}
381
382pub async fn send_path<S, F>(
383    stream: &mut S,
384    path: &Path,
385    mut header: FileTransferHeader,
386    limits: TransferLimits,
387    mut on_progress: F,
388) -> Result<(), TransferError>
389where
390    S: AsyncRead + AsyncWrite + Unpin,
391    F: FnMut(u64, u64),
392{
393    if limits.chunk_bytes == 0 {
394        return Err(TransferError::Limit("chunk size must be positive".into()));
395    }
396    let mut file = tokio::fs::File::open(path).await?;
397    let size = file.metadata().await?.len();
398    header.size = size;
399    if header.name.trim().is_empty() {
400        header.name = path
401            .file_name()
402            .and_then(|name| name.to_str())
403            .unwrap_or("transfer.bin")
404            .to_string();
405    }
406    if header.sha256.is_some() {
407        header.sha256 = Some(hash_file(&mut file, limits.chunk_bytes).await?);
408        file.rewind().await?;
409    }
410    header.validate(limits)?;
411    write_header(stream, &header, limits.max_header_bytes).await?;
412
413    file.seek(std::io::SeekFrom::Start(header.offset)).await?;
414    let mut buffer = vec![0_u8; limits.chunk_bytes];
415    let mut sent = header.offset;
416    on_progress(sent, size);
417    while sent < size {
418        let read = file.read(&mut buffer).await?;
419        if read == 0 {
420            return Err(TransferError::Protocol(
421                "source file ended before its metadata size".into(),
422            ));
423        }
424        stream.write_all(&buffer[..read]).await?;
425        sent += read as u64;
426        on_progress(sent, size);
427    }
428    stream.flush().await?;
429    let ack = stream.read_u8().await?;
430    match ack {
431        ACK_OK => Ok(()),
432        ACK_REJECTED => Err(TransferError::Rejected),
433        _ => Err(TransferError::Protocol(
434            "invalid file-transfer acknowledgement".into(),
435        )),
436    }
437}
438
439pub async fn receive_to_directory<S, F>(
440    stream: &mut S,
441    directory: &Path,
442    limits: TransferLimits,
443    mut on_progress: F,
444) -> Result<ReceivedFile, TransferError>
445where
446    S: AsyncRead + AsyncWrite + Unpin,
447    F: FnMut(u64, u64),
448{
449    let result = receive_to_directory_inner(stream, directory, limits, &mut on_progress).await;
450    if result.is_err() {
451        let _ = stream.write_u8(ACK_REJECTED).await;
452        let _ = stream.flush().await;
453    }
454    result
455}
456
457async fn receive_to_directory_inner<S, F>(
458    stream: &mut S,
459    directory: &Path,
460    limits: TransferLimits,
461    on_progress: &mut F,
462) -> Result<ReceivedFile, TransferError>
463where
464    S: AsyncRead + AsyncWrite + Unpin,
465    F: FnMut(u64, u64),
466{
467    if limits.chunk_bytes == 0 {
468        return Err(TransferError::Limit("chunk size must be positive".into()));
469    }
470    let header = read_header(stream, limits).await?;
471    tokio::fs::create_dir_all(directory).await?;
472    let safe_name = sanitize_file_name(&header.name);
473    let destination = if header.offset > 0 {
474        directory.join(&safe_name)
475    } else {
476        unique_destination(directory, &safe_name).await?
477    };
478    let mut hasher = header.sha256.as_ref().map(|_| Sha256::new());
479    if header.offset > 0 {
480        let metadata = tokio::fs::metadata(&destination)
481            .await
482            .map_err(|_| TransferError::Protocol("resume destination does not exist".into()))?;
483        if metadata.len() != header.offset {
484            return Err(TransferError::Protocol(format!(
485                "resume destination length {} does not match offset {}",
486                metadata.len(),
487                header.offset
488            )));
489        }
490        if let Some(hasher) = hasher.as_mut() {
491            let mut existing = tokio::fs::File::open(&destination).await?;
492            let mut prefix = vec![0_u8; limits.chunk_bytes];
493            loop {
494                let read = existing.read(&mut prefix).await?;
495                if read == 0 {
496                    break;
497                }
498                hasher.update(&prefix[..read]);
499            }
500        }
501    }
502    let mut file = if header.offset > 0 {
503        tokio::fs::OpenOptions::new()
504            .append(true)
505            .open(&destination)
506            .await?
507    } else {
508        tokio::fs::OpenOptions::new()
509            .write(true)
510            .create_new(true)
511            .open(&destination)
512            .await?
513    };
514    let mut buffer = vec![0_u8; limits.chunk_bytes];
515    let mut received = header.offset;
516    on_progress(received, header.size);
517    while received < header.size {
518        let remaining = (header.size - received).min(buffer.len() as u64) as usize;
519        stream.read_exact(&mut buffer[..remaining]).await?;
520        file.write_all(&buffer[..remaining]).await?;
521        if let Some(hasher) = hasher.as_mut() {
522            hasher.update(&buffer[..remaining]);
523        }
524        received += remaining as u64;
525        on_progress(received, header.size);
526    }
527    file.flush().await?;
528    if let (Some(expected), Some(hasher)) = (&header.sha256, hasher) {
529        let actual = hex_digest(hasher.finalize().as_slice());
530        if !actual.eq_ignore_ascii_case(expected) {
531            drop(file);
532            let _ = tokio::fs::remove_file(&destination).await;
533            return Err(TransferError::Integrity);
534        }
535    }
536    stream.write_u8(ACK_OK).await?;
537    stream.flush().await?;
538    Ok(ReceivedFile {
539        header,
540        path: destination,
541    })
542}
543
544async fn write_header<S: AsyncWrite + Unpin>(
545    stream: &mut S,
546    header: &FileTransferHeader,
547    max_header_bytes: usize,
548) -> Result<(), TransferError> {
549    let encoded = serde_json::to_vec(header)?;
550    if encoded.is_empty() || encoded.len() > max_header_bytes || encoded.len() > u32::MAX as usize {
551        return Err(TransferError::Limit(format!(
552            "header exceeds {max_header_bytes} bytes"
553        )));
554    }
555    stream.write_u32(encoded.len() as u32).await?;
556    stream.write_all(&encoded).await?;
557    Ok(())
558}
559
560async fn read_header<S: AsyncRead + Unpin>(
561    stream: &mut S,
562    limits: TransferLimits,
563) -> Result<FileTransferHeader, TransferError> {
564    let length = stream.read_u32().await? as usize;
565    if length == 0 || length > limits.max_header_bytes {
566        return Err(TransferError::Limit(format!(
567            "invalid header length {length}"
568        )));
569    }
570    let mut encoded = vec![0_u8; length];
571    stream.read_exact(&mut encoded).await?;
572    let header: FileTransferHeader = serde_json::from_slice(&encoded)?;
573    header.validate(limits)?;
574    Ok(header)
575}
576
577async fn hash_file(
578    file: &mut tokio::fs::File,
579    chunk_bytes: usize,
580) -> Result<String, TransferError> {
581    let mut hasher = Sha256::new();
582    let mut buffer = vec![0_u8; chunk_bytes];
583    loop {
584        let read = file.read(&mut buffer).await?;
585        if read == 0 {
586            break;
587        }
588        hasher.update(&buffer[..read]);
589    }
590    Ok(hex_digest(hasher.finalize().as_slice()))
591}
592
593fn hex_digest(bytes: &[u8]) -> String {
594    let mut output = String::with_capacity(bytes.len() * 2);
595    for byte in bytes {
596        use std::fmt::Write as _;
597        let _ = write!(output, "{byte:02x}");
598    }
599    output
600}
601
602fn is_zero(value: &u64) -> bool {
603    *value == 0
604}
605
606pub fn sanitize_file_name(value: &str) -> String {
607    let candidate = value.replace('\\', "/");
608    let name = candidate.rsplit('/').next().unwrap_or("").trim();
609    let sanitized: String = name
610        .chars()
611        .map(|character| {
612            if character.is_control() || character == '\0' {
613                '_'
614            } else {
615                character
616            }
617        })
618        .take(240)
619        .collect();
620    match sanitized.as_str() {
621        "" | "." | ".." => "transfer.bin".to_string(),
622        _ => sanitized,
623    }
624}
625
626async fn unique_destination(directory: &Path, file_name: &str) -> Result<PathBuf, TransferError> {
627    let original = Path::new(file_name);
628    let stem = original
629        .file_stem()
630        .and_then(|value| value.to_str())
631        .unwrap_or("transfer");
632    let extension = original.extension().and_then(|value| value.to_str());
633    for suffix in 0..10_000_u32 {
634        let name = if suffix == 0 {
635            file_name.to_string()
636        } else if let Some(extension) = extension {
637            format!("{stem} ({suffix}).{extension}")
638        } else {
639            format!("{stem} ({suffix})")
640        };
641        let candidate = directory.join(name);
642        if !tokio::fs::try_exists(&candidate).await? {
643            return Ok(candidate);
644        }
645    }
646    Err(TransferError::Limit(
647        "unable to allocate a unique destination name".into(),
648    ))
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654
655    #[tokio::test]
656    async fn round_trip_streams_and_verifies_a_file() {
657        let source_dir = tempfile::tempdir().unwrap();
658        let destination_dir = tempfile::tempdir().unwrap();
659        let source = source_dir.path().join("plane.webp");
660        tokio::fs::write(&source, [1_u8, 2, 3, 4, 5]).await.unwrap();
661        let (mut sender, mut receiver) = tokio::io::duplex(256 * 1024);
662        let mut header = FileTransferHeader::new("plane.webp", 0, "image/webp");
663        header.transfer_id = "transfer-1".to_string();
664        header.sha256 = Some(String::new());
665        let limits = TransferLimits::default();
666
667        let send = send_path(&mut sender, &source, header, limits, |_, _| {});
668        let receive =
669            receive_to_directory(&mut receiver, destination_dir.path(), limits, |_, _| {});
670        let (sent, received) = tokio::join!(send, receive);
671        sent.unwrap();
672        let received = received.unwrap();
673        assert_eq!(received.header.transfer_id, "transfer-1");
674        assert_eq!(
675            tokio::fs::read(received.path).await.unwrap(),
676            [1, 2, 3, 4, 5]
677        );
678    }
679
680    #[test]
681    fn sanitizes_cross_platform_path_traversal() {
682        assert_eq!(sanitize_file_name("../../secret.txt"), "secret.txt");
683        assert_eq!(
684            sanitize_file_name(r"C:\\Users\\name\\secret.txt"),
685            "secret.txt"
686        );
687        assert_eq!(sanitize_file_name(".."), "transfer.bin");
688    }
689
690    #[tokio::test]
691    async fn rejects_oversized_headers_before_creating_a_file() {
692        let destination = tempfile::tempdir().unwrap();
693        let (mut sender, mut receiver) = tokio::io::duplex(1024);
694        sender.write_u32(1024).await.unwrap();
695        sender.flush().await.unwrap();
696        let limits = TransferLimits {
697            max_header_bytes: 16,
698            ..TransferLimits::default()
699        };
700        let error = receive_to_directory(&mut receiver, destination.path(), limits, |_, _| {})
701            .await
702            .unwrap_err();
703        assert!(matches!(error, TransferError::Limit(_)));
704        assert_eq!(std::fs::read_dir(destination.path()).unwrap().count(), 0);
705    }
706
707    #[tokio::test]
708    async fn resumes_an_existing_verified_prefix() {
709        let source_dir = tempfile::tempdir().unwrap();
710        let destination_dir = tempfile::tempdir().unwrap();
711        let source = source_dir.path().join("resume.bin");
712        let destination = destination_dir.path().join("resume.bin");
713        tokio::fs::write(&source, [1_u8, 2, 3, 4, 5]).await.unwrap();
714        tokio::fs::write(&destination, [1_u8, 2]).await.unwrap();
715        let (mut sender, mut receiver) = tokio::io::duplex(256 * 1024);
716        let mut header = FileTransferHeader::new("resume.bin", 0, "application/octet-stream");
717        header.transfer_id = "resume-1".to_string();
718        header.offset = 2;
719        header.sha256 = Some(String::new());
720        let limits = TransferLimits::default();
721
722        let send = send_path(&mut sender, &source, header, limits, |_, _| {});
723        let receive =
724            receive_to_directory(&mut receiver, destination_dir.path(), limits, |_, _| {});
725        let (sent, received) = tokio::join!(send, receive);
726        sent.unwrap();
727        assert_eq!(received.unwrap().header.offset, 2);
728        assert_eq!(tokio::fs::read(destination).await.unwrap(), [1, 2, 3, 4, 5]);
729    }
730
731    #[test]
732    fn routed_frames_round_trip_offsets_chunks_and_completion() {
733        let limits = TransferLimits {
734            chunk_bytes: DEFAULT_ROUTED_CHUNK_BYTES,
735            ..TransferLimits::default()
736        };
737        let mut header = FileTransferHeader::new("route.bin", 99, "application/octet-stream");
738        header.transfer_id = "route-transfer-1".to_string();
739        let frames = vec![
740            RoutedTransferFrame::Offer(header),
741            RoutedTransferFrame::Resume {
742                transfer_id: "route-transfer-1".to_string(),
743                offset: 24,
744            },
745            RoutedTransferFrame::Chunk {
746                transfer_id: "route-transfer-1".to_string(),
747                offset: 24,
748                ack_requested: true,
749                bytes: vec![1, 2, 3],
750            },
751            RoutedTransferFrame::Ack {
752                transfer_id: "route-transfer-1".to_string(),
753                offset: 27,
754            },
755            RoutedTransferFrame::Complete {
756                transfer_id: "route-transfer-1".to_string(),
757                size: 99,
758            },
759            RoutedTransferFrame::Completed {
760                transfer_id: "route-transfer-1".to_string(),
761                size: 99,
762            },
763            RoutedTransferFrame::Reject {
764                transfer_id: "route-transfer-1".to_string(),
765                reason: "ACL changed".to_string(),
766            },
767        ];
768
769        for frame in frames {
770            let encoded = frame.encode(limits).unwrap();
771            assert_eq!(
772                RoutedTransferFrame::decode(&encoded, limits).unwrap(),
773                frame
774            );
775        }
776    }
777
778    #[test]
779    fn routed_chunk_rejects_oversized_payloads() {
780        let limits = TransferLimits {
781            chunk_bytes: 4,
782            ..TransferLimits::default()
783        };
784        let frame = RoutedTransferFrame::Chunk {
785            transfer_id: "route-transfer-1".to_string(),
786            offset: 0,
787            ack_requested: true,
788            bytes: vec![0; 5],
789        };
790        assert!(matches!(frame.encode(limits), Err(TransferError::Limit(_))));
791    }
792}