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