1use crate::{
38 metrics::{removal_reasons, HopMetrics},
39 rate_limit::{RateLimitConfig, RateLimiter},
40 types::{
41 entry_accounted_size, promotion_backoff_blocks, signing_payload, HopBlockNumber,
42 HopEntryMeta, HopError, HopHash, PoolStatus, RecipientVec, SenderId, HOP_ACK_CONTEXT,
43 HOP_CLAIM_CONTEXT, HOP_META_VERSION, MAX_PROMOTION_ATTEMPTS,
44 },
45};
46use codec::{Decode, Encode};
47use parking_lot::{Mutex, RwLock};
48use sp_core::H256;
49use sp_crypto_hashing::blake2_256;
50use sp_runtime::{
51 traits::{IdentifyAccount, Verify},
52 MultiSignature, MultiSigner,
53};
54use std::{
55 collections::{BTreeSet, HashMap, HashSet},
56 fs,
57 path::{Path, PathBuf},
58 process,
59 sync::{
60 atomic::{AtomicU64, Ordering},
61 Arc,
62 },
63 time::{SystemTime, UNIX_EPOCH},
64};
65
66static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
70
71const BLOBS_DIR: &str = "blobs";
72const META_DIR: &str = "meta";
73const BLOB_EXT: &str = "blob";
74const META_EXT: &str = "meta";
75const SHARD_COUNT: u16 = 256;
78
79pub struct HopDataPool {
81 index: Mutex<HashMap<HopHash, HopEntryMeta>>,
83 user_usage: RwLock<HashMap<SenderId, AtomicU64>>,
91 max_size: u64,
93 max_user_size: u64,
95 current_size: AtomicU64,
97 retention_secs: u64,
99 data_dir: PathBuf,
101 rate_limiter: Arc<RateLimiter>,
103 metrics: HopMetrics,
105}
106
107impl HopDataPool {
108 pub fn new(
113 max_size: u64,
114 max_user_size: u64,
115 retention_secs: u64,
116 data_dir: PathBuf,
117 rate_limit_cfg: RateLimitConfig,
118 metrics: HopMetrics,
119 ) -> Result<Self, HopError> {
120 for i in 0..SHARD_COUNT {
122 let shard = format!("{:02x}", i as u8);
123 fs::create_dir_all(data_dir.join(BLOBS_DIR).join(&shard))?;
124 fs::create_dir_all(data_dir.join(META_DIR).join(&shard))?;
125 }
126
127 let mut index = HashMap::new();
128 let mut user_usage: HashMap<SenderId, AtomicU64> = HashMap::new();
129 let mut current_size = 0u64;
130 let mut dropped = 0u64;
133
134 for i in 0..SHARD_COUNT {
136 let shard = format!("{:02x}", i as u8);
137
138 let meta_shard_dir = data_dir.join(META_DIR).join(&shard);
140 if let Ok(entries) = fs::read_dir(&meta_shard_dir) {
141 for entry in entries.flatten() {
142 let path = entry.path();
143 if path.extension().and_then(|e| e.to_str()) != Some(META_EXT) {
144 if path
145 .file_name()
146 .and_then(|n| n.to_str())
147 .map_or(false, |n| n.contains(".tmp."))
148 {
149 let _ = fs::remove_file(&path);
150 }
151 continue;
152 }
153
154 let stem = match path.file_stem().and_then(|s| s.to_str()) {
155 Some(s) => s.to_string(),
156 None => continue,
157 };
158
159 let Some(hash) = parse_hex_hash(&stem) else {
160 tracing::warn!(target: "hop", path = ?path, "Removing .meta with invalid name");
161 let _ = fs::remove_file(&path);
162 dropped += 1;
163 continue;
164 };
165
166 let meta_bytes = match fs::read(&path) {
167 Ok(b) => b,
168 Err(e) => {
169 tracing::warn!(target: "hop", path = ?path, error = %e, "Removing unreadable .meta");
170 let _ = fs::remove_file(&path);
171 dropped += 1;
172 continue;
173 },
174 };
175 let meta = match HopEntryMeta::decode(&mut &meta_bytes[..]) {
176 Ok(m) => m,
177 Err(e) => {
178 tracing::warn!(target: "hop", path = ?path, error = %e, "Removing corrupt .meta");
179 let _ = fs::remove_file(&path);
180 dropped += 1;
181 continue;
182 },
183 };
184 if meta.version != HOP_META_VERSION {
185 tracing::warn!(
186 target: "hop",
187 path = ?path,
188 version = meta.version,
189 expected = HOP_META_VERSION,
190 "Removing .meta with unsupported on-disk version",
191 );
192 let _ = fs::remove_file(&path);
193 let _ = fs::remove_file(Self::entry_path(
194 &data_dir, &hash, BLOBS_DIR, BLOB_EXT,
195 ));
196 dropped += 1;
197 continue;
198 }
199
200 let blob_path = Self::entry_path(&data_dir, &hash, BLOBS_DIR, BLOB_EXT);
201 if !blob_path.exists() {
202 tracing::warn!(target: "hop", hash = ?stem, "Removing orphan .meta (no .blob)");
203 let _ = fs::remove_file(&path);
204 dropped += 1;
205 continue;
206 }
207
208 let accounted = entry_accounted_size(meta.size, meta.recipients.len());
209 current_size += accounted;
210 user_usage
211 .entry(meta.sender_id)
212 .or_default()
213 .fetch_add(accounted, Ordering::Relaxed);
214 index.insert(hash, meta);
215 }
216 }
217
218 let blob_shard_dir = data_dir.join(BLOBS_DIR).join(&shard);
220 if let Ok(entries) = fs::read_dir(&blob_shard_dir) {
221 for entry in entries.flatten() {
222 let path = entry.path();
223 if path.extension().and_then(|e| e.to_str()) != Some(BLOB_EXT) {
224 if path
225 .file_name()
226 .and_then(|n| n.to_str())
227 .map_or(false, |n| n.contains(".tmp."))
228 {
229 let _ = fs::remove_file(&path);
230 }
231 continue;
232 }
233 let stem = match path.file_stem().and_then(|s| s.to_str()) {
234 Some(s) => s.to_string(),
235 None => continue,
236 };
237 let is_orphan = match parse_hex_hash(&stem) {
243 Some(hash) => !index.contains_key(&hash),
244 None => true,
245 };
246 if is_orphan {
247 tracing::warn!(target: "hop", hash = ?stem, "Removing orphan .blob (no .meta)");
248 let _ = fs::remove_file(&path);
249 }
250 }
251 }
252 }
253
254 tracing::info!(
255 target: "hop",
256 entries = index.len(),
257 total_bytes = current_size,
258 dropped,
259 "Recovered HOP pool from disk"
260 );
261
262 metrics.set_pool_status(index.len() as u64, current_size, max_size);
263 metrics.record_removed(removal_reasons::STARTUP_DROPPED, dropped);
264
265 Ok(Self {
266 index: Mutex::new(index),
267 user_usage: RwLock::new(user_usage),
268 max_size,
269 max_user_size,
270 current_size: AtomicU64::new(current_size),
271 retention_secs,
272 data_dir,
273 rate_limiter: Arc::new(RateLimiter::new(rate_limit_cfg)),
274 metrics,
275 })
276 }
277
278 pub(crate) fn metrics(&self) -> &HopMetrics {
280 &self.metrics
281 }
282
283 fn publish_size_metrics(&self, entries: usize) {
288 self.metrics
289 .set_pool_size(entries as u64, self.current_size.load(Ordering::Relaxed));
290 }
291
292 fn charge_user(&self, sender_id: &SenderId, accounted: u64) -> Result<(), HopError> {
298 {
300 let usage = self.user_usage.read();
301 if let Some(counter) = usage.get(sender_id) {
302 return self.try_charge(counter, accounted);
303 }
304 }
305 let mut usage = self.user_usage.write();
307 let counter = usage.entry(*sender_id).or_default();
308 self.try_charge(counter, accounted)
309 }
310
311 fn try_charge(&self, counter: &AtomicU64, accounted: u64) -> Result<(), HopError> {
316 let previous = counter.fetch_add(accounted, Ordering::Relaxed);
317 if previous.saturating_add(accounted) > self.max_user_size {
318 counter.fetch_sub(accounted, Ordering::Relaxed);
319 return Err(HopError::UserQuotaExceeded { used: previous, limit: self.max_user_size });
320 }
321 Ok(())
322 }
323
324 fn release_user_quota(&self, sender_id: &SenderId, accounted: u64) {
328 if let Some(counter) = self.user_usage.read().get(sender_id) {
329 saturating_release(counter, accounted);
330 }
331 }
332
333 fn entry_path(data_dir: &Path, hash: &HopHash, subdir: &str, ext: &str) -> PathBuf {
335 let hex = hex::encode(hash);
336 data_dir.join(subdir).join(&hex[..2]).join(format!("{}.{}", hex, ext))
337 }
338
339 fn blob_path(&self, hash: &HopHash) -> PathBuf {
341 Self::entry_path(&self.data_dir, hash, BLOBS_DIR, BLOB_EXT)
342 }
343
344 fn meta_path(&self, hash: &HopHash) -> PathBuf {
346 Self::entry_path(&self.data_dir, hash, META_DIR, META_EXT)
347 }
348
349 fn write_atomic(path: &Path, data: &[u8]) -> Result<(), HopError> {
356 let suffix = format!("tmp.{}.{}", process::id(), TMP_SEQ.fetch_add(1, Ordering::Relaxed));
357 let tmp_path = path.with_extension(suffix);
358 if let Err(e) = fs::write(&tmp_path, data) {
359 let _ = fs::remove_file(&tmp_path);
360 return Err(e.into());
361 }
362 if let Err(e) = fs::rename(&tmp_path, path) {
363 let _ = fs::remove_file(&tmp_path);
364 return Err(e.into());
365 }
366 Ok(())
367 }
368
369 pub fn insert(
373 &self,
374 data: Vec<u8>,
375 recipients: RecipientVec,
376 sender_id: SenderId,
377 signer: MultiSigner,
378 signature: MultiSignature,
379 submit_timestamp: u64,
380 ) -> Result<HopHash, HopError> {
381 if recipients.is_empty() {
382 return Err(HopError::NoRecipients);
383 }
384 let unique: BTreeSet<&MultiSigner> = recipients.iter().map(|r| &r.signer).collect();
385 if unique.len() != recipients.len() {
386 return Err(HopError::DuplicateRecipient);
387 }
388
389 if data.is_empty() {
390 return Err(HopError::EmptyData);
391 }
392
393 let data_len = data.len() as u64;
394
395 let accounted = entry_accounted_size(data_len, recipients.len());
403
404 if let Err(retry_after_secs) = self.rate_limiter.check(&sender_id, accounted) {
406 return Err(HopError::RateLimited { retry_after_secs });
407 }
408
409 let previous_size = self.current_size.fetch_add(accounted, Ordering::Relaxed);
410 if previous_size.saturating_add(accounted) > self.max_size {
411 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
412 return Err(HopError::PoolFull(previous_size, self.max_size));
413 }
414
415 if let Err(e) = self.charge_user(&sender_id, accounted) {
416 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
417 return Err(e);
418 }
419
420 let hash = H256(blake2_256(&data));
421
422 {
424 let index = self.index.lock();
425 if index.contains_key(&hash) {
426 self.release_user_quota(&sender_id, accounted);
427 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
428 return Err(HopError::DuplicateEntry);
429 }
430 }
431
432 let blob_path = self.blob_path(&hash);
435 if let Err(e) = Self::write_atomic(&blob_path, &data) {
436 self.release_user_quota(&sender_id, accounted);
437 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
438 return Err(e);
439 }
440
441 let expires_at = SystemTime::now()
442 .duration_since(UNIX_EPOCH)
443 .unwrap_or_default()
444 .as_secs()
445 .saturating_add(self.retention_secs);
446 let meta = HopEntryMeta::new(
447 data_len,
448 expires_at,
449 recipients,
450 sender_id,
451 signer,
452 signature,
453 submit_timestamp,
454 );
455 let meta_bytes = meta.encode();
456 let meta_path = self.meta_path(&hash);
457
458 {
463 let mut index = self.index.lock();
464 if index.contains_key(&hash) {
465 tracing::debug!(
466 target: "hop",
467 hash = ?hex::encode(hash),
468 "Duplicate insert race lost; keeping winner's files"
469 );
470 drop(index);
473 self.release_user_quota(&sender_id, accounted);
474 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
475 return Err(HopError::DuplicateEntry);
476 }
477 if let Err(e) = Self::write_atomic(&meta_path, &meta_bytes) {
478 let _ = fs::remove_file(&blob_path);
481 drop(index);
482 self.release_user_quota(&sender_id, accounted);
483 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
484 return Err(e);
485 }
486 index.insert(hash, meta);
487 self.publish_size_metrics(index.len());
488 }
489 self.metrics.record_inserted_bytes(accounted);
490
491 tracing::info!(
492 target: "hop",
493 hash = ?hex::encode(hash),
494 size = data_len,
495 accounted,
496 expires_at,
497 "Data added to HOP pool"
498 );
499
500 Ok(hash)
501 }
502
503 fn read_and_verify_blob(&self, hash: &HopHash) -> Result<Vec<u8>, HopError> {
510 let blob_path = self.blob_path(hash);
511 let data = fs::read(&blob_path).map_err(|e| {
512 if e.kind() == std::io::ErrorKind::NotFound {
513 HopError::NotFound
514 } else {
515 HopError::IoError(e)
516 }
517 })?;
518 if H256(blake2_256(&data)) != *hash {
519 tracing::error!(
520 target: "hop",
521 hash = ?hex::encode(hash),
522 size = data.len(),
523 "Blob integrity check failed; purging entry"
524 );
525 self.purge_corrupt_entry(hash);
526 return Err(HopError::NotFound);
527 }
528 Ok(data)
529 }
530
531 fn purge_corrupt_entry(&self, hash: &HopHash) {
534 let removed = {
535 let mut index = self.index.lock();
536 index.remove(hash).map(|meta| {
537 let accounted = entry_accounted_size(meta.size, meta.recipients.len());
538 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
539 self.publish_size_metrics(index.len());
540 (meta.sender_id, accounted)
541 })
542 };
543 if let Some((sender_id, accounted)) = removed {
544 self.release_user_quota(&sender_id, accounted);
545 self.metrics.record_removed(removal_reasons::CORRUPT, 1);
546 }
547 let _ = fs::remove_file(self.blob_path(hash));
548 let _ = fs::remove_file(self.meta_path(hash));
549 }
550
551 fn read_or_log(&self, hash: &HopHash) -> Option<Vec<u8>> {
554 match self.read_and_verify_blob(hash) {
555 Ok(data) => Some(data),
556 Err(HopError::NotFound) => None,
557 Err(e) => {
558 tracing::error!(
559 target: "hop",
560 hash = ?hex::encode(hash),
561 error = ?e,
562 "Failed to read blob from disk"
563 );
564 None
565 },
566 }
567 }
568
569 pub fn get(&self, hash: &HopHash) -> Option<Vec<u8>> {
571 {
572 let index = self.index.lock();
573 if !index.contains_key(hash) {
574 return None;
575 }
576 }
577 self.read_or_log(hash)
578 }
579
580 pub fn get_with_auth(
586 &self,
587 hash: &HopHash,
588 ) -> Option<(Vec<u8>, MultiSigner, MultiSignature, u64)> {
589 let (signer, signature, submit_timestamp) = {
590 let index = self.index.lock();
591 let meta = index.get(hash)?;
592 (meta.signer.clone(), meta.signature.clone(), meta.submit_timestamp)
593 };
594 let data = self.read_or_log(hash)?;
595 Some((data, signer, signature, submit_timestamp))
596 }
597
598 fn find_recipient_idx(
604 meta: &HopEntryMeta,
605 hash: &HopHash,
606 signature: &[u8],
607 context: &[u8],
608 ) -> Result<usize, HopError> {
609 let multi_sig =
610 MultiSignature::decode(&mut &signature[..]).map_err(|_| HopError::InvalidSignature)?;
611 let payload = signing_payload(context, hash);
612
613 meta.recipients
614 .iter()
615 .position(|r| multi_sig.verify(&payload[..], &r.signer.clone().into_account()))
616 .ok_or(HopError::NotRecipient)
617 }
618
619 pub fn claim(&self, hash: &HopHash, signature: &[u8]) -> Result<Vec<u8>, HopError> {
627 {
628 let index = self.index.lock();
629 let meta = index.get(hash).ok_or(HopError::NotFound)?;
630 let idx = Self::find_recipient_idx(meta, hash, signature, HOP_CLAIM_CONTEXT)
633 .map_err(|_| HopError::NotFound)?;
634
635 if meta.recipients[idx].claimed {
637 return Err(HopError::AlreadyClaimed);
638 }
639 }
640 self.read_and_verify_blob(hash)
643 }
644
645 pub fn ack(&self, hash: &HopHash, signature: &[u8]) -> Result<(), HopError> {
650 {
652 let index = self.index.lock();
653 let meta = index.get(hash).ok_or(HopError::NotFound)?;
654 let idx = Self::find_recipient_idx(meta, hash, signature, HOP_ACK_CONTEXT)
655 .map_err(|_| HopError::NotFound)?;
656 if meta.recipients[idx].claimed {
657 return Ok(());
658 }
659 }
660
661 let mut index = self.index.lock();
664 let meta = index.get_mut(hash).ok_or(HopError::NotFound)?;
665 let idx = Self::find_recipient_idx(meta, hash, signature, HOP_ACK_CONTEXT)
666 .map_err(|_| HopError::NotFound)?;
667
668 if meta.recipients[idx].claimed {
669 return Ok(());
670 }
671
672 meta.recipients[idx].claimed = true;
673
674 if meta.recipients.iter().all(|r| r.claimed) {
676 let accounted = entry_accounted_size(meta.size, meta.recipients.len());
677 let sender = meta.sender_id;
678 index.remove(hash);
679 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
680 self.publish_size_metrics(index.len());
681 self.release_user_quota(&sender, accounted);
682 drop(index);
683 self.metrics.record_removed(removal_reasons::ACKED, 1);
684
685 let _ = fs::remove_file(self.blob_path(hash));
687 let _ = fs::remove_file(self.meta_path(hash));
688
689 tracing::info!(
690 target: "hop",
691 hash = ?hex::encode(hash),
692 "All recipients acked, data removed"
693 );
694 } else {
695 let claimed_count = meta.recipients.iter().filter(|r| r.claimed).count();
696 let meta_bytes = meta.encode();
698 let meta_path = self.meta_path(hash);
699 if let Err(e) = Self::write_atomic(&meta_path, &meta_bytes) {
700 tracing::error!(target: "hop", hash = ?hex::encode(hash), error = %e, "Failed to persist ack state");
701 }
702 drop(index);
703
704 tracing::debug!(
705 target: "hop",
706 hash = ?hex::encode(hash),
707 claimed = claimed_count,
708 "Recipient acked"
709 );
710 }
711
712 Ok(())
713 }
714
715 #[cfg(test)]
717 pub fn has(&self, hash: &HopHash) -> bool {
718 let index = self.index.lock();
719 index.contains_key(hash)
720 }
721
722 #[cfg(test)]
724 pub fn remove(&self, hash: &HopHash) -> Result<(), HopError> {
725 let meta = {
726 let mut index = self.index.lock();
727 index.remove(hash)
728 };
729
730 if let Some(meta) = meta {
731 let accounted = entry_accounted_size(meta.size, meta.recipients.len());
732 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
733 self.release_user_quota(&meta.sender_id, accounted);
734
735 let _ = fs::remove_file(self.blob_path(hash));
737 let _ = fs::remove_file(self.meta_path(hash));
738
739 tracing::debug!(
740 target: "hop",
741 hash = ?hex::encode(hash),
742 "Data removed from pool"
743 );
744
745 Ok(())
746 } else {
747 Err(HopError::NotFound)
748 }
749 }
750
751 pub fn status(&self) -> PoolStatus {
753 let index = self.index.lock();
754 PoolStatus {
755 entry_count: index.len(),
756 total_bytes: self.current_size.load(Ordering::Relaxed),
757 max_bytes: self.max_size,
758 }
759 }
760
761 pub fn cleanup_expired(&self, promotion_buffer_secs: u64) -> u64 {
772 const CLEANUP_BATCH_SIZE: usize = 10_000;
773 let mut total_freed: u64 = 0;
774 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
775
776 loop {
777 let expired: Vec<(HopHash, HopEntryMeta)> = {
781 let mut index = self.index.lock();
782 let expired_keys: Vec<HopHash> = index
783 .iter()
784 .filter(|(_, m)| now_secs >= m.expires_at)
785 .map(|(h, _)| *h)
786 .take(CLEANUP_BATCH_SIZE)
787 .collect();
788
789 expired_keys
790 .into_iter()
791 .filter_map(|hash| index.remove(&hash).map(|meta| (hash, meta)))
792 .collect()
793 };
794
795 if expired.is_empty() {
796 break;
797 }
798
799 let mut freed = 0u64;
802 let mut promoted = 0u64;
803 let mut unpromoted = 0u64;
804 for (_, meta) in &expired {
805 freed =
806 freed.saturating_add(entry_accounted_size(meta.size, meta.recipients.len()));
807 if meta.promoted {
808 promoted = promoted.saturating_add(1);
809 } else {
810 unpromoted = unpromoted.saturating_add(1);
811 }
812 }
813 self.current_size.fetch_sub(freed, Ordering::Relaxed);
814 total_freed = total_freed.saturating_add(freed);
815 self.metrics.record_removed(removal_reasons::EXPIRED_PROMOTED, promoted);
816 self.metrics.record_removed(removal_reasons::EXPIRED_UNPROMOTED, unpromoted);
817
818 {
819 let usage = self.user_usage.read();
820 for (_, meta) in &expired {
821 if let Some(counter) = usage.get(&meta.sender_id) {
822 let accounted = entry_accounted_size(meta.size, meta.recipients.len());
823 saturating_release(counter, accounted);
824 }
825 }
826 }
827
828 for (hash, _) in &expired {
830 let _ = fs::remove_file(self.blob_path(hash));
831 let _ = fs::remove_file(self.meta_path(hash));
832 }
833 }
834
835 let backlog = {
844 let index = self.index.lock();
845 let mut usage = self.user_usage.write();
846 let mut live: HashSet<&SenderId> = HashSet::new();
847 let mut backlog = 0u64;
848 let count_backlog = self.metrics.is_enabled();
850 for meta in index.values() {
851 live.insert(&meta.sender_id);
852 if count_backlog && Self::in_promotion_window(meta, now_secs, promotion_buffer_secs)
853 {
854 backlog = backlog.saturating_add(1);
855 }
856 }
857 usage.retain(|sender_id, counter| {
858 counter.load(Ordering::Relaxed) > 0 || live.contains(sender_id)
859 });
860 self.publish_size_metrics(index.len());
861 backlog
862 };
863
864 self.rate_limiter.evict_stale();
866
867 self.metrics.set_promotion_backlog(backlog);
868
869 total_freed
870 }
871
872 fn in_promotion_window(meta: &HopEntryMeta, now_secs: u64, buffer_secs: u64) -> bool {
875 !meta.promoted &&
876 now_secs.saturating_add(buffer_secs) >= meta.expires_at &&
877 meta.promotion_attempts < MAX_PROMOTION_ATTEMPTS
878 }
879
880 pub fn get_promotable(
884 &self,
885 current_block: HopBlockNumber,
886 buffer_secs: u64,
887 limit: usize,
888 ) -> Vec<HopHash> {
889 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
890 let index = self.index.lock();
891 index
895 .iter()
896 .filter(|(_, meta)| {
897 Self::in_promotion_window(meta, now_secs, buffer_secs) &&
898 current_block >= meta.next_promotion_attempt_at
899 })
900 .map(|(h, _)| *h)
901 .take(limit)
902 .collect()
903 }
904
905 pub fn mark_promoted(&self, hash: &HopHash) {
908 let mut index = self.index.lock();
909 if let Some(meta) = index.get_mut(hash) {
910 let newly_promoted = !meta.promoted;
912 meta.promoted = true;
913 let meta_bytes = meta.encode();
914 let meta_path = self.meta_path(hash);
915 drop(index);
916 if newly_promoted {
917 self.metrics.record_promotion_confirmed();
918 }
919
920 if let Err(e) = Self::write_atomic(&meta_path, &meta_bytes) {
921 tracing::error!(
922 target: "hop",
923 hash = ?hex::encode(hash),
924 error = %e,
925 "Failed to persist promoted state"
926 );
927 }
928 }
929 }
930
931 pub fn record_promotion_attempt(
941 &self,
942 hash: &HopHash,
943 current_block: HopBlockNumber,
944 check_interval_blocks: u32,
945 ) {
946 let mut index = self.index.lock();
947 if let Some(meta) = index.get_mut(hash) {
948 meta.promotion_attempts = meta.promotion_attempts.saturating_add(1);
949 let backoff = promotion_backoff_blocks(meta.promotion_attempts, check_interval_blocks);
950 meta.next_promotion_attempt_at = current_block.saturating_add(backoff);
951 let meta_bytes = meta.encode();
952 let meta_path = self.meta_path(hash);
953 drop(index);
954
955 if let Err(e) = Self::write_atomic(&meta_path, &meta_bytes) {
956 tracing::error!(
957 target: "hop",
958 hash = ?hex::encode(hash),
959 error = %e,
960 "Failed to persist promotion-attempt state"
961 );
962 }
963 }
964 }
965}
966
967fn parse_hex_hash(stem: &str) -> Option<HopHash> {
970 let bytes = hex::decode(stem).ok()?;
971 let arr: [u8; 32] = bytes.try_into().ok()?;
972 Some(H256(arr))
973}
974
975fn saturating_release(counter: &AtomicU64, accounted: u64) {
981 let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |previous| {
982 Some(previous - accounted.min(previous))
983 });
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989 use crate::types::{Recipient, MAX_RECIPIENTS};
990 use sp_core::{crypto::Pair, ed25519, sr25519};
991 use sp_runtime::MultiSigner;
992 use tempfile::TempDir;
993
994 const SENDER_A: SenderId = [1u8; 32];
995 const SENDER_B: SenderId = [2u8; 32];
996
997 fn acct(data_size: u64, num_recipients: usize) -> u64 {
999 entry_accounted_size(data_size, num_recipients)
1000 }
1001
1002 fn make_pool(max_size: u64, retention_secs: u64) -> (HopDataPool, TempDir) {
1003 let dir = TempDir::new().unwrap();
1004 let pool = HopDataPool::new(
1005 max_size,
1006 max_size,
1007 retention_secs,
1008 dir.path().to_path_buf(),
1009 RateLimitConfig::disabled(),
1010 HopMetrics::disabled(),
1011 )
1012 .unwrap();
1013 (pool, dir)
1014 }
1015
1016 fn make_pool_with_user_cap(
1017 max_size: u64,
1018 max_user_size: u64,
1019 retention_secs: u64,
1020 ) -> (HopDataPool, TempDir) {
1021 let dir = TempDir::new().unwrap();
1022 let pool = HopDataPool::new(
1023 max_size,
1024 max_user_size,
1025 retention_secs,
1026 dir.path().to_path_buf(),
1027 RateLimitConfig::disabled(),
1028 HopMetrics::disabled(),
1029 )
1030 .unwrap();
1031 (pool, dir)
1032 }
1033
1034 fn create_test_pool() -> (HopDataPool, TempDir) {
1035 make_pool(1024 * 1024, 100)
1036 }
1037
1038 fn test_recipient() -> (ed25519::Pair, MultiSigner) {
1039 let pair = ed25519::Pair::from_seed(&[1u8; 32]);
1040 let signer = MultiSigner::Ed25519(pair.public());
1041 (pair, signer)
1042 }
1043
1044 fn dummy_auth() -> (MultiSigner, MultiSignature) {
1048 let pair = ed25519::Pair::from_seed(&[7u8; 32]);
1049 let signer = MultiSigner::Ed25519(pair.public());
1050 let sig = MultiSignature::Ed25519(pair.sign(&[]));
1051 (signer, sig)
1052 }
1053
1054 fn sign_ed(pair: &ed25519::Pair, context: &[u8], hash: &HopHash) -> Vec<u8> {
1055 let payload = signing_payload(context, hash);
1056 MultiSignature::Ed25519(pair.sign(&payload)).encode()
1057 }
1058
1059 fn sign_sr(pair: &sr25519::Pair, context: &[u8], hash: &HopHash) -> Vec<u8> {
1060 let payload = signing_payload(context, hash);
1061 MultiSignature::Sr25519(pair.sign(&payload)).encode()
1062 }
1063
1064 fn user_usage(pool: &HopDataPool, sender: &SenderId) -> u64 {
1065 pool.user_usage
1066 .read()
1067 .get(sender)
1068 .map(|c| c.load(Ordering::Relaxed))
1069 .unwrap_or(0)
1070 }
1071
1072 fn bv(v: Vec<MultiSigner>) -> RecipientVec {
1075 let recipients: Vec<Recipient> =
1076 v.into_iter().map(|signer| Recipient { signer, claimed: false }).collect();
1077 RecipientVec::try_from(recipients).expect("test recipient list exceeds MAX_RECIPIENTS")
1078 }
1079
1080 #[test]
1081 fn metrics_track_insert_ack_and_expiry() {
1082 let registry = prometheus_endpoint::Registry::new();
1083 let dir = TempDir::new().unwrap();
1084 let pool = HopDataPool::new(
1085 1024 * 1024,
1086 1024 * 1024,
1087 0, dir.path().to_path_buf(),
1089 RateLimitConfig::disabled(),
1090 HopMetrics::new(Some(®istry)).unwrap(),
1091 )
1092 .unwrap();
1093 let (pair, signer) = test_recipient();
1094
1095 let hash = pool
1097 .insert(
1098 vec![1u8; 50],
1099 bv(vec![signer.clone()]),
1100 SENDER_A,
1101 dummy_auth().0,
1102 dummy_auth().1,
1103 0,
1104 )
1105 .unwrap();
1106 assert_eq!(pool.metrics().pool_gauges(), (1, acct(50, 1)));
1107 let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1108 pool.ack(&hash, &ack).unwrap();
1109 assert_eq!(pool.metrics().removed_count(removal_reasons::ACKED), 1);
1110 assert_eq!(pool.metrics().pool_gauges(), (0, 0));
1111
1112 pool.insert(vec![2u8; 30], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1114 .unwrap();
1115 pool.cleanup_expired(0);
1116 assert_eq!(pool.metrics().removed_count(removal_reasons::EXPIRED_UNPROMOTED), 1);
1117 assert_eq!(pool.metrics().removed_count(removal_reasons::EXPIRED_PROMOTED), 0);
1118 assert_eq!(pool.metrics().pool_gauges(), (0, 0));
1119 }
1120
1121 fn make_metered_pool(retention_secs: u64) -> (HopDataPool, TempDir) {
1123 let registry = prometheus_endpoint::Registry::new();
1124 let dir = TempDir::new().unwrap();
1125 let pool = HopDataPool::new(
1126 1024 * 1024,
1127 1024 * 1024,
1128 retention_secs,
1129 dir.path().to_path_buf(),
1130 RateLimitConfig::disabled(),
1131 HopMetrics::new(Some(®istry)).unwrap(),
1132 )
1133 .unwrap();
1134 (pool, dir)
1135 }
1136
1137 #[test]
1138 fn backlog_gauge_counts_backing_off_entries() {
1139 let (pool, _dir) = make_metered_pool(100);
1140 let (_, signer) = test_recipient();
1141 let buffer = 300_u64;
1142 let hash = pool
1143 .insert(vec![1u8; 10], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1144 .unwrap();
1145
1146 pool.cleanup_expired(0);
1148 assert_eq!(pool.metrics().promotion_backlog(), 0);
1149
1150 pool.cleanup_expired(buffer);
1151 assert_eq!(pool.metrics().promotion_backlog(), 1);
1152
1153 pool.record_promotion_attempt(&hash, 60, 10);
1155 assert!(pool.get_promotable(60, buffer, 10).is_empty());
1156 pool.cleanup_expired(buffer);
1157 assert_eq!(pool.metrics().promotion_backlog(), 1);
1158
1159 pool.mark_promoted(&hash);
1160 pool.cleanup_expired(buffer);
1161 assert_eq!(pool.metrics().promotion_backlog(), 0);
1162 }
1163
1164 #[test]
1165 fn confirmed_counter_ignores_repeated_mark_promoted() {
1166 let (pool, _dir) = make_metered_pool(100);
1167 let (_, signer) = test_recipient();
1168 let hash = pool
1169 .insert(vec![1u8; 10], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1170 .unwrap();
1171
1172 pool.mark_promoted(&hash);
1173 pool.mark_promoted(&hash);
1174 assert_eq!(pool.metrics().promotions_confirmed(), 1, "only the transition counts");
1175 }
1176
1177 #[test]
1178 fn test_insert_and_get() {
1179 let (pool, _dir) = create_test_pool();
1180 let (_, signer) = test_recipient();
1181 let data = vec![1, 2, 3, 4, 5];
1182 let hash = pool
1183 .insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1184 .unwrap();
1185
1186 let retrieved = pool.get(&hash).unwrap();
1187 assert_eq!(data, retrieved);
1188 }
1189
1190 #[test]
1191 fn test_insert_no_recipients() {
1192 let (pool, _dir) = create_test_pool();
1193 let data = vec![1, 2, 3, 4, 5];
1194 let result = pool.insert(data, bv(vec![]), SENDER_A, dummy_auth().0, dummy_auth().1, 0);
1195 assert!(matches!(result, Err(HopError::NoRecipients)));
1196 }
1197
1198 #[test]
1199 fn test_duplicate_insert() {
1200 let (pool, _dir) = create_test_pool();
1201 let (_, signer) = test_recipient();
1202 let data = vec![1, 2, 3, 4, 5];
1203
1204 pool.insert(
1205 data.clone(),
1206 bv(vec![signer.clone()]),
1207 SENDER_A,
1208 dummy_auth().0,
1209 dummy_auth().1,
1210 0,
1211 )
1212 .unwrap();
1213 let result =
1214 pool.insert(data, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0);
1215
1216 assert!(matches!(result, Err(HopError::DuplicateEntry)));
1217 }
1218
1219 #[test]
1220 fn test_too_many_recipients_rejected_at_type_level() {
1221 let recipients: Vec<Recipient> = (0..=MAX_RECIPIENTS as u64)
1225 .map(|i| {
1226 let mut seed = [0u8; 32];
1227 seed[..8].copy_from_slice(&i.to_le_bytes());
1228 Recipient {
1229 signer: MultiSigner::Ed25519(ed25519::Pair::from_seed(&seed).public()),
1230 claimed: false,
1231 }
1232 })
1233 .collect();
1234 assert_eq!(recipients.len(), MAX_RECIPIENTS as usize + 1);
1235 assert!(RecipientVec::try_from(recipients).is_err());
1236 }
1237
1238 #[test]
1239 fn test_duplicate_recipient_rejected() {
1240 let (pool, _dir) = create_test_pool();
1241 let (_, signer) = test_recipient();
1242 let result = pool.insert(
1243 vec![1, 2, 3],
1244 bv(vec![signer.clone(), signer]),
1245 SENDER_A,
1246 dummy_auth().0,
1247 dummy_auth().1,
1248 0,
1249 );
1250 assert!(matches!(result, Err(HopError::DuplicateRecipient)));
1251 }
1252
1253 #[test]
1254 fn test_pool_full() {
1255 let (pool, _dir) = make_pool(acct(60, 1), 100);
1257 let (_, signer) = test_recipient();
1258
1259 let data1 = vec![0u8; 60];
1260 let data2 = vec![1u8; 50];
1261
1262 pool.insert(data1, bv(vec![signer.clone()]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1263 .unwrap();
1264 let result =
1265 pool.insert(data2, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0);
1266
1267 assert!(matches!(result, Err(HopError::PoolFull(_, _))));
1268 }
1269
1270 #[test]
1271 fn test_remove() {
1272 let (pool, _dir) = create_test_pool();
1273 let (_, signer) = test_recipient();
1274 let data = vec![1, 2, 3, 4, 5];
1275 let hash = pool
1276 .insert(data, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1277 .unwrap();
1278
1279 assert!(pool.has(&hash));
1280 pool.remove(&hash).unwrap();
1281 assert!(!pool.has(&hash));
1282
1283 assert!(!pool.blob_path(&hash).exists());
1285 assert!(!pool.meta_path(&hash).exists());
1286 }
1287
1288 #[test]
1289 fn test_status() {
1290 let (pool, _dir) = create_test_pool();
1291 let (_, signer) = test_recipient();
1292 let data1 = vec![1, 2, 3, 4, 5];
1293 let data2 = vec![6, 7, 8];
1294
1295 pool.insert(
1296 data1.clone(),
1297 bv(vec![signer.clone()]),
1298 SENDER_A,
1299 dummy_auth().0,
1300 dummy_auth().1,
1301 0,
1302 )
1303 .unwrap();
1304 pool.insert(data2.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1305 .unwrap();
1306
1307 let status = pool.status();
1308 assert_eq!(status.entry_count, 2);
1309 assert_eq!(status.total_bytes, acct(data1.len() as u64, 1) + acct(data2.len() as u64, 1));
1310 }
1311
1312 #[test]
1313 fn test_claim_valid_signature() {
1314 let (pool, _dir) = create_test_pool();
1315 let (pair, signer) = test_recipient();
1316 let data = vec![1, 2, 3, 4, 5];
1317 let hash = pool
1318 .insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1319 .unwrap();
1320
1321 let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1322 let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1323 let result = pool.claim(&hash, &claim).unwrap();
1324 assert_eq!(data, result);
1325
1326 assert!(pool.has(&hash));
1328
1329 pool.ack(&hash, &ack).unwrap();
1330 assert!(!pool.has(&hash));
1331 }
1332
1333 #[test]
1334 fn test_claim_sig_rejected_on_ack() {
1335 let (pool, _dir) = create_test_pool();
1337 let (pair, signer) = test_recipient();
1338 let hash = pool
1339 .insert(vec![1, 2, 3], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1340 .unwrap();
1341
1342 let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1343 pool.claim(&hash, &claim).unwrap();
1344 assert!(matches!(pool.ack(&hash, &claim), Err(HopError::NotFound)));
1345 }
1346
1347 #[test]
1348 fn test_claim_invalid_signature() {
1349 let (pool, _dir) = create_test_pool();
1350 let (_, signer) = test_recipient();
1351 let data = vec![1, 2, 3, 4, 5];
1352 let hash = pool
1353 .insert(data, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1354 .unwrap();
1355
1356 let result = pool.claim(&hash, &[0u8; 3]);
1358 assert!(matches!(result, Err(HopError::NotFound)));
1359 }
1360
1361 #[test]
1362 fn test_claim_wrong_key() {
1363 let (pool, _dir) = create_test_pool();
1364 let (_, signer) = test_recipient();
1365 let hash = pool
1366 .insert(
1367 vec![1, 2, 3, 4, 5],
1368 bv(vec![signer]),
1369 SENDER_A,
1370 dummy_auth().0,
1371 dummy_auth().1,
1372 0,
1373 )
1374 .unwrap();
1375
1376 let wrong_pair = ed25519::Pair::from_seed(&[99u8; 32]);
1377 let wrong_claim = sign_ed(&wrong_pair, HOP_CLAIM_CONTEXT, &hash);
1378 assert!(matches!(pool.claim(&hash, &wrong_claim), Err(HopError::NotFound)));
1379 assert!(pool.has(&hash));
1380 }
1381
1382 #[test]
1383 fn test_claim_multi_recipient() {
1384 let (pool, _dir) = create_test_pool();
1385 let pair1 = ed25519::Pair::from_seed(&[1u8; 32]);
1386 let pair2 = ed25519::Pair::from_seed(&[2u8; 32]);
1387 let signer1 = MultiSigner::Ed25519(pair1.public());
1388 let signer2 = MultiSigner::Ed25519(pair2.public());
1389
1390 let data = vec![1, 2, 3, 4, 5];
1391 let hash = pool
1392 .insert(
1393 data.clone(),
1394 bv(vec![signer1, signer2]),
1395 SENDER_A,
1396 dummy_auth().0,
1397 dummy_auth().1,
1398 0,
1399 )
1400 .unwrap();
1401
1402 let claim1 = sign_ed(&pair1, HOP_CLAIM_CONTEXT, &hash);
1403 let ack1 = sign_ed(&pair1, HOP_ACK_CONTEXT, &hash);
1404 assert_eq!(data, pool.claim(&hash, &claim1).unwrap());
1405 pool.ack(&hash, &ack1).unwrap();
1406 assert!(pool.has(&hash));
1407
1408 let claim2 = sign_ed(&pair2, HOP_CLAIM_CONTEXT, &hash);
1409 let ack2 = sign_ed(&pair2, HOP_ACK_CONTEXT, &hash);
1410 assert_eq!(data, pool.claim(&hash, &claim2).unwrap());
1411 pool.ack(&hash, &ack2).unwrap();
1412 assert!(!pool.has(&hash));
1413 assert_eq!(pool.status().total_bytes, 0);
1414 }
1415
1416 #[test]
1417 fn test_claim_after_ack_returns_already_claimed() {
1418 let (pool, _dir) = create_test_pool();
1419 let (pair, signer) = test_recipient();
1420 let pair2 = ed25519::Pair::from_seed(&[2u8; 32]);
1421 let signer2 = MultiSigner::Ed25519(pair2.public());
1422
1423 let hash = pool
1424 .insert(
1425 vec![1, 2, 3, 4, 5],
1426 bv(vec![signer, signer2]),
1427 SENDER_A,
1428 dummy_auth().0,
1429 dummy_auth().1,
1430 0,
1431 )
1432 .unwrap();
1433
1434 let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1435 let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1436 pool.claim(&hash, &claim).unwrap();
1437 pool.ack(&hash, &ack).unwrap();
1438
1439 assert!(matches!(pool.claim(&hash, &claim), Err(HopError::AlreadyClaimed)));
1441 }
1442
1443 #[test]
1444 fn test_claim_not_found() {
1445 let (pool, _dir) = create_test_pool();
1446 let fake_hash = H256([0u8; 32]);
1447 let result = pool.claim(&fake_hash, &[0u8; 64]);
1448 assert!(matches!(result, Err(HopError::NotFound)));
1449 }
1450
1451 #[test]
1452 fn test_per_user_cap_is_hard_limit() {
1453 let (pool, _dir) = make_pool_with_user_cap(10_000, acct(60, 1), 100);
1455 let (_, signer) = test_recipient();
1456
1457 pool.insert(
1458 vec![0u8; 60],
1459 bv(vec![signer.clone()]),
1460 SENDER_A,
1461 dummy_auth().0,
1462 dummy_auth().1,
1463 0,
1464 )
1465 .unwrap();
1466
1467 let result = pool.insert(
1469 vec![1u8; 10],
1470 bv(vec![signer.clone()]),
1471 SENDER_A,
1472 dummy_auth().0,
1473 dummy_auth().1,
1474 0,
1475 );
1476 assert!(matches!(result, Err(HopError::UserQuotaExceeded { .. })));
1477
1478 pool.insert(vec![2u8; 60], bv(vec![signer]), SENDER_B, dummy_auth().0, dummy_auth().1, 0)
1480 .unwrap();
1481 }
1482
1483 #[test]
1484 fn test_quota_released_after_ack() {
1485 let (pool, _dir) = make_pool_with_user_cap(10_000, acct(100, 1), 100);
1486 let (pair, signer) = test_recipient();
1487
1488 let hash = pool
1489 .insert(
1490 vec![0u8; 100],
1491 bv(vec![signer.clone()]),
1492 SENDER_A,
1493 dummy_auth().0,
1494 dummy_auth().1,
1495 0,
1496 )
1497 .unwrap();
1498
1499 let result = pool.insert(
1501 vec![1u8; 10],
1502 bv(vec![signer.clone()]),
1503 SENDER_A,
1504 dummy_auth().0,
1505 dummy_auth().1,
1506 0,
1507 );
1508 assert!(matches!(result, Err(HopError::UserQuotaExceeded { .. })));
1509
1510 let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1511 let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1512 pool.claim(&hash, &claim).unwrap();
1513 pool.ack(&hash, &ack).unwrap();
1514
1515 pool.insert(vec![2u8; 100], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1517 .unwrap();
1518 }
1519
1520 #[test]
1521 fn test_cleanup_expired_releases_quota() {
1522 let (pool, _dir) = make_pool(10_000, 0);
1523 let (_, signer) = test_recipient();
1524
1525 pool.insert(vec![0u8; 100], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1526 .unwrap();
1527 let charged = acct(100, 1);
1528 assert_eq!(user_usage(&pool, &SENDER_A), charged);
1529
1530 let freed = pool.cleanup_expired(0);
1531 assert_eq!(freed, charged);
1532 assert_eq!(pool.status().total_bytes, 0);
1533 assert_eq!(user_usage(&pool, &SENDER_A), 0);
1534 }
1535
1536 #[test]
1537 fn test_cleanup_expired_honors_wall_clock_retention() {
1538 let (pool, _dir) = make_pool(10_000, 1);
1541 let (_, signer) = test_recipient();
1542
1543 let hash = pool
1544 .insert(vec![0u8; 100], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1545 .unwrap();
1546
1547 assert_eq!(
1549 pool.cleanup_expired(0),
1550 0,
1551 "entry should still be live before retention elapses"
1552 );
1553 assert!(pool.has(&hash));
1554
1555 std::thread::sleep(std::time::Duration::from_millis(1_200));
1556
1557 assert!(
1558 pool.cleanup_expired(0) > 0,
1559 "entry should be reaped once wall-clock retention elapses"
1560 );
1561 assert!(!pool.has(&hash));
1562 }
1563
1564 #[test]
1565 fn test_user_counter_preserved_until_cleanup() {
1566 let (pool, _dir) = create_test_pool();
1570 let (pair, signer) = test_recipient();
1571
1572 let hash = pool
1573 .insert(vec![0u8; 50], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1574 .unwrap();
1575 assert!(pool.user_usage.read().contains_key(&SENDER_A));
1576
1577 let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1578 let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1579 pool.claim(&hash, &claim).unwrap();
1580 pool.ack(&hash, &ack).unwrap();
1581
1582 assert_eq!(user_usage(&pool, &SENDER_A), 0);
1583 assert!(pool.user_usage.read().contains_key(&SENDER_A));
1584 }
1585
1586 #[test]
1587 fn test_cleanup_expired_evicts_idle_user_counters() {
1588 let (pool, _dir) = make_pool(10_000, 10);
1592 let (pair, signer) = test_recipient();
1593
1594 let hash = pool
1595 .insert(vec![0u8; 50], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1596 .unwrap();
1597 let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1598 let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1599 pool.claim(&hash, &claim).unwrap();
1600 pool.ack(&hash, &ack).unwrap();
1601 assert!(pool.user_usage.read().contains_key(&SENDER_A));
1602
1603 pool.cleanup_expired(0);
1604 assert!(!pool.user_usage.read().contains_key(&SENDER_A));
1605 }
1606
1607 #[test]
1608 fn test_cleanup_expired_keeps_active_user_counters() {
1609 let (pool, _dir) = make_pool(10_000, 100);
1613 let (_, signer) = test_recipient();
1614
1615 pool.insert(vec![0u8; 50], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1616 .unwrap();
1617 pool.cleanup_expired(0);
1621 assert!(pool.user_usage.read().contains_key(&SENDER_A));
1622 }
1623
1624 #[test]
1625 fn test_cleanup_expired_processes_more_than_one_batch() {
1626 const BATCHES: u32 = 2;
1630 const PER_BATCH: u32 = 10_000 + 1; let total = BATCHES * PER_BATCH;
1632
1633 let dir = TempDir::new().unwrap();
1634 let entry_bytes = std::mem::size_of::<u32>() as u64;
1636 let pool = HopDataPool::new(
1637 (acct(entry_bytes, 1) * total as u64) + 1024,
1638 u64::MAX,
1639 0,
1640 dir.path().to_path_buf(),
1641 RateLimitConfig::disabled(),
1642 HopMetrics::disabled(),
1643 )
1644 .unwrap();
1645 let (_, signer) = test_recipient();
1646
1647 for i in 0..total {
1648 let mut sender = SENDER_A;
1649 sender[0] = (i & 0xff) as u8;
1650 sender[1] = ((i >> 8) & 0xff) as u8;
1651 sender[2] = ((i >> 16) & 0xff) as u8;
1652 let data = i.to_le_bytes().to_vec();
1656 pool.insert(data, bv(vec![signer.clone()]), sender, dummy_auth().0, dummy_auth().1, 0)
1657 .unwrap();
1658 }
1659 assert_eq!(pool.status().entry_count, total as usize);
1660
1661 pool.cleanup_expired(0);
1662 assert_eq!(pool.status().entry_count, 0);
1663 assert_eq!(pool.status().total_bytes, 0);
1664 assert!(pool.user_usage.read().is_empty());
1665 }
1666
1667 #[test]
1668 fn test_restart_recovery() {
1669 let dir = TempDir::new().unwrap();
1670 let (_, signer) = test_recipient();
1671 let expected_accounted = acct(100, 1);
1672
1673 let hash;
1674 {
1675 let pool = HopDataPool::new(
1676 1024 * 1024,
1677 1024 * 1024,
1678 100,
1679 dir.path().to_path_buf(),
1680 RateLimitConfig::disabled(),
1681 HopMetrics::disabled(),
1682 )
1683 .unwrap();
1684 hash = pool
1685 .insert(
1686 vec![42u8; 100],
1687 bv(vec![signer]),
1688 SENDER_A,
1689 dummy_auth().0,
1690 dummy_auth().1,
1691 0,
1692 )
1693 .unwrap();
1694 assert!(pool.has(&hash));
1695 assert_eq!(pool.status().entry_count, 1);
1696 assert_eq!(pool.status().total_bytes, expected_accounted);
1697 }
1698
1699 {
1700 let pool = HopDataPool::new(
1701 1024 * 1024,
1702 1024 * 1024,
1703 100,
1704 dir.path().to_path_buf(),
1705 RateLimitConfig::disabled(),
1706 HopMetrics::disabled(),
1707 )
1708 .unwrap();
1709 assert!(pool.has(&hash));
1710 assert_eq!(pool.status().entry_count, 1);
1711 assert_eq!(pool.status().total_bytes, expected_accounted);
1712
1713 let data = pool.get(&hash).unwrap();
1714 assert_eq!(data, vec![42u8; 100]);
1715 assert_eq!(user_usage(&pool, &SENDER_A), expected_accounted);
1716 }
1717 }
1718
1719 #[test]
1720 fn test_orphan_blob_cleanup() {
1721 let dir = TempDir::new().unwrap();
1722 {
1723 let _pool = HopDataPool::new(
1724 1024 * 1024,
1725 1024 * 1024,
1726 100,
1727 dir.path().to_path_buf(),
1728 RateLimitConfig::disabled(),
1729 HopMetrics::disabled(),
1730 )
1731 .unwrap();
1732 }
1733
1734 let orphan_hash = "aa".to_string() + &"bb".repeat(15);
1735 let blob_path = dir.path().join("blobs").join("aa").join(format!("{}.blob", orphan_hash));
1736 fs::write(&blob_path, b"orphan data").unwrap();
1737 assert!(blob_path.exists());
1738
1739 let _pool = HopDataPool::new(
1740 1024 * 1024,
1741 1024 * 1024,
1742 100,
1743 dir.path().to_path_buf(),
1744 RateLimitConfig::disabled(),
1745 HopMetrics::disabled(),
1746 )
1747 .unwrap();
1748 assert!(!blob_path.exists());
1749 }
1750
1751 #[test]
1752 fn test_corrupt_meta_cleanup() {
1753 let dir = TempDir::new().unwrap();
1754 {
1755 let _pool = HopDataPool::new(
1756 1024 * 1024,
1757 1024 * 1024,
1758 100,
1759 dir.path().to_path_buf(),
1760 RateLimitConfig::disabled(),
1761 HopMetrics::disabled(),
1762 )
1763 .unwrap();
1764 }
1765
1766 let fake_hash = "bb".to_string() + &"cc".repeat(15);
1767 let meta_path = dir.path().join("meta").join("bb").join(format!("{}.meta", fake_hash));
1768 fs::write(&meta_path, b"not valid SCALE data").unwrap();
1769 assert!(meta_path.exists());
1770
1771 let pool = HopDataPool::new(
1772 1024 * 1024,
1773 1024 * 1024,
1774 100,
1775 dir.path().to_path_buf(),
1776 RateLimitConfig::disabled(),
1777 HopMetrics::disabled(),
1778 )
1779 .unwrap();
1780 assert!(!meta_path.exists());
1781 assert_eq!(pool.status().entry_count, 0);
1782 }
1783
1784 #[test]
1785 fn test_claim_sr25519() {
1786 let (pool, _dir) = create_test_pool();
1787 let pair = sr25519::Pair::from_seed(&[3u8; 32]);
1788 let signer = MultiSigner::Sr25519(pair.public());
1789
1790 let data = vec![10, 20, 30];
1791 let hash = pool
1792 .insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1793 .unwrap();
1794
1795 let claim = sign_sr(&pair, HOP_CLAIM_CONTEXT, &hash);
1796 let ack = sign_sr(&pair, HOP_ACK_CONTEXT, &hash);
1797 assert_eq!(data, pool.claim(&hash, &claim).unwrap());
1798 pool.ack(&hash, &ack).unwrap();
1799 assert!(!pool.has(&hash));
1800 }
1801
1802 #[test]
1803 fn test_claim_mixed_key_types() {
1804 let (pool, _dir) = create_test_pool();
1805 let ed_pair = ed25519::Pair::from_seed(&[4u8; 32]);
1806 let sr_pair = sr25519::Pair::from_seed(&[5u8; 32]);
1807 let ed_signer = MultiSigner::Ed25519(ed_pair.public());
1808 let sr_signer = MultiSigner::Sr25519(sr_pair.public());
1809
1810 let data = vec![42, 43, 44];
1811 let hash = pool
1812 .insert(
1813 data.clone(),
1814 bv(vec![ed_signer, sr_signer]),
1815 SENDER_A,
1816 dummy_auth().0,
1817 dummy_auth().1,
1818 0,
1819 )
1820 .unwrap();
1821
1822 let sr_claim = sign_sr(&sr_pair, HOP_CLAIM_CONTEXT, &hash);
1823 let sr_ack = sign_sr(&sr_pair, HOP_ACK_CONTEXT, &hash);
1824 assert_eq!(data, pool.claim(&hash, &sr_claim).unwrap());
1825 pool.ack(&hash, &sr_ack).unwrap();
1826 assert!(pool.has(&hash));
1827
1828 let ed_claim = sign_ed(&ed_pair, HOP_CLAIM_CONTEXT, &hash);
1829 let ed_ack = sign_ed(&ed_pair, HOP_ACK_CONTEXT, &hash);
1830 assert_eq!(data, pool.claim(&hash, &ed_claim).unwrap());
1831 pool.ack(&hash, &ed_ack).unwrap();
1832 assert!(!pool.has(&hash));
1833 }
1834
1835 #[test]
1836 fn test_claim_is_repeatable() {
1837 let (pool, _dir) = create_test_pool();
1838 let (pair, signer) = test_recipient();
1839 let data = vec![1, 2, 3, 4, 5];
1840 let hash = pool
1841 .insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1842 .unwrap();
1843
1844 let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1845 assert_eq!(data, pool.claim(&hash, &claim).unwrap());
1846 assert_eq!(data, pool.claim(&hash, &claim).unwrap());
1847 assert!(pool.has(&hash));
1848 }
1849
1850 #[test]
1851 fn test_ack_idempotent() {
1852 let (pool, _dir) = create_test_pool();
1853 let (pair, signer) = test_recipient();
1854 let pair2 = ed25519::Pair::from_seed(&[2u8; 32]);
1855 let signer2 = MultiSigner::Ed25519(pair2.public());
1856
1857 let hash = pool
1858 .insert(
1859 vec![1, 2, 3, 4, 5],
1860 bv(vec![signer, signer2]),
1861 SENDER_A,
1862 dummy_auth().0,
1863 dummy_auth().1,
1864 0,
1865 )
1866 .unwrap();
1867 let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1868
1869 pool.ack(&hash, &ack).unwrap();
1870 pool.ack(&hash, &ack).unwrap();
1871 assert!(pool.has(&hash));
1872 }
1873
1874 #[test]
1875 fn test_multi_recipient_partial_ack() {
1876 let (pool, _dir) = create_test_pool();
1877 let pair1 = ed25519::Pair::from_seed(&[1u8; 32]);
1878 let pair2 = ed25519::Pair::from_seed(&[2u8; 32]);
1879 let signer1 = MultiSigner::Ed25519(pair1.public());
1880 let signer2 = MultiSigner::Ed25519(pair2.public());
1881
1882 let data = vec![1, 2, 3, 4, 5];
1883 let hash = pool
1884 .insert(
1885 data.clone(),
1886 bv(vec![signer1, signer2]),
1887 SENDER_A,
1888 dummy_auth().0,
1889 dummy_auth().1,
1890 0,
1891 )
1892 .unwrap();
1893
1894 let claim1 = sign_ed(&pair1, HOP_CLAIM_CONTEXT, &hash);
1895 let ack1 = sign_ed(&pair1, HOP_ACK_CONTEXT, &hash);
1896 let claim2 = sign_ed(&pair2, HOP_CLAIM_CONTEXT, &hash);
1897 let ack2 = sign_ed(&pair2, HOP_ACK_CONTEXT, &hash);
1898
1899 assert_eq!(data, pool.claim(&hash, &claim1).unwrap());
1900 pool.ack(&hash, &ack1).unwrap();
1901 assert!(pool.has(&hash));
1902
1903 assert_eq!(data, pool.claim(&hash, &claim2).unwrap());
1904 pool.ack(&hash, &ack2).unwrap();
1905 assert!(!pool.has(&hash));
1906 assert_eq!(pool.status().total_bytes, 0);
1907 }
1908
1909 #[test]
1910 fn test_concurrent_inserts_respect_capacity() {
1911 use std::{sync::Barrier, thread};
1912
1913 let (_, signer) = test_recipient();
1914 let (pool, _dir) = make_pool(acct(50, 1) * 4, 100);
1916 let pool = Arc::new(pool);
1917 let barrier = Arc::new(Barrier::new(10));
1918
1919 let handles: Vec<_> = (0..10u8)
1920 .map(|i| {
1921 let pool = pool.clone();
1922 let signer = signer.clone();
1923 let barrier = barrier.clone();
1924 thread::spawn(move || {
1925 barrier.wait();
1926 pool.insert(
1927 vec![i; 50],
1928 bv(vec![signer]),
1929 SENDER_A,
1930 dummy_auth().0,
1931 dummy_auth().1,
1932 0,
1933 )
1934 })
1935 })
1936 .collect();
1937
1938 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1939 let successes = results.iter().filter(|r| r.is_ok()).count();
1940
1941 assert!(successes <= 4, "Got {} successes, max should be 4", successes);
1942 assert!(pool.status().total_bytes <= acct(50, 1) * 4);
1943 }
1944
1945 #[test]
1946 fn test_concurrent_inserts_respect_user_quota() {
1947 use std::{sync::Barrier, thread};
1948
1949 let (_, signer) = test_recipient();
1950 let per_entry = acct(100, 1);
1953 let (pool, _dir) = make_pool_with_user_cap(per_entry * 20, per_entry * 3, 100);
1954 let pool = Arc::new(pool);
1955 let barrier = Arc::new(Barrier::new(10));
1956
1957 let handles: Vec<_> = (0..10u8)
1958 .map(|i| {
1959 let pool = pool.clone();
1960 let signer = signer.clone();
1961 let barrier = barrier.clone();
1962 thread::spawn(move || {
1963 barrier.wait();
1964 pool.insert(
1965 vec![i; 100],
1966 bv(vec![signer]),
1967 SENDER_A,
1968 dummy_auth().0,
1969 dummy_auth().1,
1970 0,
1971 )
1972 })
1973 })
1974 .collect();
1975
1976 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1977 let successes = results.iter().filter(|r| r.is_ok()).count();
1978
1979 assert!(successes <= 3, "hard per-user cap violated: {} successes", successes);
1981 assert!(user_usage(&pool, &SENDER_A) <= per_entry * 3);
1982 }
1983
1984 #[test]
1985 fn test_concurrent_claim_and_ack() {
1986 use std::{sync::Barrier, thread};
1987
1988 let (pool, _dir) = create_test_pool();
1989 let pool = Arc::new(pool);
1990
1991 let pairs: Vec<_> = (1..=5u8)
1992 .map(|i| {
1993 let pair = ed25519::Pair::from_seed(&[i; 32]);
1994 let signer = MultiSigner::Ed25519(pair.public());
1995 (pair, signer)
1996 })
1997 .collect();
1998
1999 let signers: Vec<_> = pairs.iter().map(|(_, s)| s.clone()).collect();
2000 let data = vec![42u8; 100];
2001 let hash = pool
2002 .insert(data.clone(), bv(signers), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2003 .unwrap();
2004
2005 let barrier = Arc::new(Barrier::new(5));
2006
2007 let handles: Vec<_> = pairs
2008 .into_iter()
2009 .map(|(pair, _)| {
2010 let pool = pool.clone();
2011 let barrier = barrier.clone();
2012 let data = data.clone();
2013 thread::spawn(move || {
2014 barrier.wait();
2015 let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
2016 let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
2017
2018 let claimed = pool.claim(&hash, &claim).unwrap();
2019 assert_eq!(data, claimed);
2020 pool.ack(&hash, &ack).unwrap();
2021 })
2022 })
2023 .collect();
2024
2025 for h in handles {
2026 h.join().unwrap();
2027 }
2028
2029 assert!(!pool.has(&hash));
2030 assert_eq!(pool.status().total_bytes, 0);
2031 }
2032
2033 #[test]
2034 fn test_concurrent_duplicate_insert_preserves_files() {
2035 use std::{sync::Barrier, thread};
2036
2037 let (kp, signer) = test_recipient();
2041 let (pool, _dir) = make_pool(1024 * 1024, 100);
2042 let pool = Arc::new(pool);
2043 let data = vec![0xABu8; 4096];
2044 let barrier = Arc::new(Barrier::new(2));
2045
2046 let handles: Vec<_> = (0..2)
2047 .map(|_| {
2048 let pool = pool.clone();
2049 let barrier = barrier.clone();
2050 let signer = signer.clone();
2051 let data = data.clone();
2052 thread::spawn(move || {
2053 barrier.wait();
2054 pool.insert(data, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2055 })
2056 })
2057 .collect();
2058 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
2059
2060 let oks: Vec<_> = results.iter().filter_map(|r| r.as_ref().ok()).collect();
2061 let dupes = results.iter().filter(|r| matches!(r, Err(HopError::DuplicateEntry))).count();
2062 assert_eq!(oks.len(), 1, "exactly one insert must win the race");
2063 assert_eq!(dupes, 1, "the other must report DuplicateEntry");
2064
2065 let hash = *oks[0];
2066 let sig = sign_ed(&kp, HOP_CLAIM_CONTEXT, &hash);
2067 let claimed = pool.claim(&hash, &sig).expect("claim must succeed");
2068 assert_eq!(claimed, data);
2069 }
2070
2071 #[test]
2072 fn test_concurrent_duplicate_insert_keeps_winner_meta_on_disk() {
2073 use std::{sync::Barrier, thread};
2074
2075 let dir = TempDir::new().unwrap();
2079 let pool = Arc::new(
2080 HopDataPool::new(
2081 1024 * 1024,
2082 1024 * 1024,
2083 100,
2084 dir.path().to_path_buf(),
2085 RateLimitConfig::disabled(),
2086 HopMetrics::disabled(),
2087 )
2088 .unwrap(),
2089 );
2090
2091 let signer_a = MultiSigner::Ed25519(ed25519::Pair::from_seed(&[11u8; 32]).public());
2092 let signer_b = MultiSigner::Ed25519(ed25519::Pair::from_seed(&[22u8; 32]).public());
2093 let data = vec![0xCDu8; 4096];
2094
2095 let barrier = Arc::new(Barrier::new(2));
2096 let (p1, d1, b1, s1) = (pool.clone(), data.clone(), barrier.clone(), signer_a.clone());
2097 let h1 = thread::spawn(move || {
2098 b1.wait();
2099 p1.insert(d1, bv(vec![s1]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2100 });
2101 let (p2, d2, b2, s2) = (pool.clone(), data.clone(), barrier.clone(), signer_b.clone());
2102 let h2 = thread::spawn(move || {
2103 b2.wait();
2104 p2.insert(d2, bv(vec![s2]), SENDER_B, dummy_auth().0, dummy_auth().1, 0)
2105 });
2106
2107 let r1 = h1.join().unwrap();
2108 let r2 = h2.join().unwrap();
2109
2110 let (winner_hash, winner_sender) = match (&r1, &r2) {
2111 (Ok(h), Err(HopError::DuplicateEntry)) => (*h, SENDER_A),
2112 (Err(HopError::DuplicateEntry), Ok(h)) => (*h, SENDER_B),
2113 other => panic!("expected exactly one winner and one DuplicateEntry, got {other:?}"),
2114 };
2115
2116 drop(pool);
2119 let pool2 = HopDataPool::new(
2120 1024 * 1024,
2121 1024 * 1024,
2122 100,
2123 dir.path().to_path_buf(),
2124 RateLimitConfig::disabled(),
2125 HopMetrics::disabled(),
2126 )
2127 .unwrap();
2128
2129 let recovered_sender = pool2
2130 .index
2131 .lock()
2132 .get(&winner_hash)
2133 .expect("winner's entry must survive restart")
2134 .sender_id;
2135 assert_eq!(
2136 recovered_sender, winner_sender,
2137 "on-disk meta diverged from the winning insert; loser's meta overwrote the winner's",
2138 );
2139 }
2140
2141 #[test]
2142 fn test_saturating_release_concurrent_no_underflow() {
2143 use std::{sync::Barrier, thread};
2144
2145 const THREADS: u64 = 32;
2150 const RELEASE_PER_THREAD: u64 = 7;
2151 let counter = Arc::new(AtomicU64::new(THREADS * RELEASE_PER_THREAD));
2152 let barrier = Arc::new(Barrier::new(THREADS as usize));
2153
2154 let handles: Vec<_> = (0..THREADS)
2155 .map(|_| {
2156 let counter = counter.clone();
2157 let barrier = barrier.clone();
2158 thread::spawn(move || {
2159 barrier.wait();
2160 saturating_release(&counter, RELEASE_PER_THREAD);
2161 })
2162 })
2163 .collect();
2164 for h in handles {
2165 h.join().unwrap();
2166 }
2167
2168 assert_eq!(counter.load(Ordering::Relaxed), 0, "counter underflowed or did not reach zero");
2169
2170 saturating_release(&counter, u64::MAX);
2172 assert_eq!(counter.load(Ordering::Relaxed), 0);
2173 }
2174
2175 #[test]
2176 fn test_get_promotable_within_buffer() {
2177 let (pool, _dir) = make_pool(1024 * 1024, 3600);
2180 let (_, signer) = test_recipient();
2181
2182 let hash = pool
2183 .insert(vec![1, 2, 3], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2184 .unwrap();
2185
2186 let promotable = pool.get_promotable(50, 180, usize::MAX);
2188 assert!(promotable.is_empty());
2189
2190 let promotable = pool.get_promotable(0, 6000, usize::MAX);
2192 assert_eq!(promotable.len(), 1);
2193 assert_eq!(promotable[0], hash);
2194 }
2195
2196 #[test]
2197 fn test_get_promotable_excludes_promoted() {
2198 let (pool, _dir) = make_pool(1024 * 1024, 100);
2199 let (_, signer) = test_recipient();
2200
2201 let hash = pool
2202 .insert(vec![1, 2, 3], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2203 .unwrap();
2204
2205 let promotable = pool.get_promotable(80, 180, usize::MAX);
2206 assert_eq!(promotable.len(), 1);
2207
2208 pool.mark_promoted(&hash);
2209
2210 let promotable = pool.get_promotable(80, 180, usize::MAX);
2211 assert!(promotable.is_empty());
2212 }
2213
2214 #[test]
2215 fn test_mark_promoted_persists_across_restart() {
2216 let dir = TempDir::new().unwrap();
2217 let (_, signer) = test_recipient();
2218
2219 let hash;
2220 {
2221 let pool = HopDataPool::new(
2222 1024 * 1024,
2223 1024 * 1024,
2224 100,
2225 dir.path().to_path_buf(),
2226 RateLimitConfig::disabled(),
2227 HopMetrics::disabled(),
2228 )
2229 .unwrap();
2230 hash = pool
2231 .insert(
2232 vec![42u8; 10],
2233 bv(vec![signer]),
2234 SENDER_A,
2235 dummy_auth().0,
2236 dummy_auth().1,
2237 0,
2238 )
2239 .unwrap();
2240 pool.mark_promoted(&hash);
2241 }
2242
2243 {
2244 let pool = HopDataPool::new(
2245 1024 * 1024,
2246 1024 * 1024,
2247 100,
2248 dir.path().to_path_buf(),
2249 RateLimitConfig::disabled(),
2250 HopMetrics::disabled(),
2251 )
2252 .unwrap();
2253 let promotable = pool.get_promotable(80, 180, usize::MAX);
2254 assert!(promotable.is_empty(), "promoted entry should not be promotable after restart");
2255 assert!(pool.has(&hash), "entry should still exist");
2256 }
2257 }
2258
2259 #[test]
2260 fn test_cleanup_expired_removes_promoted() {
2261 let (pool, _dir) = make_pool(1024 * 1024, 0);
2262 let (_, signer) = test_recipient();
2263
2264 let hash = pool
2265 .insert(vec![1, 2, 3], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2266 .unwrap();
2267 pool.mark_promoted(&hash);
2268 assert!(pool.has(&hash));
2269
2270 let freed = pool.cleanup_expired(0);
2271 assert!(freed > 0);
2272 assert!(!pool.has(&hash));
2273 }
2274
2275 #[test]
2276 fn test_rate_limit_rejects_burst_overflow() {
2277 let dir = TempDir::new().unwrap();
2278 let cfg = RateLimitConfig {
2282 enabled: true,
2283 submit_rate_per_min: 60,
2284 submit_burst: 2,
2285 bandwidth_per_min: 1024 * 1024 * 60,
2286 bandwidth_burst: 1024 * 1024,
2287 };
2288 let pool = HopDataPool::new(
2289 1024 * 1024,
2290 1024 * 1024,
2291 100,
2292 dir.path().to_path_buf(),
2293 cfg,
2294 HopMetrics::disabled(),
2295 )
2296 .unwrap();
2297 let (_, signer) = test_recipient();
2298
2299 pool.insert(
2300 vec![1, 2, 3],
2301 bv(vec![signer.clone()]),
2302 SENDER_A,
2303 dummy_auth().0,
2304 dummy_auth().1,
2305 0,
2306 )
2307 .unwrap();
2308 pool.insert(
2309 vec![4, 5, 6],
2310 bv(vec![signer.clone()]),
2311 SENDER_A,
2312 dummy_auth().0,
2313 dummy_auth().1,
2314 0,
2315 )
2316 .unwrap();
2317 assert!(matches!(
2318 pool.insert(
2319 vec![7, 8, 9],
2320 bv(vec![signer]),
2321 SENDER_A,
2322 dummy_auth().0,
2323 dummy_auth().1,
2324 0,
2325 ),
2326 Err(HopError::RateLimited { .. })
2327 ));
2328 }
2329
2330 #[test]
2331 fn test_meta_version_mismatch_rejected() {
2332 let dir = TempDir::new().unwrap();
2336 let (_, signer) = test_recipient();
2337 let recipients = bv(vec![signer.clone()]);
2338 let mut meta =
2339 HopEntryMeta::new(100, 0, recipients, SENDER_A, dummy_auth().0, dummy_auth().1, 0);
2340 meta.version = 0;
2341
2342 let fake_hash = "ee".to_string() + &"ff".repeat(15);
2343 let meta_dir = dir.path().join("meta").join("ee");
2344 let blob_dir = dir.path().join("blobs").join("ee");
2345 fs::create_dir_all(&meta_dir).unwrap();
2346 fs::create_dir_all(&blob_dir).unwrap();
2347 let meta_path = meta_dir.join(format!("{}.meta", fake_hash));
2348 let blob_path = blob_dir.join(format!("{}.blob", fake_hash));
2349 fs::write(&meta_path, meta.encode()).unwrap();
2350 fs::write(&blob_path, b"x").unwrap();
2351
2352 let registry = prometheus_endpoint::Registry::new();
2353 let pool = HopDataPool::new(
2354 1024 * 1024,
2355 1024 * 1024,
2356 100,
2357 dir.path().to_path_buf(),
2358 RateLimitConfig::disabled(),
2359 HopMetrics::new(Some(®istry)).unwrap(),
2360 )
2361 .unwrap();
2362 assert!(!meta_path.exists(), "stale-version .meta should be removed");
2363 assert!(!blob_path.exists(), "matching .blob should also be removed");
2364 assert_eq!(pool.status().entry_count, 0);
2365 assert_eq!(pool.metrics().removed_count(removal_reasons::STARTUP_DROPPED), 1);
2366 }
2367
2368 #[test]
2369 fn test_promotion_backoff_skips_until_due_then_gives_up() {
2370 use crate::types::MAX_PROMOTION_ATTEMPTS;
2371
2372 let (pool, _dir) = make_pool(1024 * 1024, 100);
2373 let (_, signer) = test_recipient();
2374 let hash = pool
2375 .insert(vec![1u8; 100], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2376 .unwrap();
2377
2378 let buffer = 300_u64;
2381 let current = 60;
2382 assert_eq!(pool.get_promotable(current, buffer, 10), vec![hash]);
2383
2384 let check_interval_blocks: u32 = 10;
2386 pool.record_promotion_attempt(&hash, current, check_interval_blocks);
2387 assert!(
2388 pool.get_promotable(current, buffer, 10).is_empty(),
2389 "entry should be skipped until back-off elapses"
2390 );
2391 assert_eq!(pool.get_promotable(current + 10, buffer, 10), vec![hash]);
2392
2393 let mut now = current + 10;
2397 for next_attempt in 2..=MAX_PROMOTION_ATTEMPTS {
2398 pool.record_promotion_attempt(&hash, now, check_interval_blocks);
2399 let shift = (next_attempt - 1).min(5);
2400 let backoff = check_interval_blocks << shift;
2401 now += backoff;
2402 }
2403 assert!(
2404 pool.get_promotable(now + 10_000, buffer, 10).is_empty(),
2405 "entry should give up after MAX_PROMOTION_ATTEMPTS"
2406 );
2407 }
2408}