1use crate::{
29 metrics::{removal_reasons, HopMetrics},
30 rate_limit::{RateLimitConfig, RateLimiter},
31 types::{
32 entry_accounted_size, promotion_backoff_blocks, signing_payload, HopBlockNumber,
33 HopEntryMeta, HopError, HopHash, PoolStatus, RecipientVec, SenderId, HOP_ACK_CONTEXT,
34 HOP_CLAIM_CONTEXT, HOP_META_VERSION, MAX_PROMOTION_ATTEMPTS,
35 },
36};
37use codec::{Decode, Encode};
38use parking_lot::{Mutex, RwLock};
39use sp_core::H256;
40use sp_crypto_hashing::blake2_256;
41use sp_runtime::{
42 traits::{IdentifyAccount, Verify},
43 MultiSignature, MultiSigner,
44};
45use std::{
46 collections::{BTreeSet, HashMap},
47 fs,
48 ops::Bound,
49 path::{Path, PathBuf},
50 process,
51 sync::{
52 atomic::{AtomicU64, Ordering},
53 Arc,
54 },
55 time::{SystemTime, UNIX_EPOCH},
56};
57
58static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
61
62const BLOBS_DIR: &str = "blobs";
63const BLOB_EXT: &str = "blob";
64const META_DB_DIR: &str = "meta-db";
66const SHARD_COUNT: u16 = 256;
70
71const LEGACY_META_DIR: &str = "meta";
75const LEGACY_META_EXT: &str = "meta";
76
77const COL_META: u8 = 0;
79const COL_DB_META: u8 = 1;
83const COL_COUNT: u8 = 2;
85
86const KEY_DB_VERSION: &[u8] = b"version";
88const CURRENT_DB_VERSION: u32 = 1;
90const MIGRATION_CHUNK: usize = 10_000;
93
94pub struct HopDataPool {
96 db: Arc<parity_db::Db>,
98 rmw_lock: Mutex<()>,
100 user_usage: RwLock<HashMap<SenderId, AtomicU64>>,
102 expiry_index: RwLock<BTreeSet<(u64, HopHash)>>,
108 max_size: u64,
110 max_user_size: u64,
112 current_size: AtomicU64,
114 entry_count: AtomicU64,
116 retention_secs: u64,
118 data_dir: PathBuf,
120 rate_limiter: Arc<RateLimiter>,
122 metrics: HopMetrics,
124}
125
126impl HopDataPool {
127 pub fn new(
130 max_size: u64,
131 max_user_size: u64,
132 retention_secs: u64,
133 data_dir: PathBuf,
134 rate_limit_cfg: RateLimitConfig,
135 metrics: HopMetrics,
136 ) -> Result<Self, HopError> {
137 for i in 0..SHARD_COUNT {
139 let shard = format!("{:02x}", i as u8);
140 fs::create_dir_all(data_dir.join(BLOBS_DIR).join(&shard))?;
141 }
142
143 let db_path = data_dir.join(META_DB_DIR);
144 fs::create_dir_all(&db_path)?;
145 Self::migrate_columns(&db_path)?;
148 let db = parity_db::Db::open_or_create(&Self::db_options(&db_path))
149 .map_err(|e| HopError::Db(e.to_string()))?;
150 Self::check_db_version(&db)?;
151
152 Self::import_legacy_meta_files(&db, &data_dir)?;
155
156 let mut user_usage: HashMap<SenderId, AtomicU64> = HashMap::new();
158 let mut current_size: u64 = 0;
159 let mut entry_count: u64 = 0;
160 let mut live_hashes: std::collections::HashSet<HopHash> = std::collections::HashSet::new();
161 let mut expiry_index: BTreeSet<(u64, HopHash)> = BTreeSet::new();
162 let mut stale_keys: Vec<Vec<u8>> = Vec::new();
163
164 {
165 let mut iter = db.iter(COL_META).map_err(|e| HopError::Db(e.to_string()))?;
166 while let Some((key, value)) = iter.next().map_err(|e| HopError::Db(e.to_string()))? {
167 let hash = match <[u8; 32]>::try_from(key.as_slice()) {
168 Ok(arr) => H256(arr),
169 Err(_) => {
170 tracing::warn!(target: "hop", key_len = key.len(), "Dropping meta row with non-32-byte key");
171 stale_keys.push(key);
172 continue;
173 },
174 };
175 match HopEntryMeta::decode(&mut value.as_slice()) {
176 Ok(meta) if meta.version == HOP_META_VERSION => {
177 if !Self::entry_path(&data_dir, &hash, BLOBS_DIR, BLOB_EXT).exists() {
183 tracing::warn!(target: "hop", hash = ?hex::encode(hash), "Dropping meta row with missing blob");
184 stale_keys.push(key);
185 continue;
186 }
187 let accounted = entry_accounted_size(meta.size, meta.recipients.len());
188 current_size = current_size.saturating_add(accounted);
189 entry_count = entry_count.saturating_add(1);
190 user_usage
191 .entry(meta.sender_id)
192 .or_default()
193 .fetch_add(accounted, Ordering::Relaxed);
194 live_hashes.insert(hash);
195 expiry_index.insert((meta.expires_at, hash));
196 },
197 Ok(meta) => {
198 tracing::warn!(
199 target: "hop",
200 version = meta.version,
201 expected = HOP_META_VERSION,
202 hash = ?hex::encode(hash),
203 "Dropping meta row with unsupported version",
204 );
205 stale_keys.push(key);
206 },
207 Err(e) => {
208 tracing::warn!(target: "hop", hash = ?hex::encode(hash), error = %e, "Dropping undecodable meta row");
209 stale_keys.push(key);
210 },
211 }
212 }
213 }
214
215 if !stale_keys.is_empty() {
217 let ops: Vec<_> =
218 stale_keys.iter().cloned().map(|k| (COL_META, k, None::<Vec<u8>>)).collect();
219 db.commit(ops).map_err(|e| HopError::Db(e.to_string()))?;
220 for k in &stale_keys {
221 if let Ok(arr) = <[u8; 32]>::try_from(k.as_slice()) {
222 let _ = fs::remove_file(Self::entry_path(
223 &data_dir,
224 &H256(arr),
225 BLOBS_DIR,
226 BLOB_EXT,
227 ));
228 }
229 }
230 }
231
232 for i in 0..SHARD_COUNT {
239 let shard = format!("{:02x}", i as u8);
240 let blob_shard_dir = data_dir.join(BLOBS_DIR).join(&shard);
241 let Ok(entries) = fs::read_dir(&blob_shard_dir) else { continue };
242 for entry in entries.flatten() {
243 let path = entry.path();
244 if path.extension().and_then(|e| e.to_str()) != Some(BLOB_EXT) {
245 if path
246 .file_name()
247 .and_then(|n| n.to_str())
248 .map_or(false, |n| n.contains(".tmp."))
249 {
250 let _ = fs::remove_file(&path);
251 }
252 continue;
253 }
254 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { continue };
255 let is_orphan = match parse_hex_hash(stem) {
256 Some(hash) => !live_hashes.contains(&hash),
257 None => true,
258 };
259 if is_orphan {
260 tracing::warn!(target: "hop", hash = ?stem, "Removing orphan .blob (no meta row)");
261 let _ = fs::remove_file(&path);
262 }
263 }
264 }
265
266 tracing::info!(
267 target: "hop",
268 entries = entry_count,
269 total_bytes = current_size,
270 "Recovered HOP pool from disk"
271 );
272
273 metrics.set_pool_status(entry_count, current_size, max_size);
277 metrics.record_removed(removal_reasons::STARTUP_DROPPED, stale_keys.len() as u64);
278
279 Ok(Self {
280 db: Arc::new(db),
281 rmw_lock: Mutex::new(()),
282 user_usage: RwLock::new(user_usage),
283 expiry_index: RwLock::new(expiry_index),
284 max_size,
285 max_user_size,
286 current_size: AtomicU64::new(current_size),
287 entry_count: AtomicU64::new(entry_count),
288 retention_secs,
289 data_dir,
290 rate_limiter: Arc::new(RateLimiter::new(rate_limit_cfg)),
291 metrics,
292 })
293 }
294
295 pub(crate) fn metrics(&self) -> &HopMetrics {
297 &self.metrics
298 }
299
300 fn publish_size_metrics(&self) {
305 self.metrics.set_pool_size(
306 self.entry_count.load(Ordering::Relaxed),
307 self.current_size.load(Ordering::Relaxed),
308 );
309 }
310
311 fn db_options(db_path: &Path) -> parity_db::Options {
317 let mut options = parity_db::Options::with_columns(db_path, COL_COUNT);
318 options.columns[COL_META as usize].btree_index = true;
319 options
320 }
321
322 fn migrate_columns(db_path: &Path) -> Result<(), HopError> {
330 let Some(metadata) =
331 parity_db::Options::load_metadata(db_path).map_err(|e| HopError::Db(e.to_string()))?
332 else {
333 return Ok(());
334 };
335 if metadata.columns.len() >= COL_COUNT as usize {
336 return Ok(());
337 }
338 let desired = Self::db_options(db_path).columns;
339 let mut migrate_options = parity_db::Options::with_columns(db_path, 0);
340 migrate_options.columns = metadata.columns;
341 tracing::info!(
342 target: "hop",
343 from = migrate_options.columns.len(),
344 to = COL_COUNT,
345 "Extending HOP database column layout",
346 );
347 for column in desired.into_iter().skip(migrate_options.columns.len()) {
351 parity_db::Db::add_column(&mut migrate_options, column)
352 .map_err(|e| HopError::Db(e.to_string()))?;
353 }
354 Ok(())
355 }
356
357 fn check_db_version(db: &parity_db::Db) -> Result<(), HopError> {
362 match db.get(COL_DB_META, KEY_DB_VERSION).map_err(|e| HopError::Db(e.to_string()))? {
363 Some(bytes) => {
364 let version = u32::from_le_bytes(
365 bytes
366 .try_into()
367 .map_err(|_| HopError::Db("Malformed HOP database version".into()))?,
368 );
369 if version > CURRENT_DB_VERSION {
370 return Err(HopError::Db(format!(
371 "Unsupported HOP database version {version}; this binary supports up to \
372 {CURRENT_DB_VERSION}"
373 )));
374 }
375 Ok(())
379 },
380 None => db
381 .commit([(
382 COL_DB_META,
383 KEY_DB_VERSION.to_vec(),
384 Some(CURRENT_DB_VERSION.to_le_bytes().to_vec()),
385 )])
386 .map_err(|e| HopError::Db(e.to_string())),
387 }
388 }
389
390 fn import_legacy_meta_files(db: &parity_db::Db, data_dir: &Path) -> Result<(), HopError> {
404 let legacy_dir = data_dir.join(LEGACY_META_DIR);
405 if !legacy_dir.exists() {
406 return Ok(());
407 }
408
409 let mut ops: Vec<(u8, Vec<u8>, Option<Vec<u8>>)> = Vec::new();
410 let mut imported: u64 = 0;
411 let mut skipped: u64 = 0;
412
413 for i in 0..SHARD_COUNT {
414 let shard = format!("{:02x}", i as u8);
415 let Ok(entries) = fs::read_dir(legacy_dir.join(&shard)) else { continue };
416 for entry in entries.flatten() {
417 let path = entry.path();
418 if path.extension().and_then(|e| e.to_str()) != Some(LEGACY_META_EXT) {
421 continue;
422 }
423 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { continue };
424 let Some(hash) = parse_hex_hash(stem) else {
425 tracing::warn!(target: "hop", path = ?path, "Skipping legacy .meta with invalid name");
426 skipped += 1;
427 continue;
428 };
429 let bytes = match fs::read(&path) {
430 Ok(b) => b,
431 Err(e) => {
432 tracing::warn!(target: "hop", path = ?path, error = %e, "Skipping unreadable legacy .meta");
433 skipped += 1;
434 continue;
435 },
436 };
437 let meta = match HopEntryMeta::decode(&mut bytes.as_slice()) {
438 Ok(meta) => meta,
439 Err(e) => {
440 tracing::warn!(target: "hop", path = ?path, error = %e, "Skipping corrupt legacy .meta");
441 skipped += 1;
442 continue;
443 },
444 };
445 if meta.version != HOP_META_VERSION {
446 tracing::warn!(
447 target: "hop",
448 path = ?path,
449 version = meta.version,
450 expected = HOP_META_VERSION,
451 "Skipping legacy .meta with unsupported version",
452 );
453 skipped += 1;
454 continue;
455 }
456 if !Self::entry_path(data_dir, &hash, BLOBS_DIR, BLOB_EXT).exists() {
457 tracing::warn!(target: "hop", hash = ?stem, "Skipping legacy .meta with no .blob");
458 skipped += 1;
459 continue;
460 }
461 if db
463 .get(COL_META, hash.as_bytes())
464 .map_err(|e| HopError::Db(e.to_string()))?
465 .is_some()
466 {
467 skipped += 1;
468 continue;
469 }
470
471 ops.push((COL_META, hash.as_bytes().to_vec(), Some(bytes)));
472 imported += 1;
473 if ops.len() >= MIGRATION_CHUNK {
474 db.commit(ops.drain(..)).map_err(|e| HopError::Db(e.to_string()))?;
475 }
476 }
477 }
478
479 if !ops.is_empty() {
480 db.commit(ops).map_err(|e| HopError::Db(e.to_string()))?;
481 }
482
483 if let Err(e) = fs::remove_dir_all(&legacy_dir) {
486 tracing::warn!(
487 target: "hop",
488 path = ?legacy_dir,
489 error = %e,
490 "Failed to remove legacy meta directory after import",
491 );
492 }
493
494 if imported > 0 || skipped > 0 {
498 tracing::info!(
499 target: "hop",
500 imported,
501 skipped,
502 "Imported legacy HOP metadata into the KV store",
503 );
504 }
505 Ok(())
506 }
507
508 fn fetch_meta(&self, hash: &HopHash) -> Result<Option<HopEntryMeta>, HopError> {
511 match self
512 .db
513 .get(COL_META, hash.as_bytes())
514 .map_err(|e| HopError::Db(e.to_string()))?
515 {
516 Some(bytes) => HopEntryMeta::decode(&mut bytes.as_slice())
517 .map(Some)
518 .map_err(|e| HopError::Db(format!("decoding meta for {}: {e}", hex::encode(hash)))),
519 None => Ok(None),
520 }
521 }
522
523 fn commit_meta(&self, hash: &HopHash, value: Option<Vec<u8>>) -> Result<(), HopError> {
525 self.db
526 .commit([(COL_META, hash.as_bytes().to_vec(), value)])
527 .map_err(|e| HopError::Db(e.to_string()))
528 }
529
530 fn charge_user(&self, sender_id: &SenderId, accounted: u64) -> Result<(), HopError> {
536 {
538 let usage = self.user_usage.read();
539 if let Some(counter) = usage.get(sender_id) {
540 return self.try_charge(counter, accounted);
541 }
542 }
543 let mut usage = self.user_usage.write();
545 let counter = usage.entry(*sender_id).or_default();
546 self.try_charge(counter, accounted)
547 }
548
549 fn try_charge(&self, counter: &AtomicU64, accounted: u64) -> Result<(), HopError> {
554 let previous = counter.fetch_add(accounted, Ordering::Relaxed);
555 if previous.saturating_add(accounted) > self.max_user_size {
556 counter.fetch_sub(accounted, Ordering::Relaxed);
557 return Err(HopError::UserQuotaExceeded { used: previous, limit: self.max_user_size });
558 }
559 Ok(())
560 }
561
562 fn release_user_quota(&self, sender_id: &SenderId, accounted: u64) {
566 if let Some(counter) = self.user_usage.read().get(sender_id) {
567 saturating_release(counter, accounted);
568 }
569 }
570
571 fn entry_path(data_dir: &Path, hash: &HopHash, subdir: &str, ext: &str) -> PathBuf {
573 let hex = hex::encode(hash);
574 data_dir.join(subdir).join(&hex[..2]).join(format!("{}.{}", hex, ext))
575 }
576
577 fn blob_path(&self, hash: &HopHash) -> PathBuf {
579 Self::entry_path(&self.data_dir, hash, BLOBS_DIR, BLOB_EXT)
580 }
581
582 fn write_atomic(path: &Path, data: &[u8]) -> Result<(), HopError> {
589 let suffix = format!("tmp.{}.{}", process::id(), TMP_SEQ.fetch_add(1, Ordering::Relaxed));
590 let tmp_path = path.with_extension(suffix);
591 if let Err(e) = fs::write(&tmp_path, data) {
592 let _ = fs::remove_file(&tmp_path);
593 return Err(e.into());
594 }
595 if let Err(e) = fs::rename(&tmp_path, path) {
596 let _ = fs::remove_file(&tmp_path);
597 return Err(e.into());
598 }
599 Ok(())
600 }
601
602 pub fn insert(
606 &self,
607 data: Vec<u8>,
608 recipients: RecipientVec,
609 sender_id: SenderId,
610 signer: MultiSigner,
611 signature: MultiSignature,
612 submit_timestamp: u64,
613 ) -> Result<HopHash, HopError> {
614 if recipients.is_empty() {
615 return Err(HopError::NoRecipients);
616 }
617 let unique: BTreeSet<&MultiSigner> = recipients.iter().map(|r| &r.signer).collect();
618 if unique.len() != recipients.len() {
619 return Err(HopError::DuplicateRecipient);
620 }
621
622 if data.is_empty() {
623 return Err(HopError::EmptyData);
624 }
625
626 let data_len = data.len() as u64;
627
628 let accounted = entry_accounted_size(data_len, recipients.len());
636
637 if let Err(retry_after_secs) = self.rate_limiter.check(&sender_id, accounted) {
639 return Err(HopError::RateLimited { retry_after_secs });
640 }
641
642 let previous_size = self.current_size.fetch_add(accounted, Ordering::Relaxed);
643 if previous_size.saturating_add(accounted) > self.max_size {
644 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
645 return Err(HopError::PoolFull(previous_size, self.max_size));
646 }
647
648 if let Err(e) = self.charge_user(&sender_id, accounted) {
649 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
650 return Err(e);
651 }
652
653 let hash = H256(blake2_256(&data));
654
655 match self.fetch_meta(&hash) {
657 Ok(Some(_)) => {
658 self.release_user_quota(&sender_id, accounted);
659 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
660 return Err(HopError::DuplicateEntry);
661 },
662 Ok(None) => (),
663 Err(e) => {
664 self.release_user_quota(&sender_id, accounted);
665 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
666 return Err(e);
667 },
668 }
669
670 let blob_path = self.blob_path(&hash);
672 if let Err(e) = Self::write_atomic(&blob_path, &data) {
673 self.release_user_quota(&sender_id, accounted);
674 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
675 return Err(e);
676 }
677
678 let expires_at = SystemTime::now()
679 .duration_since(UNIX_EPOCH)
680 .unwrap_or_default()
681 .as_secs()
682 .saturating_add(self.retention_secs);
683 let meta = HopEntryMeta::new(
684 data_len,
685 expires_at,
686 recipients,
687 sender_id,
688 signer,
689 signature,
690 submit_timestamp,
691 );
692 let meta_bytes = meta.encode();
693
694 {
696 let _guard = self.rmw_lock.lock();
697 match self.fetch_meta(&hash) {
698 Ok(Some(_)) => {
699 drop(_guard);
700 tracing::debug!(
702 target: "hop",
703 hash = ?hex::encode(hash),
704 "Duplicate insert race lost; keeping winner's blob"
705 );
706 self.release_user_quota(&sender_id, accounted);
707 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
708 return Err(HopError::DuplicateEntry);
709 },
710 Ok(None) => (),
711 Err(e) => {
712 drop(_guard);
713 let _ = fs::remove_file(&blob_path);
714 self.release_user_quota(&sender_id, accounted);
715 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
716 return Err(e);
717 },
718 }
719 if let Err(e) = self.commit_meta(&hash, Some(meta_bytes)) {
720 drop(_guard);
721 let _ = fs::remove_file(&blob_path);
722 self.release_user_quota(&sender_id, accounted);
723 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
724 return Err(e);
725 }
726 }
727
728 self.expiry_index.write().insert((expires_at, hash));
729 self.entry_count.fetch_add(1, Ordering::Relaxed);
730 self.metrics.record_inserted_bytes(accounted);
731 self.publish_size_metrics();
732
733 tracing::info!(
734 target: "hop",
735 hash = ?hex::encode(hash),
736 size = data_len,
737 accounted,
738 expires_at,
739 "Data added to HOP pool"
740 );
741
742 Ok(hash)
743 }
744
745 fn read_and_verify_blob(&self, hash: &HopHash) -> Result<Vec<u8>, HopError> {
752 let blob_path = self.blob_path(hash);
753 let data = fs::read(&blob_path).map_err(|e| {
754 if e.kind() == std::io::ErrorKind::NotFound {
755 HopError::NotFound
756 } else {
757 HopError::IoError(e)
758 }
759 })?;
760 if H256(blake2_256(&data)) != *hash {
761 tracing::error!(
762 target: "hop",
763 hash = ?hex::encode(hash),
764 size = data.len(),
765 "Blob integrity check failed; purging entry"
766 );
767 self.purge_corrupt_entry(hash);
768 return Err(HopError::NotFound);
769 }
770 Ok(data)
771 }
772
773 fn purge_corrupt_entry(&self, hash: &HopHash) {
776 let removed = {
777 let _guard = self.rmw_lock.lock();
778 match self.fetch_meta(hash) {
779 Ok(Some(meta)) => {
780 if let Err(e) = self.commit_meta(hash, None) {
781 tracing::error!(
782 target: "hop",
783 hash = ?hex::encode(hash),
784 error = %e,
785 "Failed to delete corrupt meta row",
786 );
787 None
788 } else {
789 Some(meta)
790 }
791 },
792 Ok(None) => None,
793 Err(e) => {
794 tracing::error!(target: "hop", hash = ?hex::encode(hash), error = %e, "Failed to read meta during corrupt-entry purge");
795 None
796 },
797 }
798 };
799 if let Some(meta) = removed {
800 let accounted = entry_accounted_size(meta.size, meta.recipients.len());
801 self.expiry_index.write().remove(&(meta.expires_at, *hash));
802 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
803 self.entry_count.fetch_sub(1, Ordering::Relaxed);
804 self.release_user_quota(&meta.sender_id, accounted);
805 self.metrics.record_removed(removal_reasons::CORRUPT, 1);
806 self.publish_size_metrics();
807 }
808 let _ = fs::remove_file(self.blob_path(hash));
809 }
810
811 fn read_or_log(&self, hash: &HopHash) -> Option<Vec<u8>> {
814 match self.read_and_verify_blob(hash) {
815 Ok(data) => Some(data),
816 Err(HopError::NotFound) => None,
817 Err(e) => {
818 tracing::error!(
819 target: "hop",
820 hash = ?hex::encode(hash),
821 error = ?e,
822 "Failed to read blob from disk"
823 );
824 None
825 },
826 }
827 }
828
829 pub fn get(&self, hash: &HopHash) -> Option<Vec<u8>> {
831 match self.fetch_meta(hash) {
832 Ok(Some(_)) => self.read_or_log(hash),
833 Ok(None) => None,
834 Err(e) => {
835 tracing::error!(target: "hop", hash = ?hex::encode(hash), error = %e, "Failed to read meta for get");
836 None
837 },
838 }
839 }
840
841 pub fn get_with_auth(
847 &self,
848 hash: &HopHash,
849 ) -> Option<(Vec<u8>, MultiSigner, MultiSignature, u64)> {
850 let meta = match self.fetch_meta(hash) {
851 Ok(Some(m)) => m,
852 Ok(None) => return None,
853 Err(e) => {
854 tracing::error!(target: "hop", hash = ?hex::encode(hash), error = %e, "Failed to read meta for get_with_auth");
855 return None;
856 },
857 };
858 let data = self.read_or_log(hash)?;
859 Some((data, meta.signer, meta.signature, meta.submit_timestamp))
860 }
861
862 fn find_recipient_idx(
868 meta: &HopEntryMeta,
869 hash: &HopHash,
870 signature: &[u8],
871 context: &[u8],
872 ) -> Result<usize, HopError> {
873 let multi_sig =
874 MultiSignature::decode(&mut &signature[..]).map_err(|_| HopError::InvalidSignature)?;
875 let payload = signing_payload(context, hash);
876
877 meta.recipients
878 .iter()
879 .position(|r| multi_sig.verify(&payload[..], &r.signer.clone().into_account()))
880 .ok_or(HopError::NotRecipient)
881 }
882
883 pub fn claim(&self, hash: &HopHash, signature: &[u8]) -> Result<Vec<u8>, HopError> {
891 let meta = self.fetch_meta(hash)?.ok_or(HopError::NotFound)?;
892 let idx = Self::find_recipient_idx(&meta, hash, signature, HOP_CLAIM_CONTEXT)
895 .map_err(|_| HopError::NotFound)?;
896
897 if meta.recipients[idx].claimed {
899 return Err(HopError::AlreadyClaimed);
900 }
901 self.read_and_verify_blob(hash)
904 }
905
906 pub fn ack(&self, hash: &HopHash, signature: &[u8]) -> Result<(), HopError> {
911 {
913 let meta = self.fetch_meta(hash)?.ok_or(HopError::NotFound)?;
914 let idx = Self::find_recipient_idx(&meta, hash, signature, HOP_ACK_CONTEXT)
915 .map_err(|_| HopError::NotFound)?;
916 if meta.recipients[idx].claimed {
917 return Ok(());
918 }
919 }
920
921 let _guard = self.rmw_lock.lock();
923 let mut meta = self.fetch_meta(hash)?.ok_or(HopError::NotFound)?;
924 let idx = Self::find_recipient_idx(&meta, hash, signature, HOP_ACK_CONTEXT)
925 .map_err(|_| HopError::NotFound)?;
926
927 if meta.recipients[idx].claimed {
928 return Ok(());
929 }
930
931 meta.recipients[idx].claimed = true;
932
933 if meta.recipients.iter().all(|r| r.claimed) {
934 let accounted = entry_accounted_size(meta.size, meta.recipients.len());
935 let sender = meta.sender_id;
936 let expires_at = meta.expires_at;
937 self.commit_meta(hash, None)?;
938 drop(_guard);
939
940 self.expiry_index.write().remove(&(expires_at, *hash));
941 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
942 self.entry_count.fetch_sub(1, Ordering::Relaxed);
943 self.release_user_quota(&sender, accounted);
944 self.metrics.record_removed(removal_reasons::ACKED, 1);
945 self.publish_size_metrics();
946
947 let _ = fs::remove_file(self.blob_path(hash));
949
950 tracing::info!(
951 target: "hop",
952 hash = ?hex::encode(hash),
953 "All recipients acked, data removed"
954 );
955 } else {
956 let claimed_count = meta.recipients.iter().filter(|r| r.claimed).count();
957 self.commit_meta(hash, Some(meta.encode()))?;
958 drop(_guard);
959
960 tracing::debug!(
961 target: "hop",
962 hash = ?hex::encode(hash),
963 claimed = claimed_count,
964 "Recipient acked"
965 );
966 }
967
968 Ok(())
969 }
970
971 #[cfg(test)]
973 pub fn has(&self, hash: &HopHash) -> bool {
974 matches!(self.fetch_meta(hash), Ok(Some(_)))
975 }
976
977 #[cfg(test)]
979 pub fn remove(&self, hash: &HopHash) -> Result<(), HopError> {
980 let meta = {
981 let _guard = self.rmw_lock.lock();
982 let Some(meta) = self.fetch_meta(hash)? else {
983 return Err(HopError::NotFound);
984 };
985 self.commit_meta(hash, None)?;
986 meta
987 };
988
989 let accounted = entry_accounted_size(meta.size, meta.recipients.len());
990 self.expiry_index.write().remove(&(meta.expires_at, *hash));
991 self.current_size.fetch_sub(accounted, Ordering::Relaxed);
992 self.entry_count.fetch_sub(1, Ordering::Relaxed);
993 self.release_user_quota(&meta.sender_id, accounted);
994
995 let _ = fs::remove_file(self.blob_path(hash));
996
997 tracing::debug!(
998 target: "hop",
999 hash = ?hex::encode(hash),
1000 "Data removed from pool"
1001 );
1002
1003 Ok(())
1004 }
1005
1006 pub fn status(&self) -> PoolStatus {
1008 PoolStatus {
1009 entry_count: self.entry_count.load(Ordering::Relaxed) as usize,
1010 total_bytes: self.current_size.load(Ordering::Relaxed),
1011 max_bytes: self.max_size,
1012 }
1013 }
1014
1015 pub fn cleanup_expired(&self, promotion_buffer_secs: u64) -> u64 {
1025 const CLEANUP_BATCH_SIZE: usize = 10_000;
1026 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
1027
1028 let mut total_freed: u64 = 0;
1029
1030 loop {
1031 let batch: Vec<(u64, HopHash)> = {
1035 let guard = self.expiry_index.read();
1036 guard
1037 .range((Bound::Unbounded, Bound::Included(&(now_secs, H256([0xff; 32])))))
1038 .take(CLEANUP_BATCH_SIZE)
1039 .copied()
1040 .collect()
1041 };
1042
1043 if batch.is_empty() {
1044 break;
1045 }
1046
1047 let mut processed: Vec<(HopHash, HopEntryMeta)> = Vec::with_capacity(batch.len());
1050 {
1051 let _guard = self.rmw_lock.lock();
1052 let mut ops: Vec<(u8, Vec<u8>, Option<Vec<u8>>)> = Vec::with_capacity(batch.len());
1053 for (_, hash) in &batch {
1054 match self.fetch_meta(hash) {
1055 Ok(Some(meta)) if now_secs >= meta.expires_at => {
1056 ops.push((COL_META, hash.as_bytes().to_vec(), None));
1057 processed.push((*hash, meta));
1058 },
1059 Ok(_) => (), Err(e) => {
1061 tracing::error!(target: "hop", hash = ?hex::encode(hash), error = %e, "cleanup_expired: failed to re-read meta");
1062 },
1063 }
1064 }
1065 if !ops.is_empty() {
1066 if let Err(e) = self.db.commit(ops) {
1067 tracing::error!(target: "hop", error = %e, "cleanup_expired: batch commit failed");
1068 break;
1069 }
1070 }
1071 }
1072
1073 {
1078 let mut index = self.expiry_index.write();
1079 for entry in &batch {
1080 index.remove(entry);
1081 }
1082 }
1083
1084 if processed.is_empty() {
1085 continue;
1086 }
1087
1088 let mut freed = 0u64;
1091 let mut promoted = 0u64;
1092 let mut unpromoted = 0u64;
1093 for (_, meta) in &processed {
1094 freed =
1095 freed.saturating_add(entry_accounted_size(meta.size, meta.recipients.len()));
1096 if meta.promoted {
1097 promoted = promoted.saturating_add(1);
1098 } else {
1099 unpromoted = unpromoted.saturating_add(1);
1100 }
1101 }
1102 self.current_size.fetch_sub(freed, Ordering::Relaxed);
1103 self.entry_count.fetch_sub(processed.len() as u64, Ordering::Relaxed);
1104 total_freed = total_freed.saturating_add(freed);
1105 self.metrics.record_removed(removal_reasons::EXPIRED_PROMOTED, promoted);
1106 self.metrics.record_removed(removal_reasons::EXPIRED_UNPROMOTED, unpromoted);
1107
1108 {
1109 let usage = self.user_usage.read();
1110 for (_, meta) in &processed {
1111 if let Some(counter) = usage.get(&meta.sender_id) {
1112 let accounted = entry_accounted_size(meta.size, meta.recipients.len());
1113 saturating_release(counter, accounted);
1114 }
1115 }
1116 }
1117
1118 for (hash, _) in &processed {
1119 let _ = fs::remove_file(self.blob_path(hash));
1120 }
1121 }
1122
1123 {
1127 let mut usage = self.user_usage.write();
1128 usage.retain(|_, counter| counter.load(Ordering::Relaxed) > 0);
1129 }
1130
1131 self.rate_limiter.evict_stale();
1133
1134 self.publish_size_metrics();
1139 let backlog = if self.metrics.is_enabled() {
1140 let frontier = now_secs.saturating_add(promotion_buffer_secs);
1141 let candidates: Vec<HopHash> = {
1142 let guard = self.expiry_index.read();
1143 guard
1144 .range((Bound::Unbounded, Bound::Included(&(frontier, H256([0xff; 32])))))
1145 .map(|(_, hash)| *hash)
1146 .collect()
1147 };
1148 candidates
1149 .into_iter()
1150 .filter(|hash| {
1151 matches!(
1152 self.fetch_meta(hash),
1153 Ok(Some(meta))
1154 if Self::in_promotion_window(&meta, now_secs, promotion_buffer_secs)
1155 )
1156 })
1157 .count() as u64
1158 } else {
1159 0
1160 };
1161 self.metrics.set_promotion_backlog(backlog);
1162
1163 total_freed
1164 }
1165
1166 fn in_promotion_window(meta: &HopEntryMeta, now_secs: u64, buffer_secs: u64) -> bool {
1169 !meta.promoted &&
1170 now_secs.saturating_add(buffer_secs) >= meta.expires_at &&
1171 meta.promotion_attempts < MAX_PROMOTION_ATTEMPTS
1172 }
1173
1174 pub fn get_promotable(
1182 &self,
1183 current_block: HopBlockNumber,
1184 buffer_secs: u64,
1185 limit: usize,
1186 ) -> Vec<HopHash> {
1187 let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
1188 let frontier = now_secs.saturating_add(buffer_secs);
1189
1190 let candidates: Vec<HopHash> = {
1193 let guard = self.expiry_index.read();
1194 guard
1195 .range((Bound::Unbounded, Bound::Included(&(frontier, H256([0xff; 32])))))
1196 .map(|(_, hash)| *hash)
1197 .collect()
1198 };
1199
1200 let mut out: Vec<HopHash> = Vec::new();
1201 for hash in candidates {
1202 if out.len() >= limit {
1203 break;
1204 }
1205 match self.fetch_meta(&hash) {
1206 Ok(Some(meta))
1207 if Self::in_promotion_window(&meta, now_secs, buffer_secs) &&
1208 current_block >= meta.next_promotion_attempt_at =>
1209 {
1210 out.push(hash);
1211 },
1212 _ => (),
1213 }
1214 }
1215 out
1216 }
1217
1218 pub fn mark_promoted(&self, hash: &HopHash) {
1220 let _guard = self.rmw_lock.lock();
1221 let Ok(Some(mut meta)) = self.fetch_meta(hash) else { return };
1222 let newly_promoted = !meta.promoted;
1224 meta.promoted = true;
1225 if let Err(e) = self.commit_meta(hash, Some(meta.encode())) {
1226 tracing::error!(
1227 target: "hop",
1228 hash = ?hex::encode(hash),
1229 error = %e,
1230 "Failed to persist promoted state"
1231 );
1232 return;
1233 }
1234 if newly_promoted {
1235 self.metrics.record_promotion_confirmed();
1236 }
1237 }
1238
1239 pub fn record_promotion_attempt(
1249 &self,
1250 hash: &HopHash,
1251 current_block: HopBlockNumber,
1252 check_interval_blocks: u32,
1253 ) {
1254 let _guard = self.rmw_lock.lock();
1255 let Ok(Some(mut meta)) = self.fetch_meta(hash) else { return };
1256 meta.promotion_attempts = meta.promotion_attempts.saturating_add(1);
1257 let backoff = promotion_backoff_blocks(meta.promotion_attempts, check_interval_blocks);
1258 meta.next_promotion_attempt_at = current_block.saturating_add(backoff);
1259 if let Err(e) = self.commit_meta(hash, Some(meta.encode())) {
1260 tracing::error!(
1261 target: "hop",
1262 hash = ?hex::encode(hash),
1263 error = %e,
1264 "Failed to persist promotion-attempt state"
1265 );
1266 }
1267 }
1268}
1269
1270fn parse_hex_hash(stem: &str) -> Option<HopHash> {
1273 let bytes = hex::decode(stem).ok()?;
1274 let arr: [u8; 32] = bytes.try_into().ok()?;
1275 Some(H256(arr))
1276}
1277
1278fn saturating_release(counter: &AtomicU64, accounted: u64) {
1284 let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |previous| {
1285 Some(previous - accounted.min(previous))
1286 });
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291 use super::*;
1292 use crate::types::{Recipient, MAX_RECIPIENTS};
1293 use sp_core::{crypto::Pair, ed25519, sr25519};
1294 use sp_runtime::MultiSigner;
1295 use tempfile::TempDir;
1296
1297 const SENDER_A: SenderId = [1u8; 32];
1298 const SENDER_B: SenderId = [2u8; 32];
1299
1300 fn acct(data_size: u64, num_recipients: usize) -> u64 {
1302 entry_accounted_size(data_size, num_recipients)
1303 }
1304
1305 fn make_pool(max_size: u64, retention_secs: u64) -> (HopDataPool, TempDir) {
1306 let dir = TempDir::new().unwrap();
1307 let pool = HopDataPool::new(
1308 max_size,
1309 max_size,
1310 retention_secs,
1311 dir.path().to_path_buf(),
1312 RateLimitConfig::disabled(),
1313 HopMetrics::disabled(),
1314 )
1315 .unwrap();
1316 (pool, dir)
1317 }
1318
1319 fn make_pool_with_user_cap(
1320 max_size: u64,
1321 max_user_size: u64,
1322 retention_secs: u64,
1323 ) -> (HopDataPool, TempDir) {
1324 let dir = TempDir::new().unwrap();
1325 let pool = HopDataPool::new(
1326 max_size,
1327 max_user_size,
1328 retention_secs,
1329 dir.path().to_path_buf(),
1330 RateLimitConfig::disabled(),
1331 HopMetrics::disabled(),
1332 )
1333 .unwrap();
1334 (pool, dir)
1335 }
1336
1337 fn create_test_pool() -> (HopDataPool, TempDir) {
1338 make_pool(1024 * 1024, 100)
1339 }
1340
1341 fn test_recipient() -> (ed25519::Pair, MultiSigner) {
1342 let pair = ed25519::Pair::from_seed(&[1u8; 32]);
1343 let signer = MultiSigner::Ed25519(pair.public());
1344 (pair, signer)
1345 }
1346
1347 fn dummy_auth() -> (MultiSigner, MultiSignature) {
1351 let pair = ed25519::Pair::from_seed(&[7u8; 32]);
1352 let signer = MultiSigner::Ed25519(pair.public());
1353 let sig = MultiSignature::Ed25519(pair.sign(&[]));
1354 (signer, sig)
1355 }
1356
1357 fn sign_ed(pair: &ed25519::Pair, context: &[u8], hash: &HopHash) -> Vec<u8> {
1358 let payload = signing_payload(context, hash);
1359 MultiSignature::Ed25519(pair.sign(&payload)).encode()
1360 }
1361
1362 fn sign_sr(pair: &sr25519::Pair, context: &[u8], hash: &HopHash) -> Vec<u8> {
1363 let payload = signing_payload(context, hash);
1364 MultiSignature::Sr25519(pair.sign(&payload)).encode()
1365 }
1366
1367 fn user_usage(pool: &HopDataPool, sender: &SenderId) -> u64 {
1368 pool.user_usage
1369 .read()
1370 .get(sender)
1371 .map(|c| c.load(Ordering::Relaxed))
1372 .unwrap_or(0)
1373 }
1374
1375 fn bv(v: Vec<MultiSigner>) -> RecipientVec {
1378 let recipients: Vec<Recipient> =
1379 v.into_iter().map(|signer| Recipient { signer, claimed: false }).collect();
1380 RecipientVec::try_from(recipients).expect("test recipient list exceeds MAX_RECIPIENTS")
1381 }
1382
1383 fn legacy_meta(data: &[u8], sender: SenderId) -> HopEntryMeta {
1385 let (_, signer) = test_recipient();
1386 HopEntryMeta::new(
1387 data.len() as u64,
1388 u64::MAX,
1389 bv(vec![signer]),
1390 sender,
1391 dummy_auth().0,
1392 dummy_auth().1,
1393 0,
1394 )
1395 }
1396
1397 fn write_legacy_entry(dir: &Path, data: &[u8], meta_bytes: &[u8]) -> HopHash {
1401 let hash = H256(blake2_256(data));
1402 let blob_path = HopDataPool::entry_path(dir, &hash, BLOBS_DIR, BLOB_EXT);
1403 fs::create_dir_all(blob_path.parent().unwrap()).unwrap();
1404 fs::write(&blob_path, data).unwrap();
1405 write_legacy_meta_only(dir, &hash, meta_bytes);
1406 hash
1407 }
1408
1409 fn write_legacy_meta_only(dir: &Path, hash: &HopHash, meta_bytes: &[u8]) {
1411 let meta_path = HopDataPool::entry_path(dir, hash, LEGACY_META_DIR, LEGACY_META_EXT);
1412 fs::create_dir_all(meta_path.parent().unwrap()).unwrap();
1413 fs::write(&meta_path, meta_bytes).unwrap();
1414 }
1415
1416 fn read_db_version(dir: &Path) -> Option<u32> {
1418 let db = parity_db::Db::open_or_create(&HopDataPool::db_options(&dir.join(META_DB_DIR)))
1419 .unwrap();
1420 db.get(COL_DB_META, KEY_DB_VERSION)
1421 .unwrap()
1422 .map(|bytes| u32::from_le_bytes(bytes.try_into().unwrap()))
1423 }
1424
1425 #[test]
1426 fn test_insert_and_get() {
1427 let (pool, _dir) = create_test_pool();
1428 let (_, signer) = test_recipient();
1429 let data = vec![1, 2, 3, 4, 5];
1430 let hash = pool
1431 .insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1432 .unwrap();
1433
1434 let retrieved = pool.get(&hash).unwrap();
1435 assert_eq!(data, retrieved);
1436 }
1437
1438 #[test]
1439 fn test_insert_no_recipients() {
1440 let (pool, _dir) = create_test_pool();
1441 let data = vec![1, 2, 3, 4, 5];
1442 let result = pool.insert(data, bv(vec![]), SENDER_A, dummy_auth().0, dummy_auth().1, 0);
1443 assert!(matches!(result, Err(HopError::NoRecipients)));
1444 }
1445
1446 #[test]
1447 fn test_duplicate_insert() {
1448 let (pool, _dir) = create_test_pool();
1449 let (_, signer) = test_recipient();
1450 let data = vec![1, 2, 3, 4, 5];
1451
1452 pool.insert(
1453 data.clone(),
1454 bv(vec![signer.clone()]),
1455 SENDER_A,
1456 dummy_auth().0,
1457 dummy_auth().1,
1458 0,
1459 )
1460 .unwrap();
1461 let result =
1462 pool.insert(data, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0);
1463
1464 assert!(matches!(result, Err(HopError::DuplicateEntry)));
1465 }
1466
1467 #[test]
1468 fn test_too_many_recipients_rejected_at_type_level() {
1469 let recipients: Vec<Recipient> = (0..=MAX_RECIPIENTS as u64)
1473 .map(|i| {
1474 let mut seed = [0u8; 32];
1475 seed[..8].copy_from_slice(&i.to_le_bytes());
1476 Recipient {
1477 signer: MultiSigner::Ed25519(ed25519::Pair::from_seed(&seed).public()),
1478 claimed: false,
1479 }
1480 })
1481 .collect();
1482 assert_eq!(recipients.len(), MAX_RECIPIENTS as usize + 1);
1483 assert!(RecipientVec::try_from(recipients).is_err());
1484 }
1485
1486 #[test]
1487 fn test_duplicate_recipient_rejected() {
1488 let (pool, _dir) = create_test_pool();
1489 let (_, signer) = test_recipient();
1490 let result = pool.insert(
1491 vec![1, 2, 3],
1492 bv(vec![signer.clone(), signer]),
1493 SENDER_A,
1494 dummy_auth().0,
1495 dummy_auth().1,
1496 0,
1497 );
1498 assert!(matches!(result, Err(HopError::DuplicateRecipient)));
1499 }
1500
1501 #[test]
1502 fn test_pool_full() {
1503 let (pool, _dir) = make_pool(acct(60, 1), 100);
1505 let (_, signer) = test_recipient();
1506
1507 let data1 = vec![0u8; 60];
1508 let data2 = vec![1u8; 50];
1509
1510 pool.insert(data1, bv(vec![signer.clone()]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1511 .unwrap();
1512 let result =
1513 pool.insert(data2, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0);
1514
1515 assert!(matches!(result, Err(HopError::PoolFull(_, _))));
1516 }
1517
1518 #[test]
1519 fn test_remove() {
1520 let (pool, _dir) = create_test_pool();
1521 let (_, signer) = test_recipient();
1522 let data = vec![1, 2, 3, 4, 5];
1523 let hash = pool
1524 .insert(data, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1525 .unwrap();
1526
1527 assert!(pool.has(&hash));
1528 pool.remove(&hash).unwrap();
1529 assert!(!pool.has(&hash));
1530
1531 assert!(!pool.blob_path(&hash).exists());
1533 }
1534
1535 #[test]
1536 fn test_status() {
1537 let (pool, _dir) = create_test_pool();
1538 let (_, signer) = test_recipient();
1539 let data1 = vec![1, 2, 3, 4, 5];
1540 let data2 = vec![6, 7, 8];
1541
1542 pool.insert(
1543 data1.clone(),
1544 bv(vec![signer.clone()]),
1545 SENDER_A,
1546 dummy_auth().0,
1547 dummy_auth().1,
1548 0,
1549 )
1550 .unwrap();
1551 pool.insert(data2.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1552 .unwrap();
1553
1554 let status = pool.status();
1555 assert_eq!(status.entry_count, 2);
1556 assert_eq!(status.total_bytes, acct(data1.len() as u64, 1) + acct(data2.len() as u64, 1));
1557 }
1558
1559 #[test]
1560 fn test_claim_valid_signature() {
1561 let (pool, _dir) = create_test_pool();
1562 let (pair, signer) = test_recipient();
1563 let data = vec![1, 2, 3, 4, 5];
1564 let hash = pool
1565 .insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1566 .unwrap();
1567
1568 let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1569 let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1570 let result = pool.claim(&hash, &claim).unwrap();
1571 assert_eq!(data, result);
1572
1573 assert!(pool.has(&hash));
1575
1576 pool.ack(&hash, &ack).unwrap();
1577 assert!(!pool.has(&hash));
1578 }
1579
1580 #[test]
1581 fn test_claim_sig_rejected_on_ack() {
1582 let (pool, _dir) = create_test_pool();
1584 let (pair, signer) = test_recipient();
1585 let hash = pool
1586 .insert(vec![1, 2, 3], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1587 .unwrap();
1588
1589 let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1590 pool.claim(&hash, &claim).unwrap();
1591 assert!(matches!(pool.ack(&hash, &claim), Err(HopError::NotFound)));
1592 }
1593
1594 #[test]
1595 fn test_claim_invalid_signature() {
1596 let (pool, _dir) = create_test_pool();
1597 let (_, signer) = test_recipient();
1598 let data = vec![1, 2, 3, 4, 5];
1599 let hash = pool
1600 .insert(data, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1601 .unwrap();
1602
1603 let result = pool.claim(&hash, &[0u8; 3]);
1605 assert!(matches!(result, Err(HopError::NotFound)));
1606 }
1607
1608 #[test]
1609 fn test_claim_wrong_key() {
1610 let (pool, _dir) = create_test_pool();
1611 let (_, signer) = test_recipient();
1612 let hash = pool
1613 .insert(
1614 vec![1, 2, 3, 4, 5],
1615 bv(vec![signer]),
1616 SENDER_A,
1617 dummy_auth().0,
1618 dummy_auth().1,
1619 0,
1620 )
1621 .unwrap();
1622
1623 let wrong_pair = ed25519::Pair::from_seed(&[99u8; 32]);
1624 let wrong_claim = sign_ed(&wrong_pair, HOP_CLAIM_CONTEXT, &hash);
1625 assert!(matches!(pool.claim(&hash, &wrong_claim), Err(HopError::NotFound)));
1626 assert!(pool.has(&hash));
1627 }
1628
1629 #[test]
1630 fn test_claim_multi_recipient() {
1631 let (pool, _dir) = create_test_pool();
1632 let pair1 = ed25519::Pair::from_seed(&[1u8; 32]);
1633 let pair2 = ed25519::Pair::from_seed(&[2u8; 32]);
1634 let signer1 = MultiSigner::Ed25519(pair1.public());
1635 let signer2 = MultiSigner::Ed25519(pair2.public());
1636
1637 let data = vec![1, 2, 3, 4, 5];
1638 let hash = pool
1639 .insert(
1640 data.clone(),
1641 bv(vec![signer1, signer2]),
1642 SENDER_A,
1643 dummy_auth().0,
1644 dummy_auth().1,
1645 0,
1646 )
1647 .unwrap();
1648
1649 let claim1 = sign_ed(&pair1, HOP_CLAIM_CONTEXT, &hash);
1650 let ack1 = sign_ed(&pair1, HOP_ACK_CONTEXT, &hash);
1651 assert_eq!(data, pool.claim(&hash, &claim1).unwrap());
1652 pool.ack(&hash, &ack1).unwrap();
1653 assert!(pool.has(&hash));
1654
1655 let claim2 = sign_ed(&pair2, HOP_CLAIM_CONTEXT, &hash);
1656 let ack2 = sign_ed(&pair2, HOP_ACK_CONTEXT, &hash);
1657 assert_eq!(data, pool.claim(&hash, &claim2).unwrap());
1658 pool.ack(&hash, &ack2).unwrap();
1659 assert!(!pool.has(&hash));
1660 assert_eq!(pool.status().total_bytes, 0);
1661 }
1662
1663 #[test]
1664 fn test_claim_after_ack_returns_already_claimed() {
1665 let (pool, _dir) = create_test_pool();
1666 let (pair, signer) = test_recipient();
1667 let pair2 = ed25519::Pair::from_seed(&[2u8; 32]);
1668 let signer2 = MultiSigner::Ed25519(pair2.public());
1669
1670 let hash = pool
1671 .insert(
1672 vec![1, 2, 3, 4, 5],
1673 bv(vec![signer, signer2]),
1674 SENDER_A,
1675 dummy_auth().0,
1676 dummy_auth().1,
1677 0,
1678 )
1679 .unwrap();
1680
1681 let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1682 let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1683 pool.claim(&hash, &claim).unwrap();
1684 pool.ack(&hash, &ack).unwrap();
1685
1686 assert!(matches!(pool.claim(&hash, &claim), Err(HopError::AlreadyClaimed)));
1688 }
1689
1690 #[test]
1691 fn test_claim_not_found() {
1692 let (pool, _dir) = create_test_pool();
1693 let fake_hash = H256([0u8; 32]);
1694 let result = pool.claim(&fake_hash, &[0u8; 64]);
1695 assert!(matches!(result, Err(HopError::NotFound)));
1696 }
1697
1698 #[test]
1699 fn test_per_user_cap_is_hard_limit() {
1700 let (pool, _dir) = make_pool_with_user_cap(10_000, acct(60, 1), 100);
1702 let (_, signer) = test_recipient();
1703
1704 pool.insert(
1705 vec![0u8; 60],
1706 bv(vec![signer.clone()]),
1707 SENDER_A,
1708 dummy_auth().0,
1709 dummy_auth().1,
1710 0,
1711 )
1712 .unwrap();
1713
1714 let result = pool.insert(
1716 vec![1u8; 10],
1717 bv(vec![signer.clone()]),
1718 SENDER_A,
1719 dummy_auth().0,
1720 dummy_auth().1,
1721 0,
1722 );
1723 assert!(matches!(result, Err(HopError::UserQuotaExceeded { .. })));
1724
1725 pool.insert(vec![2u8; 60], bv(vec![signer]), SENDER_B, dummy_auth().0, dummy_auth().1, 0)
1727 .unwrap();
1728 }
1729
1730 #[test]
1731 fn test_quota_released_after_ack() {
1732 let (pool, _dir) = make_pool_with_user_cap(10_000, acct(100, 1), 100);
1733 let (pair, signer) = test_recipient();
1734
1735 let hash = pool
1736 .insert(
1737 vec![0u8; 100],
1738 bv(vec![signer.clone()]),
1739 SENDER_A,
1740 dummy_auth().0,
1741 dummy_auth().1,
1742 0,
1743 )
1744 .unwrap();
1745
1746 let result = pool.insert(
1748 vec![1u8; 10],
1749 bv(vec![signer.clone()]),
1750 SENDER_A,
1751 dummy_auth().0,
1752 dummy_auth().1,
1753 0,
1754 );
1755 assert!(matches!(result, Err(HopError::UserQuotaExceeded { .. })));
1756
1757 let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1758 let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1759 pool.claim(&hash, &claim).unwrap();
1760 pool.ack(&hash, &ack).unwrap();
1761
1762 pool.insert(vec![2u8; 100], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1764 .unwrap();
1765 }
1766
1767 #[test]
1768 fn test_cleanup_expired_releases_quota() {
1769 let (pool, _dir) = make_pool(10_000, 0);
1770 let (_, signer) = test_recipient();
1771
1772 pool.insert(vec![0u8; 100], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1773 .unwrap();
1774 let charged = acct(100, 1);
1775 assert_eq!(user_usage(&pool, &SENDER_A), charged);
1776
1777 let freed = pool.cleanup_expired(0);
1778 assert_eq!(freed, charged);
1779 assert_eq!(pool.status().total_bytes, 0);
1780 assert_eq!(user_usage(&pool, &SENDER_A), 0);
1781 }
1782
1783 #[test]
1784 fn test_cleanup_expired_honors_wall_clock_retention() {
1785 let (pool, _dir) = make_pool(10_000, 1);
1788 let (_, signer) = test_recipient();
1789
1790 let hash = pool
1791 .insert(vec![0u8; 100], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1792 .unwrap();
1793
1794 assert_eq!(
1796 pool.cleanup_expired(0),
1797 0,
1798 "entry should still be live before retention elapses"
1799 );
1800 assert!(pool.has(&hash));
1801
1802 std::thread::sleep(std::time::Duration::from_millis(1_200));
1803
1804 assert!(
1805 pool.cleanup_expired(0) > 0,
1806 "entry should be reaped once wall-clock retention elapses"
1807 );
1808 assert!(!pool.has(&hash));
1809 }
1810
1811 #[test]
1812 fn test_user_counter_preserved_until_cleanup() {
1813 let (pool, _dir) = create_test_pool();
1817 let (pair, signer) = test_recipient();
1818
1819 let hash = pool
1820 .insert(vec![0u8; 50], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1821 .unwrap();
1822 assert!(pool.user_usage.read().contains_key(&SENDER_A));
1823
1824 let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1825 let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1826 pool.claim(&hash, &claim).unwrap();
1827 pool.ack(&hash, &ack).unwrap();
1828
1829 assert_eq!(user_usage(&pool, &SENDER_A), 0);
1830 assert!(pool.user_usage.read().contains_key(&SENDER_A));
1831 }
1832
1833 #[test]
1834 fn test_cleanup_expired_evicts_idle_user_counters() {
1835 let (pool, _dir) = make_pool(10_000, 10);
1839 let (pair, signer) = test_recipient();
1840
1841 let hash = pool
1842 .insert(vec![0u8; 50], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1843 .unwrap();
1844 let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
1845 let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
1846 pool.claim(&hash, &claim).unwrap();
1847 pool.ack(&hash, &ack).unwrap();
1848 assert!(pool.user_usage.read().contains_key(&SENDER_A));
1849
1850 pool.cleanup_expired(0);
1851 assert!(!pool.user_usage.read().contains_key(&SENDER_A));
1852 }
1853
1854 #[test]
1855 fn test_cleanup_expired_keeps_active_user_counters() {
1856 let (pool, _dir) = make_pool(10_000, 100);
1860 let (_, signer) = test_recipient();
1861
1862 pool.insert(vec![0u8; 50], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
1863 .unwrap();
1864 pool.cleanup_expired(0);
1868 assert!(pool.user_usage.read().contains_key(&SENDER_A));
1869 }
1870
1871 #[test]
1872 fn test_cleanup_expired_processes_more_than_one_batch() {
1873 const BATCHES: u32 = 2;
1877 const PER_BATCH: u32 = 10_000 + 1; let total = BATCHES * PER_BATCH;
1879
1880 let dir = TempDir::new().unwrap();
1881 let entry_bytes = std::mem::size_of::<u32>() as u64;
1883 let pool = HopDataPool::new(
1884 (acct(entry_bytes, 1) * total as u64) + 1024,
1885 u64::MAX,
1886 0,
1887 dir.path().to_path_buf(),
1888 RateLimitConfig::disabled(),
1889 HopMetrics::disabled(),
1890 )
1891 .unwrap();
1892 let (_, signer) = test_recipient();
1893
1894 for i in 0..total {
1895 let mut sender = SENDER_A;
1896 sender[0] = (i & 0xff) as u8;
1897 sender[1] = ((i >> 8) & 0xff) as u8;
1898 sender[2] = ((i >> 16) & 0xff) as u8;
1899 let data = i.to_le_bytes().to_vec();
1903 pool.insert(data, bv(vec![signer.clone()]), sender, dummy_auth().0, dummy_auth().1, 0)
1904 .unwrap();
1905 }
1906 assert_eq!(pool.status().entry_count, total as usize);
1907
1908 pool.cleanup_expired(0);
1909 assert_eq!(pool.status().entry_count, 0);
1910 assert_eq!(pool.status().total_bytes, 0);
1911 assert!(pool.user_usage.read().is_empty());
1912 }
1913
1914 #[test]
1915 fn test_restart_recovery() {
1916 let dir = TempDir::new().unwrap();
1917 let (_, signer) = test_recipient();
1918 let expected_accounted = acct(100, 1);
1919
1920 let hash;
1921 {
1922 let pool = HopDataPool::new(
1923 1024 * 1024,
1924 1024 * 1024,
1925 100,
1926 dir.path().to_path_buf(),
1927 RateLimitConfig::disabled(),
1928 HopMetrics::disabled(),
1929 )
1930 .unwrap();
1931 hash = pool
1932 .insert(
1933 vec![42u8; 100],
1934 bv(vec![signer]),
1935 SENDER_A,
1936 dummy_auth().0,
1937 dummy_auth().1,
1938 0,
1939 )
1940 .unwrap();
1941 assert!(pool.has(&hash));
1942 assert_eq!(pool.status().entry_count, 1);
1943 assert_eq!(pool.status().total_bytes, expected_accounted);
1944 }
1945
1946 {
1947 let pool = HopDataPool::new(
1948 1024 * 1024,
1949 1024 * 1024,
1950 100,
1951 dir.path().to_path_buf(),
1952 RateLimitConfig::disabled(),
1953 HopMetrics::disabled(),
1954 )
1955 .unwrap();
1956 assert!(pool.has(&hash));
1957 assert_eq!(pool.status().entry_count, 1);
1958 assert_eq!(pool.status().total_bytes, expected_accounted);
1959
1960 let data = pool.get(&hash).unwrap();
1961 assert_eq!(data, vec![42u8; 100]);
1962 assert_eq!(user_usage(&pool, &SENDER_A), expected_accounted);
1963 }
1964 }
1965
1966 #[test]
1967 fn test_orphan_blob_cleanup() {
1968 let dir = TempDir::new().unwrap();
1969 {
1970 let _pool = HopDataPool::new(
1971 1024 * 1024,
1972 1024 * 1024,
1973 100,
1974 dir.path().to_path_buf(),
1975 RateLimitConfig::disabled(),
1976 HopMetrics::disabled(),
1977 )
1978 .unwrap();
1979 }
1980
1981 let orphan_hash = "aa".to_string() + &"bb".repeat(15);
1982 let blob_path = dir.path().join("blobs").join("aa").join(format!("{}.blob", orphan_hash));
1983 fs::write(&blob_path, b"orphan data").unwrap();
1984 assert!(blob_path.exists());
1985
1986 let _pool = HopDataPool::new(
1987 1024 * 1024,
1988 1024 * 1024,
1989 100,
1990 dir.path().to_path_buf(),
1991 RateLimitConfig::disabled(),
1992 HopMetrics::disabled(),
1993 )
1994 .unwrap();
1995 assert!(!blob_path.exists());
1996 }
1997
1998 #[test]
1999 fn test_corrupt_meta_cleanup() {
2000 let dir = TempDir::new().unwrap();
2001 {
2003 let _pool = HopDataPool::new(
2004 1024 * 1024,
2005 1024 * 1024,
2006 100,
2007 dir.path().to_path_buf(),
2008 RateLimitConfig::disabled(),
2009 HopMetrics::disabled(),
2010 )
2011 .unwrap();
2012 }
2013
2014 let fake_hash = H256([0xbbu8; 32]);
2016 {
2017 let db_path = dir.path().join(META_DB_DIR);
2018 let db = parity_db::Db::open_or_create(&HopDataPool::db_options(&db_path)).unwrap();
2019 db.commit([(
2020 COL_META,
2021 fake_hash.as_bytes().to_vec(),
2022 Some(b"not valid SCALE data".to_vec()),
2023 )])
2024 .unwrap();
2025 }
2026
2027 let pool = HopDataPool::new(
2028 1024 * 1024,
2029 1024 * 1024,
2030 100,
2031 dir.path().to_path_buf(),
2032 RateLimitConfig::disabled(),
2033 HopMetrics::disabled(),
2034 )
2035 .unwrap();
2036 assert!(!pool.has(&fake_hash));
2038 assert_eq!(pool.status().entry_count, 0);
2039 }
2040
2041 #[test]
2042 fn test_claim_sr25519() {
2043 let (pool, _dir) = create_test_pool();
2044 let pair = sr25519::Pair::from_seed(&[3u8; 32]);
2045 let signer = MultiSigner::Sr25519(pair.public());
2046
2047 let data = vec![10, 20, 30];
2048 let hash = pool
2049 .insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2050 .unwrap();
2051
2052 let claim = sign_sr(&pair, HOP_CLAIM_CONTEXT, &hash);
2053 let ack = sign_sr(&pair, HOP_ACK_CONTEXT, &hash);
2054 assert_eq!(data, pool.claim(&hash, &claim).unwrap());
2055 pool.ack(&hash, &ack).unwrap();
2056 assert!(!pool.has(&hash));
2057 }
2058
2059 #[test]
2060 fn test_claim_mixed_key_types() {
2061 let (pool, _dir) = create_test_pool();
2062 let ed_pair = ed25519::Pair::from_seed(&[4u8; 32]);
2063 let sr_pair = sr25519::Pair::from_seed(&[5u8; 32]);
2064 let ed_signer = MultiSigner::Ed25519(ed_pair.public());
2065 let sr_signer = MultiSigner::Sr25519(sr_pair.public());
2066
2067 let data = vec![42, 43, 44];
2068 let hash = pool
2069 .insert(
2070 data.clone(),
2071 bv(vec![ed_signer, sr_signer]),
2072 SENDER_A,
2073 dummy_auth().0,
2074 dummy_auth().1,
2075 0,
2076 )
2077 .unwrap();
2078
2079 let sr_claim = sign_sr(&sr_pair, HOP_CLAIM_CONTEXT, &hash);
2080 let sr_ack = sign_sr(&sr_pair, HOP_ACK_CONTEXT, &hash);
2081 assert_eq!(data, pool.claim(&hash, &sr_claim).unwrap());
2082 pool.ack(&hash, &sr_ack).unwrap();
2083 assert!(pool.has(&hash));
2084
2085 let ed_claim = sign_ed(&ed_pair, HOP_CLAIM_CONTEXT, &hash);
2086 let ed_ack = sign_ed(&ed_pair, HOP_ACK_CONTEXT, &hash);
2087 assert_eq!(data, pool.claim(&hash, &ed_claim).unwrap());
2088 pool.ack(&hash, &ed_ack).unwrap();
2089 assert!(!pool.has(&hash));
2090 }
2091
2092 #[test]
2093 fn test_claim_is_repeatable() {
2094 let (pool, _dir) = create_test_pool();
2095 let (pair, signer) = test_recipient();
2096 let data = vec![1, 2, 3, 4, 5];
2097 let hash = pool
2098 .insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2099 .unwrap();
2100
2101 let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
2102 assert_eq!(data, pool.claim(&hash, &claim).unwrap());
2103 assert_eq!(data, pool.claim(&hash, &claim).unwrap());
2104 assert!(pool.has(&hash));
2105 }
2106
2107 #[test]
2108 fn test_ack_idempotent() {
2109 let (pool, _dir) = create_test_pool();
2110 let (pair, signer) = test_recipient();
2111 let pair2 = ed25519::Pair::from_seed(&[2u8; 32]);
2112 let signer2 = MultiSigner::Ed25519(pair2.public());
2113
2114 let hash = pool
2115 .insert(
2116 vec![1, 2, 3, 4, 5],
2117 bv(vec![signer, signer2]),
2118 SENDER_A,
2119 dummy_auth().0,
2120 dummy_auth().1,
2121 0,
2122 )
2123 .unwrap();
2124 let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
2125
2126 pool.ack(&hash, &ack).unwrap();
2127 pool.ack(&hash, &ack).unwrap();
2128 assert!(pool.has(&hash));
2129 }
2130
2131 #[test]
2132 fn test_multi_recipient_partial_ack() {
2133 let (pool, _dir) = create_test_pool();
2134 let pair1 = ed25519::Pair::from_seed(&[1u8; 32]);
2135 let pair2 = ed25519::Pair::from_seed(&[2u8; 32]);
2136 let signer1 = MultiSigner::Ed25519(pair1.public());
2137 let signer2 = MultiSigner::Ed25519(pair2.public());
2138
2139 let data = vec![1, 2, 3, 4, 5];
2140 let hash = pool
2141 .insert(
2142 data.clone(),
2143 bv(vec![signer1, signer2]),
2144 SENDER_A,
2145 dummy_auth().0,
2146 dummy_auth().1,
2147 0,
2148 )
2149 .unwrap();
2150
2151 let claim1 = sign_ed(&pair1, HOP_CLAIM_CONTEXT, &hash);
2152 let ack1 = sign_ed(&pair1, HOP_ACK_CONTEXT, &hash);
2153 let claim2 = sign_ed(&pair2, HOP_CLAIM_CONTEXT, &hash);
2154 let ack2 = sign_ed(&pair2, HOP_ACK_CONTEXT, &hash);
2155
2156 assert_eq!(data, pool.claim(&hash, &claim1).unwrap());
2157 pool.ack(&hash, &ack1).unwrap();
2158 assert!(pool.has(&hash));
2159
2160 assert_eq!(data, pool.claim(&hash, &claim2).unwrap());
2161 pool.ack(&hash, &ack2).unwrap();
2162 assert!(!pool.has(&hash));
2163 assert_eq!(pool.status().total_bytes, 0);
2164 }
2165
2166 #[test]
2167 fn test_concurrent_inserts_respect_capacity() {
2168 use std::{sync::Barrier, thread};
2169
2170 let (_, signer) = test_recipient();
2171 let (pool, _dir) = make_pool(acct(50, 1) * 4, 100);
2173 let pool = Arc::new(pool);
2174 let barrier = Arc::new(Barrier::new(10));
2175
2176 let handles: Vec<_> = (0..10u8)
2177 .map(|i| {
2178 let pool = pool.clone();
2179 let signer = signer.clone();
2180 let barrier = barrier.clone();
2181 thread::spawn(move || {
2182 barrier.wait();
2183 pool.insert(
2184 vec![i; 50],
2185 bv(vec![signer]),
2186 SENDER_A,
2187 dummy_auth().0,
2188 dummy_auth().1,
2189 0,
2190 )
2191 })
2192 })
2193 .collect();
2194
2195 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
2196 let successes = results.iter().filter(|r| r.is_ok()).count();
2197
2198 assert!(successes <= 4, "Got {} successes, max should be 4", successes);
2199 assert!(pool.status().total_bytes <= acct(50, 1) * 4);
2200 }
2201
2202 #[test]
2203 fn test_concurrent_inserts_respect_user_quota() {
2204 use std::{sync::Barrier, thread};
2205
2206 let (_, signer) = test_recipient();
2207 let per_entry = acct(100, 1);
2210 let (pool, _dir) = make_pool_with_user_cap(per_entry * 20, per_entry * 3, 100);
2211 let pool = Arc::new(pool);
2212 let barrier = Arc::new(Barrier::new(10));
2213
2214 let handles: Vec<_> = (0..10u8)
2215 .map(|i| {
2216 let pool = pool.clone();
2217 let signer = signer.clone();
2218 let barrier = barrier.clone();
2219 thread::spawn(move || {
2220 barrier.wait();
2221 pool.insert(
2222 vec![i; 100],
2223 bv(vec![signer]),
2224 SENDER_A,
2225 dummy_auth().0,
2226 dummy_auth().1,
2227 0,
2228 )
2229 })
2230 })
2231 .collect();
2232
2233 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
2234 let successes = results.iter().filter(|r| r.is_ok()).count();
2235
2236 assert!(successes <= 3, "hard per-user cap violated: {} successes", successes);
2238 assert!(user_usage(&pool, &SENDER_A) <= per_entry * 3);
2239 }
2240
2241 #[test]
2242 fn test_concurrent_claim_and_ack() {
2243 use std::{sync::Barrier, thread};
2244
2245 let (pool, _dir) = create_test_pool();
2246 let pool = Arc::new(pool);
2247
2248 let pairs: Vec<_> = (1..=5u8)
2249 .map(|i| {
2250 let pair = ed25519::Pair::from_seed(&[i; 32]);
2251 let signer = MultiSigner::Ed25519(pair.public());
2252 (pair, signer)
2253 })
2254 .collect();
2255
2256 let signers: Vec<_> = pairs.iter().map(|(_, s)| s.clone()).collect();
2257 let data = vec![42u8; 100];
2258 let hash = pool
2259 .insert(data.clone(), bv(signers), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2260 .unwrap();
2261
2262 let barrier = Arc::new(Barrier::new(5));
2263
2264 let handles: Vec<_> = pairs
2265 .into_iter()
2266 .map(|(pair, _)| {
2267 let pool = pool.clone();
2268 let barrier = barrier.clone();
2269 let data = data.clone();
2270 thread::spawn(move || {
2271 barrier.wait();
2272 let claim = sign_ed(&pair, HOP_CLAIM_CONTEXT, &hash);
2273 let ack = sign_ed(&pair, HOP_ACK_CONTEXT, &hash);
2274
2275 let claimed = pool.claim(&hash, &claim).unwrap();
2276 assert_eq!(data, claimed);
2277 pool.ack(&hash, &ack).unwrap();
2278 })
2279 })
2280 .collect();
2281
2282 for h in handles {
2283 h.join().unwrap();
2284 }
2285
2286 assert!(!pool.has(&hash));
2287 assert_eq!(pool.status().total_bytes, 0);
2288 }
2289
2290 #[test]
2291 fn test_concurrent_duplicate_insert_preserves_files() {
2292 use std::{sync::Barrier, thread};
2293
2294 let (kp, signer) = test_recipient();
2298 let (pool, _dir) = make_pool(1024 * 1024, 100);
2299 let pool = Arc::new(pool);
2300 let data = vec![0xABu8; 4096];
2301 let barrier = Arc::new(Barrier::new(2));
2302
2303 let handles: Vec<_> = (0..2)
2304 .map(|_| {
2305 let pool = pool.clone();
2306 let barrier = barrier.clone();
2307 let signer = signer.clone();
2308 let data = data.clone();
2309 thread::spawn(move || {
2310 barrier.wait();
2311 pool.insert(data, bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2312 })
2313 })
2314 .collect();
2315 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
2316
2317 let oks: Vec<_> = results.iter().filter_map(|r| r.as_ref().ok()).collect();
2318 let dupes = results.iter().filter(|r| matches!(r, Err(HopError::DuplicateEntry))).count();
2319 assert_eq!(oks.len(), 1, "exactly one insert must win the race");
2320 assert_eq!(dupes, 1, "the other must report DuplicateEntry");
2321
2322 let hash = *oks[0];
2323 let sig = sign_ed(&kp, HOP_CLAIM_CONTEXT, &hash);
2324 let claimed = pool.claim(&hash, &sig).expect("claim must succeed");
2325 assert_eq!(claimed, data);
2326 }
2327
2328 #[test]
2329 fn test_concurrent_duplicate_insert_keeps_winner_meta_on_disk() {
2330 use std::{sync::Barrier, thread};
2331
2332 let dir = TempDir::new().unwrap();
2336 let pool = Arc::new(
2337 HopDataPool::new(
2338 1024 * 1024,
2339 1024 * 1024,
2340 100,
2341 dir.path().to_path_buf(),
2342 RateLimitConfig::disabled(),
2343 HopMetrics::disabled(),
2344 )
2345 .unwrap(),
2346 );
2347
2348 let signer_a = MultiSigner::Ed25519(ed25519::Pair::from_seed(&[11u8; 32]).public());
2349 let signer_b = MultiSigner::Ed25519(ed25519::Pair::from_seed(&[22u8; 32]).public());
2350 let auth_a = dummy_auth();
2351 let auth_b = {
2352 let pair = ed25519::Pair::from_seed(&[33u8; 32]);
2353 (MultiSigner::Ed25519(pair.public()), MultiSignature::Ed25519(pair.sign(b"x")))
2354 };
2355 let data = vec![0xCDu8; 4096];
2356
2357 let barrier = Arc::new(Barrier::new(2));
2358 let (p1, d1, b1, s1, a1) =
2359 (pool.clone(), data.clone(), barrier.clone(), signer_a.clone(), auth_a.clone());
2360 let h1 = thread::spawn(move || {
2361 b1.wait();
2362 p1.insert(d1, bv(vec![s1]), SENDER_A, a1.0, a1.1, 0)
2363 });
2364 let (p2, d2, b2, s2, a2) =
2365 (pool.clone(), data.clone(), barrier.clone(), signer_b.clone(), auth_b.clone());
2366 let h2 = thread::spawn(move || {
2367 b2.wait();
2368 p2.insert(d2, bv(vec![s2]), SENDER_B, a2.0, a2.1, 0)
2369 });
2370
2371 let r1 = h1.join().unwrap();
2372 let r2 = h2.join().unwrap();
2373
2374 let (winner_hash, winner_sender_auth) = match (&r1, &r2) {
2375 (Ok(h), Err(HopError::DuplicateEntry)) => (*h, auth_a.0.clone()),
2376 (Err(HopError::DuplicateEntry), Ok(h)) => (*h, auth_b.0.clone()),
2377 other => panic!("expected exactly one winner and one DuplicateEntry, got {other:?}"),
2378 };
2379
2380 drop(pool);
2383 let pool2 = HopDataPool::new(
2384 1024 * 1024,
2385 1024 * 1024,
2386 100,
2387 dir.path().to_path_buf(),
2388 RateLimitConfig::disabled(),
2389 HopMetrics::disabled(),
2390 )
2391 .unwrap();
2392
2393 let (_data, recovered_auth_signer, _sig, _ts) =
2394 pool2.get_with_auth(&winner_hash).expect("winner's entry must survive restart");
2395 assert_eq!(
2396 recovered_auth_signer, winner_sender_auth,
2397 "meta in parity-db diverged from the winning insert; loser's meta overwrote the winner's",
2398 );
2399 }
2400
2401 #[test]
2402 fn test_saturating_release_concurrent_no_underflow() {
2403 use std::{sync::Barrier, thread};
2404
2405 const THREADS: u64 = 32;
2410 const RELEASE_PER_THREAD: u64 = 7;
2411 let counter = Arc::new(AtomicU64::new(THREADS * RELEASE_PER_THREAD));
2412 let barrier = Arc::new(Barrier::new(THREADS as usize));
2413
2414 let handles: Vec<_> = (0..THREADS)
2415 .map(|_| {
2416 let counter = counter.clone();
2417 let barrier = barrier.clone();
2418 thread::spawn(move || {
2419 barrier.wait();
2420 saturating_release(&counter, RELEASE_PER_THREAD);
2421 })
2422 })
2423 .collect();
2424 for h in handles {
2425 h.join().unwrap();
2426 }
2427
2428 assert_eq!(counter.load(Ordering::Relaxed), 0, "counter underflowed or did not reach zero");
2429
2430 saturating_release(&counter, u64::MAX);
2432 assert_eq!(counter.load(Ordering::Relaxed), 0);
2433 }
2434
2435 #[test]
2436 fn test_get_promotable_within_buffer() {
2437 let (pool, _dir) = make_pool(1024 * 1024, 3600);
2440 let (_, signer) = test_recipient();
2441
2442 let hash = pool
2443 .insert(vec![1, 2, 3], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2444 .unwrap();
2445
2446 let promotable = pool.get_promotable(50, 180, usize::MAX);
2448 assert!(promotable.is_empty());
2449
2450 let promotable = pool.get_promotable(0, 6000, usize::MAX);
2452 assert_eq!(promotable.len(), 1);
2453 assert_eq!(promotable[0], hash);
2454 }
2455
2456 #[test]
2457 fn test_get_promotable_excludes_promoted() {
2458 let (pool, _dir) = make_pool(1024 * 1024, 100);
2459 let (_, signer) = test_recipient();
2460
2461 let hash = pool
2462 .insert(vec![1, 2, 3], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2463 .unwrap();
2464
2465 let promotable = pool.get_promotable(80, 180, usize::MAX);
2466 assert_eq!(promotable.len(), 1);
2467
2468 pool.mark_promoted(&hash);
2469
2470 let promotable = pool.get_promotable(80, 180, usize::MAX);
2471 assert!(promotable.is_empty());
2472 }
2473
2474 #[test]
2475 fn test_mark_promoted_persists_across_restart() {
2476 let dir = TempDir::new().unwrap();
2477 let (_, signer) = test_recipient();
2478
2479 let hash;
2480 {
2481 let pool = HopDataPool::new(
2482 1024 * 1024,
2483 1024 * 1024,
2484 100,
2485 dir.path().to_path_buf(),
2486 RateLimitConfig::disabled(),
2487 HopMetrics::disabled(),
2488 )
2489 .unwrap();
2490 hash = pool
2491 .insert(
2492 vec![42u8; 10],
2493 bv(vec![signer]),
2494 SENDER_A,
2495 dummy_auth().0,
2496 dummy_auth().1,
2497 0,
2498 )
2499 .unwrap();
2500 pool.mark_promoted(&hash);
2501 }
2502
2503 {
2504 let pool = HopDataPool::new(
2505 1024 * 1024,
2506 1024 * 1024,
2507 100,
2508 dir.path().to_path_buf(),
2509 RateLimitConfig::disabled(),
2510 HopMetrics::disabled(),
2511 )
2512 .unwrap();
2513 let promotable = pool.get_promotable(80, 180, usize::MAX);
2514 assert!(promotable.is_empty(), "promoted entry should not be promotable after restart");
2515 assert!(pool.has(&hash), "entry should still exist");
2516 }
2517 }
2518
2519 #[test]
2520 fn test_cleanup_expired_removes_promoted() {
2521 let (pool, _dir) = make_pool(1024 * 1024, 0);
2522 let (_, signer) = test_recipient();
2523
2524 let hash = pool
2525 .insert(vec![1, 2, 3], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2526 .unwrap();
2527 pool.mark_promoted(&hash);
2528 assert!(pool.has(&hash));
2529
2530 let freed = pool.cleanup_expired(0);
2531 assert!(freed > 0);
2532 assert!(!pool.has(&hash));
2533 }
2534
2535 #[test]
2536 fn test_rate_limit_rejects_burst_overflow() {
2537 let dir = TempDir::new().unwrap();
2538 let cfg = RateLimitConfig {
2542 enabled: true,
2543 submit_rate_per_min: 60,
2544 submit_burst: 2,
2545 bandwidth_per_min: 1024 * 1024 * 60,
2546 bandwidth_burst: 1024 * 1024,
2547 };
2548 let pool = HopDataPool::new(
2549 1024 * 1024,
2550 1024 * 1024,
2551 100,
2552 dir.path().to_path_buf(),
2553 cfg,
2554 HopMetrics::disabled(),
2555 )
2556 .unwrap();
2557 let (_, signer) = test_recipient();
2558
2559 pool.insert(
2560 vec![1, 2, 3],
2561 bv(vec![signer.clone()]),
2562 SENDER_A,
2563 dummy_auth().0,
2564 dummy_auth().1,
2565 0,
2566 )
2567 .unwrap();
2568 pool.insert(
2569 vec![4, 5, 6],
2570 bv(vec![signer.clone()]),
2571 SENDER_A,
2572 dummy_auth().0,
2573 dummy_auth().1,
2574 0,
2575 )
2576 .unwrap();
2577 assert!(matches!(
2578 pool.insert(
2579 vec![7, 8, 9],
2580 bv(vec![signer]),
2581 SENDER_A,
2582 dummy_auth().0,
2583 dummy_auth().1,
2584 0,
2585 ),
2586 Err(HopError::RateLimited { .. })
2587 ));
2588 }
2589
2590 #[test]
2591 fn test_meta_version_mismatch_rejected() {
2592 let dir = TempDir::new().unwrap();
2597
2598 {
2600 let _pool = HopDataPool::new(
2601 1024 * 1024,
2602 1024 * 1024,
2603 100,
2604 dir.path().to_path_buf(),
2605 RateLimitConfig::disabled(),
2606 HopMetrics::disabled(),
2607 )
2608 .unwrap();
2609 }
2610
2611 let (_, signer) = test_recipient();
2612 let recipients = bv(vec![signer.clone()]);
2613 let mut meta =
2614 HopEntryMeta::new(100, 0, recipients, SENDER_A, dummy_auth().0, dummy_auth().1, 0);
2615 meta.version = 0;
2616
2617 let fake_hash = H256([0xeeu8; 32]);
2618 let blob_shard = dir.path().join(BLOBS_DIR).join("ee");
2619 fs::create_dir_all(&blob_shard).unwrap();
2620 let blob_path = blob_shard.join(format!("{}.blob", hex::encode(fake_hash)));
2621 fs::write(&blob_path, b"x").unwrap();
2622
2623 {
2624 let db_path = dir.path().join(META_DB_DIR);
2625 let db = parity_db::Db::open_or_create(&HopDataPool::db_options(&db_path)).unwrap();
2626 db.commit([(COL_META, fake_hash.as_bytes().to_vec(), Some(meta.encode()))])
2627 .unwrap();
2628 }
2629
2630 let pool = HopDataPool::new(
2631 1024 * 1024,
2632 1024 * 1024,
2633 100,
2634 dir.path().to_path_buf(),
2635 RateLimitConfig::disabled(),
2636 HopMetrics::disabled(),
2637 )
2638 .unwrap();
2639 assert!(!pool.has(&fake_hash), "stale-version row should be dropped");
2640 assert!(!blob_path.exists(), "matching .blob should also be removed");
2641 assert_eq!(pool.status().entry_count, 0);
2642 }
2643
2644 #[test]
2645 fn test_meta_row_without_blob_dropped_on_recovery() {
2646 let dir = TempDir::new().unwrap();
2652
2653 {
2655 let _pool = HopDataPool::new(
2656 1024 * 1024,
2657 1024 * 1024,
2658 100,
2659 dir.path().to_path_buf(),
2660 RateLimitConfig::disabled(),
2661 HopMetrics::disabled(),
2662 )
2663 .unwrap();
2664 }
2665
2666 let (_, signer) = test_recipient();
2667 let recipients = bv(vec![signer]);
2668 let meta =
2671 HopEntryMeta::new(100, 0, recipients, SENDER_A, dummy_auth().0, dummy_auth().1, 0);
2672 assert_eq!(meta.version, HOP_META_VERSION);
2673
2674 let fake_hash = H256([0xabu8; 32]);
2676 {
2677 let db_path = dir.path().join(META_DB_DIR);
2678 let mut opts = parity_db::Options::with_columns(&db_path, COL_COUNT);
2679 opts.columns[COL_META as usize].btree_index = true;
2680 let db = parity_db::Db::open_or_create(&opts).unwrap();
2681 db.commit([(COL_META, fake_hash.as_bytes().to_vec(), Some(meta.encode()))])
2682 .unwrap();
2683 }
2684
2685 let pool = HopDataPool::new(
2686 1024 * 1024,
2687 1024 * 1024,
2688 100,
2689 dir.path().to_path_buf(),
2690 RateLimitConfig::disabled(),
2691 HopMetrics::disabled(),
2692 )
2693 .unwrap();
2694
2695 assert!(!pool.has(&fake_hash), "meta row with no blob should be dropped");
2696 assert_eq!(pool.status().entry_count, 0, "dropped row must not count toward entries");
2697 assert_eq!(pool.status().total_bytes, 0, "dropped row must not hold pool quota");
2698 assert!(matches!(pool.fetch_meta(&fake_hash), Ok(None)));
2700 }
2701
2702 #[test]
2703 fn test_metrics_track_insert_promotion_and_expiry() {
2704 let dir = TempDir::new().unwrap();
2706 let registry = prometheus_endpoint::Registry::new();
2707 let pool = HopDataPool::new(
2708 1024 * 1024,
2709 1024 * 1024,
2710 0, dir.path().to_path_buf(),
2713 RateLimitConfig::disabled(),
2714 HopMetrics::new(Some(®istry)).unwrap(),
2715 )
2716 .unwrap();
2717 let (_, signer) = test_recipient();
2718
2719 let hash = pool
2721 .insert(
2722 vec![1u8; 50],
2723 bv(vec![signer.clone()]),
2724 SENDER_A,
2725 dummy_auth().0,
2726 dummy_auth().1,
2727 0,
2728 )
2729 .unwrap();
2730 assert_eq!(pool.metrics().pool_gauges(), (1, acct(50, 1)));
2731
2732 pool.mark_promoted(&hash);
2734 pool.mark_promoted(&hash);
2735 assert_eq!(pool.metrics().promotions_confirmed(), 1);
2736
2737 pool.cleanup_expired(0);
2739 assert_eq!(pool.metrics().removed_count(removal_reasons::EXPIRED_PROMOTED), 1);
2740 assert_eq!(pool.metrics().pool_gauges(), (0, 0));
2741
2742 pool.insert(vec![2u8; 30], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2744 .unwrap();
2745 pool.cleanup_expired(0);
2746 assert_eq!(pool.metrics().removed_count(removal_reasons::EXPIRED_UNPROMOTED), 1);
2747 assert_eq!(pool.metrics().pool_gauges(), (0, 0));
2748 }
2749
2750 #[test]
2751 fn test_metrics_record_startup_dropped() {
2752 let dir = TempDir::new().unwrap();
2756 {
2757 let _pool = HopDataPool::new(
2758 1024 * 1024,
2759 1024 * 1024,
2760 100,
2761 dir.path().to_path_buf(),
2762 RateLimitConfig::disabled(),
2763 HopMetrics::disabled(),
2764 )
2765 .unwrap();
2766 }
2767
2768 let (_, signer) = test_recipient();
2769 let meta = HopEntryMeta::new(
2770 100,
2771 0,
2772 bv(vec![signer]),
2773 SENDER_A,
2774 dummy_auth().0,
2775 dummy_auth().1,
2776 0,
2777 );
2778 let fake_hash = H256([0xcdu8; 32]);
2779 {
2780 let db_path = dir.path().join(META_DB_DIR);
2781 let mut opts = parity_db::Options::with_columns(&db_path, COL_COUNT);
2782 opts.columns[COL_META as usize].btree_index = true;
2783 let db = parity_db::Db::open_or_create(&opts).unwrap();
2784 db.commit([(COL_META, fake_hash.as_bytes().to_vec(), Some(meta.encode()))])
2785 .unwrap();
2786 }
2787
2788 let registry = prometheus_endpoint::Registry::new();
2789 let pool = HopDataPool::new(
2790 1024 * 1024,
2791 1024 * 1024,
2792 100,
2793 dir.path().to_path_buf(),
2794 RateLimitConfig::disabled(),
2795 HopMetrics::new(Some(®istry)).unwrap(),
2796 )
2797 .unwrap();
2798
2799 assert_eq!(pool.metrics().removed_count(removal_reasons::STARTUP_DROPPED), 1);
2800 assert_eq!(pool.metrics().pool_gauges(), (0, 0));
2801 }
2802
2803 #[test]
2804 fn test_promotion_backoff_skips_until_due_then_gives_up() {
2805 use crate::types::MAX_PROMOTION_ATTEMPTS;
2806
2807 let (pool, _dir) = make_pool(1024 * 1024, 100);
2808 let (_, signer) = test_recipient();
2809 let hash = pool
2810 .insert(vec![1u8; 100], bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2811 .unwrap();
2812
2813 let buffer = 300_u64;
2816 let current = 60;
2817 assert_eq!(pool.get_promotable(current, buffer, 10), vec![hash]);
2818
2819 let check_interval_blocks: u32 = 10;
2821 pool.record_promotion_attempt(&hash, current, check_interval_blocks);
2822 assert!(
2823 pool.get_promotable(current, buffer, 10).is_empty(),
2824 "entry should be skipped until back-off elapses"
2825 );
2826 assert_eq!(pool.get_promotable(current + 10, buffer, 10), vec![hash]);
2827
2828 let mut now = current + 10;
2832 for next_attempt in 2..=MAX_PROMOTION_ATTEMPTS {
2833 pool.record_promotion_attempt(&hash, now, check_interval_blocks);
2834 let shift = (next_attempt - 1).min(5);
2835 let backoff = check_interval_blocks << shift;
2836 now += backoff;
2837 }
2838 assert!(
2839 pool.get_promotable(now + 10_000, buffer, 10).is_empty(),
2840 "entry should give up after MAX_PROMOTION_ATTEMPTS"
2841 );
2842 }
2843
2844 #[test]
2845 fn test_legacy_meta_files_migrated() {
2846 let dir = TempDir::new().unwrap();
2850 let data_a = vec![1u8; 100];
2851 let data_b = vec![2u8; 250];
2852 let hash_a =
2853 write_legacy_entry(dir.path(), &data_a, &legacy_meta(&data_a, SENDER_A).encode());
2854 let hash_b =
2855 write_legacy_entry(dir.path(), &data_b, &legacy_meta(&data_b, SENDER_B).encode());
2856
2857 let pool = HopDataPool::new(
2858 1024 * 1024,
2859 1024 * 1024,
2860 100,
2861 dir.path().to_path_buf(),
2862 RateLimitConfig::disabled(),
2863 HopMetrics::disabled(),
2864 )
2865 .unwrap();
2866
2867 assert!(pool.has(&hash_a));
2868 assert!(pool.has(&hash_b));
2869 assert_eq!(pool.get(&hash_a).unwrap(), data_a);
2870 assert_eq!(pool.get(&hash_b).unwrap(), data_b);
2871 assert_eq!(pool.status().entry_count, 2);
2872 assert_eq!(pool.status().total_bytes, acct(100, 1) + acct(250, 1));
2873 assert_eq!(user_usage(&pool, &SENDER_A), acct(100, 1));
2874 assert_eq!(user_usage(&pool, &SENDER_B), acct(250, 1));
2875 assert!(!dir.path().join(LEGACY_META_DIR).exists());
2877 }
2878
2879 #[test]
2880 fn test_legacy_meta_survives_second_restart() {
2881 let dir = TempDir::new().unwrap();
2884 let data = vec![7u8; 64];
2885 let hash = write_legacy_entry(dir.path(), &data, &legacy_meta(&data, SENDER_A).encode());
2886
2887 for _ in 0..2 {
2888 let pool = HopDataPool::new(
2889 1024 * 1024,
2890 1024 * 1024,
2891 100,
2892 dir.path().to_path_buf(),
2893 RateLimitConfig::disabled(),
2894 HopMetrics::disabled(),
2895 )
2896 .unwrap();
2897 assert!(pool.has(&hash));
2898 assert_eq!(pool.get(&hash).unwrap(), data);
2899 assert_eq!(pool.status().entry_count, 1);
2900 assert_eq!(pool.status().total_bytes, acct(64, 1));
2901 }
2902 }
2903
2904 #[test]
2905 fn test_legacy_meta_without_blob_skipped() {
2906 let dir = TempDir::new().unwrap();
2907 let orphan_hash = H256([0x5au8; 32]);
2908 write_legacy_meta_only(dir.path(), &orphan_hash, &legacy_meta(&[], SENDER_A).encode());
2909
2910 let pool = HopDataPool::new(
2911 1024 * 1024,
2912 1024 * 1024,
2913 100,
2914 dir.path().to_path_buf(),
2915 RateLimitConfig::disabled(),
2916 HopMetrics::disabled(),
2917 )
2918 .unwrap();
2919
2920 assert!(!pool.has(&orphan_hash));
2921 assert_eq!(pool.status().entry_count, 0);
2922 assert!(!dir.path().join(LEGACY_META_DIR).exists());
2923 }
2924
2925 #[test]
2926 fn test_legacy_meta_version_mismatch_dropped() {
2927 let dir = TempDir::new().unwrap();
2930 let data = vec![3u8; 32];
2931 let mut meta = legacy_meta(&data, SENDER_A);
2932 meta.version = 0;
2933 let hash = write_legacy_entry(dir.path(), &data, &meta.encode());
2934 let blob_path = HopDataPool::entry_path(dir.path(), &hash, BLOBS_DIR, BLOB_EXT);
2935 assert!(blob_path.exists());
2936
2937 let pool = HopDataPool::new(
2938 1024 * 1024,
2939 1024 * 1024,
2940 100,
2941 dir.path().to_path_buf(),
2942 RateLimitConfig::disabled(),
2943 HopMetrics::disabled(),
2944 )
2945 .unwrap();
2946
2947 assert!(!pool.has(&hash));
2948 assert_eq!(pool.status().entry_count, 0);
2949 assert!(!blob_path.exists());
2950 assert!(!dir.path().join(LEGACY_META_DIR).exists());
2951 }
2952
2953 #[test]
2954 fn test_legacy_meta_corrupt_file_skipped() {
2955 let dir = TempDir::new().unwrap();
2957 let data = vec![9u8; 16];
2958 let hash = write_legacy_entry(dir.path(), &data, b"not valid SCALE data");
2959
2960 let pool = HopDataPool::new(
2961 1024 * 1024,
2962 1024 * 1024,
2963 100,
2964 dir.path().to_path_buf(),
2965 RateLimitConfig::disabled(),
2966 HopMetrics::disabled(),
2967 )
2968 .unwrap();
2969
2970 assert!(!pool.has(&hash));
2971 assert_eq!(pool.status().entry_count, 0);
2972 assert!(!dir.path().join(LEGACY_META_DIR).exists());
2973 }
2974
2975 #[test]
2976 fn test_legacy_meta_does_not_clobber_existing_row() {
2977 let dir = TempDir::new().unwrap();
2980 let data = vec![4u8; 48];
2981 let (_, signer) = test_recipient();
2982
2983 let hash;
2984 let live_expires_at;
2985 {
2986 let pool = HopDataPool::new(
2987 1024 * 1024,
2988 1024 * 1024,
2989 100,
2990 dir.path().to_path_buf(),
2991 RateLimitConfig::disabled(),
2992 HopMetrics::disabled(),
2993 )
2994 .unwrap();
2995 hash = pool
2996 .insert(data.clone(), bv(vec![signer]), SENDER_A, dummy_auth().0, dummy_auth().1, 0)
2997 .unwrap();
2998 live_expires_at = pool.fetch_meta(&hash).unwrap().unwrap().expires_at;
2999 }
3000
3001 let mut stale = legacy_meta(&data, SENDER_A);
3003 stale.expires_at = live_expires_at.wrapping_add(999_999);
3004 write_legacy_meta_only(dir.path(), &hash, &stale.encode());
3005
3006 let pool = HopDataPool::new(
3007 1024 * 1024,
3008 1024 * 1024,
3009 100,
3010 dir.path().to_path_buf(),
3011 RateLimitConfig::disabled(),
3012 HopMetrics::disabled(),
3013 )
3014 .unwrap();
3015
3016 assert!(pool.has(&hash));
3017 assert_eq!(pool.status().entry_count, 1);
3018 assert_eq!(pool.fetch_meta(&hash).unwrap().unwrap().expires_at, live_expires_at);
3019 assert!(!dir.path().join(LEGACY_META_DIR).exists());
3020 }
3021
3022 #[test]
3023 fn test_legacy_empty_meta_dir_removed() {
3024 let dir = TempDir::new().unwrap();
3027 for i in 0..SHARD_COUNT {
3028 fs::create_dir_all(dir.path().join(LEGACY_META_DIR).join(format!("{:02x}", i as u8)))
3029 .unwrap();
3030 }
3031
3032 let pool = HopDataPool::new(
3033 1024 * 1024,
3034 1024 * 1024,
3035 100,
3036 dir.path().to_path_buf(),
3037 RateLimitConfig::disabled(),
3038 HopMetrics::disabled(),
3039 )
3040 .unwrap();
3041
3042 assert_eq!(pool.status().entry_count, 0);
3043 assert!(!dir.path().join(LEGACY_META_DIR).exists());
3044 }
3045
3046 #[test]
3047 fn test_db_version_stamped_on_fresh_pool() {
3048 let dir = TempDir::new().unwrap();
3049 {
3050 let _pool = HopDataPool::new(
3051 1024 * 1024,
3052 1024 * 1024,
3053 100,
3054 dir.path().to_path_buf(),
3055 RateLimitConfig::disabled(),
3056 HopMetrics::disabled(),
3057 )
3058 .unwrap();
3059 }
3060 assert_eq!(read_db_version(dir.path()), Some(CURRENT_DB_VERSION));
3061 }
3062
3063 #[test]
3064 fn test_future_db_version_rejected() {
3065 let dir = TempDir::new().unwrap();
3067 {
3068 let _pool = HopDataPool::new(
3069 1024 * 1024,
3070 1024 * 1024,
3071 100,
3072 dir.path().to_path_buf(),
3073 RateLimitConfig::disabled(),
3074 HopMetrics::disabled(),
3075 )
3076 .unwrap();
3077 }
3078 {
3079 let db = parity_db::Db::open_or_create(&HopDataPool::db_options(
3080 &dir.path().join(META_DB_DIR),
3081 ))
3082 .unwrap();
3083 db.commit([(
3084 COL_DB_META,
3085 KEY_DB_VERSION.to_vec(),
3086 Some((CURRENT_DB_VERSION + 1).to_le_bytes().to_vec()),
3087 )])
3088 .unwrap();
3089 }
3090
3091 let result = HopDataPool::new(
3092 1024 * 1024,
3093 1024 * 1024,
3094 100,
3095 dir.path().to_path_buf(),
3096 RateLimitConfig::disabled(),
3097 HopMetrics::disabled(),
3098 );
3099 assert!(matches!(result, Err(HopError::Db(_))), "future db version must be rejected");
3100 }
3101
3102 #[test]
3103 fn test_column_layout_migration() {
3104 let dir = TempDir::new().unwrap();
3107 let data = vec![8u8; 80];
3108 let hash = H256(blake2_256(&data));
3109 let blob_path = HopDataPool::entry_path(dir.path(), &hash, BLOBS_DIR, BLOB_EXT);
3110 fs::create_dir_all(blob_path.parent().unwrap()).unwrap();
3111 fs::write(&blob_path, &data).unwrap();
3112
3113 let db_path = dir.path().join(META_DB_DIR);
3114 fs::create_dir_all(&db_path).unwrap();
3115 {
3116 let mut options = parity_db::Options::with_columns(&db_path, 1);
3117 options.columns[COL_META as usize].btree_index = true;
3118 let db = parity_db::Db::open_or_create(&options).unwrap();
3119 db.commit([(
3120 COL_META,
3121 hash.as_bytes().to_vec(),
3122 Some(legacy_meta(&data, SENDER_A).encode()),
3123 )])
3124 .unwrap();
3125 }
3126
3127 let pool = HopDataPool::new(
3128 1024 * 1024,
3129 1024 * 1024,
3130 100,
3131 dir.path().to_path_buf(),
3132 RateLimitConfig::disabled(),
3133 HopMetrics::disabled(),
3134 )
3135 .unwrap();
3136
3137 assert!(pool.has(&hash));
3138 assert_eq!(pool.get(&hash).unwrap(), data);
3139 assert_eq!(pool.status().entry_count, 1);
3140 drop(pool);
3141 assert_eq!(read_db_version(dir.path()), Some(CURRENT_DB_VERSION));
3142 }
3143}