Skip to main content

zyris_transfer/
send.rs

1//! The sending side: what an agent calls to put a file on another node of the same account.
2//!
3//! Everything else under [`super`] is the receiving side. This module is the half that says
4//! "send", and it is the only place in the crate that talks to the rendezvous
5//! (`zyris-attacca`) or to the peer transport (`zyris-p2p`).
6//!
7//! # What guards what
8//!
9//! | Guard | Where | What it stops |
10//! |---|---|---|
11//! | The read jail | [`LocalFileTransfer::resolve_source`] | Sending a file that is not under the node's root |
12//! | The answer check | [`LocalFileTransfer::look_up_peer`] | Sending to a machine the caller did not name |
13//! | The pin | [`LocalFileTransfer::authorize_peer`] | Sending to a peer whose key is not the one a person confirmed |
14//!
15//! # Why the peer link is behind a trait
16//!
17//! [`PeerLink`] exists so the three guards above can be exercised end to end — through the real
18//! [`super::peer::LocalPeerTransfer`], the real `push_offer`/`pull` exchange, onto a real file —
19//! with no socket in it. [`IrohPeerLink`] is the production implementation and the only part of
20//! this module that touches QUIC. That split is the same one the crates themselves already draw:
21//! `zyris-transfer` implements a capability, `zyris-p2p` carries it.
22
23use std::path::{Path, PathBuf};
24use std::sync::atomic::{AtomicU8, Ordering};
25use std::sync::Arc;
26use std::time::Duration;
27
28use sha2::{Digest, Sha256};
29use zyris::{ErrorCode, Node, NodeKind, Result, WireError};
30use zyris_attacca::{AttaccaApi, AttaccaApiClient, ZPeerAddr};
31use zyris_caps::file_transfer::{FileTransfer, InboxEntry, SendReceipt};
32use zyris_caps::peer_transfer::{
33    PeerTransfer, PeerTransferClient, PeerTransferServer, TransferOffer,
34};
35use zyris_p2p::fingerprint::PeerConfirmer;
36use zyris_p2p::iroh;
37use zyris_p2p::tofu::{TofuError, TofuStore};
38
39use super::peer::{LocalPeerTransfer, TRANSFER_IN_FLIGHT};
40use super::rendezvous::Rendezvous;
41
42/// How long the whole of `send_to` gets before it answers `pending` instead of finishing.
43///
44/// Attacca cuts a node call off at 60 seconds with a `Timeout` error. Coming back at 55 with a
45/// deliberate `pending: true` beats being cut off: the agent is told to call again in a reply that
46/// is not shaped like a failure, and the call it makes resumes rather than restarts.
47pub const DEFAULT_WIRE_DEADLINE: Duration = Duration::from_secs(55);
48
49/// How long [`IrohPeerLink`] waits for the peer to announce `peer_transfer` after the zyris
50/// handshake. The peer announces during its own handshake, so this only ever absorbs scheduling
51/// jitter; a peer that has not announced by now is not going to.
52const CAPABILITY_WAIT: Duration = Duration::from_secs(5);
53
54/// How much of the file to hash per read. Same size as the transfer chunk, for the same reason:
55/// a fixed buffer instead of loading a file that may be gigabytes into memory to hash it.
56const HASH_CHUNK: usize = 64 * 1024;
57
58/// Bytes of `sha256(node_id ‖ name ‖ size ‖ sha256)` kept as the `transfer_id`.
59const TRANSFER_ID_BYTES: usize = 16;
60
61/// How far [`LocalFileTransfer::send_inner`] had got when the deadline ran out.
62///
63/// Only one of the three has anything on the peer's disk to come back to, and the answer has to say
64/// which. "Call again and it resumes" is true once bytes are moving; said while the file is still
65/// being measured it points the caller at a `.part` that was never created, and the caller — an
66/// agent, which will do exactly what the `next` line says — retries forever against a step that
67/// starts from zero every time.
68mod phase {
69    /// Resolving the path and reading the file to size and hash it. Nothing has left this machine,
70    /// and nothing has been asked of anyone.
71    pub const MEASURING: u8 = 0;
72    /// Looking the peer up, settling its key, opening the link. Still nothing sent.
73    pub const REACHING: u8 = 1;
74    /// `push_offer` is in flight and the peer is pulling. A partial file may exist over there.
75    pub const SENDING: u8 = 2;
76}
77
78/// One open link to a peer, for the length of one transfer.
79pub struct PeerSession {
80    /// The handle `push_offer` is called through.
81    pub client: PeerTransferClient,
82    /// Held for as long as the transfer runs. The peer calls `pull` back over this same
83    /// connection while it is answering `push_offer`, so dropping it early would cut the bytes
84    /// off mid-flight.
85    pub connection: zyris::Connection,
86}
87
88/// How `send_to` reaches a peer. Implemented by [`IrohPeerLink`] in production.
89#[async_trait::async_trait]
90pub trait PeerLink: Send + Sync {
91    /// Connects to `addr` and announces `sender` — **`peer_transfer` and nothing else** — on the
92    /// resulting link.
93    ///
94    /// `sender` is taken by value because the link owns it from here on: it is the object the
95    /// peer's `pull` calls land on, and it has to stay alive as long as the connection does.
96    async fn open(&self, addr: &ZPeerAddr, sender: LocalPeerTransfer) -> Result<PeerSession>;
97
98    /// Whether bytes to `endpoint_id` are travelling a direct path rather than through the relay.
99    ///
100    /// Asked **after** the transfer, not before: iroh starts a connection over the relay and
101    /// upgrades it once hole punching lands, so a reading taken at connect time reports "relayed"
102    /// for a transfer that spent almost all of itself direct.
103    ///
104    /// A link with no way to tell answers `false`, which is the honest reading of "we cannot claim
105    /// this was direct" — never a claim that it was.
106    async fn is_direct(&self, endpoint_id: &str) -> bool {
107        let _ = endpoint_id;
108        false
109    }
110}
111
112/// The production [`PeerLink`]: an iroh QUIC connection carrying zyris.
113pub struct IrohPeerLink {
114    endpoint: iroh::Endpoint,
115}
116
117impl IrohPeerLink {
118    pub fn new(endpoint: iroh::Endpoint) -> IrohPeerLink {
119        IrohPeerLink { endpoint }
120    }
121
122    /// Dials the peer, with the rendezvous's addresses as hints and **iroh's own discovery as the
123    /// fallback**.
124    ///
125    /// The two steps are not redundant, and leaving the second one out is how a transfer that
126    /// should work fails. A peer publishes the addresses it can see on itself, and behind NAT — a
127    /// container, a laptop on wifi — those are private ones that mean nothing here. Handing iroh an
128    /// `EndpointAddr` carrying them is not a hint, it is an assertion: iroh takes it to mean the
129    /// caller knows where this peer is, dials only there, and never asks discovery. So a peer that
130    /// is perfectly reachable is refused because of the addresses it published about itself.
131    ///
132    /// Measured against a node in a Kubernetes sandbox on another continent: dialing its published
133    /// address failed, and dialing the same endpoint id with no hints at all connected in 1.1
134    /// seconds. Both machines were on public n0 relays, and neither had been told anything about
135    /// the other's.
136    ///
137    /// The hints are still tried first, because when they *are* reachable — two machines on one
138    /// desk — they are the direct path, and discovery would be a slower route to the same place.
139    async fn dial(&self, addr: &ZPeerAddr) -> Result<zyris_p2p::transport::IrohTransport> {
140        let hinted = endpoint_addr(addr)?;
141        let hinted_at_something = hinted.ip_addrs().next().is_some() || hinted.relay_urls().next().is_some();
142
143        let first = match zyris_p2p::peer::dial(&self.endpoint, hinted).await {
144            Ok(transport) => return Ok(transport),
145            Err(e) if !hinted_at_something => {
146                // Nothing was hinted, so that attempt already was the discovery one.
147                return Err(refuse("peer_unreachable", e.to_string()).retriable(true));
148            }
149            Err(e) => e,
150        };
151
152        let id = addr.endpoint_id.parse::<iroh::EndpointId>().map_err(|e| {
153            refuse("peer_unreachable", format!("{} is not a usable endpoint id: {e}", addr.endpoint_id))
154        })?;
155        tracing::debug!(
156            endpoint_id = %addr.endpoint_id,
157            error = %first,
158            "the peer's published addresses did not reach it; asking discovery"
159        );
160        zyris_p2p::peer::dial(&self.endpoint, iroh::EndpointAddr::new(id)).await.map_err(|second| {
161            // Both failures, because they usually say different things: the first is what the
162            // published addresses did, the second is what discovery could not find.
163            refuse(
164                "peer_unreachable",
165                format!("its published addresses: {first}; and by discovery: {second}"),
166            )
167            .retriable(true)
168        })
169    }
170}
171
172#[async_trait::async_trait]
173impl PeerLink for IrohPeerLink {
174    async fn open(&self, addr: &ZPeerAddr, sender: LocalPeerTransfer) -> Result<PeerSession> {
175        let transport = self.dial(addr).await?;
176        // One capability on this link and one only. The peer link is not a general-purpose node
177        // connection that happens to be filtered down — it is built with nothing else on it.
178        let node = Node::builder()
179            .name("zyris-transfer")
180            .kind(NodeKind::Cli)
181            .capability(PeerTransferServer(sender))
182            .build()?;
183        let connection = node.connect_over(transport).await?;
184        let client: PeerTransferClient = connection.wait_capability(CAPABILITY_WAIT).await?;
185        Ok(PeerSession { client, connection })
186    }
187
188    async fn is_direct(&self, endpoint_id: &str) -> bool {
189        let Ok(id) = endpoint_id.parse::<iroh::EndpointId>() else { return false };
190        let Some(info) = self.endpoint.remote_info(id).await else { return false };
191        // The binding is not redundant, however much it looks it. As the block's tail expression,
192        // `addrs()`'s iterator would be a temporary living to the end of the block — dropped *after*
193        // `info`, which it borrows, which does not compile (E0597) in a desugared `async_trait`
194        // body. Naming the bool ends the borrow on this line.
195        let direct = info.addrs().any(|a| {
196            a.addr().is_ip() && matches!(a.usage(), iroh::endpoint::TransportAddrUsage::Active)
197        });
198        direct
199    }
200}
201
202/// Turns the rendezvous's answer into something iroh can dial.
203///
204/// Unparseable candidate addresses are dropped rather than failing the dial: the list is a set of
205/// hints, and one bad hint among several good ones is not a reason to refuse to connect. The
206/// `endpoint_id` is the exception — it is the peer's identity, it is what QUIC authenticates
207/// against, and there is nothing to dial without it.
208fn endpoint_addr(addr: &ZPeerAddr) -> Result<iroh::EndpointAddr> {
209    let id = addr.endpoint_id.parse::<iroh::EndpointId>().map_err(|e| {
210        refuse("peer_unreachable", format!("{} is not a usable endpoint id: {e}", addr.endpoint_id))
211    })?;
212    let mut endpoint_addr = iroh::EndpointAddr::new(id);
213    for candidate in &addr.addrs {
214        if let Ok(socket) = candidate.parse::<std::net::SocketAddr>() {
215            endpoint_addr = endpoint_addr.with_ip_addr(socket);
216        }
217    }
218    if let Some(relay) = &addr.relay_url {
219        if let Ok(url) = relay.parse::<iroh::RelayUrl>() {
220            endpoint_addr = endpoint_addr.with_relay_url(url);
221        }
222    }
223    Ok(endpoint_addr)
224}
225
226#[derive(Debug, Clone)]
227pub struct FileTransferConfig {
228    /// The only directory `send_to` will read out of. Everything outside is refused.
229    pub root: PathBuf,
230    /// Where received files land — what `inbox_list` reads. Normally the same path as
231    /// [`super::TransferConfig::inbox`] on the same node.
232    pub inbox: PathBuf,
233    /// This node's own identifier, mixed into `transfer_id` so two nodes sending the same file
234    /// under the same name do not collide in the receiver's `.part` slot.
235    ///
236    /// **A uniqueness salt, not a trust anchor.** Nothing is authorized by it and nothing is
237    /// pinned under it — attacca issues it and can issue another one at will.
238    pub node_id: String,
239    pub wire_deadline: Duration,
240}
241
242impl Default for FileTransferConfig {
243    fn default() -> FileTransferConfig {
244        FileTransferConfig {
245            root: PathBuf::from("."),
246            inbox: PathBuf::from("."),
247            node_id: String::new(),
248            wire_deadline: DEFAULT_WIRE_DEADLINE,
249        }
250    }
251}
252
253/// The reference `file_transfer` implementation.
254#[derive(Clone)]
255pub struct LocalFileTransfer {
256    config: FileTransferConfig,
257    /// The rendezvous client, which does not exist yet when a node builds its capabilities: it
258    /// arrives on the connection to Attacca, after `Node::connect` has been handed everything this
259    /// node announces. See [`Rendezvous`] for why it is replaceable rather than write-once.
260    api: Rendezvous,
261    tofu: TofuStore,
262    confirmer: Arc<dyn PeerConfirmer>,
263    link: Arc<dyn PeerLink>,
264}
265
266impl LocalFileTransfer {
267    pub fn new(
268        config: FileTransferConfig,
269        api: AttaccaApiClient,
270        tofu: TofuStore,
271        confirmer: Arc<dyn PeerConfirmer>,
272        link: Arc<dyn PeerLink>,
273    ) -> LocalFileTransfer {
274        let transfer = LocalFileTransfer::pending(config, tofu, confirmer, link);
275        transfer.set_api(api);
276        transfer
277    }
278
279    /// The same thing, with the rendezvous client still to come.
280    ///
281    /// A node announces its capabilities before it connects, and the client is something the
282    /// connection hands back — so a node that registers this one has no client to give it yet.
283    /// Until [`Self::set_api`] is called, `send_to` refuses rather than pretending: there is no
284    /// way to find a peer without something to ask.
285    pub fn pending(
286        config: FileTransferConfig,
287        tofu: TofuStore,
288        confirmer: Arc<dyn PeerConfirmer>,
289        link: Arc<dyn PeerLink>,
290    ) -> LocalFileTransfer {
291        LocalFileTransfer { config, api: Rendezvous::default(), tofu, confirmer, link }
292    }
293
294    /// Plugs in the rendezvous client for the connection this node is on now.
295    ///
296    /// Call it on **every** connect, not just the first. See [`Rendezvous`].
297    pub fn set_api(&self, api: AttaccaApiClient) {
298        self.api.set(api);
299    }
300
301    /// The same handle this transfer looks peers up through, for a node that also wants to hand it
302    /// to `listen::serve_peers` as its directory — one client, kept current in one place,
303    /// serving both directions.
304    pub fn rendezvous(&self) -> Rendezvous {
305        self.api.clone()
306    }
307
308    /// Resolves a caller-supplied path **and refuses anything outside the root**.
309    ///
310    /// [`zyris_caps::resolve_under`] is explicitly not a jail — its own doc says "the root is a
311    /// default, not a jail", an absolute path sails straight through it, and it never looks at the
312    /// filesystem. Here it is used as one, so it needs the two things it does not do: the result is
313    /// canonicalized (which resolves every symlink in the path, so a link planted inside the root
314    /// that points out of it is followed to where it really goes) and then checked against the
315    /// canonicalized root.
316    ///
317    /// Order matters. Checking the *un*-canonicalized path would pass a link whose target is
318    /// outside, and canonicalizing without re-checking would pass an absolute path outright.
319    ///
320    /// **A window remains between this check and the reads that follow it.** The file is opened
321    /// again to be hashed, and a third time by `pull` when the peer asks for the bytes; anyone who
322    /// can write into the root between those points can swap the last component for a link (or a
323    /// hard link) pointing somewhere this process can read and the caller cannot. Closing it for
324    /// real means carrying one file descriptor from here through to the last read, which `pull`
325    /// re-opening by path (`super::peer`) currently makes impossible. What bounds it today is that
326    /// write access to the node's own root is already most of the way to owning the node.
327    async fn resolve_source(&self, path: &str) -> Result<PathBuf> {
328        let root = tokio::fs::canonicalize(&self.config.root).await.map_err(|e| {
329            WireError::internal(format!(
330                "this node's root {} could not be read: {e}",
331                self.config.root.display()
332            ))
333        })?;
334        let requested = zyris_caps::resolve_under(&root, path);
335        let real = tokio::fs::canonicalize(&requested).await.map_err(|e| {
336            refuse("file_not_found", format!("{} could not be read: {e}", requested.display()))
337        })?;
338        if !real.starts_with(&root) {
339            return Err(refuse(
340                "path_outside_root",
341                format!("{path} is outside this node's root and will not be sent"),
342            ));
343        }
344        let info = tokio::fs::metadata(&real)
345            .await
346            .map_err(|e| refuse("file_not_found", format!("{} could not be read: {e}", real.display())))?;
347        if !info.is_file() {
348            return Err(refuse("not_a_file", format!("{path} is not a regular file")));
349        }
350        Ok(real)
351    }
352
353    /// Asks the rendezvous which machine the caller meant.
354    ///
355    /// **A slug is neither unique nor stable, so two live nodes can answer to one name.**
356    /// `AttaccaApi::peer_lookup`'s contract is that an implementation finding that must refuse
357    /// rather than pick one, and refusing is the only safe answer: sending to an arbitrary one of
358    /// them puts the file on a machine the caller did not name, and — because the pin is per-name
359    /// — surfaces here as "this peer's key changed", a false alarm at exactly the place a real one
360    /// has to be believed.
361    ///
362    /// **That refusal is not re-implemented here, because this call cannot see the collision.**
363    /// The ambiguity is visible in `peer_list`, which returns every node with its `slug`, `node_id`
364    /// and `online` flag; `peer_lookup` returns one already-chosen `ZPeerAddr`, so by the time the
365    /// answer arrives the choice has been made and there is nothing left to count. Fetching
366    /// `peer_list` as well to count it here would put the decision on a *second*, separately-timed
367    /// answer from the same server that just gave the first — two answers that can disagree, and a
368    /// new rule about which to believe. Against a rendezvous that is lying, neither call helps:
369    /// both come from it.
370    ///
371    /// What is checked here is the one thing this call **can** see: that the answer names the peer
372    /// that was asked for. An answer about some other node is refused rather than dialed.
373    ///
374    /// The comparison ignores ASCII case, because a deployment that canonicalizes slugs may well
375    /// answer a lookup for `Laptop` with `laptop` and mean the same machine. **The pin's ledger key
376    /// does not fold case** — see [`Self::authorize_peer`]. The two are asking different questions:
377    /// this one asks whether the server answered about the peer that was named, and folding is
378    /// tolerance; that one asks what the caller actually said, and folding there would quietly
379    /// merge two slots into one.
380    async fn look_up_peer(&self, node: &str) -> Result<ZPeerAddr> {
381        let current = self.api.get();
382        let Some(api) = current else {
383            return Err(refuse(
384                "rendezvous_unavailable",
385                "this node is not connected to Attacca yet, so there is nothing to ask where \
386                 a peer is".to_string(),
387            )
388            .retriable(true));
389        };
390        let addr = api.peer_lookup(node.to_string()).await?;
391        if !addr.slug.eq_ignore_ascii_case(node) {
392            return Err(refuse(
393                "peer_lookup_mismatch",
394                format!(
395                    "asked for {node} and the rendezvous answered about {} ({}); \
396                     refusing to send to a node that was not named",
397                    addr.slug, addr.node_id
398                ),
399            ));
400        }
401        if !addr.online {
402            return Err(refuse("peer_offline", format!("{node} is not connected"))
403                .retriable(true));
404        }
405        Ok(addr)
406    }
407
408    /// Settles whether this key is the one a person confirmed for this name.
409    ///
410    /// `label` is the name **the caller asked for, verbatim** — not [`ZPeerAddr::slug`] and not
411    /// [`ZPeerAddr::node_id`]. Both of those are minted by attacca, which is the party the pin
412    /// exists to constrain: keying the ledger on either lets a substituted peer arrive under a
413    /// freshly-issued identifier, land in a slot nothing has been pinned in, pass, and get pinned
414    /// — every time, leaving no trace. The name the caller said is the one string in this call the
415    /// server has no channel to reassign, so it is the one the ledger is keyed on. See
416    /// `zyris_p2p::tofu`'s module docs and `ZPeerEntry::slug` in `zyris-attacca`.
417    ///
418    /// This is what makes the case attacca actually supports — freeing a revoked node's slug so a
419    /// new node can take it — come out as a refusal here instead of a silent redirection: the new
420    /// node answers to the same word the caller said, that word already has a different key pinned
421    /// under it, and the mismatch is exactly what a pin is for.
422    ///
423    /// [`TofuStore::authorize`], not `check` + `pin_preapproved`: those two compose into the flow
424    /// where an unknown peer is pinned on its first successful connection with nobody asked, which
425    /// is the exact substitution a pin exists to catch. `authorize` puts the person in the loop for
426    /// the unknown case and refuses a changed key outright without asking anyone.
427    ///
428    /// This runs **before** the dial, not after a successful transfer. The anchor is the person who
429    /// compared the fingerprint, not the fact that a connection happened to succeed — so there is
430    /// nothing about connecting first that makes the pin any more true, and asking first means a
431    /// changed key costs nothing to discover.
432    async fn authorize_peer(&self, label: &str, endpoint_id: &str) -> Result<()> {
433        match self.tofu.authorize(self.confirmer.as_ref(), label, endpoint_id).await {
434            Ok(()) => Ok(()),
435            Err(e @ TofuError::Changed { .. }) => {
436                // Never retriable. A retriable error invites the caller to try again, and the one
437                // thing that must not happen here is a second, quieter attempt at the same slot.
438                Err(refuse("peer_key_changed", e.to_string()))
439            }
440            Err(e @ TofuError::Refused { .. }) => Err(refuse("peer_not_confirmed", e.to_string())),
441            // A ledger that cannot be read, a key that does not parse, a lock that could not be
442            // taken. Kept apart from the two above rather than folded in with them: "a person said
443            // no" and "the pin file is corrupt" call for completely different things from whoever
444            // reads this, and one code for both would hide which happened.
445            Err(e) => Err(refuse("peer_pin_unavailable", e.to_string())),
446        }
447    }
448
449    /// `at` is moved forward as the work moves, and is read only if the deadline cuts this short.
450    /// See [`phase`] for why the answer cannot be the same in all three places.
451    async fn send_inner(
452        &self,
453        node: &str,
454        path: &str,
455        name: Option<String>,
456        overwrite: Option<bool>,
457        at: &AtomicU8,
458    ) -> Result<SendReceipt> {
459        let source = self.resolve_source(path).await?;
460        let proposed = match name {
461            Some(name) => name,
462            // `resolve_source` has already established this is a regular file, which cannot have
463            // an empty last component, so the fallback is unreachable in practice.
464            None => source.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
465        };
466        let (size, sha256) = hash_file(&source).await?;
467        let transfer_id = transfer_id(&self.config.node_id, &proposed, size, &sha256);
468        at.store(phase::REACHING, Ordering::SeqCst);
469
470        let addr = self.look_up_peer(node).await?;
471        self.authorize_peer(node, &addr.endpoint_id).await?;
472
473        // Registered before the link opens, because the peer calls `pull` back while it is
474        // answering `push_offer` — and because `open` takes the sender by value, so this is the
475        // last moment it can be reached.
476        let sender = LocalPeerTransfer::sender(self.config.root.clone());
477        sender.offer_file(transfer_id.clone(), source, size, sha256.clone()).await;
478
479        let session = self.link.open(&addr, sender).await?;
480        // From here on the peer can be holding bytes, so a deadline that lands after this line has
481        // something for the next call to come back to.
482        at.store(phase::SENDING, Ordering::SeqCst);
483        let offered = session
484            .client
485            .push_offer(TransferOffer {
486                transfer_id,
487                name: proposed,
488                size,
489                sha256,
490                overwrite: overwrite.unwrap_or(false),
491            })
492            .await;
493        let done = match offered {
494            Ok(done) => done,
495            // **Not a failure — the answer.** The peer is still writing the bytes an earlier call
496            // for this same transfer handed it, so it turned this one away rather than letting a
497            // second stream append into the same file. That is exactly what `pending` already
498            // means here, and the caller was told to call again by the receipt that produced this
499            // call; answering with an error would make that instruction a lie.
500            Err(e) if e.code == ErrorCode::Other(TRANSFER_IN_FLIGHT.to_string()) => {
501                return Ok(SendReceipt {
502                    node: node.to_string(),
503                    written: String::new(),
504                    bytes: 0,
505                    sha256: String::new(),
506                    replaced: false,
507                    undo: None,
508                    direct: self.link.is_direct(&addr.endpoint_id).await,
509                    pending: true,
510                    next: Some(
511                        "the peer is still receiving an earlier call for this same file. Call \
512                         send_to again with the same arguments once it settles."
513                            .to_string(),
514                    ),
515                })
516            }
517            Err(e) => return Err(e),
518        };
519
520        Ok(SendReceipt {
521            node: node.to_string(),
522            written: done.written,
523            bytes: done.bytes,
524            sha256: done.sha256,
525            replaced: done.replaced,
526            undo: done.undo,
527            direct: self.link.is_direct(&addr.endpoint_id).await,
528            pending: false,
529            next: None,
530        })
531    }
532}
533
534#[async_trait::async_trait]
535impl FileTransfer for LocalFileTransfer {
536    async fn send_to(
537        &self,
538        node: String,
539        path: String,
540        name: Option<String>,
541        overwrite: Option<bool>,
542    ) -> Result<SendReceipt> {
543        let deadline = self.config.wire_deadline;
544        let at = AtomicU8::new(phase::MEASURING);
545        let running = self.send_inner(&node, &path, name, overwrite, &at);
546        let expired = match tokio::time::timeout(deadline, running).await {
547            Ok(result) => return result,
548            Err(_) => at.load(Ordering::SeqCst),
549        };
550
551        // Still reading the file when time ran out. Answering `pending` here would be a promise
552        // this cannot keep: nothing has been offered, so there is no partial file on the peer, and
553        // the retry the `next` line asks for begins the same read from zero and ends in the same
554        // place. A file this node cannot hash inside one call is one it cannot send at all, and
555        // saying so once beats an agent retrying until someone notices.
556        if expired == phase::MEASURING {
557            return Err(refuse(
558                "source_too_slow_to_measure",
559                format!(
560                    "reading and hashing {path} did not finish within the {}s a call gets, so \
561                     nothing has been offered to {node}. Calling again starts the same read over; \
562                     this file cannot be sent from this node until it can be read faster.",
563                    deadline.as_secs()
564                ),
565            ));
566        }
567
568        // Not an error. Once the link is open the bytes already received are on the peer's disk as
569        // a `.part`, and the same arguments produce the same `transfer_id`, so calling again picks
570        // up from there instead of starting over. Before that there is nothing to resume yet, but
571        // the work that remains is a lookup and a dial — both worth retrying — so the shape of the
572        // answer is the same and only the sentence differs.
573        //
574        // Every measured field stays at its zero value: nothing has been confirmed written, and
575        // filling `bytes` in with the source file's size here would read as progress that has not
576        // happened.
577        let next = if expired == phase::SENDING {
578            "still sending. Call send_to again with the same arguments to resume."
579        } else {
580            "the peer had not been reached yet. Call send_to again with the same arguments."
581        };
582        Ok(SendReceipt {
583            node,
584            written: String::new(),
585            bytes: 0,
586            sha256: String::new(),
587            replaced: false,
588            undo: None,
589            direct: false,
590            pending: true,
591            next: Some(next.to_string()),
592        })
593    }
594
595    async fn inbox_list(&self) -> Result<Vec<InboxEntry>> {
596        let mut entries = Vec::new();
597        let mut peers = match tokio::fs::read_dir(&self.config.inbox).await {
598            Ok(dir) => dir,
599            // An inbox nobody has written to yet does not exist, and that is an empty list rather
600            // than a failure.
601            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
602            Err(e) => return Err(WireError::internal(e.to_string())),
603        };
604        // `while let Ok(Some(..))` would end the walk on a read error as if the directory had
605        // simply ended, and hand back a short list as though it were the whole one. A caller asking
606        // what arrived is worse served by a confident wrong answer than by a failure.
607        loop {
608            let peer = match peers.next_entry().await {
609                Ok(Some(peer)) => peer,
610                Ok(None) => break,
611                Err(e) => {
612                    return Err(WireError::internal(format!(
613                        "{} could not be listed in full: {e}",
614                        self.config.inbox.display()
615                    )))
616                }
617            };
618            // Received files always sit one level down, under the sending peer's washed name
619            // (`Inbox::resolve`). Anything else at the top level is not something this node put
620            // there.
621            if !peer.path().is_dir() {
622                continue;
623            }
624            let from = peer.file_name().to_string_lossy().into_owned();
625            // A sender's directory that has been removed since the walk above listed it is simply
626            // gone, and skipping it is right. Any other failure is not "there is nothing here".
627            let mut files = match tokio::fs::read_dir(peer.path()).await {
628                Ok(files) => files,
629                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
630                Err(e) => {
631                    return Err(WireError::internal(format!(
632                        "{} could not be listed: {e}",
633                        peer.path().display()
634                    )))
635                }
636            };
637            loop {
638                let file = match files.next_entry().await {
639                    Ok(Some(file)) => file,
640                    Ok(None) => break,
641                    Err(e) => {
642                        return Err(WireError::internal(format!(
643                            "{} could not be listed in full: {e}",
644                            peer.path().display()
645                        )))
646                    }
647                };
648                let name = file.file_name().to_string_lossy().into_owned();
649                // A transfer still in flight is not something that has arrived. This also hides a
650                // genuinely received file whose own name ends in `.part`, which is the safer way
651                // round: listing an unfinished transfer as a delivered file is the worse mistake.
652                if name.ends_with(".part") {
653                    continue;
654                }
655                let Ok(info) = file.metadata().await else { continue };
656                if !info.is_file() {
657                    continue;
658                }
659                entries.push(InboxEntry {
660                    from: from.clone(),
661                    name,
662                    bytes: info.len(),
663                    path: file.path().display().to_string(),
664                    received_unix_ms: modified_ms(&info),
665                });
666            }
667        }
668        // Newest first, and ties broken by path so the order does not depend on what order the
669        // filesystem handed the directory back in.
670        entries.sort_by(|a, b| {
671            b.received_unix_ms.cmp(&a.received_unix_ms).then_with(|| a.path.cmp(&b.path))
672        });
673        Ok(entries)
674    }
675}
676
677/// The name of one transfer, derived so that **calling `send_to` again with the same arguments
678/// resumes rather than restarts** — the receiving side finds the `.part` it already has under this
679/// same id.
680///
681/// The fields are separated by a byte that cannot occur in any of them rather than concatenated
682/// raw. Without a separator, `("ab", "c")` and `("a", "bc")` hash identically, which would let two
683/// genuinely different transfers claim one another's partial file. `size` goes in as fixed-width
684/// big-endian for the same reason.
685///
686/// The receiving side's path is deliberately not an ingredient: the destination is the receiver's
687/// choice, so the sender cannot know it before asking, and a retry has to produce the same id
688/// *before* anything is asked.
689fn transfer_id(node_id: &str, name: &str, size: u64, sha256: &str) -> String {
690    let mut hasher = Sha256::new();
691    hasher.update(node_id.as_bytes());
692    hasher.update([0]);
693    hasher.update(name.as_bytes());
694    hasher.update([0]);
695    hasher.update(size.to_be_bytes());
696    hasher.update([0]);
697    hasher.update(sha256.as_bytes());
698    hex::encode(&hasher.finalize()[..TRANSFER_ID_BYTES])
699}
700
701/// Measures the file: its size and its sha256, read in one pass through a fixed buffer.
702///
703/// The size comes from the bytes actually read, not from `metadata().len()`. Those two can
704/// disagree — the file can be appended to or truncated between the two calls — and the one that has
705/// to be right is the one the peer will check its own hash against.
706async fn hash_file(path: &Path) -> Result<(u64, String)> {
707    use tokio::io::AsyncReadExt as _;
708    let mut file = tokio::fs::File::open(path)
709        .await
710        .map_err(|e| refuse("file_not_found", format!("{} could not be read: {e}", path.display())))?;
711    let mut hasher = Sha256::new();
712    let mut buf = vec![0u8; HASH_CHUNK];
713    let mut size = 0u64;
714    loop {
715        let read = file.read(&mut buf).await.map_err(|e| WireError::internal(e.to_string()))?;
716        if read == 0 {
717            break;
718        }
719        size += read as u64;
720        hasher.update(&buf[..read]);
721    }
722    Ok((size, hex::encode(hasher.finalize())))
723}
724
725fn modified_ms(info: &std::fs::Metadata) -> u64 {
726    info.modified()
727        .ok()
728        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
729        .map(|d| d.as_millis() as u64)
730        .unwrap_or(0)
731}
732
733/// A refusal, spelled with the code from the design's error table. Not retriable unless the caller
734/// says otherwise: `ErrorCode::Other`'s own default is already `false`, and every refusal in this
735/// module that a caller *should* try again is marked so explicitly at its own site.
736fn refuse(code: &str, message: String) -> WireError {
737    WireError::new(ErrorCode::Other(code.to_string()), message).retriable(false)
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    #[test]
745    fn the_same_arguments_always_name_the_same_transfer() {
746        // This is what makes a repeated `send_to` a resume instead of a restart.
747        let a = transfer_id("node-1", "report.pdf", 1024, "abc");
748        assert_eq!(a, transfer_id("node-1", "report.pdf", 1024, "abc"));
749        assert_eq!(a.len(), TRANSFER_ID_BYTES * 2, "{a} should be 16 bytes of hex");
750    }
751
752    #[test]
753    fn a_different_transfer_never_borrows_another_ones_name() {
754        let base = transfer_id("node-1", "report.pdf", 1024, "abc");
755        assert_ne!(base, transfer_id("node-2", "report.pdf", 1024, "abc"));
756        assert_ne!(base, transfer_id("node-1", "notes.pdf", 1024, "abc"));
757        assert_ne!(base, transfer_id("node-1", "report.pdf", 1025, "abc"));
758        assert_ne!(base, transfer_id("node-1", "report.pdf", 1024, "abd"));
759    }
760
761    #[test]
762    fn fields_cannot_be_slid_across_the_boundary_between_them() {
763        // Concatenating without a separator makes these two collide, and the receiving side would
764        // then hand one transfer the partial file belonging to the other.
765        assert_ne!(
766            transfer_id("node", "1report.pdf", 1024, "abc"),
767            transfer_id("node1", "report.pdf", 1024, "abc")
768        );
769    }
770}