Skip to main content

syncweb_core/indexing/
resilience.rs

1//! Lease-based availability and replication for the indexing service.
2//!
3//! The resilience layer observes signed provider claims without changing the
4//! document synchronization protocol. When a blob falls below its configured
5//! replication budget, it can use the advertised blob tickets to fetch and
6//! pin the content through the normal blob store.
7
8use std::{
9    collections::{HashMap, HashSet},
10    io,
11    str::FromStr,
12    sync::{Arc, Mutex},
13    time::{Duration, SystemTime, UNIX_EPOCH},
14};
15
16use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
17use iroh::{Endpoint, PublicKey, SecretKey};
18use iroh_blobs::{Hash, ticket::BlobTicket};
19use iroh_gossip::{
20    TopicId,
21    api::{Event, GossipSender, GossipTopic},
22};
23use n0_future::StreamExt;
24use serde::{Deserialize, Serialize};
25use tokio::io::{AsyncRead, AsyncReadExt};
26use tokio::{sync::watch, task::JoinHandle};
27
28use crate::{
29    error::{Result, SyncwebError},
30    indexing::{
31        IndexingDatabase,
32        reputation::{ProviderReputationStore, ReputationConfig},
33        wot::{ProviderTrustDecision, WotService},
34    },
35    node::{blob_store::BlobStore, gossip_service::GossipService},
36};
37
38const RESILIENCE_TOPIC_SEED: &[u8] = b"syncweb/provider-leases/v1";
39const PROVIDER_LEASE_SIGNATURE_CONTEXT: &[u8] = b"syncweb/provider-lease/v1\0";
40const DEFAULT_OBSERVATION_TTL: Duration = Duration::from_mins(5);
41const DEFAULT_MAX_JITTER: Duration = Duration::from_secs(30);
42const DEFAULT_RESPONSIBLE_PEERS: usize = 1;
43const DEFAULT_MAX_FAILURES_PER_PROVIDER: usize = 128;
44const DEFAULT_FAILURE_TTL: Duration = Duration::from_hours(24);
45const AUTOMATED_BAN_DURATION: Duration = Duration::from_hours(1);
46const DEFINITIVE_FAILURE_THRESHOLD: u32 = 3;
47const STREAM_VALIDATION_CHUNK_SIZE: usize = 64 * 1024;
48const REPLICATION_PIN_PREFIX: &str = "syncweb/replication/";
49
50/// The broad cause of a failed provider fetch.
51#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
52#[non_exhaustive]
53pub enum FetchFailureKind {
54    NotFound,
55    ConnectionRefused,
56    Timeout,
57    Corruption,
58    Unknown,
59}
60
61impl FetchFailureKind {
62    /// Classify an error at the resilience boundary.
63    ///
64    /// `SyncwebError::Operation` intentionally retains the underlying
65    /// library error as display text, so classification uses stable
66    /// lower-case error phrases rather than coupling this layer to iroh's
67    /// private error types.
68    #[must_use]
69    pub fn from_syncweb_error(error: &SyncwebError) -> Self {
70        Self::from_message(error.to_string())
71    }
72
73    /// Alias for [`Self::from_syncweb_error`].
74    #[must_use]
75    pub fn classify(error: &SyncwebError) -> Self {
76        Self::from_syncweb_error(error)
77    }
78
79    /// Alias for [`Self::from_syncweb_error`].
80    #[must_use]
81    pub fn from_error(error: &SyncwebError) -> Self {
82        Self::from_syncweb_error(error)
83    }
84
85    /// Classify a displayable error message.
86    #[must_use]
87    pub fn from_message(message: impl AsRef<str>) -> Self {
88        let message_text = message.as_ref().to_ascii_lowercase();
89        if [
90            "not found",
91            "does not exist",
92            "no such file",
93            "missing blob",
94            "unknown hash",
95            "404",
96        ]
97        .iter()
98        .any(|phrase| message_text.contains(phrase))
99        {
100            return Self::NotFound;
101        }
102        if [
103            "hash mismatch",
104            "checksum mismatch",
105            "integrity",
106            "corrupt",
107            "invalid data",
108        ]
109        .iter()
110        .any(|phrase| message_text.contains(phrase))
111        {
112            return Self::Corruption;
113        }
114        if [
115            "connection refused",
116            "connection reset",
117            "network is unreachable",
118            "no route to host",
119            "unreachable",
120        ]
121        .iter()
122        .any(|phrase| message_text.contains(phrase))
123        {
124            return Self::ConnectionRefused;
125        }
126        if ["timed out", "timeout", "deadline exceeded"]
127            .iter()
128            .any(|phrase| message_text.contains(phrase))
129        {
130            return Self::Timeout;
131        }
132        Self::Unknown
133    }
134
135    #[must_use]
136    pub const fn is_definitive(self) -> bool {
137        matches!(self, Self::NotFound | Self::Corruption)
138    }
139
140    #[must_use]
141    pub const fn is_transient(self) -> bool {
142        !self.is_definitive()
143    }
144}
145
146impl From<&SyncwebError> for FetchFailureKind {
147    fn from(error: &SyncwebError) -> Self {
148        Self::from_syncweb_error(error)
149    }
150}
151
152/// A provider-specific failure observed while fetching one blob.
153#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
154#[non_exhaustive]
155pub struct FetchFailure {
156    pub kind: FetchFailureKind,
157    pub provider: PublicKey,
158    pub hash: Hash,
159    pub timestamp: u64,
160    pub error_detail: String,
161}
162
163impl FetchFailure {
164    /// Construct a failure timestamped with the current epoch time.
165    #[must_use]
166    pub fn new(kind: FetchFailureKind, provider: PublicKey, hash: Hash, error_detail: impl Into<String>) -> Self {
167        Self::new_at(kind, provider, hash, current_epoch_seconds(), error_detail)
168    }
169
170    /// Construct a failure with an explicit timestamp.
171    #[must_use]
172    pub fn new_at(
173        kind: FetchFailureKind,
174        provider: PublicKey,
175        hash: Hash,
176        timestamp: u64,
177        error_detail: impl Into<String>,
178    ) -> Self {
179        Self {
180            kind,
181            provider,
182            hash,
183            timestamp,
184            error_detail: error_detail.into(),
185        }
186    }
187
188    /// Construct and classify a failure from a core error.
189    #[must_use]
190    pub fn from_syncweb_error(provider: PublicKey, hash: Hash, error: &SyncwebError) -> Self {
191        Self::new(
192            FetchFailureKind::from_syncweb_error(error),
193            provider,
194            hash,
195            error.to_string(),
196        )
197    }
198
199    /// Alias for [`Self::from_syncweb_error`].
200    #[must_use]
201    pub fn from_error(provider: PublicKey, hash: Hash, error: &SyncwebError) -> Self {
202        Self::from_syncweb_error(provider, hash, error)
203    }
204
205    /// Encode this failure as JSON.
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if JSON serialization fails.
210    pub fn to_bytes(&self) -> Result<Vec<u8>> {
211        serde_json::to_vec(self).map_err(|error| SyncwebError::operation("failed to serialize fetch failure", error))
212    }
213
214    /// Decode a failure from JSON.
215    ///
216    /// # Errors
217    ///
218    /// Returns an error if the JSON is malformed.
219    pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Result<Self> {
220        serde_json::from_slice(bytes.as_ref())
221            .map_err(|error| SyncwebError::operation("failed to deserialize fetch failure", error))
222    }
223}
224
225/// Validate a provider stream using bounded memory.
226///
227/// The reader is consumed in fixed-size chunks. At most one chunk is held in
228/// memory, and an extra byte is read only after the expected size to reject
229/// oversized responses before they can be accepted or buffered.
230///
231/// # Errors
232///
233/// Returns an error if the stream is too long, truncated, cannot be read, or
234/// does not hash to `expected_hash`.
235pub async fn validate_bounded_fetch<R>(mut reader: R, expected_size: u64, expected_hash: Hash) -> Result<()>
236where
237    R: AsyncRead + Unpin,
238{
239    let mut hasher = blake3::Hasher::new();
240    let mut remaining = expected_size;
241    let mut buffer = vec![0_u8; STREAM_VALIDATION_CHUNK_SIZE];
242    while remaining > 0 {
243        let requested = usize::try_from(remaining).map_or(STREAM_VALIDATION_CHUNK_SIZE, |remaining_size| {
244            remaining_size.min(STREAM_VALIDATION_CHUNK_SIZE)
245        });
246        let read_buffer = buffer
247            .get_mut(..requested)
248            .ok_or_else(|| SyncwebError::InvalidConfig("invalid bounded fetch buffer size".to_owned()))?;
249        let read = reader.read(read_buffer).await?;
250        if read == 0 {
251            return Err(SyncwebError::operation(
252                "fetched blob validation failed",
253                io::Error::new(io::ErrorKind::UnexpectedEof, "provider stream is truncated"),
254            ));
255        }
256        let data = buffer
257            .get(..read)
258            .ok_or_else(|| SyncwebError::InvalidConfig("invalid bounded fetch read size".to_owned()))?;
259        hasher.update(data);
260        remaining = remaining.saturating_sub(u64::try_from(read).unwrap_or(u64::MAX));
261    }
262
263    let read_buffer = buffer
264        .get_mut(..1)
265        .ok_or_else(|| SyncwebError::InvalidConfig("invalid bounded fetch buffer size".to_owned()))?;
266    let read = reader.read(read_buffer).await?;
267    if read != 0 {
268        return Err(SyncwebError::operation(
269            "fetched blob validation failed",
270            io::Error::new(io::ErrorKind::InvalidData, "provider stream exceeds expected size"),
271        ));
272    }
273
274    let actual_hash = Hash::from_bytes(*hasher.finalize().as_bytes());
275    if actual_hash != expected_hash {
276        return Err(SyncwebError::operation(
277            "fetched blob validation failed",
278            io::Error::new(io::ErrorKind::InvalidData, "provider stream hash mismatch"),
279        ));
280    }
281    Ok(())
282}
283
284/// Alias for [`validate_bounded_fetch`].
285///
286/// # Errors
287///
288/// Returns an error if the stream fails bounded size or hash validation.
289pub async fn validate_fetch_stream<R>(reader: R, expected_size: u64, expected_hash: Hash) -> Result<()>
290where
291    R: AsyncRead + Unpin,
292{
293    validate_bounded_fetch(reader, expected_size, expected_hash).await
294}
295
296/// Alias for [`validate_bounded_fetch`].
297///
298/// # Errors
299///
300/// Returns an error if the stream fails bounded size or hash validation.
301pub async fn validate_bounded_stream<R>(reader: R, expected_size: u64, expected_hash: Hash) -> Result<()>
302where
303    R: AsyncRead + Unpin,
304{
305    validate_bounded_fetch(reader, expected_size, expected_hash).await
306}
307
308/// A signed claim that a provider currently serves a blob.
309///
310/// The blob ticket is included in the signed payload so a lease cannot be
311/// redirected to a different hash or provider after it is published.
312#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
313#[non_exhaustive]
314pub struct ProviderLease {
315    pub hash: Hash,
316    pub provider: PublicKey,
317    pub ticket: String,
318    pub sequence: u64,
319    pub issued_at: u64,
320    pub expires_at: u64,
321    pub signature: Option<String>,
322}
323
324impl ProviderLease {
325    /// Create an unsigned lease using the current time as its issue time.
326    ///
327    /// Call [`Self::sign`] before publishing or tracking the lease.
328    ///
329    /// # Errors
330    ///
331    /// Returns an error if the ticket is invalid or does not match the hash.
332    pub fn new(hash: Hash, ticket: impl Into<String>, sequence: u64, expires_at: u64) -> Result<Self> {
333        Self::new_with_times(hash, ticket, sequence, current_epoch_seconds(), expires_at)
334    }
335
336    /// Create an unsigned lease with explicit timestamps.
337    ///
338    /// Explicit timestamps are useful when restoring persisted leases or
339    /// testing expiry behavior.
340    ///
341    /// # Errors
342    ///
343    /// Returns an error if the ticket is invalid, does not match the hash, or
344    /// the lease timestamps are invalid.
345    pub fn new_with_times(
346        hash: Hash,
347        ticket: impl Into<String>,
348        sequence: u64,
349        issued_at: u64,
350        expires_at: u64,
351    ) -> Result<Self> {
352        let ticket_text = ticket.into();
353        let parsed_ticket = parse_ticket(&ticket_text)?;
354        if parsed_ticket.hash() != hash {
355            return Err(SyncwebError::InvalidTicket(
356                "provider lease ticket does not match its blob hash".to_owned(),
357            ));
358        }
359        let lease = Self {
360            hash,
361            provider: parsed_ticket.addr().id,
362            ticket: ticket_text,
363            sequence,
364            issued_at,
365            expires_at,
366            signature: None,
367        };
368        lease.validate()?;
369        Ok(lease)
370    }
371
372    /// Create and sign a lease with an iroh node secret key.
373    ///
374    /// # Errors
375    ///
376    /// Returns an error if the ticket is invalid, the key does not own the
377    /// advertised provider, or the lease cannot be serialized.
378    pub fn signed(
379        hash: Hash,
380        ticket: impl Into<String>,
381        sequence: u64,
382        expires_at: u64,
383        secret_key: &SecretKey,
384    ) -> Result<Self> {
385        let mut lease = Self::new(hash, ticket, sequence, expires_at)?;
386        lease.sign_with_secret_key(secret_key)?;
387        Ok(lease)
388    }
389
390    /// Sign this lease with an Ed25519 signing key.
391    ///
392    /// The signing key must correspond to the provider encoded in the ticket.
393    ///
394    /// # Errors
395    ///
396    /// Returns an error if the key does not own the advertised provider or the
397    /// unsigned lease cannot be serialized.
398    pub fn sign(&mut self, signing_key: &SigningKey) -> Result<()> {
399        if signing_key.verifying_key().to_bytes() != *self.provider.as_bytes() {
400            return Err(SyncwebError::InvalidIdentity(
401                "provider lease signer does not match the ticket provider".to_owned(),
402            ));
403        }
404        self.signature = Some(hex::encode(signing_key.sign(&self.unsigned_bytes()?).to_bytes()));
405        Ok(())
406    }
407
408    /// Sign this lease with an iroh node secret key.
409    ///
410    /// # Errors
411    ///
412    /// Returns an error if the key does not own the advertised provider or the
413    /// lease cannot be serialized.
414    pub fn sign_with_secret_key(&mut self, secret_key: &SecretKey) -> Result<()> {
415        let signing_key = SigningKey::from_bytes(&secret_key.to_bytes());
416        self.sign(&signing_key)
417    }
418
419    /// Return whether the lease carries a signature.
420    #[must_use]
421    pub const fn is_signed(&self) -> bool {
422        self.signature.is_some()
423    }
424
425    /// Verify the lease signature without checking expiry.
426    ///
427    /// # Errors
428    ///
429    /// Returns an error if the lease structure or signature is invalid.
430    pub fn verify_signature(&self) -> Result<()> {
431        self.validate()?;
432        let signature_text = self
433            .signature
434            .as_deref()
435            .ok_or_else(|| SyncwebError::InvalidConfig("provider lease must contain a signature".to_owned()))?;
436        let signature_bytes = hex::decode(signature_text).map_err(|error| {
437            SyncwebError::InvalidConfig(format!("invalid provider lease signature encoding: {error}"))
438        })?;
439        let signature = Signature::from_slice(&signature_bytes)
440            .map_err(|error| SyncwebError::InvalidConfig(format!("invalid provider lease signature: {error}")))?;
441        let verifying_key = VerifyingKey::from_bytes(self.provider.as_bytes())
442            .map_err(|error| SyncwebError::InvalidIdentity(format!("invalid provider lease identity: {error}")))?;
443        verifying_key
444            .verify(&self.unsigned_bytes()?, &signature)
445            .map_err(|error| SyncwebError::InvalidConfig(format!("provider lease signature is invalid: {error}")))
446    }
447
448    /// Verify the signature and require the lease to be active at `now`.
449    ///
450    /// # Errors
451    ///
452    /// Returns an error if the signature is invalid or the lease has expired.
453    pub fn verify_at(&self, now: u64) -> Result<()> {
454        self.verify_signature()?;
455        if self.expires_at <= now {
456            return Err(SyncwebError::InvalidConfig("provider lease has expired".to_owned()));
457        }
458        Ok(())
459    }
460
461    /// Return whether the lease is expired at `now`.
462    #[must_use]
463    pub const fn is_expired_at(&self, now: u64) -> bool {
464        self.expires_at <= now
465    }
466
467    /// Encode the lease as JSON.
468    ///
469    /// # Errors
470    ///
471    /// Returns an error if the lease is malformed or cannot be serialized.
472    pub fn to_bytes(&self) -> Result<Vec<u8>> {
473        self.validate()?;
474        serde_json::to_vec(self).map_err(|error| SyncwebError::operation("failed to serialize provider lease", error))
475    }
476
477    /// Decode a lease from JSON.
478    ///
479    /// # Errors
480    ///
481    /// Returns an error if the lease is malformed or cannot be decoded.
482    pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Result<Self> {
483        let lease: Self = serde_json::from_slice(bytes.as_ref())
484            .map_err(|error| SyncwebError::operation("failed to deserialize provider lease", error))?;
485        lease.validate()?;
486        Ok(lease)
487    }
488
489    /// Validate the non-cryptographic lease fields.
490    ///
491    /// # Errors
492    ///
493    /// Returns an error if the ticket, sequence, or timestamps are invalid.
494    pub fn validate(&self) -> Result<()> {
495        if self.sequence == 0 {
496            return Err(SyncwebError::InvalidConfig(
497                "provider lease sequence must be greater than zero".to_owned(),
498            ));
499        }
500        if self.expires_at <= self.issued_at {
501            return Err(SyncwebError::InvalidConfig(
502                "provider lease expiration must be after its issue time".to_owned(),
503            ));
504        }
505        let ticket = parse_ticket(&self.ticket)?;
506        if ticket.hash() != self.hash {
507            return Err(SyncwebError::InvalidTicket(
508                "provider lease ticket does not match its blob hash".to_owned(),
509            ));
510        }
511        if ticket.addr().id != self.provider {
512            return Err(SyncwebError::InvalidIdentity(
513                "provider lease provider does not match its ticket".to_owned(),
514            ));
515        }
516        Ok(())
517    }
518
519    fn unsigned_bytes(&self) -> Result<Vec<u8>> {
520        let mut unsigned = self.clone();
521        unsigned.signature = None;
522        let encoded = serde_json::to_vec(&unsigned)
523            .map_err(|error| SyncwebError::operation("failed to serialize unsigned provider lease", error))?;
524        let mut signed_bytes = Vec::with_capacity(PROVIDER_LEASE_SIGNATURE_CONTEXT.len().saturating_add(encoded.len()));
525        signed_bytes.extend_from_slice(PROVIDER_LEASE_SIGNATURE_CONTEXT);
526        signed_bytes.extend_from_slice(&encoded);
527        Ok(signed_bytes)
528    }
529}
530
531/// A lease update accepted by a [`ProviderLeaseTracker`].
532#[derive(Clone, Copy, Debug, Eq, PartialEq)]
533#[non_exhaustive]
534pub enum LeaseUpdate {
535    Inserted,
536    Replaced,
537    IgnoredOlder,
538}
539
540impl LeaseUpdate {
541    #[must_use]
542    pub const fn changed(self) -> bool {
543        matches!(self, Self::Inserted | Self::Replaced)
544    }
545}
546
547/// The source of a provider ban.
548#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
549#[non_exhaustive]
550pub enum BanSource {
551    Manual,
552    Automated,
553    WoT,
554}
555
556/// A global or hash-scoped provider ban.
557#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
558#[non_exhaustive]
559pub struct BanRecord {
560    pub provider: PublicKey,
561    pub hash: Option<Hash>,
562    pub banned_at: u64,
563    pub expires_at: Option<u64>,
564    pub reason: String,
565    pub source: BanSource,
566}
567
568/// Verified and locally observed providers for one blob.
569#[derive(Clone, Debug, Eq, PartialEq)]
570#[non_exhaustive]
571pub struct AvailabilityHealth {
572    pub verified: usize,
573    pub local: usize,
574    pub verified_providers: Vec<PublicKey>,
575    pub local_providers: Vec<PublicKey>,
576}
577
578impl AvailabilityHealth {
579    #[must_use]
580    pub const fn verified_count(&self) -> usize {
581        self.verified
582    }
583
584    #[must_use]
585    pub const fn verified_lease_count(&self) -> usize {
586        self.verified
587    }
588
589    #[must_use]
590    pub const fn local_count(&self) -> usize {
591        self.local
592    }
593
594    #[must_use]
595    pub const fn local_observation_count(&self) -> usize {
596        self.local
597    }
598}
599
600/// In-memory tracker for signed provider leases and local observations.
601#[derive(Clone, Debug)]
602pub struct ProviderLeaseTracker {
603    leases: HashMap<Hash, HashMap<PublicKey, ProviderLease>>,
604    observations: HashMap<Hash, HashMap<PublicKey, u64>>,
605    failures: HashMap<Hash, HashMap<PublicKey, FailureRecord>>,
606    failure_totals: HashMap<Hash, HashMap<PublicKey, u64>>,
607    definitive_streaks: HashMap<Hash, HashMap<PublicKey, u32>>,
608    bans: HashMap<PublicKey, BanRecord>,
609    hash_bans: HashMap<Hash, HashMap<PublicKey, BanRecord>>,
610    max_failures_per_provider: usize,
611}
612
613impl Default for ProviderLeaseTracker {
614    fn default() -> Self {
615        Self::with_max_failures_per_provider(DEFAULT_MAX_FAILURES_PER_PROVIDER)
616    }
617}
618
619/// Bounded failure history and aggregate counters for one provider and blob.
620#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
621#[non_exhaustive]
622pub struct FailureRecord {
623    pub failures: Vec<FetchFailure>,
624    pub consecutive_failures: u32,
625    pub last_failure_at: u64,
626    pub first_failure_at: u64,
627}
628
629impl ProviderLeaseTracker {
630    /// Create an empty tracker with a bounded failure history.
631    #[must_use]
632    pub fn with_max_failures_per_provider(max_failures_per_provider: usize) -> Self {
633        Self {
634            leases: HashMap::new(),
635            observations: HashMap::new(),
636            failures: HashMap::new(),
637            failure_totals: HashMap::new(),
638            definitive_streaks: HashMap::new(),
639            bans: HashMap::new(),
640            hash_bans: HashMap::new(),
641            max_failures_per_provider,
642        }
643    }
644
645    /// Return the failure-detail cap for each `(hash, provider)` pair.
646    #[must_use]
647    pub const fn max_failures_per_provider(&self) -> usize {
648        self.max_failures_per_provider
649    }
650
651    /// Set the failure-detail cap and evict oldest details if necessary.
652    pub fn set_max_failures_per_provider(&mut self, max_failures_per_provider: usize) {
653        self.max_failures_per_provider = max_failures_per_provider;
654        for providers in self.failures.values_mut() {
655            for record in providers.values_mut() {
656                let keep_from = record.failures.len().saturating_sub(max_failures_per_provider);
657                if keep_from > 0 {
658                    record.failures.drain(..keep_from);
659                }
660            }
661        }
662    }
663
664    /// Track an active, signed lease using the current time.
665    ///
666    /// # Errors
667    ///
668    /// Returns an error if the lease signature or expiry is invalid.
669    pub fn track(&mut self, lease: ProviderLease) -> Result<LeaseUpdate> {
670        self.track_at(lease, current_epoch_seconds())
671    }
672
673    /// Track an active, signed lease at an explicit time.
674    ///
675    /// Older sequence numbers from the same provider are ignored. A new
676    /// sequence replaces the provider's previous lease for this blob.
677    ///
678    /// # Errors
679    ///
680    /// Returns an error if the lease signature or expiry is invalid.
681    pub fn track_at(&mut self, lease: ProviderLease, now: u64) -> Result<LeaseUpdate> {
682        lease.verify_at(now)?;
683        let providers = self.leases.entry(lease.hash).or_default();
684        let update = match providers.get(&lease.provider) {
685            None => LeaseUpdate::Inserted,
686            Some(existing) if lease.sequence > existing.sequence => LeaseUpdate::Replaced,
687            Some(_) => LeaseUpdate::IgnoredOlder,
688        };
689        if update.changed() {
690            providers.insert(lease.provider, lease);
691        }
692        Ok(update)
693    }
694
695    /// Alias for [`Self::track`].
696    ///
697    /// # Errors
698    ///
699    /// Returns an error if the lease signature or expiry is invalid.
700    pub fn record_lease(&mut self, lease: ProviderLease) -> Result<LeaseUpdate> {
701        self.track(lease)
702    }
703
704    /// Alias for [`Self::track`].
705    ///
706    /// # Errors
707    ///
708    /// Returns an error if the lease signature or expiry is invalid.
709    pub fn add_lease(&mut self, lease: ProviderLease) -> Result<LeaseUpdate> {
710        self.track(lease)
711    }
712
713    /// Remove a provider's lease for a blob and record an automated,
714    /// hash-scoped ban.
715    pub fn invalidate_lease(&mut self, hash: Hash, provider: PublicKey) -> bool {
716        self.invalidate_lease_at(hash, provider, current_epoch_seconds())
717    }
718
719    /// Remove a provider's lease and record an automated ban at an explicit
720    /// time.
721    pub fn invalidate_lease_at(&mut self, hash: Hash, provider: PublicKey, now: u64) -> bool {
722        let removed = self
723            .leases
724            .get_mut(&hash)
725            .is_some_and(|providers| providers.remove(&provider).is_some());
726        if let Some(providers) = self.leases.get(&hash)
727            && providers.is_empty()
728        {
729            self.leases.remove(&hash);
730        }
731        self.ban_provider(
732            provider,
733            Some(hash),
734            "provider failed to serve the leased blob".to_owned(),
735            BanSource::Automated,
736            Some(AUTOMATED_BAN_DURATION),
737            now,
738        );
739        removed
740    }
741
742    /// Return whether a provider is covered by an active global or
743    /// hash-scoped ban.
744    #[must_use]
745    pub fn is_banned(&self, provider: PublicKey, hash: &Hash, now: u64) -> bool {
746        self.bans.get(&provider).is_some_and(|ban| ban_is_active(ban, now))
747            || self
748                .hash_bans
749                .get(hash)
750                .and_then(|providers| providers.get(&provider))
751                .is_some_and(|ban| ban_is_active(ban, now))
752    }
753
754    /// Add or update a global or hash-scoped provider ban.
755    pub fn ban_provider(
756        &mut self,
757        provider: PublicKey,
758        scope: Option<Hash>,
759        reason: impl Into<String>,
760        source: BanSource,
761        duration: Option<Duration>,
762        now: u64,
763    ) -> BanRecord {
764        let record = BanRecord {
765            provider,
766            hash: scope,
767            banned_at: now,
768            expires_at: duration.map(|value| now.saturating_add(value.as_secs())),
769            reason: reason.into(),
770            source,
771        };
772        match scope {
773            Some(scope_hash) => {
774                let bans = self.hash_bans.entry(scope_hash).or_default();
775                insert_ban(bans, record.clone());
776            }
777            None => {
778                insert_ban(&mut self.bans, record.clone());
779            }
780        }
781        record
782    }
783
784    /// Apply a persisted ban record to in-memory state.
785    ///
786    /// Used when hydrating bans from the database on construction.
787    pub fn apply_ban_record(&mut self, record: BanRecord) {
788        match record.hash {
789            Some(scope_hash) => {
790                let bans = self.hash_bans.entry(scope_hash).or_default();
791                insert_ban(bans, record);
792            }
793            None => {
794                insert_ban(&mut self.bans, record);
795            }
796        }
797    }
798
799    /// Remove a global or hash-scoped ban.
800    pub fn unban_provider(&mut self, provider: PublicKey, scope: Option<Hash>) -> bool {
801        match scope {
802            Some(scope_hash) => {
803                let removed = self
804                    .hash_bans
805                    .get_mut(&scope_hash)
806                    .is_some_and(|providers| providers.remove(&provider).is_some());
807                if self.hash_bans.get(&scope_hash).is_some_and(HashMap::is_empty) {
808                    self.hash_bans.remove(&scope_hash);
809                }
810                removed
811            }
812            None => self.bans.remove(&provider).is_some(),
813        }
814    }
815
816    /// Return active provider identities covered by global and hash-scoped
817    /// bans for a blob.
818    #[must_use]
819    pub fn banned_providers(&self, hash: &Hash, now: u64) -> Vec<PublicKey> {
820        let mut providers = self
821            .bans
822            .values()
823            .filter(|ban| ban_is_active(ban, now))
824            .map(|ban| ban.provider)
825            .collect::<Vec<_>>();
826        if let Some(scoped) = self.hash_bans.get(hash) {
827            providers.extend(
828                scoped
829                    .values()
830                    .filter(|ban| ban_is_active(ban, now))
831                    .map(|ban| ban.provider),
832            );
833        }
834        providers.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
835        providers.dedup();
836        providers
837    }
838
839    /// Return active ban records applying to a blob.
840    #[must_use]
841    pub fn ban_records(&self, hash: &Hash, now: u64) -> Vec<BanRecord> {
842        let mut records = Vec::new();
843        records.extend(self.bans.values().filter(|ban| ban_is_active(ban, now)).cloned());
844        if let Some(scoped) = self.hash_bans.get(hash) {
845            records.extend(scoped.values().filter(|ban| ban_is_active(ban, now)).cloned());
846        }
847        records.sort_by(|left, right| left.provider.as_bytes().cmp(right.provider.as_bytes()));
848        records
849    }
850
851    /// Invalidate all providers with definitive failures for a blob except
852    /// the provider that successfully supplied it.
853    pub fn retroactive_invalidate(&mut self, hash: Hash, successful_provider: PublicKey, now: u64) -> Vec<PublicKey> {
854        self.purge_stale_failures(now, DEFAULT_FAILURE_TTL);
855        let failed_providers = self
856            .failures
857            .get(&hash)
858            .map(|providers| {
859                providers
860                    .iter()
861                    .filter(|(provider, record)| {
862                        **provider != successful_provider
863                            && record.failures.iter().any(|failure| failure.kind.is_definitive())
864                    })
865                    .map(|(provider, _)| *provider)
866                    .collect::<Vec<_>>()
867            })
868            .unwrap_or_default();
869        let mut invalidated = failed_providers
870            .into_iter()
871            .filter(|provider| self.invalidate_lease_at(hash, *provider, now))
872            .collect::<Vec<_>>();
873        invalidated.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
874        invalidated
875    }
876
877    /// Record a provider failure using the current epoch time.
878    pub fn record_failure(&mut self, hash: Hash, provider: PublicKey, failure: FetchFailure) {
879        self.record_failure_at(hash, provider, failure, current_epoch_seconds());
880    }
881
882    /// Record a provider failure at an explicit time.
883    ///
884    /// Failure details are capped per provider, while aggregate counts remain
885    /// available through [`Self::failure_count`].
886    pub fn record_failure_at(&mut self, hash: Hash, provider: PublicKey, failure: FetchFailure, now: u64) {
887        self.purge_stale_failures(now, DEFAULT_FAILURE_TTL);
888        let existing_total = self
889            .failure_totals
890            .get(&hash)
891            .and_then(|providers| providers.get(&provider))
892            .copied()
893            .unwrap_or(0);
894        let previous_kind = self
895            .failures
896            .get(&hash)
897            .and_then(|providers| providers.get(&provider))
898            .and_then(|record| record.failures.last())
899            .map(|item| item.kind);
900        let previous_consecutive = self
901            .failures
902            .get(&hash)
903            .and_then(|providers| providers.get(&provider))
904            .map_or(0, |record| record.consecutive_failures);
905        let record = self
906            .failures
907            .entry(hash)
908            .or_default()
909            .entry(provider)
910            .or_insert_with(|| FailureRecord {
911                failures: Vec::new(),
912                consecutive_failures: 0,
913                last_failure_at: now,
914                first_failure_at: now,
915            });
916        if existing_total == 0 {
917            record.first_failure_at = now;
918        }
919        record.last_failure_at = now;
920        record.consecutive_failures = previous_consecutive.saturating_add(1);
921        let definitive_streak = self
922            .definitive_streaks
923            .entry(hash)
924            .or_default()
925            .entry(provider)
926            .or_default();
927        *definitive_streak = if failure.kind.is_definitive() {
928            if previous_kind.is_some_and(FetchFailureKind::is_definitive) {
929                definitive_streak.saturating_add(1)
930            } else {
931                1
932            }
933        } else {
934            0
935        };
936        record.failures.push(failure);
937        let keep_from = record.failures.len().saturating_sub(self.max_failures_per_provider);
938        if keep_from > 0 {
939            record.failures.drain(..keep_from);
940        }
941        let total = self
942            .failure_totals
943            .entry(hash)
944            .or_default()
945            .entry(provider)
946            .or_default();
947        *total = existing_total.saturating_add(1);
948    }
949
950    /// Return the bounded failure details for a provider and blob.
951    #[must_use]
952    pub fn failure_record(&self, hash: &Hash, provider: &PublicKey) -> Option<&FailureRecord> {
953        self.failures.get(hash).and_then(|providers| providers.get(provider))
954    }
955
956    /// Return the aggregate failure count for a provider and blob.
957    #[must_use]
958    pub fn failure_count(&self, hash: &Hash, provider: &PublicKey) -> usize {
959        self.failure_totals
960            .get(hash)
961            .and_then(|providers| providers.get(provider))
962            .copied()
963            .map_or(0, |count| usize::try_from(count).unwrap_or(usize::MAX))
964    }
965
966    /// Return the current consecutive definitive-failure count.
967    #[must_use]
968    pub fn consecutive_failures(&self, hash: &Hash, provider: &PublicKey) -> u32 {
969        self.failure_record(hash, provider)
970            .map_or(0, |record| record.consecutive_failures)
971    }
972
973    /// Return whether a provider has crossed the definitive-failure threshold.
974    #[must_use]
975    pub fn is_definitively_failed(&self, hash: &Hash, provider: &PublicKey) -> bool {
976        self.definitive_streaks
977            .get(hash)
978            .and_then(|providers| providers.get(provider))
979            .is_some_and(|streak| *streak >= DEFINITIVE_FAILURE_THRESHOLD)
980    }
981
982    /// Clear all failure history for a provider after a successful fetch.
983    pub fn clear_failures_for_provider(&mut self, hash: &Hash, provider: &PublicKey) {
984        if let Some(providers) = self.failures.get_mut(hash) {
985            providers.remove(provider);
986            if providers.is_empty() {
987                self.failures.remove(hash);
988            }
989        }
990        if let Some(providers) = self.failure_totals.get_mut(hash) {
991            providers.remove(provider);
992            if providers.is_empty() {
993                self.failure_totals.remove(hash);
994            }
995        }
996        if let Some(providers) = self.definitive_streaks.get_mut(hash) {
997            providers.remove(provider);
998            if providers.is_empty() {
999                self.definitive_streaks.remove(hash);
1000            }
1001        }
1002    }
1003
1004    /// Remove failure records whose last observation is outside `ttl`.
1005    pub fn purge_stale_failures(&mut self, now: u64, ttl: Duration) {
1006        let ttl_seconds = ttl.as_secs();
1007        let mut stale = Vec::new();
1008        self.failures.retain(|hash, providers| {
1009            providers.retain(|provider, record| {
1010                let keep = now.saturating_sub(record.last_failure_at) <= ttl_seconds;
1011                if !keep {
1012                    stale.push((*hash, *provider));
1013                }
1014                keep
1015            });
1016            !providers.is_empty()
1017        });
1018        for (hash, provider) in stale {
1019            if let Some(totals) = self.failure_totals.get_mut(&hash) {
1020                totals.remove(&provider);
1021            }
1022            if let Some(streaks) = self.definitive_streaks.get_mut(&hash) {
1023                streaks.remove(&provider);
1024            }
1025        }
1026        self.failure_totals.retain(|_, providers| !providers.is_empty());
1027        self.definitive_streaks.retain(|_, providers| !providers.is_empty());
1028    }
1029
1030    /// Record that a provider was observed serving a blob locally.
1031    pub fn observe_provider(&mut self, hash: Hash, provider: PublicKey) {
1032        self.observe_provider_at(hash, provider, current_epoch_seconds());
1033    }
1034
1035    /// Record a provider observation at an explicit time.
1036    pub fn observe_provider_at(&mut self, hash: Hash, provider: PublicKey, observed_at: u64) {
1037        self.observations.entry(hash).or_default().insert(provider, observed_at);
1038    }
1039
1040    /// Return active leases for a blob.
1041    #[must_use]
1042    pub fn leases(&self, hash: &Hash) -> Vec<ProviderLease> {
1043        self.leases_at(hash, current_epoch_seconds())
1044    }
1045
1046    /// Return active leases for a blob at an explicit time.
1047    #[must_use]
1048    pub fn leases_at(&self, hash: &Hash, now: u64) -> Vec<ProviderLease> {
1049        let mut leases = self
1050            .leases
1051            .get(hash)
1052            .map(|providers| {
1053                providers
1054                    .values()
1055                    .filter(|lease| !lease.is_expired_at(now) && !self.is_banned(lease.provider, hash, now))
1056                    .cloned()
1057                    .collect::<Vec<_>>()
1058            })
1059            .unwrap_or_default();
1060        leases.sort_by(|left, right| left.provider.as_bytes().cmp(right.provider.as_bytes()));
1061        leases
1062    }
1063
1064    /// Return active provider identities for a blob.
1065    #[must_use]
1066    pub fn providers(&self, hash: &Hash) -> Vec<PublicKey> {
1067        self.leases(hash).into_iter().map(|lease| lease.provider).collect()
1068    }
1069
1070    /// Return the number of active, previously verified leases.
1071    #[must_use]
1072    pub fn verified_count(&self, hash: &Hash) -> usize {
1073        self.leases(hash).len()
1074    }
1075
1076    /// Return all providers that have an active lease for any blob.
1077    #[must_use]
1078    pub fn list_providers(&self) -> Vec<PublicKey> {
1079        let now = current_epoch_seconds();
1080        let mut seen = HashSet::new();
1081        for (hash, providers) in &self.leases {
1082            for lease in providers.values() {
1083                if !lease.is_expired_at(now) && !self.is_banned(lease.provider, hash, now) {
1084                    seen.insert(lease.provider);
1085                }
1086            }
1087        }
1088        let mut providers: Vec<PublicKey> = seen.into_iter().collect();
1089        providers.sort_by_key(std::string::ToString::to_string);
1090        providers
1091    }
1092
1093    /// Return all blob hashes for which the given provider has an active lease.
1094    #[must_use]
1095    pub fn blobs_for_provider(&self, provider: &PublicKey) -> Vec<Hash> {
1096        let now = current_epoch_seconds();
1097        let mut seen = HashSet::new();
1098        for (hash, providers) in &self.leases {
1099            for lease in providers.values() {
1100                if lease.provider == *provider && !lease.is_expired_at(now) && !self.is_banned(*provider, hash, now) {
1101                    seen.insert(*hash);
1102                    break;
1103                }
1104            }
1105        }
1106        let mut hashes: Vec<Hash> = seen.into_iter().collect();
1107        hashes.sort_by_key(std::string::ToString::to_string);
1108        hashes
1109    }
1110
1111    /// Build a verified-vs-local availability report.
1112    #[must_use]
1113    pub fn health(&self, hash: &Hash, observation_ttl: Duration) -> AvailabilityHealth {
1114        self.health_at(hash, current_epoch_seconds(), observation_ttl)
1115    }
1116
1117    /// Build an availability report at an explicit time.
1118    #[must_use]
1119    pub fn health_at(&self, hash: &Hash, now: u64, observation_ttl: Duration) -> AvailabilityHealth {
1120        let verified_providers = self
1121            .leases_at(hash, now)
1122            .into_iter()
1123            .map(|lease| lease.provider)
1124            .collect::<Vec<_>>();
1125        let ttl = observation_ttl.as_secs();
1126        let mut local_providers = self
1127            .observations
1128            .get(hash)
1129            .map(|observations| {
1130                observations
1131                    .iter()
1132                    .filter(|(_, observed_at)| now.saturating_sub(**observed_at) <= ttl)
1133                    .map(|(provider, _)| *provider)
1134                    .collect::<Vec<_>>()
1135            })
1136            .unwrap_or_default();
1137        local_providers.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
1138        AvailabilityHealth {
1139            verified: verified_providers.len(),
1140            local: local_providers.len(),
1141            verified_providers,
1142            local_providers,
1143        }
1144    }
1145
1146    /// Remove expired leases and stale local observations.
1147    pub fn purge(&mut self, now: u64, observation_ttl: Duration) {
1148        self.leases.retain(|_, providers| {
1149            providers.retain(|_, lease| !lease.is_expired_at(now));
1150            !providers.is_empty()
1151        });
1152        let ttl = observation_ttl.as_secs();
1153        self.observations.retain(|_, providers| {
1154            providers.retain(|_, observed_at| now.saturating_sub(*observed_at) <= ttl);
1155            !providers.is_empty()
1156        });
1157        self.purge_stale_failures(now, DEFAULT_FAILURE_TTL);
1158    }
1159}
1160
1161fn ban_is_active(ban: &BanRecord, now: u64) -> bool {
1162    ban.expires_at.is_none_or(|expires_at| expires_at > now)
1163}
1164
1165const fn ban_priority(source: BanSource) -> u8 {
1166    match source {
1167        BanSource::Automated => 0,
1168        BanSource::WoT => 1,
1169        BanSource::Manual => 2,
1170    }
1171}
1172
1173fn insert_ban(bans: &mut HashMap<PublicKey, BanRecord>, record: BanRecord) {
1174    let should_replace = bans.get(&record.provider).is_none_or(|existing| {
1175        ban_priority(record.source) >= ban_priority(existing.source) || !ban_is_active(existing, record.banned_at)
1176    });
1177    if should_replace {
1178        bans.insert(record.provider, record);
1179    }
1180}
1181
1182/// Replication policy used by [`ResilienceService`].
1183#[derive(Clone, Debug, Eq, PartialEq)]
1184#[non_exhaustive]
1185pub struct ReplicationBudget {
1186    pub min_providers: usize,
1187    pub responsible_peers: usize,
1188    pub max_jitter: Duration,
1189    pub observation_ttl: Duration,
1190}
1191
1192impl ReplicationBudget {
1193    /// Create a budget with the default responsibility and timing settings.
1194    #[must_use]
1195    pub const fn new(min_providers: usize) -> Self {
1196        Self {
1197            min_providers,
1198            responsible_peers: DEFAULT_RESPONSIBLE_PEERS,
1199            max_jitter: DEFAULT_MAX_JITTER,
1200            observation_ttl: DEFAULT_OBSERVATION_TTL,
1201        }
1202    }
1203
1204    /// Set the number of closest peers selected for a fetch.
1205    #[must_use]
1206    pub const fn with_responsible_peers(mut self, peers: usize) -> Self {
1207        self.responsible_peers = peers;
1208        self
1209    }
1210
1211    /// Set the maximum deterministic jitter before a fetch.
1212    #[must_use]
1213    pub const fn with_max_jitter(mut self, jitter: Duration) -> Self {
1214        self.max_jitter = jitter;
1215        self
1216    }
1217
1218    /// Set the lifetime of local provider observations.
1219    #[must_use]
1220    pub const fn with_observation_ttl(mut self, ttl: Duration) -> Self {
1221        self.observation_ttl = ttl;
1222        self
1223    }
1224}
1225
1226impl Default for ReplicationBudget {
1227    fn default() -> Self {
1228        Self::new(3)
1229    }
1230}
1231
1232/// Configuration for the resilience service.
1233#[derive(Clone, Debug, Eq, PartialEq)]
1234#[non_exhaustive]
1235pub struct ResilienceConfig {
1236    pub budget: ReplicationBudget,
1237    pub max_failures_per_provider: usize,
1238    pub parallel: super::parallel::ParallelDownloadConfig,
1239}
1240
1241impl ResilienceConfig {
1242    #[must_use]
1243    pub const fn new(budget: ReplicationBudget) -> Self {
1244        Self {
1245            budget,
1246            max_failures_per_provider: DEFAULT_MAX_FAILURES_PER_PROVIDER,
1247            parallel: super::parallel::ParallelDownloadConfig {
1248                min_blob_size: 16 * 1024 * 1024,
1249                max_concurrent_connections: 5,
1250                min_chunks_per_range: 64,
1251            },
1252        }
1253    }
1254
1255    /// Set the maximum retained failure details per provider and blob.
1256    #[must_use]
1257    pub const fn with_max_failures_per_provider(mut self, max_failures_per_provider: usize) -> Self {
1258        self.max_failures_per_provider = max_failures_per_provider;
1259        self
1260    }
1261
1262    /// Configure parallel multi-provider downloads.
1263    #[must_use]
1264    pub const fn with_parallel_config(mut self, config: super::parallel::ParallelDownloadConfig) -> Self {
1265        self.parallel = config;
1266        self
1267    }
1268}
1269
1270impl Default for ResilienceConfig {
1271    fn default() -> Self {
1272        Self::new(ReplicationBudget::default())
1273    }
1274}
1275
1276/// Result of an attempted availability repair.
1277#[derive(Clone, Debug, Eq, PartialEq)]
1278#[non_exhaustive]
1279pub struct ReplicationResult {
1280    pub hash: Hash,
1281    pub health_before: AvailabilityHealth,
1282    pub health_after: AvailabilityHealth,
1283    pub selected_providers: Vec<PublicKey>,
1284    pub fetched_from: Vec<PublicKey>,
1285    pub failed_from: Vec<(PublicKey, FetchFailureKind)>,
1286    pub invalidated_leases: Vec<PublicKey>,
1287    pub pinned: bool,
1288    pub short_circuited: bool,
1289}
1290
1291/// Result of waiting for a responsible fetch slot.
1292#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1293#[non_exhaustive]
1294pub enum FetchWait {
1295    Ready,
1296    ShortCircuited,
1297}
1298
1299/// Indexing-layer manager for leases, health, jitter, and repair fetches.
1300#[derive(Clone)]
1301pub struct ResilienceService {
1302    config: ResilienceConfig,
1303    tracker: Arc<Mutex<ProviderLeaseTracker>>,
1304    generations: Arc<Mutex<HashMap<Hash, watch::Sender<u64>>>>,
1305    reputation: Arc<Mutex<ProviderReputationStore>>,
1306    wot: Option<WotService>,
1307    database: Option<IndexingDatabase>,
1308}
1309
1310impl std::fmt::Debug for ResilienceService {
1311    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1312        formatter
1313            .debug_struct("ResilienceService")
1314            .field("config", &self.config)
1315            .finish_non_exhaustive()
1316    }
1317}
1318
1319impl ResilienceService {
1320    /// Create a resilience service from explicit configuration.
1321    #[must_use]
1322    pub fn new(config: ResilienceConfig) -> Self {
1323        let tracker = ProviderLeaseTracker::with_max_failures_per_provider(config.max_failures_per_provider);
1324        Self {
1325            config,
1326            tracker: Arc::new(Mutex::new(tracker)),
1327            generations: Arc::new(Mutex::new(HashMap::new())),
1328            reputation: Arc::new(Mutex::new(ProviderReputationStore::default())),
1329            wot: None,
1330            database: None,
1331        }
1332    }
1333
1334    /// Create a resilience service backed by an indexing database.
1335    ///
1336    /// Active leases and bans are hydrated from the database on construction.
1337    /// Mutations are persisted through the database on every operation.
1338    #[must_use]
1339    pub fn with_database(database: IndexingDatabase, config: ResilienceConfig) -> Self {
1340        let now = current_epoch_seconds();
1341        let leases = database.load_active_leases(now).unwrap_or_default();
1342        let bans = database.load_active_bans(now).unwrap_or_default();
1343        let mut tracker = ProviderLeaseTracker::with_max_failures_per_provider(config.max_failures_per_provider);
1344        for lease in &leases {
1345            let _ = tracker.track_at(lease.clone(), now);
1346        }
1347        for ban in &bans {
1348            tracker.apply_ban_record(ban.clone());
1349        }
1350        let reputation =
1351            ProviderReputationStore::with_database(database.clone(), ReputationConfig::default()).unwrap_or_default();
1352        Self {
1353            config,
1354            tracker: Arc::new(Mutex::new(tracker)),
1355            generations: Arc::new(Mutex::new(HashMap::new())),
1356            reputation: Arc::new(Mutex::new(reputation)),
1357            wot: None,
1358            database: Some(database),
1359        }
1360    }
1361
1362    /// Create a resilience service using a replication budget.
1363    #[must_use]
1364    pub fn with_budget(budget: ReplicationBudget) -> Self {
1365        Self::new(ResilienceConfig::new(budget))
1366    }
1367
1368    /// Create a resilience service which filters providers through local `WoT`.
1369    #[must_use]
1370    pub fn with_wot(config: ResilienceConfig, wot: WotService) -> Self {
1371        let mut service = Self::new(config);
1372        service.wot = Some(wot);
1373        service
1374    }
1375
1376    /// Create a service with explicit reputation and `WoT` policy state.
1377    #[must_use]
1378    pub fn with_reputation(
1379        config: ResilienceConfig,
1380        reputation_config: ReputationConfig,
1381        wot: Option<WotService>,
1382    ) -> Self {
1383        let mut service = Self::new(config);
1384        service.reputation = Arc::new(Mutex::new(ProviderReputationStore::new(reputation_config)));
1385        service.wot = wot;
1386        service
1387    }
1388
1389    #[must_use]
1390    pub const fn config(&self) -> &ResilienceConfig {
1391        &self.config
1392    }
1393
1394    /// Return the shared historical provider reputation store.
1395    #[must_use]
1396    pub fn reputation_store(&self) -> Arc<Mutex<ProviderReputationStore>> {
1397        Arc::clone(&self.reputation)
1398    }
1399
1400    /// Replace the local `WoT` service used for provider filtering.
1401    #[must_use]
1402    pub fn with_trust_policy(mut self, wot: WotService) -> Self {
1403        self.wot = Some(wot);
1404        self
1405    }
1406
1407    /// Track a verified lease and wake pending fetches when it is new.
1408    ///
1409    /// # Errors
1410    ///
1411    /// Returns an error if the lease is invalid or the service state lock is
1412    /// poisoned.
1413    pub fn record_lease(&self, lease: &ProviderLease) -> Result<LeaseUpdate> {
1414        let hash = lease.hash;
1415        let update = self
1416            .tracker
1417            .lock()
1418            .map_err(|error| SyncwebError::operation("provider lease tracker lock poisoned", error))?
1419            .track(lease.clone())?;
1420        if update.changed() {
1421            if let Some(ref database) = self.database {
1422                let _ = database.insert_provider_lease(lease);
1423            }
1424            self.bump_generation(hash)?;
1425        }
1426        Ok(update)
1427    }
1428
1429    /// Alias for [`Self::record_lease`].
1430    ///
1431    /// # Errors
1432    ///
1433    /// Returns an error if the lease is invalid or the service state lock is
1434    /// poisoned.
1435    pub fn track_lease(&self, lease: &ProviderLease) -> Result<LeaseUpdate> {
1436        self.record_lease(lease)
1437    }
1438
1439    /// Alias for [`Self::record_lease`].
1440    ///
1441    /// # Errors
1442    ///
1443    /// Returns an error if the lease is invalid or the service state lock is
1444    /// poisoned.
1445    pub fn ingest_lease(&self, lease: &ProviderLease) -> Result<LeaseUpdate> {
1446        self.record_lease(lease)
1447    }
1448
1449    /// Invalidate a provider's lease and cancel pending fetches for its blob.
1450    ///
1451    /// # Errors
1452    ///
1453    /// Returns an error if the provider lease tracker or generation state is
1454    /// poisoned.
1455    pub fn invalidate_provider(&self, hash: Hash, provider: PublicKey) -> Result<bool> {
1456        let invalidated = self
1457            .tracker
1458            .lock()
1459            .map_err(|error| SyncwebError::operation("provider lease tracker lock poisoned", error))?
1460            .invalidate_lease(hash, provider);
1461        if invalidated {
1462            self.bump_generation(hash)?;
1463        }
1464        Ok(invalidated)
1465    }
1466
1467    /// Add a manual global or hash-scoped provider ban.
1468    ///
1469    /// # Errors
1470    ///
1471    /// Returns an error if provider state or generation state is poisoned.
1472    pub fn ban_provider(
1473        &self,
1474        provider: PublicKey,
1475        reason: impl Into<String>,
1476        scope: Option<Hash>,
1477        duration: Option<Duration>,
1478    ) -> Result<BanRecord> {
1479        let record = self
1480            .tracker
1481            .lock()
1482            .map_err(|error| SyncwebError::operation("provider lease tracker lock poisoned", error))?
1483            .ban_provider(
1484                provider,
1485                scope,
1486                reason,
1487                BanSource::Manual,
1488                duration,
1489                current_epoch_seconds(),
1490            );
1491        if let Some(ref database) = self.database {
1492            let _ = database.insert_provider_ban(&record);
1493        }
1494        if let Some(scope_hash) = scope {
1495            self.bump_generation(scope_hash)?;
1496        }
1497        Ok(record)
1498    }
1499
1500    /// Remove a manual global or hash-scoped provider ban.
1501    ///
1502    /// # Errors
1503    ///
1504    /// Returns an error if provider state or generation state is poisoned.
1505    pub fn unban_provider(&self, provider: PublicKey, scope: Option<Hash>) -> Result<bool> {
1506        let removed = self
1507            .tracker
1508            .lock()
1509            .map_err(|error| SyncwebError::operation("provider lease tracker lock poisoned", error))?
1510            .unban_provider(provider, scope);
1511        if removed && let Some(scope_hash) = scope {
1512            self.bump_generation(scope_hash)?;
1513        }
1514        Ok(removed)
1515    }
1516
1517    /// Retroactively invalidate providers that definitively failed after a
1518    /// successful fetch.
1519    ///
1520    /// # Errors
1521    ///
1522    /// Returns an error if provider state or generation state is poisoned.
1523    pub fn retroactive_invalidate(&self, hash: Hash, successful_provider: PublicKey) -> Result<Vec<PublicKey>> {
1524        let invalidated = self
1525            .tracker
1526            .lock()
1527            .map_err(|error| SyncwebError::operation("provider lease tracker lock poisoned", error))?
1528            .retroactive_invalidate(hash, successful_provider, current_epoch_seconds());
1529        if !invalidated.is_empty() {
1530            self.bump_generation(hash)?;
1531        }
1532        Ok(invalidated)
1533    }
1534
1535    /// Record a locally observed provider.
1536    ///
1537    /// # Errors
1538    ///
1539    /// Returns an error if the service state lock is poisoned.
1540    pub fn observe_provider(&self, hash: Hash, provider: PublicKey) -> Result<()> {
1541        self.tracker
1542            .lock()
1543            .map_err(|error| SyncwebError::operation("provider lease tracker lock poisoned", error))?
1544            .observe_provider(hash, provider);
1545        Ok(())
1546    }
1547
1548    /// Return the current health report for a blob.
1549    ///
1550    /// # Errors
1551    ///
1552    /// Returns an error if the service state lock is poisoned.
1553    pub fn health(&self, hash: &Hash) -> Result<AvailabilityHealth> {
1554        Ok(self
1555            .tracker
1556            .lock()
1557            .map_err(|error| SyncwebError::operation("provider lease tracker lock poisoned", error))?
1558            .health(hash, self.config.budget.observation_ttl))
1559    }
1560
1561    /// Query health for many hashes at once using a reverse index.
1562    ///
1563    /// This is O(n + m) where n = total provider leases and m = queried
1564    /// hashes, instead of O(n * m) for calling `health()` per hash.
1565    ///
1566    /// # Errors
1567    ///
1568    /// Returns an error if the service state lock is poisoned.
1569    pub fn health_batch(&self, hashes: &[Hash]) -> Result<HashMap<Hash, AvailabilityHealth>> {
1570        let tracker = self
1571            .tracker
1572            .lock()
1573            .map_err(|error| SyncwebError::operation("provider lease tracker lock poisoned", error))?;
1574        let now = current_epoch_seconds();
1575        let mut by_hash: HashMap<Hash, usize> = HashMap::new();
1576        for (hash, provider_leases) in &tracker.leases {
1577            for lease in provider_leases.values() {
1578                if !lease.is_expired_at(now) && !tracker.is_banned(lease.provider, hash, now) {
1579                    let counter = by_hash.entry(*hash).or_default();
1580                    *counter = counter.saturating_add(1);
1581                }
1582            }
1583        }
1584        Ok(hashes
1585            .iter()
1586            .map(|hash| {
1587                let verified = by_hash.get(hash).copied().unwrap_or(0);
1588                let health = AvailabilityHealth {
1589                    verified,
1590                    local: 0,
1591                    verified_providers: Vec::new(),
1592                    local_providers: Vec::new(),
1593                };
1594                (*hash, health)
1595            })
1596            .collect())
1597    }
1598
1599    /// Return whether a blob is below the configured verified lease budget.
1600    ///
1601    /// # Errors
1602    ///
1603    /// Returns an error if the service state lock is poisoned.
1604    pub fn needs_replication(&self, hash: &Hash) -> Result<bool> {
1605        Ok(self.health(hash)?.verified < self.config.budget.min_providers)
1606    }
1607
1608    /// Return the closest active providers selected by consistent hashing.
1609    ///
1610    /// # Errors
1611    ///
1612    /// Returns an error if the service state lock is poisoned.
1613    pub fn responsible_providers(&self, hash: &Hash) -> Result<Vec<PublicKey>> {
1614        let providers = self
1615            .tracker
1616            .lock()
1617            .map_err(|error| SyncwebError::operation("provider lease tracker lock poisoned", error))?
1618            .providers(hash);
1619        Ok(self
1620            .rank_provider_candidates(*hash, &providers, current_epoch_seconds())?
1621            .into_iter()
1622            .take(self.config.budget.responsible_peers)
1623            .collect())
1624    }
1625
1626    /// Return all providers with active leases across all blobs.
1627    ///
1628    /// # Errors
1629    ///
1630    /// Returns an error if the service state lock is poisoned.
1631    pub fn list_providers(&self) -> Result<Vec<PublicKey>> {
1632        Ok(self
1633            .tracker
1634            .lock()
1635            .map_err(|error| SyncwebError::operation("provider lease tracker lock poisoned", error))?
1636            .list_providers())
1637    }
1638
1639    /// Return all blob hashes that the given provider has an active lease for.
1640    ///
1641    /// # Errors
1642    ///
1643    /// Returns an error if the service state lock is poisoned.
1644    pub fn blobs_for_provider(&self, provider: &PublicKey) -> Result<Vec<Hash>> {
1645        Ok(self
1646            .tracker
1647            .lock()
1648            .map_err(|error| SyncwebError::operation("provider lease tracker lock poisoned", error))?
1649            .blobs_for_provider(provider))
1650    }
1651
1652    /// Record a fetch failure in the shared provider tracker.
1653    ///
1654    /// # Errors
1655    ///
1656    /// Returns an error if the service state lock is poisoned.
1657    pub fn record_failure(&self, hash: Hash, provider: PublicKey, failure: FetchFailure) -> Result<()> {
1658        self.record_failure_at(hash, provider, failure, current_epoch_seconds())
1659    }
1660
1661    /// Record a fetch failure at an explicit time.
1662    ///
1663    /// # Errors
1664    ///
1665    /// Returns an error if the service state lock is poisoned.
1666    pub fn record_failure_at(&self, hash: Hash, provider: PublicKey, failure: FetchFailure, now: u64) -> Result<()> {
1667        let kind = failure.kind;
1668        self.tracker
1669            .lock()
1670            .map_err(|error| SyncwebError::operation("provider lease tracker lock poisoned", error))?
1671            .record_failure_at(hash, provider, failure, now);
1672        self.reputation
1673            .lock()
1674            .map_err(|error| SyncwebError::operation("provider reputation lock poisoned", error))?
1675            .record_failure(provider, kind, now);
1676        Ok(())
1677    }
1678
1679    /// Clear a provider's recorded failures after a successful fetch.
1680    ///
1681    /// # Errors
1682    ///
1683    /// Returns an error if the service state lock is poisoned.
1684    pub fn clear_failures_for_provider(&self, hash: &Hash, provider: &PublicKey) -> Result<()> {
1685        self.tracker
1686            .lock()
1687            .map_err(|error| SyncwebError::operation("provider lease tracker lock poisoned", error))?
1688            .clear_failures_for_provider(hash, provider);
1689        Ok(())
1690    }
1691
1692    /// Record a successful provider fetch and clear its transient failure
1693    /// history.
1694    ///
1695    /// # Errors
1696    ///
1697    /// Returns an error if provider state is poisoned.
1698    pub fn record_success(&self, hash: &Hash, provider: &PublicKey) -> Result<()> {
1699        self.clear_failures_for_provider(hash, provider)?;
1700        self.reputation
1701            .lock()
1702            .map_err(|error| SyncwebError::operation("provider reputation lock poisoned", error))?
1703            .record_success(*provider, current_epoch_seconds());
1704        Ok(())
1705    }
1706
1707    /// Return the current historical score for a provider.
1708    ///
1709    /// # Errors
1710    ///
1711    /// Returns an error if provider state is poisoned.
1712    pub fn reputation_score(&self, provider: PublicKey) -> Result<f64> {
1713        Ok(self
1714            .reputation
1715            .lock()
1716            .map_err(|error| SyncwebError::operation("provider reputation lock poisoned", error))?
1717            .score(provider, current_epoch_seconds()))
1718    }
1719
1720    /// Return the deterministic jitter assigned to one provider.
1721    #[must_use]
1722    pub fn jitter_delay(&self, hash: Hash, provider: PublicKey) -> Duration {
1723        jitter_delay(hash, provider, self.config.budget.max_jitter)
1724    }
1725
1726    /// Wait for a provider's jitter slot, cancelling when a newer lease is
1727    /// observed for the same blob.
1728    ///
1729    /// # Errors
1730    ///
1731    /// Returns an error if service state cannot be accessed.
1732    pub async fn wait_for_fetch_slot(&self, hash: Hash, provider: PublicKey) -> Result<FetchWait> {
1733        let mut generation = self.generation_receiver(hash)?;
1734        let delay = self.jitter_delay(hash, provider);
1735        tokio::select! {
1736            () = tokio::time::sleep(delay) => Ok(FetchWait::Ready),
1737            changed = generation.changed() => {
1738                changed.map_err(|error| SyncwebError::operation("provider lease cancellation channel closed", error))?;
1739                Ok(FetchWait::ShortCircuited)
1740            }
1741        }
1742    }
1743
1744    /// Fetch and pin a blob when its verified availability is below budget.
1745    ///
1746    /// Providers are tried in consistent-hash order and each fetch is delayed
1747    /// by its provider-specific jitter. A newly accepted lease short-circuits
1748    /// the pending fetch instead of creating a thundering herd.
1749    ///
1750    /// # Errors
1751    ///
1752    /// Returns an error if state cannot be read, a selected provider ticket is
1753    /// invalid, or pinning fails. Provider fetch failures are returned in
1754    /// [`ReplicationResult::failed_from`] so another provider can be tried.
1755    pub async fn ensure_replication(
1756        &self,
1757        endpoint: &Endpoint,
1758        blobs: &BlobStore,
1759        hash: Hash,
1760    ) -> Result<ReplicationResult> {
1761        let health_before = self.health(&hash)?;
1762        let mut result = ReplicationResult {
1763            hash,
1764            health_before: health_before.clone(),
1765            health_after: health_before.clone(),
1766            selected_providers: Vec::new(),
1767            fetched_from: Vec::new(),
1768            failed_from: Vec::new(),
1769            invalidated_leases: Vec::new(),
1770            pinned: false,
1771            short_circuited: false,
1772        };
1773        if health_before.verified >= self.config.budget.min_providers {
1774            return Ok(result);
1775        }
1776
1777        let now = current_epoch_seconds();
1778        let leases = self
1779            .tracker
1780            .lock()
1781            .map_err(|error| SyncwebError::operation("provider lease tracker lock poisoned", error))?
1782            .leases_at(&hash, now);
1783        let providers = leases.iter().map(|lease| lease.provider).collect::<Vec<_>>();
1784        result.selected_providers = self.rank_provider_candidates(hash, &providers, now)?;
1785        result.selected_providers.truncate(self.config.budget.responsible_peers);
1786        if result.selected_providers.is_empty() {
1787            return Ok(result);
1788        }
1789
1790        if blobs.has(hash).await? {
1791            blobs.pin(replication_pin_name(hash), hash).await?;
1792            result.pinned = true;
1793            self.observe_provider(hash, endpoint.secret_key().public())?;
1794            result.health_after = self.health(&hash)?;
1795            return Ok(result);
1796        }
1797
1798        // Attempt parallel multi-provider download for large blobs.
1799        if let Some(outcome) = self
1800            .try_parallel_download(endpoint, blobs, hash, &leases, &mut result, now)
1801            .await?
1802        {
1803            return Ok(outcome);
1804        }
1805
1806        let selected = result.selected_providers.clone();
1807        for provider in selected {
1808            if self
1809                .tracker
1810                .lock()
1811                .map_err(|error| SyncwebError::operation("provider lease tracker lock poisoned", error))?
1812                .is_banned(provider, &hash, current_epoch_seconds())
1813            {
1814                continue;
1815            }
1816            if self
1817                .reputation
1818                .lock()
1819                .map_err(|error| SyncwebError::operation("provider reputation lock poisoned", error))?
1820                .is_banned(provider, current_epoch_seconds())
1821            {
1822                continue;
1823            }
1824            match self.wait_for_fetch_slot(hash, provider).await? {
1825                FetchWait::ShortCircuited => {
1826                    result.short_circuited = true;
1827                    result.health_after = self.health(&hash)?;
1828                    return Ok(result);
1829                }
1830                FetchWait::Ready => {}
1831            }
1832            let Some(lease) = leases.iter().find(|lease| lease.provider == provider) else {
1833                continue;
1834            };
1835            let ticket = parse_ticket(&lease.ticket)?;
1836            match blobs.fetch(endpoint, &ticket).await {
1837                Ok(()) => {
1838                    result.fetched_from.push(provider);
1839                    self.reputation
1840                        .lock()
1841                        .map_err(|error| SyncwebError::operation("provider reputation lock poisoned", error))?
1842                        .record_success(provider, current_epoch_seconds());
1843                    self.clear_failures_for_provider(&hash, &provider)?;
1844                    result
1845                        .invalidated_leases
1846                        .extend(self.retroactive_invalidate(hash, provider)?);
1847                    break;
1848                }
1849                Err(error) => {
1850                    let kind = FetchFailureKind::from_syncweb_error(&error);
1851                    let failure = FetchFailure::from_syncweb_error(provider, hash, &error);
1852                    self.record_failure(hash, provider, failure)?;
1853                    result.failed_from.push((provider, kind));
1854                    if kind.is_definitive() && self.invalidate_provider(hash, provider)? {
1855                        result.invalidated_leases.push(provider);
1856                    }
1857                }
1858            }
1859        }
1860        if result.fetched_from.is_empty() {
1861            result.health_after = self.health(&hash)?;
1862            return Ok(result);
1863        }
1864
1865        blobs.pin(replication_pin_name(hash), hash).await?;
1866        result.pinned = true;
1867        self.observe_provider(hash, endpoint.secret_key().public())?;
1868        result.health_after = self.health(&hash)?;
1869        Ok(result)
1870    }
1871
1872    /// Handle a successful parallel download.
1873    async fn handle_parallel_success(
1874        &self,
1875        successful: &[PublicKey],
1876        hash: Hash,
1877        result: &mut ReplicationResult,
1878        endpoint: &Endpoint,
1879        blobs: &BlobStore,
1880    ) -> Result<()> {
1881        for provider in successful {
1882            self.reputation
1883                .lock()
1884                .map_err(|error| SyncwebError::operation("provider reputation lock poisoned", error))?
1885                .record_success(*provider, current_epoch_seconds());
1886            self.clear_failures_for_provider(&hash, provider)?;
1887            result.fetched_from.push(*provider);
1888        }
1889        result
1890            .invalidated_leases
1891            .extend(self.retroactive_invalidate(hash, endpoint.secret_key().public())?);
1892        blobs.pin(replication_pin_name(hash), hash).await?;
1893        result.pinned = true;
1894        self.observe_provider(hash, endpoint.secret_key().public())?;
1895        result.health_after = self.health(&hash)?;
1896        Ok(())
1897    }
1898
1899    /// Handle a failed parallel download (all providers failed).
1900    fn handle_parallel_all_failed(
1901        &self,
1902        candidates: &[(PublicKey, BlobTicket)],
1903        hash: Hash,
1904        result: &mut ReplicationResult,
1905        now: u64,
1906    ) -> Result<()> {
1907        for (provider, _) in candidates {
1908            let failure = FetchFailure::new(FetchFailureKind::Unknown, *provider, hash, "parallel download failed");
1909            self.record_failure_at(hash, *provider, failure, now)?;
1910            result.failed_from.push((*provider, FetchFailureKind::Unknown));
1911        }
1912        Ok(())
1913    }
1914
1915    /// Try a parallel multi-provider download for a large blob.
1916    ///
1917    /// Returns `Ok(Some(result))` if the download succeeded and the caller
1918    /// should return immediately; `Ok(None)` if the blob was too small or had
1919    /// too few providers, so the caller should fall through to sequential.
1920    async fn try_parallel_download(
1921        &self,
1922        endpoint: &Endpoint,
1923        blobs: &BlobStore,
1924        hash: Hash,
1925        leases: &[ProviderLease],
1926        result: &mut ReplicationResult,
1927        now: u64,
1928    ) -> Result<Option<ReplicationResult>> {
1929        let candidates: Vec<(PublicKey, BlobTicket)> = {
1930            let tracker = self
1931                .tracker
1932                .lock()
1933                .map_err(|error| SyncwebError::operation("provider lease tracker lock poisoned", error))?;
1934            let reputation = self
1935                .reputation
1936                .lock()
1937                .map_err(|error| SyncwebError::operation("provider reputation lock poisoned", error))?;
1938            result
1939                .selected_providers
1940                .iter()
1941                .copied()
1942                .filter(|provider| !tracker.is_banned(*provider, &hash, now) && !reputation.is_banned(*provider, now))
1943                .filter_map(|provider| {
1944                    leases
1945                        .iter()
1946                        .find(|lease| lease.provider == provider)
1947                        .and_then(|lease| parse_ticket(&lease.ticket).ok().map(|t| (provider, t)))
1948                })
1949                .collect()
1950        };
1951        if candidates.len() < 2 {
1952            return Ok(None);
1953        }
1954        match crate::indexing::parallel::try_fetch_parallel(endpoint, blobs, hash, &candidates, &self.config.parallel)
1955            .await?
1956        {
1957            crate::indexing::parallel::TryParallelResult::Downloaded(successful) => {
1958                self.handle_parallel_success(&successful, hash, result, endpoint, blobs)
1959                    .await?;
1960                Ok(Some(result.clone()))
1961            }
1962            crate::indexing::parallel::TryParallelResult::AllFailed => {
1963                self.handle_parallel_all_failed(&candidates, hash, result, now)?;
1964                Ok(None)
1965            }
1966            crate::indexing::parallel::TryParallelResult::Inapplicable => Ok(None),
1967        }
1968    }
1969
1970    fn rank_provider_candidates(&self, hash: Hash, providers: &[PublicKey], now: u64) -> Result<Vec<PublicKey>> {
1971        let mut candidates = {
1972            let reputation = self
1973                .reputation
1974                .lock()
1975                .map_err(|error| SyncwebError::operation("provider reputation lock poisoned", error))?;
1976            reputation
1977                .rank_provider_list(now, hash, providers)
1978                .into_iter()
1979                .filter(|provider| !reputation.is_banned(*provider, now))
1980                .map(|provider| (reputation.score(provider, now), provider))
1981                .collect::<Vec<_>>()
1982        };
1983        if let Some(wot) = &self.wot {
1984            let mut trusted = Vec::with_capacity(candidates.len());
1985            for (score, provider) in candidates {
1986                let decision = wot.evaluate_provider_trust(provider, Some(&hash), now)?;
1987                if decision == ProviderTrustDecision::Distrusted {
1988                    continue;
1989                }
1990                trusted.push((matches!(decision, ProviderTrustDecision::Trusted), score, provider));
1991            }
1992            trusted.sort_by(|left, right| {
1993                right
1994                    .0
1995                    .cmp(&left.0)
1996                    .then_with(|| right.1.total_cmp(&left.1))
1997                    .then_with(|| xor_distance(hash, left.2).cmp(&xor_distance(hash, right.2)))
1998                    .then_with(|| left.2.as_bytes().cmp(right.2.as_bytes()))
1999            });
2000            return Ok(trusted.into_iter().map(|(_, _, provider)| provider).collect());
2001        }
2002        candidates.sort_by(|left, right| {
2003            right
2004                .0
2005                .total_cmp(&left.0)
2006                .then_with(|| xor_distance(hash, left.1).cmp(&xor_distance(hash, right.1)))
2007                .then_with(|| left.1.as_bytes().cmp(right.1.as_bytes()))
2008        });
2009        Ok(candidates.into_iter().map(|(_, provider)| provider).collect())
2010    }
2011
2012    /// Alias for [`Self::ensure_replication`].
2013    ///
2014    /// # Errors
2015    ///
2016    /// Returns an error if the repair fetch fails.
2017    pub async fn replicate(&self, endpoint: &Endpoint, blobs: &BlobStore, hash: Hash) -> Result<ReplicationResult> {
2018        self.ensure_replication(endpoint, blobs, hash).await
2019    }
2020
2021    /// Subscribe to the provider-lease gossip topic.
2022    ///
2023    /// # Errors
2024    ///
2025    /// Returns an error if the gossip subscription cannot be created.
2026    pub async fn subscribe(&self, gossip: &GossipService, bootstrap: Vec<PublicKey>) -> Result<GossipTopic> {
2027        gossip.subscribe(resilience_topic(), bootstrap).await
2028    }
2029
2030    /// Publish a signed provider lease to gossip.
2031    ///
2032    /// # Errors
2033    ///
2034    /// Returns an error if the lease is unsigned, invalid, or cannot be sent.
2035    pub async fn announce(&self, gossip: &GossipService, sender: &GossipSender, lease: &ProviderLease) -> Result<()> {
2036        lease.verify_at(current_epoch_seconds())?;
2037        gossip.publish(sender, lease.to_bytes()?).await
2038    }
2039
2040    /// Consume provider leases until the topic closes or `timeout` expires.
2041    ///
2042    /// A timeout is a normal end condition because gossip has no finite
2043    /// response boundary.
2044    ///
2045    /// # Errors
2046    ///
2047    /// Returns an error if a gossip event or lease is malformed.
2048    pub async fn consume_gossip(&self, topic: &mut GossipTopic, timeout: Duration) -> Result<usize> {
2049        let mut tracked = 0_usize;
2050        let receive = async {
2051            while let Some(event) = topic.next().await {
2052                if let Event::Received(message) =
2053                    event.map_err(|error| SyncwebError::operation("provider lease gossip event failed", error))?
2054                {
2055                    let lease = ProviderLease::from_bytes(message.content)?;
2056                    self.record_lease(&lease)?;
2057                    tracked = tracked.saturating_add(1);
2058                }
2059            }
2060            Ok::<(), SyncwebError>(())
2061        };
2062        if let Ok(result) = tokio::time::timeout(timeout, receive).await {
2063            result?;
2064        }
2065        Ok(tracked)
2066    }
2067
2068    /// Spawn a background provider-lease gossip consumer.
2069    #[must_use]
2070    pub fn spawn_gossip_listener(&self, mut topic: GossipTopic) -> JoinHandle<Result<usize>> {
2071        let service = self.clone();
2072        tokio::spawn(async move { service.consume_gossip(&mut topic, Duration::MAX).await })
2073    }
2074
2075    fn generation_receiver(&self, hash: Hash) -> Result<watch::Receiver<u64>> {
2076        let mut generations = self
2077            .generations
2078            .lock()
2079            .map_err(|error| SyncwebError::operation("provider lease generation lock poisoned", error))?;
2080        Ok(generations
2081            .entry(hash)
2082            .or_insert_with(|| watch::channel(0_u64).0)
2083            .subscribe())
2084    }
2085
2086    fn bump_generation(&self, hash: Hash) -> Result<()> {
2087        {
2088            let mut generations = self
2089                .generations
2090                .lock()
2091                .map_err(|error| SyncwebError::operation("provider lease generation lock poisoned", error))?;
2092            let sender = generations.entry(hash).or_insert_with(|| watch::channel(0_u64).0);
2093            sender.send_modify(|generation| *generation = generation.saturating_add(1));
2094            drop(generations);
2095        }
2096        Ok(())
2097    }
2098}
2099
2100/// The deterministic gossip topic used for provider leases.
2101#[must_use]
2102pub fn resilience_topic() -> TopicId {
2103    TopicId::from_bytes(*blake3::hash(RESILIENCE_TOPIC_SEED).as_bytes())
2104}
2105
2106/// Select the `count` providers closest to a blob's hash.
2107///
2108/// The XOR distance is compared lexicographically, making selection stable
2109/// across nodes without a central coordinator.
2110#[must_use]
2111pub fn consistent_hashing_selection(hash: Hash, providers: &[PublicKey], count: usize) -> Vec<PublicKey> {
2112    if count == 0 {
2113        return Vec::new();
2114    }
2115    let mut unique = HashSet::new();
2116    let mut ranked = providers
2117        .iter()
2118        .copied()
2119        .filter(|provider| unique.insert(*provider))
2120        .map(|provider| (xor_distance(hash, provider), provider))
2121        .collect::<Vec<_>>();
2122    ranked.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.as_bytes().cmp(right.1.as_bytes())));
2123    ranked.truncate(count);
2124    ranked.into_iter().map(|(_, provider)| provider).collect()
2125}
2126
2127/// Return the XOR distance between a blob hash and a provider identity.
2128#[must_use]
2129pub fn xor_distance(hash: Hash, provider: PublicKey) -> [u8; 32] {
2130    let mut distance = [0_u8; 32];
2131    for ((byte, hash_byte), provider_byte) in distance.iter_mut().zip(hash.as_bytes()).zip(provider.as_bytes()) {
2132        *byte = *hash_byte ^ *provider_byte;
2133    }
2134    distance
2135}
2136
2137/// Return a stable pseudo-random delay in `[0, max]` for a provider.
2138///
2139/// The delay is derived from both identities rather than from wall-clock
2140/// state, so every node assigns the same slot to a provider while different
2141/// providers normally receive different slots.
2142#[must_use]
2143pub fn jitter_delay(hash: Hash, provider: PublicKey, max: Duration) -> Duration {
2144    let max_millis = u64::try_from(max.as_millis()).unwrap_or(u64::MAX);
2145    if max_millis == 0 {
2146        return Duration::ZERO;
2147    }
2148    let mut seed = Vec::with_capacity(64);
2149    seed.extend_from_slice(hash.as_bytes());
2150    seed.extend_from_slice(provider.as_bytes());
2151    let digest = blake3::hash(&seed);
2152    let mut value_bytes = [0_u8; 8];
2153    for (target, source) in value_bytes.iter_mut().zip(digest.as_bytes().iter().take(8)) {
2154        *target = *source;
2155    }
2156    let value = u64::from_le_bytes(value_bytes);
2157    let divisor = max_millis.saturating_add(1);
2158    Duration::from_millis(value.checked_rem(divisor).unwrap_or(0))
2159}
2160
2161fn replication_pin_name(hash: Hash) -> String {
2162    format!("{REPLICATION_PIN_PREFIX}{hash}")
2163}
2164
2165fn parse_ticket(ticket: &str) -> Result<BlobTicket> {
2166    BlobTicket::from_str(ticket).map_err(|error| SyncwebError::InvalidTicket(error.to_string()))
2167}
2168
2169fn current_epoch_seconds() -> u64 {
2170    SystemTime::now()
2171        .duration_since(UNIX_EPOCH)
2172        .map_or(0, |duration| duration.as_secs())
2173}
2174
2175#[cfg(test)]
2176mod tests {
2177    use std::{io::Cursor, time::Duration};
2178
2179    use anyhow::Result;
2180    use iroh::{EndpointAddr, SecretKey};
2181    use iroh_blobs::{BlobFormat, Hash};
2182
2183    use super::*;
2184
2185    fn signed_lease(seed: u8, hash: Hash, sequence: u64) -> Result<(ProviderLease, SecretKey)> {
2186        let secret_key = SecretKey::from_bytes(&[seed; 32]);
2187        let ticket = BlobTicket::new(EndpointAddr::new(secret_key.public()), hash, BlobFormat::Raw).to_string();
2188        let mut lease = ProviderLease::new_with_times(hash, ticket, sequence, 0, u64::MAX)?;
2189        lease.sign_with_secret_key(&secret_key)?;
2190        Ok((lease, secret_key))
2191    }
2192
2193    #[test]
2194    fn provider_leases_are_signed_and_monotonic() -> Result<()> {
2195        let hash = Hash::from_bytes([7_u8; 32]);
2196        let (lease, provider_key) = signed_lease(1, hash, 1)?;
2197        let encoded = lease.to_bytes()?;
2198        let decoded = ProviderLease::from_bytes(encoded)?;
2199        decoded.verify_at(10)?;
2200
2201        let mut tracker = ProviderLeaseTracker::default();
2202        anyhow::ensure!(
2203            tracker.track_at(decoded.clone(), 10)? == LeaseUpdate::Inserted,
2204            "first lease should be inserted"
2205        );
2206        anyhow::ensure!(
2207            tracker.track_at(decoded, 10)? == LeaseUpdate::IgnoredOlder,
2208            "same sequence should be ignored"
2209        );
2210        let (replacement, _) = signed_lease(1, hash, 2)?;
2211        anyhow::ensure!(
2212            tracker.track_at(replacement, 10)? == LeaseUpdate::Replaced,
2213            "newer sequence should replace the old lease"
2214        );
2215        anyhow::ensure!(tracker.verified_count(&hash) == 1, "one provider should remain tracked");
2216        tracker.observe_provider_at(hash, provider_key.public(), 10);
2217        let health = tracker.health_at(&hash, 20, Duration::from_secs(30));
2218        anyhow::ensure!(health.verified == 1, "one verified lease should be reported");
2219        anyhow::ensure!(health.local == 1, "one local observation should be reported");
2220        Ok(())
2221    }
2222
2223    #[test]
2224    fn consistent_selection_and_jitter_are_stable() -> Result<()> {
2225        let hash = Hash::from_bytes([9_u8; 32]);
2226        let (_, first_key) = signed_lease(1, hash, 1)?;
2227        let (_, second_key) = signed_lease(2, hash, 1)?;
2228        let (_, third_key) = signed_lease(3, hash, 1)?;
2229        let providers = vec![third_key.public(), first_key.public(), second_key.public()];
2230        let selected = consistent_hashing_selection(hash, &providers, 2);
2231        anyhow::ensure!(
2232            selected == consistent_hashing_selection(hash, &providers, 2),
2233            "selection should be stable"
2234        );
2235        anyhow::ensure!(selected.len() == 2, "selection should honor its limit");
2236        anyhow::ensure!(
2237            selected.iter().all(|provider| providers.contains(provider)),
2238            "selection should only contain candidates"
2239        );
2240
2241        let first_delay = jitter_delay(hash, first_key.public(), Duration::from_millis(500));
2242        let second_delay = jitter_delay(hash, second_key.public(), Duration::from_millis(500));
2243        anyhow::ensure!(
2244            first_delay <= Duration::from_millis(500),
2245            "first jitter should stay within the configured window"
2246        );
2247        anyhow::ensure!(
2248            second_delay <= Duration::from_millis(500),
2249            "second jitter should stay within the configured window"
2250        );
2251        anyhow::ensure!(
2252            first_delay != second_delay,
2253            "providers should receive different jitter slots"
2254        );
2255        Ok(())
2256    }
2257
2258    #[tokio::test]
2259    async fn a_new_lease_short_circuits_a_pending_fetch() -> Result<()> {
2260        let hash = Hash::from_bytes([11_u8; 32]);
2261        let (first_lease, first_key) = signed_lease(1, hash, 1)?;
2262        let (second_lease, _) = signed_lease(2, hash, 1)?;
2263        let budget = ReplicationBudget::new(3).with_max_jitter(Duration::from_secs(1));
2264        let service = ResilienceService::with_budget(budget);
2265        service.record_lease(&first_lease)?;
2266        let delay = service.jitter_delay(hash, first_key.public());
2267        if delay.is_zero() {
2268            return Ok(());
2269        }
2270        let waiting = service.clone();
2271        let task = tokio::spawn(async move { waiting.wait_for_fetch_slot(hash, first_key.public()).await });
2272        tokio::time::sleep(Duration::from_millis(10)).await;
2273        service.record_lease(&second_lease)?;
2274        anyhow::ensure!(
2275            task.await
2276                .map_err(|error| SyncwebError::operation("fetch wait task failed", error))??
2277                == FetchWait::ShortCircuited,
2278            "new lease should cancel the pending fetch"
2279        );
2280        Ok(())
2281    }
2282
2283    #[test]
2284    fn health_verified_vs_local_are_distinct() -> Result<()> {
2285        let hash = Hash::from_bytes([13_u8; 32]);
2286        let (lease, key) = signed_lease(1, hash, 1)?;
2287
2288        let local_only = Hash::from_bytes([14_u8; 32]);
2289        let (_, local_key) = signed_lease(2, local_only, 1)?;
2290
2291        let service = ResilienceService::with_budget(ReplicationBudget::new(1));
2292        service.record_lease(&lease)?;
2293        service.observe_provider(local_only, local_key.public())?;
2294
2295        let health = service.health(&hash)?;
2296        anyhow::ensure!(health.verified == 1, "one verified lease should be present");
2297        anyhow::ensure!(health.local == 0, "no local observations for this hash");
2298        anyhow::ensure!(
2299            health.verified_providers == vec![key.public()],
2300            "verified providers should list the lease key"
2301        );
2302
2303        let local_health = service.health(&local_only)?;
2304        anyhow::ensure!(local_health.verified == 0, "no verified leases for local-only hash");
2305        anyhow::ensure!(local_health.local == 1, "one local observation should be present");
2306        anyhow::ensure!(
2307            local_health.local_providers == vec![local_key.public()],
2308            "local providers should list the observed key"
2309        );
2310
2311        anyhow::ensure!(
2312            !service.needs_replication(&hash)?,
2313            "min_providers=1 is satisfied by one verified lease"
2314        );
2315        anyhow::ensure!(
2316            service.needs_replication(&local_only)?,
2317            "hash with no verified leases should need replication"
2318        );
2319
2320        let high_budget = ResilienceService::with_budget(ReplicationBudget::new(3));
2321        high_budget.record_lease(&lease)?;
2322        anyhow::ensure!(
2323            high_budget.needs_replication(&hash)?,
2324            "min_providers=3 is not satisfied by one verified lease"
2325        );
2326
2327        Ok(())
2328    }
2329
2330    #[test]
2331    fn purge_removes_expired_leases_and_stale_observations() -> Result<()> {
2332        let hash = Hash::from_bytes([15_u8; 32]);
2333        let sk1 = SecretKey::from_bytes(&[1; 32]);
2334        let ticket1 = BlobTicket::new(EndpointAddr::new(sk1.public()), hash, BlobFormat::Raw).to_string();
2335        let mut lease_a = ProviderLease::new_with_times(hash, ticket1, 1, 10, 50)?;
2336        lease_a.sign_with_secret_key(&sk1)?;
2337
2338        let sk2 = SecretKey::from_bytes(&[2; 32]);
2339        let ticket2 = BlobTicket::new(EndpointAddr::new(sk2.public()), hash, BlobFormat::Raw).to_string();
2340        let mut lease_b = ProviderLease::new_with_times(hash, ticket2, 1, 10, 200)?;
2341        lease_b.sign_with_secret_key(&sk2)?;
2342
2343        let mut tracker = ProviderLeaseTracker::default();
2344        tracker.track_at(lease_a, 10)?;
2345        tracker.track_at(lease_b, 10)?;
2346        tracker.observe_provider_at(hash, sk1.public(), 10);
2347        anyhow::ensure!(tracker.leases_at(&hash, 15).len() == 2, "both leases tracked");
2348
2349        tracker.purge(15, Duration::from_secs(5));
2350        anyhow::ensure!(tracker.leases_at(&hash, 15).len() == 2, "both active at t=15");
2351
2352        tracker.purge(55, Duration::from_secs(5));
2353        anyhow::ensure!(tracker.leases_at(&hash, 55).len() == 1, "lease_a expired at t=55");
2354
2355        tracker.purge(200, Duration::from_secs(5));
2356        anyhow::ensure!(tracker.leases_at(&hash, 200).is_empty(), "all expired at t=200");
2357
2358        Ok(())
2359    }
2360
2361    #[test]
2362    fn fetch_failure_kind_classification_and_definitiveness() {
2363        let not_found = SyncwebError::operation("fetch", "provider blob not found");
2364        let refused = SyncwebError::operation("fetch", "connection refused");
2365        let timeout = SyncwebError::operation("fetch", "request timed out");
2366        let corruption = SyncwebError::operation("fetch", "hash mismatch");
2367        let unknown = SyncwebError::operation("fetch", "unexpected provider response");
2368
2369        assert_eq!(
2370            FetchFailureKind::from_syncweb_error(&not_found),
2371            FetchFailureKind::NotFound
2372        );
2373        assert_eq!(
2374            FetchFailureKind::from_syncweb_error(&refused),
2375            FetchFailureKind::ConnectionRefused
2376        );
2377        assert_eq!(
2378            FetchFailureKind::from_syncweb_error(&timeout),
2379            FetchFailureKind::Timeout
2380        );
2381        assert_eq!(
2382            FetchFailureKind::from_syncweb_error(&corruption),
2383            FetchFailureKind::Corruption
2384        );
2385        assert_eq!(
2386            FetchFailureKind::from_syncweb_error(&unknown),
2387            FetchFailureKind::Unknown
2388        );
2389        assert!(FetchFailureKind::NotFound.is_definitive());
2390        assert!(FetchFailureKind::Corruption.is_definitive());
2391        assert!(FetchFailureKind::Timeout.is_transient());
2392        assert!(!FetchFailureKind::Timeout.is_definitive());
2393    }
2394
2395    #[test]
2396    fn fetch_failure_round_trip_and_timestamp() -> Result<()> {
2397        let hash = Hash::from_bytes([21_u8; 32]);
2398        let provider = SecretKey::from_bytes(&[21_u8; 32]).public();
2399        let before = current_epoch_seconds();
2400        let failure = FetchFailure::new(FetchFailureKind::Timeout, provider, hash, "timed out");
2401        let after = current_epoch_seconds();
2402
2403        anyhow::ensure!(
2404            (before..=after).contains(&failure.timestamp),
2405            "failure timestamp should use the current epoch"
2406        );
2407        anyhow::ensure!(
2408            FetchFailure::from_bytes(failure.to_bytes()?)? == failure,
2409            "fetch failures should round-trip through JSON"
2410        );
2411        Ok(())
2412    }
2413
2414    #[test]
2415    fn failure_tracking_counts_clears_and_purges() -> Result<()> {
2416        let hash = Hash::from_bytes([22_u8; 32]);
2417        let other_hash = Hash::from_bytes([23_u8; 32]);
2418        let provider = SecretKey::from_bytes(&[22_u8; 32]).public();
2419        let other_provider = SecretKey::from_bytes(&[23_u8; 32]).public();
2420        let mut tracker = ProviderLeaseTracker::with_max_failures_per_provider(2);
2421
2422        for timestamp in 1..=3 {
2423            tracker.record_failure_at(
2424                hash,
2425                provider,
2426                FetchFailure::new_at(FetchFailureKind::NotFound, provider, hash, timestamp, "missing"),
2427                timestamp,
2428            );
2429        }
2430        tracker.record_failure_at(
2431            other_hash,
2432            provider,
2433            FetchFailure::new_at(FetchFailureKind::NotFound, provider, other_hash, 4, "missing"),
2434            4,
2435        );
2436        tracker.record_failure_at(
2437            hash,
2438            other_provider,
2439            FetchFailure::new_at(FetchFailureKind::NotFound, other_provider, hash, 4, "missing"),
2440            4,
2441        );
2442
2443        let record = tracker.failure_record(&hash, &provider).expect("failure record");
2444        anyhow::ensure!(record.failures.len() == 2, "failure details should be capped");
2445        anyhow::ensure!(
2446            record.failures.first().is_some_and(|failure| failure.timestamp == 2),
2447            "oldest detail should be evicted"
2448        );
2449        anyhow::ensure!(
2450            tracker.failure_count(&hash, &provider) == 3,
2451            "aggregate count should be retained"
2452        );
2453        anyhow::ensure!(tracker.consecutive_failures(&hash, &provider) == 3);
2454        anyhow::ensure!(tracker.is_definitively_failed(&hash, &provider));
2455        anyhow::ensure!(!tracker.is_definitively_failed(&other_hash, &provider));
2456        anyhow::ensure!(!tracker.is_definitively_failed(&hash, &other_provider));
2457
2458        tracker.clear_failures_for_provider(&hash, &provider);
2459        anyhow::ensure!(tracker.failure_record(&hash, &provider).is_none());
2460        anyhow::ensure!(tracker.failure_count(&hash, &provider) == 0);
2461
2462        tracker.purge_stale_failures(10, Duration::from_secs(1));
2463        anyhow::ensure!(tracker.failure_record(&other_hash, &provider).is_none());
2464        anyhow::ensure!(tracker.failure_record(&hash, &other_provider).is_none());
2465        Ok(())
2466    }
2467
2468    #[test]
2469    fn transient_failures_do_not_become_definitive() {
2470        let hash = Hash::from_bytes([24_u8; 32]);
2471        let provider = SecretKey::from_bytes(&[24_u8; 32]).public();
2472        let mut tracker = ProviderLeaseTracker::default();
2473        for timestamp in 1..=3 {
2474            tracker.record_failure_at(
2475                hash,
2476                provider,
2477                FetchFailure::new_at(FetchFailureKind::Timeout, provider, hash, timestamp, "timeout"),
2478                timestamp,
2479            );
2480        }
2481        assert_eq!(tracker.consecutive_failures(&hash, &provider), 3);
2482        assert!(!tracker.is_definitively_failed(&hash, &provider));
2483    }
2484
2485    #[test]
2486    fn bans_invalidate_health_and_expire() -> Result<()> {
2487        let hash = Hash::from_bytes([25_u8; 32]);
2488        let (lease, provider_key) = signed_lease(25, hash, 1)?;
2489        let provider = provider_key.public();
2490        let mut tracker = ProviderLeaseTracker::default();
2491        tracker.track_at(lease, 10)?;
2492
2493        tracker.ban_provider(
2494            provider,
2495            None,
2496            "manual test ban",
2497            BanSource::Manual,
2498            Some(Duration::from_secs(10)),
2499            10,
2500        );
2501        anyhow::ensure!(tracker.is_banned(provider, &hash, 19));
2502        anyhow::ensure!(!tracker.is_banned(provider, &hash, 20));
2503        anyhow::ensure!(tracker.health_at(&hash, 19, Duration::from_secs(30)).verified == 0);
2504        anyhow::ensure!(tracker.health_at(&hash, 20, Duration::from_secs(30)).verified == 1);
2505        anyhow::ensure!(tracker.unban_provider(provider, None));
2506        anyhow::ensure!(!tracker.is_banned(provider, &hash, 20));
2507        Ok(())
2508    }
2509
2510    #[test]
2511    fn invalidation_records_scoped_automated_ban() -> Result<()> {
2512        let hash = Hash::from_bytes([26_u8; 32]);
2513        let other_hash = Hash::from_bytes([27_u8; 32]);
2514        let (lease, provider_key) = signed_lease(26, hash, 1)?;
2515        let provider = provider_key.public();
2516        let mut tracker = ProviderLeaseTracker::default();
2517        tracker.track_at(lease, 10)?;
2518
2519        anyhow::ensure!(tracker.invalidate_lease_at(hash, provider, 10));
2520        anyhow::ensure!(tracker.leases_at(&hash, 10).is_empty());
2521        anyhow::ensure!(tracker.is_banned(provider, &hash, 10));
2522        anyhow::ensure!(!tracker.is_banned(provider, &other_hash, 10));
2523        anyhow::ensure!(tracker.banned_providers(&hash, 10) == vec![provider]);
2524        Ok(())
2525    }
2526
2527    #[test]
2528    fn retroactive_invalidation_preserves_success_and_transient_failures() -> Result<()> {
2529        let hash = Hash::from_bytes([28_u8; 32]);
2530        let (failed_lease, failed_key) = signed_lease(28, hash, 1)?;
2531        let (successful_lease, successful_key) = signed_lease(29, hash, 1)?;
2532        let (transient_lease, transient_key) = signed_lease(30, hash, 1)?;
2533        let mut tracker = ProviderLeaseTracker::default();
2534        tracker.track_at(failed_lease, 10)?;
2535        tracker.track_at(successful_lease, 10)?;
2536        tracker.track_at(transient_lease, 10)?;
2537        tracker.record_failure_at(
2538            hash,
2539            failed_key.public(),
2540            FetchFailure::new_at(FetchFailureKind::NotFound, failed_key.public(), hash, 10, "missing"),
2541            10,
2542        );
2543        tracker.record_failure_at(
2544            hash,
2545            transient_key.public(),
2546            FetchFailure::new_at(FetchFailureKind::Timeout, transient_key.public(), hash, 10, "timeout"),
2547            10,
2548        );
2549
2550        let invalidated = tracker.retroactive_invalidate(hash, successful_key.public(), 10);
2551        anyhow::ensure!(invalidated == vec![failed_key.public()]);
2552        anyhow::ensure!(tracker.health_at(&hash, 10, Duration::from_secs(30)).verified == 2);
2553        anyhow::ensure!(
2554            tracker
2555                .leases_at(&hash, 10)
2556                .iter()
2557                .any(|lease| lease.provider == successful_key.public())
2558        );
2559        anyhow::ensure!(
2560            tracker
2561                .leases_at(&hash, 10)
2562                .iter()
2563                .any(|lease| lease.provider == transient_key.public())
2564        );
2565        Ok(())
2566    }
2567
2568    #[tokio::test]
2569    async fn bounded_fetch_validation_rejects_oversized_truncated_and_corrupt_streams() -> Result<()> {
2570        let data = b"bounded fetch";
2571        let hash = Hash::new(data);
2572        let expected_size = u64::try_from(data.len())?;
2573        validate_bounded_fetch(Cursor::new(data), expected_size, hash).await?;
2574        anyhow::ensure!(
2575            validate_bounded_fetch(Cursor::new(b"bounded fetch!"), expected_size, hash)
2576                .await
2577                .is_err(),
2578            "oversized streams should be rejected"
2579        );
2580        anyhow::ensure!(
2581            validate_bounded_fetch(Cursor::new(b"bounded"), expected_size, hash)
2582                .await
2583                .is_err(),
2584            "truncated streams should be rejected"
2585        );
2586        anyhow::ensure!(
2587            validate_bounded_fetch(Cursor::new(b"wrong fetch"), expected_size, hash)
2588                .await
2589                .is_err(),
2590            "hash mismatches should be rejected"
2591        );
2592        Ok(())
2593    }
2594
2595    #[test]
2596    fn list_providers_returns_all_unique_providers_with_active_leases() -> Result<()> {
2597        let mut tracker = ProviderLeaseTracker::default();
2598        let hash_a = Hash::new(b"blob-a");
2599        let hash_b = Hash::new(b"blob-b");
2600        let hash_c = Hash::new(b"blob-c");
2601
2602        let first = signed_lease(1, hash_a, 1)?;
2603        let second = signed_lease(2, hash_a, 1)?;
2604        let third = signed_lease(1, hash_b, 1)?;
2605        let fourth = signed_lease(3, hash_c, 1)?;
2606
2607        let key_first = first.1.public();
2608        let key_fourth = fourth.1.public();
2609
2610        tracker.track(first.0)?;
2611        tracker.track(second.0)?;
2612        tracker.track(third.0)?;
2613        tracker.track(fourth.0)?;
2614
2615        let providers = tracker.list_providers();
2616        anyhow::ensure!(providers.len() == 3, "three unique providers expected");
2617        anyhow::ensure!(providers.contains(&key_first), "provider 1 should be listed");
2618        anyhow::ensure!(providers.contains(&key_fourth), "provider 3 should be listed");
2619        Ok(())
2620    }
2621
2622    #[test]
2623    fn list_providers_empty_when_no_leases() {
2624        let tracker = ProviderLeaseTracker::default();
2625        assert!(tracker.list_providers().is_empty());
2626    }
2627
2628    #[test]
2629    fn blobs_for_provider_returns_all_hashes_for_given_provider() -> Result<()> {
2630        let mut tracker = ProviderLeaseTracker::default();
2631        let hash_a = Hash::new(b"blob-a");
2632        let hash_b = Hash::new(b"blob-b");
2633        let hash_c = Hash::new(b"blob-c");
2634
2635        let (lease_a, sk1) = signed_lease(1, hash_a, 1)?;
2636        let (lease_b, _sk_b) = signed_lease(1, hash_b, 2)?;
2637        let (lease_c, sk2) = signed_lease(2, hash_c, 1)?;
2638
2639        tracker.track(lease_a)?;
2640        tracker.track(lease_b)?;
2641        tracker.track(lease_c)?;
2642
2643        let prov1_blobs = tracker.blobs_for_provider(&sk1.public());
2644        anyhow::ensure!(prov1_blobs.len() == 2, "provider 1 should have 2 blobs");
2645        anyhow::ensure!(prov1_blobs.contains(&hash_a));
2646        anyhow::ensure!(prov1_blobs.contains(&hash_b));
2647
2648        let prov2_blobs = tracker.blobs_for_provider(&sk2.public());
2649        anyhow::ensure!(prov2_blobs.len() == 1, "provider 2 should have 1 blob");
2650        anyhow::ensure!(prov2_blobs.contains(&hash_c));
2651
2652        Ok(())
2653    }
2654
2655    #[test]
2656    fn blobs_for_provider_empty_when_provider_unknown() {
2657        let tracker = ProviderLeaseTracker::default();
2658        let unknown = SecretKey::from_bytes(&[99; 32]).public();
2659        assert!(tracker.blobs_for_provider(&unknown).is_empty());
2660    }
2661
2662    #[test]
2663    fn blobs_for_provider_excludes_banned_leases() -> Result<()> {
2664        let mut tracker = ProviderLeaseTracker::default();
2665        let hash = Hash::new(b"some-blob");
2666        let sk = SecretKey::from_bytes(&[1; 32]);
2667        let now = current_epoch_seconds();
2668        let far_future = now.saturating_add(86_400 * 365);
2669        let ticket_str = BlobTicket::new(EndpointAddr::new(sk.public()), hash, BlobFormat::Raw).to_string();
2670        let mut lease = ProviderLease::new_with_times(hash, &ticket_str, 1, now, far_future)?;
2671        lease.sign_with_secret_key(&sk)?;
2672        tracker.track(lease)?;
2673
2674        anyhow::ensure!(
2675            tracker.blobs_for_provider(&sk.public()).len() == 1,
2676            "lease should be active"
2677        );
2678
2679        tracker.ban_provider(sk.public(), None, "test", BanSource::Automated, None, now);
2680        anyhow::ensure!(
2681            tracker.blobs_for_provider(&sk.public()).is_empty(),
2682            "banned provider should have no blobs"
2683        );
2684        Ok(())
2685    }
2686}