1use crate::codec::compress::{self, FileHint};
18use crate::codec::crypto::{Handshake, Role, Sealer, HANDSHAKE_MSG_LEN};
19use crate::config::Config;
20use crate::error::{Error, Result};
21use crate::io::ChunkReader;
22use crate::manifest::{self, Manifest, Source};
23use crate::metrics::{Metrics, Progress, ProgressFn};
24use crate::pool::{BufPool, ObjPool};
25use crate::resume::ChunkBitmap;
26use crate::transport::{BoxRecv, BoxSend, Transport};
27use crate::wire::{self, Control, EntryKind, FrameHeader};
28use futures_util::stream::{FuturesOrdered, StreamExt};
29use parking_lot::Mutex;
30use std::collections::HashMap;
31use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
32use std::sync::Arc;
33use tokio::io::AsyncWriteExt;
34use tokio::sync::mpsc;
35
36struct ChunkJob {
38 file_id: u32,
39 chunk_index: u64,
40 offset: u64,
41 len: usize,
42 last: bool,
43 hash_only: bool,
47 progress: Option<Arc<FileProgress>>,
49 peer_hash: Option<[u8; 32]>,
52 cached_hash: Option<[u8; 32]>,
55 reader: Arc<ChunkReader>,
56 hint: FileHint,
57}
58
59struct EncodedChunk {
62 frame: Vec<u8>,
63 file_id: u32,
64 chunk_index: u64,
65 raw_len: usize,
66 compressed: bool,
67 hash: [u8; 32],
69 skipped: bool,
71 reused: bool,
73 compressor_ran: bool,
75}
76
77#[inline]
87pub fn is_all_zero(buf: &[u8]) -> bool {
88 const PREFIX: usize = 64;
89 if buf.len() >= PREFIX && buf[..PREFIX].iter().any(|&b| b != 0) {
90 return false;
91 }
92 let mut words = buf.chunks_exact(8);
93 let folded = words
94 .by_ref()
95 .map(|w| u64::from_ne_bytes(w.try_into().expect("chunks_exact(8) yields 8 bytes")))
96 .fold(0u64, |acc, w| acc | w);
97 folded == 0 && words.remainder().iter().all(|&b| b == 0)
98}
99
100struct ChunkCursor {
102 entry_idx: usize,
103 chunk_index: u64,
104 reader: Option<Arc<ChunkReader>>,
105 hint: FileHint,
106}
107
108struct CursorShared {
109 manifest: Arc<Manifest>,
110 resume: HashMap<u32, ChunkBitmap>,
112 state: Mutex<ChunkCursor>,
113 metrics: Metrics,
114 outstanding: HashMap<u32, Arc<FileProgress>>,
117 verify_hashes: bool,
120 peer_index: HashMap<u32, Vec<[u8; 32]>>,
122 own_index: HashMap<u32, Vec<[u8; 32]>>,
125}
126
127const COMPRESSION_PROBE_CHUNKS: u32 = 8;
129const COMPRESSION_WIN_RATE: f32 = 0.25;
131
132struct FileProgress {
133 remaining: AtomicU64,
134 chunk_hashes: Mutex<Vec<[u8; 32]>>,
135 announced: AtomicBool,
136 probe_attempts: AtomicU32,
138 probe_wins: AtomicU32,
140 give_up: AtomicBool,
142}
143
144impl FileProgress {
145 fn should_compress(&self) -> bool {
156 !self.give_up.load(Ordering::Relaxed)
157 }
158
159 fn record_compression(&self, won: bool) {
161 if self.give_up.load(Ordering::Relaxed) {
162 return;
163 }
164 if won {
165 self.probe_wins.fetch_add(1, Ordering::Relaxed);
166 }
167 let attempts = self.probe_attempts.fetch_add(1, Ordering::Relaxed) + 1;
168 if attempts < COMPRESSION_PROBE_CHUNKS {
169 return;
170 }
171 let wins = self.probe_wins.load(Ordering::Relaxed);
172 if (wins as f32) < attempts as f32 * COMPRESSION_WIN_RATE {
173 self.give_up.store(true, Ordering::Relaxed);
175 }
176 }
177}
178
179impl CursorShared {
180 fn next_job(&self) -> Result<Option<ChunkJob>> {
185 let mut st = self.state.lock();
186 loop {
187 let Some(entry) = self.manifest.entries.get(st.entry_idx) else {
188 return Ok(None);
189 };
190
191 if entry.kind != EntryKind::File || entry.size == 0 {
192 st.entry_idx += 1;
193 st.chunk_index = 0;
194 st.reader = None;
195 continue;
196 }
197
198 let total = entry.chunk_count();
199 if st.chunk_index >= total {
200 st.entry_idx += 1;
201 st.chunk_index = 0;
202 st.reader = None;
203 continue;
204 }
205
206 if st.reader.is_none() {
208 let path = &self.manifest.local_paths[st.entry_idx];
209 let r = ChunkReader::open(path)?;
210 if r.len() != entry.size {
213 return Err(Error::Io(std::io::Error::other(format!(
214 "{} changed size during transfer ({} -> {})",
215 path.display(),
216 entry.size,
217 r.len()
218 ))));
219 }
220 st.reader = Some(Arc::new(r));
221 st.hint = FileHint {
222 known_incompressible: entry.incompressible,
223 audio: self.manifest.audio.get(st.entry_idx).copied().flatten(),
224 chunk_offset: 0,
225 };
226 }
227
228 let idx = st.chunk_index;
229 st.chunk_index += 1;
230
231 let chunk_size = entry.chunk_size as u64;
232 let offset = idx * chunk_size;
233 let len = chunk_size.min(entry.size - offset) as usize;
234 let last = idx + 1 == total;
235
236 let already_there = self
238 .resume
239 .get(&entry.file_id)
240 .is_some_and(|bm| bm.get(idx));
241
242 if already_there && !self.verify_hashes {
243 self.metrics.chunk_skipped(len as u64);
245 if let Some(fp) = self.outstanding.get(&entry.file_id) {
246 fp.remaining.fetch_sub(1, Ordering::AcqRel);
247 }
248 continue;
249 }
250
251 return Ok(Some(ChunkJob {
252 file_id: entry.file_id,
253 chunk_index: idx,
254 offset,
255 len,
256 last,
257 hash_only: already_there,
258 progress: self.outstanding.get(&entry.file_id).cloned(),
259 peer_hash: self
260 .peer_index
261 .get(&entry.file_id)
262 .and_then(|h| h.get(idx as usize))
263 .copied(),
264 cached_hash: self
265 .own_index
266 .get(&entry.file_id)
267 .and_then(|h| h.get(idx as usize))
268 .copied(),
269 reader: st.reader.clone().expect("reader opened above"),
270 hint: FileHint {
271 chunk_offset: offset,
272 ..st.hint
273 },
274 }));
275 }
276 }
277}
278
279fn encode_chunk(
281 job: &ChunkJob,
282 cfg: &Config,
283 sealer: &mut Sealer,
284 pool: &BufPool,
285) -> Result<EncodedChunk> {
286 if let (Some(mine), Some(theirs)) = (job.cached_hash, job.peer_hash) {
290 if mine == theirs {
291 let mut frame = pool.take();
292 frame.resize(wire::FRAME_HEADER_LEN, 0);
293 let mut flags = wire::flags::REUSE_LOCAL;
294 if job.last {
295 flags |= wire::flags::LAST_CHUNK;
296 }
297 let header = FrameHeader {
298 flags,
299 algorithm: compress::Algorithm::None,
300 file_id: job.file_id,
301 chunk_index: job.chunk_index,
302 epoch: 0,
303 raw_len: job.len as u32,
304 payload_len: 0,
305 };
306 let head: &mut [u8; wire::FRAME_HEADER_LEN] = (&mut frame[..])
307 .try_into()
308 .expect("frame is exactly a header");
309 header.encode(head);
310 return Ok(EncodedChunk {
311 frame,
312 file_id: job.file_id,
313 chunk_index: job.chunk_index,
314 raw_len: job.len,
315 compressed: false,
316 hash: mine,
317 skipped: false,
318 reused: true,
319 compressor_ran: false,
320 });
321 }
322 }
323
324 const HDR: usize = wire::FRAME_HEADER_LEN;
329 let mut frame = pool.take();
330 frame.resize(HDR + job.len, 0);
331 let n = job.reader.read_at(job.offset, &mut frame[HDR..])?;
332 if n != job.len {
333 return Err(Error::Io(std::io::Error::new(
334 std::io::ErrorKind::UnexpectedEof,
335 format!(
336 "short read at offset {} of file {}: wanted {}, got {n}",
337 job.offset, job.file_id, job.len
338 ),
339 )));
340 }
341
342 let hash = *blake3::hash(&frame[HDR..]).as_bytes();
343
344 if job.hash_only {
345 let raw_len = frame.len() - HDR;
348 pool.put(frame);
349 return Ok(EncodedChunk {
350 frame: Vec::new(),
351 file_id: job.file_id,
352 chunk_index: job.chunk_index,
353 raw_len,
354 compressed: false,
355 hash,
356 skipped: true,
357 reused: false,
358 compressor_ran: false,
359 });
360 }
361
362 if job.peer_hash == Some(hash) {
365 let raw_len = frame.len() - HDR;
366 frame.truncate(HDR);
367 let mut flags = wire::flags::REUSE_LOCAL;
368 if job.last {
369 flags |= wire::flags::LAST_CHUNK;
370 }
371 let header = FrameHeader {
372 flags,
373 algorithm: compress::Algorithm::None,
374 file_id: job.file_id,
375 chunk_index: job.chunk_index,
376 epoch: 0,
377 raw_len: raw_len as u32,
378 payload_len: 0,
379 };
380 let head: &mut [u8; wire::FRAME_HEADER_LEN] = (&mut frame[..])
381 .try_into()
382 .expect("frame is exactly a header");
383 header.encode(head);
384 return Ok(EncodedChunk {
385 frame,
386 file_id: job.file_id,
387 chunk_index: job.chunk_index,
388 raw_len,
389 compressed: false,
390 hash,
391 skipped: false,
392 reused: true,
393 compressor_ran: false,
394 });
395 }
396
397 if cfg.sparse && is_all_zero(&frame[HDR..]) {
400 let raw_len = frame.len() - HDR;
401 frame.truncate(HDR);
402 let mut flags = wire::flags::ZERO;
403 if job.last {
404 flags |= wire::flags::LAST_CHUNK;
405 }
406 let header = FrameHeader {
407 flags,
408 algorithm: compress::Algorithm::None,
409 file_id: job.file_id,
410 chunk_index: job.chunk_index,
411 epoch: 0,
412 raw_len: raw_len as u32,
413 payload_len: 0,
414 };
415 let head: &mut [u8; wire::FRAME_HEADER_LEN] = (&mut frame[..])
416 .try_into()
417 .expect("frame is exactly a header");
418 header.encode(head);
419 return Ok(EncodedChunk {
420 frame,
421 file_id: job.file_id,
422 chunk_index: job.chunk_index,
423 raw_len,
424 compressed: false,
425 hash,
426 skipped: false,
427 reused: false,
428 compressor_ran: false,
429 });
430 }
431
432 let mut hint = job.hint;
435 let probing =
436 !hint.known_incompressible && job.progress.as_ref().is_some_and(|p| !p.should_compress());
437 if probing {
438 hint.known_incompressible = true;
439 }
440
441 let enc =
442 compress::with_codec(|c| c.compress_in_place(&cfg.compression, hint, &mut frame, HDR))?;
443
444 if !hint.known_incompressible {
445 if let Some(p) = job.progress.as_ref() {
446 p.record_compression(enc.algorithm != compress::Algorithm::None);
447 }
448 }
449
450 let payload_len = frame.len() - HDR;
451 let sealed = !sealer.is_passthrough();
452
453 let mut flags = 0u8;
454 if job.last {
455 flags |= wire::flags::LAST_CHUNK;
456 }
457 if sealed {
458 flags |= wire::flags::SEALED;
459 }
460
461 let header = FrameHeader {
462 flags,
463 algorithm: enc.algorithm,
464 file_id: job.file_id,
465 chunk_index: job.chunk_index,
466 epoch: 0,
470 raw_len: enc.raw_len as u32,
471 payload_len: payload_len as u32,
472 };
473 let (head, body) = frame.split_at_mut(wire::FRAME_HEADER_LEN);
474 let head: &mut [u8; wire::FRAME_HEADER_LEN] = head.try_into().expect("split at header length");
475 header.encode(head);
476
477 if sealed {
478 let tag = sealer.seal(job.file_id, job.chunk_index, 0, head, body)?;
481 frame.extend_from_slice(&tag);
482 }
483
484 Ok(EncodedChunk {
485 frame,
486 file_id: job.file_id,
487 chunk_index: job.chunk_index,
488 raw_len: enc.raw_len,
489 compressed: enc.algorithm != compress::Algorithm::None,
490 hash,
491 skipped: false,
492 reused: false,
493 compressor_ran: !hint.known_incompressible,
494 })
495}
496
497pub async fn send(
499 transport: Arc<dyn Transport>,
500 sources: &[Source],
501 cfg: &Config,
502 progress: Option<ProgressFn>,
503) -> Result<Progress> {
504 cfg.validate()?;
505 let metrics = Metrics::new();
506
507 let (mut ctl_w, mut ctl_r) = transport.open_bi().await?;
509
510 let hs = Handshake::new(Role::Initiator, &cfg.secrecy, cfg.cipher);
511 ctl_w.write_all(hs.message()).await?;
512 ctl_w.flush().await?;
513 let mut peer = [0u8; HANDSHAKE_MSG_LEN];
514 tokio::time::timeout(cfg.handshake_timeout, read_exact(&mut ctl_r, &mut peer))
515 .await
516 .map_err(|_| Error::Handshake("timed out waiting for the peer's handshake".into()))??;
517 let crypto = Arc::new(hs.finish(&peer)?);
518
519 let manifest = Arc::new(manifest::build(sources, cfg).await?);
522 metrics.set_totals(manifest.file_count() as u64, manifest.total_bytes());
523 tracing::info!(
524 files = manifest.file_count(),
525 bytes = manifest.total_bytes(),
526 peer = %transport.peer_label(),
527 "manifest ready"
528 );
529
530 wire::write_control(&mut ctl_w, &Control::Manifest(manifest.entries.clone())).await?;
531
532 let mut peer_index: HashMap<u32, Vec<[u8; 32]>> = HashMap::new();
535 let resume = loop {
536 match wire::read_control(&mut ctl_r, cfg.max_frame_bytes, cfg.max_manifest_entries).await? {
537 Control::LocalIndex(entries) => {
538 for e in entries {
539 peer_index.entry(e.file_id).or_default().extend(e.hashes);
540 }
541 continue;
542 }
543 other => break other,
544 }
545 };
546 let resume = match resume {
547 Control::ResumeState(entries) => {
548 let mut map = HashMap::new();
549 for e in entries {
550 let Some(me) = manifest.entries.iter().find(|m| m.file_id == e.file_id) else {
551 continue;
552 };
553 let bm = ChunkBitmap::from_bytes(e.have, me.chunk_count())?;
556 if bm.count() > 0 {
557 map.insert(e.file_id, bm);
558 }
559 }
560 map
561 }
562 Control::Abort { reason } => return Err(Error::Closed(reason)),
563 other => {
564 return Err(Error::protocol(format!(
565 "expected ResumeState, got {other:?}"
566 )))
567 }
568 };
569
570 let mut own_index: HashMap<u32, Vec<[u8; 32]>> = HashMap::new();
573 if cfg.delta && cfg.trust_mtime && !peer_index.is_empty() {
574 if let Some(root) = index_root(sources) {
575 let cache = crate::index::ChunkIndex::load(&root);
576 for (i, e) in manifest.entries.iter().enumerate() {
577 if e.kind != EntryKind::File || !peer_index.contains_key(&e.file_id) {
578 continue;
579 }
580 let Some(path) = manifest.local_paths.get(i) else {
581 continue;
582 };
583 let Ok(meta) = std::fs::metadata(path) else {
584 continue;
585 };
586 if let Some(h) = cache.get(
587 &e.path,
588 meta.len(),
589 crate::index::mtime_of(&meta),
590 e.chunk_size,
591 ) {
592 own_index.insert(e.file_id, h.to_vec());
593 }
594 }
595 }
596 }
597
598 let resumed_chunks: u64 = resume.values().map(|b| b.count()).sum();
599 if resumed_chunks > 0 {
600 tracing::info!(
601 chunks = resumed_chunks,
602 "receiver already holds chunks; skipping them"
603 );
604 }
605
606 let mut outstanding = HashMap::new();
609 for e in &manifest.entries {
610 if e.kind == EntryKind::File {
611 let total = e.chunk_count();
612 outstanding.insert(
613 e.file_id,
614 Arc::new(FileProgress {
615 remaining: AtomicU64::new(total),
616 chunk_hashes: Mutex::new(vec![[0u8; 32]; total as usize]),
617 announced: AtomicBool::new(false),
618 probe_attempts: AtomicU32::new(0),
619 probe_wins: AtomicU32::new(0),
620 give_up: AtomicBool::new(false),
621 }),
622 );
623 }
624 }
625
626 let shared = Arc::new(CursorShared {
627 manifest: manifest.clone(),
628 resume,
629 state: Mutex::new(ChunkCursor {
630 entry_idx: 0,
631 chunk_index: 0,
632 reader: None,
633 hint: FileHint::default(),
634 }),
635 metrics: metrics.clone(),
636 outstanding,
637 verify_hashes: cfg.verify_hashes,
638 peer_index,
639 own_index,
640 });
641
642 let (ctl_tx, mut ctl_rx) = mpsc::unbounded_channel::<Control>();
645 for e in &manifest.entries {
646 if e.kind != EntryKind::File {
647 continue;
648 }
649 let fp = &shared.outstanding[&e.file_id];
650 if fp.remaining.load(Ordering::Acquire) == 0 && !fp.announced.swap(true, Ordering::AcqRel) {
651 let _ = ctl_tx.send(Control::FileComplete {
655 file_id: e.file_id,
656 hash: None,
657 });
658 metrics.file_done();
659 }
660 }
661
662 wire::write_control(
663 &mut ctl_w,
664 &Control::Start {
665 streams: cfg.streams as u32,
666 },
667 )
668 .await?;
669
670 let ctl_task = tokio::spawn(async move {
675 let mut failure = None;
676 while let Some(msg) = ctl_rx.recv().await {
677 if let Err(e) = wire::write_control(&mut ctl_w, &msg).await {
678 failure = Some(e);
679 break;
680 }
681 }
682 if failure.is_none() {
683 if let Err(e) = wire::write_control(&mut ctl_w, &Control::AllComplete).await {
684 failure = Some(e);
685 }
686 }
687 (ctl_w, failure)
692 });
693
694 let cpu = Arc::new(
698 rayon::ThreadPoolBuilder::new()
699 .num_threads(cfg.workers)
700 .thread_name(|i| format!("rst-encode-{i}"))
701 .build()
702 .map_err(|e| Error::Worker(e.to_string()))?,
703 );
704
705 let pool = BufPool::new(
707 cfg.streams * cfg.queue_depth * 2 + cfg.workers * 2,
708 cfg.chunk_size + cfg.chunk_size / 8,
709 );
710
711 let sealers: Arc<ObjPool<Sealer>> = ObjPool::new(cfg.workers + cfg.streams);
714
715 let progress_task = progress.map(|f| spawn_progress(metrics.clone(), f));
716
717 let mut stream_tasks = Vec::with_capacity(cfg.streams);
718 for _ in 0..cfg.streams {
719 let sink = transport.open_uni().await?;
720 stream_tasks.push(tokio::spawn(run_stream(
721 sink,
722 shared.clone(),
723 cfg.clone(),
724 sealers.clone(),
725 crypto.clone(),
726 cpu.clone(),
727 pool.clone(),
728 metrics.clone(),
729 ctl_tx.clone(),
730 )));
731 }
732 drop(ctl_tx);
734
735 let mut first_err = None;
736 for t in stream_tasks {
737 match t.await {
738 Ok(Ok(())) => {}
739 Ok(Err(e)) => {
740 tracing::error!(error = %e, "data stream failed");
741 first_err.get_or_insert(e);
742 }
743 Err(e) => {
744 first_err.get_or_insert(Error::Worker(e.to_string()));
745 }
746 }
747 }
748
749 let (mut ctl_w, ctl_failure) = ctl_task.await.map_err(|e| Error::Worker(e.to_string()))?;
750
751 if let Some(e) = first_err.or(ctl_failure) {
752 return Err(e);
753 }
754
755 loop {
759 match wire::read_control(&mut ctl_r, cfg.max_frame_bytes, cfg.max_manifest_entries).await {
760 Ok(Control::AllComplete) => break,
761 Ok(Control::Abort { reason }) => return Err(Error::Closed(reason)),
762 Ok(_) => continue,
763 Err(Error::Closed(m)) => {
764 return Err(Error::Closed(format!(
765 "receiver closed before confirming completion: {m}"
766 )))
767 }
768 Err(e) => return Err(e),
769 }
770 }
771
772 let _ = ctl_w.shutdown().await;
776
777 if let Some(p) = progress_task {
778 p.abort();
779 }
780 if cfg.delta && cfg.trust_mtime {
783 if let Some(root) = index_root(sources) {
784 let mut cache = crate::index::ChunkIndex::load(&root);
785 let mut seen = std::collections::HashSet::new();
786 for (i, e) in manifest.entries.iter().enumerate() {
787 if e.kind != EntryKind::File || e.size == 0 {
788 continue;
789 }
790 seen.insert(e.path.clone());
791 let Some(fp) = shared.outstanding.get(&e.file_id) else {
792 continue;
793 };
794 let hashes = fp.chunk_hashes.lock().clone();
795 if hashes.is_empty() || hashes.iter().all(|h| *h == [0u8; 32]) {
796 continue;
797 }
798 if let Some(meta) = manifest
799 .local_paths
800 .get(i)
801 .and_then(|p| std::fs::metadata(p).ok())
802 {
803 cache.insert(
804 &e.path,
805 meta.len(),
806 crate::index::mtime_of(&meta),
807 e.chunk_size,
808 hashes,
809 );
810 }
811 }
812 cache.retain(&seen);
813 if let Err(err) = cache.save() {
814 tracing::warn!(error = %err, "could not persist the source chunk index");
815 }
816 }
817 }
818
819 let final_snapshot = metrics.snapshot();
820 tracing::info!(summary = %final_snapshot, "send complete");
821 Ok(final_snapshot)
825}
826
827#[allow(clippy::too_many_arguments)]
829async fn run_stream(
830 mut sink: BoxSend,
831 shared: Arc<CursorShared>,
832 cfg: Config,
833 sealers: Arc<ObjPool<Sealer>>,
834 crypto: Arc<crate::codec::crypto::SessionCrypto>,
835 cpu: Arc<rayon::ThreadPool>,
836 pool: Arc<BufPool>,
837 metrics: Metrics,
838 ctl: mpsc::UnboundedSender<Control>,
839) -> Result<()> {
840 let mut inflight = FuturesOrdered::new();
841 let mut drained = false;
842
843 loop {
844 while !drained && inflight.len() < cfg.queue_depth {
846 match shared.next_job()? {
847 Some(job) => {
848 let (tx, rx) = tokio::sync::oneshot::channel();
849 let cfg2 = cfg.clone();
850 let sealers2 = sealers.clone();
851 let crypto2 = crypto.clone();
852 let pool2 = pool.clone();
853 cpu.spawn(move || {
854 let mut sealer = sealers2.take_or(|| crypto2.sealer());
857 let r = encode_chunk(&job, &cfg2, &mut sealer, &pool2);
858 sealers2.put(sealer);
859 let _ = tx.send(r);
862 });
863 inflight.push_back(rx);
864 }
865 None => drained = true,
866 }
867 }
868
869 let Some(res) = inflight.next().await else {
870 break;
871 };
872 let encoded = res.map_err(|_| Error::Worker("encode worker vanished".into()))??;
873
874 if encoded.compressor_ran {
875 metrics.compressor_ran();
876 }
877 if encoded.skipped {
878 metrics.chunk_skipped(encoded.raw_len as u64);
879 } else {
880 let is_hole = encoded.frame.len() == wire::FRAME_HEADER_LEN && !encoded.reused;
881 sink.write_all(&encoded.frame).await?;
882 let wire_len = encoded.frame.len() as u64;
883 pool.put(encoded.frame);
884 if encoded.reused {
885 metrics.chunk_reused(encoded.raw_len as u64, wire_len);
886 } else if is_hole {
887 metrics.chunk_zero(encoded.raw_len as u64, wire_len);
888 } else {
889 metrics.chunk_done(encoded.raw_len as u64, wire_len, encoded.compressed);
890 }
891 }
892
893 if let Some(fp) = shared.outstanding.get(&encoded.file_id) {
897 if cfg.verify_hashes {
898 let mut hashes = fp.chunk_hashes.lock();
899 if let Some(slot) = hashes.get_mut(encoded.chunk_index as usize) {
900 *slot = encoded.hash;
901 }
902 }
903 let left = fp.remaining.fetch_sub(1, Ordering::AcqRel) - 1;
904 if left == 0 && !fp.announced.swap(true, Ordering::AcqRel) {
905 let hash = if cfg.verify_hashes {
906 Some(merkle_root(&fp.chunk_hashes.lock()))
907 } else {
908 None
909 };
910 let _ = ctl.send(Control::FileComplete {
911 file_id: encoded.file_id,
912 hash,
913 });
914 metrics.file_done();
915 }
916 }
917 }
918
919 sink.shutdown().await?;
922 Ok(())
923}
924
925fn index_root(sources: &[Source]) -> Option<std::path::PathBuf> {
928 let first = sources.first()?;
929 if first.path.is_dir() {
930 Some(first.path.clone())
931 } else {
932 first.path.parent().map(|p| p.to_path_buf())
933 }
934}
935
936pub fn merkle_root(chunk_hashes: &[[u8; 32]]) -> [u8; 32] {
943 let mut h = blake3::Hasher::new();
944 for c in chunk_hashes {
945 h.update(c);
946 }
947 *h.finalize().as_bytes()
948}
949
950fn spawn_progress(metrics: Metrics, f: ProgressFn) -> tokio::task::JoinHandle<()> {
951 tokio::spawn(async move {
952 let mut tick = tokio::time::interval(std::time::Duration::from_millis(500));
953 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
954 loop {
955 tick.tick().await;
956 f(metrics.snapshot());
957 }
958 })
959}
960
961async fn read_exact(r: &mut BoxRecv, buf: &mut [u8]) -> Result<()> {
962 use tokio::io::AsyncReadExt;
963 r.read_exact(buf).await.map_err(|e| {
964 if e.kind() == std::io::ErrorKind::UnexpectedEof {
965 Error::Closed("peer closed during handshake".into())
966 } else {
967 Error::Io(e)
968 }
969 })?;
970 Ok(())
971}