1use 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#[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}