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 canonical Tailscale node ID.
331    let peer_id = node
332        .resolve_peer_id(peer_id)
333        .await
334        .map_err(|e| TransferError::Node(e.to_string()))?;
335    let peer_id = peer_id.as_str();
336
337    // 1. Send PULL_REQUEST and wait for OFFER from peer
338    let pull_req = FtMessage::PullRequest {
339        path: remote_path.to_string(),
340        requester_id,
341        token: token.clone(),
342    };
343
344    let pull_payload = serde_json::to_value(&pull_req)
345        .map_err(|e| TransferError::Protocol(format!("Failed to serialize pull request: {e}")))?;
346
347    tracing::info!(peer = peer_id, path = remote_path, "Sending PULL_REQUEST");
348
349    let (offer_sha256, offer_size, offer_token, file_name) = crate::request_reply::send_and_wait(
350        node,
351        peer_id,
352        "ft",
353        "pull_request",
354        &pull_payload,
355        std::time::Duration::from_secs(30),
356        |msg| {
357            if msg.from != peer_id {
358                return None;
359            }
360            let ft_msg: FtMessage = serde_json::from_value(msg.payload.clone()).ok()?;
361            match ft_msg {
362                FtMessage::Offer {
363                    file_name,
364                    sha256,
365                    size,
366                    token: offer_token,
367                    ..
368                } => {
369                    tracing::info!(size = size, "Received OFFER from peer");
370                    Some(Ok((sha256, size, offer_token, file_name)))
371                }
372                FtMessage::Reject { reason, .. } => Some(Err(TransferError::Rejected(reason))),
373                _ => None,
374            }
375        },
376    )
377    .await
378    .map_err(|e| match e {
379        crate::request_reply::RequestError::Timeout => TransferError::Timeout,
380        crate::request_reply::RequestError::Send(e) => TransferError::Node(e.to_string()),
381        crate::request_reply::RequestError::ChannelClosed => {
382            TransferError::Protocol("Channel closed".into())
383        }
384    })??;
385
386    // M1: reject an over-size OFFER before accepting the pull.
387    let max_size = node
388        .file_transfer_state
389        .max_transfer_size
390        .load(Ordering::Relaxed);
391    if offer_size > max_size {
392        return Err(TransferError::Protocol(format!(
393            "Offered file size {offer_size} exceeds max transfer size {max_size}"
394        )));
395    }
396
397    // 3. Start TCP listener, then send ACCEPT with our port
398    let mut listener = node
399        .listen_tcp(0)
400        .await
401        .map_err(|e| TransferError::Node(format!("Failed to listen TCP: {e}")))?;
402
403    let accept = FtMessage::Accept {
404        token: offer_token,
405        tcp_port: listener.port,
406    };
407
408    let accept_payload = serde_json::to_value(&accept)
409        .map_err(|e| TransferError::Protocol(format!("Failed to serialize accept: {e}")))?;
410
411    node.send_typed(peer_id, "ft", "accept", &accept_payload)
412        .await
413        .map_err(|e| TransferError::Node(e.to_string()))?;
414
415    tracing::info!(
416        port = listener.port,
417        "Sent ACCEPT, listening for TCP connection"
418    );
419
420    // 4. Accept incoming TCP connection with timeout
421    let incoming = tokio::time::timeout(tokio::time::Duration::from_secs(30), listener.accept())
422        .await
423        .map_err(|_| TransferError::Timeout)?
424        .ok_or_else(|| TransferError::Protocol("Listener closed before accepting".to_string()))?;
425
426    let mut stream = incoming.stream;
427    tracing::info!("TCP connection accepted from peer");
428
429    // 5. Read [8-byte size][64-byte sha256_hex][file_bytes]
430    let mut size_buf = [0u8; 8];
431    stream.read_exact(&mut size_buf).await?;
432    let file_size = u64::from_be_bytes(size_buf);
433
434    let mut sha_buf = [0u8; 64]; // hex-encoded SHA-256
435    stream.read_exact(&mut sha_buf).await?;
436    let received_sha = String::from_utf8_lossy(&sha_buf).to_string();
437
438    // Verify the metadata matches the OFFER
439    if received_sha != offer_sha256 {
440        return Err(TransferError::IntegrityError {
441            expected: offer_sha256,
442            actual: received_sha,
443        });
444    }
445
446    if file_size != offer_size {
447        return Err(TransferError::Protocol(format!(
448            "Size mismatch: offer said {offer_size}, stream header says {file_size}"
449        )));
450    }
451
452    // Stream file data to disk (instead of memory buffer)
453    // Create parent directories if needed
454    if let Some(parent) = std::path::Path::new(local_path).parent() {
455        tokio::fs::create_dir_all(parent).await?;
456    }
457
458    let temp_path = format!("{local_path}.truffle-tmp");
459    let mut temp_file = tokio::fs::File::create(&temp_path).await?;
460    let mut hasher = Sha256::new();
461    let mut bytes_received: u64 = 0;
462    let progress_start = Instant::now();
463    let mut buf = vec![0u8; 64 * 1024];
464
465    while bytes_received < file_size {
466        let to_read = ((file_size - bytes_received) as usize).min(buf.len());
467        let n = stream.read(&mut buf[..to_read]).await?;
468        if n == 0 {
469            tokio::fs::remove_file(&temp_path).await.ok();
470            return Err(TransferError::Io(std::io::Error::new(
471                std::io::ErrorKind::UnexpectedEof,
472                format!("Connection closed after {bytes_received}/{file_size} bytes"),
473            )));
474        }
475        hasher.update(&buf[..n]);
476        AsyncWriteExt::write_all(&mut temp_file, &buf[..n]).await?;
477        bytes_received += n as u64;
478
479        let elapsed = progress_start.elapsed().as_secs_f64();
480        let speed = if elapsed > 0.0 {
481            bytes_received as f64 / elapsed
482        } else {
483            0.0
484        };
485
486        // Emit progress event (best-effort)
487        let _ = event_tx.send(FileTransferEvent::Progress(TransferProgress {
488            token: token.clone(),
489            direction: TransferDirection::Receive,
490            file_name: file_name.clone(),
491            bytes_transferred: bytes_received,
492            total_bytes: file_size,
493            speed_bps: speed,
494        }));
495    }
496
497    // Flush temp file
498    AsyncWriteExt::flush(&mut temp_file).await?;
499
500    // 6. Verify SHA-256
501    let actual_sha = hex::encode(hasher.finalize());
502
503    if actual_sha != offer_sha256 {
504        // Send NACK
505        stream.write_all(&[0x00]).await?;
506        tokio::fs::remove_file(&temp_path).await.ok();
507        return Err(TransferError::IntegrityError {
508            expected: offer_sha256,
509            actual: actual_sha,
510        });
511    }
512
513    // Rename temp to final
514    tokio::fs::rename(&temp_path, local_path).await?;
515
516    // 7. Send ACK
517    stream.write_all(&[0x01]).await?;
518
519    let elapsed = start.elapsed().as_secs_f64();
520    tracing::info!(
521        bytes = file_size,
522        elapsed_ms = (elapsed * 1000.0) as u64,
523        path = local_path,
524        "Download complete"
525    );
526
527    // Emit completed event
528    let _ = event_tx.send(FileTransferEvent::Completed {
529        token,
530        direction: TransferDirection::Receive,
531        file_name,
532        bytes_transferred: file_size,
533        sha256: actual_sha.clone(),
534        elapsed_secs: elapsed,
535    });
536
537    Ok(TransferResult {
538        bytes_transferred: file_size,
539        sha256: actual_sha,
540        elapsed_secs: elapsed,
541    })
542}