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