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