use serde::{Deserialize, Serialize};
use std::path::Path;
pub const DEFAULT_CHUNK_SIZE: usize = 32768;
pub const MAX_TRANSFER_SIZE: u64 = 1_073_741_824;
pub fn total_chunks_for_size(size: u64, chunk_size: usize) -> u64 {
if size == 0 || chunk_size == 0 {
0
} else {
size.div_ceil(chunk_size as u64)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileOffer {
pub transfer_id: String,
pub filename: String,
pub size: u64,
pub sha256: String,
pub chunk_size: usize,
pub total_chunks: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileChunk {
pub transfer_id: String,
pub sequence: u64,
pub data: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileComplete {
pub transfer_id: String,
pub sha256: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum TransferDirection {
Sending,
Receiving,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum TransferStatus {
Pending,
InProgress,
Complete,
Failed,
Rejected,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransferState {
pub transfer_id: String,
pub direction: TransferDirection,
pub remote_agent_id: String,
pub filename: String,
pub total_size: u64,
pub bytes_transferred: u64,
pub status: TransferStatus,
pub sha256: String,
pub error: Option<String>,
pub started_at: u64,
#[serde(default)]
pub started_at_unix_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completed_at_unix_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_path: Option<String>,
#[serde(default = "default_chunk_size")]
pub chunk_size: usize,
#[serde(default)]
pub total_chunks: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileChunkValidationError {
WrongDirection,
WrongStatus,
WrongSender,
}
pub fn receive_chunk_expected_sequence(
transfer: &TransferState,
sender_agent_id: &str,
) -> Result<u64, FileChunkValidationError> {
if transfer.direction != TransferDirection::Receiving {
return Err(FileChunkValidationError::WrongDirection);
}
if transfer.status != TransferStatus::InProgress {
return Err(FileChunkValidationError::WrongStatus);
}
if transfer.remote_agent_id != sender_agent_id {
return Err(FileChunkValidationError::WrongSender);
}
Ok(if transfer.chunk_size > 0 {
transfer.bytes_transferred / transfer.chunk_size as u64
} else {
0
})
}
pub fn received_file_base_name(raw_filename: &str, fallback: &str) -> String {
Path::new(raw_filename)
.file_name()
.map(|name| name.to_string_lossy().to_string())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| fallback.to_string())
}
pub fn received_file_output_name(transfer_id: &str, raw_filename: &str) -> String {
let base_name = received_file_base_name(raw_filename, transfer_id);
let id_prefix = transfer_id.get(..8).unwrap_or(transfer_id);
format!("{id_prefix}_{base_name}")
}
fn default_chunk_size() -> usize {
DEFAULT_CHUNK_SIZE
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum FileMessage {
#[serde(rename = "file-offer")]
Offer(FileOffer),
#[serde(rename = "file-chunk")]
Chunk(FileChunk),
#[serde(rename = "file-complete")]
Complete(FileComplete),
#[serde(rename = "file-accept")]
Accept {
transfer_id: String,
},
#[serde(rename = "file-reject")]
Reject {
transfer_id: String,
reason: String,
},
#[serde(rename = "file-chunk-ack")]
ChunkAck {
transfer_id: String,
sequence: u64,
},
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
#[test]
fn default_chunk_size_value() {
assert_eq!(default_chunk_size(), DEFAULT_CHUNK_SIZE);
assert_eq!(DEFAULT_CHUNK_SIZE, 32768);
}
#[test]
fn max_transfer_size_value() {
assert_eq!(MAX_TRANSFER_SIZE, 1_073_741_824);
}
#[test]
fn file_offer_roundtrip() {
let offer = FileOffer {
transfer_id: "transfer-123".to_string(),
filename: "test.txt".to_string(),
size: 1024,
sha256: "abc123".to_string(),
chunk_size: 32768,
total_chunks: 1,
};
let json = serde_json::to_string(&offer).unwrap();
let decoded: FileOffer = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.transfer_id, "transfer-123");
assert_eq!(decoded.filename, "test.txt");
assert_eq!(decoded.size, 1024);
}
#[test]
fn file_chunk_roundtrip() {
let chunk = FileChunk {
transfer_id: "transfer-123".to_string(),
sequence: 0,
data: base64::engine::general_purpose::STANDARD.encode(b"hello world"),
};
let json = serde_json::to_string(&chunk).unwrap();
let decoded: FileChunk = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.transfer_id, "transfer-123");
assert_eq!(decoded.sequence, 0);
}
#[test]
fn file_complete_roundtrip() {
let complete = FileComplete {
transfer_id: "transfer-123".to_string(),
sha256: "abc123".to_string(),
};
let json = serde_json::to_string(&complete).unwrap();
let decoded: FileComplete = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.transfer_id, "transfer-123");
}
#[test]
fn transfer_direction_display() {
assert_eq!(TransferDirection::Sending as u8, 0);
assert_eq!(TransferDirection::Receiving as u8, 1);
}
#[test]
fn transfer_status_variants() {
assert_eq!(TransferStatus::Pending as u8, 0);
assert_eq!(TransferStatus::InProgress as u8, 1);
assert_eq!(TransferStatus::Complete as u8, 2);
assert_eq!(TransferStatus::Failed as u8, 3);
assert_eq!(TransferStatus::Rejected as u8, 4);
}
#[test]
fn transfer_state_roundtrip() {
let state = TransferState {
transfer_id: "transfer-123".to_string(),
direction: TransferDirection::Sending,
remote_agent_id: "agent-456".to_string(),
filename: "test.txt".to_string(),
total_size: 1024,
bytes_transferred: 512,
status: TransferStatus::InProgress,
sha256: "abc123".to_string(),
error: None,
started_at: 1000,
started_at_unix_ms: 1_000_000,
completed_at_unix_ms: None,
source_path: Some("/tmp/test.txt".to_string()),
output_path: None,
chunk_size: 32768,
total_chunks: 1,
};
let json = serde_json::to_string(&state).unwrap();
let decoded: TransferState = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.transfer_id, "transfer-123");
assert_eq!(decoded.direction, TransferDirection::Sending);
assert_eq!(decoded.status, TransferStatus::InProgress);
assert_eq!(decoded.chunk_size, 32768);
}
#[test]
fn file_message_offer_roundtrip() {
let offer = FileOffer {
transfer_id: "t1".to_string(),
filename: "f.txt".to_string(),
size: 100,
sha256: "hash".to_string(),
chunk_size: 32768,
total_chunks: 1,
};
let msg = FileMessage::Offer(offer);
let json = serde_json::to_string(&msg).unwrap();
let decoded: FileMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, FileMessage::Offer(_)));
}
#[test]
fn file_message_accept_roundtrip() {
let msg = FileMessage::Accept {
transfer_id: "t1".to_string(),
};
let json = serde_json::to_string(&msg).unwrap();
let decoded: FileMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, FileMessage::Accept { .. }));
}
#[test]
fn file_message_reject_roundtrip() {
let msg = FileMessage::Reject {
transfer_id: "t1".to_string(),
reason: "too big".to_string(),
};
let json = serde_json::to_string(&msg).unwrap();
let decoded: FileMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, FileMessage::Reject { .. }));
}
#[test]
fn file_message_chunk_ack_roundtrip() {
let msg = FileMessage::ChunkAck {
transfer_id: "t1".to_string(),
sequence: 5,
};
let json = serde_json::to_string(&msg).unwrap();
let decoded: FileMessage = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, FileMessage::ChunkAck { .. }));
}
}