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