use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::Arc;
use std::time::Duration;
use sha2::{Digest, Sha256};
use zyris::{ErrorCode, Node, NodeKind, Result, WireError};
use zyris_attacca::{AttaccaApi, AttaccaApiClient, ZPeerAddr};
use zyris_caps::file_transfer::{FileTransfer, InboxEntry, SendReceipt};
use zyris_caps::peer_transfer::{
PeerTransfer, PeerTransferClient, PeerTransferServer, TransferOffer,
};
use zyris_p2p::fingerprint::PeerConfirmer;
use zyris_p2p::iroh;
use zyris_p2p::tofu::{TofuError, TofuStore};
use super::peer::{LocalPeerTransfer, TRANSFER_IN_FLIGHT};
use super::rendezvous::Rendezvous;
pub const DEFAULT_WIRE_DEADLINE: Duration = Duration::from_secs(55);
const CAPABILITY_WAIT: Duration = Duration::from_secs(5);
const HASH_CHUNK: usize = 64 * 1024;
const TRANSFER_ID_BYTES: usize = 16;
mod phase {
pub const MEASURING: u8 = 0;
pub const REACHING: u8 = 1;
pub const SENDING: u8 = 2;
}
pub struct PeerSession {
pub client: PeerTransferClient,
pub connection: zyris::Connection,
}
#[async_trait::async_trait]
pub trait PeerLink: Send + Sync {
async fn open(&self, addr: &ZPeerAddr, sender: LocalPeerTransfer) -> Result<PeerSession>;
async fn is_direct(&self, endpoint_id: &str) -> bool {
let _ = endpoint_id;
false
}
}
pub struct IrohPeerLink {
endpoint: iroh::Endpoint,
}
impl IrohPeerLink {
pub fn new(endpoint: iroh::Endpoint) -> IrohPeerLink {
IrohPeerLink { endpoint }
}
async fn dial(&self, addr: &ZPeerAddr) -> Result<zyris_p2p::transport::IrohTransport> {
let hinted = endpoint_addr(addr)?;
let hinted_at_something = hinted.ip_addrs().next().is_some() || hinted.relay_urls().next().is_some();
let first = match zyris_p2p::peer::dial(&self.endpoint, hinted).await {
Ok(transport) => return Ok(transport),
Err(e) if !hinted_at_something => {
return Err(refuse("peer_unreachable", e.to_string()).retriable(true));
}
Err(e) => e,
};
let id = addr.endpoint_id.parse::<iroh::EndpointId>().map_err(|e| {
refuse("peer_unreachable", format!("{} is not a usable endpoint id: {e}", addr.endpoint_id))
})?;
tracing::debug!(
endpoint_id = %addr.endpoint_id,
error = %first,
"the peer's published addresses did not reach it; asking discovery"
);
zyris_p2p::peer::dial(&self.endpoint, iroh::EndpointAddr::new(id)).await.map_err(|second| {
refuse(
"peer_unreachable",
format!("its published addresses: {first}; and by discovery: {second}"),
)
.retriable(true)
})
}
}
#[async_trait::async_trait]
impl PeerLink for IrohPeerLink {
async fn open(&self, addr: &ZPeerAddr, sender: LocalPeerTransfer) -> Result<PeerSession> {
let transport = self.dial(addr).await?;
let node = Node::builder()
.name("zyris-transfer")
.kind(NodeKind::Cli)
.capability(PeerTransferServer(sender))
.build()?;
let connection = node.connect_over(transport).await?;
let client: PeerTransferClient = connection.wait_capability(CAPABILITY_WAIT).await?;
Ok(PeerSession { client, connection })
}
async fn is_direct(&self, endpoint_id: &str) -> bool {
let Ok(id) = endpoint_id.parse::<iroh::EndpointId>() else { return false };
let Some(info) = self.endpoint.remote_info(id).await else { return false };
let direct = info.addrs().any(|a| {
a.addr().is_ip() && matches!(a.usage(), iroh::endpoint::TransportAddrUsage::Active)
});
direct
}
}
fn endpoint_addr(addr: &ZPeerAddr) -> Result<iroh::EndpointAddr> {
let id = addr.endpoint_id.parse::<iroh::EndpointId>().map_err(|e| {
refuse("peer_unreachable", format!("{} is not a usable endpoint id: {e}", addr.endpoint_id))
})?;
let mut endpoint_addr = iroh::EndpointAddr::new(id);
for candidate in &addr.addrs {
if let Ok(socket) = candidate.parse::<std::net::SocketAddr>() {
endpoint_addr = endpoint_addr.with_ip_addr(socket);
}
}
if let Some(relay) = &addr.relay_url {
if let Ok(url) = relay.parse::<iroh::RelayUrl>() {
endpoint_addr = endpoint_addr.with_relay_url(url);
}
}
Ok(endpoint_addr)
}
#[derive(Debug, Clone)]
pub struct FileTransferConfig {
pub root: PathBuf,
pub inbox: PathBuf,
pub node_id: String,
pub wire_deadline: Duration,
}
impl Default for FileTransferConfig {
fn default() -> FileTransferConfig {
FileTransferConfig {
root: PathBuf::from("."),
inbox: PathBuf::from("."),
node_id: String::new(),
wire_deadline: DEFAULT_WIRE_DEADLINE,
}
}
}
#[derive(Clone)]
pub struct LocalFileTransfer {
config: FileTransferConfig,
api: Rendezvous,
tofu: TofuStore,
confirmer: Arc<dyn PeerConfirmer>,
link: Arc<dyn PeerLink>,
}
impl LocalFileTransfer {
pub fn new(
config: FileTransferConfig,
api: AttaccaApiClient,
tofu: TofuStore,
confirmer: Arc<dyn PeerConfirmer>,
link: Arc<dyn PeerLink>,
) -> LocalFileTransfer {
let transfer = LocalFileTransfer::pending(config, tofu, confirmer, link);
transfer.set_api(api);
transfer
}
pub fn pending(
config: FileTransferConfig,
tofu: TofuStore,
confirmer: Arc<dyn PeerConfirmer>,
link: Arc<dyn PeerLink>,
) -> LocalFileTransfer {
LocalFileTransfer { config, api: Rendezvous::default(), tofu, confirmer, link }
}
pub fn set_api(&self, api: AttaccaApiClient) {
self.api.set(api);
}
pub fn rendezvous(&self) -> Rendezvous {
self.api.clone()
}
async fn resolve_source(&self, path: &str) -> Result<PathBuf> {
let root = tokio::fs::canonicalize(&self.config.root).await.map_err(|e| {
WireError::internal(format!(
"this node's root {} could not be read: {e}",
self.config.root.display()
))
})?;
let requested = zyris_caps::resolve_under(&root, path);
let real = tokio::fs::canonicalize(&requested).await.map_err(|e| {
refuse("file_not_found", format!("{} could not be read: {e}", requested.display()))
})?;
if !real.starts_with(&root) {
return Err(refuse(
"path_outside_root",
format!("{path} is outside this node's root and will not be sent"),
));
}
let info = tokio::fs::metadata(&real)
.await
.map_err(|e| refuse("file_not_found", format!("{} could not be read: {e}", real.display())))?;
if !info.is_file() {
return Err(refuse("not_a_file", format!("{path} is not a regular file")));
}
Ok(real)
}
async fn look_up_peer(&self, node: &str) -> Result<ZPeerAddr> {
let current = self.api.get();
let Some(api) = current else {
return Err(refuse(
"rendezvous_unavailable",
"this node is not connected to Attacca yet, so there is nothing to ask where \
a peer is".to_string(),
)
.retriable(true));
};
let addr = api.peer_lookup(node.to_string()).await?;
if !addr.slug.eq_ignore_ascii_case(node) {
return Err(refuse(
"peer_lookup_mismatch",
format!(
"asked for {node} and the rendezvous answered about {} ({}); \
refusing to send to a node that was not named",
addr.slug, addr.node_id
),
));
}
if !addr.online {
return Err(refuse("peer_offline", format!("{node} is not connected"))
.retriable(true));
}
Ok(addr)
}
async fn authorize_peer(&self, label: &str, endpoint_id: &str) -> Result<()> {
match self.tofu.authorize(self.confirmer.as_ref(), label, endpoint_id).await {
Ok(()) => Ok(()),
Err(e @ TofuError::Changed { .. }) => {
Err(refuse("peer_key_changed", e.to_string()))
}
Err(e @ TofuError::Refused { .. }) => Err(refuse("peer_not_confirmed", e.to_string())),
Err(e) => Err(refuse("peer_pin_unavailable", e.to_string())),
}
}
async fn send_inner(
&self,
node: &str,
path: &str,
name: Option<String>,
overwrite: Option<bool>,
at: &AtomicU8,
) -> Result<SendReceipt> {
let source = self.resolve_source(path).await?;
let proposed = match name {
Some(name) => name,
None => source.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
};
let (size, sha256) = hash_file(&source).await?;
let transfer_id = transfer_id(&self.config.node_id, &proposed, size, &sha256);
at.store(phase::REACHING, Ordering::SeqCst);
let addr = self.look_up_peer(node).await?;
self.authorize_peer(node, &addr.endpoint_id).await?;
let sender = LocalPeerTransfer::sender(self.config.root.clone());
sender.offer_file(transfer_id.clone(), source, size, sha256.clone()).await;
let session = self.link.open(&addr, sender).await?;
at.store(phase::SENDING, Ordering::SeqCst);
let offered = session
.client
.push_offer(TransferOffer {
transfer_id,
name: proposed,
size,
sha256,
overwrite: overwrite.unwrap_or(false),
})
.await;
let done = match offered {
Ok(done) => done,
Err(e) if e.code == ErrorCode::Other(TRANSFER_IN_FLIGHT.to_string()) => {
return Ok(SendReceipt {
node: node.to_string(),
written: String::new(),
bytes: 0,
sha256: String::new(),
replaced: false,
undo: None,
direct: self.link.is_direct(&addr.endpoint_id).await,
pending: true,
next: Some(
"the peer is still receiving an earlier call for this same file. Call \
send_to again with the same arguments once it settles."
.to_string(),
),
})
}
Err(e) => return Err(e),
};
Ok(SendReceipt {
node: node.to_string(),
written: done.written,
bytes: done.bytes,
sha256: done.sha256,
replaced: done.replaced,
undo: done.undo,
direct: self.link.is_direct(&addr.endpoint_id).await,
pending: false,
next: None,
})
}
}
#[async_trait::async_trait]
impl FileTransfer for LocalFileTransfer {
async fn send_to(
&self,
node: String,
path: String,
name: Option<String>,
overwrite: Option<bool>,
) -> Result<SendReceipt> {
let deadline = self.config.wire_deadline;
let at = AtomicU8::new(phase::MEASURING);
let running = self.send_inner(&node, &path, name, overwrite, &at);
let expired = match tokio::time::timeout(deadline, running).await {
Ok(result) => return result,
Err(_) => at.load(Ordering::SeqCst),
};
if expired == phase::MEASURING {
return Err(refuse(
"source_too_slow_to_measure",
format!(
"reading and hashing {path} did not finish within the {}s a call gets, so \
nothing has been offered to {node}. Calling again starts the same read over; \
this file cannot be sent from this node until it can be read faster.",
deadline.as_secs()
),
));
}
let next = if expired == phase::SENDING {
"still sending. Call send_to again with the same arguments to resume."
} else {
"the peer had not been reached yet. Call send_to again with the same arguments."
};
Ok(SendReceipt {
node,
written: String::new(),
bytes: 0,
sha256: String::new(),
replaced: false,
undo: None,
direct: false,
pending: true,
next: Some(next.to_string()),
})
}
async fn inbox_list(&self) -> Result<Vec<InboxEntry>> {
let mut entries = Vec::new();
let mut peers = match tokio::fs::read_dir(&self.config.inbox).await {
Ok(dir) => dir,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
Err(e) => return Err(WireError::internal(e.to_string())),
};
loop {
let peer = match peers.next_entry().await {
Ok(Some(peer)) => peer,
Ok(None) => break,
Err(e) => {
return Err(WireError::internal(format!(
"{} could not be listed in full: {e}",
self.config.inbox.display()
)))
}
};
if !peer.path().is_dir() {
continue;
}
let from = peer.file_name().to_string_lossy().into_owned();
let mut files = match tokio::fs::read_dir(peer.path()).await {
Ok(files) => files,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
return Err(WireError::internal(format!(
"{} could not be listed: {e}",
peer.path().display()
)))
}
};
loop {
let file = match files.next_entry().await {
Ok(Some(file)) => file,
Ok(None) => break,
Err(e) => {
return Err(WireError::internal(format!(
"{} could not be listed in full: {e}",
peer.path().display()
)))
}
};
let name = file.file_name().to_string_lossy().into_owned();
if name.ends_with(".part") {
continue;
}
let Ok(info) = file.metadata().await else { continue };
if !info.is_file() {
continue;
}
entries.push(InboxEntry {
from: from.clone(),
name,
bytes: info.len(),
path: file.path().display().to_string(),
received_unix_ms: modified_ms(&info),
});
}
}
entries.sort_by(|a, b| {
b.received_unix_ms.cmp(&a.received_unix_ms).then_with(|| a.path.cmp(&b.path))
});
Ok(entries)
}
}
fn transfer_id(node_id: &str, name: &str, size: u64, sha256: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(node_id.as_bytes());
hasher.update([0]);
hasher.update(name.as_bytes());
hasher.update([0]);
hasher.update(size.to_be_bytes());
hasher.update([0]);
hasher.update(sha256.as_bytes());
hex::encode(&hasher.finalize()[..TRANSFER_ID_BYTES])
}
async fn hash_file(path: &Path) -> Result<(u64, String)> {
use tokio::io::AsyncReadExt as _;
let mut file = tokio::fs::File::open(path)
.await
.map_err(|e| refuse("file_not_found", format!("{} could not be read: {e}", path.display())))?;
let mut hasher = Sha256::new();
let mut buf = vec![0u8; HASH_CHUNK];
let mut size = 0u64;
loop {
let read = file.read(&mut buf).await.map_err(|e| WireError::internal(e.to_string()))?;
if read == 0 {
break;
}
size += read as u64;
hasher.update(&buf[..read]);
}
Ok((size, hex::encode(hasher.finalize())))
}
fn modified_ms(info: &std::fs::Metadata) -> u64 {
info.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn refuse(code: &str, message: String) -> WireError {
WireError::new(ErrorCode::Other(code.to_string()), message).retriable(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_same_arguments_always_name_the_same_transfer() {
let a = transfer_id("node-1", "report.pdf", 1024, "abc");
assert_eq!(a, transfer_id("node-1", "report.pdf", 1024, "abc"));
assert_eq!(a.len(), TRANSFER_ID_BYTES * 2, "{a} should be 16 bytes of hex");
}
#[test]
fn a_different_transfer_never_borrows_another_ones_name() {
let base = transfer_id("node-1", "report.pdf", 1024, "abc");
assert_ne!(base, transfer_id("node-2", "report.pdf", 1024, "abc"));
assert_ne!(base, transfer_id("node-1", "notes.pdf", 1024, "abc"));
assert_ne!(base, transfer_id("node-1", "report.pdf", 1025, "abc"));
assert_ne!(base, transfer_id("node-1", "report.pdf", 1024, "abd"));
}
#[test]
fn fields_cannot_be_slid_across_the_boundary_between_them() {
assert_ne!(
transfer_id("node", "1report.pdf", 1024, "abc"),
transfer_id("node1", "report.pdf", 1024, "abc")
);
}
}