use std::path::{Path, PathBuf};
use std::sync::Arc;
use bytes::Bytes;
use futures_util::StreamExt;
use sha2::{Digest, Sha256};
use zyris::{Chunk, ErrorCode, Result, Streaming, WireError};
use zyris_caps::peer_transfer::{
PeerTransfer, PeerTransferClient, PullHead, TransferDone, TransferOffer,
};
use super::audit::{Audit, AuditLine};
use super::inbox::Inbox;
use super::undo::UndoStore;
const CHUNK_SIZE: usize = 64 * 1024;
pub fn part_path(dest: &Path, transfer_id: &str) -> PathBuf {
let marker = hex::encode(Sha256::digest(transfer_id.as_bytes()));
let tail = format!(".{}.part", &marker[..16]);
let name = dest
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_string());
let keep_bytes = super::name::MAX_BYTES.saturating_sub(tail.len());
let mut stem = String::new();
for c in name.chars() {
if stem.len() + c.len_utf8() > keep_bytes {
break;
}
stem.push(c);
}
let mut temp = dest.with_file_name(format!("{stem}{tail}"));
if temp == dest {
stem.pop();
temp = dest.with_file_name(format!("{stem}{tail}"));
}
temp
}
pub const TRANSFER_IN_FLIGHT: &str = "transfer_in_flight";
#[derive(Debug, Clone, Default)]
pub struct InFlight(Arc<std::sync::Mutex<std::collections::HashSet<String>>>);
impl InFlight {
pub fn claim(&self, transfer_id: &str) -> Option<InFlightClaim> {
let mut held = self.0.lock().unwrap_or_else(|e| e.into_inner());
held.insert(transfer_id.to_string()).then(|| InFlightClaim {
held: self.clone(),
transfer_id: transfer_id.to_string(),
})
}
}
pub struct InFlightClaim {
held: InFlight,
transfer_id: String,
}
impl Drop for InFlightClaim {
fn drop(&mut self) {
let mut held = self.held.0.lock().unwrap_or_else(|e| e.into_inner());
held.remove(&self.transfer_id);
}
}
#[derive(Debug, Clone)]
pub struct TransferConfig {
pub inbox: PathBuf,
pub undo: PathBuf,
pub audit: Option<PathBuf>,
pub max_file_bytes: u64,
pub max_inbox_bytes: u64,
pub in_flight: InFlight,
}
impl Default for TransferConfig {
fn default() -> TransferConfig {
TransferConfig {
inbox: PathBuf::from("."),
undo: PathBuf::from("."),
audit: None,
max_file_bytes: 8 * 1024 * 1024 * 1024,
max_inbox_bytes: 32 * 1024 * 1024 * 1024,
in_flight: InFlight::default(),
}
}
}
#[derive(Clone)]
struct ToSend {
transfer_id: String,
path: PathBuf,
size: u64,
sha256: String,
}
#[derive(Clone)]
pub struct LocalPeerTransfer {
config: TransferConfig,
peer: Arc<std::sync::OnceLock<PeerTransferClient>>,
peer_slug: String,
pending: Arc<tokio::sync::Mutex<Vec<ToSend>>>,
}
impl LocalPeerTransfer {
pub fn receiver_pending(config: TransferConfig, peer_slug: String) -> LocalPeerTransfer {
LocalPeerTransfer {
config,
peer: Arc::new(std::sync::OnceLock::new()),
peer_slug,
pending: Arc::new(tokio::sync::Mutex::new(Vec::new())),
}
}
pub fn set_peer(&self, client: PeerTransferClient) {
let _ = self.peer.set(client);
}
pub fn sender(root: PathBuf) -> LocalPeerTransfer {
LocalPeerTransfer {
config: TransferConfig { inbox: root.clone(), undo: root, ..Default::default() },
peer: Arc::new(std::sync::OnceLock::new()),
peer_slug: String::new(),
pending: Arc::new(tokio::sync::Mutex::new(Vec::new())),
}
}
pub async fn offer_file(&self, transfer_id: String, path: PathBuf, size: u64, sha256: String) {
self.pending.lock().await.push(ToSend { transfer_id, path, size, sha256 });
}
}
#[async_trait::async_trait]
impl PeerTransfer for LocalPeerTransfer {
async fn push_offer(&self, offer: TransferOffer) -> Result<TransferDone> {
if offer.size > self.config.max_file_bytes {
return Err(WireError::new(
ErrorCode::PayloadTooLarge,
format!(
"{} bytes exceeds this node's limit of {} bytes",
offer.size, self.config.max_file_bytes
),
)
.retriable(false));
}
let Some(_claim) = self.config.in_flight.claim(&offer.transfer_id) else {
return Err(WireError::new(
ErrorCode::Other(TRANSFER_IN_FLIGHT.to_string()),
format!(
"{} is already being received on this node. Its bytes are still arriving; \
call again once that settles.",
offer.name
),
)
.retriable(true));
};
let peer = self.peer.get().ok_or_else(|| {
WireError::internal("this node is not set up as a receiver".to_string())
})?;
let inbox = Inbox::new(&self.config.inbox);
let dest = inbox
.resolve(&self.peer_slug, &offer.name)
.await
.map_err(|e| WireError::internal(e.to_string()))?;
if tokio::fs::symlink_metadata(&dest).await.is_ok() && !offer.overwrite {
return Err(WireError::new(
ErrorCode::InvalidParams,
format!("{} already exists. Enable overwrite to replace it", dest.display()),
)
.retriable(false));
}
let temp = part_path(&dest, &offer.transfer_id);
if let Ok(info) = tokio::fs::symlink_metadata(&temp).await {
if info.file_type().is_symlink() {
return Err(WireError::new(
ErrorCode::InvalidParams,
"the temp file location is a symbolic link".to_string(),
)
.retriable(false));
}
}
let file_len = tokio::fs::metadata(&temp).await.map(|m| m.len()).unwrap_or(0);
let received_offset = if file_len > offer.size { 0 } else { file_len };
let mut stream = peer.pull(offer.transfer_id.clone(), received_offset).await?;
if stream.head.sha256 != offer.sha256 || stream.head.size != offer.size {
return Err(WireError::internal(
"the sending side is trying to hand over something different from the offer".to_string(),
));
}
let mut hasher = Sha256::new();
if received_offset > 0 {
use tokio::io::AsyncReadExt as _;
let mut already_received =
temp_open_options().read(true).open(&temp).await.map_err(io_error)?;
let mut buf = vec![0u8; CHUNK_SIZE];
loop {
let n = already_received.read(&mut buf).await.map_err(io_error)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
}
let mut open = temp_open_options();
open.create(true);
if received_offset > 0 {
open.append(true);
} else {
open.write(true).truncate(true);
}
let mut file = open.open(&temp).await.map_err(io_error)?;
use tokio::io::AsyncWriteExt;
let mut written = received_offset;
while let Some(chunk) = stream.items.next().await {
let Chunk(bytes) = chunk?;
written += bytes.len() as u64;
if written > offer.size {
let _ = tokio::fs::remove_file(&temp).await;
return Err(WireError::internal("sent more than the declared size".to_string()));
}
hasher.update(&bytes);
file.write_all(&bytes).await.map_err(io_error)?;
}
file.flush().await.map_err(io_error)?;
drop(file);
let actual = hex::encode(hasher.finalize());
if actual != offer.sha256 {
let _ = tokio::fs::remove_file(&temp).await;
return Err(WireError::new(
ErrorCode::Internal,
format!("sha256 mismatch: {actual} ≠ {}", offer.sha256),
)
.retriable(true));
}
let on_disk = tokio::fs::metadata(&temp).await.map(|m| m.len()).unwrap_or(0);
if on_disk != offer.size {
let _ = tokio::fs::remove_file(&temp).await;
return Err(WireError::internal(format!(
"{on_disk} bytes are on disk but {} were offered; not renaming it into place",
offer.size
))
.retriable(true));
}
let already_exists = tokio::fs::symlink_metadata(&dest).await.is_ok();
if already_exists && !offer.overwrite {
let _ = tokio::fs::remove_file(&temp).await;
return Err(WireError::new(
ErrorCode::InvalidParams,
format!(
"{} appeared during the transfer. Enable overwrite to replace it",
dest.display()
),
)
.retriable(false));
}
let undo = if already_exists {
UndoStore::new(&self.config.undo).stash(&dest, now_ms()).await
} else {
None
};
tokio::fs::rename(&temp, &dest).await.map_err(io_error)?;
strip_exec_bits(&dest).await;
let result = TransferDone {
written: dest.display().to_string(),
bytes: written,
sha256: actual,
replaced: already_exists,
undo: undo.map(|p| p.display().to_string()),
};
if let Some(audit_path) = &self.config.audit {
Audit::new(audit_path)
.record(AuditLine {
at_ms: now_ms(),
peer_slug: self.peer_slug.clone(),
peer_endpoint: String::new(),
name: offer.name.clone(),
bytes: result.bytes,
sha256: result.sha256.clone(),
written: result.written.clone(),
replaced: result.replaced,
undo: result.undo.clone(),
direct: false,
})
.await;
}
Ok(result)
}
async fn pull(&self, transfer_id: String, offset: u64) -> Result<Streaming<PullHead, Chunk>> {
let to_send = self
.pending
.lock()
.await
.iter()
.find(|p| p.transfer_id == transfer_id)
.cloned()
.ok_or_else(|| {
WireError::new(ErrorCode::InvalidParams, format!("unknown transfer: {transfer_id}"))
.retriable(false)
})?;
let head = PullHead { size: to_send.size, sha256: to_send.sha256.clone() };
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let mut file = tokio::fs::File::open(&to_send.path).await.map_err(io_error)?;
if offset > 0 {
file.seek(std::io::SeekFrom::Start(offset)).await.map_err(io_error)?;
}
let items =
futures_util::stream::unfold((file, false), move |(mut file, done)| async move {
if done {
return None;
}
let mut buf = vec![0u8; CHUNK_SIZE];
match file.read(&mut buf).await {
Ok(0) => None,
Ok(n) => {
buf.truncate(n);
Some((Ok(Chunk(Bytes::from(buf))), (file, false)))
}
Err(e) => Some((Err(io_error(e)), (file, true))),
}
});
Ok(Streaming::new(head, items))
}
}
fn io_error(e: std::io::Error) -> WireError {
WireError::new(ErrorCode::Internal, e.to_string())
}
#[cfg_attr(not(unix), allow(unused_mut))]
fn temp_open_options() -> tokio::fs::OpenOptions {
let mut options = tokio::fs::OpenOptions::new();
#[cfg(unix)]
options.custom_flags(libc::O_NOFOLLOW);
options
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
async fn strip_exec_bits(path: &std::path::Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await;
}
#[cfg(not(unix))]
let _ = path;
}
#[cfg(test)]
mod tests {
use std::path::Path;
use sha2::{Digest, Sha256};
use super::part_path;
use super::super::name::{safe_name, MAX_BYTES};
fn name_with_tail(transfer_id: &str) -> String {
let marker = hex::encode(Sha256::digest(transfer_id.as_bytes()));
let tail = format!(".{}.part", &marker[..16]);
format!("{}{}", "A".repeat(MAX_BYTES - tail.len()), tail)
}
#[test]
fn same_transfer_same_slot_different_transfer_different_slot() {
let dest = Path::new("/inbox/a/same.bin");
assert_eq!(part_path(dest, "t1"), part_path(dest, "t1"));
assert_ne!(part_path(dest, "t1"), part_path(dest, "t2"));
assert_ne!(part_path(dest, "t1"), dest);
assert_ne!(part_path(Path::new("/inbox/a/x.part"), "t1"), Path::new("/inbox/a/x.part"));
assert_eq!(part_path(dest, "t1").parent(), dest.parent());
}
#[test]
fn whatever_the_peer_sends_exactly_one_path_component_survives() {
let dest = Path::new("/inbox/a/x.bin");
let temp = part_path(dest, "../../etc/passwd\0\n/..");
assert_eq!(temp.parent(), dest.parent(), "actual: {}", temp.display());
let name = temp.file_name().unwrap().to_str().unwrap();
assert!(!name.contains('/') && !name.contains('\\'), "actual: {name}");
}
#[test]
fn planting_the_suffix_at_the_end_of_the_name_still_keeps_temp_and_destination_apart() {
let name = name_with_tail("t-evil");
assert_eq!(name.len(), MAX_BYTES, "must fill 255 bytes exactly for truncation to occur");
assert_eq!(safe_name(&name), name);
let dest = Path::new("/inbox/a").join(&name);
let temp = part_path(&dest, "t-evil");
assert_ne!(temp, dest, "actual: {}", temp.display());
assert_eq!(temp.parent(), dest.parent());
let temp_name = temp.file_name().unwrap().to_str().unwrap();
assert!(temp_name.len() <= MAX_BYTES, "{} bytes", temp_name.len());
assert!(temp_name.ends_with(".part"), "actual: {temp_name}");
}
#[test]
fn name_filling_the_limit_exactly_still_stays_within_255_bytes() {
let long_name = "가".repeat(85); let dest = Path::new("/inbox/a").join(&long_name);
let name = part_path(&dest, "t1").file_name().unwrap().to_str().unwrap().to_string();
assert!(name.len() <= 255, "{} bytes", name.len());
assert!(name.ends_with(".part"), "actual: {name}");
}
}