use std::time::Instant;
use sha2::{Digest, Sha256};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::broadcast;
use tracing::{info, warn};
use crate::network::NetworkProvider;
use crate::node::Node;
use super::types::{
FileTransferEvent, FtMessage, TransferDirection, TransferError, TransferProgress,
TransferResult,
};
pub async fn send_file<N: NetworkProvider + 'static>(
node: &Node<N>,
peer_id: &str,
local_path: &str,
remote_path: &str,
max_transfer_size: u64,
event_tx: &broadcast::Sender<FileTransferEvent>,
) -> Result<TransferResult, TransferError> {
let start = Instant::now();
let peer_id = node
.resolve_peer_id(peer_id)
.await
.map_err(|e| TransferError::Node(e.to_string()))?;
let peer_id = peer_id.as_str();
let metadata = tokio::fs::metadata(local_path)
.await
.map_err(TransferError::Io)?;
let file_size = metadata.len();
if file_size > max_transfer_size {
return Err(TransferError::Protocol(format!(
"File size ({} bytes) exceeds max transfer size ({} bytes)",
file_size, max_transfer_size
)));
}
info!(
path = local_path,
size = file_size,
"Hashing file (streaming)"
);
let token = uuid::Uuid::new_v4().to_string();
let sha256 = {
let mut hasher = Sha256::new();
let mut file = tokio::fs::File::open(local_path)
.await
.map_err(TransferError::Io)?;
let mut buf = vec![0u8; 64 * 1024];
let mut bytes_hashed: u64 = 0;
let mut last_event = Instant::now();
loop {
let n = file.read(&mut buf).await.map_err(TransferError::Io)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
bytes_hashed += n as u64;
if last_event.elapsed() >= std::time::Duration::from_millis(250) {
let _ = event_tx.send(FileTransferEvent::Hashing {
token: token.clone(),
file_name: std::path::Path::new(local_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
.to_string(),
bytes_hashed,
total_bytes: file_size,
});
last_event = Instant::now();
}
}
hex::encode(hasher.finalize())
};
let file_name = std::path::Path::new(local_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
.to_string();
let _ = event_tx.send(FileTransferEvent::Hashing {
token: token.clone(),
file_name: file_name.clone(),
bytes_hashed: file_size,
total_bytes: file_size,
});
let offer = FtMessage::Offer {
file_name: file_name.clone(),
size: file_size,
sha256: sha256.clone(),
save_path: remote_path.to_string(),
token: token.clone(),
tcp_port: 0,
};
let offer_payload = serde_json::to_value(&offer)
.map_err(|e| TransferError::Protocol(format!("Failed to serialize offer: {e}")))?;
info!(
peer = peer_id,
file = file_name.as_str(),
size = file_size,
"Sending OFFER"
);
let _ = event_tx.send(FileTransferEvent::WaitingForAccept {
token: token.clone(),
file_name: file_name.clone(),
});
let accept_port = crate::request_reply::send_and_wait(
node,
peer_id,
"ft",
"offer",
&offer_payload,
std::time::Duration::from_secs(60),
|msg| {
if msg.from != peer_id {
return None;
}
let ft_msg: FtMessage = serde_json::from_value(msg.payload.clone()).ok()?;
match ft_msg {
FtMessage::Accept {
token: ref t,
tcp_port,
} if *t == token => Some(Ok(tcp_port)),
FtMessage::Reject {
token: ref t,
reason,
} if *t == token => Some(Err(TransferError::Rejected(reason))),
_ => None,
}
},
)
.await
.map_err(|e| match e {
crate::request_reply::RequestError::Timeout => TransferError::Timeout,
crate::request_reply::RequestError::Send(e) => TransferError::Node(e.to_string()),
crate::request_reply::RequestError::ChannelClosed => {
TransferError::Protocol("Channel closed".into())
}
})?
.map_err(|e| e)?;
info!(tcp_port = accept_port, "Received ACCEPT from peer");
info!(
accept_port = accept_port,
peer = peer_id,
"Opening TCP stream to peer"
);
let mut stream = tokio::time::timeout(
std::time::Duration::from_secs(30),
node.open_tcp(peer_id, accept_port),
)
.await
.map_err(|_| TransferError::Timeout)?
.map_err(|e| TransferError::Node(format!("Failed to open TCP to port {accept_port}: {e}")))?;
info!(port = accept_port, "TCP stream opened to peer");
stream.write_all(&file_size.to_be_bytes()).await?;
stream.write_all(sha256.as_bytes()).await?;
let mut file = tokio::fs::File::open(local_path)
.await
.map_err(TransferError::Io)?;
let chunk_size = 64 * 1024;
let mut buf = vec![0u8; chunk_size];
let mut bytes_sent: u64 = 0;
let progress_start = Instant::now();
let mut last_progress = Instant::now();
loop {
let n = file.read(&mut buf).await.map_err(TransferError::Io)?;
if n == 0 {
break;
}
stream.write_all(&buf[..n]).await?;
bytes_sent += n as u64;
if last_progress.elapsed() >= std::time::Duration::from_millis(250) {
let elapsed = progress_start.elapsed().as_secs_f64();
let speed = if elapsed > 0.0 {
bytes_sent as f64 / elapsed
} else {
0.0
};
let _ = event_tx.send(FileTransferEvent::Progress(TransferProgress {
token: token.clone(),
direction: TransferDirection::Send,
file_name: file_name.clone(),
bytes_transferred: bytes_sent,
total_bytes: file_size,
speed_bps: speed,
}));
last_progress = Instant::now();
}
}
let elapsed = progress_start.elapsed().as_secs_f64();
let speed = if elapsed > 0.0 {
bytes_sent as f64 / elapsed
} else {
0.0
};
let _ = event_tx.send(FileTransferEvent::Progress(TransferProgress {
token: token.clone(),
direction: TransferDirection::Send,
file_name: file_name.clone(),
bytes_transferred: bytes_sent,
total_bytes: file_size,
speed_bps: speed,
}));
stream.flush().await?;
let mut ack = [0u8; 1];
let ack_result = tokio::time::timeout(
std::time::Duration::from_secs(10),
stream.read_exact(&mut ack),
)
.await;
match ack_result {
Ok(Ok(_)) => {
if ack[0] != 0x01 {
return Err(TransferError::IntegrityError {
expected: sha256.clone(),
actual: "receiver reported integrity failure".to_string(),
});
}
}
Ok(Err(e)) => {
warn!("ACK read failed (bridge may have closed early): {e}");
}
Err(_timeout) => {
warn!("ACK read timed out after 10s (bridge may have closed early)");
}
}
let elapsed = start.elapsed().as_secs_f64();
info!(
bytes = file_size,
elapsed_ms = (elapsed * 1000.0) as u64,
"Upload complete"
);
let _ = event_tx.send(FileTransferEvent::Completed {
token,
direction: TransferDirection::Send,
file_name,
bytes_transferred: file_size,
sha256: sha256.clone(),
elapsed_secs: elapsed,
});
Ok(TransferResult {
bytes_transferred: file_size,
sha256,
elapsed_secs: elapsed,
})
}