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}