Skip to main content

truffle_core/file_transfer/
mod.rs

1//! File transfer subsystem for truffle-core.
2//!
3//! Provides a first-class API for sending and receiving files between peers
4//! in the truffle mesh. The [`FileTransfer`] manager handles:
5//!
6//! - **Sending**: Push a local file to a remote peer.
7//! - **Receiving**: Accept/reject incoming file offers via an offer channel.
8//! - **Pulling**: Request a file from a remote peer. On the serving side, pulls
9//!   are served only from roots registered via [`FileTransfer::add_pull_root`];
10//!   with no roots configured all incoming PULL_REQUESTs are rejected.
11//! - **Events**: Subscribe to transfer lifecycle events (progress, completed, failed).
12//!
13//! # Architecture
14//!
15//! The file transfer module sits on top of the Node API, using:
16//! - WS namespace `"ft"` for signaling (offers, accepts, rejects, pull requests)
17//! - Raw TCP streams for bulk data transfer
18//! - SHA-256 for integrity verification
19//!
20//! # Example
21//!
22//! ```ignore
23//! let ft = node.file_transfer();
24//!
25//! // Subscribe to events
26//! let mut events = ft.subscribe();
27//!
28//! // Send a file
29//! let result = ft.send_file("peer-id", "/path/to/file.txt", "file.txt").await?;
30//!
31//! // Receive with manual accept/reject
32//! let mut offers = ft.offer_channel().await;
33//! while let Some((offer, responder)) = offers.recv().await {
34//!     responder.accept("/tmp/received-file.txt");
35//! }
36//! ```
37
38pub mod receiver;
39pub mod sender;
40pub mod types;
41
42pub use types::*;
43
44use std::sync::atomic::{AtomicU64, Ordering};
45use std::sync::Arc;
46
47use tokio::sync::{broadcast, mpsc, Mutex};
48use tokio::task::JoinHandle;
49use tracing::info;
50
51use crate::network::NetworkProvider;
52use crate::node::Node;
53
54/// Default maximum transfer size: 1 GB.
55const DEFAULT_MAX_TRANSFER_SIZE: u64 = 1_073_741_824;
56
57/// File transfer manager (internal state).
58///
59/// This struct holds the channels and configuration for the file transfer
60/// subsystem. It is stored inside [`Node`] and accessed via
61/// [`Node::file_transfer()`], which returns a [`FileTransferHandle`]
62/// that has access to both this state and the Node's networking capabilities.
63pub(crate) struct FileTransferState {
64    /// Broadcast sender for file transfer events.
65    pub(crate) event_tx: broadcast::Sender<FileTransferEvent>,
66    /// Sender side of the offer channel (held to clone for new receivers).
67    pub(crate) offer_tx: Mutex<Option<mpsc::UnboundedSender<(FileOffer, OfferResponder)>>>,
68    /// Maximum allowed transfer size in bytes.
69    pub(crate) max_transfer_size: AtomicU64,
70    /// Canonicalized roots that PULL_REQUESTs may be served from. Empty = deny all pulls.
71    pub(crate) pull_roots: std::sync::RwLock<Vec<std::path::PathBuf>>,
72    /// Handle to the background receiver task (lazy-started).
73    pub(crate) receiver_handle: Mutex<Option<JoinHandle<()>>>,
74}
75
76impl FileTransferState {
77    /// Create a new file transfer state.
78    pub(crate) fn new() -> Self {
79        let (event_tx, _) = broadcast::channel(256);
80        Self {
81            event_tx,
82            offer_tx: Mutex::new(None),
83            max_transfer_size: AtomicU64::new(DEFAULT_MAX_TRANSFER_SIZE),
84            pull_roots: std::sync::RwLock::new(Vec::new()),
85            receiver_handle: Mutex::new(None),
86        }
87    }
88}
89
90/// File transfer handle — the public API for file transfers.
91///
92/// This is a borrowed reference to a [`Node`] that provides file transfer
93/// operations. Obtained via [`Node::file_transfer()`].
94///
95/// Generic over `N: NetworkProvider` to match the [`Node`] it wraps.
96pub struct FileTransfer<'a, N: NetworkProvider + 'static> {
97    /// Reference to the owning node.
98    node: &'a Node<N>,
99}
100
101impl<'a, N: NetworkProvider + 'static> FileTransfer<'a, N> {
102    /// Create a new file transfer handle for the given node.
103    pub(crate) fn new(node: &'a Node<N>) -> Self {
104        Self { node }
105    }
106
107    /// Access the internal file transfer state.
108    fn state(&self) -> &FileTransferState {
109        &self.node.file_transfer_state
110    }
111
112    /// Subscribe to file transfer events.
113    ///
114    /// Returns a broadcast receiver that yields [`FileTransferEvent`]s.
115    /// Multiple subscribers are supported.
116    pub fn subscribe(&self) -> broadcast::Receiver<FileTransferEvent> {
117        self.state().event_tx.subscribe()
118    }
119
120    /// Set the maximum allowed transfer size in bytes.
121    ///
122    /// Transfers exceeding this size will be rejected. Default is 1 GB.
123    pub fn set_max_transfer_size(&self, bytes: u64) {
124        self.state()
125            .max_transfer_size
126            .store(bytes, Ordering::Relaxed);
127    }
128
129    /// Get the current maximum transfer size in bytes.
130    pub fn max_transfer_size(&self) -> u64 {
131        self.state().max_transfer_size.load(Ordering::Relaxed)
132    }
133
134    /// Register a directory that incoming PULL_REQUESTs may be served from.
135    ///
136    /// Pull serving is **deny-by-default**: with no roots registered every
137    /// incoming PULL_REQUEST is rejected. The `root` is canonicalized at
138    /// registration time (so it must already exist, and symlinked roots
139    /// compare correctly against canonicalized request paths); a requested
140    /// file is only served if its canonical path lives inside one of these
141    /// roots.
142    pub fn add_pull_root(&self, root: &str) -> std::io::Result<()> {
143        let canon = std::fs::canonicalize(root)?;
144        self.state().pull_roots.write().unwrap().push(canon);
145        Ok(())
146    }
147
148    /// Return the currently registered (canonicalized) pull roots.
149    ///
150    /// An empty list means pull serving is disabled (deny-by-default).
151    pub fn pull_roots(&self) -> Vec<std::path::PathBuf> {
152        self.state().pull_roots.read().unwrap().clone()
153    }
154
155    /// Remove all registered pull roots, disabling pull serving entirely
156    /// (returns to the deny-by-default state).
157    pub fn clear_pull_roots(&self) {
158        self.state().pull_roots.write().unwrap().clear();
159    }
160
161    /// Get a channel for receiving incoming file offers.
162    ///
163    /// Each offer comes with an [`OfferResponder`] that must be used to
164    /// accept or reject the transfer. Calling this starts the background
165    /// receiver listener (lazy start, idempotent).
166    ///
167    /// Only one offer channel can be active at a time. Calling this again
168    /// replaces the previous channel (the old receiver will stop getting offers).
169    ///
170    /// **Important**: The returned receiver must be polled. The `node` reference
171    /// used here must remain valid — store the `Node` in an `Arc` and pass it
172    /// to spawned tasks as needed.
173    pub async fn offer_channel(
174        &self,
175        node: Arc<Node<N>>,
176    ) -> mpsc::UnboundedReceiver<(FileOffer, OfferResponder)> {
177        let (tx, rx) = mpsc::unbounded_channel();
178        {
179            let mut offer_tx = self.state().offer_tx.lock().await;
180            *offer_tx = Some(tx.clone());
181        }
182        self.ensure_receiver_started(node, tx).await;
183        rx
184    }
185
186    /// Convenience: auto-accept all incoming offers, saving files to
187    /// `output_dir`.
188    ///
189    /// This starts the receiver listener (lazy start) and spawns a task
190    /// that automatically accepts every offer with the save path set to
191    /// `{output_dir}/{file_name}`.
192    pub async fn auto_accept(&self, node: Arc<Node<N>>, output_dir: &str) {
193        let mut rx = self.offer_channel(node).await;
194        // Normalize to a directory path (trailing separator) so the receiver
195        // treats it as a directory and appends a *sanitized* base name derived
196        // from the peer's `file_name`. We deliberately IGNORE the peer-supplied
197        // `suggested_path`: honoring it would let a remote peer choose an
198        // arbitrary destination (path traversal / absolute path → arbitrary file
199        // overwrite). See `receiver::resolve_dest_path`.
200        let output_dir = format!("{}/", output_dir.trim_end_matches(['/', '\\']));
201
202        tokio::spawn(async move {
203            while let Some((offer, responder)) = rx.recv().await {
204                info!(
205                    file = offer.file_name.as_str(),
206                    dir = output_dir.as_str(),
207                    "Auto-accepting file offer"
208                );
209                responder.accept(&output_dir);
210            }
211        });
212    }
213
214    /// Convenience: auto-reject all incoming offers.
215    ///
216    /// This starts the receiver listener (lazy start) and spawns a task
217    /// that automatically rejects every offer.
218    pub async fn auto_reject(&self, node: Arc<Node<N>>) {
219        let mut rx = self.offer_channel(node).await;
220
221        tokio::spawn(async move {
222            while let Some((_offer, responder)) = rx.recv().await {
223                responder.reject("auto-rejected");
224            }
225        });
226    }
227
228    /// Send a file to a peer.
229    ///
230    /// Hashes the local file, sends an OFFER via the `"ft"` namespace,
231    /// waits for ACCEPT, then streams the file over TCP.
232    ///
233    /// # Errors
234    ///
235    /// Returns [`TransferError`] on I/O failure, rejection, timeout, or
236    /// integrity check failure.
237    pub async fn send_file(
238        &self,
239        peer_id: &str,
240        local_path: &str,
241        remote_path: &str,
242    ) -> Result<TransferResult, TransferError> {
243        let max_size = self.state().max_transfer_size.load(Ordering::Relaxed);
244        sender::send_file(
245            self.node,
246            peer_id,
247            local_path,
248            remote_path,
249            max_size,
250            &self.state().event_tx,
251        )
252        .await
253    }
254
255    /// Pull a file from a remote peer.
256    ///
257    /// Sends a PULL_REQUEST, waits for the peer's OFFER, accepts it, then
258    /// receives the file over TCP.
259    ///
260    /// # Errors
261    ///
262    /// Returns [`TransferError`] on I/O failure, rejection, timeout, or
263    /// integrity check failure.
264    pub async fn pull_file(
265        &self,
266        peer_id: &str,
267        remote_path: &str,
268        local_path: &str,
269    ) -> Result<TransferResult, TransferError> {
270        pull_file(
271            self.node,
272            peer_id,
273            remote_path,
274            local_path,
275            &self.state().event_tx,
276        )
277        .await
278    }
279
280    /// Ensure the background receiver task is running.
281    ///
282    /// The receiver is lazy-started on the first call to `offer_channel()`,
283    /// `auto_accept()`, or `auto_reject()`.
284    async fn ensure_receiver_started(
285        &self,
286        node: Arc<Node<N>>,
287        offer_tx: mpsc::UnboundedSender<(FileOffer, OfferResponder)>,
288    ) {
289        let mut handle = self.state().receiver_handle.lock().await;
290        // If there's an existing handle, abort it (we're replacing the offer channel)
291        if let Some(h) = handle.take() {
292            h.abort();
293        }
294        let h = receiver::spawn_receive_handler(node, offer_tx, self.state().event_tx.clone());
295        *handle = Some(h);
296    }
297}
298
299// ---------------------------------------------------------------------------
300// Pull file — download from a remote peer
301// ---------------------------------------------------------------------------
302
303/// Pull (download) a file from a remote peer.
304///
305/// 1. Send PULL_REQUEST via WS
306/// 2. Wait for OFFER from peer
307/// 3. Send ACCEPT
308/// 4. Listen for TCP connection from peer
309/// 5. Read [size][sha256][file_bytes], streaming to disk
310/// 6. Verify SHA-256, rename temp file
311/// 7. Send ACK
312async fn pull_file<N: NetworkProvider + 'static>(
313    node: &Node<N>,
314    peer_id: &str,
315    remote_path: &str,
316    local_path: &str,
317    event_tx: &broadcast::Sender<FileTransferEvent>,
318) -> Result<TransferResult, TransferError> {
319    use sha2::{Digest, Sha256};
320    use std::time::Instant;
321    use tokio::io::{AsyncReadExt, AsyncWriteExt};
322
323    let start = Instant::now();
324    let token = uuid::Uuid::new_v4().to_string();
325    // RFC 017 §8: use the stable `device_id` (ULID) as the requester
326    // identifier. The hello handshake now carries this between peers so
327    // the receiving side can match requester to sender.
328    let requester_id = node.local_info().device_id;
329
330    // 0. Resolve peer_id to the Tailscale routing key. The OFFER filter
331    // below compares against `IncomingMessage.from`, which carries the
332    // connection's WhoIs-verified Tailscale id (RFC 022 §7.5) — never the
333    // ULID, so resolving to the ULID here would reject every reply.
334    let peer_id = node
335        .resolve_peer(peer_id)
336        .await
337        .map_err(|e| TransferError::Node(e.to_string()))?
338        .id;
339    let peer_id = peer_id.as_str();
340
341    // 1. Send PULL_REQUEST and wait for OFFER from peer
342    let pull_req = FtMessage::PullRequest {
343        path: remote_path.to_string(),
344        requester_id,
345        token: token.clone(),
346    };
347
348    let pull_payload = serde_json::to_value(&pull_req)
349        .map_err(|e| TransferError::Protocol(format!("Failed to serialize pull request: {e}")))?;
350
351    tracing::info!(peer = peer_id, path = remote_path, "Sending PULL_REQUEST");
352
353    let (offer_sha256, offer_size, offer_token, file_name) = crate::request_reply::send_and_wait(
354        node,
355        peer_id,
356        "ft",
357        "pull_request",
358        &pull_payload,
359        std::time::Duration::from_secs(30),
360        |msg| {
361            if msg.from != peer_id {
362                return None;
363            }
364            let ft_msg: FtMessage = serde_json::from_value(msg.payload.clone()).ok()?;
365            match ft_msg {
366                FtMessage::Offer {
367                    file_name,
368                    sha256,
369                    size,
370                    token: offer_token,
371                    ..
372                } => {
373                    tracing::info!(size = size, "Received OFFER from peer");
374                    Some(Ok((sha256, size, offer_token, file_name)))
375                }
376                FtMessage::Reject { reason, .. } => Some(Err(TransferError::Rejected(reason))),
377                _ => None,
378            }
379        },
380    )
381    .await
382    .map_err(|e| match e {
383        crate::request_reply::RequestError::Timeout => TransferError::Timeout,
384        crate::request_reply::RequestError::Send(e) => TransferError::Node(e.to_string()),
385        crate::request_reply::RequestError::ChannelClosed => {
386            TransferError::Protocol("Channel closed".into())
387        }
388    })??;
389
390    // M1: reject an over-size OFFER before accepting the pull.
391    let max_size = node
392        .file_transfer_state
393        .max_transfer_size
394        .load(Ordering::Relaxed);
395    if offer_size > max_size {
396        return Err(TransferError::Protocol(format!(
397            "Offered file size {offer_size} exceeds max transfer size {max_size}"
398        )));
399    }
400
401    // 3. Start TCP listener, then send ACCEPT with our port
402    let mut listener = node
403        .listen_tcp(0)
404        .await
405        .map_err(|e| TransferError::Node(format!("Failed to listen TCP: {e}")))?;
406
407    let accept = FtMessage::Accept {
408        token: offer_token,
409        tcp_port: listener.port,
410    };
411
412    let accept_payload = serde_json::to_value(&accept)
413        .map_err(|e| TransferError::Protocol(format!("Failed to serialize accept: {e}")))?;
414
415    node.send_typed(peer_id, "ft", "accept", &accept_payload)
416        .await
417        .map_err(|e| TransferError::Node(e.to_string()))?;
418
419    tracing::info!(
420        port = listener.port,
421        "Sent ACCEPT, listening for TCP connection"
422    );
423
424    // 4. Accept incoming TCP connection with timeout
425    let incoming = tokio::time::timeout(tokio::time::Duration::from_secs(30), listener.accept())
426        .await
427        .map_err(|_| TransferError::Timeout)?
428        .ok_or_else(|| TransferError::Protocol("Listener closed before accepting".to_string()))?;
429
430    let mut stream = incoming.stream;
431    tracing::info!("TCP connection accepted from peer");
432
433    // 5. Read [8-byte size][64-byte sha256_hex][file_bytes]
434    let mut size_buf = [0u8; 8];
435    stream.read_exact(&mut size_buf).await?;
436    let file_size = u64::from_be_bytes(size_buf);
437
438    let mut sha_buf = [0u8; 64]; // hex-encoded SHA-256
439    stream.read_exact(&mut sha_buf).await?;
440    let received_sha = String::from_utf8_lossy(&sha_buf).to_string();
441
442    // Verify the metadata matches the OFFER
443    if received_sha != offer_sha256 {
444        return Err(TransferError::IntegrityError {
445            expected: offer_sha256,
446            actual: received_sha,
447        });
448    }
449
450    if file_size != offer_size {
451        return Err(TransferError::Protocol(format!(
452            "Size mismatch: offer said {offer_size}, stream header says {file_size}"
453        )));
454    }
455
456    // Stream file data to disk (instead of memory buffer)
457    // Create parent directories if needed
458    if let Some(parent) = std::path::Path::new(local_path).parent() {
459        tokio::fs::create_dir_all(parent).await?;
460    }
461
462    let temp_path = format!("{local_path}.truffle-tmp");
463    let mut temp_file = tokio::fs::File::create(&temp_path).await?;
464    let mut hasher = Sha256::new();
465    let mut bytes_received: u64 = 0;
466    let progress_start = Instant::now();
467    let mut buf = vec![0u8; 64 * 1024];
468
469    while bytes_received < file_size {
470        let to_read = ((file_size - bytes_received) as usize).min(buf.len());
471        let n = stream.read(&mut buf[..to_read]).await?;
472        if n == 0 {
473            tokio::fs::remove_file(&temp_path).await.ok();
474            return Err(TransferError::Io(std::io::Error::new(
475                std::io::ErrorKind::UnexpectedEof,
476                format!("Connection closed after {bytes_received}/{file_size} bytes"),
477            )));
478        }
479        hasher.update(&buf[..n]);
480        AsyncWriteExt::write_all(&mut temp_file, &buf[..n]).await?;
481        bytes_received += n as u64;
482
483        let elapsed = progress_start.elapsed().as_secs_f64();
484        let speed = if elapsed > 0.0 {
485            bytes_received as f64 / elapsed
486        } else {
487            0.0
488        };
489
490        // Emit progress event (best-effort)
491        let _ = event_tx.send(FileTransferEvent::Progress(TransferProgress {
492            token: token.clone(),
493            direction: TransferDirection::Receive,
494            file_name: file_name.clone(),
495            bytes_transferred: bytes_received,
496            total_bytes: file_size,
497            speed_bps: speed,
498        }));
499    }
500
501    // Flush temp file
502    AsyncWriteExt::flush(&mut temp_file).await?;
503
504    // 6. Verify SHA-256
505    let actual_sha = hex::encode(hasher.finalize());
506
507    if actual_sha != offer_sha256 {
508        // Send NACK
509        stream.write_all(&[0x00]).await?;
510        tokio::fs::remove_file(&temp_path).await.ok();
511        return Err(TransferError::IntegrityError {
512            expected: offer_sha256,
513            actual: actual_sha,
514        });
515    }
516
517    // Rename temp to final
518    tokio::fs::rename(&temp_path, local_path).await?;
519
520    // 7. Send ACK
521    stream.write_all(&[0x01]).await?;
522
523    let elapsed = start.elapsed().as_secs_f64();
524    tracing::info!(
525        bytes = file_size,
526        elapsed_ms = (elapsed * 1000.0) as u64,
527        path = local_path,
528        "Download complete"
529    );
530
531    // Emit completed event
532    let _ = event_tx.send(FileTransferEvent::Completed {
533        token,
534        direction: TransferDirection::Receive,
535        file_name,
536        bytes_transferred: file_size,
537        sha256: actual_sha.clone(),
538        elapsed_secs: elapsed,
539    });
540
541    Ok(TransferResult {
542        bytes_transferred: file_size,
543        sha256: actual_sha,
544        elapsed_secs: elapsed,
545    })
546}