From 40385b519685b667cc7593f689e11a66be770194 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 06:02:16 +0000 Subject: [PATCH 1/4] Add comprehensive test coverage across all crates - oxidebt-cache: Add 47 new tests covering BlockCache, PieceCache (ARC), BufferPool, MemoryBudget, and HashState (previously had zero tests) - oxidebt-tracker: Add 20 tests for HTTP/UDP response parsing, URL conversion, protocol routing, and compact peer format edge cases - oxidebt-net: Add 15 tests for PEX IPv6, bandwidth limiting, rate limiting, and port mapping - oxidebt-dht: Add 14 tests for announce_peer, response parsing, malformed message handling, NodeId edge cases, and routing table - oxidebt-torrent: Add 20 tests for InfoHash error paths, Metainfo edge cases, MagnetLink errors, and MerkleTree - oxidebt-peer: Add 20 tests for extension handshake, metadata messages, PEX encoding, bitfield edge cases, PeerId, and FastExtension state - engine/stats: Add 10 tests for RateCalculator and TorrentStats https://claude.ai/code/session_014Z9cWnuBb6Gdu2bVqJzf6H --- src-tauri/crates/oxidebt-cache/src/lib.rs | 3 + src-tauri/crates/oxidebt-cache/src/tests.rs | 696 ++++++++++++++++++ src-tauri/crates/oxidebt-dht/src/tests.rs | 311 ++++++++ src-tauri/crates/oxidebt-net/src/tests.rs | 251 +++++++ src-tauri/crates/oxidebt-peer/src/tests.rs | 313 ++++++++ src-tauri/crates/oxidebt-torrent/src/tests.rs | 232 ++++++ src-tauri/crates/oxidebt-tracker/Cargo.toml | 1 + src-tauri/crates/oxidebt-tracker/src/http.rs | 6 +- src-tauri/crates/oxidebt-tracker/src/tests.rs | 524 +++++++++++++ src-tauri/crates/oxidebt-tracker/src/udp.rs | 4 +- src-tauri/src/engine/stats.rs | 124 ++++ 11 files changed, 2460 insertions(+), 5 deletions(-) create mode 100644 src-tauri/crates/oxidebt-cache/src/tests.rs diff --git a/src-tauri/crates/oxidebt-cache/src/lib.rs b/src-tauri/crates/oxidebt-cache/src/lib.rs index 9ef99d1..fa31749 100644 --- a/src-tauri/crates/oxidebt-cache/src/lib.rs +++ b/src-tauri/crates/oxidebt-cache/src/lib.rs @@ -7,3 +7,6 @@ pub use block_cache::{BlockCache, HashState}; pub use buffer_pool::BufferPool; pub use memory_budget::{MemoryBudget, MemoryPermit}; pub use piece_cache::PieceCache; + +#[cfg(test)] +mod tests; diff --git a/src-tauri/crates/oxidebt-cache/src/tests.rs b/src-tauri/crates/oxidebt-cache/src/tests.rs new file mode 100644 index 0000000..4bbb8d1 --- /dev/null +++ b/src-tauri/crates/oxidebt-cache/src/tests.rs @@ -0,0 +1,696 @@ +use super::*; +use crate::buffer_pool::{BLOCK_SIZE, BUFFER_POOL_BLOCKS, BUFFER_POOL_PIECES}; +use crate::memory_budget::{BLOCK_CACHE_RATIO, MAX_CACHE_MEMORY, PIECE_CACHE_RATIO}; +use bytes::Bytes; + +// ======================== +// BlockCache tests +// ======================== + +#[test] +fn test_block_cache_new() { + let cache = BlockCache::new(1024 * 1024); + assert_eq!(cache.memory_used(), 0); + assert_eq!(cache.memory_limit(), 1024 * 1024); + assert_eq!(cache.pieces_count(), 0); +} + +#[test] +fn test_block_cache_add_single_block() { + let cache = BlockCache::new(1024 * 1024); + let data = Bytes::from(vec![0xAB; 16384]); + + // Piece of 16384 bytes = 1 block, should be complete immediately + let complete = cache.add_block("hash1", 0, 0, data, 16384, 1); + assert!(complete); + assert_eq!(cache.memory_used(), 16384); + assert!(cache.has_piece("hash1", 0)); + assert!(cache.is_piece_complete("hash1", 0)); +} + +#[test] +fn test_block_cache_add_multiple_blocks() { + let cache = BlockCache::new(1024 * 1024); + let piece_length = 16384 * 3; // 3 blocks + + // Add first block - not complete yet + let complete = cache.add_block( + "hash1", + 0, + 0, + Bytes::from(vec![1u8; 16384]), + piece_length, + 1, + ); + assert!(!complete); + assert!(!cache.is_piece_complete("hash1", 0)); + + // Add second block + let complete = cache.add_block( + "hash1", + 0, + 16384, + Bytes::from(vec![2u8; 16384]), + piece_length, + 1, + ); + assert!(!complete); + + // Add third block - now complete + let complete = cache.add_block( + "hash1", + 0, + 32768, + Bytes::from(vec![3u8; 16384]), + piece_length, + 1, + ); + assert!(complete); + assert!(cache.is_piece_complete("hash1", 0)); + assert_eq!(cache.memory_used(), piece_length as usize); +} + +#[test] +fn test_block_cache_get_assembled_piece() { + let cache = BlockCache::new(1024 * 1024); + let piece_length = 16384 * 2; + + cache.add_block( + "hash1", + 0, + 0, + Bytes::from(vec![0xAA; 16384]), + piece_length, + 1, + ); + cache.add_block( + "hash1", + 0, + 16384, + Bytes::from(vec![0xBB; 16384]), + piece_length, + 1, + ); + + let assembled = cache.get_assembled_piece("hash1", 0).unwrap(); + assert_eq!(assembled.len(), piece_length as usize); + assert!(assembled[..16384].iter().all(|&b| b == 0xAA)); + assert!(assembled[16384..].iter().all(|&b| b == 0xBB)); +} + +#[test] +fn test_block_cache_get_assembled_piece_missing() { + let cache = BlockCache::new(1024 * 1024); + assert!(cache.get_assembled_piece("nonexistent", 0).is_none()); +} + +#[test] +fn test_block_cache_remove_piece() { + let cache = BlockCache::new(1024 * 1024); + cache.add_block("hash1", 0, 0, Bytes::from(vec![0u8; 16384]), 16384, 1); + + assert_eq!(cache.memory_used(), 16384); + let removed = cache.remove_piece("hash1", 0); + assert!(removed.is_some()); + assert_eq!(removed.unwrap().len(), 16384); + assert_eq!(cache.memory_used(), 0); + assert!(!cache.has_piece("hash1", 0)); +} + +#[test] +fn test_block_cache_remove_nonexistent() { + let cache = BlockCache::new(1024 * 1024); + assert!(cache.remove_piece("nonexistent", 0).is_none()); +} + +#[test] +fn test_block_cache_coalesced_regions_contiguous() { + let cache = BlockCache::new(1024 * 1024); + let piece_length = 16384 * 3; + + // Add contiguous blocks in order + cache.add_block( + "hash1", + 0, + 0, + Bytes::from(vec![1u8; 16384]), + piece_length, + 1, + ); + cache.add_block( + "hash1", + 0, + 16384, + Bytes::from(vec![2u8; 16384]), + piece_length, + 1, + ); + cache.add_block( + "hash1", + 0, + 32768, + Bytes::from(vec![3u8; 16384]), + piece_length, + 1, + ); + + let regions = cache.get_coalesced_regions("hash1", 0); + // All contiguous, should be one region + assert_eq!(regions.len(), 1); + assert_eq!(regions[0].offset, 0); + assert_eq!(regions[0].data.len(), piece_length as usize); +} + +#[test] +fn test_block_cache_coalesced_regions_gap() { + let cache = BlockCache::new(1024 * 1024); + let piece_length = 16384 * 3; + + // Add blocks with gap (missing middle block) + cache.add_block( + "hash1", + 0, + 0, + Bytes::from(vec![1u8; 16384]), + piece_length, + 1, + ); + cache.add_block( + "hash1", + 0, + 32768, + Bytes::from(vec![3u8; 16384]), + piece_length, + 1, + ); + + let regions = cache.get_coalesced_regions("hash1", 0); + assert_eq!(regions.len(), 2); + assert_eq!(regions[0].offset, 0); + assert_eq!(regions[0].data.len(), 16384); + assert_eq!(regions[1].offset, 32768); + assert_eq!(regions[1].data.len(), 16384); +} + +#[test] +fn test_block_cache_coalesced_regions_empty() { + let cache = BlockCache::new(1024 * 1024); + let regions = cache.get_coalesced_regions("nonexistent", 0); + assert!(regions.is_empty()); +} + +#[test] +fn test_block_cache_finalize_and_verify_v1() { + use sha1::{Digest, Sha1}; + + let cache = BlockCache::new(1024 * 1024); + let data = vec![0xABu8; 16384]; + + // Compute expected hash + let mut hasher = Sha1::new(); + hasher.update(&data); + let expected_hash = hasher.finalize().to_vec(); + + cache.add_block("hash1", 0, 0, Bytes::from(data), 16384, 1); + + assert!(cache.finalize_and_verify("hash1", 0, &expected_hash)); +} + +#[test] +fn test_block_cache_finalize_and_verify_v2() { + use sha2::{Digest, Sha256}; + + let cache = BlockCache::new(1024 * 1024); + let data = vec![0xCDu8; 16384]; + + let mut hasher = Sha256::new(); + hasher.update(&data); + let expected_hash = hasher.finalize().to_vec(); + + cache.add_block("hash1", 0, 0, Bytes::from(data), 16384, 2); + + assert!(cache.finalize_and_verify("hash1", 0, &expected_hash)); +} + +#[test] +fn test_block_cache_finalize_wrong_hash() { + let cache = BlockCache::new(1024 * 1024); + cache.add_block( + "hash1", + 0, + 0, + Bytes::from(vec![0xAB; 16384]), + 16384, + 1, + ); + + let wrong_hash = vec![0u8; 20]; + assert!(!cache.finalize_and_verify("hash1", 0, &wrong_hash)); +} + +#[test] +fn test_block_cache_finalize_nonexistent() { + let cache = BlockCache::new(1024 * 1024); + assert!(!cache.finalize_and_verify("nonexistent", 0, &[0u8; 20])); +} + +#[test] +fn test_block_cache_finalize_multiblock_v1() { + use sha1::{Digest, Sha1}; + + let cache = BlockCache::new(1024 * 1024); + let piece_length = 16384 * 2; + let block1 = vec![0xAAu8; 16384]; + let block2 = vec![0xBBu8; 16384]; + + let mut hasher = Sha1::new(); + hasher.update(&block1); + hasher.update(&block2); + let expected_hash = hasher.finalize().to_vec(); + + // Add blocks in order so streaming hash can advance + cache.add_block("hash1", 0, 0, Bytes::from(block1), piece_length, 1); + cache.add_block( + "hash1", + 0, + 16384, + Bytes::from(block2), + piece_length, + 1, + ); + + assert!(cache.finalize_and_verify("hash1", 0, &expected_hash)); +} + +#[test] +fn test_block_cache_is_under_pressure() { + let cache = BlockCache::new(100); // Very small limit + + // Not under pressure at start + assert!(!cache.is_under_pressure()); + + // Add enough data to exceed 90% + cache.add_block("hash1", 0, 0, Bytes::from(vec![0u8; 91]), 91, 1); + assert!(cache.is_under_pressure()); +} + +#[test] +fn test_block_cache_multiple_pieces() { + let cache = BlockCache::new(1024 * 1024); + + cache.add_block("hash1", 0, 0, Bytes::from(vec![1u8; 16384]), 16384, 1); + cache.add_block("hash1", 1, 0, Bytes::from(vec![2u8; 16384]), 16384, 1); + cache.add_block("hash2", 0, 0, Bytes::from(vec![3u8; 16384]), 16384, 1); + + assert_eq!(cache.pieces_count(), 3); + assert!(cache.has_piece("hash1", 0)); + assert!(cache.has_piece("hash1", 1)); + assert!(cache.has_piece("hash2", 0)); + assert!(!cache.has_piece("hash2", 1)); +} + +#[test] +fn test_block_cache_clear() { + let cache = BlockCache::new(1024 * 1024); + cache.add_block("hash1", 0, 0, Bytes::from(vec![0u8; 16384]), 16384, 1); + cache.add_block("hash1", 1, 0, Bytes::from(vec![0u8; 16384]), 16384, 1); + + assert_eq!(cache.pieces_count(), 2); + assert!(cache.memory_used() > 0); + + cache.clear(); + assert_eq!(cache.pieces_count(), 0); + assert_eq!(cache.memory_used(), 0); +} + +#[test] +fn test_block_cache_duplicate_block() { + let cache = BlockCache::new(1024 * 1024); + + cache.add_block("hash1", 0, 0, Bytes::from(vec![1u8; 16384]), 16384, 1); + let mem_after_first = cache.memory_used(); + + // Re-add same block offset - should not double count memory + cache.add_block("hash1", 0, 0, Bytes::from(vec![2u8; 16384]), 16384, 1); + assert_eq!(cache.memory_used(), mem_after_first); +} + +// ======================== +// HashState tests +// ======================== + +#[test] +fn test_hash_state_v1() { + use sha1::{Digest, Sha1}; + + let mut state = HashState::new_v1(); + state.update(b"hello world"); + let result = state.finalize(); + + let mut expected = Sha1::new(); + expected.update(b"hello world"); + assert_eq!(result, expected.finalize().to_vec()); +} + +#[test] +fn test_hash_state_v2() { + use sha2::{Digest, Sha256}; + + let mut state = HashState::new_v2(); + state.update(b"hello world"); + let result = state.finalize(); + + let mut expected = Sha256::new(); + expected.update(b"hello world"); + assert_eq!(result, expected.finalize().to_vec()); +} + +// ======================== +// PieceCache tests (ARC algorithm) +// ======================== + +#[test] +fn test_piece_cache_new() { + let cache = PieceCache::new(10); + assert_eq!(cache.capacity(), 10); + assert_eq!(cache.len(), 0); + assert!(cache.is_empty()); + assert_eq!(cache.memory_used(), 0); +} + +#[test] +fn test_piece_cache_insert_and_get() { + let cache = PieceCache::new(10); + let data = Bytes::from(vec![0xAB; 1024]); + + cache.insert("hash1", 0, data.clone(), true); + assert_eq!(cache.len(), 1); + assert!(!cache.is_empty()); + assert!(cache.contains("hash1", 0)); + + let retrieved = cache.get("hash1", 0).unwrap(); + assert_eq!(retrieved, data); +} + +#[test] +fn test_piece_cache_get_nonexistent() { + let cache = PieceCache::new(10); + assert!(cache.get("hash1", 0).is_none()); + assert!(!cache.contains("hash1", 0)); +} + +#[test] +fn test_piece_cache_remove() { + let cache = PieceCache::new(10); + cache.insert("hash1", 0, Bytes::from(vec![0u8; 1024]), true); + + let removed = cache.remove("hash1", 0); + assert!(removed.is_some()); + assert_eq!(removed.unwrap().len(), 1024); + assert!(!cache.contains("hash1", 0)); + assert_eq!(cache.len(), 0); +} + +#[test] +fn test_piece_cache_remove_nonexistent() { + let cache = PieceCache::new(10); + assert!(cache.remove("hash1", 0).is_none()); +} + +#[test] +fn test_piece_cache_memory_tracking() { + let cache = PieceCache::new(10); + + cache.insert("hash1", 0, Bytes::from(vec![0u8; 1000]), true); + assert_eq!(cache.memory_used(), 1000); + + cache.insert("hash1", 1, Bytes::from(vec![0u8; 2000]), true); + assert_eq!(cache.memory_used(), 3000); + + cache.remove("hash1", 0); + assert_eq!(cache.memory_used(), 2000); +} + +#[test] +fn test_piece_cache_eviction_at_capacity() { + let cache = PieceCache::new(3); + + // Fill to capacity + cache.insert("hash1", 0, Bytes::from(vec![1u8; 100]), true); + cache.insert("hash1", 1, Bytes::from(vec![2u8; 100]), true); + cache.insert("hash1", 2, Bytes::from(vec![3u8; 100]), true); + assert_eq!(cache.len(), 3); + + // Add one more - should evict oldest + cache.insert("hash1", 3, Bytes::from(vec![4u8; 100]), true); + assert!(cache.len() <= 3); + + // Most recent should still be there + assert!(cache.contains("hash1", 3)); +} + +#[test] +fn test_piece_cache_promotion_on_hit() { + let cache = PieceCache::new(3); + + // Insert three items into T1 + cache.insert("hash1", 0, Bytes::from(vec![1u8; 100]), true); + cache.insert("hash1", 1, Bytes::from(vec![2u8; 100]), true); + cache.insert("hash1", 2, Bytes::from(vec![3u8; 100]), true); + + // Access item 0 - should promote from T1 to T2 + let _ = cache.get("hash1", 0); + + // Add new item - should evict from T1 (not item 0 which is now in T2) + cache.insert("hash1", 3, Bytes::from(vec![4u8; 100]), true); + + // Item 0 should survive (it was promoted to T2) + assert!(cache.contains("hash1", 0)); +} + +#[test] +fn test_piece_cache_clear() { + let cache = PieceCache::new(10); + cache.insert("hash1", 0, Bytes::from(vec![0u8; 1024]), true); + cache.insert("hash1", 1, Bytes::from(vec![0u8; 1024]), true); + + cache.clear(); + assert_eq!(cache.len(), 0); + assert_eq!(cache.memory_used(), 0); + assert!(cache.is_empty()); +} + +#[test] +fn test_piece_cache_multiple_torrents() { + let cache = PieceCache::new(10); + + cache.insert("hash_a", 0, Bytes::from(vec![1u8; 100]), true); + cache.insert("hash_b", 0, Bytes::from(vec![2u8; 200]), true); + cache.insert("hash_a", 1, Bytes::from(vec![3u8; 300]), true); + + assert_eq!(cache.len(), 3); + assert!(cache.contains("hash_a", 0)); + assert!(cache.contains("hash_b", 0)); + assert!(cache.contains("hash_a", 1)); + assert!(!cache.contains("hash_b", 1)); +} + +// ======================== +// BufferPool tests +// ======================== + +#[test] +fn test_buffer_pool_new() { + let pool = BufferPool::new(); + assert_eq!(pool.block_buffers_available(), BUFFER_POOL_BLOCKS); + assert_eq!(pool.piece_buffers_available(), BUFFER_POOL_PIECES); +} + +#[test] +fn test_buffer_pool_get_return_block() { + let pool = BufferPool::new(); + let initial = pool.block_buffers_available(); + + let buf = pool.get_block_buffer(); + assert!(buf.capacity() >= BLOCK_SIZE); + assert_eq!(pool.block_buffers_available(), initial - 1); + + pool.return_block_buffer(buf); + assert_eq!(pool.block_buffers_available(), initial); +} + +#[test] +fn test_buffer_pool_get_return_piece() { + let pool = BufferPool::new(); + let initial = pool.piece_buffers_available(); + + let buf = pool.get_piece_buffer(1024 * 1024); + assert!(buf.capacity() >= 1024 * 1024); + assert_eq!(pool.piece_buffers_available(), initial - 1); + + pool.return_piece_buffer(buf); + assert_eq!(pool.piece_buffers_available(), initial); +} + +#[test] +fn test_buffer_pool_exhaustion() { + let pool = BufferPool::new(); + + // Drain all block buffers + let mut bufs = Vec::new(); + for _ in 0..BUFFER_POOL_BLOCKS { + bufs.push(pool.get_block_buffer()); + } + assert_eq!(pool.block_buffers_available(), 0); + + // Should still work - allocates new buffer + let extra = pool.get_block_buffer(); + assert!(extra.capacity() >= BLOCK_SIZE); + + // Return all buffers + for buf in bufs { + pool.return_block_buffer(buf); + } + assert_eq!(pool.block_buffers_available(), BUFFER_POOL_BLOCKS); +} + +#[test] +fn test_buffer_pool_piece_small_request() { + let pool = BufferPool::new(); + + // Request smaller than default piece size - should reuse pooled buffer + let buf = pool.get_piece_buffer(1024); + assert!(buf.capacity() >= 1024); +} + +#[test] +fn test_buffer_pool_default() { + let pool = BufferPool::default(); + // Default creates empty pools (no pre-allocation) + assert_eq!(pool.block_buffers_available(), 0); + assert_eq!(pool.piece_buffers_available(), 0); + + // Should still work via fallback allocation + let buf = pool.get_block_buffer(); + assert!(buf.capacity() >= BLOCK_SIZE); +} + +// ======================== +// MemoryBudget tests +// ======================== + +#[test] +fn test_memory_budget_new() { + let budget = MemoryBudget::new(100 * 1024 * 1024); + assert_eq!(budget.total_limit(), 100 * 1024 * 1024); + assert_eq!(budget.current_usage(), 0); + assert!(!budget.is_under_pressure()); +} + +#[test] +fn test_memory_budget_capped_at_max() { + let budget = MemoryBudget::new(usize::MAX); + assert_eq!(budget.total_limit(), MAX_CACHE_MEMORY); +} + +#[test] +fn test_memory_budget_ratios() { + let total = 100 * 1024 * 1024; + let budget = MemoryBudget::new(total); + assert_eq!( + budget.block_cache_limit(), + (total as f32 * BLOCK_CACHE_RATIO) as usize + ); + assert_eq!( + budget.piece_cache_limit(), + (total as f32 * PIECE_CACHE_RATIO) as usize + ); +} + +#[test] +fn test_memory_budget_try_allocate_success() { + let budget = MemoryBudget::new(1024); + + let permit = budget.try_allocate(512); + assert!(permit.is_some()); + assert_eq!(budget.current_usage(), 512); + assert_eq!(permit.unwrap().bytes(), 512); +} + +#[test] +fn test_memory_budget_try_allocate_failure() { + let budget = MemoryBudget::new(1024); + + // Try to allocate more than limit + let permit = budget.try_allocate(2048); + assert!(permit.is_none()); + assert_eq!(budget.current_usage(), 0); +} + +#[test] +fn test_memory_budget_permit_drop_releases() { + let budget = MemoryBudget::new(1024); + + { + let _permit = budget.try_allocate(512).unwrap(); + assert_eq!(budget.current_usage(), 512); + } + // Permit dropped, memory should be released + assert_eq!(budget.current_usage(), 0); +} + +#[test] +fn test_memory_budget_multiple_permits() { + let budget = MemoryBudget::new(1024); + + let p1 = budget.try_allocate(256).unwrap(); + let p2 = budget.try_allocate(256).unwrap(); + assert_eq!(budget.current_usage(), 512); + + drop(p1); + assert_eq!(budget.current_usage(), 256); + + drop(p2); + assert_eq!(budget.current_usage(), 0); +} + +#[test] +fn test_memory_budget_permit_resize() { + let budget = MemoryBudget::new(1024); + let mut permit = budget.try_allocate(256).unwrap(); + assert_eq!(budget.current_usage(), 256); + + // Grow + permit.resize(512); + assert_eq!(budget.current_usage(), 512); + assert_eq!(permit.bytes(), 512); + + // Shrink + permit.resize(128); + assert_eq!(budget.current_usage(), 128); + assert_eq!(permit.bytes(), 128); +} + +#[test] +fn test_memory_budget_is_under_pressure() { + let budget = MemoryBudget::new(100); + + let _p1 = budget.try_allocate(89); + assert!(!budget.is_under_pressure()); + + let _p2 = budget.try_allocate(2); + assert!(budget.is_under_pressure()); +} + +#[test] +fn test_memory_budget_allocate_exactly_at_limit() { + let budget = MemoryBudget::new(1024); + + let permit = budget.try_allocate(1024); + assert!(permit.is_some()); + assert_eq!(budget.current_usage(), 1024); + + // Can't allocate any more + let permit2 = budget.try_allocate(1); + assert!(permit2.is_none()); +} diff --git a/src-tauri/crates/oxidebt-dht/src/tests.rs b/src-tauri/crates/oxidebt-dht/src/tests.rs index 343f4f3..aca604c 100644 --- a/src-tauri/crates/oxidebt-dht/src/tests.rs +++ b/src-tauri/crates/oxidebt-dht/src/tests.rs @@ -1,4 +1,5 @@ use super::*; +use std::collections::BTreeMap; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; #[test] @@ -166,3 +167,313 @@ fn test_dht_message_get_peers() { panic!("missing query"); } } + +// ======================== +// announce_peer message encode/decode +// ======================== + +#[test] +fn test_dht_message_announce_peer() { + let our_id = NodeId::generate(); + let info_hash = [0xBB; 20]; + let tid = bytes::Bytes::from_static(b"dd"); + let token = bytes::Bytes::from_static(b"tok123"); + + let msg = DhtMessage::announce_peer(tid.clone(), &our_id, info_hash, 6881, token.clone()); + let encoded = msg.encode().unwrap(); + + let parsed = DhtMessage::parse(&encoded).unwrap(); + assert_eq!(parsed.transaction_id, tid); + + if let Some((name, query)) = parsed.query { + assert_eq!(name, "announce_peer"); + match query { + DhtQuery::AnnouncePeer { + info_hash: h, + port, + token: t, + implied_port, + } => { + assert_eq!(h, info_hash); + assert_eq!(port, 6881); + assert_eq!(t, token); + assert!(!implied_port); + } + _ => panic!("wrong query type"), + } + } else { + panic!("missing query"); + } +} + +// ======================== +// Response parsing +// ======================== + +#[test] +fn test_dht_parse_ping_response() { + let responder_id = NodeId::generate(); + let tid = bytes::Bytes::from_static(b"pp"); + + let msg = DhtMessage { + transaction_id: tid.clone(), + sender_id: Some(responder_id), + query: None, + response: Some(DhtResponse::Ping { id: responder_id }), + }; + + let encoded = msg.encode().unwrap(); + let parsed = DhtMessage::parse(&encoded).unwrap(); + + assert_eq!(parsed.transaction_id, tid); + assert!(parsed.query.is_none()); + assert!(parsed.response.is_some()); + + match parsed.response.unwrap() { + DhtResponse::Ping { id } => assert_eq!(id.0, responder_id.0), + _ => panic!("wrong response type"), + } +} + +#[test] +fn test_dht_parse_find_node_response() { + let responder_id = NodeId::generate(); + let tid = bytes::Bytes::from_static(b"fn"); + + let node_id = NodeId([42u8; 20]); + let node_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), 6881); + let node = Node::new(node_id, node_addr); + + let msg = DhtMessage { + transaction_id: tid.clone(), + sender_id: Some(responder_id), + query: None, + response: Some(DhtResponse::FindNode { + id: responder_id, + nodes: vec![node], + }), + }; + + let encoded = msg.encode().unwrap(); + let parsed = DhtMessage::parse(&encoded).unwrap(); + + match parsed.response.unwrap() { + DhtResponse::FindNode { id, nodes } => { + assert_eq!(id.0, responder_id.0); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].id.0, node_id.0); + } + _ => panic!("wrong response type"), + } +} + +#[test] +fn test_dht_parse_get_peers_with_values() { + let responder_id = NodeId::generate(); + let tid = bytes::Bytes::from_static(b"gp"); + let token = bytes::Bytes::from_static(b"secret"); + + let peer_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 51413); + + let msg = DhtMessage { + transaction_id: tid.clone(), + sender_id: Some(responder_id), + query: None, + response: Some(DhtResponse::GetPeers { + id: responder_id, + token: token.clone(), + peers: Some(vec![peer_addr]), + nodes: None, + }), + }; + + let encoded = msg.encode().unwrap(); + let parsed = DhtMessage::parse(&encoded).unwrap(); + + match parsed.response.unwrap() { + DhtResponse::GetPeers { + id, + token: t, + peers, + nodes, + } => { + assert_eq!(id.0, responder_id.0); + assert_eq!(t, token); + let peers = peers.unwrap(); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0], peer_addr); + assert!(nodes.is_none()); + } + _ => panic!("wrong response type"), + } +} + +#[test] +fn test_dht_parse_error_response() { + let tid = bytes::Bytes::from_static(b"er"); + + let msg = DhtMessage { + transaction_id: tid.clone(), + sender_id: None, + query: None, + response: Some(DhtResponse::Error { + code: 201, + message: "Generic Error".to_string(), + }), + }; + + let encoded = msg.encode().unwrap(); + let parsed = DhtMessage::parse(&encoded).unwrap(); + + match parsed.response.unwrap() { + DhtResponse::Error { code, message } => { + assert_eq!(code, 201); + assert_eq!(message, "Generic Error"); + } + _ => panic!("wrong response type"), + } +} + +// ======================== +// Malformed message handling +// ======================== + +#[test] +fn test_dht_parse_invalid_bencode() { + let result = DhtMessage::parse(b"not valid bencode"); + assert!(result.is_err()); +} + +#[test] +fn test_dht_parse_missing_transaction_id() { + let mut dict = BTreeMap::new(); + dict.insert( + bytes::Bytes::from_static(b"y"), + oxidebt_bencode::Value::string("q"), + ); + + let encoded = oxidebt_bencode::encode(&oxidebt_bencode::Value::Dict(dict)).unwrap(); + let result = DhtMessage::parse(&encoded); + assert!(result.is_err()); +} + +#[test] +fn test_dht_parse_missing_message_type() { + let mut dict = BTreeMap::new(); + dict.insert( + bytes::Bytes::from_static(b"t"), + oxidebt_bencode::Value::Bytes(bytes::Bytes::from_static(b"aa")), + ); + + let encoded = oxidebt_bencode::encode(&oxidebt_bencode::Value::Dict(dict)).unwrap(); + let result = DhtMessage::parse(&encoded); + assert!(result.is_err()); +} + +#[test] +fn test_dht_parse_unknown_message_type() { + let mut dict = BTreeMap::new(); + dict.insert( + bytes::Bytes::from_static(b"t"), + oxidebt_bencode::Value::Bytes(bytes::Bytes::from_static(b"aa")), + ); + dict.insert( + bytes::Bytes::from_static(b"y"), + oxidebt_bencode::Value::string("x"), + ); + + let encoded = oxidebt_bencode::encode(&oxidebt_bencode::Value::Dict(dict)).unwrap(); + let result = DhtMessage::parse(&encoded); + assert!(result.is_err()); +} + +// ======================== +// NodeId edge cases +// ======================== + +#[test] +fn test_node_id_bucket_index_adjacent() { + let id1 = NodeId([0u8; 20]); + let mut id2_bytes = [0u8; 20]; + id2_bytes[19] = 1; // Distance differs in last byte only + let id2 = NodeId(id2_bytes); + + // Bucket index should be 159 (last bit) + assert_eq!(id1.bucket_index(&id2), 159); +} + +#[test] +fn test_node_id_bucket_index_self() { + let id = NodeId([1u8; 20]); + // Distance with self is all zeros - implementation returns 159 as fallback + assert_eq!(id.bucket_index(&id), 159); +} + +// ======================== +// RoutingTable advanced tests +// ======================== + +#[test] +fn test_routing_table_find_closest_returns_sorted() { + let our_id = NodeId([0u8; 20]); + let table = RoutingTable::new(our_id); + + // Add nodes with known distances + for i in 0u8..10 { + let mut id_bytes = [0u8; 20]; + id_bytes[0] = i + 1; // Increasing distance from our_id (all zeros) + let id = NodeId(id_bytes); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, i + 1)), 6881); + table.add_node(Node::new(id, addr)); + } + + let target = NodeId([0u8; 20]); // same as our_id + let closest = table.find_closest(&target, 5); + assert!(closest.len() <= 5); + + // Verify sorted by distance (first byte determines distance here) + for pair in closest.windows(2) { + let d1 = target.distance(&pair[0].id); + let d2 = target.distance(&pair[1].id); + assert!(d1 <= d2, "Nodes should be sorted by distance to target"); + } +} + +#[test] +fn test_routing_table_node_count() { + let our_id = NodeId::generate(); + let table = RoutingTable::new(our_id); + + assert_eq!(table.node_count(), 0); + + for i in 0..5 { + let id = NodeId::generate(); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, i + 1)), 6881); + table.add_node(Node::new(id, addr)); + } + + assert!(table.node_count() >= 1); +} + +#[test] +fn test_node_failure_tracking() { + let id = NodeId::generate(); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 6881); + let mut node = Node::new(id, addr); + + // Fresh node is good + assert!(node.is_good()); + assert!(!node.is_bad()); + + // One failure doesn't make it bad + node.fail(); + assert!(!node.is_bad()); + + // Two failures doesn't make it bad + node.fail(); + assert!(!node.is_bad()); + + // Three failures makes it bad + node.fail(); + assert!(node.is_bad()); +} diff --git a/src-tauri/crates/oxidebt-net/src/tests.rs b/src-tauri/crates/oxidebt-net/src/tests.rs index ae206c8..04e9079 100644 --- a/src-tauri/crates/oxidebt-net/src/tests.rs +++ b/src-tauri/crates/oxidebt-net/src/tests.rs @@ -87,3 +87,254 @@ fn test_port_mapping_protocol() { let _tcp = Protocol::Tcp; let _udp = Protocol::Udp; } + +// ======================== +// PEX IPv6 encode/decode +// ======================== + +#[test] +fn test_pex_encode_decode_v6() { + let mut msg = PexMessage::new(); + msg.add_peer(PexPeer { + addr: SocketAddr::V6(std::net::SocketAddrV6::new( + std::net::Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1), + 6881, + 0, + 0, + )), + flags: PexFlags { + utp: true, + ..Default::default() + }, + }); + + let added6 = msg.encode_added6(); + let flags6 = msg.encode_added6_flags(); + + let decoded = PexMessage::decode_added6(&added6, &flags6); + assert_eq!(decoded.len(), 1); + assert!(decoded[0].addr.is_ipv6()); + assert_eq!(decoded[0].addr.port(), 6881); + assert!(decoded[0].flags.utp); +} + +#[test] +fn test_pex_dropped_v6() { + let mut msg = PexMessage::new(); + msg.drop_peer(SocketAddr::V6(std::net::SocketAddrV6::new( + std::net::Ipv6Addr::LOCALHOST, + 6881, + 0, + 0, + ))); + + let dropped6 = msg.encode_dropped6(); + let decoded = PexMessage::decode_dropped6(&dropped6); + assert_eq!(decoded.len(), 1); + assert!(decoded[0].is_ipv6()); + assert_eq!(decoded[0].port(), 6881); +} + +#[test] +fn test_pex_add_peer_routing() { + let mut msg = PexMessage::new(); + + // IPv4 peer should go to added + msg.add_peer(PexPeer::new(SocketAddr::V4(SocketAddrV4::new( + Ipv4Addr::new(1, 2, 3, 4), + 6881, + )))); + + // IPv6 peer should go to added6 + msg.add_peer(PexPeer::new(SocketAddr::V6(std::net::SocketAddrV6::new( + std::net::Ipv6Addr::LOCALHOST, + 6882, + 0, + 0, + )))); + + assert_eq!(msg.added.len(), 1); + assert_eq!(msg.added6.len(), 1); +} + +#[test] +fn test_pex_drop_peer_routing() { + let mut msg = PexMessage::new(); + + msg.drop_peer(SocketAddr::V4(SocketAddrV4::new( + Ipv4Addr::new(1, 2, 3, 4), + 6881, + ))); + msg.drop_peer(SocketAddr::V6(std::net::SocketAddrV6::new( + std::net::Ipv6Addr::LOCALHOST, + 6882, + 0, + 0, + ))); + + assert_eq!(msg.dropped.len(), 1); + assert_eq!(msg.dropped6.len(), 1); +} + +#[test] +fn test_pex_is_empty() { + let msg = PexMessage::new(); + assert!(msg.is_empty()); + + let mut msg2 = PexMessage::new(); + msg2.add_peer(PexPeer::new(SocketAddr::V4(SocketAddrV4::new( + Ipv4Addr::new(1, 2, 3, 4), + 6881, + )))); + assert!(!msg2.is_empty()); +} + +#[test] +fn test_pex_flags_all_set() { + let flags = PexFlags { + encryption: true, + seed: true, + utp: true, + holepunch: true, + connectable: true, + }; + + let byte = flags.to_byte(); + assert_eq!(byte, 0x01 | 0x02 | 0x04 | 0x08 | 0x10); + + let decoded = PexFlags::from_byte(byte); + assert_eq!(flags, decoded); +} + +#[test] +fn test_pex_flags_none_set() { + let flags = PexFlags::default(); + assert_eq!(flags.to_byte(), 0); + let decoded = PexFlags::from_byte(0); + assert_eq!(flags, decoded); +} + +#[test] +fn test_pex_peer_with_flags() { + let addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(1, 2, 3, 4), 6881)); + let flags = PexFlags { + seed: true, + ..Default::default() + }; + let peer = PexPeer::with_flags(addr, flags); + assert_eq!(peer.addr, addr); + assert!(peer.flags.seed); +} + +// ======================== +// BandwidthLimiter tests +// ======================== + +#[tokio::test] +async fn test_bandwidth_limiter_with_limits() { + let limiter = BandwidthLimiter::new(100_000, 50_000); + + // Small transfers should still be fast (within burst allowance) + let start = std::time::Instant::now(); + limiter.acquire_download(1000).await; + let elapsed = start.elapsed(); + assert!(elapsed.as_millis() < 100); +} + +#[tokio::test] +async fn test_bandwidth_limiter_set_limits() { + let mut limiter = BandwidthLimiter::unlimited(); + + limiter.set_download_limit(100_000); + limiter.set_upload_limit(50_000); + + // Should still work + let start = std::time::Instant::now(); + limiter.acquire_download(1000).await; + limiter.acquire_upload(1000).await; + let elapsed = start.elapsed(); + assert!(elapsed.as_millis() < 100); +} + +#[tokio::test] +async fn test_bandwidth_limiter_zero_means_unlimited() { + let mut limiter = BandwidthLimiter::new(100_000, 100_000); + + // Setting to 0 should make it unlimited + limiter.set_download_limit(0); + limiter.set_upload_limit(0); + + let start = std::time::Instant::now(); + limiter.acquire_download(1_000_000).await; + limiter.acquire_upload(1_000_000).await; + let elapsed = start.elapsed(); + assert!(elapsed.as_millis() < 100); +} + +#[tokio::test] +async fn test_rate_limiter_tokens_deplete() { + let limiter = RateLimiter::new(10_000); // 10KB/s + + // Consume all burst tokens (2 * rate = 20KB) + let _ = limiter.acquire(20_000).await; + + // Next acquire should require waiting + let wait = limiter.acquire(10_000).await; + assert!( + wait.as_millis() > 0, + "Should have to wait after depleting tokens" + ); +} + +#[tokio::test] +async fn test_rate_limiter_set_rate() { + let limiter = RateLimiter::new(10_000); + + // Change rate - should not panic + limiter.set_rate(1_000_000); + + // Acquire within the existing token budget (started with 20,000 tokens) + let wait = limiter.acquire(1_000).await; + assert!(wait.is_zero() || wait.as_millis() < 10); +} + +#[test] +fn test_bandwidth_limiter_get_limiters() { + let limiter = BandwidthLimiter::new(100_000, 50_000); + let dl = limiter.download_limiter(); + let ul = limiter.upload_limiter(); + assert!(dl.available() > 0); + assert!(ul.available() > 0); +} + +// ======================== +// PortMapper tests +// ======================== + +#[test] +fn test_port_mapper_new_not_available() { + let mapper = PortMapper::new(); + assert!(!mapper.is_available()); +} + +#[test] +fn test_protocol_equality() { + assert_eq!(Protocol::Tcp, Protocol::Tcp); + assert_eq!(Protocol::Udp, Protocol::Udp); + assert_ne!(Protocol::Tcp, Protocol::Udp); +} + +#[test] +fn test_port_mapping_struct() { + let mapping = PortMapping { + internal_port: 6881, + external_port: 6881, + protocol: Protocol::Tcp, + lifetime: 3600, + }; + + assert_eq!(mapping.internal_port, 6881); + assert_eq!(mapping.external_port, 6881); + assert_eq!(mapping.protocol, Protocol::Tcp); + assert_eq!(mapping.lifetime, 3600); +} diff --git a/src-tauri/crates/oxidebt-peer/src/tests.rs b/src-tauri/crates/oxidebt-peer/src/tests.rs index e364930..6e4d332 100644 --- a/src-tauri/crates/oxidebt-peer/src/tests.rs +++ b/src-tauri/crates/oxidebt-peer/src/tests.rs @@ -716,3 +716,316 @@ fn stress_test_piece_manager_progressive_verification() { assert_eq!(pm.verified_count(), 100); assert!(pm.is_verification_complete()); } + +// ======================== +// Extension Handshake tests +// ======================== + +#[test] +fn test_extension_handshake_encode_decode() { + let hs = ExtensionHandshake::new() + .with_metadata_size(12345) + .with_listen_port(6881); + + let encoded = hs.encode(); + let parsed = ExtensionHandshake::parse(&encoded).unwrap(); + + assert_eq!(parsed.metadata_size, Some(12345)); + assert_eq!(parsed.listen_port, Some(6881)); + assert_eq!(parsed.client, Some("oxidebt/0.1.0".to_string())); + assert!(parsed.ut_metadata.is_some()); + assert!(parsed.ut_pex.is_some()); + assert_eq!(parsed.reqq, Some(250)); +} + +#[test] +fn test_extension_handshake_default() { + let hs = ExtensionHandshake::default(); + assert_eq!(hs.metadata_size, None); + assert_eq!(hs.listen_port, None); + assert!(hs.ut_metadata.is_some()); + assert!(hs.ut_pex.is_some()); +} + +#[test] +fn test_extension_handshake_parse_empty() { + let result = ExtensionHandshake::parse(b""); + assert!(result.is_none()); +} + +#[test] +fn test_extension_handshake_parse_invalid() { + let result = ExtensionHandshake::parse(b"not bencode"); + assert!(result.is_none()); +} + +// ======================== +// MetadataMessage tests +// ======================== + +#[test] +fn test_metadata_request_encode_decode() { + let msg = extension::MetadataMessage::Request { piece: 5 }; + let encoded = msg.encode(); + let parsed = extension::MetadataMessage::parse(&encoded).unwrap(); + + match parsed { + extension::MetadataMessage::Request { piece } => assert_eq!(piece, 5), + _ => panic!("wrong message type"), + } +} + +#[test] +fn test_metadata_data_encode_decode() { + let data = Bytes::from(vec![0xAB; 1024]); + let msg = extension::MetadataMessage::Data { + piece: 3, + total_size: 16384, + data: data.clone(), + }; + let encoded = msg.encode(); + let parsed = extension::MetadataMessage::parse(&encoded).unwrap(); + + match parsed { + extension::MetadataMessage::Data { + piece, + total_size, + data: parsed_data, + } => { + assert_eq!(piece, 3); + assert_eq!(total_size, 16384); + assert_eq!(parsed_data, data); + } + _ => panic!("wrong message type"), + } +} + +#[test] +fn test_metadata_reject_encode_decode() { + let msg = extension::MetadataMessage::Reject { piece: 7 }; + let encoded = msg.encode(); + let parsed = extension::MetadataMessage::parse(&encoded).unwrap(); + + match parsed { + extension::MetadataMessage::Reject { piece } => assert_eq!(piece, 7), + _ => panic!("wrong message type"), + } +} + +#[test] +fn test_metadata_message_parse_invalid() { + let result = extension::MetadataMessage::parse(b""); + assert!(result.is_none()); + + let result = extension::MetadataMessage::parse(b"not bencode"); + assert!(result.is_none()); +} + +// ======================== +// PEX Message (extension module) tests +// ======================== + +#[test] +fn test_extension_pex_encode_decode() { + let mut msg = extension::PexMessage::new(); + msg.added.push(extension::PexPeer { + ip: std::net::IpAddr::V4(std::net::Ipv4Addr::new(192, 168, 1, 1)), + port: 6881, + }); + msg.added_flags.push(0x01); // encryption + + msg.added6.push(extension::PexPeer { + ip: std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST), + port: 6882, + }); + + let encoded = msg.encode(); + let parsed = extension::PexMessage::parse(&encoded).unwrap(); + + assert_eq!(parsed.added.len(), 1); + assert_eq!(parsed.added[0].port, 6881); + assert_eq!(parsed.added_flags.len(), 1); + assert_eq!(parsed.added_flags[0], 0x01); + assert_eq!(parsed.added6.len(), 1); + assert_eq!(parsed.added6[0].port, 6882); +} + +#[test] +fn test_extension_pex_with_dropped() { + let mut msg = extension::PexMessage::new(); + msg.dropped.push(extension::PexPeer { + ip: std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 1)), + port: 51413, + }); + msg.dropped6.push(extension::PexPeer { + ip: std::net::IpAddr::V6(std::net::Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)), + port: 6881, + }); + + let encoded = msg.encode(); + let parsed = extension::PexMessage::parse(&encoded).unwrap(); + + assert_eq!(parsed.dropped.len(), 1); + assert_eq!(parsed.dropped[0].port, 51413); + assert_eq!(parsed.dropped6.len(), 1); + assert_eq!(parsed.dropped6[0].port, 6881); +} + +#[test] +fn test_extension_pex_parse_empty() { + let result = extension::PexMessage::parse(b""); + assert!(result.is_none()); +} + +// ======================== +// Handshake tests +// ======================== + +#[test] +fn test_handshake_supports_extensions() { + let mut hs = Handshake::new([0xAB; 20], [0xCD; 20]); + // BEP-10: bit 20 (0x00100000 in reserved) + hs.reserved[5] |= 0x10; + assert!(hs.supports_extensions()); +} + +#[test] +fn test_handshake_supports_dht() { + let mut hs = Handshake::new([0xAB; 20], [0xCD; 20]); + // BEP-5: bit 0 + hs.reserved[7] |= 0x01; + assert!(hs.supports_dht()); +} + +#[test] +fn test_handshake_supports_fast() { + let mut hs = Handshake::new([0xAB; 20], [0xCD; 20]); + // BEP-6: bit 2 + hs.reserved[7] |= 0x04; + assert!(hs.supports_fast()); +} + +// ======================== +// Bitfield edge cases +// ======================== + +#[test] +fn test_bitfield_clear_piece() { + let mut bf = Bitfield::new(100); + bf.set_piece(50); + assert!(bf.has_piece(50)); + + bf.clear_piece(50); + assert!(!bf.has_piece(50)); +} + +#[test] +fn test_bitfield_from_bytes_spare_bits() { + // 10 pieces = 2 bytes, but only 10 bits are valid + let bytes = [0xFF, 0xC0]; // 11111111 11000000 - all 10 pieces set + let bf = Bitfield::from_bytes(&bytes, 10).unwrap(); + assert_eq!(bf.count(), 10); + assert!(bf.is_complete()); +} + +#[test] +fn test_bitfield_from_bytes_spare_bits_ignored() { + // 10 pieces = need 2 bytes, spare bits should be 0 per spec + // but the implementation tolerates set spare bits (lenient parsing) + let bytes = [0xFF, 0xFF]; // Spare bits are set + let result = Bitfield::from_bytes(&bytes, 10); + assert!(result.is_ok()); + // Only the first 10 bits count as pieces + let bf = result.unwrap(); + assert_eq!(bf.count(), 10); +} + +#[test] +fn test_bitfield_missing_pieces() { + let mut our = Bitfield::new(10); + our.set_piece(0); + our.set_piece(1); + + let mut peer = Bitfield::new(10); + peer.set_piece(1); + peer.set_piece(2); + peer.set_piece(3); + + let missing = peer.missing_pieces(&our); + // Pieces peer has that we don't + assert!(missing.contains(&2)); + assert!(missing.contains(&3)); + assert!(!missing.contains(&1)); // We already have this +} + +#[test] +fn test_bitfield_to_bytes() { + let mut bf = Bitfield::new(16); + bf.set_piece(0); + bf.set_piece(8); + + let bytes = bf.to_bytes(); + assert_eq!(bytes.len(), 2); +} + +// ======================== +// PeerId additional tests +// ======================== + +#[test] +fn test_peer_id_from_bytes() { + let bytes = [0xAB; 20]; + let id = PeerId::from_bytes(&bytes).unwrap(); + assert_eq!(id.0, bytes); +} + +#[test] +fn test_peer_id_from_bytes_wrong_length() { + assert!(PeerId::from_bytes(&[0u8; 10]).is_none()); +} + +#[test] +fn test_peer_id_as_bytes() { + let id = PeerId::generate(); + let bytes = id.as_bytes(); + assert_eq!(bytes.len(), 20); +} + +// ======================== +// FastExtensionState tests +// ======================== + +#[test] +fn test_fast_extension_state_init() { + let mut state = FastExtensionState::new(); + let info_hash = [0xAB; 20]; + state.init_for_peer( + std::net::IpAddr::V4(std::net::Ipv4Addr::new(192, 168, 1, 1)), + &info_hash, + 1000, + ); + + let allowed = state.get_outgoing_allowed_fast(); + assert!(!allowed.is_empty()); +} + +#[test] +fn test_fast_extension_can_request_while_choked() { + let mut state = FastExtensionState::new(); + + // Add incoming allowed fast piece + state.add_incoming_allowed_fast(42); + assert!(state.can_request_while_choked(42)); + assert!(!state.can_request_while_choked(43)); +} + +#[test] +fn test_fast_extension_suggestions() { + let mut state = FastExtensionState::new(); + state.add_suggestion(10); + state.add_suggestion(20); + + state.clear_suggestions(); + // After clearing, no suggestions should remain + // (suggestions are kept in a separate vec, clearing removes them) +} diff --git a/src-tauri/crates/oxidebt-torrent/src/tests.rs b/src-tauri/crates/oxidebt-torrent/src/tests.rs index 96acff1..1abdf62 100644 --- a/src-tauri/crates/oxidebt-torrent/src/tests.rs +++ b/src-tauri/crates/oxidebt-torrent/src/tests.rs @@ -213,3 +213,235 @@ fn test_tracker_urls() { assert_eq!(urls.len(), 4); assert_eq!(urls[0], "http://main.tracker.com/announce"); } + +// ======================== +// InfoHashV1 error paths +// ======================== + +#[test] +fn test_info_hash_v1_from_bytes_valid() { + let bytes = [0xAB; 20]; + let hash = InfoHashV1::from_bytes(&bytes).unwrap(); + assert_eq!(hash.0, bytes); +} + +#[test] +fn test_info_hash_v1_from_bytes_wrong_length() { + let result = InfoHashV1::from_bytes(&[0u8; 10]); + assert!(result.is_err()); +} + +#[test] +fn test_info_hash_v1_from_hex_invalid() { + let result = InfoHashV1::from_hex("not_hex"); + assert!(result.is_err()); +} + +#[test] +fn test_info_hash_v1_from_hex_wrong_length() { + let result = InfoHashV1::from_hex("abcd"); + assert!(result.is_err()); +} + +#[test] +fn test_info_hash_v1_from_info_bytes() { + let hash = InfoHashV1::from_info_bytes(b"test data"); + assert_eq!(hash.0.len(), 20); + assert_ne!(hash.0, [0u8; 20]); +} + +// ======================== +// InfoHashV2 methods +// ======================== + +#[test] +fn test_info_hash_v2_from_bytes_valid() { + let bytes = [0xCD; 32]; + let hash = InfoHashV2::from_bytes(&bytes).unwrap(); + assert_eq!(hash.0, bytes); +} + +#[test] +fn test_info_hash_v2_from_bytes_wrong_length() { + let result = InfoHashV2::from_bytes(&[0u8; 20]); + assert!(result.is_err()); +} + +#[test] +fn test_info_hash_v2_from_hex_invalid() { + let result = InfoHashV2::from_hex("not_hex_at_all"); + assert!(result.is_err()); +} + +#[test] +fn test_info_hash_v2_truncated() { + let hash = + InfoHashV2::from_hex("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") + .unwrap(); + let truncated = hash.truncated(); + assert_eq!(truncated.len(), 20); + assert_eq!(&truncated, &hash.0[..20]); +} + +#[test] +fn test_info_hash_v2_from_info_bytes() { + let hash = InfoHashV2::from_info_bytes(b"test data"); + assert_eq!(hash.0.len(), 32); + assert_ne!(hash.0, [0u8; 32]); +} + +// ======================== +// InfoHash enum +// ======================== + +#[test] +fn test_info_hash_v1_variant() { + let hash = InfoHash::V1(InfoHashV1([0xAB; 20])); + assert!(hash.v1().is_some()); + assert!(hash.v2().is_none()); + assert_eq!(hash.primary_bytes().len(), 20); +} + +#[test] +fn test_info_hash_v2_variant() { + let hash = InfoHash::V2(InfoHashV2([0xCD; 32])); + assert!(hash.v1().is_none()); + assert!(hash.v2().is_some()); + assert_eq!(hash.primary_bytes().len(), 32); +} + +#[test] +fn test_info_hash_hybrid_variant() { + let hash = InfoHash::Hybrid { + v1: InfoHashV1([0xAB; 20]), + v2: InfoHashV2([0xCD; 32]), + }; + assert!(hash.v1().is_some()); + assert!(hash.v2().is_some()); + assert_eq!(hash.primary_bytes().len(), 20); +} + +// ======================== +// Metainfo error paths +// ======================== + +#[test] +fn test_metainfo_from_bytes_invalid_bencode() { + let result = Metainfo::from_bytes(b"not bencode"); + assert!(result.is_err()); +} + +#[test] +fn test_metainfo_from_bytes_missing_info() { + let mut dict = BTreeMap::new(); + dict.insert( + Bytes::from_static(b"announce"), + Value::string("http://test.com"), + ); + let data = encode(&Value::Dict(dict)).unwrap(); + let result = Metainfo::from_bytes(&data); + assert!(result.is_err()); +} + +#[test] +fn test_metainfo_is_private_false() { + let data = create_test_torrent_v1(); + let meta = Metainfo::from_bytes(&data).unwrap(); + assert!(!meta.is_private()); +} + +#[test] +fn test_metainfo_is_private_true() { + let mut info = BTreeMap::new(); + info.insert(Bytes::from_static(b"length"), Value::Integer(12345)); + info.insert(Bytes::from_static(b"name"), Value::string("private.txt")); + info.insert(Bytes::from_static(b"piece length"), Value::Integer(262144)); + info.insert( + Bytes::from_static(b"pieces"), + Value::Bytes(Bytes::from(vec![0u8; 20])), + ); + info.insert(Bytes::from_static(b"private"), Value::Integer(1)); + + let mut torrent = BTreeMap::new(); + torrent.insert( + Bytes::from_static(b"announce"), + Value::string("http://private.tracker.com/announce"), + ); + torrent.insert(Bytes::from_static(b"info"), Value::Dict(info)); + + let data = encode(&Value::Dict(torrent)).unwrap(); + let meta = Metainfo::from_bytes(&data).unwrap(); + assert!(meta.is_private()); +} + +#[test] +fn test_metainfo_piece_count() { + let data = create_test_torrent_v1(); + let meta = Metainfo::from_bytes(&data).unwrap(); + assert_eq!(meta.piece_count(), 1); +} + +#[test] +fn test_metainfo_piece_length() { + let data = create_test_torrent_v1(); + let meta = Metainfo::from_bytes(&data).unwrap(); + let piece_len = meta.piece_length(0); + assert_eq!(piece_len, 12345); +} + +#[test] +fn test_metainfo_raw_info() { + let data = create_test_torrent_v1(); + let meta = Metainfo::from_bytes(&data).unwrap(); + assert!(!meta.raw_info().is_empty()); +} + +// ======================== +// MagnetLink error paths +// ======================== + +#[test] +fn test_magnet_parse_invalid_scheme() { + let result = MagnetLink::parse("http://not-a-magnet.com"); + assert!(result.is_err()); +} + +#[test] +fn test_magnet_parse_missing_hash() { + let result = MagnetLink::parse("magnet:?dn=test&tr=http://tracker.com"); + assert!(result.is_err()); +} + +#[test] +fn test_magnet_with_web_seeds() { + let uri = "magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709&ws=http://seed.example.com/file"; + let magnet = MagnetLink::parse(uri).unwrap(); + assert_eq!(magnet.web_seeds.len(), 1); + assert_eq!(magnet.web_seeds[0], "http://seed.example.com/file"); +} + +#[test] +fn test_magnet_with_peer_address() { + let uri = "magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709&x.pe=192.168.1.1:6881"; + let magnet = MagnetLink::parse(uri).unwrap(); + assert_eq!(magnet.peer_addresses.len(), 1); +} + +// ======================== +// MerkleTree additional tests +// ======================== + +#[test] +fn test_merkle_tree_from_leaves() { + let leaves = vec![[0u8; 32], [1u8; 32], [2u8; 32], [3u8; 32]]; + let tree = MerkleTree::from_leaves(leaves); + assert!(!tree.root().iter().all(|&b| b == 0)); + assert!(tree.depth() > 0); +} + +#[test] +fn test_merkle_tree_depth() { + let data = vec![0u8; 16384 * 4]; + let tree = MerkleTree::from_piece_data(&data); + assert!(tree.depth() >= 2); +} diff --git a/src-tauri/crates/oxidebt-tracker/Cargo.toml b/src-tauri/crates/oxidebt-tracker/Cargo.toml index e259695..2fb4b82 100644 --- a/src-tauri/crates/oxidebt-tracker/Cargo.toml +++ b/src-tauri/crates/oxidebt-tracker/Cargo.toml @@ -20,3 +20,4 @@ tracing = { workspace = true } parking_lot = { workspace = true } [dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/src-tauri/crates/oxidebt-tracker/src/http.rs b/src-tauri/crates/oxidebt-tracker/src/http.rs index 12cb09d..2dfb8aa 100644 --- a/src-tauri/crates/oxidebt-tracker/src/http.rs +++ b/src-tauri/crates/oxidebt-tracker/src/http.rs @@ -81,7 +81,7 @@ impl HttpTracker { self.parse_announce_response(&body) } - fn parse_announce_response(&self, data: &[u8]) -> Result { + pub(crate) fn parse_announce_response(&self, data: &[u8]) -> Result { let value = decode(data)?; let dict = value @@ -228,7 +228,7 @@ impl HttpTracker { self.parse_scrape_response(&body) } - fn announce_to_scrape_url(&self, announce_url: &str) -> Result { + pub(crate) fn announce_to_scrape_url(&self, announce_url: &str) -> Result { if let Some(pos) = announce_url.rfind("/announce") { let mut url = announce_url.to_string(); url.replace_range(pos..pos + 9, "/scrape"); @@ -240,7 +240,7 @@ impl HttpTracker { } } - fn parse_scrape_response(&self, data: &[u8]) -> Result { + pub(crate) fn parse_scrape_response(&self, data: &[u8]) -> Result { let value = decode(data)?; let dict = value diff --git a/src-tauri/crates/oxidebt-tracker/src/tests.rs b/src-tauri/crates/oxidebt-tracker/src/tests.rs index 67c49ba..30a053b 100644 --- a/src-tauri/crates/oxidebt-tracker/src/tests.rs +++ b/src-tauri/crates/oxidebt-tracker/src/tests.rs @@ -1,5 +1,10 @@ use super::*; +use crate::http::HttpTracker; use crate::response::{Peer, ScrapeStats}; +use crate::udp::UdpTracker; +use bytes::{BufMut, BytesMut}; +use oxidebt_bencode::{encode, Value}; +use std::collections::BTreeMap; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; #[test] @@ -89,3 +94,522 @@ fn test_scrape_stats() { assert_eq!(stats.incomplete, 50); assert_eq!(stats.downloaded, 1000); } + +// ======================== +// HTTP announce response parsing +// ======================== + +#[test] +fn test_http_parse_announce_compact() { + let tracker = HttpTracker::new(); + + let mut dict = BTreeMap::new(); + dict.insert( + bytes::Bytes::from_static(b"interval"), + Value::Integer(1800), + ); + dict.insert( + bytes::Bytes::from_static(b"complete"), + Value::Integer(42), + ); + dict.insert( + bytes::Bytes::from_static(b"incomplete"), + Value::Integer(7), + ); + + // Compact IPv4 peers: 127.0.0.1:6881, 10.0.0.1:51413 + let mut peer_data = Vec::new(); + peer_data.extend_from_slice(&[127, 0, 0, 1, 0x1A, 0xE1]); + peer_data.extend_from_slice(&[10, 0, 0, 1, 0xC8, 0xD5]); + dict.insert( + bytes::Bytes::from_static(b"peers"), + Value::Bytes(bytes::Bytes::from(peer_data)), + ); + + let encoded = encode(&Value::Dict(dict)).unwrap(); + let result = tracker.parse_announce_response(&encoded); + assert!(result.is_ok()); + + let resp = result.unwrap(); + assert_eq!(resp.interval, 1800); + assert_eq!(resp.complete, Some(42)); + assert_eq!(resp.incomplete, Some(7)); + assert_eq!(resp.peers.len(), 2); + assert_eq!( + resp.peers[0].addr, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 6881) + ); + assert_eq!( + resp.peers[1].addr, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 51413) + ); +} + +#[test] +fn test_http_parse_announce_dict_peers() { + let tracker = HttpTracker::new(); + + let mut dict = BTreeMap::new(); + dict.insert( + bytes::Bytes::from_static(b"interval"), + Value::Integer(900), + ); + + let mut peer_dict = BTreeMap::new(); + peer_dict.insert(bytes::Bytes::from_static(b"ip"), Value::string("192.168.1.1")); + peer_dict.insert( + bytes::Bytes::from_static(b"port"), + Value::Integer(6881), + ); + + let peer_id = vec![0xAB; 20]; + peer_dict.insert( + bytes::Bytes::from_static(b"peer id"), + Value::Bytes(bytes::Bytes::from(peer_id)), + ); + + dict.insert( + bytes::Bytes::from_static(b"peers"), + Value::List(vec![Value::Dict(peer_dict)]), + ); + + let encoded = encode(&Value::Dict(dict)).unwrap(); + let result = tracker.parse_announce_response(&encoded); + assert!(result.is_ok()); + + let resp = result.unwrap(); + assert_eq!(resp.interval, 900); + assert_eq!(resp.peers.len(), 1); + assert_eq!( + resp.peers[0].addr, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), 6881) + ); + assert!(resp.peers[0].peer_id.is_some()); +} + +#[test] +fn test_http_parse_announce_failure() { + let tracker = HttpTracker::new(); + + let mut dict = BTreeMap::new(); + dict.insert( + bytes::Bytes::from_static(b"failure reason"), + Value::string("torrent not registered"), + ); + + let encoded = encode(&Value::Dict(dict)).unwrap(); + let result = tracker.parse_announce_response(&encoded); + assert!(result.is_err()); + + match result.unwrap_err() { + TrackerError::TrackerFailure(msg) => { + assert!(msg.contains("not registered")); + } + e => panic!("wrong error type: {:?}", e), + } +} + +#[test] +fn test_http_parse_announce_with_warning() { + let tracker = HttpTracker::new(); + + let mut dict = BTreeMap::new(); + dict.insert( + bytes::Bytes::from_static(b"interval"), + Value::Integer(1800), + ); + dict.insert( + bytes::Bytes::from_static(b"warning message"), + Value::string("slow down"), + ); + + let encoded = encode(&Value::Dict(dict)).unwrap(); + let resp = tracker.parse_announce_response(&encoded).unwrap(); + assert_eq!(resp.warning_message, Some("slow down".to_string())); +} + +#[test] +fn test_http_parse_announce_with_tracker_id() { + let tracker = HttpTracker::new(); + + let mut dict = BTreeMap::new(); + dict.insert( + bytes::Bytes::from_static(b"interval"), + Value::Integer(1800), + ); + dict.insert( + bytes::Bytes::from_static(b"tracker id"), + Value::string("abc123"), + ); + + let encoded = encode(&Value::Dict(dict)).unwrap(); + let resp = tracker.parse_announce_response(&encoded).unwrap(); + assert_eq!(resp.tracker_id, Some("abc123".to_string())); +} + +#[test] +fn test_http_parse_announce_with_min_interval() { + let tracker = HttpTracker::new(); + + let mut dict = BTreeMap::new(); + dict.insert( + bytes::Bytes::from_static(b"interval"), + Value::Integer(1800), + ); + dict.insert( + bytes::Bytes::from_static(b"min interval"), + Value::Integer(60), + ); + + let encoded = encode(&Value::Dict(dict)).unwrap(); + let resp = tracker.parse_announce_response(&encoded).unwrap(); + assert_eq!(resp.min_interval, Some(60)); +} + +#[test] +fn test_http_parse_announce_missing_interval() { + let tracker = HttpTracker::new(); + + let dict = BTreeMap::new(); + let encoded = encode(&Value::Dict(dict)).unwrap(); + let result = tracker.parse_announce_response(&encoded); + assert!(result.is_err()); +} + +#[test] +fn test_http_parse_announce_invalid_bencode() { + let tracker = HttpTracker::new(); + let result = tracker.parse_announce_response(b"invalid data"); + assert!(result.is_err()); +} + +#[test] +fn test_http_parse_announce_with_peers6() { + let tracker = HttpTracker::new(); + + let mut dict = BTreeMap::new(); + dict.insert( + bytes::Bytes::from_static(b"interval"), + Value::Integer(1800), + ); + + // Compact IPv6: ::1 port 6881 + let mut peer6_data = vec![0u8; 16]; + peer6_data[15] = 1; + peer6_data.extend_from_slice(&[0x1A, 0xE1]); + dict.insert( + bytes::Bytes::from_static(b"peers6"), + Value::Bytes(bytes::Bytes::from(peer6_data)), + ); + + let encoded = encode(&Value::Dict(dict)).unwrap(); + let resp = tracker.parse_announce_response(&encoded).unwrap(); + assert_eq!(resp.peers6.len(), 1); + assert_eq!(resp.peers6[0].addr.port(), 6881); + assert!(resp.peers6[0].addr.is_ipv6()); +} + +// ======================== +// HTTP scrape response parsing +// ======================== + +#[test] +fn test_http_parse_scrape_response() { + let tracker = HttpTracker::new(); + + let info_hash = [0xAB; 20]; + let mut stats = BTreeMap::new(); + stats.insert( + bytes::Bytes::from_static(b"complete"), + Value::Integer(100), + ); + stats.insert( + bytes::Bytes::from_static(b"incomplete"), + Value::Integer(50), + ); + stats.insert( + bytes::Bytes::from_static(b"downloaded"), + Value::Integer(1000), + ); + + let mut files = BTreeMap::new(); + files.insert( + bytes::Bytes::from(info_hash.to_vec()), + Value::Dict(stats), + ); + + let mut dict = BTreeMap::new(); + dict.insert(bytes::Bytes::from_static(b"files"), Value::Dict(files)); + + let encoded = encode(&Value::Dict(dict)).unwrap(); + let resp = tracker.parse_scrape_response(&encoded).unwrap(); + assert_eq!(resp.files.len(), 1); + assert_eq!(resp.files[0].0, info_hash); + assert_eq!(resp.files[0].1.complete, 100); + assert_eq!(resp.files[0].1.incomplete, 50); + assert_eq!(resp.files[0].1.downloaded, 1000); +} + +#[test] +fn test_http_parse_scrape_failure() { + let tracker = HttpTracker::new(); + + let mut dict = BTreeMap::new(); + dict.insert( + bytes::Bytes::from_static(b"failure reason"), + Value::string("scrape not allowed"), + ); + + let encoded = encode(&Value::Dict(dict)).unwrap(); + let result = tracker.parse_scrape_response(&encoded); + assert!(result.is_err()); +} + +#[test] +fn test_http_announce_to_scrape_url() { + let tracker = HttpTracker::new(); + + let url = "http://tracker.example.com/announce"; + let scrape = tracker.announce_to_scrape_url(url).unwrap(); + assert_eq!(scrape, "http://tracker.example.com/scrape"); +} + +#[test] +fn test_http_announce_to_scrape_url_with_path() { + let tracker = HttpTracker::new(); + + let url = "http://tracker.example.com/path/to/announce?passkey=abc"; + let scrape = tracker.announce_to_scrape_url(url).unwrap(); + assert_eq!( + scrape, + "http://tracker.example.com/path/to/scrape?passkey=abc" + ); +} + +#[test] +fn test_http_announce_to_scrape_url_invalid() { + let tracker = HttpTracker::new(); + + let url = "http://tracker.example.com/something"; + let result = tracker.announce_to_scrape_url(url); + assert!(result.is_err()); +} + +// ======================== +// UDP announce response parsing +// ======================== + +#[test] +fn test_udp_parse_announce_response() { + let tracker = UdpTracker::new(); + let transaction_id: u32 = 12345; + + let mut buf = BytesMut::new(); + buf.put_u32(1); // action: announce + buf.put_u32(transaction_id); + buf.put_u32(1800); // interval + buf.put_u32(5); // leechers (incomplete) + buf.put_u32(10); // seeders (complete) + // Compact peers + buf.put_slice(&[127, 0, 0, 1, 0x1A, 0xE1]); // 127.0.0.1:6881 + buf.put_slice(&[10, 0, 0, 1, 0xC8, 0xD5]); // 10.0.0.1:51413 + + let resp = tracker + .parse_announce_response(&buf, transaction_id) + .unwrap(); + assert_eq!(resp.interval, 1800); + assert_eq!(resp.complete, Some(10)); + assert_eq!(resp.incomplete, Some(5)); + assert_eq!(resp.peers.len(), 2); +} + +#[test] +fn test_udp_parse_announce_wrong_transaction() { + let tracker = UdpTracker::new(); + + let mut buf = BytesMut::new(); + buf.put_u32(1); // action: announce + buf.put_u32(99999); // wrong transaction id + buf.put_u32(1800); + buf.put_u32(0); + buf.put_u32(0); + + let result = tracker.parse_announce_response(&buf, 12345); + assert!(matches!(result, Err(TrackerError::InvalidTransactionId))); +} + +#[test] +fn test_udp_parse_announce_error_action() { + let tracker = UdpTracker::new(); + let transaction_id: u32 = 12345; + + let mut buf = BytesMut::new(); + buf.put_u32(3); // action: error + buf.put_u32(transaction_id); + buf.put_slice(b"torrent not found"); + + let result = tracker.parse_announce_response(&buf, transaction_id); + assert!(matches!(result, Err(TrackerError::TrackerFailure(_)))); +} + +#[test] +fn test_udp_parse_announce_invalid_action() { + let tracker = UdpTracker::new(); + let transaction_id: u32 = 12345; + + let mut buf = BytesMut::new(); + buf.put_u32(255); // invalid action + buf.put_u32(transaction_id); + buf.put_u32(1800); + buf.put_u32(0); + buf.put_u32(0); + + let result = tracker.parse_announce_response(&buf, transaction_id); + assert!(matches!(result, Err(TrackerError::InvalidAction))); +} + +#[test] +fn test_udp_parse_announce_too_short() { + let tracker = UdpTracker::new(); + let result = tracker.parse_announce_response(&[0u8; 10], 12345); + assert!(result.is_err()); +} + +// ======================== +// UDP scrape response parsing +// ======================== + +#[test] +fn test_udp_parse_scrape_response() { + let tracker = UdpTracker::new(); + let transaction_id: u32 = 12345; + let info_hash = oxidebt_torrent::InfoHashV1([0xAB; 20]); + + let mut buf = BytesMut::new(); + buf.put_u32(2); // action: scrape + buf.put_u32(transaction_id); + buf.put_u32(100); // complete (seeders) + buf.put_u32(1000); // downloaded + buf.put_u32(50); // incomplete (leechers) + + let resp = tracker + .parse_scrape_response(&buf, transaction_id, &[info_hash]) + .unwrap(); + assert_eq!(resp.files.len(), 1); + assert_eq!(resp.files[0].1.complete, 100); + assert_eq!(resp.files[0].1.downloaded, 1000); + assert_eq!(resp.files[0].1.incomplete, 50); +} + +#[test] +fn test_udp_parse_scrape_wrong_transaction() { + let tracker = UdpTracker::new(); + let info_hash = oxidebt_torrent::InfoHashV1([0xAB; 20]); + + let mut buf = BytesMut::new(); + buf.put_u32(2); + buf.put_u32(99999); + buf.put_u32(0); + buf.put_u32(0); + buf.put_u32(0); + + let result = tracker.parse_scrape_response(&buf, 12345, &[info_hash]); + assert!(matches!(result, Err(TrackerError::InvalidTransactionId))); +} + +#[test] +fn test_udp_parse_scrape_error_action() { + let tracker = UdpTracker::new(); + let transaction_id: u32 = 12345; + let info_hash = oxidebt_torrent::InfoHashV1([0xAB; 20]); + + let mut buf = BytesMut::new(); + buf.put_u32(3); // error action + buf.put_u32(transaction_id); + buf.put_slice(b"scrape denied"); + + let result = tracker.parse_scrape_response(&buf, transaction_id, &[info_hash]); + assert!(matches!(result, Err(TrackerError::TrackerFailure(_)))); +} + +#[test] +fn test_udp_parse_scrape_too_short() { + let tracker = UdpTracker::new(); + let info_hash = oxidebt_torrent::InfoHashV1([0xAB; 20]); + + let result = tracker.parse_scrape_response(&[0u8; 4], 12345, &[info_hash]); + assert!(result.is_err()); +} + +// ======================== +// TrackerClient protocol routing +// ======================== + +#[tokio::test] +async fn test_tracker_client_unsupported_protocol() { + let client = TrackerClient::new(); + let info_hash = oxidebt_torrent::InfoHashV1([0xAB; 20]); + let peer_id = [0u8; 20]; + + let params = AnnounceParams { + url: "ftp://tracker.example.com/announce", + info_hash: &info_hash, + peer_id: &peer_id, + port: 6881, + uploaded: 0, + downloaded: 0, + left: 1000, + event: TrackerEvent::Started, + }; + + let result = client.announce(params).await; + assert!(matches!(result, Err(TrackerError::UnsupportedProtocol(_)))); +} + +#[tokio::test] +async fn test_tracker_client_scrape_unsupported_protocol() { + let client = TrackerClient::new(); + let result = client.scrape("ftp://bad.com/scrape", &[]).await; + assert!(matches!(result, Err(TrackerError::UnsupportedProtocol(_)))); +} + +// ======================== +// Peer compact format edge cases +// ======================== + +#[test] +fn test_peer_from_compact_v4_empty() { + let peers = Peer::from_compact_v4(&[]); + assert!(peers.is_empty()); +} + +#[test] +fn test_peer_from_compact_v4_incomplete_chunk() { + // 7 bytes - not a multiple of 6, last byte should be ignored + let data = [127, 0, 0, 1, 0x1A, 0xE1, 0xFF]; + let peers = Peer::from_compact_v4(&data); + assert_eq!(peers.len(), 1); +} + +#[test] +fn test_peer_from_compact_v6_empty() { + let peers = Peer::from_compact_v6(&[]); + assert!(peers.is_empty()); +} + +#[test] +fn test_announce_response_empty_peers() { + let response = AnnounceResponse { + interval: 1800, + min_interval: None, + complete: None, + incomplete: None, + peers: vec![], + peers6: vec![], + warning_message: None, + tracker_id: None, + }; + + let all = response.all_peers(); + assert!(all.is_empty()); +} diff --git a/src-tauri/crates/oxidebt-tracker/src/udp.rs b/src-tauri/crates/oxidebt-tracker/src/udp.rs index 38883b7..9878580 100644 --- a/src-tauri/crates/oxidebt-tracker/src/udp.rs +++ b/src-tauri/crates/oxidebt-tracker/src/udp.rs @@ -196,7 +196,7 @@ impl UdpTracker { Err(TrackerError::Timeout) } - fn parse_announce_response( + pub(crate) fn parse_announce_response( &self, data: &[u8], expected_transaction_id: u32, @@ -279,7 +279,7 @@ impl UdpTracker { self.parse_scrape_response(&buf[..n], transaction_id, info_hashes) } - fn parse_scrape_response( + pub(crate) fn parse_scrape_response( &self, data: &[u8], expected_transaction_id: u32, diff --git a/src-tauri/src/engine/stats.rs b/src-tauri/src/engine/stats.rs index 3b2761e..04222c0 100644 --- a/src-tauri/src/engine/stats.rs +++ b/src-tauri/src/engine/stats.rs @@ -324,4 +324,128 @@ mod tests { rate1 ); } + + #[test] + fn test_rate_calculator_reset() { + let mut calc = RateCalculator::new(); + let start = Instant::now(); + + calc.update(1_000_000, start); + assert!(calc.rate() > 0.0); + + calc.reset(); + assert_eq!(calc.rate(), 0.0); + assert_eq!(calc.instant_rate(), 0.0); + } + + #[test] + fn test_rate_calculator_set_baseline() { + let mut calc = RateCalculator::new(); + let start = Instant::now(); + + // Transfer some data + calc.update(500_000, start); + assert!(calc.rate() > 0.0); + + // Set baseline - should reset rate but keep the total + calc.set_baseline(1_000_000); + assert_eq!(calc.rate(), 0.0); + + // Further updates should only count new bytes above baseline + calc.update(1_100_000, start + Duration::from_millis(100)); + // Only 100K new bytes + assert!(calc.rate() > 0.0); + } + + #[test] + fn test_rate_calculator_zero_bytes() { + let mut calc = RateCalculator::new(); + let start = Instant::now(); + + // Update with no data transfer + calc.update(0, start); + calc.update(0, start + Duration::from_millis(100)); + assert_eq!(calc.rate(), 0.0); + } + + #[test] + fn test_rate_calculator_burst_then_steady() { + let mut calc = RateCalculator::new(); + let start = Instant::now(); + + // Initial burst + calc.update(1_000_000, start); + + // Steady transfer at 100KB per 100ms = 1MB/s + for i in 1..20 { + calc.update( + 1_000_000 + i * 100_000, + start + Duration::from_millis(i * 100), + ); + } + + let rate = calc.rate(); + // Should converge toward 1MB/s + assert!( + rate > 500_000.0 && rate < 3_000_000.0, + "Rate was {}", + rate + ); + } + + // ======================== + // TorrentStats tests + // ======================== + + #[test] + fn test_torrent_stats_new() { + let stats = TorrentStats::new(); + assert_eq!(stats.downloaded, 0); + assert_eq!(stats.uploaded, 0); + assert_eq!(stats.download_rate, 0.0); + assert_eq!(stats.upload_rate, 0.0); + } + + #[test] + fn test_torrent_stats_record_download() { + let mut stats = TorrentStats::new(); + stats.record_download(1_000_000); + assert_eq!(stats.downloaded, 1_000_000); + // Rate should be initialized + assert!(stats.download_rate >= 0.0); + } + + #[test] + fn test_torrent_stats_record_upload() { + let mut stats = TorrentStats::new(); + stats.record_upload(500_000); + assert_eq!(stats.uploaded, 500_000); + assert!(stats.upload_rate >= 0.0); + } + + #[test] + fn test_torrent_stats_set_downloaded_baseline() { + let mut stats = TorrentStats::new(); + stats.set_downloaded_baseline(10_000_000); + assert_eq!(stats.downloaded, 10_000_000); + assert_eq!(stats.download_rate, 0.0); + } + + #[test] + fn test_torrent_stats_update_rates() { + let mut stats = TorrentStats::new(); + stats.downloaded = 1_000_000; + stats.uploaded = 500_000; + stats.update_rates(); + // Rates are computed from RateCalculator + assert!(stats.download_rate >= 0.0); + assert!(stats.upload_rate >= 0.0); + } + + #[test] + fn test_torrent_stats_default() { + let stats = TorrentStats::default(); + assert_eq!(stats.downloaded, 0); + assert_eq!(stats.uploaded, 0); + } } From 36d83d2798231435a68f5ff3dccd7593fb953666 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 06:05:40 +0000 Subject: [PATCH 2/4] Add CI/CD workflows for testing and releases - CI workflow: runs on push/PR to main with parallel jobs for rustfmt, clippy, workspace tests, and frontend build - Release workflow: triggered by version tags, builds cross-platform Tauri bundles (Linux, macOS arm64/x64, Windows) and creates GitHub draft releases with artifacts https://claude.ai/code/session_014Z9cWnuBb6Gdu2bVqJzf6H --- .github/workflows/ci.yml | 66 +++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 63 +++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8b7d633 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-Dwarnings" + +jobs: + fmt: + name: Rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all --check + working-directory: src-tauri + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev + - run: cargo clippy --workspace --all-targets -- -D warnings + working-directory: src-tauri + + test: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev + - run: cargo test --workspace + working-directory: src-tauri + + frontend: + name: Frontend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install + - run: bun run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..eeb5d8e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,63 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + name: Build (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: macos-latest + target: aarch64-apple-darwin + - os: macos-13 + target: x86_64-apple-darwin + - os: windows-latest + target: x86_64-pc-windows-msvc + + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - uses: oven-sh/setup-bun@v2 + + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev + + - run: bun install + + - name: Run tests + working-directory: src-tauri + run: cargo test --workspace + + - name: Build Tauri app + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tagName: ${{ github.ref_name }} + releaseName: "RBitt ${{ github.ref_name }}" + releaseBody: "See the assets below to download and install RBitt." + releaseDraft: true + prerelease: false From 3cfa470b444af112bcf352181f8fd02901877778 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 06:17:33 +0000 Subject: [PATCH 3/4] Fix formatting and clippy warnings across all crates - Apply rustfmt formatting to all files - Fix clippy::for_kv_map: iterate over map values directly - Fix clippy::collapsible_else_if and collapsible_if: collapse nested ifs - Fix clippy::io_other_error: use std::io::Error::other() - Fix clippy::unnecessary_sort_by: use sort_by_key - Fix clippy::needless_range_loop: use iterator instead of index loop - Fix clippy::op_ref: remove needless reference on comparison operand - Allow clippy::too_many_arguments on write_block https://claude.ai/code/session_014Z9cWnuBb6Gdu2bVqJzf6H --- .../crates/oxidebt-cache/src/block_cache.rs | 2 +- .../crates/oxidebt-cache/src/piece_cache.rs | 6 +- src-tauri/crates/oxidebt-cache/src/tests.rs | 18 +--- src-tauri/crates/oxidebt-dht/src/server.rs | 8 +- .../oxidebt-disk/src/caching_manager.rs | 1 + src-tauri/crates/oxidebt-disk/src/io_queue.rs | 2 +- src-tauri/crates/oxidebt-disk/src/manager.rs | 75 ++++++++-------- src-tauri/crates/oxidebt-disk/src/tests.rs | 12 ++- src-tauri/crates/oxidebt-tracker/src/http.rs | 15 +++- src-tauri/crates/oxidebt-tracker/src/tests.rs | 63 ++++---------- src-tauri/src/engine/mod.rs | 86 ++++++++++--------- src-tauri/src/engine/search.rs | 25 ++---- src-tauri/src/engine/stats.rs | 6 +- src-tauri/tests/integration_tests.rs | 19 ++-- 14 files changed, 146 insertions(+), 192 deletions(-) diff --git a/src-tauri/crates/oxidebt-cache/src/block_cache.rs b/src-tauri/crates/oxidebt-cache/src/block_cache.rs index c985be9..f5f44e6 100644 --- a/src-tauri/crates/oxidebt-cache/src/block_cache.rs +++ b/src-tauri/crates/oxidebt-cache/src/block_cache.rs @@ -68,7 +68,7 @@ impl PieceBlocks { fn assemble(&self) -> Bytes { let mut data = Vec::with_capacity(self.piece_length as usize); - for (_, block) in &self.blocks { + for block in self.blocks.values() { data.extend_from_slice(block); } Bytes::from(data) diff --git a/src-tauri/crates/oxidebt-cache/src/piece_cache.rs b/src-tauri/crates/oxidebt-cache/src/piece_cache.rs index 4fe8779..7ffd112 100644 --- a/src-tauri/crates/oxidebt-cache/src/piece_cache.rs +++ b/src-tauri/crates/oxidebt-cache/src/piece_cache.rs @@ -222,10 +222,8 @@ impl PieceCache { if t1_len < self.capacity { self.b1.write().pop_front(); self.replace(&key, false); - } else { - if let Some((_, _, size)) = self.t1.write().pop_front() { - self.memory_used.fetch_sub(size, Ordering::Relaxed); - } + } else if let Some((_, _, size)) = self.t1.write().pop_front() { + self.memory_used.fetch_sub(size, Ordering::Relaxed); } } else { let total = t1_len + b1_len + self.t2.read().len() + self.b2.read().len(); diff --git a/src-tauri/crates/oxidebt-cache/src/tests.rs b/src-tauri/crates/oxidebt-cache/src/tests.rs index 4bbb8d1..b2f89b6 100644 --- a/src-tauri/crates/oxidebt-cache/src/tests.rs +++ b/src-tauri/crates/oxidebt-cache/src/tests.rs @@ -235,14 +235,7 @@ fn test_block_cache_finalize_and_verify_v2() { #[test] fn test_block_cache_finalize_wrong_hash() { let cache = BlockCache::new(1024 * 1024); - cache.add_block( - "hash1", - 0, - 0, - Bytes::from(vec![0xAB; 16384]), - 16384, - 1, - ); + cache.add_block("hash1", 0, 0, Bytes::from(vec![0xAB; 16384]), 16384, 1); let wrong_hash = vec![0u8; 20]; assert!(!cache.finalize_and_verify("hash1", 0, &wrong_hash)); @@ -270,14 +263,7 @@ fn test_block_cache_finalize_multiblock_v1() { // Add blocks in order so streaming hash can advance cache.add_block("hash1", 0, 0, Bytes::from(block1), piece_length, 1); - cache.add_block( - "hash1", - 0, - 16384, - Bytes::from(block2), - piece_length, - 1, - ); + cache.add_block("hash1", 0, 16384, Bytes::from(block2), piece_length, 1); assert!(cache.finalize_and_verify("hash1", 0, &expected_hash)); } diff --git a/src-tauri/crates/oxidebt-dht/src/server.rs b/src-tauri/crates/oxidebt-dht/src/server.rs index 74553eb..db03bc7 100644 --- a/src-tauri/crates/oxidebt-dht/src/server.rs +++ b/src-tauri/crates/oxidebt-dht/src/server.rs @@ -538,8 +538,8 @@ impl DhtServer { id[byte_idx] = (id[byte_idx] & keep_mask) | (random_byte & random_mask); } - for i in (byte_idx + 1)..20 { - id[i] = rand::random(); + for byte in id.iter_mut().skip(byte_idx + 1) { + *byte = rand::random(); } NodeId(id) @@ -685,12 +685,12 @@ impl DhtServer { let secrets = self.token_secrets.read(); let current_token = self.generate_token_with_secret(addr, &secrets.current); - if ¤t_token == token { + if current_token == token { return true; } let previous_token = self.generate_token_with_secret(addr, &secrets.previous); - &previous_token == token + previous_token == token } pub fn rotate_token_secret(&self) { diff --git a/src-tauri/crates/oxidebt-disk/src/caching_manager.rs b/src-tauri/crates/oxidebt-disk/src/caching_manager.rs index c057dca..193878f 100644 --- a/src-tauri/crates/oxidebt-disk/src/caching_manager.rs +++ b/src-tauri/crates/oxidebt-disk/src/caching_manager.rs @@ -58,6 +58,7 @@ impl CachingDiskManager { &self.storage } + #[allow(clippy::too_many_arguments)] pub async fn write_block( &self, info_hash: &str, diff --git a/src-tauri/crates/oxidebt-disk/src/io_queue.rs b/src-tauri/crates/oxidebt-disk/src/io_queue.rs index c99e4c3..c860eee 100644 --- a/src-tauri/crates/oxidebt-disk/src/io_queue.rs +++ b/src-tauri/crates/oxidebt-disk/src/io_queue.rs @@ -114,7 +114,7 @@ impl IoQueue { } async fn dispatch_batch(&self, batch: &mut Vec, num_workers: usize) { - batch.sort_by(|a, b| a.sort_key().cmp(&b.sort_key())); + batch.sort_by_key(|a| a.sort_key()); let mut per_worker: Vec> = (0..num_workers).map(|_| Vec::new()).collect(); diff --git a/src-tauri/crates/oxidebt-disk/src/manager.rs b/src-tauri/crates/oxidebt-disk/src/manager.rs index 7a6fbfe..bc6fcbb 100644 --- a/src-tauri/crates/oxidebt-disk/src/manager.rs +++ b/src-tauri/crates/oxidebt-disk/src/manager.rs @@ -131,11 +131,11 @@ impl FileHandleCache { async fn flush_all(&self) { let keys: Vec = self.handles.iter().map(|r| *r.key()).collect(); for key in keys { - if let Some((_, handle)) = self.handles.remove(&key) { - if handle.is_write { - let file = handle.file.lock().await; - let _ = file.sync_data().await; - } + if let Some((_, handle)) = self.handles.remove(&key) + && handle.is_write + { + let file = handle.file.lock().await; + let _ = file.sync_data().await; } } } @@ -150,11 +150,11 @@ impl FileHandleCache { .collect(); for idx in to_evict { - if let Some((_, handle)) = self.handles.remove(&idx) { - if handle.is_write { - let file = handle.file.lock().await; - let _ = file.sync_data().await; - } + if let Some((_, handle)) = self.handles.remove(&idx) + && handle.is_write + { + let file = handle.file.lock().await; + let _ = file.sync_data().await; } } } @@ -580,12 +580,11 @@ impl DiskManager { pub async fn read_piece(&self, info_hash: &str, piece_index: u32) -> Result { let storage = self.get_storage(info_hash)?; - let _permit = self.semaphore.acquire().await.map_err(|_| { - DiskError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - "semaphore closed", - )) - })?; + let _permit = self + .semaphore + .acquire() + .await + .map_err(|_| DiskError::Io(std::io::Error::other("semaphore closed")))?; storage.read_piece(piece_index).await } @@ -597,12 +596,11 @@ impl DiskManager { length: u32, ) -> Result { let storage = self.get_storage(info_hash)?; - let _permit = self.semaphore.acquire().await.map_err(|_| { - DiskError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - "semaphore closed", - )) - })?; + let _permit = self + .semaphore + .acquire() + .await + .map_err(|_| DiskError::Io(std::io::Error::other("semaphore closed")))?; storage.read_block(piece_index, offset, length).await } @@ -613,12 +611,11 @@ impl DiskManager { data: &[u8], ) -> Result<(), DiskError> { let storage = self.get_storage(info_hash)?; - let _permit = self.semaphore.acquire().await.map_err(|_| { - DiskError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - "semaphore closed", - )) - })?; + let _permit = self + .semaphore + .acquire() + .await + .map_err(|_| DiskError::Io(std::io::Error::other("semaphore closed")))?; storage.write_piece(piece_index, data).await } @@ -630,23 +627,21 @@ impl DiskManager { data: &[u8], ) -> Result<(), DiskError> { let storage = self.get_storage(info_hash)?; - let _permit = self.semaphore.acquire().await.map_err(|_| { - DiskError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - "semaphore closed", - )) - })?; + let _permit = self + .semaphore + .acquire() + .await + .map_err(|_| DiskError::Io(std::io::Error::other("semaphore closed")))?; storage.write_block(piece_index, offset, data).await } pub async fn verify_piece(&self, info_hash: &str, piece_index: u32) -> Result { let storage = self.get_storage(info_hash)?; - let _permit = self.semaphore.acquire().await.map_err(|_| { - DiskError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - "semaphore closed", - )) - })?; + let _permit = self + .semaphore + .acquire() + .await + .map_err(|_| DiskError::Io(std::io::Error::other("semaphore closed")))?; storage.verify_piece(piece_index).await } diff --git a/src-tauri/crates/oxidebt-disk/src/tests.rs b/src-tauri/crates/oxidebt-disk/src/tests.rs index d91c298..9c71a3d 100644 --- a/src-tauri/crates/oxidebt-disk/src/tests.rs +++ b/src-tauri/crates/oxidebt-disk/src/tests.rs @@ -85,7 +85,8 @@ async fn test_multifile_storage() { PieceInfo::v1(1, [0u8; 20], 16384, 3616), ]; - let storage = TorrentStorage::new(base_path, files, pieces, 20000, false).expect("test storage creation"); + let storage = + TorrentStorage::new(base_path, files, pieces, 20000, false).expect("test storage creation"); storage.preallocate().await.unwrap(); let data: Vec = (0..16384).map(|i| (i % 256) as u8).collect(); @@ -285,7 +286,8 @@ async fn stress_test_many_small_files() { }) .collect(); - let storage = TorrentStorage::new(base_path, files, pieces, total_size, false).expect("test storage creation"); + let storage = TorrentStorage::new(base_path, files, pieces, total_size, false) + .expect("test storage creation"); storage.preallocate().await.unwrap(); for i in 0..piece_count { @@ -389,7 +391,8 @@ async fn stress_test_piece_spanning_multiple_files() { PieceInfo::v1(1, [0u8; 20], 8192, 6808), ]; - let storage = TorrentStorage::new(base_path, files, pieces, total_size, false).expect("test storage creation"); + let storage = TorrentStorage::new(base_path, files, pieces, total_size, false) + .expect("test storage creation"); storage.preallocate().await.unwrap(); let data0: Vec = (0..8192).map(|i| (i % 256) as u8).collect(); @@ -439,7 +442,8 @@ async fn stress_test_subdirectory_files() { let total_size = 15000u64; let pieces = vec![PieceInfo::v1(0, [0u8; 20], 0, 15000)]; - let storage = TorrentStorage::new(base_path.clone(), files, pieces, total_size, false).expect("test storage creation"); + let storage = TorrentStorage::new(base_path.clone(), files, pieces, total_size, false) + .expect("test storage creation"); storage.preallocate().await.unwrap(); assert!(base_path.join("folder1/subfolder").exists()); diff --git a/src-tauri/crates/oxidebt-tracker/src/http.rs b/src-tauri/crates/oxidebt-tracker/src/http.rs index 2dfb8aa..41da1ff 100644 --- a/src-tauri/crates/oxidebt-tracker/src/http.rs +++ b/src-tauri/crates/oxidebt-tracker/src/http.rs @@ -81,7 +81,10 @@ impl HttpTracker { self.parse_announce_response(&body) } - pub(crate) fn parse_announce_response(&self, data: &[u8]) -> Result { + pub(crate) fn parse_announce_response( + &self, + data: &[u8], + ) -> Result { let value = decode(data)?; let dict = value @@ -228,7 +231,10 @@ impl HttpTracker { self.parse_scrape_response(&body) } - pub(crate) fn announce_to_scrape_url(&self, announce_url: &str) -> Result { + pub(crate) fn announce_to_scrape_url( + &self, + announce_url: &str, + ) -> Result { if let Some(pos) = announce_url.rfind("/announce") { let mut url = announce_url.to_string(); url.replace_range(pos..pos + 9, "/scrape"); @@ -240,7 +246,10 @@ impl HttpTracker { } } - pub(crate) fn parse_scrape_response(&self, data: &[u8]) -> Result { + pub(crate) fn parse_scrape_response( + &self, + data: &[u8], + ) -> Result { let value = decode(data)?; let dict = value diff --git a/src-tauri/crates/oxidebt-tracker/src/tests.rs b/src-tauri/crates/oxidebt-tracker/src/tests.rs index 30a053b..67c6ea5 100644 --- a/src-tauri/crates/oxidebt-tracker/src/tests.rs +++ b/src-tauri/crates/oxidebt-tracker/src/tests.rs @@ -104,18 +104,9 @@ fn test_http_parse_announce_compact() { let tracker = HttpTracker::new(); let mut dict = BTreeMap::new(); - dict.insert( - bytes::Bytes::from_static(b"interval"), - Value::Integer(1800), - ); - dict.insert( - bytes::Bytes::from_static(b"complete"), - Value::Integer(42), - ); - dict.insert( - bytes::Bytes::from_static(b"incomplete"), - Value::Integer(7), - ); + dict.insert(bytes::Bytes::from_static(b"interval"), Value::Integer(1800)); + dict.insert(bytes::Bytes::from_static(b"complete"), Value::Integer(42)); + dict.insert(bytes::Bytes::from_static(b"incomplete"), Value::Integer(7)); // Compact IPv4 peers: 127.0.0.1:6881, 10.0.0.1:51413 let mut peer_data = Vec::new(); @@ -150,17 +141,14 @@ fn test_http_parse_announce_dict_peers() { let tracker = HttpTracker::new(); let mut dict = BTreeMap::new(); - dict.insert( - bytes::Bytes::from_static(b"interval"), - Value::Integer(900), - ); + dict.insert(bytes::Bytes::from_static(b"interval"), Value::Integer(900)); let mut peer_dict = BTreeMap::new(); - peer_dict.insert(bytes::Bytes::from_static(b"ip"), Value::string("192.168.1.1")); peer_dict.insert( - bytes::Bytes::from_static(b"port"), - Value::Integer(6881), + bytes::Bytes::from_static(b"ip"), + Value::string("192.168.1.1"), ); + peer_dict.insert(bytes::Bytes::from_static(b"port"), Value::Integer(6881)); let peer_id = vec![0xAB; 20]; peer_dict.insert( @@ -214,10 +202,7 @@ fn test_http_parse_announce_with_warning() { let tracker = HttpTracker::new(); let mut dict = BTreeMap::new(); - dict.insert( - bytes::Bytes::from_static(b"interval"), - Value::Integer(1800), - ); + dict.insert(bytes::Bytes::from_static(b"interval"), Value::Integer(1800)); dict.insert( bytes::Bytes::from_static(b"warning message"), Value::string("slow down"), @@ -233,10 +218,7 @@ fn test_http_parse_announce_with_tracker_id() { let tracker = HttpTracker::new(); let mut dict = BTreeMap::new(); - dict.insert( - bytes::Bytes::from_static(b"interval"), - Value::Integer(1800), - ); + dict.insert(bytes::Bytes::from_static(b"interval"), Value::Integer(1800)); dict.insert( bytes::Bytes::from_static(b"tracker id"), Value::string("abc123"), @@ -252,10 +234,7 @@ fn test_http_parse_announce_with_min_interval() { let tracker = HttpTracker::new(); let mut dict = BTreeMap::new(); - dict.insert( - bytes::Bytes::from_static(b"interval"), - Value::Integer(1800), - ); + dict.insert(bytes::Bytes::from_static(b"interval"), Value::Integer(1800)); dict.insert( bytes::Bytes::from_static(b"min interval"), Value::Integer(60), @@ -288,10 +267,7 @@ fn test_http_parse_announce_with_peers6() { let tracker = HttpTracker::new(); let mut dict = BTreeMap::new(); - dict.insert( - bytes::Bytes::from_static(b"interval"), - Value::Integer(1800), - ); + dict.insert(bytes::Bytes::from_static(b"interval"), Value::Integer(1800)); // Compact IPv6: ::1 port 6881 let mut peer6_data = vec![0u8; 16]; @@ -319,24 +295,15 @@ fn test_http_parse_scrape_response() { let info_hash = [0xAB; 20]; let mut stats = BTreeMap::new(); - stats.insert( - bytes::Bytes::from_static(b"complete"), - Value::Integer(100), - ); - stats.insert( - bytes::Bytes::from_static(b"incomplete"), - Value::Integer(50), - ); + stats.insert(bytes::Bytes::from_static(b"complete"), Value::Integer(100)); + stats.insert(bytes::Bytes::from_static(b"incomplete"), Value::Integer(50)); stats.insert( bytes::Bytes::from_static(b"downloaded"), Value::Integer(1000), ); let mut files = BTreeMap::new(); - files.insert( - bytes::Bytes::from(info_hash.to_vec()), - Value::Dict(stats), - ); + files.insert(bytes::Bytes::from(info_hash.to_vec()), Value::Dict(stats)); let mut dict = BTreeMap::new(); dict.insert(bytes::Bytes::from_static(b"files"), Value::Dict(files)); @@ -410,7 +377,7 @@ fn test_udp_parse_announce_response() { buf.put_u32(1800); // interval buf.put_u32(5); // leechers (incomplete) buf.put_u32(10); // seeders (complete) - // Compact peers + // Compact peers buf.put_slice(&[127, 0, 0, 1, 0x1A, 0xE1]); // 127.0.0.1:6881 buf.put_slice(&[10, 0, 0, 1, 0xC8, 0xD5]); // 10.0.0.1:51413 diff --git a/src-tauri/src/engine/mod.rs b/src-tauri/src/engine/mod.rs index 2491033..ea71a2f 100644 --- a/src-tauri/src/engine/mod.rs +++ b/src-tauri/src/engine/mod.rs @@ -357,61 +357,63 @@ impl TorrentEngine { } match response.bytes().await { - Ok(data) => { - // Also check after download in case content-length was missing - if data.len() as u64 > MAX_TORRENT_FILE_SIZE { - tracing::warn!( + Ok(data) => { + // Also check after download in case content-length was missing + if data.len() as u64 > MAX_TORRENT_FILE_SIZE { + tracing::warn!( "RSS: Downloaded torrent file too large ({} bytes, max {}): {}", data.len(), MAX_TORRENT_FILE_SIZE, torrent_url ); - continue; - } + continue; + } - match oxidebt_torrent::Metainfo::from_bytes(&data) { - Ok(meta) => { - let hash = match &meta.info_hash { - oxidebt_torrent::InfoHash::V1(h) => h.to_hex(), - oxidebt_torrent::InfoHash::V2(h) => h.to_hex(), - oxidebt_torrent::InfoHash::Hybrid { v1, .. } => v1.to_hex(), - }; + match oxidebt_torrent::Metainfo::from_bytes(&data) { + Ok(meta) => { + let hash = match &meta.info_hash { + oxidebt_torrent::InfoHash::V1(h) => h.to_hex(), + oxidebt_torrent::InfoHash::V2(h) => h.to_hex(), + oxidebt_torrent::InfoHash::Hybrid { + v1, .. + } => v1.to_hex(), + }; + + tracing::info!( + "RSS: Adding torrent '{}' ({})", + meta.info.name, + hash + ); - tracing::info!( - "RSS: Adding torrent '{}' ({})", - meta.info.name, - hash - ); + let mut managed = + ManagedTorrent::with_save_path(meta, save_path); + managed.category = event.category; + managed.tags = event.tags.into_iter().collect(); + managed.share_limits = default_limits.read().clone(); - let mut managed = - ManagedTorrent::with_save_path(meta, save_path); - managed.category = event.category; - managed.tags = event.tags.into_iter().collect(); - managed.share_limits = default_limits.read().clone(); + if event.add_paused { + managed.state = TorrentState::Paused; + } - if event.add_paused { - managed.state = TorrentState::Paused; + torrents.write().insert(hash, managed); + } + Err(e) => { + tracing::warn!( + "RSS: Failed to parse torrent from {}: {}", + torrent_url, + e + ); } - - torrents.write().insert(hash, managed); - } - Err(e) => { - tracing::warn!( - "RSS: Failed to parse torrent from {}: {}", - torrent_url, - e - ); } } + Err(e) => { + tracing::warn!( + "RSS: Failed to read torrent from {}: {}", + torrent_url, + e + ); + } } - Err(e) => { - tracing::warn!( - "RSS: Failed to read torrent from {}: {}", - torrent_url, - e - ); - } - } } Err(e) => { tracing::warn!( diff --git a/src-tauri/src/engine/search.rs b/src-tauri/src/engine/search.rs index eb48194..e93ff82 100644 --- a/src-tauri/src/engine/search.rs +++ b/src-tauri/src/engine/search.rs @@ -24,39 +24,30 @@ fn validate_plugin_filename(filename: &str) -> Result<(), String> { // Check for path separators (both Unix and Windows) if filename.contains('/') || filename.contains('\\') { - return Err(format!( - "Filename contains path separators: {}", - filename - )); + return Err(format!("Filename contains path separators: {}", filename)); } // Check for parent directory references if filename == ".." || filename.starts_with("..") { - return Err(format!( - "Filename contains path traversal: {}", - filename - )); + return Err(format!("Filename contains path traversal: {}", filename)); } // Check for hidden files (starting with .) if filename.starts_with('.') { - return Err(format!( - "Hidden files not allowed: {}", - filename - )); + return Err(format!("Hidden files not allowed: {}", filename)); } // Must have .py extension if !filename.ends_with(".py") { - return Err(format!( - "Plugin must have .py extension: {}", - filename - )); + return Err(format!("Plugin must have .py extension: {}", filename)); } // Only allow alphanumeric, underscore, hyphen, and .py extension let name_without_ext = &filename[..filename.len() - 3]; - if !name_without_ext.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') { + if !name_without_ext + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-') + { return Err(format!( "Filename contains invalid characters: {}", filename diff --git a/src-tauri/src/engine/stats.rs b/src-tauri/src/engine/stats.rs index 04222c0..047a4a5 100644 --- a/src-tauri/src/engine/stats.rs +++ b/src-tauri/src/engine/stats.rs @@ -386,11 +386,7 @@ mod tests { let rate = calc.rate(); // Should converge toward 1MB/s - assert!( - rate > 500_000.0 && rate < 3_000_000.0, - "Rate was {}", - rate - ); + assert!(rate > 500_000.0 && rate < 3_000_000.0, "Rate was {}", rate); } // ======================== diff --git a/src-tauri/tests/integration_tests.rs b/src-tauri/tests/integration_tests.rs index a5c9ee9..3732f34 100644 --- a/src-tauri/tests/integration_tests.rs +++ b/src-tauri/tests/integration_tests.rs @@ -50,7 +50,8 @@ async fn integration_test_parse_torrent_and_create_storage() { let pieces = vec![PieceInfo::v1(0, piece_hash, 0, metainfo.info.total_length)]; - let storage = TorrentStorage::new(base_path, files, pieces, metainfo.info.total_length, false).expect("test storage creation"); + let storage = TorrentStorage::new(base_path, files, pieces, metainfo.info.total_length, false) + .expect("test storage creation"); storage.preallocate().await.unwrap(); @@ -88,7 +89,8 @@ async fn integration_test_multifile_torrent_storage() { let pieces = vec![PieceInfo::v1(0, piece_hash, 0, total_size)]; - let storage = TorrentStorage::new(base_path.clone(), files, pieces, total_size, false).expect("test storage creation"); + let storage = TorrentStorage::new(base_path.clone(), files, pieces, total_size, false) + .expect("test storage creation"); storage.preallocate().await.unwrap(); storage.write_piece(0, &piece_data).await.unwrap(); @@ -130,7 +132,8 @@ async fn integration_test_piece_manager_with_disk() { .map(|(i, hash)| PieceInfo::v1(i as u32, *hash, i as u64 * piece_length, piece_length)) .collect(); - let storage = TorrentStorage::new(base_path, files, pieces, total_size, false).expect("test storage creation"); + let storage = TorrentStorage::new(base_path, files, pieces, total_size, false) + .expect("test storage creation"); storage.preallocate().await.unwrap(); let piece_manager = PieceManager::new(piece_count, piece_length, total_size); @@ -251,7 +254,8 @@ async fn integration_test_disk_manager_multiple_torrents() { .map(|i| PieceInfo::v1(i as u32, [0u8; 20], i as u64 * piece_length, piece_length)) .collect(); - let storage = TorrentStorage::new(base_path, files, pieces, total_size, false).expect("test storage creation"); + let storage = TorrentStorage::new(base_path, files, pieces, total_size, false) + .expect("test storage creation"); storage.preallocate().await.unwrap(); let hash = format!("hash_{}", torrent_id); @@ -377,9 +381,10 @@ async fn integration_test_simulated_download_flow() { .map(|(i, hash)| PieceInfo::v1(i as u32, *hash, i as u64 * piece_length, piece_length)) .collect(); - let storage = Arc::new(TorrentStorage::new( - base_path, files, pieces, total_size, false, - ).expect("test storage creation")); + let storage = Arc::new( + TorrentStorage::new(base_path, files, pieces, total_size, false) + .expect("test storage creation"), + ); storage.preallocate().await.unwrap(); let piece_manager = Arc::new(PieceManager::new(piece_count, piece_length, total_size)); From 733416adbdd8a109d99e253f6e0f7d9092ead4c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 13:54:39 +0000 Subject: [PATCH 4/4] Fix remaining clippy warnings in app crate and example - Fix clippy::double_ended_iterator_last: use next_back() instead of last() - Fix clippy::manual_contains: use contains() instead of iter().any() - Fix clippy::collapsible_if: collapse nested if in share limits check - Fix clippy::useless_vec: use array literal in generate_torrents example - Allow clippy::too_many_arguments on add_rss_rule tauri command https://claude.ai/code/session_014Z9cWnuBb6Gdu2bVqJzf6H --- examples/generate_torrents.rs | 2 +- src-tauri/src/engine/mod.rs | 7 +++---- src-tauri/src/engine/search.rs | 2 +- src-tauri/src/engine/torrent.rs | 4 +--- src-tauri/src/lib.rs | 1 + 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/examples/generate_torrents.rs b/examples/generate_torrents.rs index 5116195..ea1ff7d 100644 --- a/examples/generate_torrents.rs +++ b/examples/generate_torrents.rs @@ -81,7 +81,7 @@ fn generate_v1_single_file(dir: &Path) { fn generate_v1_multi_file(dir: &Path) { let piece_length: i64 = 16384; - let files = vec![ + let files = [ (b"file1.txt".to_vec(), 512i64), (b"file2.txt".to_vec(), 768i64), (b"subdir/file3.txt".to_vec(), 256i64), diff --git a/src-tauri/src/engine/mod.rs b/src-tauri/src/engine/mod.rs index ea71a2f..6a300dd 100644 --- a/src-tauri/src/engine/mod.rs +++ b/src-tauri/src/engine/mod.rs @@ -446,10 +446,9 @@ impl TorrentEngine { if matches!( torrent.state, TorrentState::Seeding | TorrentState::Completed - ) { - if torrent.share_limits_reached() { - actions.push((hash.clone(), torrent.share_limits.limit_action)); - } + ) && torrent.share_limits_reached() + { + actions.push((hash.clone(), torrent.share_limits.limit_action)); } } } diff --git a/src-tauri/src/engine/search.rs b/src-tauri/src/engine/search.rs index e93ff82..12088fc 100644 --- a/src-tauri/src/engine/search.rs +++ b/src-tauri/src/engine/search.rs @@ -252,7 +252,7 @@ impl SearchEngine { // Extract filename from URL or generate one let filename = url .split('/') - .last() + .next_back() .filter(|s| s.ends_with(".py")) .unwrap_or("plugin.py"); diff --git a/src-tauri/src/engine/torrent.rs b/src-tauri/src/engine/torrent.rs index 8ed974e..2555a04 100644 --- a/src-tauri/src/engine/torrent.rs +++ b/src-tauri/src/engine/torrent.rs @@ -276,9 +276,7 @@ impl ManagedTorrent { /// Check if any files are set to skip pub fn has_skipped_files(&self) -> bool { - self.file_priorities - .iter() - .any(|p| *p == FilePriority::Skip) + self.file_priorities.contains(&FilePriority::Skip) } pub fn info_hash_hex(&self) -> String { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 60a661d..723ea4b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1124,6 +1124,7 @@ async fn get_rss_feed_items( } #[tauri::command] +#[allow(clippy::too_many_arguments)] async fn add_rss_rule( state: State<'_, AppState>, name: String,