iroh_db_blobs/
engine.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    future::Future,
4    pin::Pin,
5    sync::{
6        Arc, Mutex,
7        atomic::{AtomicBool, AtomicU64, Ordering},
8    },
9    time::{Duration, Instant},
10};
11
12use chacha20poly1305::{
13    XChaCha20Poly1305, XNonce,
14    aead::{Aead as _, KeyInit as _, Payload},
15};
16use iroh_db_core::{BlobHash, DomainId};
17use iroh_db_security::DomainKey;
18use iroh_db_store::{BlobStore, BlobStoreError};
19use tokio::{
20    io::{AsyncRead, AsyncReadExt as _},
21    sync::Notify,
22};
23
24use crate::{
25    BlobError, BlobRef, BlobStream, IrohBlobStore,
26    model::{ChunkDescriptor, ManifestBody, ManifestEnvelope},
27    scheduler::{
28        BlobPriority, FetchScheduler, PrioritySignal, ProviderBook, ProviderMetrics, TransferPermit,
29    },
30    stream::validate_manifest,
31};
32
33pub(crate) const FORMAT_VERSION: u16 = 1;
34pub(crate) const MIN_CHUNK_SIZE: usize = 64 * 1024;
35pub(crate) const MAX_CHUNK_SIZE: usize = 8 * 1024 * 1024;
36pub(crate) const DEFAULT_CHUNK_SIZE: usize = 4 * 1024 * 1024;
37
38/// Bounded remote streaming policy.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct FetchOptions {
41    pub priority: BlobPriority,
42    pub prefetch_chunks: u8,
43}
44
45impl Default for FetchOptions {
46    fn default() -> Self {
47        Self {
48            priority: BlobPriority::Normal,
49            prefetch_chunks: 2,
50        }
51    }
52}
53
54#[derive(Default)]
55struct FetchCounters {
56    provider_attempts: AtomicU64,
57    chunks_fetched: AtomicU64,
58    bytes_fetched: AtomicU64,
59    retries: AtomicU64,
60    hedged_attempts: AtomicU64,
61    cache_hits: AtomicU64,
62}
63
64/// Live, lock-free counters for one lazy multi-provider stream.
65#[derive(Clone)]
66pub struct FetchMetrics {
67    counters: Arc<FetchCounters>,
68    providers: Arc<Mutex<ProviderBook>>,
69}
70
71impl Default for FetchMetrics {
72    fn default() -> Self {
73        Self::new(0)
74    }
75}
76
77impl FetchMetrics {
78    fn new(providers: usize) -> Self {
79        Self {
80            counters: Arc::new(FetchCounters::default()),
81            providers: Arc::new(Mutex::new(ProviderBook::new(providers))),
82        }
83    }
84
85    pub fn provider_attempts(&self) -> u64 {
86        self.counters.provider_attempts.load(Ordering::Relaxed)
87    }
88
89    pub fn chunks_fetched(&self) -> u64 {
90        self.counters.chunks_fetched.load(Ordering::Relaxed)
91    }
92
93    pub fn bytes_fetched(&self) -> u64 {
94        self.counters.bytes_fetched.load(Ordering::Relaxed)
95    }
96
97    pub fn retries(&self) -> u64 {
98        self.counters.retries.load(Ordering::Relaxed)
99    }
100
101    pub fn hedged_attempts(&self) -> u64 {
102        self.counters.hedged_attempts.load(Ordering::Relaxed)
103    }
104
105    pub fn cache_hits(&self) -> u64 {
106        self.counters.cache_hits.load(Ordering::Relaxed)
107    }
108
109    pub fn providers(&self) -> Vec<ProviderMetrics> {
110        self.providers
111            .lock()
112            .map_or_else(|_| Vec::new(), |book| book.snapshots(Instant::now()))
113    }
114}
115
116#[derive(Clone)]
117struct ProviderPool {
118    local: IrohBlobStore,
119    claims: Arc<dyn BlobStore>,
120    providers: Arc<Vec<iroh::endpoint::Connection>>,
121    scheduler: FetchScheduler,
122    available: Arc<Notify>,
123    in_flight: Arc<Mutex<BTreeMap<BlobHash, Arc<SharedFetch>>>>,
124    metrics: FetchMetrics,
125}
126
127impl ProviderPool {
128    async fn fetch(
129        &self,
130        hash: BlobHash,
131        expected_bytes: u64,
132        priority: BlobPriority,
133        allow_hedge: bool,
134    ) -> Result<(), BlobError> {
135        loop {
136            if self
137                .claims
138                .get(hash)
139                .await
140                .map_err(BlobError::from)?
141                .is_some()
142            {
143                self.metrics
144                    .counters
145                    .cache_hits
146                    .fetch_add(1, Ordering::Relaxed);
147                return self.claims.claim(hash).await.map_err(BlobError::from);
148            }
149            let (shared, leader) = {
150                let mut in_flight = self
151                    .in_flight
152                    .lock()
153                    .map_err(|_| BlobError::SchedulerUnavailable)?;
154                if let Some(shared) = in_flight.get(&hash) {
155                    shared.promote(priority, allow_hedge);
156                    (shared.clone(), false)
157                } else {
158                    let shared = Arc::new(SharedFetch::new(priority, allow_hedge));
159                    in_flight.insert(hash, shared.clone());
160                    (shared, true)
161                }
162            };
163            if leader {
164                let owner = SharedFetchOwner::new(self.clone(), hash, shared.clone());
165                let result = async {
166                    self.fetch_unclaimed_with_policy(hash, expected_bytes, &shared)
167                        .await?;
168                    self.claims.claim(hash).await.map_err(BlobError::from)
169                }
170                .await;
171                return owner.complete(result);
172            }
173            match shared.wait().await {
174                Err(BlobError::FetchCancelled) => {}
175                result => return result,
176            }
177        }
178    }
179
180    async fn fetch_unclaimed(
181        &self,
182        hash: BlobHash,
183        expected_bytes: u64,
184        priority: BlobPriority,
185        allow_hedge: bool,
186    ) -> Result<(), BlobError> {
187        let policy = Arc::new(SharedFetch::new(priority, allow_hedge));
188        self.fetch_unclaimed_with_policy(hash, expected_bytes, &policy)
189            .await
190    }
191
192    async fn fetch_unclaimed_with_policy(
193        &self,
194        hash: BlobHash,
195        expected_bytes: u64,
196        policy: &Arc<SharedFetch>,
197    ) -> Result<(), BlobError> {
198        if self.providers.is_empty() {
199            return Err(BlobError::NoProviders);
200        }
201        let mut excluded = BTreeSet::new();
202        let mut last_error = None;
203        while excluded.len() < self.providers.len() {
204            if !excluded.is_empty() {
205                self.metrics
206                    .counters
207                    .retries
208                    .fetch_add(1, Ordering::Relaxed);
209            }
210            let attempt = self.reserve(&excluded, expected_bytes, policy).await?;
211            match self
212                .run_attempt(attempt, hash, expected_bytes, policy, &excluded)
213                .await?
214            {
215                AttemptResult::Success => {
216                    self.record_success();
217                    return Ok(());
218                }
219                AttemptResult::Failed(failures) => {
220                    for (provider, error) in failures {
221                        excluded.insert(provider);
222                        last_error = Some(error);
223                    }
224                }
225            }
226        }
227        Err(last_error.unwrap_or(BlobError::NoProviders))
228    }
229
230    async fn run_attempt(
231        &self,
232        attempt: RemoteAttempt,
233        hash: BlobHash,
234        expected_bytes: u64,
235        policy: &Arc<SharedFetch>,
236        excluded: &BTreeSet<usize>,
237    ) -> Result<AttemptResult, BlobError> {
238        let provider = attempt.provider;
239        if self.providers.len() - excluded.len() <= 1 {
240            return Ok(single_attempt_result(
241                provider,
242                self.execute(attempt, hash, expected_bytes).await,
243            ));
244        }
245        let mut primary = Box::pin(self.execute(attempt, hash, expected_bytes));
246        loop {
247            let promoted = policy.promoted.notified();
248            if policy.should_hedge() {
249                break;
250            }
251            tokio::select! {
252                result = &mut primary => return Ok(single_attempt_result(provider, result)),
253                () = promoted => {}
254            }
255        }
256        let delay = self.hedge_delay(provider)?;
257        tokio::select! {
258            result = &mut primary => return Ok(single_attempt_result(provider, result)),
259            () = tokio::time::sleep(delay) => {}
260        }
261
262        let mut hedge_excluded = excluded.clone();
263        hedge_excluded.insert(provider);
264        let mut reservation = Box::pin(self.reserve(&hedge_excluded, expected_bytes, policy));
265        let hedge = tokio::select! {
266            result = &mut primary => return Ok(single_attempt_result(provider, result)),
267            reservation = &mut reservation => reservation?,
268        };
269        let hedge_provider = hedge.provider;
270        self.metrics
271            .counters
272            .hedged_attempts
273            .fetch_add(1, Ordering::Relaxed);
274        let mut secondary = Box::pin(self.execute(hedge, hash, expected_bytes));
275        let first_failure = tokio::select! {
276            result = &mut primary => match result {
277                Ok(()) => return Ok(AttemptResult::Success),
278                Err(error) => (provider, error),
279            },
280            result = &mut secondary => match result {
281                Ok(()) => return Ok(AttemptResult::Success),
282                Err(error) => (hedge_provider, error),
283            },
284        };
285        let second_failure = if first_failure.0 == provider {
286            secondary.await.err().map(|error| (hedge_provider, error))
287        } else {
288            primary.await.err().map(|error| (provider, error))
289        };
290        Ok(second_failure.map_or(AttemptResult::Success, |failure| {
291            AttemptResult::Failed(vec![first_failure, failure])
292        }))
293    }
294
295    async fn reserve(
296        &self,
297        excluded: &BTreeSet<usize>,
298        expected_bytes: u64,
299        policy: &Arc<SharedFetch>,
300    ) -> Result<RemoteAttempt, BlobError> {
301        loop {
302            let notified = self.available.notified();
303            let permit = self
304                .scheduler
305                .acquire_promotable(policy.priority.clone())
306                .await?;
307            let (provider, delay) = {
308                let mut book = self
309                    .metrics
310                    .providers
311                    .lock()
312                    .map_err(|_| BlobError::SchedulerUnavailable)?;
313                let now = Instant::now();
314                (
315                    book.select(excluded, expected_bytes, now),
316                    book.next_ready(excluded, now),
317                )
318            };
319            if let Some(provider) = provider {
320                return Ok(RemoteAttempt {
321                    provider,
322                    permit: Some(permit),
323                    providers: self.metrics.providers.clone(),
324                    available: self.available.clone(),
325                    finished: false,
326                });
327            }
328            drop(permit);
329            if excluded.len() == self.providers.len() {
330                return Err(BlobError::NoProviders);
331            }
332            if let Some(delay) = delay {
333                tokio::select! {
334                    () = notified => {}
335                    () = tokio::time::sleep(delay) => {}
336                }
337            } else {
338                notified.await;
339            }
340        }
341    }
342
343    async fn execute(
344        &self,
345        mut attempt: RemoteAttempt,
346        hash: BlobHash,
347        expected_bytes: u64,
348    ) -> Result<(), BlobError> {
349        self.metrics
350            .counters
351            .provider_attempts
352            .fetch_add(1, Ordering::Relaxed);
353        let provider = attempt.provider;
354        match self
355            .local
356            .fetch_with_stats(self.providers[provider].clone(), hash)
357            .await
358        {
359            Ok(stats) => {
360                let bytes = stats.bytes.max(expected_bytes).max(1);
361                attempt.success(bytes, stats.elapsed)?;
362                self.metrics
363                    .counters
364                    .bytes_fetched
365                    .fetch_add(bytes, Ordering::Relaxed);
366                Ok(())
367            }
368            Err(error) => {
369                attempt.failure()?;
370                Err(BlobError::from(error))
371            }
372        }
373    }
374
375    fn hedge_delay(&self, provider: usize) -> Result<Duration, BlobError> {
376        self.metrics
377            .providers
378            .lock()
379            .map_err(|_| BlobError::SchedulerUnavailable)
380            .map(|book| book.hedge_delay(provider))
381    }
382
383    fn record_success(&self) {
384        self.metrics
385            .counters
386            .chunks_fetched
387            .fetch_add(1, Ordering::Relaxed);
388    }
389}
390
391enum AttemptResult {
392    Success,
393    Failed(Vec<(usize, BlobError)>),
394}
395
396fn single_attempt_result(provider: usize, result: Result<(), BlobError>) -> AttemptResult {
397    match result {
398        Ok(()) => AttemptResult::Success,
399        Err(error) => AttemptResult::Failed(vec![(provider, error)]),
400    }
401}
402
403struct SharedFetch {
404    priority: PrioritySignal,
405    allow_hedge: AtomicBool,
406    promoted: Notify,
407    result: Mutex<Option<Result<(), BlobError>>>,
408    completed: Notify,
409}
410
411impl SharedFetch {
412    fn new(priority: BlobPriority, allow_hedge: bool) -> Self {
413        Self {
414            priority: PrioritySignal::new(priority),
415            allow_hedge: AtomicBool::new(allow_hedge),
416            promoted: Notify::new(),
417            result: Mutex::new(None),
418            completed: Notify::new(),
419        }
420    }
421
422    fn promote(&self, priority: BlobPriority, allow_hedge: bool) {
423        let previous = self.priority.get();
424        self.priority.promote(priority);
425        let hedge_was_enabled = self.allow_hedge.fetch_or(allow_hedge, Ordering::AcqRel);
426        if self.priority.get() != previous || (allow_hedge && !hedge_was_enabled) {
427            self.promoted.notify_waiters();
428        }
429    }
430
431    fn should_hedge(&self) -> bool {
432        self.priority.get() == BlobPriority::High && self.allow_hedge.load(Ordering::Acquire)
433    }
434
435    async fn wait(&self) -> Result<(), BlobError> {
436        loop {
437            let notified = self.completed.notified();
438            if let Some(result) = self
439                .result
440                .lock()
441                .map_err(|_| BlobError::SchedulerUnavailable)?
442                .clone()
443            {
444                return result;
445            }
446            notified.await;
447        }
448    }
449
450    fn complete(&self, result: Result<(), BlobError>) {
451        if let Ok(mut slot) = self.result.lock() {
452            *slot = Some(result);
453        }
454        self.completed.notify_waiters();
455    }
456}
457
458struct SharedFetchOwner {
459    pool: ProviderPool,
460    hash: BlobHash,
461    shared: Arc<SharedFetch>,
462    completed: bool,
463}
464
465impl SharedFetchOwner {
466    fn new(pool: ProviderPool, hash: BlobHash, shared: Arc<SharedFetch>) -> Self {
467        Self {
468            pool,
469            hash,
470            shared,
471            completed: false,
472        }
473    }
474
475    fn complete(mut self, result: Result<(), BlobError>) -> Result<(), BlobError> {
476        self.shared.complete(result.clone());
477        self.remove();
478        self.completed = true;
479        result
480    }
481
482    fn remove(&self) {
483        if let Ok(mut in_flight) = self.pool.in_flight.lock() {
484            in_flight.remove(&self.hash);
485        }
486    }
487}
488
489impl Drop for SharedFetchOwner {
490    fn drop(&mut self) {
491        if !self.completed {
492            self.shared.complete(Err(BlobError::FetchCancelled));
493            self.remove();
494        }
495    }
496}
497
498struct RemoteAttempt {
499    provider: usize,
500    permit: Option<TransferPermit>,
501    providers: Arc<Mutex<ProviderBook>>,
502    available: Arc<Notify>,
503    finished: bool,
504}
505
506impl RemoteAttempt {
507    fn success(&mut self, bytes: u64, elapsed: Duration) -> Result<(), BlobError> {
508        self.providers
509            .lock()
510            .map_err(|_| BlobError::SchedulerUnavailable)?
511            .finish_success(self.provider, bytes, elapsed);
512        self.finish();
513        Ok(())
514    }
515
516    fn failure(&mut self) -> Result<(), BlobError> {
517        self.providers
518            .lock()
519            .map_err(|_| BlobError::SchedulerUnavailable)?
520            .finish_failure(self.provider, Instant::now());
521        self.finish();
522        Ok(())
523    }
524
525    fn finish(&mut self) {
526        self.finished = true;
527        self.permit.take();
528        self.available.notify_waiters();
529    }
530}
531
532impl Drop for RemoteAttempt {
533    fn drop(&mut self) {
534        if !self.finished {
535            if let Ok(mut providers) = self.providers.lock() {
536                providers.cancel(self.provider);
537            }
538            self.permit.take();
539            self.available.notify_waiters();
540        }
541    }
542}
543
544/// Domain-bound encrypted blob import and streaming engine.
545#[derive(Clone)]
546pub struct BlobEngine {
547    pub(crate) store: Arc<dyn BlobStore>,
548    pub(crate) domain_id: DomainId,
549    pub(crate) epoch: u64,
550    pub(crate) chunk_size: usize,
551    pub(crate) key: DomainKey,
552    pub(crate) scheduler: FetchScheduler,
553}
554
555impl BlobEngine {
556    /// Creates an engine using four-MiB encrypted chunks.
557    pub fn new(store: Arc<dyn BlobStore>, domain_id: DomainId, key: DomainKey) -> Self {
558        Self {
559            store,
560            domain_id,
561            epoch: 0,
562            chunk_size: DEFAULT_CHUNK_SIZE,
563            key,
564            scheduler: FetchScheduler::default(),
565        }
566    }
567
568    /// Creates an engine bound to an explicit domain key epoch.
569    pub fn new_at_epoch(
570        store: Arc<dyn BlobStore>,
571        domain_id: DomainId,
572        epoch: u64,
573        key: DomainKey,
574    ) -> Self {
575        Self {
576            store,
577            domain_id,
578            epoch,
579            chunk_size: DEFAULT_CHUNK_SIZE,
580            key,
581            scheduler: FetchScheduler::default(),
582        }
583    }
584
585    /// Creates an engine with an explicit 64-KiB through 8-MiB chunk size.
586    pub fn with_chunk_size(
587        store: Arc<dyn BlobStore>,
588        domain_id: DomainId,
589        chunk_size: usize,
590        key: DomainKey,
591    ) -> Result<Self, BlobError> {
592        if !(MIN_CHUNK_SIZE..=MAX_CHUNK_SIZE).contains(&chunk_size) {
593            return Err(BlobError::InvalidChunkSize(chunk_size));
594        }
595        Ok(Self {
596            store,
597            domain_id,
598            epoch: 0,
599            chunk_size,
600            key,
601            scheduler: FetchScheduler::default(),
602        })
603    }
604
605    /// Shares process-wide transfer admission with other engines.
606    #[must_use]
607    pub fn with_fetch_scheduler(mut self, scheduler: FetchScheduler) -> Self {
608        self.scheduler = scheduler;
609        self
610    }
611
612    /// Encrypts, hashes and persists chunks followed by their encrypted manifest.
613    pub async fn import_bytes(
614        &self,
615        bytes: Vec<u8>,
616        media_type: Option<String>,
617    ) -> Result<BlobRef, BlobError> {
618        self.import_reader(bytes.as_slice(), media_type).await
619    }
620
621    /// Incrementally encrypts and persists an asynchronous plaintext stream.
622    pub async fn import_reader(
623        &self,
624        mut reader: impl AsyncRead + Unpin,
625        media_type: Option<String>,
626    ) -> Result<BlobRef, BlobError> {
627        let mut salt = [0_u8; 32];
628        getrandom::fill(&mut salt).map_err(|_| BlobError::RandomnessUnavailable)?;
629        let mut chunks = Vec::new();
630        let mut plaintext_len = 0_u64;
631        let mut plaintext_hasher = blake3::Hasher::new();
632        loop {
633            let mut plaintext = vec![0_u8; self.chunk_size];
634            let mut filled = 0;
635            while filled < plaintext.len() {
636                let count = reader
637                    .read(&mut plaintext[filled..])
638                    .await
639                    .map_err(|error| BlobError::Read(error.to_string()))?;
640                if count == 0 {
641                    break;
642                }
643                filled += count;
644            }
645            if filled == 0 {
646                break;
647            }
648            plaintext.truncate(filled);
649            let index = chunks.len();
650            let index = u32::try_from(index).map_err(|_| BlobError::BlobTooLarge)?;
651            let chunk_plaintext_len =
652                u32::try_from(plaintext.len()).map_err(|_| BlobError::BlobTooLarge)?;
653            let mut nonce = [0_u8; 24];
654            getrandom::fill(&mut nonce).map_err(|_| BlobError::RandomnessUnavailable)?;
655            let ciphertext = encrypt_chunk(
656                &self.key,
657                self.domain_id,
658                self.epoch,
659                &salt,
660                index,
661                chunk_plaintext_len,
662                nonce,
663                &plaintext,
664            )?;
665            let ciphertext_hash = self.store.put(ciphertext).await.map_err(BlobError::from)?;
666            chunks.push(ChunkDescriptor {
667                index,
668                plaintext_len: chunk_plaintext_len,
669                ciphertext_hash,
670                nonce,
671            });
672            plaintext_hasher.update(&plaintext);
673            plaintext_len = plaintext_len
674                .checked_add(u64::try_from(filled).map_err(|_| BlobError::BlobTooLarge)?)
675                .ok_or(BlobError::BlobTooLarge)?;
676            if filled < self.chunk_size {
677                break;
678            }
679        }
680        let chunk_size = u32::try_from(self.chunk_size).map_err(|_| BlobError::BlobTooLarge)?;
681        let body = ManifestBody {
682            plaintext_len,
683            plaintext_hash: *plaintext_hasher.finalize().as_bytes(),
684            chunk_size,
685            chunks,
686            media_type,
687        };
688        let plaintext = minicbor::to_vec(&body).map_err(codec_error)?;
689        let mut nonce = [0_u8; 24];
690        getrandom::fill(&mut nonce).map_err(|_| BlobError::RandomnessUnavailable)?;
691        let ciphertext = encrypt_manifest(
692            &self.key,
693            self.domain_id,
694            self.epoch,
695            &salt,
696            nonce,
697            &plaintext,
698        )?;
699        let envelope = ManifestEnvelope {
700            version: FORMAT_VERSION,
701            domain_id: self.domain_id,
702            epoch: self.epoch,
703            salt,
704            nonce,
705            ciphertext,
706        };
707        let encoded = minicbor::to_vec(envelope).map_err(codec_error)?;
708        let manifest_hash = self.store.put(encoded).await.map_err(BlobError::from)?;
709        Ok(BlobRef::new(self.domain_id, self.epoch, manifest_hash))
710    }
711
712    /// Authenticates a manifest and returns a lazy chunk stream.
713    pub async fn open(&self, reference: &BlobRef) -> Result<BlobStream, BlobError> {
714        if reference.domain_id() != self.domain_id || reference.epoch() != self.epoch {
715            return Err(BlobError::WrongDomainOrEpoch);
716        }
717        let encoded = self
718            .store
719            .get(reference.manifest_hash())
720            .await
721            .map_err(BlobError::from)?
722            .ok_or(BlobError::MissingBlob(reference.manifest_hash()))?;
723        verify_hash(reference.manifest_hash(), &encoded)?;
724        let envelope: ManifestEnvelope = decode_canonical(&encoded)?;
725        if envelope.version != FORMAT_VERSION
726            || envelope.domain_id != self.domain_id
727            || envelope.epoch != self.epoch
728        {
729            return Err(BlobError::WrongDomainOrEpoch);
730        }
731        let plaintext = decrypt_manifest(&self.key, &envelope)?;
732        let body: ManifestBody = decode_canonical(&plaintext)?;
733        validate_manifest(&body)?;
734        Ok(BlobStream::new(
735            self.store.clone(),
736            self.key.clone(),
737            envelope,
738            body,
739        ))
740    }
741
742    /// Opens immediately after fetching only the manifest; chunks are pulled lazily across peers.
743    pub async fn open_from_providers(
744        &self,
745        local: IrohBlobStore,
746        mut providers: Vec<iroh::endpoint::Connection>,
747        reference: &BlobRef,
748        options: FetchOptions,
749    ) -> Result<(BlobStream, FetchMetrics), BlobError> {
750        if reference.domain_id() != self.domain_id || reference.epoch() != self.epoch {
751            return Err(BlobError::WrongDomainOrEpoch);
752        }
753        let mut endpoint_ids = BTreeSet::new();
754        providers.retain(|connection| endpoint_ids.insert(connection.remote_id()));
755        let metrics = FetchMetrics::new(providers.len());
756        let pool = ProviderPool {
757            local,
758            claims: self.store.clone(),
759            providers: Arc::new(providers),
760            scheduler: self.scheduler.clone(),
761            available: Arc::new(Notify::new()),
762            in_flight: Arc::new(Mutex::new(BTreeMap::new())),
763            metrics: metrics.clone(),
764        };
765        if self
766            .store
767            .get(reference.manifest_hash())
768            .await
769            .map_err(BlobError::from)?
770            .is_none()
771        {
772            pool.fetch_unclaimed(
773                reference.manifest_hash(),
774                0,
775                options.priority,
776                options.priority == BlobPriority::High,
777            )
778            .await?;
779        }
780        let encoded = self
781            .store
782            .get(reference.manifest_hash())
783            .await
784            .map_err(BlobError::from)?
785            .ok_or(BlobError::MissingBlob(reference.manifest_hash()))?;
786        verify_hash(reference.manifest_hash(), &encoded)?;
787        let envelope: ManifestEnvelope = decode_canonical(&encoded)?;
788        if envelope.version != FORMAT_VERSION
789            || envelope.domain_id != self.domain_id
790            || envelope.epoch != self.epoch
791        {
792            return Err(BlobError::WrongDomainOrEpoch);
793        }
794        let plaintext = decrypt_manifest(&self.key, &envelope)?;
795        let body: ManifestBody = decode_canonical(&plaintext)?;
796        validate_manifest(&body)?;
797        self.store
798            .claim(reference.manifest_hash())
799            .await
800            .map_err(BlobError::from)?;
801
802        let fetch_pool = pool.clone();
803        let fetcher = Arc::new(move |hash, expected_bytes, priority, allow_hedge| {
804            let pool = fetch_pool.clone();
805            let future: Pin<Box<dyn Future<Output = Result<(), BlobError>> + Send + 'static>> =
806                Box::pin(async move {
807                    pool.fetch(hash, expected_bytes, priority, allow_hedge)
808                        .await
809                });
810            future
811        });
812        Ok((
813            BlobStream::new(self.store.clone(), self.key.clone(), envelope, body).with_fetcher(
814                fetcher,
815                options.priority,
816                options.prefetch_chunks,
817            ),
818            metrics,
819        ))
820    }
821
822    /// Fetches, verifies, authenticates, and stores a remote encrypted manifest and all chunks.
823    pub async fn fetch_from(
824        &self,
825        local: &IrohBlobStore,
826        connection: iroh::endpoint::Connection,
827        reference: &BlobRef,
828    ) -> Result<(), BlobError> {
829        if reference.domain_id() != self.domain_id || reference.epoch() != self.epoch {
830            return Err(BlobError::WrongDomainOrEpoch);
831        }
832        local
833            .fetch(connection.clone(), reference.manifest_hash())
834            .await
835            .map_err(BlobError::from)?;
836        let encoded = self
837            .store
838            .get(reference.manifest_hash())
839            .await
840            .map_err(BlobError::from)?
841            .ok_or(BlobError::MissingBlob(reference.manifest_hash()))?;
842        verify_hash(reference.manifest_hash(), &encoded)?;
843        let envelope: ManifestEnvelope = decode_canonical(&encoded)?;
844        if envelope.version != FORMAT_VERSION
845            || envelope.domain_id != self.domain_id
846            || envelope.epoch != self.epoch
847        {
848            return Err(BlobError::WrongDomainOrEpoch);
849        }
850        let plaintext = decrypt_manifest(&self.key, &envelope)?;
851        let body: ManifestBody = decode_canonical(&plaintext)?;
852        validate_manifest(&body)?;
853        self.store
854            .claim(reference.manifest_hash())
855            .await
856            .map_err(BlobError::from)?;
857        for chunk in body.chunks {
858            local
859                .fetch(connection.clone(), chunk.ciphertext_hash)
860                .await
861                .map_err(BlobError::from)?;
862            self.store
863                .claim(chunk.ciphertext_hash)
864                .await
865                .map_err(BlobError::from)?;
866        }
867        Ok(())
868    }
869}
870
871pub(crate) fn decrypt_chunk(
872    key: &DomainKey,
873    domain_id: DomainId,
874    epoch: u64,
875    salt: &[u8; 32],
876    descriptor: &ChunkDescriptor,
877    ciphertext: &[u8],
878) -> Result<Vec<u8>, BlobError> {
879    verify_hash(descriptor.ciphertext_hash, ciphertext)?;
880    let key = chunk_key(key, domain_id, epoch, salt, descriptor.index)?;
881    let cipher = XChaCha20Poly1305::new((&*key).into());
882    let aad = chunk_aad(
883        domain_id,
884        epoch,
885        salt,
886        descriptor.index,
887        descriptor.plaintext_len,
888    );
889    let plaintext = cipher
890        .decrypt(
891            &XNonce::from(descriptor.nonce),
892            Payload {
893                msg: ciphertext,
894                aad: &aad,
895            },
896        )
897        .map_err(|_| BlobError::AuthenticationFailed)?;
898    if plaintext.len() != descriptor.plaintext_len as usize {
899        return Err(BlobError::InvalidManifest);
900    }
901    Ok(plaintext)
902}
903
904// Keeping every authenticated field explicit makes key/nonce/AAD review straightforward.
905#[allow(clippy::too_many_arguments)]
906fn encrypt_chunk(
907    key: &DomainKey,
908    domain_id: DomainId,
909    epoch: u64,
910    salt: &[u8; 32],
911    index: u32,
912    plaintext_len: u32,
913    nonce: [u8; 24],
914    plaintext: &[u8],
915) -> Result<Vec<u8>, BlobError> {
916    let key = chunk_key(key, domain_id, epoch, salt, index)?;
917    let cipher = XChaCha20Poly1305::new((&*key).into());
918    let aad = chunk_aad(domain_id, epoch, salt, index, plaintext_len);
919    cipher
920        .encrypt(
921            &XNonce::from(nonce),
922            Payload {
923                msg: plaintext,
924                aad: &aad,
925            },
926        )
927        .map_err(|_| BlobError::EncryptionFailed)
928}
929
930fn chunk_key(
931    key: &DomainKey,
932    domain_id: DomainId,
933    epoch: u64,
934    salt: &[u8; 32],
935    index: u32,
936) -> Result<zeroize::Zeroizing<[u8; 32]>, BlobError> {
937    let mut derivation_salt = Vec::with_capacity(64);
938    derivation_salt.extend_from_slice(domain_id.as_bytes());
939    derivation_salt.extend_from_slice(salt);
940    let mut context = Vec::with_capacity(37);
941    context.extend_from_slice(b"iroh-db/blob-chunk/v1");
942    context.extend_from_slice(&epoch.to_be_bytes());
943    context.extend_from_slice(&index.to_be_bytes());
944    key.derive_subkey(&derivation_salt, &context)
945        .map_err(|_| BlobError::KeyDerivationFailed)
946}
947
948fn chunk_aad(
949    domain_id: DomainId,
950    epoch: u64,
951    salt: &[u8; 32],
952    index: u32,
953    plaintext_len: u32,
954) -> Vec<u8> {
955    let mut aad = Vec::with_capacity(80);
956    aad.extend_from_slice(b"iroh-db/blob-chunk-aad/v1");
957    aad.extend_from_slice(domain_id.as_bytes());
958    aad.extend_from_slice(&epoch.to_be_bytes());
959    aad.extend_from_slice(salt);
960    aad.extend_from_slice(&index.to_be_bytes());
961    aad.extend_from_slice(&plaintext_len.to_be_bytes());
962    aad
963}
964
965fn encrypt_manifest(
966    key: &DomainKey,
967    domain_id: DomainId,
968    epoch: u64,
969    salt: &[u8; 32],
970    nonce: [u8; 24],
971    plaintext: &[u8],
972) -> Result<Vec<u8>, BlobError> {
973    let key = manifest_key(key, domain_id, epoch, salt)?;
974    let cipher = XChaCha20Poly1305::new((&*key).into());
975    let aad = manifest_aad(domain_id, epoch, salt, &nonce);
976    cipher
977        .encrypt(
978            &XNonce::from(nonce),
979            Payload {
980                msg: plaintext,
981                aad: &aad,
982            },
983        )
984        .map_err(|_| BlobError::EncryptionFailed)
985}
986
987fn decrypt_manifest(key: &DomainKey, envelope: &ManifestEnvelope) -> Result<Vec<u8>, BlobError> {
988    let key = manifest_key(key, envelope.domain_id, envelope.epoch, &envelope.salt)?;
989    let cipher = XChaCha20Poly1305::new((&*key).into());
990    let aad = manifest_aad(
991        envelope.domain_id,
992        envelope.epoch,
993        &envelope.salt,
994        &envelope.nonce,
995    );
996    cipher
997        .decrypt(
998            &XNonce::from(envelope.nonce),
999            Payload {
1000                msg: &envelope.ciphertext,
1001                aad: &aad,
1002            },
1003        )
1004        .map_err(|_| BlobError::AuthenticationFailed)
1005}
1006
1007fn manifest_key(
1008    key: &DomainKey,
1009    domain_id: DomainId,
1010    epoch: u64,
1011    salt: &[u8; 32],
1012) -> Result<zeroize::Zeroizing<[u8; 32]>, BlobError> {
1013    let mut derivation_salt = Vec::with_capacity(64);
1014    derivation_salt.extend_from_slice(domain_id.as_bytes());
1015    derivation_salt.extend_from_slice(salt);
1016    let mut context = Vec::with_capacity(32);
1017    context.extend_from_slice(b"iroh-db/blob-manifest/v1");
1018    context.extend_from_slice(&epoch.to_be_bytes());
1019    key.derive_subkey(&derivation_salt, &context)
1020        .map_err(|_| BlobError::KeyDerivationFailed)
1021}
1022
1023fn manifest_aad(domain_id: DomainId, epoch: u64, salt: &[u8; 32], nonce: &[u8; 24]) -> Vec<u8> {
1024    let mut aad = Vec::with_capacity(104);
1025    aad.extend_from_slice(b"iroh-db/blob-manifest-aad/v1");
1026    aad.extend_from_slice(&FORMAT_VERSION.to_be_bytes());
1027    aad.extend_from_slice(domain_id.as_bytes());
1028    aad.extend_from_slice(&epoch.to_be_bytes());
1029    aad.extend_from_slice(salt);
1030    aad.extend_from_slice(nonce);
1031    aad
1032}
1033
1034pub(crate) fn verify_hash(expected: BlobHash, bytes: &[u8]) -> Result<(), BlobError> {
1035    let actual = BlobHash::from_bytes(*blake3::hash(bytes).as_bytes());
1036    if actual != expected {
1037        return Err(BlobError::HashMismatch);
1038    }
1039    Ok(())
1040}
1041
1042fn decode_canonical<T>(bytes: &[u8]) -> Result<T, BlobError>
1043where
1044    T: minicbor::Encode<()> + for<'value> minicbor::Decode<'value, ()>,
1045{
1046    let mut decoder = minicbor::Decoder::new(bytes);
1047    let value = decoder.decode().map_err(codec_error)?;
1048    if decoder.position() != bytes.len() {
1049        return Err(BlobError::InvalidManifest);
1050    }
1051    let canonical = minicbor::to_vec(&value).map_err(codec_error)?;
1052    if canonical != bytes {
1053        return Err(BlobError::InvalidManifest);
1054    }
1055    Ok(value)
1056}
1057
1058#[allow(clippy::needless_pass_by_value)]
1059fn codec_error(error: impl std::fmt::Display) -> BlobError {
1060    BlobError::Codec(error.to_string())
1061}
1062
1063impl From<BlobStoreError> for BlobError {
1064    fn from(error: BlobStoreError) -> Self {
1065        Self::Store(error.to_string())
1066    }
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071    use iroh::{Endpoint, RelayMode, endpoint::presets, protocol::Router};
1072    use iroh_blobs::provider::events::{EventMask, EventSender, ProviderMessage, RequestMode};
1073
1074    use super::*;
1075
1076    #[tokio::test]
1077    async fn stalled_high_priority_fetch_hedges_to_another_provider() {
1078        let bytes = vec![42_u8; 1024 * 1024];
1079        let (slow_dir, slow_store, slow_router, hash) =
1080            start_provider(&bytes, Some(Duration::from_millis(500))).await;
1081        let (fast_dir, fast_store, fast_router, fast_hash) = start_provider(&bytes, None).await;
1082        assert_eq!(hash, fast_hash);
1083        let client_dir = tempfile::tempdir().unwrap();
1084        let client_store = IrohBlobStore::load(client_dir.path()).await.unwrap();
1085        let endpoint = Endpoint::builder(presets::Minimal)
1086            .relay_mode(RelayMode::Disabled)
1087            .bind()
1088            .await
1089            .unwrap();
1090        let slow = endpoint
1091            .connect(slow_router.endpoint().addr(), iroh_blobs::ALPN)
1092            .await
1093            .unwrap();
1094        let fast = endpoint
1095            .connect(fast_router.endpoint().addr(), iroh_blobs::ALPN)
1096            .await
1097            .unwrap();
1098        let metrics = FetchMetrics::new(2);
1099        let pool = ProviderPool {
1100            local: client_store.clone(),
1101            claims: Arc::new(client_store.clone()),
1102            providers: Arc::new(vec![slow, fast]),
1103            scheduler: FetchScheduler::default(),
1104            available: Arc::new(Notify::new()),
1105            in_flight: Arc::new(Mutex::new(BTreeMap::new())),
1106            metrics: metrics.clone(),
1107        };
1108
1109        tokio::time::timeout(
1110            Duration::from_secs(2),
1111            pool.fetch_unclaimed(hash, bytes.len() as u64, BlobPriority::High, true),
1112        )
1113        .await
1114        .unwrap()
1115        .unwrap();
1116        assert_eq!(metrics.hedged_attempts(), 1);
1117        assert_eq!(metrics.chunks_fetched(), 1);
1118        assert_eq!(metrics.providers()[1].successes(), 1);
1119
1120        endpoint.close().await;
1121        slow_router.shutdown().await.unwrap();
1122        fast_router.shutdown().await.unwrap();
1123        client_store.shutdown().await.unwrap();
1124        slow_store.shutdown().await.unwrap();
1125        fast_store.shutdown().await.unwrap();
1126        drop((slow_dir, fast_dir));
1127    }
1128
1129    async fn start_provider(
1130        bytes: &[u8],
1131        delay: Option<Duration>,
1132    ) -> (tempfile::TempDir, IrohBlobStore, Router, BlobHash) {
1133        let directory = tempfile::tempdir().unwrap();
1134        let store = IrohBlobStore::load(directory.path()).await.unwrap();
1135        let hash = store.put(bytes.to_vec()).await.unwrap();
1136        let events = delay.map_or(EventSender::DEFAULT, |delay| {
1137            let mask = EventMask {
1138                get: RequestMode::Intercept,
1139                ..EventMask::DEFAULT
1140            };
1141            let (events, mut receiver) = EventSender::channel(8, mask);
1142            tokio::spawn(async move {
1143                while let Some(message) = receiver.recv().await {
1144                    if let ProviderMessage::GetRequestReceived(message) = message {
1145                        tokio::time::sleep(delay).await;
1146                        let _ignored = message.tx.send(Ok(())).await;
1147                    }
1148                }
1149            });
1150            events
1151        });
1152        let endpoint = Endpoint::builder(presets::Minimal)
1153            .relay_mode(RelayMode::Disabled)
1154            .bind()
1155            .await
1156            .unwrap();
1157        let router = Router::builder(endpoint)
1158            .accept(iroh_blobs::ALPN, store.protocol_with_events(events))
1159            .spawn();
1160        (directory, store, router, hash)
1161    }
1162}