1#![allow(
2 clippy::cast_possible_truncation,
3 clippy::cast_precision_loss,
4 clippy::cast_possible_wrap,
5 clippy::cast_sign_loss,
6 clippy::unchecked_time_subtraction,
7 reason = "M175: session-level counters/rates bounded by torrent count; time deltas use post-init Instants"
8)]
9
10use std::collections::HashMap;
16use std::net::{IpAddr, SocketAddr};
17use std::path::PathBuf;
18use std::sync::Arc;
19use std::sync::atomic::{AtomicU32, Ordering};
20
21use dashmap::DashMap;
22use tokio::sync::{broadcast, mpsc, oneshot};
23
24use tracing::{debug, info, warn};
25
26use irontide_core::{DEFAULT_CHUNK_SIZE, Id20, Lengths, Magnet};
27use irontide_dht::DhtHandle;
28use irontide_storage::TorrentStorage;
29
30use crate::alert::{Alert, AlertCategory, AlertKind, AlertStream, post_alert};
31use crate::settings::Settings;
32use crate::torrent::TorrentHandle;
33use crate::types::{
34 FileInfo, SessionStats, TorrentConfig, TorrentInfo, TorrentState, TorrentStats, TorrentSummary,
35};
36
37type SharedBucket = Arc<parking_lot::Mutex<crate::rate_limiter::TokenBucket>>;
39
40type QueueMoveFn = fn(&mut [crate::queue::QueueEntry], Id20) -> Vec<(Id20, i32, i32)>;
42
43pub(crate) type SharedBanManager = Arc<parking_lot::RwLock<crate::ban::BanManager>>;
45
46pub(crate) type SharedIpFilter = Arc<parking_lot::RwLock<crate::ip_filter::IpFilter>>;
48
49#[derive(Debug, Clone)]
51pub struct ResumeLoadResult {
52 pub restored: usize,
54 pub skipped: usize,
56 pub failed: usize,
58}
59
60#[derive(Debug, Clone)]
65pub enum AddSource {
66 Magnet(String),
68 Bytes(Vec<u8>),
70}
71
72#[derive(Debug, Clone)]
93pub struct AddTorrentParams {
94 pub source: AddSource,
96 pub category: Option<String>,
99 pub tags: Vec<String>,
102 pub download_dir: Option<PathBuf>,
105 pub paused: Option<bool>,
111 pub skip_checking: bool,
113}
114
115impl AddTorrentParams {
116 #[must_use]
118 pub fn magnet(uri: impl Into<String>) -> Self {
119 Self {
120 source: AddSource::Magnet(uri.into()),
121 category: None,
122 tags: Vec::new(),
123 download_dir: None,
124 paused: None,
125 skip_checking: false,
126 }
127 }
128
129 #[must_use]
131 pub fn bytes(data: impl Into<Vec<u8>>) -> Self {
132 Self {
133 source: AddSource::Bytes(data.into()),
134 category: None,
135 tags: Vec::new(),
136 download_dir: None,
137 paused: None,
138 skip_checking: false,
139 }
140 }
141
142 #[must_use]
145 pub fn with_category(mut self, name: impl Into<String>) -> Self {
146 self.category = Some(name.into());
147 self
148 }
149
150 #[must_use]
154 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
155 self.tags = tags;
156 self
157 }
158
159 #[must_use]
161 pub fn with_download_dir(mut self, dir: impl Into<PathBuf>) -> Self {
162 self.download_dir = Some(dir.into());
163 self
164 }
165
166 #[must_use]
171 pub fn paused(mut self, paused: bool) -> Self {
172 self.paused = Some(paused);
173 self
174 }
175
176 #[must_use]
178 pub fn skip_checking(mut self, skip: bool) -> Self {
179 self.skip_checking = skip;
180 self
181 }
182}
183
184struct TorrentEntry {
194 handle: TorrentHandle,
195 queue_position: i32,
197 auto_managed: bool,
199 started_at: Option<tokio::time::Instant>,
201 smoothed_download_rate: f64,
203 smoothed_upload_rate: f64,
205}
206
207struct PreparedAddTorrent {
224 handle: TorrentHandle,
225 info_hash: Id20,
226 is_private: bool,
227 m170_post: Option<M170PostAdd>,
231}
232
233struct M170PostAdd {
237 category: Option<String>,
238 paused: bool,
239}
240
241struct AddTorrentPrepBundle {
248 torrent_meta: irontide_core::TorrentMeta,
249 storage_override: Option<Arc<dyn TorrentStorage>>,
250 torrent_config: TorrentConfig,
251 disk_manager: crate::disk::DiskManagerHandle,
252 dht_v4_broadcast: irontide_dht::DhtBroadcast,
253 dht_v6_broadcast: irontide_dht::DhtBroadcast,
254 global_up: Option<SharedBucket>,
255 global_down: Option<SharedBucket>,
256 slot_tuner: crate::slot_tuner::SlotTuner,
257 alert_tx: broadcast::Sender<Alert>,
258 alert_mask: Arc<AtomicU32>,
259 utp_socket: Option<irontide_utp::UtpSocket>,
260 utp_socket_v6: Option<irontide_utp::UtpSocket>,
261 ban_manager: SharedBanManager,
262 ip_filter: SharedIpFilter,
263 plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
264 sam_session: Option<Arc<crate::i2p::SamSession>>,
265 ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
266 factory: Arc<crate::transport::NetworkFactory>,
267 hash_pool: std::sync::Arc<crate::hash_pool::HashPool>,
268 counters: Arc<crate::stats::SessionCounters>,
269 m170_post: Option<M170PostAdd>,
270}
271
272impl TorrentEntry {
273 async fn is_private(&self) -> bool {
284 match self.handle.get_meta().await {
285 Ok(Some(meta)) => meta.info.private == Some(1),
286 _ => false,
290 }
291 }
292}
293
294enum SessionCommand {
296 AddTorrent {
297 meta: Box<irontide_core::TorrentMeta>,
298 storage: Option<Arc<dyn TorrentStorage>>,
299 download_dir: Option<PathBuf>,
300 reply: oneshot::Sender<crate::Result<Id20>>,
301 },
302 CommitAddTorrent {
311 result: crate::Result<PreparedAddTorrent>,
312 reply: oneshot::Sender<crate::Result<Id20>>,
313 },
314 AddMagnet {
315 magnet: Magnet,
316 download_dir: Option<PathBuf>,
317 reply: oneshot::Sender<crate::Result<Id20>>,
318 },
319 RemoveTorrent {
320 info_hash: Id20,
321 reply: oneshot::Sender<crate::Result<()>>,
322 },
323 PauseTorrent {
324 info_hash: Id20,
325 reply: oneshot::Sender<crate::Result<()>>,
326 },
327 ResumeTorrent {
328 info_hash: Id20,
329 reply: oneshot::Sender<crate::Result<()>>,
330 },
331 ForceResumeTorrent {
332 info_hash: Id20,
333 reply: oneshot::Sender<crate::Result<()>>,
334 },
335 SetTorrentSeedRatio {
336 info_hash: Id20,
337 limit: Option<f64>,
338 reply: oneshot::Sender<crate::Result<()>>,
339 },
340 TorrentStats {
341 info_hash: Id20,
342 reply: oneshot::Sender<crate::Result<TorrentStats>>,
343 },
344 TorrentInfo {
345 info_hash: Id20,
346 reply: oneshot::Sender<crate::Result<TorrentInfo>>,
347 },
348 ListTorrents {
349 reply: oneshot::Sender<Vec<Id20>>,
350 },
351 SessionStats {
352 reply: oneshot::Sender<SessionStats>,
353 },
354 SaveTorrentResumeData {
355 info_hash: Id20,
356 reply: oneshot::Sender<crate::Result<irontide_core::FastResumeData>>,
357 },
358 SaveSessionState {
359 reply: oneshot::Sender<crate::Result<crate::persistence::SessionState>>,
360 },
361 LoadResumeState {
363 reply: oneshot::Sender<crate::Result<ResumeLoadResult>>,
364 },
365 QueuePosition {
366 info_hash: Id20,
367 reply: oneshot::Sender<crate::Result<i32>>,
368 },
369 SetQueuePosition {
370 info_hash: Id20,
371 pos: i32,
372 reply: oneshot::Sender<crate::Result<()>>,
373 },
374 QueuePositionUp {
375 info_hash: Id20,
376 reply: oneshot::Sender<crate::Result<()>>,
377 },
378 QueuePositionDown {
379 info_hash: Id20,
380 reply: oneshot::Sender<crate::Result<()>>,
381 },
382 QueuePositionTop {
383 info_hash: Id20,
384 reply: oneshot::Sender<crate::Result<()>>,
385 },
386 QueuePositionBottom {
387 info_hash: Id20,
388 reply: oneshot::Sender<crate::Result<()>>,
389 },
390 BanPeer {
391 ip: IpAddr,
392 reply: oneshot::Sender<()>,
393 },
394 UnbanPeer {
395 ip: IpAddr,
396 reply: oneshot::Sender<bool>,
397 },
398 BannedPeers {
399 reply: oneshot::Sender<Vec<IpAddr>>,
400 },
401 SetIpFilter {
402 filter: crate::ip_filter::IpFilter,
403 reply: oneshot::Sender<()>,
404 },
405 GetIpFilter {
406 reply: oneshot::Sender<crate::ip_filter::IpFilter>,
407 },
408 GetSettings {
409 reply: oneshot::Sender<Settings>,
410 },
411 ApplySettings {
412 settings: Box<Settings>,
413 reply: oneshot::Sender<crate::Result<()>>,
414 },
415 MoveTorrentStorage {
416 info_hash: Id20,
417 new_path: std::path::PathBuf,
418 reply: oneshot::Sender<crate::Result<()>>,
419 },
420 AddPeers {
421 info_hash: Id20,
422 peers: Vec<SocketAddr>,
423 source: crate::peer_state::PeerSource,
424 reply: oneshot::Sender<crate::Result<()>>,
425 },
426 OpenFile {
427 info_hash: Id20,
428 file_index: usize,
429 reply: oneshot::Sender<crate::Result<crate::streaming::FileStream>>,
430 },
431 ForceReannounce {
432 info_hash: Id20,
433 reply: oneshot::Sender<crate::Result<()>>,
434 },
435 TrackerList {
436 info_hash: Id20,
437 reply: oneshot::Sender<crate::Result<Vec<crate::tracker_manager::TrackerInfo>>>,
438 },
439 GetPeerSourceCounts {
443 info_hash: Id20,
444 reply: oneshot::Sender<crate::Result<(usize, usize)>>,
445 },
446 QueryUnchokeDurations {
450 info_hash: Id20,
451 reply: oneshot::Sender<Option<std::collections::HashMap<SocketAddr, std::time::Duration>>>,
452 },
453 GetWebSeedStats {
456 info_hash: Id20,
457 reply: oneshot::Sender<crate::Result<Vec<irontide_core::WebSeedStats>>>,
458 },
459 Scrape {
460 info_hash: Id20,
461 reply: oneshot::Sender<crate::Result<Option<(String, irontide_tracker::ScrapeInfo)>>>,
462 },
463 SetFilePriority {
464 info_hash: Id20,
465 index: usize,
466 priority: irontide_core::FilePriority,
467 reply: oneshot::Sender<crate::Result<()>>,
468 },
469 FilePriorities {
470 info_hash: Id20,
471 reply: oneshot::Sender<crate::Result<Vec<irontide_core::FilePriority>>>,
472 },
473 SetDownloadLimit {
474 info_hash: Id20,
475 bytes_per_sec: u64,
476 reply: oneshot::Sender<crate::Result<()>>,
477 },
478 SetUploadLimit {
479 info_hash: Id20,
480 bytes_per_sec: u64,
481 reply: oneshot::Sender<crate::Result<()>>,
482 },
483 DownloadLimit {
484 info_hash: Id20,
485 reply: oneshot::Sender<crate::Result<u64>>,
486 },
487 UploadLimit {
488 info_hash: Id20,
489 reply: oneshot::Sender<crate::Result<u64>>,
490 },
491 SetSequentialDownload {
492 info_hash: Id20,
493 enabled: bool,
494 reply: oneshot::Sender<crate::Result<()>>,
495 },
496 IsSequentialDownload {
497 info_hash: Id20,
498 reply: oneshot::Sender<crate::Result<bool>>,
499 },
500 SetSuperSeeding {
501 info_hash: Id20,
502 enabled: bool,
503 reply: oneshot::Sender<crate::Result<()>>,
504 },
505 IsSuperSeeding {
506 info_hash: Id20,
507 reply: oneshot::Sender<crate::Result<bool>>,
508 },
509 SetSeedMode {
511 info_hash: Id20,
512 enabled: bool,
513 reply: oneshot::Sender<crate::Result<()>>,
514 },
515 AddTracker {
516 info_hash: Id20,
517 url: String,
518 reply: oneshot::Sender<crate::Result<()>>,
519 },
520 ReplaceTrackers {
521 info_hash: Id20,
522 urls: Vec<String>,
523 reply: oneshot::Sender<crate::Result<()>>,
524 },
525 ForceRecheck {
527 info_hash: Id20,
528 reply: oneshot::Sender<crate::Result<()>>,
529 },
530 RenameFile {
532 info_hash: Id20,
533 file_index: usize,
534 new_name: String,
535 reply: oneshot::Sender<crate::Result<()>>,
536 },
537 SetMaxConnections {
539 info_hash: Id20,
540 limit: usize,
541 reply: oneshot::Sender<crate::Result<()>>,
542 },
543 MaxConnections {
545 info_hash: Id20,
546 reply: oneshot::Sender<crate::Result<usize>>,
547 },
548 SetMaxUploads {
550 info_hash: Id20,
551 limit: usize,
552 reply: oneshot::Sender<crate::Result<()>>,
553 },
554 MaxUploads {
556 info_hash: Id20,
557 reply: oneshot::Sender<crate::Result<usize>>,
558 },
559 GetPeerInfo {
561 info_hash: Id20,
562 reply: oneshot::Sender<crate::Result<Vec<crate::types::PeerInfo>>>,
563 },
564 GetDownloadQueue {
566 info_hash: Id20,
567 reply: oneshot::Sender<crate::Result<Vec<crate::types::PartialPieceInfo>>>,
568 },
569 HavePiece {
571 info_hash: Id20,
572 index: u32,
573 reply: oneshot::Sender<crate::Result<bool>>,
574 },
575 PieceAvailability {
577 info_hash: Id20,
578 reply: oneshot::Sender<crate::Result<Vec<u32>>>,
579 },
580 FileProgress {
582 info_hash: Id20,
583 reply: oneshot::Sender<crate::Result<Vec<u64>>>,
584 },
585 InfoHashesQuery {
587 info_hash: Id20,
588 reply: oneshot::Sender<crate::Result<irontide_core::InfoHashes>>,
589 },
590 TorrentFile {
592 info_hash: Id20,
593 reply: oneshot::Sender<crate::Result<Option<irontide_core::TorrentMetaV1>>>,
594 },
595 TorrentFileV2 {
597 info_hash: Id20,
598 reply: oneshot::Sender<crate::Result<Option<irontide_core::TorrentMetaV2>>>,
599 },
600 ForceDhtAnnounce {
602 info_hash: Id20,
603 reply: oneshot::Sender<crate::Result<()>>,
604 },
605 ForceLsdAnnounce {
607 info_hash: Id20,
608 reply: oneshot::Sender<crate::Result<()>>,
609 },
610 ReadPiece {
612 info_hash: Id20,
613 index: u32,
614 reply: oneshot::Sender<crate::Result<bytes::Bytes>>,
615 },
616 FlushCache {
618 info_hash: Id20,
619 reply: oneshot::Sender<crate::Result<()>>,
620 },
621 IsValid {
623 info_hash: Id20,
624 reply: oneshot::Sender<bool>,
625 },
626 ClearError {
628 info_hash: Id20,
629 reply: oneshot::Sender<crate::Result<()>>,
630 },
631 FileStatus {
633 info_hash: Id20,
634 reply: oneshot::Sender<crate::Result<Vec<crate::types::FileStatus>>>,
635 },
636 Flags {
638 info_hash: Id20,
639 reply: oneshot::Sender<crate::Result<crate::types::TorrentFlags>>,
640 },
641 SetFlags {
643 info_hash: Id20,
644 flags: crate::types::TorrentFlags,
645 reply: oneshot::Sender<crate::Result<()>>,
646 },
647 UnsetFlags {
649 info_hash: Id20,
650 flags: crate::types::TorrentFlags,
651 reply: oneshot::Sender<crate::Result<()>>,
652 },
653 ConnectPeer {
655 info_hash: Id20,
656 addr: SocketAddr,
657 reply: oneshot::Sender<crate::Result<()>>,
658 },
659 DhtPutImmutable {
660 value: Vec<u8>,
661 reply: oneshot::Sender<crate::Result<Id20>>,
662 },
663 DhtGetImmutable {
664 target: Id20,
665 reply: oneshot::Sender<crate::Result<Option<Vec<u8>>>>,
666 },
667 DhtPutMutable {
668 keypair_bytes: [u8; 32],
669 value: Vec<u8>,
670 seq: i64,
671 salt: Vec<u8>,
672 reply: oneshot::Sender<crate::Result<Id20>>,
673 },
674 #[allow(clippy::type_complexity)]
675 DhtGetMutable {
676 public_key: [u8; 32],
677 salt: Vec<u8>,
678 reply: oneshot::Sender<crate::Result<Option<(Vec<u8>, i64)>>>,
679 },
680 SaveResumeState {
682 reply: oneshot::Sender<crate::Result<usize>>,
683 },
684 PostSessionStats,
686 AddTorrentM170 {
689 params: Box<AddTorrentParams>,
690 reply: oneshot::Sender<crate::Result<Id20>>,
691 },
692 CreateCategory {
694 name: String,
695 save_path: PathBuf,
696 reply: oneshot::Sender<Result<(), crate::category_manager::CategoryError>>,
697 },
698 EditCategory {
700 name: String,
701 save_path: PathBuf,
702 reply: oneshot::Sender<Result<(), crate::category_manager::CategoryError>>,
703 },
704 RemoveCategories {
706 names: Vec<String>,
707 reply: oneshot::Sender<Vec<String>>,
708 },
709 ListCategories {
711 reply: oneshot::Sender<Vec<crate::category_manager::CategoryMetadata>>,
712 },
713 CreateTags {
716 names: Vec<String>,
717 reply: oneshot::Sender<Vec<Result<(), crate::tag_manager::TagError>>>,
718 },
719 DeleteTags {
722 names: Vec<String>,
723 reply: oneshot::Sender<Vec<String>>,
724 },
725 ListTags {
727 reply: oneshot::Sender<Vec<String>>,
728 },
729 AddTagsToTorrents {
735 info_hashes: Vec<Id20>,
736 tags: Vec<String>,
737 reply: oneshot::Sender<crate::Result<()>>,
738 },
739 RemoveTagsFromTorrents {
742 info_hashes: Vec<Id20>,
743 tags: Vec<String>,
744 reply: oneshot::Sender<crate::Result<()>>,
745 },
746 RemoveTorrentWithFiles {
749 info_hash: Id20,
750 reply: oneshot::Sender<crate::Result<()>>,
751 },
752 GetWebSeeds {
755 info_hash: Id20,
756 reply: oneshot::Sender<crate::Result<Vec<String>>>,
757 },
758 GetPieceStates {
761 info_hash: Id20,
762 reply: oneshot::Sender<crate::Result<Vec<u8>>>,
763 },
764 GetPieceHashes {
766 info_hash: Id20,
767 offset: u32,
768 limit: u32,
769 reply: oneshot::Sender<crate::Result<Vec<String>>>,
770 },
771 DhtNodeCount {
774 reply: oneshot::Sender<usize>,
775 },
776 DebugState {
782 reply: oneshot::Sender<crate::types::DebugState>,
783 },
784 #[cfg(feature = "test-util")]
785 TestInjectMetadata {
786 info_hash: Id20,
787 info_bytes: Vec<u8>,
788 reply: oneshot::Sender<crate::Result<()>>,
789 },
790 Shutdown,
791}
792
793impl SessionCommand {
794 fn name(&self) -> &'static str {
799 match self {
800 Self::AddTorrent { .. } => "AddTorrent",
801 Self::CommitAddTorrent { .. } => "CommitAddTorrent",
802 Self::AddMagnet { .. } => "AddMagnet",
803 Self::RemoveTorrent { .. } => "RemoveTorrent",
804 Self::PauseTorrent { .. } => "PauseTorrent",
805 Self::ResumeTorrent { .. } => "ResumeTorrent",
806 Self::ForceResumeTorrent { .. } => "ForceResumeTorrent",
807 Self::SetTorrentSeedRatio { .. } => "SetTorrentSeedRatio",
808 Self::TorrentStats { .. } => "TorrentStats",
809 Self::TorrentInfo { .. } => "TorrentInfo",
810 Self::ListTorrents { .. } => "ListTorrents",
811 Self::SessionStats { .. } => "SessionStats",
812 Self::SaveTorrentResumeData { .. } => "SaveTorrentResumeData",
813 Self::SaveSessionState { .. } => "SaveSessionState",
814 Self::LoadResumeState { .. } => "LoadResumeState",
815 Self::QueuePosition { .. } => "QueuePosition",
816 Self::SetQueuePosition { .. } => "SetQueuePosition",
817 Self::QueuePositionUp { .. } => "QueuePositionUp",
818 Self::QueuePositionDown { .. } => "QueuePositionDown",
819 Self::QueuePositionTop { .. } => "QueuePositionTop",
820 Self::QueuePositionBottom { .. } => "QueuePositionBottom",
821 Self::BanPeer { .. } => "BanPeer",
822 Self::UnbanPeer { .. } => "UnbanPeer",
823 Self::BannedPeers { .. } => "BannedPeers",
824 Self::SetIpFilter { .. } => "SetIpFilter",
825 Self::GetIpFilter { .. } => "GetIpFilter",
826 Self::GetSettings { .. } => "GetSettings",
827 Self::ApplySettings { .. } => "ApplySettings",
828 Self::MoveTorrentStorage { .. } => "MoveTorrentStorage",
829 Self::AddPeers { .. } => "AddPeers",
830 Self::OpenFile { .. } => "OpenFile",
831 Self::ForceReannounce { .. } => "ForceReannounce",
832 Self::TrackerList { .. } => "TrackerList",
833 Self::GetPeerSourceCounts { .. } => "GetPeerSourceCounts",
834 Self::QueryUnchokeDurations { .. } => "QueryUnchokeDurations",
835 Self::GetWebSeedStats { .. } => "GetWebSeedStats",
836 Self::Scrape { .. } => "Scrape",
837 Self::SetFilePriority { .. } => "SetFilePriority",
838 Self::FilePriorities { .. } => "FilePriorities",
839 Self::SetDownloadLimit { .. } => "SetDownloadLimit",
840 Self::SetUploadLimit { .. } => "SetUploadLimit",
841 Self::DownloadLimit { .. } => "DownloadLimit",
842 Self::UploadLimit { .. } => "UploadLimit",
843 Self::SetSequentialDownload { .. } => "SetSequentialDownload",
844 Self::IsSequentialDownload { .. } => "IsSequentialDownload",
845 Self::SetSuperSeeding { .. } => "SetSuperSeeding",
846 Self::IsSuperSeeding { .. } => "IsSuperSeeding",
847 Self::SetSeedMode { .. } => "SetSeedMode",
848 Self::AddTracker { .. } => "AddTracker",
849 Self::ReplaceTrackers { .. } => "ReplaceTrackers",
850 Self::ForceRecheck { .. } => "ForceRecheck",
851 Self::RenameFile { .. } => "RenameFile",
852 Self::SetMaxConnections { .. } => "SetMaxConnections",
853 Self::MaxConnections { .. } => "MaxConnections",
854 Self::SetMaxUploads { .. } => "SetMaxUploads",
855 Self::MaxUploads { .. } => "MaxUploads",
856 Self::GetPeerInfo { .. } => "GetPeerInfo",
857 Self::GetDownloadQueue { .. } => "GetDownloadQueue",
858 Self::HavePiece { .. } => "HavePiece",
859 Self::PieceAvailability { .. } => "PieceAvailability",
860 Self::FileProgress { .. } => "FileProgress",
861 Self::InfoHashesQuery { .. } => "InfoHashesQuery",
862 Self::TorrentFile { .. } => "TorrentFile",
863 Self::TorrentFileV2 { .. } => "TorrentFileV2",
864 Self::ForceDhtAnnounce { .. } => "ForceDhtAnnounce",
865 Self::ForceLsdAnnounce { .. } => "ForceLsdAnnounce",
866 Self::ReadPiece { .. } => "ReadPiece",
867 Self::FlushCache { .. } => "FlushCache",
868 Self::IsValid { .. } => "IsValid",
869 Self::ClearError { .. } => "ClearError",
870 Self::FileStatus { .. } => "FileStatus",
871 Self::Flags { .. } => "Flags",
872 Self::SetFlags { .. } => "SetFlags",
873 Self::UnsetFlags { .. } => "UnsetFlags",
874 Self::ConnectPeer { .. } => "ConnectPeer",
875 Self::DhtPutImmutable { .. } => "DhtPutImmutable",
876 Self::DhtGetImmutable { .. } => "DhtGetImmutable",
877 Self::DhtPutMutable { .. } => "DhtPutMutable",
878 Self::DhtGetMutable { .. } => "DhtGetMutable",
879 Self::SaveResumeState { .. } => "SaveResumeState",
880 Self::PostSessionStats => "PostSessionStats",
881 Self::AddTorrentM170 { .. } => "AddTorrentM170",
882 Self::CreateCategory { .. } => "CreateCategory",
883 Self::EditCategory { .. } => "EditCategory",
884 Self::RemoveCategories { .. } => "RemoveCategories",
885 Self::ListCategories { .. } => "ListCategories",
886 Self::CreateTags { .. } => "CreateTags",
887 Self::DeleteTags { .. } => "DeleteTags",
888 Self::ListTags { .. } => "ListTags",
889 Self::AddTagsToTorrents { .. } => "AddTagsToTorrents",
890 Self::RemoveTagsFromTorrents { .. } => "RemoveTagsFromTorrents",
891 Self::RemoveTorrentWithFiles { .. } => "RemoveTorrentWithFiles",
892 Self::GetWebSeeds { .. } => "GetWebSeeds",
893 Self::GetPieceStates { .. } => "GetPieceStates",
894 Self::GetPieceHashes { .. } => "GetPieceHashes",
895 Self::DhtNodeCount { .. } => "DhtNodeCount",
896 Self::DebugState { .. } => "DebugState",
897 #[cfg(feature = "test-util")]
898 Self::TestInjectMetadata { .. } => "TestInjectMetadata",
899 Self::Shutdown => "Shutdown",
900 }
901 }
902}
903
904#[derive(Clone)]
909struct SessionCmdSender(mpsc::Sender<(tokio::time::Instant, SessionCommand)>);
910
911impl SessionCmdSender {
912 async fn send(
913 &self,
914 cmd: SessionCommand,
915 ) -> Result<(), mpsc::error::SendError<SessionCommand>> {
916 let sent_at = tokio::time::Instant::now();
917 self.0
918 .send((sent_at, cmd))
919 .await
920 .map_err(|e| mpsc::error::SendError(e.0.1))
921 }
922}
923
924#[derive(Debug, Clone, Default)]
932pub struct AppliedSettings {
933 pub immediate: Vec<&'static str>,
935 pub restart_required: Vec<&'static str>,
939}
940
941fn classify_immediate(old: &Settings, new: &Settings) -> Vec<&'static str> {
960 let mut v = Vec::new();
961 if old.download_rate_limit != new.download_rate_limit {
962 v.push("dl_limit");
963 }
964 if old.upload_rate_limit != new.upload_rate_limit {
965 v.push("up_limit");
966 }
967 if old.max_peers_per_torrent != new.max_peers_per_torrent {
968 v.push("max_connec");
969 }
970 if old.max_ratio_action != new.max_ratio_action {
971 v.push("max_ratio_act");
972 }
973 if old.create_subfolder != new.create_subfolder {
974 v.push("create_subfolder_enabled");
975 }
976 if old.auto_manage_torrents != new.auto_manage_torrents {
977 v.push("auto_tmm_enabled");
978 }
979 if old.queueing_enabled != new.queueing_enabled {
980 v.push("queueing_enabled");
981 }
982 if old.seed_ratio_limit != new.seed_ratio_limit {
983 v.push("max_ratio");
984 }
985 if old.listen_port != new.listen_port {
989 v.push("listen_port");
990 }
991 if old.enable_dht != new.enable_dht {
992 v.push("dht");
993 }
994 if old.enable_lsd != new.enable_lsd {
995 v.push("lsd");
996 }
997 if old.qbt_compat.csrf_protection_enabled != new.qbt_compat.csrf_protection_enabled {
1002 v.push("web_ui_csrf_protection_enabled");
1003 }
1004 if old.qbt_compat.host_header_validation_enabled
1005 != new.qbt_compat.host_header_validation_enabled
1006 {
1007 v.push("web_ui_host_header_validation_enabled");
1008 }
1009 if old.qbt_compat.web_ui_reverse_proxy_enabled != new.qbt_compat.web_ui_reverse_proxy_enabled {
1010 v.push("web_ui_reverse_proxy_enabled");
1011 }
1012 if old.qbt_compat.web_ui_reverse_proxies_list != new.qbt_compat.web_ui_reverse_proxies_list {
1013 v.push("web_ui_reverse_proxies_list");
1014 }
1015 if old.qbt_compat.max_failed_auth_count != new.qbt_compat.max_failed_auth_count {
1025 v.push("web_ui_max_auth_fail_count");
1026 }
1027 if old.qbt_compat.ban_duration_secs != new.qbt_compat.ban_duration_secs {
1028 v.push("web_ui_ban_duration");
1029 }
1030 if old.qbt_compat.bypass_local_auth != new.qbt_compat.bypass_local_auth {
1031 v.push("bypass_local_auth");
1032 }
1033 if old.qbt_compat.bypass_auth_subnet_whitelist != new.qbt_compat.bypass_auth_subnet_whitelist {
1034 v.push("bypass_auth_subnet_whitelist");
1035 }
1036 if old.qbt_compat.brute_force_registry_capacity != new.qbt_compat.brute_force_registry_capacity
1037 {
1038 v.push("brute_force_registry_capacity");
1039 }
1040 if old.max_connections_global != new.max_connections_global {
1046 v.push("max_connec_global");
1047 }
1048 if old.max_uploads_per_torrent != new.max_uploads_per_torrent {
1054 v.push("max_uploads_per_torrent");
1055 }
1056 if old.seed_time_limit_secs != new.seed_time_limit_secs {
1063 v.push("max_seeding_time");
1064 }
1065 if old.inactive_seed_time_limit_secs != new.inactive_seed_time_limit_secs {
1066 v.push("max_inactive_seeding_time");
1067 }
1068 if old.save_resume_interval_secs != new.save_resume_interval_secs {
1069 v.push("save_resume_interval");
1070 }
1071 if old.hashing_threads != new.hashing_threads {
1072 v.push("hashing_threads");
1073 }
1074 if old.ip_filter_enabled != new.ip_filter_enabled {
1075 v.push("ip_filter_enabled");
1076 }
1077 if old.notify_on_complete != new.notify_on_complete {
1083 v.push("notify_on_complete");
1084 }
1085 if old.notify_on_error != new.notify_on_error {
1086 v.push("notify_on_error");
1087 }
1088 if old.on_complete_program != new.on_complete_program {
1089 v.push("on_complete_program");
1090 }
1091 if old.use_incomplete_dir != new.use_incomplete_dir {
1092 v.push("use_incomplete_dir");
1093 }
1094 if old.incomplete_dir != new.incomplete_dir {
1095 v.push("incomplete_dir");
1096 }
1097 if old.default_skip_hash_check != new.default_skip_hash_check {
1098 v.push("default_skip_hash_check");
1099 }
1100 if old.incomplete_extension_enabled != new.incomplete_extension_enabled {
1101 v.push("incomplete_extension_enabled");
1102 }
1103 if old.watched_folder != new.watched_folder {
1104 v.push("watched_folder");
1105 }
1106 if old.delete_torrent_after_add != new.delete_torrent_after_add {
1107 v.push("delete_torrent_after_add");
1108 }
1109 if old.move_completed_enabled != new.move_completed_enabled {
1110 v.push("move_completed_enabled");
1111 }
1112 if old.move_completed_to != new.move_completed_to {
1113 v.push("move_completed_to");
1114 }
1115 if old.ip_filter_auto_refresh != new.ip_filter_auto_refresh {
1116 v.push("ip_filter_auto_refresh");
1117 }
1118 if old.web_ui_https_enabled != new.web_ui_https_enabled {
1119 v.push("web_ui_https_enabled");
1120 }
1121 if old.network_interface != new.network_interface {
1122 v.push("network_interface");
1123 }
1124 if old.default_add_paused != new.default_add_paused {
1125 v.push("default_add_paused");
1126 }
1127 v
1128}
1129
1130fn classify_restart_required(old: &Settings, new: &Settings) -> Vec<&'static str> {
1143 let mut v = Vec::new();
1144 if old.enable_pex != new.enable_pex {
1145 v.push("pex");
1146 }
1147 if old.encryption_mode != new.encryption_mode {
1148 v.push("encryption");
1149 }
1150 if old.anonymous_mode != new.anonymous_mode {
1151 v.push("anonymous_mode");
1152 }
1153 if old.download_dir != new.download_dir {
1154 v.push("save_path");
1155 }
1156 if old.qbt_compat.port != new.qbt_compat.port {
1160 v.push("webui_port");
1161 }
1162 if old.qbt_compat.bind_address != new.qbt_compat.bind_address {
1163 v.push("webui_bind");
1164 }
1165 if old.enable_upnp != new.enable_upnp {
1170 v.push("upnp");
1171 }
1172 if old.enable_natpmp != new.enable_natpmp {
1173 v.push("natpmp");
1174 }
1175 if old.proxy.proxy_type != new.proxy.proxy_type {
1180 v.push("proxy_type");
1181 }
1182 if old.proxy.hostname != new.proxy.hostname {
1183 v.push("proxy_ip");
1184 }
1185 if old.proxy.port != new.proxy.port {
1186 v.push("proxy_port");
1187 }
1188 if old.proxy.username != new.proxy.username {
1189 v.push("proxy_username");
1190 }
1191 if old.proxy.password != new.proxy.password {
1192 v.push("proxy_password");
1193 }
1194 if old.proxy.proxy_peer_connections != new.proxy.proxy_peer_connections {
1195 v.push("proxy_peer_connections");
1196 }
1197 if old.proxy.proxy_hostnames != new.proxy.proxy_hostnames {
1198 v.push("proxy_hostnames");
1199 }
1200 if old.force_proxy != new.force_proxy {
1201 v.push("force_proxy");
1202 }
1203 v
1204}
1205
1206#[derive(Clone)]
1208pub struct SessionHandle {
1209 cmd_tx: SessionCmdSender,
1210 alert_tx: broadcast::Sender<Alert>,
1211 alert_mask: Arc<AtomicU32>,
1212 counters: Arc<crate::stats::SessionCounters>,
1213 #[allow(dead_code)]
1215 factory: Arc<crate::transport::NetworkFactory>,
1216 reconfig_in_flight: crate::apply::ReconfigInFlight,
1224}
1225
1226impl SessionHandle {
1227 pub async fn start(settings: Settings) -> crate::Result<Self> {
1233 Self::start_with_plugins(settings, Arc::new(Vec::new())).await
1234 }
1235
1236 pub async fn start_with_backend(
1242 settings: Settings,
1243 backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1244 ) -> crate::Result<Self> {
1245 Self::start_with_plugins_and_backend(settings, Arc::new(Vec::new()), backend).await
1246 }
1247
1248 pub async fn start_with_plugins(
1254 settings: Settings,
1255 plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1256 ) -> crate::Result<Self> {
1257 let disk_config = crate::disk::DiskConfig::from(&settings);
1258 let backend = crate::disk_backend::create_backend_from_config(&disk_config);
1259 Self::start_with_plugins_and_backend(settings, plugins, backend).await
1260 }
1261
1262 pub async fn start_with_plugins_and_backend(
1269 settings: Settings,
1270 plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1271 backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1272 ) -> crate::Result<Self> {
1273 Self::start_full(
1274 settings,
1275 plugins,
1276 backend,
1277 Arc::new(crate::transport::NetworkFactory::tokio()),
1278 )
1279 .await
1280 }
1281
1282 pub async fn start_with_transport(
1290 settings: Settings,
1291 factory: Arc<crate::transport::NetworkFactory>,
1292 ) -> crate::Result<Self> {
1293 let disk_config = crate::disk::DiskConfig::from(&settings);
1294 let backend = crate::disk_backend::create_backend_from_config(&disk_config);
1295 Self::start_full(settings, Arc::new(Vec::new()), backend, factory).await
1296 }
1297
1298 pub async fn start_full(
1309 settings: Settings,
1310 plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
1311 backend: Arc<dyn crate::disk_backend::DiskIoBackend>,
1312 factory: Arc<crate::transport::NetworkFactory>,
1313 ) -> crate::Result<Self> {
1314 let mut settings = settings;
1315
1316 if settings.force_proxy {
1318 if settings.proxy.proxy_type == crate::proxy::ProxyType::None {
1319 return Err(crate::Error::Config(
1320 "force_proxy requires a proxy to be configured".into(),
1321 ));
1322 }
1323 settings.enable_upnp = false;
1324 settings.enable_natpmp = false;
1325 settings.enable_dht = false;
1326 settings.enable_lsd = false;
1327 }
1328
1329 if settings.anonymous_mode {
1331 settings.enable_dht = false;
1332 settings.enable_lsd = false;
1333 settings.enable_upnp = false;
1334 settings.enable_natpmp = false;
1335 }
1336
1337 match crate::settings::migrate_qbt_credentials(&mut settings.qbt_compat) {
1347 Ok(crate::settings::QbtCredentialMigration::Upgraded) => {
1348 warn!(
1349 "qbt_compat: legacy plaintext password migrated to argon2id in memory — \
1350 persist via `irontide_config::migrate_qbt_credentials_in_file` or the \
1351 next config-touching CLI command to remove the plaintext from disk"
1352 );
1353 }
1354 Ok(crate::settings::QbtCredentialMigration::NoOp) => {}
1355 Err(e) => {
1356 warn!(
1357 error = %e,
1358 "qbt_compat: in-memory password migration failed — continuing with \
1359 legacy plaintext; retry on next daemon start"
1360 );
1361 }
1362 }
1363
1364 let (raw_cmd_tx, cmd_rx) = mpsc::channel::<(tokio::time::Instant, SessionCommand)>(256);
1365 let cmd_tx = SessionCmdSender(raw_cmd_tx);
1366
1367 let (alert_tx, _) = broadcast::channel(settings.alert_channel_size);
1369 let alert_mask = Arc::new(AtomicU32::new(settings.alert_mask.bits()));
1370
1371 let (notification_settings_tx, notification_settings_rx) =
1381 tokio::sync::watch::channel(settings.clone());
1382 let (notification_shutdown_tx, notification_shutdown_rx) = oneshot::channel::<()>();
1383 let _notification_dispatcher_handle = crate::notification::spawn_notification_dispatcher(
1384 crate::notification::DispatcherOptions {
1385 sink: Box::new(crate::notification::LibNotifySink::new()),
1386 settings_rx: notification_settings_rx,
1387 alerts_rx: alert_tx.subscribe(),
1388 shutdown_rx: notification_shutdown_rx,
1389 },
1390 );
1391
1392 let watched_folder_changed = Arc::new(tokio::sync::Notify::new());
1398 let (watched_folder_shutdown_tx, watched_folder_shutdown_rx) =
1399 oneshot::channel::<()>();
1400 let watched_folder_settings_rx = notification_settings_tx.subscribe();
1404
1405 let (lsd, lsd_peers_rx) = if settings.enable_lsd {
1406 match crate::lsd::LsdHandle::start(settings.listen_port, settings.enable_ipv6).await {
1407 Ok((handle, rx)) => (Some(handle), Some(rx)),
1408 Err(e) => {
1409 warn!("LSD unavailable (port 6771): {e}");
1410 (None, None)
1411 }
1412 }
1413 } else {
1414 (None, None)
1415 };
1416
1417 let global_upload_bucket = Arc::new(parking_lot::Mutex::new(
1418 crate::rate_limiter::TokenBucket::new(settings.upload_rate_limit),
1419 ));
1420 let global_download_bucket = Arc::new(parking_lot::Mutex::new(
1421 crate::rate_limiter::TokenBucket::new(settings.download_rate_limit),
1422 ));
1423
1424 let ip_filter: SharedIpFilter =
1432 Arc::new(parking_lot::RwLock::new(crate::ip_filter::IpFilter::new()));
1433 let max_connections_global = Arc::new(std::sync::atomic::AtomicI32::new(
1434 settings.max_connections_global,
1435 ));
1436 let live_connections = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1437
1438 let utp_admit = {
1439 let ip_filter_for_utp = Arc::clone(&ip_filter);
1440 irontide_utp::AdmitGate::new(
1441 Arc::clone(&max_connections_global),
1442 Arc::clone(&live_connections),
1443 Arc::new(move |addr| ip_filter_for_utp.read().is_blocked(addr)),
1444 )
1445 };
1446
1447 let (utp_socket, utp_listener) = if settings.enable_utp {
1456 let utp_config = settings.to_utp_config(settings.listen_port);
1457 let bind_addr = utp_config.bind_addr;
1458 let result = if factory.has_bind_udp() {
1459 match factory.bind_udp(bind_addr).await {
1460 Ok(transport) => {
1461 irontide_utp::UtpSocket::bind_with_transport_and_admit_gate(
1462 transport,
1463 utp_config,
1464 utp_admit.clone(),
1465 )
1466 }
1467 Err(e) => Err(irontide_utp::Error::Io(e)),
1468 }
1469 } else {
1470 irontide_utp::UtpSocket::bind_with_admit_gate(utp_config, utp_admit.clone()).await
1471 };
1472 match result {
1473 Ok((socket, listener)) => (Some(socket), Some(listener)),
1474 Err(e) => {
1475 warn!("uTP bind failed: {e}");
1476 (None, None)
1477 }
1478 }
1479 } else {
1480 (None, None)
1481 };
1482
1483 let (utp_socket_v6, utp_listener_v6) =
1486 if settings.enable_utp && settings.enable_ipv6 && !factory.has_bind_udp() {
1487 match irontide_utp::UtpSocket::bind_with_admit_gate(
1488 settings.to_utp_config_v6(settings.listen_port),
1489 utp_admit.clone(),
1490 )
1491 .await
1492 {
1493 Ok((socket, listener)) => (Some(socket), Some(listener)),
1494 Err(e) => {
1495 debug!("uTP IPv6 bind failed (non-fatal): {e}");
1496 (None, None)
1497 }
1498 }
1499 } else {
1500 (None, None)
1501 };
1502
1503 let (nat, nat_events_rx) = if settings.enable_upnp || settings.enable_natpmp {
1505 let nat_config = settings.to_nat_config();
1506 let (handle, events_rx) = irontide_nat::NatHandle::start(nat_config);
1507 let udp_port = if settings.enable_utp {
1508 Some(settings.listen_port)
1509 } else {
1510 None
1511 };
1512 handle.map_ports(settings.listen_port, udp_port).await;
1513 (Some(handle), Some(events_rx))
1514 } else {
1515 (None, None)
1516 };
1517
1518 let sam_session = if settings.enable_i2p {
1520 let tunnel_config = settings.to_sam_tunnel_config();
1521 match crate::i2p::SamSession::create(
1522 &settings.i2p_hostname,
1523 settings.i2p_port,
1524 "torrent",
1525 tunnel_config,
1526 )
1527 .await
1528 {
1529 Ok(session) => {
1530 let b32 = session.destination().to_b32_address();
1531 info!("I2P SAM session created: {}", b32);
1532 post_alert(
1533 &alert_tx,
1534 &alert_mask,
1535 AlertKind::I2pSessionCreated { b32_address: b32 },
1536 );
1537 Some(Arc::new(session))
1538 }
1539 Err(e) => {
1540 warn!("I2P SAM session failed: {e}");
1541 post_alert(
1542 &alert_tx,
1543 &alert_mask,
1544 AlertKind::I2pError {
1545 message: format!("SAM session creation failed: {e}"),
1546 },
1547 );
1548 None
1549 }
1550 }
1551 } else {
1552 None
1553 };
1554
1555 let ssl_manager = if settings.ssl_listen_port != 0 || settings.ssl_cert_path.is_some() {
1557 match crate::ssl_manager::SslManager::new(&settings) {
1558 Ok(mgr) => {
1559 info!("SSL manager initialized");
1560 Some(Arc::new(mgr))
1561 }
1562 Err(e) => {
1563 warn!(error = %e, "SSL manager initialization failed");
1564 None
1565 }
1566 }
1567 } else {
1568 None
1569 };
1570
1571 let tcp_listener: Option<Box<dyn crate::transport::TransportListener>> = match factory
1573 .bind_tcp(SocketAddr::from(([0, 0, 0, 0], settings.listen_port)))
1574 .await
1575 {
1576 Ok(l) => {
1577 info!(port = settings.listen_port, "TCP listener started");
1578 Some(l)
1579 }
1580 Err(e) => {
1581 warn!(port = settings.listen_port, error = %e, "TCP listener bind failed");
1582 None
1583 }
1584 };
1585
1586 let ssl_listener: Option<Box<dyn crate::transport::TransportListener>> = if settings
1588 .ssl_listen_port
1589 != 0
1590 {
1591 match factory
1592 .bind_tcp(SocketAddr::from(([0, 0, 0, 0], settings.ssl_listen_port)))
1593 .await
1594 {
1595 Ok(l) => {
1596 info!(port = settings.ssl_listen_port, "SSL listener started");
1597 Some(l)
1598 }
1599 Err(e) => {
1600 warn!(port = settings.ssl_listen_port, error = %e, "SSL listener bind failed");
1601 None
1602 }
1603 }
1604 } else {
1605 None
1606 };
1607
1608 let (dht_v4, dht_v4_ip_rx) = if settings.enable_dht {
1610 match DhtHandle::start(settings.to_dht_config()).await {
1611 Ok((handle, ip_rx)) => {
1612 info!("DHT v4 started");
1613 (Some(handle), Some(ip_rx))
1614 }
1615 Err(e) => {
1616 warn!("DHT v4 start failed: {e}");
1617 (None, None)
1618 }
1619 }
1620 } else {
1621 (None, None)
1622 };
1623
1624 let (dht_v6, dht_v6_ip_rx) = if settings.enable_dht && settings.enable_ipv6 {
1625 match DhtHandle::start(settings.to_dht_config_v6()).await {
1626 Ok((handle, ip_rx)) => {
1627 info!("DHT v6 started");
1628 (Some(handle), Some(ip_rx))
1629 }
1630 Err(e) => {
1631 debug!("DHT v6 start failed (non-fatal): {e}");
1632 (None, None)
1633 }
1634 }
1635 } else {
1636 (None, None)
1637 };
1638
1639 let dht_v4_broadcast = irontide_dht::DhtBroadcast::new(dht_v4.clone());
1645 let dht_v6_broadcast = irontide_dht::DhtBroadcast::new(dht_v6.clone());
1646
1647 let ban_config = crate::ban::BanConfig::from(&settings);
1648 let ban_manager: SharedBanManager = Arc::new(parking_lot::RwLock::new(
1649 crate::ban::BanManager::new(ban_config),
1650 ));
1651
1652 let disk_config = crate::disk::DiskConfig::from(&settings);
1656 let spawner = crate::blocking_spawner::BlockingSpawner::new(settings.max_blocking_threads);
1657 let (disk_manager, disk_actor_handle) =
1658 crate::disk::DiskManagerHandle::new_with_backend(disk_config, backend, spawner);
1659
1660 let counters = Arc::new(crate::stats::SessionCounters::new_with_diagnostics(
1661 settings.enable_diagnostic_counters,
1662 ));
1663
1664 let hash_pool = std::sync::Arc::new(crate::hash_pool::HashPool::new(
1666 settings.hashing_threads,
1667 64,
1668 ));
1669
1670 let info_hash_registry = Arc::new(DashMap::new());
1672 let (validated_tx, validated_conn_rx) = mpsc::channel(64);
1673 let listener_task = crate::listener::ListenerTask::new(
1678 tcp_listener,
1679 utp_listener,
1680 utp_listener_v6,
1681 Arc::clone(&info_hash_registry),
1682 validated_tx,
1683 Arc::clone(&max_connections_global),
1684 Arc::clone(&live_connections),
1685 );
1686 let listener_handle = crate::listener::ListenerHandle::spawn(listener_task);
1695
1696 let external_ip = settings.external_ip;
1697
1698 let category_registry_path = crate::category_manager::resolve_category_registry_path(
1702 settings.category_registry_path.as_deref(),
1703 );
1704 let category_registry = Arc::new(parking_lot::RwLock::new(
1705 crate::category_manager::CategoryRegistry::load(category_registry_path),
1706 ));
1707 let tag_registry_path =
1710 crate::tag_manager::resolve_tag_registry_path(settings.tag_registry_path.as_deref());
1711 let tag_registry = Arc::new(parking_lot::RwLock::new(
1712 crate::tag_manager::TagRegistry::load(tag_registry_path),
1713 ));
1714 let deletion_grace = Arc::new(parking_lot::Mutex::new(std::collections::HashSet::new()));
1715 let reconfig_in_flight = crate::apply::ReconfigInFlight::new();
1716
1717 let actor = SessionActor {
1718 settings,
1719 commit_tx: cmd_tx.clone(),
1723 torrents: HashMap::new(),
1724 dht_v4,
1725 dht_v6,
1726 dht_v4_broadcast,
1727 dht_v6_broadcast,
1728 lsd,
1729 lsd_peers_rx,
1730 cmd_rx,
1731 alert_tx: alert_tx.clone(),
1732 alert_mask: Arc::clone(&alert_mask),
1733 global_upload_bucket,
1734 global_download_bucket,
1735 utp_socket,
1736 utp_socket_v6,
1737 nat,
1738 nat_events_rx,
1739 ban_manager,
1740 ip_filter,
1741 disk_manager,
1742 disk_actor_handle,
1743 external_ip,
1744 dht_v4_ip_rx,
1745 dht_v6_ip_rx,
1746 plugins,
1747 sam_session,
1748 ssl_manager,
1749 ssl_listener,
1750 validated_conn_rx,
1751 info_hash_registry,
1752 _listener_task: listener_handle,
1753 max_connections_global,
1754 live_connections,
1755 counters: Arc::clone(&counters),
1756 factory: Arc::clone(&factory),
1757 hash_pool,
1758 category_registry,
1759 tag_registry,
1760 deletion_grace,
1761 reconfig_in_flight: reconfig_in_flight.clone(),
1763 self_alert_rx: alert_tx.subscribe(),
1764 resume_save_notify: Arc::new(tokio::sync::Notify::new()),
1765 notification_settings_tx,
1766 notification_shutdown_tx,
1767 watched_folder_changed: Arc::clone(&watched_folder_changed),
1768 watched_folder_shutdown_tx,
1769 };
1770
1771 let join_handle = tokio::spawn(actor.run());
1772 tokio::spawn(async move {
1773 match join_handle.await {
1774 Ok(()) => {
1775 tracing::warn!("session actor exited cleanly");
1776 }
1777 Err(e) if e.is_panic() => {
1778 let panic_payload = e.into_panic();
1779 let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
1780 (*s).to_string()
1781 } else if let Some(s) = panic_payload.downcast_ref::<String>() {
1782 s.clone()
1783 } else {
1784 "unknown panic payload".to_string()
1785 };
1786 tracing::error!("session actor PANICKED: {msg}");
1787 }
1788 Err(e) => {
1789 tracing::error!("session actor task error: {e}");
1790 }
1791 }
1792 });
1793 let handle = Self {
1794 cmd_tx,
1795 alert_tx,
1796 alert_mask,
1797 counters,
1798 factory,
1799 reconfig_in_flight,
1805 };
1806
1807 let _watched_folder_join = crate::watched_folder::spawn_watched_folder_dispatcher(
1814 handle.clone(),
1815 watched_folder_settings_rx,
1816 watched_folder_changed,
1817 watched_folder_shutdown_rx,
1818 );
1819
1820 Ok(handle)
1821 }
1822
1823 pub async fn add_torrent_with_meta(
1834 &self,
1835 meta: irontide_core::TorrentMeta,
1836 storage: Option<Arc<dyn TorrentStorage>>,
1837 ) -> crate::Result<Id20> {
1838 self.add_torrent_with_dir(meta, storage, None).await
1839 }
1840
1841 pub async fn add_torrent(&self, params: AddTorrentParams) -> crate::Result<Id20> {
1859 let (tx, rx) = oneshot::channel();
1860 self.cmd_tx
1861 .send(SessionCommand::AddTorrentM170 {
1862 params: Box::new(params),
1863 reply: tx,
1864 })
1865 .await
1866 .map_err(|_| crate::Error::Shutdown)?;
1867 rx.await.map_err(|_| crate::Error::Shutdown)?
1868 }
1869
1870 pub async fn create_category(
1877 &self,
1878 name: String,
1879 save_path: PathBuf,
1880 ) -> Result<(), crate::category_manager::CategoryError> {
1881 let (tx, rx) = oneshot::channel();
1882 if self
1883 .cmd_tx
1884 .send(SessionCommand::CreateCategory {
1885 name,
1886 save_path,
1887 reply: tx,
1888 })
1889 .await
1890 .is_err()
1891 {
1892 return Err(crate::category_manager::CategoryError::Persistence(
1893 std::io::Error::other("session shutting down"),
1894 ));
1895 }
1896 rx.await.unwrap_or_else(|_| {
1897 Err(crate::category_manager::CategoryError::Persistence(
1898 std::io::Error::other("session shutting down"),
1899 ))
1900 })
1901 }
1902
1903 pub async fn edit_category(
1911 &self,
1912 name: String,
1913 save_path: PathBuf,
1914 ) -> Result<(), crate::category_manager::CategoryError> {
1915 let (tx, rx) = oneshot::channel();
1916 if self
1917 .cmd_tx
1918 .send(SessionCommand::EditCategory {
1919 name,
1920 save_path,
1921 reply: tx,
1922 })
1923 .await
1924 .is_err()
1925 {
1926 return Err(crate::category_manager::CategoryError::Persistence(
1927 std::io::Error::other("session shutting down"),
1928 ));
1929 }
1930 rx.await.unwrap_or_else(|_| {
1931 Err(crate::category_manager::CategoryError::Persistence(
1932 std::io::Error::other("session shutting down"),
1933 ))
1934 })
1935 }
1936
1937 pub async fn remove_categories(&self, names: Vec<String>) -> Vec<String> {
1942 let (tx, rx) = oneshot::channel();
1943 if self
1944 .cmd_tx
1945 .send(SessionCommand::RemoveCategories { names, reply: tx })
1946 .await
1947 .is_err()
1948 {
1949 return Vec::new();
1950 }
1951 rx.await.unwrap_or_default()
1952 }
1953
1954 pub async fn list_categories(&self) -> Vec<crate::category_manager::CategoryMetadata> {
1956 let (tx, rx) = oneshot::channel();
1957 if self
1958 .cmd_tx
1959 .send(SessionCommand::ListCategories { reply: tx })
1960 .await
1961 .is_err()
1962 {
1963 return Vec::new();
1964 }
1965 rx.await.unwrap_or_default()
1966 }
1967
1968 pub async fn list_tags(&self) -> Vec<String> {
1970 let (tx, rx) = oneshot::channel();
1971 if self
1972 .cmd_tx
1973 .send(SessionCommand::ListTags { reply: tx })
1974 .await
1975 .is_err()
1976 {
1977 return Vec::new();
1978 }
1979 rx.await.unwrap_or_default()
1980 }
1981
1982 pub async fn create_tags(
1988 &self,
1989 names: Vec<String>,
1990 ) -> Vec<Result<(), crate::tag_manager::TagError>> {
1991 let (tx, rx) = oneshot::channel();
1992 if self
1993 .cmd_tx
1994 .send(SessionCommand::CreateTags { names, reply: tx })
1995 .await
1996 .is_err()
1997 {
1998 return Vec::new();
1999 }
2000 rx.await.unwrap_or_default()
2001 }
2002
2003 pub async fn delete_tags(&self, names: Vec<String>) -> Vec<String> {
2007 let (tx, rx) = oneshot::channel();
2008 if self
2009 .cmd_tx
2010 .send(SessionCommand::DeleteTags { names, reply: tx })
2011 .await
2012 .is_err()
2013 {
2014 return Vec::new();
2015 }
2016 rx.await.unwrap_or_default()
2017 }
2018
2019 pub async fn add_tags_to_torrents(
2028 &self,
2029 hashes: Vec<Id20>,
2030 tags: Vec<String>,
2031 ) -> crate::Result<()> {
2032 let (tx, rx) = oneshot::channel();
2033 self.cmd_tx
2034 .send(SessionCommand::AddTagsToTorrents {
2035 info_hashes: hashes,
2036 tags,
2037 reply: tx,
2038 })
2039 .await
2040 .map_err(|_| crate::Error::Shutdown)?;
2041 rx.await.map_err(|_| crate::Error::Shutdown)?
2042 }
2043
2044 pub async fn remove_tags_from_torrents(
2052 &self,
2053 hashes: Vec<Id20>,
2054 tags: Vec<String>,
2055 ) -> crate::Result<()> {
2056 let (tx, rx) = oneshot::channel();
2057 self.cmd_tx
2058 .send(SessionCommand::RemoveTagsFromTorrents {
2059 info_hashes: hashes,
2060 tags,
2061 reply: tx,
2062 })
2063 .await
2064 .map_err(|_| crate::Error::Shutdown)?;
2065 rx.await.map_err(|_| crate::Error::Shutdown)?
2066 }
2067
2068 pub async fn remove_torrent_with_files(&self, info_hash: Id20) -> crate::Result<()> {
2085 let (tx, rx) = oneshot::channel();
2086 self.cmd_tx
2087 .send(SessionCommand::RemoveTorrentWithFiles {
2088 info_hash,
2089 reply: tx,
2090 })
2091 .await
2092 .map_err(|_| crate::Error::Shutdown)?;
2093 rx.await.map_err(|_| crate::Error::Shutdown)?
2094 }
2095
2096 pub async fn add_torrent_with_dir(
2102 &self,
2103 meta: irontide_core::TorrentMeta,
2104 storage: Option<Arc<dyn TorrentStorage>>,
2105 download_dir: Option<PathBuf>,
2106 ) -> crate::Result<Id20> {
2107 let (tx, rx) = oneshot::channel();
2108 self.cmd_tx
2109 .send(SessionCommand::AddTorrent {
2110 meta: Box::new(meta),
2111 storage,
2112 download_dir,
2113 reply: tx,
2114 })
2115 .await
2116 .map_err(|_| crate::Error::Shutdown)?;
2117 rx.await.map_err(|_| crate::Error::Shutdown)?
2118 }
2119
2120 pub async fn add_magnet(&self, magnet: Magnet) -> crate::Result<Id20> {
2126 self.add_magnet_with_dir(magnet, None).await
2127 }
2128
2129 pub async fn add_magnet_with_dir(
2135 &self,
2136 magnet: Magnet,
2137 download_dir: Option<PathBuf>,
2138 ) -> crate::Result<Id20> {
2139 let (tx, rx) = oneshot::channel();
2140 self.cmd_tx
2141 .send(SessionCommand::AddMagnet {
2142 magnet,
2143 download_dir,
2144 reply: tx,
2145 })
2146 .await
2147 .map_err(|_| crate::Error::Shutdown)?;
2148 rx.await.map_err(|_| crate::Error::Shutdown)?
2149 }
2150
2151 pub async fn remove_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2157 let (tx, rx) = oneshot::channel();
2158 self.cmd_tx
2159 .send(SessionCommand::RemoveTorrent {
2160 info_hash,
2161 reply: tx,
2162 })
2163 .await
2164 .map_err(|_| crate::Error::Shutdown)?;
2165 rx.await.map_err(|_| crate::Error::Shutdown)?
2166 }
2167
2168 pub async fn pause_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2174 let (tx, rx) = oneshot::channel();
2175 self.cmd_tx
2176 .send(SessionCommand::PauseTorrent {
2177 info_hash,
2178 reply: tx,
2179 })
2180 .await
2181 .map_err(|_| crate::Error::Shutdown)?;
2182 rx.await.map_err(|_| crate::Error::Shutdown)?
2183 }
2184
2185 pub async fn resume_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2191 let (tx, rx) = oneshot::channel();
2192 self.cmd_tx
2193 .send(SessionCommand::ResumeTorrent {
2194 info_hash,
2195 reply: tx,
2196 })
2197 .await
2198 .map_err(|_| crate::Error::Shutdown)?;
2199 rx.await.map_err(|_| crate::Error::Shutdown)?
2200 }
2201
2202 pub async fn force_resume_torrent(&self, info_hash: Id20) -> crate::Result<()> {
2208 let (tx, rx) = oneshot::channel();
2209 self.cmd_tx
2210 .send(SessionCommand::ForceResumeTorrent {
2211 info_hash,
2212 reply: tx,
2213 })
2214 .await
2215 .map_err(|_| crate::Error::Shutdown)?;
2216 rx.await.map_err(|_| crate::Error::Shutdown)?
2217 }
2218
2219 pub async fn set_torrent_seed_ratio(
2225 &self,
2226 info_hash: Id20,
2227 limit: Option<f64>,
2228 ) -> crate::Result<()> {
2229 let (tx, rx) = oneshot::channel();
2230 self.cmd_tx
2231 .send(SessionCommand::SetTorrentSeedRatio {
2232 info_hash,
2233 limit,
2234 reply: tx,
2235 })
2236 .await
2237 .map_err(|_| crate::Error::Shutdown)?;
2238 rx.await.map_err(|_| crate::Error::Shutdown)?
2239 }
2240
2241 pub async fn torrent_stats(&self, info_hash: Id20) -> crate::Result<TorrentStats> {
2247 let (tx, rx) = oneshot::channel();
2248 self.cmd_tx
2249 .send(SessionCommand::TorrentStats {
2250 info_hash,
2251 reply: tx,
2252 })
2253 .await
2254 .map_err(|_| crate::Error::Shutdown)?;
2255 rx.await.map_err(|_| crate::Error::Shutdown)?
2256 }
2257
2258 pub async fn peer_unchoke_durations(
2274 &self,
2275 info_hash: Id20,
2276 ) -> crate::Result<Option<std::collections::HashMap<SocketAddr, std::time::Duration>>> {
2277 let (tx, rx) = oneshot::channel();
2278 self.cmd_tx
2279 .send(SessionCommand::QueryUnchokeDurations {
2280 info_hash,
2281 reply: tx,
2282 })
2283 .await
2284 .map_err(|_| crate::Error::Shutdown)?;
2285 rx.await.map_err(|_| crate::Error::Shutdown)
2286 }
2287
2288 pub async fn torrent_info(&self, info_hash: Id20) -> crate::Result<TorrentInfo> {
2294 let (tx, rx) = oneshot::channel();
2295 self.cmd_tx
2296 .send(SessionCommand::TorrentInfo {
2297 info_hash,
2298 reply: tx,
2299 })
2300 .await
2301 .map_err(|_| crate::Error::Shutdown)?;
2302 rx.await.map_err(|_| crate::Error::Shutdown)?
2303 }
2304
2305 pub async fn list_torrents(&self) -> crate::Result<Vec<Id20>> {
2311 let (tx, rx) = oneshot::channel();
2312 self.cmd_tx
2313 .send(SessionCommand::ListTorrents { reply: tx })
2314 .await
2315 .map_err(|_| crate::Error::Shutdown)?;
2316 rx.await.map_err(|_| crate::Error::Shutdown)
2317 }
2318
2319 pub async fn session_stats(&self) -> crate::Result<SessionStats> {
2325 let (tx, rx) = oneshot::channel();
2326 self.cmd_tx
2327 .send(SessionCommand::SessionStats { reply: tx })
2328 .await
2329 .map_err(|_| crate::Error::Shutdown)?;
2330 rx.await.map_err(|_| crate::Error::Shutdown)
2331 }
2332
2333 pub async fn debug_state(&self) -> crate::Result<crate::types::DebugState> {
2343 let (tx, rx) = oneshot::channel();
2344 self.cmd_tx
2345 .send(SessionCommand::DebugState { reply: tx })
2346 .await
2347 .map_err(|_| crate::Error::Shutdown)?;
2348 tokio::time::timeout(std::time::Duration::from_secs(5), rx)
2351 .await
2352 .map_err(|_| crate::Error::Shutdown)?
2353 .map_err(|_| crate::Error::Shutdown)
2354 }
2355
2356 #[must_use]
2358 pub fn subscribe(&self) -> broadcast::Receiver<Alert> {
2359 self.alert_tx.subscribe()
2360 }
2361
2362 #[must_use]
2364 pub fn subscribe_filtered(&self, filter: AlertCategory) -> AlertStream {
2365 AlertStream::new(self.alert_tx.subscribe(), filter)
2366 }
2367
2368 pub async fn post_session_stats(&self) -> crate::Result<()> {
2374 self.cmd_tx
2375 .send(SessionCommand::PostSessionStats)
2376 .await
2377 .map_err(|_| crate::Error::Shutdown)
2378 }
2379
2380 #[must_use]
2382 pub fn counters(&self) -> &Arc<crate::stats::SessionCounters> {
2383 &self.counters
2384 }
2385
2386 pub fn set_alert_mask(&self, mask: AlertCategory) {
2388 self.alert_mask.store(mask.bits(), Ordering::Relaxed);
2389 }
2390
2391 #[must_use]
2393 pub fn alert_mask(&self) -> AlertCategory {
2394 AlertCategory::from_bits_truncate(self.alert_mask.load(Ordering::Relaxed))
2395 }
2396
2397 pub async fn add_peers(
2403 &self,
2404 info_hash: Id20,
2405 peers: Vec<SocketAddr>,
2406 source: crate::peer_state::PeerSource,
2407 ) -> crate::Result<()> {
2408 let (tx, rx) = oneshot::channel();
2409 self.cmd_tx
2410 .send(SessionCommand::AddPeers {
2411 info_hash,
2412 peers,
2413 source,
2414 reply: tx,
2415 })
2416 .await
2417 .map_err(|_| crate::Error::Shutdown)?;
2418 rx.await.map_err(|_| crate::Error::Shutdown)?
2419 }
2420
2421 pub async fn shutdown(&self) -> crate::Result<()> {
2427 let _ = tokio::time::timeout(
2429 std::time::Duration::from_secs(10),
2430 self.cmd_tx.send(SessionCommand::Shutdown),
2431 )
2432 .await;
2433 Ok(())
2434 }
2435
2436 pub async fn save_torrent_resume_data(
2442 &self,
2443 info_hash: Id20,
2444 ) -> crate::Result<irontide_core::FastResumeData> {
2445 let (tx, rx) = oneshot::channel();
2446 self.cmd_tx
2447 .send(SessionCommand::SaveTorrentResumeData {
2448 info_hash,
2449 reply: tx,
2450 })
2451 .await
2452 .map_err(|_| crate::Error::Shutdown)?;
2453 rx.await.map_err(|_| crate::Error::Shutdown)?
2454 }
2455
2456 pub async fn save_session_state(&self) -> crate::Result<crate::persistence::SessionState> {
2462 let (tx, rx) = oneshot::channel();
2463 self.cmd_tx
2464 .send(SessionCommand::SaveSessionState { reply: tx })
2465 .await
2466 .map_err(|_| crate::Error::Shutdown)?;
2467 rx.await.map_err(|_| crate::Error::Shutdown)?
2468 }
2469
2470 pub async fn load_resume_state(&self) -> crate::Result<ResumeLoadResult> {
2481 let (tx, rx) = oneshot::channel();
2482 self.cmd_tx
2483 .send(SessionCommand::LoadResumeState { reply: tx })
2484 .await
2485 .map_err(|_| crate::Error::Shutdown)?;
2486 rx.await.map_err(|_| crate::Error::Shutdown)?
2487 }
2488
2489 pub async fn save_resume_state(&self) -> crate::Result<usize> {
2499 let (tx, rx) = oneshot::channel();
2500 self.cmd_tx
2501 .send(SessionCommand::SaveResumeState { reply: tx })
2502 .await
2503 .map_err(|_| crate::Error::Shutdown)?;
2504 rx.await.map_err(|_| crate::Error::Shutdown)?
2505 }
2506
2507 pub async fn queue_position(&self, info_hash: Id20) -> crate::Result<i32> {
2513 let (tx, rx) = oneshot::channel();
2514 self.cmd_tx
2515 .send(SessionCommand::QueuePosition {
2516 info_hash,
2517 reply: tx,
2518 })
2519 .await
2520 .map_err(|_| crate::Error::Shutdown)?;
2521 rx.await.map_err(|_| crate::Error::Shutdown)?
2522 }
2523
2524 pub async fn set_queue_position(&self, info_hash: Id20, pos: i32) -> crate::Result<()> {
2530 let (tx, rx) = oneshot::channel();
2531 self.cmd_tx
2532 .send(SessionCommand::SetQueuePosition {
2533 info_hash,
2534 pos,
2535 reply: tx,
2536 })
2537 .await
2538 .map_err(|_| crate::Error::Shutdown)?;
2539 rx.await.map_err(|_| crate::Error::Shutdown)?
2540 }
2541
2542 pub async fn queue_position_up(&self, info_hash: Id20) -> crate::Result<()> {
2548 let (tx, rx) = oneshot::channel();
2549 self.cmd_tx
2550 .send(SessionCommand::QueuePositionUp {
2551 info_hash,
2552 reply: tx,
2553 })
2554 .await
2555 .map_err(|_| crate::Error::Shutdown)?;
2556 rx.await.map_err(|_| crate::Error::Shutdown)?
2557 }
2558
2559 pub async fn queue_position_down(&self, info_hash: Id20) -> crate::Result<()> {
2565 let (tx, rx) = oneshot::channel();
2566 self.cmd_tx
2567 .send(SessionCommand::QueuePositionDown {
2568 info_hash,
2569 reply: tx,
2570 })
2571 .await
2572 .map_err(|_| crate::Error::Shutdown)?;
2573 rx.await.map_err(|_| crate::Error::Shutdown)?
2574 }
2575
2576 pub async fn queue_position_top(&self, info_hash: Id20) -> crate::Result<()> {
2582 let (tx, rx) = oneshot::channel();
2583 self.cmd_tx
2584 .send(SessionCommand::QueuePositionTop {
2585 info_hash,
2586 reply: tx,
2587 })
2588 .await
2589 .map_err(|_| crate::Error::Shutdown)?;
2590 rx.await.map_err(|_| crate::Error::Shutdown)?
2591 }
2592
2593 pub async fn queue_position_bottom(&self, info_hash: Id20) -> crate::Result<()> {
2599 let (tx, rx) = oneshot::channel();
2600 self.cmd_tx
2601 .send(SessionCommand::QueuePositionBottom {
2602 info_hash,
2603 reply: tx,
2604 })
2605 .await
2606 .map_err(|_| crate::Error::Shutdown)?;
2607 rx.await.map_err(|_| crate::Error::Shutdown)?
2608 }
2609
2610 pub async fn ban_peer(&self, ip: IpAddr) -> crate::Result<()> {
2616 let (tx, rx) = oneshot::channel();
2617 self.cmd_tx
2618 .send(SessionCommand::BanPeer { ip, reply: tx })
2619 .await
2620 .map_err(|_| crate::Error::Shutdown)?;
2621 rx.await.map_err(|_| crate::Error::Shutdown)
2622 }
2623
2624 pub async fn unban_peer(&self, ip: IpAddr) -> crate::Result<bool> {
2630 let (tx, rx) = oneshot::channel();
2631 self.cmd_tx
2632 .send(SessionCommand::UnbanPeer { ip, reply: tx })
2633 .await
2634 .map_err(|_| crate::Error::Shutdown)?;
2635 rx.await.map_err(|_| crate::Error::Shutdown)
2636 }
2637
2638 pub async fn set_ip_filter(&self, filter: crate::ip_filter::IpFilter) -> crate::Result<()> {
2645 let (tx, rx) = oneshot::channel();
2646 self.cmd_tx
2647 .send(SessionCommand::SetIpFilter { filter, reply: tx })
2648 .await
2649 .map_err(|_| crate::Error::Shutdown)?;
2650 rx.await.map_err(|_| crate::Error::Shutdown)
2651 }
2652
2653 pub async fn ip_filter(&self) -> crate::Result<crate::ip_filter::IpFilter> {
2659 let (tx, rx) = oneshot::channel();
2660 self.cmd_tx
2661 .send(SessionCommand::GetIpFilter { reply: tx })
2662 .await
2663 .map_err(|_| crate::Error::Shutdown)?;
2664 rx.await.map_err(|_| crate::Error::Shutdown)
2665 }
2666
2667 pub async fn settings(&self) -> crate::Result<Settings> {
2673 let (tx, rx) = oneshot::channel();
2674 self.cmd_tx
2675 .send(SessionCommand::GetSettings { reply: tx })
2676 .await
2677 .map_err(|_| crate::Error::Shutdown)?;
2678 rx.await.map_err(|_| crate::Error::Shutdown)
2679 }
2680
2681 pub async fn apply_settings(&self, settings: Settings) -> crate::Result<()> {
2695 let _guard = self
2696 .reconfig_in_flight
2697 .try_lock()
2698 .ok_or(crate::Error::ConcurrentReconfig)?;
2699 let (tx, rx) = oneshot::channel();
2700 self.cmd_tx
2701 .send(SessionCommand::ApplySettings {
2702 settings: Box::new(settings),
2703 reply: tx,
2704 })
2705 .await
2706 .map_err(|_| crate::Error::Shutdown)?;
2707 rx.await.map_err(|_| crate::Error::Shutdown)?
2708 }
2709
2710 pub async fn apply_settings_classified(
2730 &self,
2731 settings: Settings,
2732 ) -> crate::Result<AppliedSettings> {
2733 let _guard = self
2734 .reconfig_in_flight
2735 .try_lock()
2736 .ok_or(crate::Error::ConcurrentReconfig)?;
2737 let snapshot = self.settings().await?;
2740 let immediate = classify_immediate(&snapshot, &settings);
2741 let restart_required = classify_restart_required(&snapshot, &settings);
2742 let (tx, rx) = oneshot::channel();
2747 self.cmd_tx
2748 .send(SessionCommand::ApplySettings {
2749 settings: Box::new(settings),
2750 reply: tx,
2751 })
2752 .await
2753 .map_err(|_| crate::Error::Shutdown)?;
2754 rx.await.map_err(|_| crate::Error::Shutdown)??;
2755 Ok(AppliedSettings {
2756 immediate,
2757 restart_required,
2758 })
2759 }
2760
2761 pub async fn dht_node_count(&self) -> crate::Result<usize> {
2773 let (tx, rx) = oneshot::channel();
2774 self.cmd_tx
2775 .send(SessionCommand::DhtNodeCount { reply: tx })
2776 .await
2777 .map_err(|_| crate::Error::Shutdown)?;
2778 rx.await.map_err(|_| crate::Error::Shutdown)
2779 }
2780
2781 pub async fn banned_peers(&self) -> crate::Result<Vec<IpAddr>> {
2787 let (tx, rx) = oneshot::channel();
2788 self.cmd_tx
2789 .send(SessionCommand::BannedPeers { reply: tx })
2790 .await
2791 .map_err(|_| crate::Error::Shutdown)?;
2792 rx.await.map_err(|_| crate::Error::Shutdown)
2793 }
2794
2795 pub async fn move_torrent_storage(
2801 &self,
2802 info_hash: Id20,
2803 new_path: std::path::PathBuf,
2804 ) -> crate::Result<()> {
2805 let (tx, rx) = oneshot::channel();
2806 self.cmd_tx
2807 .send(SessionCommand::MoveTorrentStorage {
2808 info_hash,
2809 new_path,
2810 reply: tx,
2811 })
2812 .await
2813 .map_err(|_| crate::Error::Shutdown)?;
2814 rx.await.map_err(|_| crate::Error::Shutdown)?
2815 }
2816
2817 pub async fn open_file(
2827 &self,
2828 info_hash: Id20,
2829 file_index: usize,
2830 ) -> crate::Result<crate::streaming::FileStream> {
2831 let (tx, rx) = oneshot::channel();
2832 self.cmd_tx
2833 .send(SessionCommand::OpenFile {
2834 info_hash,
2835 file_index,
2836 reply: tx,
2837 })
2838 .await
2839 .map_err(|_| crate::Error::Shutdown)?;
2840 rx.await.map_err(|_| crate::Error::Shutdown)?
2841 }
2842
2843 pub async fn force_reannounce(&self, info_hash: Id20) -> crate::Result<()> {
2849 let (tx, rx) = oneshot::channel();
2850 self.cmd_tx
2851 .send(SessionCommand::ForceReannounce {
2852 info_hash,
2853 reply: tx,
2854 })
2855 .await
2856 .map_err(|_| crate::Error::Shutdown)?;
2857 rx.await.map_err(|_| crate::Error::Shutdown)?
2858 }
2859
2860 pub async fn tracker_list(
2866 &self,
2867 info_hash: Id20,
2868 ) -> crate::Result<Vec<crate::tracker_manager::TrackerInfo>> {
2869 let (tx, rx) = oneshot::channel();
2870 self.cmd_tx
2871 .send(SessionCommand::TrackerList {
2872 info_hash,
2873 reply: tx,
2874 })
2875 .await
2876 .map_err(|_| crate::Error::Shutdown)?;
2877 rx.await.map_err(|_| crate::Error::Shutdown)?
2878 }
2879
2880 pub async fn pex_peer_count(&self, info_hash: Id20) -> crate::Result<usize> {
2888 let counts = self.peer_source_counts(info_hash).await?;
2889 Ok(counts.0)
2890 }
2891
2892 pub async fn lsd_peer_count(&self, info_hash: Id20) -> crate::Result<usize> {
2900 let counts = self.peer_source_counts(info_hash).await?;
2901 Ok(counts.1)
2902 }
2903
2904 async fn peer_source_counts(&self, info_hash: Id20) -> crate::Result<(usize, usize)> {
2905 let (tx, rx) = oneshot::channel();
2906 self.cmd_tx
2907 .send(SessionCommand::GetPeerSourceCounts {
2908 info_hash,
2909 reply: tx,
2910 })
2911 .await
2912 .map_err(|_| crate::Error::Shutdown)?;
2913 rx.await.map_err(|_| crate::Error::Shutdown)?
2914 }
2915
2916 pub async fn web_seed_stats(
2924 &self,
2925 info_hash: Id20,
2926 ) -> crate::Result<Vec<irontide_core::WebSeedStats>> {
2927 let (tx, rx) = oneshot::channel();
2928 self.cmd_tx
2929 .send(SessionCommand::GetWebSeedStats {
2930 info_hash,
2931 reply: tx,
2932 })
2933 .await
2934 .map_err(|_| crate::Error::Shutdown)?;
2935 rx.await.map_err(|_| crate::Error::Shutdown)?
2936 }
2937
2938 pub async fn get_web_seeds(&self, info_hash: Id20) -> crate::Result<Vec<String>> {
2951 let (tx, rx) = oneshot::channel();
2952 self.cmd_tx
2953 .send(SessionCommand::GetWebSeeds {
2954 info_hash,
2955 reply: tx,
2956 })
2957 .await
2958 .map_err(|_| crate::Error::Shutdown)?;
2959 rx.await.map_err(|_| crate::Error::Shutdown)?
2960 }
2961
2962 pub async fn get_piece_states(&self, info_hash: Id20) -> crate::Result<Vec<u8>> {
2975 let (tx, rx) = oneshot::channel();
2976 self.cmd_tx
2977 .send(SessionCommand::GetPieceStates {
2978 info_hash,
2979 reply: tx,
2980 })
2981 .await
2982 .map_err(|_| crate::Error::Shutdown)?;
2983 rx.await.map_err(|_| crate::Error::Shutdown)?
2984 }
2985
2986 pub async fn get_piece_hashes(
3001 &self,
3002 info_hash: Id20,
3003 offset: u32,
3004 limit: u32,
3005 ) -> crate::Result<Vec<String>> {
3006 let (tx, rx) = oneshot::channel();
3007 self.cmd_tx
3008 .send(SessionCommand::GetPieceHashes {
3009 info_hash,
3010 offset,
3011 limit,
3012 reply: tx,
3013 })
3014 .await
3015 .map_err(|_| crate::Error::Shutdown)?;
3016 rx.await.map_err(|_| crate::Error::Shutdown)?
3017 }
3018
3019 pub async fn scrape(
3025 &self,
3026 info_hash: Id20,
3027 ) -> crate::Result<Option<(String, irontide_tracker::ScrapeInfo)>> {
3028 let (tx, rx) = oneshot::channel();
3029 self.cmd_tx
3030 .send(SessionCommand::Scrape {
3031 info_hash,
3032 reply: tx,
3033 })
3034 .await
3035 .map_err(|_| crate::Error::Shutdown)?;
3036 rx.await.map_err(|_| crate::Error::Shutdown)?
3037 }
3038
3039 pub async fn set_file_priority(
3045 &self,
3046 info_hash: Id20,
3047 index: usize,
3048 priority: irontide_core::FilePriority,
3049 ) -> crate::Result<()> {
3050 let (tx, rx) = oneshot::channel();
3051 self.cmd_tx
3052 .send(SessionCommand::SetFilePriority {
3053 info_hash,
3054 index,
3055 priority,
3056 reply: tx,
3057 })
3058 .await
3059 .map_err(|_| crate::Error::Shutdown)?;
3060 rx.await.map_err(|_| crate::Error::Shutdown)?
3061 }
3062
3063 pub async fn file_priorities(
3069 &self,
3070 info_hash: Id20,
3071 ) -> crate::Result<Vec<irontide_core::FilePriority>> {
3072 let (tx, rx) = oneshot::channel();
3073 self.cmd_tx
3074 .send(SessionCommand::FilePriorities {
3075 info_hash,
3076 reply: tx,
3077 })
3078 .await
3079 .map_err(|_| crate::Error::Shutdown)?;
3080 rx.await.map_err(|_| crate::Error::Shutdown)?
3081 }
3082
3083 pub async fn set_download_limit(
3089 &self,
3090 info_hash: Id20,
3091 bytes_per_sec: u64,
3092 ) -> crate::Result<()> {
3093 let (tx, rx) = oneshot::channel();
3094 self.cmd_tx
3095 .send(SessionCommand::SetDownloadLimit {
3096 info_hash,
3097 bytes_per_sec,
3098 reply: tx,
3099 })
3100 .await
3101 .map_err(|_| crate::Error::Shutdown)?;
3102 rx.await.map_err(|_| crate::Error::Shutdown)?
3103 }
3104
3105 pub async fn set_upload_limit(&self, info_hash: Id20, bytes_per_sec: u64) -> crate::Result<()> {
3111 let (tx, rx) = oneshot::channel();
3112 self.cmd_tx
3113 .send(SessionCommand::SetUploadLimit {
3114 info_hash,
3115 bytes_per_sec,
3116 reply: tx,
3117 })
3118 .await
3119 .map_err(|_| crate::Error::Shutdown)?;
3120 rx.await.map_err(|_| crate::Error::Shutdown)?
3121 }
3122
3123 pub async fn download_limit(&self, info_hash: Id20) -> crate::Result<u64> {
3129 let (tx, rx) = oneshot::channel();
3130 self.cmd_tx
3131 .send(SessionCommand::DownloadLimit {
3132 info_hash,
3133 reply: tx,
3134 })
3135 .await
3136 .map_err(|_| crate::Error::Shutdown)?;
3137 rx.await.map_err(|_| crate::Error::Shutdown)?
3138 }
3139
3140 pub async fn upload_limit(&self, info_hash: Id20) -> crate::Result<u64> {
3146 let (tx, rx) = oneshot::channel();
3147 self.cmd_tx
3148 .send(SessionCommand::UploadLimit {
3149 info_hash,
3150 reply: tx,
3151 })
3152 .await
3153 .map_err(|_| crate::Error::Shutdown)?;
3154 rx.await.map_err(|_| crate::Error::Shutdown)?
3155 }
3156
3157 pub async fn set_sequential_download(
3163 &self,
3164 info_hash: Id20,
3165 enabled: bool,
3166 ) -> crate::Result<()> {
3167 let (tx, rx) = oneshot::channel();
3168 self.cmd_tx
3169 .send(SessionCommand::SetSequentialDownload {
3170 info_hash,
3171 enabled,
3172 reply: tx,
3173 })
3174 .await
3175 .map_err(|_| crate::Error::Shutdown)?;
3176 rx.await.map_err(|_| crate::Error::Shutdown)?
3177 }
3178
3179 pub async fn is_sequential_download(&self, info_hash: Id20) -> crate::Result<bool> {
3185 let (tx, rx) = oneshot::channel();
3186 self.cmd_tx
3187 .send(SessionCommand::IsSequentialDownload {
3188 info_hash,
3189 reply: tx,
3190 })
3191 .await
3192 .map_err(|_| crate::Error::Shutdown)?;
3193 rx.await.map_err(|_| crate::Error::Shutdown)?
3194 }
3195
3196 pub async fn set_super_seeding(&self, info_hash: Id20, enabled: bool) -> crate::Result<()> {
3202 let (tx, rx) = oneshot::channel();
3203 self.cmd_tx
3204 .send(SessionCommand::SetSuperSeeding {
3205 info_hash,
3206 enabled,
3207 reply: tx,
3208 })
3209 .await
3210 .map_err(|_| crate::Error::Shutdown)?;
3211 rx.await.map_err(|_| crate::Error::Shutdown)?
3212 }
3213
3214 pub async fn is_super_seeding(&self, info_hash: Id20) -> crate::Result<bool> {
3220 let (tx, rx) = oneshot::channel();
3221 self.cmd_tx
3222 .send(SessionCommand::IsSuperSeeding {
3223 info_hash,
3224 reply: tx,
3225 })
3226 .await
3227 .map_err(|_| crate::Error::Shutdown)?;
3228 rx.await.map_err(|_| crate::Error::Shutdown)?
3229 }
3230
3231 pub async fn set_seed_mode(&self, info_hash: Id20, enabled: bool) -> crate::Result<()> {
3247 let (tx, rx) = oneshot::channel();
3248 self.cmd_tx
3249 .send(SessionCommand::SetSeedMode {
3250 info_hash,
3251 enabled,
3252 reply: tx,
3253 })
3254 .await
3255 .map_err(|_| crate::Error::Shutdown)?;
3256 rx.await.map_err(|_| crate::Error::Shutdown)?
3257 }
3258
3259 pub async fn add_tracker(&self, info_hash: Id20, url: String) -> crate::Result<()> {
3267 let (tx, rx) = oneshot::channel();
3268 self.cmd_tx
3269 .send(SessionCommand::AddTracker {
3270 info_hash,
3271 url,
3272 reply: tx,
3273 })
3274 .await
3275 .map_err(|_| crate::Error::Shutdown)?;
3276 rx.await.map_err(|_| crate::Error::Shutdown)?
3277 }
3278
3279 pub async fn replace_trackers(&self, info_hash: Id20, urls: Vec<String>) -> crate::Result<()> {
3285 let (tx, rx) = oneshot::channel();
3286 self.cmd_tx
3287 .send(SessionCommand::ReplaceTrackers {
3288 info_hash,
3289 urls,
3290 reply: tx,
3291 })
3292 .await
3293 .map_err(|_| crate::Error::Shutdown)?;
3294 rx.await.map_err(|_| crate::Error::Shutdown)?
3295 }
3296
3297 pub async fn force_recheck(&self, info_hash: Id20) -> crate::Result<()> {
3307 let (tx, rx) = oneshot::channel();
3308 self.cmd_tx
3309 .send(SessionCommand::ForceRecheck {
3310 info_hash,
3311 reply: tx,
3312 })
3313 .await
3314 .map_err(|_| crate::Error::Shutdown)?;
3315 rx.await.map_err(|_| crate::Error::Shutdown)?
3316 }
3317
3318 pub async fn rename_file(
3328 &self,
3329 info_hash: Id20,
3330 file_index: usize,
3331 new_name: String,
3332 ) -> crate::Result<()> {
3333 let (tx, rx) = oneshot::channel();
3334 self.cmd_tx
3335 .send(SessionCommand::RenameFile {
3336 info_hash,
3337 file_index,
3338 new_name,
3339 reply: tx,
3340 })
3341 .await
3342 .map_err(|_| crate::Error::Shutdown)?;
3343 rx.await.map_err(|_| crate::Error::Shutdown)?
3344 }
3345
3346 pub async fn set_max_connections(&self, info_hash: Id20, limit: usize) -> crate::Result<()> {
3352 let (tx, rx) = oneshot::channel();
3353 self.cmd_tx
3354 .send(SessionCommand::SetMaxConnections {
3355 info_hash,
3356 limit,
3357 reply: tx,
3358 })
3359 .await
3360 .map_err(|_| crate::Error::Shutdown)?;
3361 rx.await.map_err(|_| crate::Error::Shutdown)?
3362 }
3363
3364 pub async fn max_connections(&self, info_hash: Id20) -> crate::Result<usize> {
3370 let (tx, rx) = oneshot::channel();
3371 self.cmd_tx
3372 .send(SessionCommand::MaxConnections {
3373 info_hash,
3374 reply: tx,
3375 })
3376 .await
3377 .map_err(|_| crate::Error::Shutdown)?;
3378 rx.await.map_err(|_| crate::Error::Shutdown)?
3379 }
3380
3381 pub async fn set_max_uploads(&self, info_hash: Id20, limit: usize) -> crate::Result<()> {
3387 let (tx, rx) = oneshot::channel();
3388 self.cmd_tx
3389 .send(SessionCommand::SetMaxUploads {
3390 info_hash,
3391 limit,
3392 reply: tx,
3393 })
3394 .await
3395 .map_err(|_| crate::Error::Shutdown)?;
3396 rx.await.map_err(|_| crate::Error::Shutdown)?
3397 }
3398
3399 pub async fn max_uploads(&self, info_hash: Id20) -> crate::Result<usize> {
3405 let (tx, rx) = oneshot::channel();
3406 self.cmd_tx
3407 .send(SessionCommand::MaxUploads {
3408 info_hash,
3409 reply: tx,
3410 })
3411 .await
3412 .map_err(|_| crate::Error::Shutdown)?;
3413 rx.await.map_err(|_| crate::Error::Shutdown)?
3414 }
3415
3416 pub async fn get_peer_info(
3422 &self,
3423 info_hash: Id20,
3424 ) -> crate::Result<Vec<crate::types::PeerInfo>> {
3425 let (tx, rx) = oneshot::channel();
3426 self.cmd_tx
3427 .send(SessionCommand::GetPeerInfo {
3428 info_hash,
3429 reply: tx,
3430 })
3431 .await
3432 .map_err(|_| crate::Error::Shutdown)?;
3433 rx.await.map_err(|_| crate::Error::Shutdown)?
3434 }
3435
3436 pub async fn get_download_queue(
3442 &self,
3443 info_hash: Id20,
3444 ) -> crate::Result<Vec<crate::types::PartialPieceInfo>> {
3445 let (tx, rx) = oneshot::channel();
3446 self.cmd_tx
3447 .send(SessionCommand::GetDownloadQueue {
3448 info_hash,
3449 reply: tx,
3450 })
3451 .await
3452 .map_err(|_| crate::Error::Shutdown)?;
3453 rx.await.map_err(|_| crate::Error::Shutdown)?
3454 }
3455
3456 pub async fn have_piece(&self, info_hash: Id20, index: u32) -> crate::Result<bool> {
3462 let (tx, rx) = oneshot::channel();
3463 self.cmd_tx
3464 .send(SessionCommand::HavePiece {
3465 info_hash,
3466 index,
3467 reply: tx,
3468 })
3469 .await
3470 .map_err(|_| crate::Error::Shutdown)?;
3471 rx.await.map_err(|_| crate::Error::Shutdown)?
3472 }
3473
3474 pub async fn piece_availability(&self, info_hash: Id20) -> crate::Result<Vec<u32>> {
3480 let (tx, rx) = oneshot::channel();
3481 self.cmd_tx
3482 .send(SessionCommand::PieceAvailability {
3483 info_hash,
3484 reply: tx,
3485 })
3486 .await
3487 .map_err(|_| crate::Error::Shutdown)?;
3488 rx.await.map_err(|_| crate::Error::Shutdown)?
3489 }
3490
3491 pub async fn file_progress(&self, info_hash: Id20) -> crate::Result<Vec<u64>> {
3497 let (tx, rx) = oneshot::channel();
3498 self.cmd_tx
3499 .send(SessionCommand::FileProgress {
3500 info_hash,
3501 reply: tx,
3502 })
3503 .await
3504 .map_err(|_| crate::Error::Shutdown)?;
3505 rx.await.map_err(|_| crate::Error::Shutdown)?
3506 }
3507
3508 pub async fn info_hashes(&self, info_hash: Id20) -> crate::Result<irontide_core::InfoHashes> {
3514 let (tx, rx) = oneshot::channel();
3515 self.cmd_tx
3516 .send(SessionCommand::InfoHashesQuery {
3517 info_hash,
3518 reply: tx,
3519 })
3520 .await
3521 .map_err(|_| crate::Error::Shutdown)?;
3522 rx.await.map_err(|_| crate::Error::Shutdown)?
3523 }
3524
3525 pub async fn torrent_file(
3533 &self,
3534 info_hash: Id20,
3535 ) -> crate::Result<Option<irontide_core::TorrentMetaV1>> {
3536 let (tx, rx) = oneshot::channel();
3537 self.cmd_tx
3538 .send(SessionCommand::TorrentFile {
3539 info_hash,
3540 reply: tx,
3541 })
3542 .await
3543 .map_err(|_| crate::Error::Shutdown)?;
3544 rx.await.map_err(|_| crate::Error::Shutdown)?
3545 }
3546
3547 pub async fn torrent_file_v2(
3556 &self,
3557 info_hash: Id20,
3558 ) -> crate::Result<Option<irontide_core::TorrentMetaV2>> {
3559 let (tx, rx) = oneshot::channel();
3560 self.cmd_tx
3561 .send(SessionCommand::TorrentFileV2 {
3562 info_hash,
3563 reply: tx,
3564 })
3565 .await
3566 .map_err(|_| crate::Error::Shutdown)?;
3567 rx.await.map_err(|_| crate::Error::Shutdown)?
3568 }
3569
3570 #[cfg(feature = "test-util")]
3586 pub async fn debug_inject_metadata(
3587 &self,
3588 info_hash: Id20,
3589 info_bytes: Vec<u8>,
3590 ) -> crate::Result<()> {
3591 let (tx, rx) = oneshot::channel();
3592 self.cmd_tx
3593 .send(SessionCommand::TestInjectMetadata {
3594 info_hash,
3595 info_bytes,
3596 reply: tx,
3597 })
3598 .await
3599 .map_err(|_| crate::Error::Shutdown)?;
3600 rx.await.map_err(|_| crate::Error::Shutdown)?
3601 }
3602
3603 pub async fn force_dht_announce(&self, info_hash: Id20) -> crate::Result<()> {
3609 let (tx, rx) = oneshot::channel();
3610 self.cmd_tx
3611 .send(SessionCommand::ForceDhtAnnounce {
3612 info_hash,
3613 reply: tx,
3614 })
3615 .await
3616 .map_err(|_| crate::Error::Shutdown)?;
3617 rx.await.map_err(|_| crate::Error::Shutdown)?
3618 }
3619
3620 pub async fn force_lsd_announce(&self, info_hash: Id20) -> crate::Result<()> {
3628 let (tx, rx) = oneshot::channel();
3629 self.cmd_tx
3630 .send(SessionCommand::ForceLsdAnnounce {
3631 info_hash,
3632 reply: tx,
3633 })
3634 .await
3635 .map_err(|_| crate::Error::Shutdown)?;
3636 rx.await.map_err(|_| crate::Error::Shutdown)?
3637 }
3638
3639 pub async fn read_piece(&self, info_hash: Id20, index: u32) -> crate::Result<bytes::Bytes> {
3645 let (tx, rx) = oneshot::channel();
3646 self.cmd_tx
3647 .send(SessionCommand::ReadPiece {
3648 info_hash,
3649 index,
3650 reply: tx,
3651 })
3652 .await
3653 .map_err(|_| crate::Error::Shutdown)?;
3654 rx.await.map_err(|_| crate::Error::Shutdown)?
3655 }
3656
3657 pub async fn flush_cache(&self, info_hash: Id20) -> crate::Result<()> {
3663 let (tx, rx) = oneshot::channel();
3664 self.cmd_tx
3665 .send(SessionCommand::FlushCache {
3666 info_hash,
3667 reply: tx,
3668 })
3669 .await
3670 .map_err(|_| crate::Error::Shutdown)?;
3671 rx.await.map_err(|_| crate::Error::Shutdown)?
3672 }
3673
3674 pub async fn is_valid(&self, info_hash: Id20) -> bool {
3676 let (tx, rx) = oneshot::channel();
3677 if self
3678 .cmd_tx
3679 .send(SessionCommand::IsValid {
3680 info_hash,
3681 reply: tx,
3682 })
3683 .await
3684 .is_err()
3685 {
3686 return false;
3687 }
3688 rx.await.unwrap_or(false)
3689 }
3690
3691 pub async fn clear_error(&self, info_hash: Id20) -> crate::Result<()> {
3697 let (tx, rx) = oneshot::channel();
3698 self.cmd_tx
3699 .send(SessionCommand::ClearError {
3700 info_hash,
3701 reply: tx,
3702 })
3703 .await
3704 .map_err(|_| crate::Error::Shutdown)?;
3705 rx.await.map_err(|_| crate::Error::Shutdown)?
3706 }
3707
3708 pub async fn file_status(
3714 &self,
3715 info_hash: Id20,
3716 ) -> crate::Result<Vec<crate::types::FileStatus>> {
3717 let (tx, rx) = oneshot::channel();
3718 self.cmd_tx
3719 .send(SessionCommand::FileStatus {
3720 info_hash,
3721 reply: tx,
3722 })
3723 .await
3724 .map_err(|_| crate::Error::Shutdown)?;
3725 rx.await.map_err(|_| crate::Error::Shutdown)?
3726 }
3727
3728 pub async fn flags(&self, info_hash: Id20) -> crate::Result<crate::types::TorrentFlags> {
3734 let (tx, rx) = oneshot::channel();
3735 self.cmd_tx
3736 .send(SessionCommand::Flags {
3737 info_hash,
3738 reply: tx,
3739 })
3740 .await
3741 .map_err(|_| crate::Error::Shutdown)?;
3742 rx.await.map_err(|_| crate::Error::Shutdown)?
3743 }
3744
3745 pub async fn set_flags(
3751 &self,
3752 info_hash: Id20,
3753 flags: crate::types::TorrentFlags,
3754 ) -> crate::Result<()> {
3755 let (tx, rx) = oneshot::channel();
3756 self.cmd_tx
3757 .send(SessionCommand::SetFlags {
3758 info_hash,
3759 flags,
3760 reply: tx,
3761 })
3762 .await
3763 .map_err(|_| crate::Error::Shutdown)?;
3764 rx.await.map_err(|_| crate::Error::Shutdown)?
3765 }
3766
3767 pub async fn unset_flags(
3773 &self,
3774 info_hash: Id20,
3775 flags: crate::types::TorrentFlags,
3776 ) -> crate::Result<()> {
3777 let (tx, rx) = oneshot::channel();
3778 self.cmd_tx
3779 .send(SessionCommand::UnsetFlags {
3780 info_hash,
3781 flags,
3782 reply: tx,
3783 })
3784 .await
3785 .map_err(|_| crate::Error::Shutdown)?;
3786 rx.await.map_err(|_| crate::Error::Shutdown)?
3787 }
3788
3789 pub async fn connect_peer(&self, info_hash: Id20, addr: SocketAddr) -> crate::Result<()> {
3795 let (tx, rx) = oneshot::channel();
3796 self.cmd_tx
3797 .send(SessionCommand::ConnectPeer {
3798 info_hash,
3799 addr,
3800 reply: tx,
3801 })
3802 .await
3803 .map_err(|_| crate::Error::Shutdown)?;
3804 rx.await.map_err(|_| crate::Error::Shutdown)?
3805 }
3806
3807 pub async fn dht_put_immutable(&self, value: Vec<u8>) -> crate::Result<Id20> {
3815 let (tx, rx) = oneshot::channel();
3816 self.cmd_tx
3817 .send(SessionCommand::DhtPutImmutable { value, reply: tx })
3818 .await
3819 .map_err(|_| crate::Error::Shutdown)?;
3820 rx.await.map_err(|_| crate::Error::Shutdown)?
3821 }
3822
3823 pub async fn dht_get_immutable(&self, target: Id20) -> crate::Result<Option<Vec<u8>>> {
3831 let (tx, rx) = oneshot::channel();
3832 self.cmd_tx
3833 .send(SessionCommand::DhtGetImmutable { target, reply: tx })
3834 .await
3835 .map_err(|_| crate::Error::Shutdown)?;
3836 rx.await.map_err(|_| crate::Error::Shutdown)?
3837 }
3838
3839 pub async fn dht_put_mutable(
3847 &self,
3848 keypair_bytes: [u8; 32],
3849 value: Vec<u8>,
3850 seq: i64,
3851 salt: Vec<u8>,
3852 ) -> crate::Result<Id20> {
3853 let (tx, rx) = oneshot::channel();
3854 self.cmd_tx
3855 .send(SessionCommand::DhtPutMutable {
3856 keypair_bytes,
3857 value,
3858 seq,
3859 salt,
3860 reply: tx,
3861 })
3862 .await
3863 .map_err(|_| crate::Error::Shutdown)?;
3864 rx.await.map_err(|_| crate::Error::Shutdown)?
3865 }
3866
3867 pub async fn dht_get_mutable(
3875 &self,
3876 public_key: [u8; 32],
3877 salt: Vec<u8>,
3878 ) -> crate::Result<Option<(Vec<u8>, i64)>> {
3879 let (tx, rx) = oneshot::channel();
3880 self.cmd_tx
3881 .send(SessionCommand::DhtGetMutable {
3882 public_key,
3883 salt,
3884 reply: tx,
3885 })
3886 .await
3887 .map_err(|_| crate::Error::Shutdown)?;
3888 rx.await.map_err(|_| crate::Error::Shutdown)?
3889 }
3890
3891 pub async fn list_torrent_summaries(&self) -> crate::Result<Vec<TorrentSummary>> {
3902 let ids = self.list_torrents().await?;
3903 let mut summaries = Vec::with_capacity(ids.len());
3904 for id in ids {
3905 if let Ok(stats) = self.torrent_stats(id).await {
3906 summaries.push(TorrentSummary::from(&stats));
3907 }
3908 }
3909 Ok(summaries)
3910 }
3911
3912 pub async fn add_magnet_uri(&self, uri: &str) -> crate::Result<irontide_core::InfoHashes> {
3921 let magnet = irontide_core::Magnet::parse(uri)?;
3922 let info_hashes = magnet.info_hashes.clone();
3923 self.add_magnet(magnet).await?;
3924 Ok(info_hashes)
3925 }
3926
3927 pub async fn add_torrent_bytes(
3936 &self,
3937 bytes: &[u8],
3938 ) -> crate::Result<irontide_core::InfoHashes> {
3939 let meta = irontide_core::torrent_from_bytes_any(bytes)?;
3940 let info_hashes = meta.info_hashes();
3941 self.add_torrent_with_meta(meta, None).await?;
3942 Ok(info_hashes)
3943 }
3944}
3945
3946struct SessionActor {
3951 settings: Settings,
3952 commit_tx: SessionCmdSender,
3959 torrents: HashMap<Id20, TorrentEntry>,
3960 dht_v4: Option<DhtHandle>,
3961 dht_v6: Option<DhtHandle>,
3962 dht_v4_broadcast: irontide_dht::DhtBroadcast,
3969 dht_v6_broadcast: irontide_dht::DhtBroadcast,
3970 lsd: Option<crate::lsd::LsdHandle>,
3971 lsd_peers_rx: Option<mpsc::Receiver<(Id20, SocketAddr)>>,
3972 cmd_rx: mpsc::Receiver<(tokio::time::Instant, SessionCommand)>,
3973 alert_tx: broadcast::Sender<Alert>,
3974 alert_mask: Arc<AtomicU32>,
3975 global_upload_bucket: SharedBucket,
3976 global_download_bucket: SharedBucket,
3977 utp_socket: Option<irontide_utp::UtpSocket>,
3978 utp_socket_v6: Option<irontide_utp::UtpSocket>,
3979 nat: Option<irontide_nat::NatHandle>,
3980 nat_events_rx: Option<mpsc::Receiver<irontide_nat::NatEvent>>,
3981 ban_manager: SharedBanManager,
3982 ip_filter: SharedIpFilter,
3983 disk_manager: crate::disk::DiskManagerHandle,
3984 #[allow(dead_code)]
3985 disk_actor_handle: tokio::task::JoinHandle<()>,
3986 external_ip: Option<std::net::IpAddr>,
3988 dht_v4_ip_rx: Option<mpsc::Receiver<std::net::IpAddr>>,
3990 dht_v6_ip_rx: Option<mpsc::Receiver<std::net::IpAddr>>,
3992 plugins: Arc<Vec<Box<dyn crate::extension::ExtensionPlugin>>>,
3994 sam_session: Option<Arc<crate::i2p::SamSession>>,
3996 ssl_manager: Option<Arc<crate::ssl_manager::SslManager>>,
3998 ssl_listener: Option<Box<dyn crate::transport::TransportListener>>,
4000 validated_conn_rx: mpsc::Receiver<crate::listener::IdentifiedConnection>,
4002 info_hash_registry: Arc<DashMap<Id20, ()>>,
4007 #[allow(dead_code)] _listener_task: crate::listener::ListenerHandle,
4013 max_connections_global: Arc<std::sync::atomic::AtomicI32>,
4018 #[allow(dead_code)] live_connections: Arc<std::sync::atomic::AtomicUsize>,
4025 counters: Arc<crate::stats::SessionCounters>,
4027 factory: Arc<crate::transport::NetworkFactory>,
4029 hash_pool: std::sync::Arc<crate::hash_pool::HashPool>,
4031 category_registry: Arc<parking_lot::RwLock<crate::category_manager::CategoryRegistry>>,
4033 tag_registry: Arc<parking_lot::RwLock<crate::tag_manager::TagRegistry>>,
4035 deletion_grace: Arc<parking_lot::Mutex<std::collections::HashSet<Id20>>>,
4039 #[allow(dead_code)] reconfig_in_flight: crate::apply::ReconfigInFlight,
4045 self_alert_rx: broadcast::Receiver<Alert>,
4046 resume_save_notify: Arc<tokio::sync::Notify>,
4047 notification_settings_tx: tokio::sync::watch::Sender<Settings>,
4053 #[allow(dead_code)]
4062 notification_shutdown_tx: oneshot::Sender<()>,
4063 watched_folder_changed: Arc<tokio::sync::Notify>,
4071 #[allow(dead_code)]
4074 watched_folder_shutdown_tx: oneshot::Sender<()>,
4075}
4076
4077impl SessionActor {
4078 async fn get_entry_meta(&self, info_hash: Id20) -> crate::Result<irontide_core::TorrentMetaV1> {
4088 let entry = self
4089 .torrents
4090 .get(&info_hash)
4091 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
4092 entry
4093 .handle
4094 .get_meta()
4095 .await?
4096 .ok_or(crate::Error::MetadataNotReady(info_hash))
4097 }
4098
4099 async fn run(mut self) {
4100 let mut refill_interval = tokio::time::interval(std::time::Duration::from_millis(100));
4101 refill_interval.tick().await; let auto_manage_secs = self.settings.auto_manage_interval.max(1);
4104 let mut auto_manage_interval =
4105 tokio::time::interval(std::time::Duration::from_secs(auto_manage_secs));
4106 auto_manage_interval.tick().await; let stats_interval_ms = self.settings.stats_report_interval;
4110 let mut stats_timer = if stats_interval_ms > 0 {
4111 Some(tokio::time::interval(std::time::Duration::from_millis(
4112 stats_interval_ms,
4113 )))
4114 } else {
4115 None
4116 };
4117 if let Some(ref mut t) = stats_timer {
4118 t.tick().await; }
4120
4121 let sample_interval_secs = self.settings.dht_sample_infohashes_interval;
4123 let mut sample_timer = if sample_interval_secs > 0 {
4124 Some(tokio::time::interval(std::time::Duration::from_secs(
4125 sample_interval_secs,
4126 )))
4127 } else {
4128 None
4129 };
4130 if let Some(ref mut t) = sample_timer {
4131 t.tick().await; }
4133
4134 let mut resume_save_interval = if self.settings.save_resume_interval_secs > 0 {
4136 Some(tokio::time::interval(std::time::Duration::from_secs(
4137 self.settings.save_resume_interval_secs,
4138 )))
4139 } else {
4140 None
4141 };
4142 if let Some(ref mut t) = resume_save_interval {
4143 t.tick().await; }
4145
4146 {
4148 let resume_dir = self.effective_resume_dir();
4149 let resume_files = crate::resume_file::scan_resume_dir(&resume_dir);
4150 if !resume_files.is_empty() {
4151 match self.handle_load_resume_state().await {
4153 Ok(result) => {
4154 info!(
4155 restored = result.restored,
4156 skipped = result.skipped,
4157 failed = result.failed,
4158 "auto-restored torrents on startup"
4159 );
4160 }
4161 Err(e) => {
4162 warn!("auto-restore on startup failed: {e}");
4163 }
4164 }
4165
4166 if self.settings.queueing_enabled {
4171 self.evaluate_queue().await;
4172 }
4173
4174 let active_hashes: std::collections::HashSet<String> = self
4177 .torrents
4178 .keys()
4179 .map(|h| hex::encode(h.as_bytes()))
4180 .collect();
4181
4182 let current_files = crate::resume_file::scan_resume_dir(&resume_dir);
4184 for path in ¤t_files {
4185 if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
4186 && !active_hashes.contains(stem)
4187 {
4188 if let Err(e) = std::fs::remove_file(path) {
4189 warn!(path = %path.display(), "failed to remove orphan resume file: {e}");
4190 } else {
4191 debug!(path = %path.display(), "removed orphan resume file");
4192 }
4193 }
4194 }
4195 }
4196 }
4197
4198 loop {
4199 tokio::select! {
4200 cmd = self.cmd_rx.recv() => {
4201 let recv_at = tokio::time::Instant::now();
4210 let queue_wait_ms = cmd.as_ref().map_or(0.0, |(sent_at, _)| {
4211 recv_at.saturating_duration_since(*sent_at).as_secs_f64() * 1000.0
4212 });
4213 let cmd_name = cmd.as_ref().map_or("<closed>", |(_, c)| c.name());
4214 let handler_start = tokio::time::Instant::now();
4215 let cmd = cmd.map(|(_sent_at, c)| c);
4216 match cmd {
4217 Some(SessionCommand::AddTorrent {
4218 meta,
4219 storage,
4220 download_dir,
4221 reply,
4222 }) => {
4223 let setup: crate::Result<AddTorrentPrepBundle> = (|| {
4231 let info_hash = meta.as_v1().map_or_else(
4232 || meta.info_hashes().best_v1(),
4233 |v| v.info_hash,
4234 );
4235 if self.torrents.contains_key(&info_hash) {
4236 return Err(crate::Error::DuplicateTorrent(info_hash));
4237 }
4238 if self.torrents.len() >= self.settings.max_torrents {
4239 return Err(crate::Error::SessionAtCapacity(
4240 self.settings.max_torrents,
4241 ));
4242 }
4243 Ok(self.build_add_torrent_prep_bundle(
4244 *meta,
4245 storage,
4246 download_dir,
4247 Vec::new(),
4248 None,
4249 ))
4250 })();
4251 match setup {
4252 Ok(bundle) => self.try_spawn_add_torrent(bundle, reply),
4253 Err(e) => {
4254 let _ = reply.send(Err(e));
4255 }
4256 }
4257 }
4258 Some(SessionCommand::CommitAddTorrent { result, reply }) => {
4259 let id = self.commit_add_torrent(result).await;
4266 let _ = reply.send(id);
4267 }
4268 Some(SessionCommand::AddMagnet { magnet, download_dir, reply }) => {
4269 let result = self
4272 .handle_add_magnet(magnet, download_dir, Vec::new())
4273 .await;
4274 let _ = reply.send(result);
4275 }
4276 Some(SessionCommand::RemoveTorrent { info_hash, reply }) => {
4277 let result = self.handle_remove_torrent(info_hash).await;
4278 let _ = reply.send(result);
4279 }
4280 Some(SessionCommand::PauseTorrent { info_hash, reply }) => {
4281 let result = self.handle_pause_torrent(info_hash).await;
4282 let _ = reply.send(result);
4283 }
4284 Some(SessionCommand::ResumeTorrent { info_hash, reply }) => {
4285 let result = self.handle_resume_torrent(info_hash).await;
4286 let _ = reply.send(result);
4287 }
4288 Some(SessionCommand::ForceResumeTorrent { info_hash, reply }) => {
4289 let result = self.handle_force_resume_torrent(info_hash).await;
4290 let _ = reply.send(result);
4291 }
4292 Some(SessionCommand::SetTorrentSeedRatio { info_hash, limit, reply }) => {
4293 let result = self.handle_set_torrent_seed_ratio(info_hash, limit).await;
4294 let _ = reply.send(result);
4295 }
4296 Some(SessionCommand::TorrentStats { info_hash, reply }) => {
4297 let result = self.handle_torrent_stats(info_hash).await;
4298 let _ = reply.send(result);
4299 }
4300 Some(SessionCommand::TorrentInfo { info_hash, reply }) => {
4301 let result = self.handle_torrent_info(info_hash).await;
4305 let _ = reply.send(result);
4306 }
4307 Some(SessionCommand::ListTorrents { reply }) => {
4308 let list: Vec<Id20> = self.torrents.keys().copied().collect();
4309 let _ = reply.send(list);
4310 }
4311 Some(SessionCommand::SessionStats { reply }) => {
4312 let stats = self.make_session_stats().await;
4313 let _ = reply.send(stats);
4314 }
4315 Some(SessionCommand::SaveTorrentResumeData { info_hash, reply }) => {
4316 let result = self.handle_save_torrent_resume(info_hash).await;
4317 let _ = reply.send(result);
4318 }
4319 Some(SessionCommand::SaveSessionState { reply }) => {
4320 let result = self.handle_save_session_state().await;
4321 let _ = reply.send(result);
4322 }
4323 Some(SessionCommand::LoadResumeState { reply }) => {
4324 let result = self.handle_load_resume_state().await;
4325 let _ = reply.send(result);
4326 }
4327 Some(SessionCommand::QueuePosition { info_hash, reply }) => {
4328 let result = match self.torrents.get(&info_hash) {
4329 Some(entry) => Ok(entry.queue_position),
4330 None => Err(crate::Error::TorrentNotFound(info_hash)),
4331 };
4332 let _ = reply.send(result);
4333 }
4334 Some(SessionCommand::SetQueuePosition { info_hash, pos, reply }) => {
4335 let result = self.handle_set_queue_position(info_hash, pos);
4336 let _ = reply.send(result);
4337 }
4338 Some(SessionCommand::QueuePositionUp { info_hash, reply }) => {
4339 let result = self.handle_queue_move(info_hash, crate::queue::move_up);
4340 let _ = reply.send(result);
4341 }
4342 Some(SessionCommand::QueuePositionDown { info_hash, reply }) => {
4343 let result = self.handle_queue_move(info_hash, crate::queue::move_down);
4344 let _ = reply.send(result);
4345 }
4346 Some(SessionCommand::QueuePositionTop { info_hash, reply }) => {
4347 let result = self.handle_queue_move(info_hash, crate::queue::move_top);
4348 let _ = reply.send(result);
4349 }
4350 Some(SessionCommand::QueuePositionBottom { info_hash, reply }) => {
4351 let result = self.handle_queue_move(info_hash, crate::queue::move_bottom);
4352 let _ = reply.send(result);
4353 }
4354 Some(SessionCommand::BanPeer { ip, reply }) => {
4355 self.ban_manager.write().ban(ip);
4356 let _ = reply.send(());
4357 }
4358 Some(SessionCommand::UnbanPeer { ip, reply }) => {
4359 let was_banned = self.ban_manager.write().unban(&ip);
4360 let _ = reply.send(was_banned);
4361 }
4362 Some(SessionCommand::BannedPeers { reply }) => {
4363 let list: Vec<IpAddr> = self.ban_manager.read()
4364 .banned_list().iter().copied().collect();
4365 let _ = reply.send(list);
4366 }
4367 Some(SessionCommand::SetIpFilter { filter, reply }) => {
4368 *self.ip_filter.write() = filter;
4369 let _ = reply.send(());
4370 }
4371 Some(SessionCommand::GetIpFilter { reply }) => {
4372 let filter = self.ip_filter.read().clone();
4373 let _ = reply.send(filter);
4374 }
4375 Some(SessionCommand::GetSettings { reply }) => {
4376 let _ = reply.send(self.settings.clone());
4377 }
4378 Some(SessionCommand::ApplySettings { settings, reply }) => {
4379 let result = self.handle_apply_settings(*settings);
4380 let _ = reply.send(result);
4381 }
4382 Some(SessionCommand::DhtNodeCount { reply }) => {
4383 let mut total: usize = 0;
4388 if let Some(dht) = &self.dht_v4
4389 && let Ok(c) = dht.node_count().await
4390 {
4391 total += c;
4392 }
4393 if let Some(dht) = &self.dht_v6
4394 && let Ok(c) = dht.node_count().await
4395 {
4396 total += c;
4397 }
4398 let _ = reply.send(total);
4399 }
4400 Some(SessionCommand::MoveTorrentStorage { info_hash, new_path, reply }) => {
4401 let result = self.handle_move_torrent_storage(info_hash, new_path).await;
4402 let _ = reply.send(result);
4403 }
4404 Some(SessionCommand::AddPeers { info_hash, peers, source, reply }) => {
4405 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4406 entry.handle.add_peers(peers, source).await
4407 } else {
4408 Err(crate::Error::TorrentNotFound(info_hash))
4409 };
4410 let _ = reply.send(result);
4411 }
4412 Some(SessionCommand::OpenFile { info_hash, file_index, reply }) => {
4413 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4414 entry.handle.open_file(file_index).await
4415 } else {
4416 Err(crate::Error::TorrentNotFound(info_hash))
4417 };
4418 let _ = reply.send(result);
4419 }
4420 Some(SessionCommand::ForceReannounce { info_hash, reply }) => {
4421 let result = match self.torrents.get(&info_hash) {
4422 Some(entry) => {
4423 entry.handle.force_reannounce().await
4424 }
4425 None => Err(crate::Error::TorrentNotFound(info_hash)),
4426 };
4427 let _ = reply.send(result);
4428 }
4429 Some(SessionCommand::TrackerList { info_hash, reply }) => {
4430 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4431 entry.handle.tracker_list().await
4432 } else {
4433 Err(crate::Error::TorrentNotFound(info_hash))
4434 };
4435 let _ = reply.send(result);
4436 }
4437 Some(SessionCommand::GetPeerSourceCounts { info_hash, reply }) => {
4438 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4439 entry.handle.peer_source_counts().await
4440 } else {
4441 Err(crate::Error::TorrentNotFound(info_hash))
4442 };
4443 let _ = reply.send(result);
4444 }
4445 Some(SessionCommand::QueryUnchokeDurations { info_hash, reply }) => {
4446 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4447 entry.handle.query_unchoke_durations().await.ok()
4448 } else {
4449 None
4450 };
4451 let _ = reply.send(result);
4452 }
4453 Some(SessionCommand::GetWebSeedStats { info_hash, reply }) => {
4454 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4455 entry.handle.get_web_seed_stats().await
4456 } else {
4457 Err(crate::Error::TorrentNotFound(info_hash))
4458 };
4459 let _ = reply.send(result);
4460 }
4461 Some(SessionCommand::GetWebSeeds { info_hash, reply }) => {
4462 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4463 entry.handle.get_web_seeds().await
4464 } else {
4465 Err(crate::Error::TorrentNotFound(info_hash))
4466 };
4467 let _ = reply.send(result);
4468 }
4469 Some(SessionCommand::GetPieceStates { info_hash, reply }) => {
4470 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4471 entry.handle.get_piece_states().await
4472 } else {
4473 Err(crate::Error::TorrentNotFound(info_hash))
4474 };
4475 let _ = reply.send(result);
4476 }
4477 Some(SessionCommand::GetPieceHashes { info_hash, offset, limit, reply }) => {
4478 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4479 entry.handle.get_piece_hashes(offset, limit).await
4480 } else {
4481 Err(crate::Error::TorrentNotFound(info_hash))
4482 };
4483 let _ = reply.send(result);
4484 }
4485 Some(SessionCommand::Scrape { info_hash, reply }) => {
4486 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4487 entry.handle.scrape().await
4488 } else {
4489 Err(crate::Error::TorrentNotFound(info_hash))
4490 };
4491 let _ = reply.send(result);
4492 }
4493 Some(SessionCommand::SetFilePriority { info_hash, index, priority, reply }) => {
4494 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4495 entry.handle.set_file_priority(index, priority).await
4496 } else {
4497 Err(crate::Error::TorrentNotFound(info_hash))
4498 };
4499 let _ = reply.send(result);
4500 }
4501 Some(SessionCommand::FilePriorities { info_hash, reply }) => {
4502 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4503 entry.handle.file_priorities().await
4504 } else {
4505 Err(crate::Error::TorrentNotFound(info_hash))
4506 };
4507 let _ = reply.send(result);
4508 }
4509 Some(SessionCommand::SetDownloadLimit { info_hash, bytes_per_sec, reply }) => {
4510 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4511 entry.handle.set_download_limit(bytes_per_sec).await
4512 } else {
4513 Err(crate::Error::TorrentNotFound(info_hash))
4514 };
4515 let _ = reply.send(result);
4516 }
4517 Some(SessionCommand::SetUploadLimit { info_hash, bytes_per_sec, reply }) => {
4518 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4519 entry.handle.set_upload_limit(bytes_per_sec).await
4520 } else {
4521 Err(crate::Error::TorrentNotFound(info_hash))
4522 };
4523 let _ = reply.send(result);
4524 }
4525 Some(SessionCommand::DownloadLimit { info_hash, reply }) => {
4526 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4527 entry.handle.download_limit().await
4528 } else {
4529 Err(crate::Error::TorrentNotFound(info_hash))
4530 };
4531 let _ = reply.send(result);
4532 }
4533 Some(SessionCommand::UploadLimit { info_hash, reply }) => {
4534 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4535 entry.handle.upload_limit().await
4536 } else {
4537 Err(crate::Error::TorrentNotFound(info_hash))
4538 };
4539 let _ = reply.send(result);
4540 }
4541 Some(SessionCommand::SetSequentialDownload { info_hash, enabled, reply }) => {
4542 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4543 entry.handle.set_sequential_download(enabled).await
4544 } else {
4545 Err(crate::Error::TorrentNotFound(info_hash))
4546 };
4547 let _ = reply.send(result);
4548 }
4549 Some(SessionCommand::IsSequentialDownload { info_hash, reply }) => {
4550 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4551 entry.handle.is_sequential_download().await
4552 } else {
4553 Err(crate::Error::TorrentNotFound(info_hash))
4554 };
4555 let _ = reply.send(result);
4556 }
4557 Some(SessionCommand::SetSuperSeeding { info_hash, enabled, reply }) => {
4558 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4559 entry.handle.set_super_seeding(enabled).await
4560 } else {
4561 Err(crate::Error::TorrentNotFound(info_hash))
4562 };
4563 let _ = reply.send(result);
4564 }
4565 Some(SessionCommand::IsSuperSeeding { info_hash, reply }) => {
4566 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4567 entry.handle.is_super_seeding().await
4568 } else {
4569 Err(crate::Error::TorrentNotFound(info_hash))
4570 };
4571 let _ = reply.send(result);
4572 }
4573 Some(SessionCommand::SetSeedMode { info_hash, enabled, reply }) => {
4574 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4575 entry.handle.set_seed_mode(enabled).await
4576 } else {
4577 Err(crate::Error::TorrentNotFound(info_hash))
4578 };
4579 let _ = reply.send(result);
4580 }
4581 Some(SessionCommand::AddTracker { info_hash, url, reply }) => {
4582 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4583 entry.handle.add_tracker(url).await
4584 } else {
4585 Err(crate::Error::TorrentNotFound(info_hash))
4586 };
4587 let _ = reply.send(result);
4588 }
4589 Some(SessionCommand::ReplaceTrackers { info_hash, urls, reply }) => {
4590 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4591 entry.handle.replace_trackers(urls).await
4592 } else {
4593 Err(crate::Error::TorrentNotFound(info_hash))
4594 };
4595 let _ = reply.send(result);
4596 }
4597 Some(SessionCommand::ForceRecheck { info_hash, reply }) => {
4598 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4599 entry.handle.force_recheck().await
4600 } else {
4601 Err(crate::Error::TorrentNotFound(info_hash))
4602 };
4603 let _ = reply.send(result);
4604 }
4605 Some(SessionCommand::RenameFile { info_hash, file_index, new_name, reply }) => {
4606 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4607 entry.handle.rename_file(file_index, new_name).await
4608 } else {
4609 Err(crate::Error::TorrentNotFound(info_hash))
4610 };
4611 let _ = reply.send(result);
4612 }
4613 Some(SessionCommand::SetMaxConnections { info_hash, limit, reply }) => {
4614 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4615 entry.handle.set_max_connections(limit).await
4616 } else {
4617 Err(crate::Error::TorrentNotFound(info_hash))
4618 };
4619 let _ = reply.send(result);
4620 }
4621 Some(SessionCommand::MaxConnections { info_hash, reply }) => {
4622 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4623 entry.handle.max_connections().await
4624 } else {
4625 Err(crate::Error::TorrentNotFound(info_hash))
4626 };
4627 let _ = reply.send(result);
4628 }
4629 Some(SessionCommand::SetMaxUploads { info_hash, limit, reply }) => {
4630 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4631 entry.handle.set_max_uploads(limit).await
4632 } else {
4633 Err(crate::Error::TorrentNotFound(info_hash))
4634 };
4635 let _ = reply.send(result);
4636 }
4637 Some(SessionCommand::MaxUploads { info_hash, reply }) => {
4638 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4639 entry.handle.max_uploads().await
4640 } else {
4641 Err(crate::Error::TorrentNotFound(info_hash))
4642 };
4643 let _ = reply.send(result);
4644 }
4645 Some(SessionCommand::GetPeerInfo { info_hash, reply }) => {
4646 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4647 entry.handle.get_peer_info().await
4648 } else {
4649 Err(crate::Error::TorrentNotFound(info_hash))
4650 };
4651 let _ = reply.send(result);
4652 }
4653 Some(SessionCommand::GetDownloadQueue { info_hash, reply }) => {
4654 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4655 entry.handle.get_download_queue().await
4656 } else {
4657 Err(crate::Error::TorrentNotFound(info_hash))
4658 };
4659 let _ = reply.send(result);
4660 }
4661 Some(SessionCommand::HavePiece { info_hash, index, reply }) => {
4662 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4663 entry.handle.have_piece(index).await
4664 } else {
4665 Err(crate::Error::TorrentNotFound(info_hash))
4666 };
4667 let _ = reply.send(result);
4668 }
4669 Some(SessionCommand::PieceAvailability { info_hash, reply }) => {
4670 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4671 entry.handle.piece_availability().await
4672 } else {
4673 Err(crate::Error::TorrentNotFound(info_hash))
4674 };
4675 let _ = reply.send(result);
4676 }
4677 Some(SessionCommand::FileProgress { info_hash, reply }) => {
4678 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4679 entry.handle.file_progress().await
4680 } else {
4681 Err(crate::Error::TorrentNotFound(info_hash))
4682 };
4683 let _ = reply.send(result);
4684 }
4685 Some(SessionCommand::InfoHashesQuery { info_hash, reply }) => {
4686 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4687 entry.handle.info_hashes().await
4688 } else {
4689 Err(crate::Error::TorrentNotFound(info_hash))
4690 };
4691 let _ = reply.send(result);
4692 }
4693 Some(SessionCommand::TorrentFile { info_hash, reply }) => {
4694 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4695 entry.handle.torrent_file().await
4696 } else {
4697 Err(crate::Error::TorrentNotFound(info_hash))
4698 };
4699 let _ = reply.send(result);
4700 }
4701 Some(SessionCommand::TorrentFileV2 { info_hash, reply }) => {
4702 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4703 entry.handle.torrent_file_v2().await
4704 } else {
4705 Err(crate::Error::TorrentNotFound(info_hash))
4706 };
4707 let _ = reply.send(result);
4708 }
4709 #[cfg(feature = "test-util")]
4710 Some(SessionCommand::TestInjectMetadata {
4711 info_hash,
4712 info_bytes,
4713 reply,
4714 }) => {
4715 let result = match self.torrents.get(&info_hash) {
4716 Some(entry) => {
4717 entry.handle.test_inject_metadata(info_bytes).await
4718 }
4719 None => Err(crate::Error::TorrentNotFound(info_hash)),
4720 };
4721 let _ = reply.send(result);
4722 }
4723 Some(SessionCommand::ForceDhtAnnounce { info_hash, reply }) => {
4724 let result = match self.torrents.get(&info_hash) {
4729 Some(entry) => {
4730 if entry.is_private().await {
4731 Err(crate::Error::InvalidSettings(
4732 "DHT disabled for private torrent".into(),
4733 ))
4734 } else {
4735 entry.handle.force_dht_announce().await
4736 }
4737 }
4738 None => Err(crate::Error::TorrentNotFound(info_hash)),
4739 };
4740 let _ = reply.send(result);
4741 }
4742 Some(SessionCommand::ForceLsdAnnounce { info_hash, reply }) => {
4743 let result = match self.torrents.get(&info_hash) {
4748 Some(entry) => {
4749 if entry.is_private().await {
4750 Err(crate::Error::InvalidSettings(
4752 "LSD disabled for private torrent".into(),
4753 ))
4754 } else {
4755 if let Some(ref lsd) = self.lsd {
4756 lsd.announce(vec![info_hash]).await;
4757 }
4758 Ok(())
4759 }
4760 }
4761 None => Err(crate::Error::TorrentNotFound(info_hash)),
4762 };
4763 let _ = reply.send(result);
4764 }
4765 Some(SessionCommand::ReadPiece { info_hash, index, reply }) => {
4766 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4767 entry.handle.read_piece(index).await
4768 } else {
4769 Err(crate::Error::TorrentNotFound(info_hash))
4770 };
4771 let _ = reply.send(result);
4772 }
4773 Some(SessionCommand::FlushCache { info_hash, reply }) => {
4774 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4775 entry.handle.flush_cache().await
4776 } else {
4777 Err(crate::Error::TorrentNotFound(info_hash))
4778 };
4779 let _ = reply.send(result);
4780 }
4781 Some(SessionCommand::IsValid { info_hash, reply }) => {
4782 let valid = self.torrents.get(&info_hash)
4783 .is_some_and(|e| e.handle.is_valid());
4784 let _ = reply.send(valid);
4785 }
4786 Some(SessionCommand::ClearError { info_hash, reply }) => {
4787 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4788 entry.handle.clear_error().await
4789 } else {
4790 Err(crate::Error::TorrentNotFound(info_hash))
4791 };
4792 let _ = reply.send(result);
4793 }
4794 Some(SessionCommand::FileStatus { info_hash, reply }) => {
4795 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4796 entry.handle.file_status().await
4797 } else {
4798 Err(crate::Error::TorrentNotFound(info_hash))
4799 };
4800 let _ = reply.send(result);
4801 }
4802 Some(SessionCommand::Flags { info_hash, reply }) => {
4803 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4804 entry.handle.flags().await
4805 } else {
4806 Err(crate::Error::TorrentNotFound(info_hash))
4807 };
4808 let _ = reply.send(result);
4809 }
4810 Some(SessionCommand::SetFlags { info_hash, flags, reply }) => {
4811 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4812 entry.handle.set_flags(flags).await
4813 } else {
4814 Err(crate::Error::TorrentNotFound(info_hash))
4815 };
4816 let _ = reply.send(result);
4817 }
4818 Some(SessionCommand::UnsetFlags { info_hash, flags, reply }) => {
4819 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4820 entry.handle.unset_flags(flags).await
4821 } else {
4822 Err(crate::Error::TorrentNotFound(info_hash))
4823 };
4824 let _ = reply.send(result);
4825 }
4826 Some(SessionCommand::ConnectPeer { info_hash, addr, reply }) => {
4827 let result = if let Some(entry) = self.torrents.get(&info_hash) {
4828 entry.handle.connect_peer(addr).await
4829 } else {
4830 Err(crate::Error::TorrentNotFound(info_hash))
4831 };
4832 let _ = reply.send(result);
4833 }
4834 Some(SessionCommand::DhtPutImmutable { value, reply }) => {
4835 let result = self.handle_dht_put_immutable(value).await;
4836 let _ = reply.send(result);
4837 }
4838 Some(SessionCommand::DhtGetImmutable { target, reply }) => {
4839 let result = self.handle_dht_get_immutable(target).await;
4840 let _ = reply.send(result);
4841 }
4842 Some(SessionCommand::DhtPutMutable { keypair_bytes, value, seq, salt, reply }) => {
4843 let result = self.handle_dht_put_mutable(keypair_bytes, value, seq, salt).await;
4844 let _ = reply.send(result);
4845 }
4846 Some(SessionCommand::DhtGetMutable { public_key, salt, reply }) => {
4847 let result = self.handle_dht_get_mutable(public_key, salt).await;
4848 let _ = reply.send(result);
4849 }
4850 Some(SessionCommand::PostSessionStats) => {
4851 self.fire_stats_alert();
4852 }
4853 Some(SessionCommand::SaveResumeState { reply }) => {
4854 let count = self.save_dirty_resume_files().await;
4855 let _ = reply.send(Ok(count));
4856 }
4857 Some(SessionCommand::AddTorrentM170 { params, reply }) => {
4858 self.dispatch_add_torrent_m170(*params, reply).await;
4862 }
4863 Some(SessionCommand::CreateCategory { name, save_path, reply }) => {
4864 let result = self.handle_create_category(name, save_path).await;
4865 let _ = reply.send(result);
4866 }
4867 Some(SessionCommand::EditCategory { name, save_path, reply }) => {
4868 let result = self.handle_edit_category(name, save_path).await;
4869 let _ = reply.send(result);
4870 }
4871 Some(SessionCommand::RemoveCategories { names, reply }) => {
4872 let result = self.handle_remove_categories(names).await;
4873 let _ = reply.send(result);
4874 }
4875 Some(SessionCommand::ListCategories { reply }) => {
4876 let snapshot = self.category_registry.read().list();
4877 let _ = reply.send(snapshot);
4878 }
4879 Some(SessionCommand::CreateTags { names, reply }) => {
4880 let results: Vec<_> = {
4881 let mut reg = self.tag_registry.write();
4882 names.into_iter().map(|n| reg.create(n)).collect()
4883 };
4884 if let Err(e) = self.persist_tag_registry().await {
4888 tracing::warn!(
4889 error = %e,
4890 "failed to persist tag registry after CreateTags"
4891 );
4892 }
4893 let _ = reply.send(results);
4894 }
4895 Some(SessionCommand::DeleteTags { names, reply }) => {
4896 let removed = self.handle_delete_tags(names).await;
4897 let _ = reply.send(removed);
4898 }
4899 Some(SessionCommand::ListTags { reply }) => {
4900 let names = self.tag_registry.read().list();
4901 let _ = reply.send(names);
4902 }
4903 Some(SessionCommand::AddTagsToTorrents { info_hashes, tags, reply }) => {
4904 let res = self.handle_add_tags_to_torrents(info_hashes, tags).await;
4905 let _ = reply.send(res);
4906 }
4907 Some(SessionCommand::RemoveTagsFromTorrents { info_hashes, tags, reply }) => {
4908 let res = self
4909 .handle_remove_tags_from_torrents(info_hashes, tags)
4910 .await;
4911 let _ = reply.send(res);
4912 }
4913 Some(SessionCommand::RemoveTorrentWithFiles { info_hash, reply }) => {
4914 let result = self.handle_remove_torrent_with_files(info_hash).await;
4915 let _ = reply.send(result);
4916 }
4917 Some(SessionCommand::DebugState { reply }) => {
4918 let state = self.make_debug_state().await;
4919 let _ = reply.send(state);
4920 }
4921 Some(SessionCommand::Shutdown) | None => {
4922 self.shutdown_all().await;
4923 return;
4924 }
4925 }
4926 let handler_ms = handler_start.elapsed().as_secs_f64() * 1000.0;
4933 info!(
4934 target: "irontide_session::cmd_timing",
4935 cmd = cmd_name,
4936 queue_wait_ms = queue_wait_ms,
4937 handler_ms = handler_ms,
4938 "session_cmd"
4939 );
4940 }
4941 result = async {
4942 match &mut self.lsd_peers_rx {
4943 Some(rx) => rx.recv().await,
4944 None => std::future::pending().await,
4945 }
4946 } => {
4947 if let Some((info_hash, peer_addr)) = result
4948 && let Some(entry) = self.torrents.get(&info_hash)
4949 {
4950 let is_priv = entry.is_private().await;
4953 if !is_priv {
4954 let _ = entry.handle.add_peers(vec![peer_addr], crate::peer_state::PeerSource::Lsd).await;
4956 }
4957 }
4958 }
4959 Some(conn) = self.validated_conn_rx.recv() => {
4961 self.handle_identified_inbound(conn);
4962 }
4963 result = async {
4965 if let Some(ref mut listener) = self.ssl_listener {
4966 listener.accept().await
4967 } else {
4968 std::future::pending().await
4969 }
4970 } => {
4971 if let Ok((stream, addr)) = result {
4972 self.handle_ssl_incoming(stream, addr).await;
4973 }
4974 }
4975 _ = refill_interval.tick() => {
4977 let elapsed = std::time::Duration::from_millis(100);
4978 self.global_upload_bucket.lock().refill(elapsed);
4979 self.global_download_bucket.lock().refill(elapsed);
4980 }
4981 _ = auto_manage_interval.tick() => {
4983 self.evaluate_queue().await;
4984 }
4985 alert = self.self_alert_rx.recv() => {
4989 if let Ok(alert) = alert
4990 && matches!(
4991 alert.kind,
4992 AlertKind::StateChanged {
4993 prev_state: TorrentState::Checking,
4994 new_state,
4995 ..
4996 } if new_state != TorrentState::Checking
4997 )
4998 {
4999 self.evaluate_queue().await;
5000 }
5001 }
5002 event = recv_nat_event(&mut self.nat_events_rx) => {
5004 match event {
5005 irontide_nat::NatEvent::MappingSucceeded { port, protocol } => {
5006 info!(port, %protocol, "port mapping succeeded");
5007 post_alert(
5008 &self.alert_tx,
5009 &self.alert_mask,
5010 AlertKind::PortMappingSucceeded { port, protocol },
5011 );
5012 }
5013 irontide_nat::NatEvent::MappingFailed { port, message } => {
5014 warn!(port, %message, "port mapping failed");
5015 post_alert(
5016 &self.alert_tx,
5017 &self.alert_mask,
5018 AlertKind::PortMappingFailed { port, message },
5019 );
5020 }
5021 irontide_nat::NatEvent::ExternalIpDiscovered { ip } => {
5022 info!(%ip, "external IP discovered via NAT traversal");
5023 self.external_ip = Some(ip);
5024 for entry in self.torrents.values() {
5026 let _ = entry.handle.update_external_ip(ip).await;
5027 }
5028 if let Some(dht) = &self.dht_v4 {
5030 let _ = dht.update_external_ip(ip, irontide_dht::IpVoteSource::Nat).await;
5031 }
5032 if let Some(dht) = &self.dht_v6 {
5033 let _ = dht.update_external_ip(ip, irontide_dht::IpVoteSource::Nat).await;
5034 }
5035 }
5036 }
5037 }
5038 Some(ip) = recv_dht_ip(&mut self.dht_v4_ip_rx) => {
5040 info!(%ip, "external IP discovered via DHT v4 (BEP 42)");
5041 self.external_ip = Some(ip);
5042 for entry in self.torrents.values() {
5043 let _ = entry.handle.update_external_ip(ip).await;
5044 }
5045 }
5046 Some(ip) = recv_dht_ip(&mut self.dht_v6_ip_rx) => {
5048 info!(%ip, "external IP discovered via DHT v6 (BEP 42)");
5049 self.external_ip = Some(ip);
5050 for entry in self.torrents.values() {
5051 let _ = entry.handle.update_external_ip(ip).await;
5052 }
5053 }
5054 _ = async {
5056 match &mut stats_timer {
5057 Some(t) => t.tick().await,
5058 None => std::future::pending().await,
5059 }
5060 } => {
5061 self.fire_stats_alert();
5062 }
5063 _ = async {
5065 match &mut sample_timer {
5066 Some(t) => t.tick().await,
5067 None => std::future::pending().await,
5068 }
5069 } => {
5070 self.fire_sample_infohashes().await;
5071 }
5072 _ = async {
5074 match &mut resume_save_interval {
5075 Some(t) => t.tick().await,
5076 None => std::future::pending().await,
5077 }
5078 } => {
5079 let count = self.save_dirty_resume_files().await;
5080 if count > 0 {
5081 info!(count, "periodic resume save completed");
5082 }
5083 }
5084 () = self.resume_save_notify.notified() => {
5086 resume_save_interval = if self.settings.save_resume_interval_secs > 0 {
5087 Some(tokio::time::interval(std::time::Duration::from_secs(
5088 self.settings.save_resume_interval_secs,
5089 )))
5090 } else {
5091 None
5092 };
5093 if let Some(ref mut t) = resume_save_interval {
5094 t.tick().await; }
5096 }
5097 }
5098 }
5099 }
5100
5101 fn global_buckets_if_limited(&self) -> (Option<SharedBucket>, Option<SharedBucket>) {
5103 let up = if self.settings.upload_rate_limit > 0 {
5104 Some(Arc::clone(&self.global_upload_bucket))
5105 } else {
5106 None
5107 };
5108 let down = if self.settings.download_rate_limit > 0 {
5109 Some(Arc::clone(&self.global_download_bucket))
5110 } else {
5111 None
5112 };
5113 (up, down)
5114 }
5115
5116 fn make_slot_tuner(&self) -> crate::slot_tuner::SlotTuner {
5117 if self.settings.auto_upload_slots {
5118 crate::slot_tuner::SlotTuner::new(
5119 4, self.settings.auto_upload_slots_min,
5121 self.settings.auto_upload_slots_max,
5122 )
5123 } else {
5124 crate::slot_tuner::SlotTuner::disabled(4)
5125 }
5126 }
5127
5128 fn make_torrent_config(&self) -> TorrentConfig {
5129 TorrentConfig::from(&self.settings)
5130 }
5131
5132 fn next_queue_position(&self) -> i32 {
5134 self.torrents
5135 .values()
5136 .filter(|e| e.auto_managed)
5137 .map(|e| e.queue_position)
5138 .max()
5139 .map_or(0, |m| m + 1)
5140 }
5141
5142 async fn handle_add_torrent(
5151 &mut self,
5152 torrent_meta: irontide_core::TorrentMeta,
5153 storage: Option<Arc<dyn TorrentStorage>>,
5154 download_dir: Option<PathBuf>,
5155 tags: Vec<String>,
5156 ) -> crate::Result<Id20> {
5157 let info_hash = torrent_meta
5158 .as_v1()
5159 .map_or_else(|| torrent_meta.info_hashes().best_v1(), |v| v.info_hash);
5160 if self.torrents.contains_key(&info_hash) {
5161 return Err(crate::Error::DuplicateTorrent(info_hash));
5162 }
5163 if self.torrents.len() >= self.settings.max_torrents {
5164 return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5165 }
5166 let bundle =
5167 self.build_add_torrent_prep_bundle(torrent_meta, storage, download_dir, tags, None);
5168 let prep = prepare_add_torrent_off_actor(bundle).await;
5169 self.commit_add_torrent(prep).await
5170 }
5171
5172 fn build_add_torrent_prep_bundle(
5177 &self,
5178 torrent_meta: irontide_core::TorrentMeta,
5179 storage: Option<Arc<dyn TorrentStorage>>,
5180 download_dir: Option<PathBuf>,
5181 tags: Vec<String>,
5182 m170_post: Option<M170PostAdd>,
5183 ) -> AddTorrentPrepBundle {
5184 let mut torrent_config = self.make_torrent_config();
5185 if let Some(dir) = download_dir {
5186 torrent_config.download_dir = dir;
5187 }
5188 torrent_config.tags = tags;
5193
5194 let (global_up, global_down) = self.global_buckets_if_limited();
5195 let slot_tuner = self.make_slot_tuner();
5196
5197 AddTorrentPrepBundle {
5198 torrent_meta,
5199 storage_override: storage,
5200 torrent_config,
5201 disk_manager: self.disk_manager.clone(),
5202 dht_v4_broadcast: self.dht_v4_broadcast.clone(),
5203 dht_v6_broadcast: self.dht_v6_broadcast.clone(),
5204 global_up,
5205 global_down,
5206 slot_tuner,
5207 alert_tx: self.alert_tx.clone(),
5208 alert_mask: Arc::clone(&self.alert_mask),
5209 utp_socket: self.utp_socket.clone(),
5210 utp_socket_v6: self.utp_socket_v6.clone(),
5211 ban_manager: Arc::clone(&self.ban_manager),
5212 ip_filter: Arc::clone(&self.ip_filter),
5213 plugins: Arc::clone(&self.plugins),
5214 sam_session: self.sam_session.clone(),
5215 ssl_manager: self.ssl_manager.clone(),
5216 factory: Arc::clone(&self.factory),
5217 hash_pool: Arc::clone(&self.hash_pool),
5218 counters: Arc::clone(&self.counters),
5219 m170_post,
5220 }
5221 }
5222
5223 async fn commit_add_torrent(
5229 &mut self,
5230 prep: crate::Result<PreparedAddTorrent>,
5231 ) -> crate::Result<Id20> {
5232 let PreparedAddTorrent {
5233 handle,
5234 info_hash,
5235 is_private,
5236 m170_post,
5237 } = prep?;
5238 if self.torrents.contains_key(&info_hash) {
5244 drop(handle);
5245 return Err(crate::Error::DuplicateTorrent(info_hash));
5246 }
5247 if self.torrents.len() >= self.settings.max_torrents {
5248 drop(handle);
5249 return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5250 }
5251 self.torrents.insert(
5252 info_hash,
5253 TorrentEntry {
5254 handle,
5255 queue_position: -1,
5256 auto_managed: true,
5257 started_at: Some(tokio::time::Instant::now()),
5258 smoothed_download_rate: f64::MAX,
5259 smoothed_upload_rate: f64::MAX,
5260 },
5261 );
5262 self.info_hash_registry.insert(info_hash, ());
5263
5264 let pos = self.next_queue_position();
5266 if let Some(entry) = self.torrents.get_mut(&info_hash)
5267 && entry.auto_managed
5268 {
5269 entry.queue_position = pos;
5270 }
5271
5272 info!(%info_hash, "torrent added to session");
5273 if let Some(ref lsd) = self.lsd
5280 && !is_private
5281 {
5282 lsd.announce(vec![info_hash]).await;
5283 }
5284 if let Some(M170PostAdd { category, paused }) = m170_post {
5288 self.apply_post_add_m170(info_hash, category, paused);
5289 }
5290 Ok(info_hash)
5291 }
5292
5293 fn try_spawn_add_torrent(
5297 &self,
5298 bundle: AddTorrentPrepBundle,
5299 reply: oneshot::Sender<crate::Result<Id20>>,
5300 ) {
5301 let commit_tx = self.commit_tx.clone();
5302 tokio::spawn(async move {
5303 let result = prepare_add_torrent_off_actor(bundle).await;
5304 if commit_tx
5305 .send(SessionCommand::CommitAddTorrent { result, reply })
5306 .await
5307 .is_err()
5308 {
5309 warn!("M223 prep task: commit_tx send failed (session shutting down)");
5314 }
5315 });
5316 }
5317
5318 async fn handle_add_magnet(
5319 &mut self,
5320 magnet: Magnet,
5321 download_dir: Option<PathBuf>,
5322 tags: Vec<String>,
5323 ) -> crate::Result<Id20> {
5324 let info_hash = magnet.info_hash();
5325 let display_name = magnet.display_name.clone().unwrap_or_default();
5326 if self.torrents.contains_key(&info_hash) {
5327 return Err(crate::Error::DuplicateTorrent(info_hash));
5328 }
5329 if self.torrents.len() >= self.settings.max_torrents {
5330 return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5331 }
5332 let mut config = self.make_torrent_config();
5333 if let Some(dir) = download_dir {
5334 config.download_dir = dir;
5335 }
5336 config.tags = tags;
5341 let (global_up, global_down) = self.global_buckets_if_limited();
5342 let slot_tuner = self.make_slot_tuner();
5343 let handle = TorrentHandle::from_magnet(
5344 magnet,
5345 self.disk_manager.clone(),
5346 config,
5347 self.dht_v4_broadcast.subscribe(),
5348 self.dht_v6_broadcast.subscribe(),
5349 global_up,
5350 global_down,
5351 slot_tuner,
5352 self.alert_tx.clone(),
5353 Arc::clone(&self.alert_mask),
5354 self.utp_socket.clone(),
5355 self.utp_socket_v6.clone(),
5356 Arc::clone(&self.ban_manager),
5357 Arc::clone(&self.ip_filter),
5358 Arc::clone(&self.plugins),
5359 self.sam_session.clone(),
5360 self.ssl_manager.clone(),
5361 Arc::clone(&self.factory),
5362 Some(Arc::clone(&self.hash_pool)),
5363 Arc::clone(&self.counters),
5364 )
5365 .await?;
5366 self.spawn_metadata_resolver(info_hash, &handle);
5370
5371 self.torrents.insert(
5372 info_hash,
5373 TorrentEntry {
5374 handle,
5375 queue_position: -1,
5376 auto_managed: true,
5377 started_at: Some(tokio::time::Instant::now()),
5378 smoothed_download_rate: f64::MAX,
5379 smoothed_upload_rate: f64::MAX,
5380 },
5381 );
5382 self.info_hash_registry.insert(info_hash, ());
5383
5384 let pos = self.next_queue_position();
5386 if let Some(entry) = self.torrents.get_mut(&info_hash)
5387 && entry.auto_managed
5388 {
5389 entry.queue_position = pos;
5390 }
5391
5392 info!(%info_hash, "magnet torrent added to session");
5393 post_alert(
5394 &self.alert_tx,
5395 &self.alert_mask,
5396 AlertKind::TorrentAdded {
5397 info_hash,
5398 name: display_name,
5399 },
5400 );
5401 if let Some(ref lsd) = self.lsd {
5405 lsd.announce(vec![info_hash]).await;
5406 }
5407 Ok(info_hash)
5408 }
5409
5410 fn spawn_metadata_resolver(&self, info_hash: Id20, torrent_handle: &TorrentHandle) {
5417 let dht = match self.dht_v4 {
5418 Some(ref dht) => dht.clone(),
5419 None => return, };
5421 let factory = Arc::clone(&self.factory);
5422 let connect_timeout = std::time::Duration::from_secs(self.settings.peer_connect_timeout);
5423 let handle = torrent_handle.clone();
5424
5425 tokio::spawn(async move {
5426 let peer_rx = match dht.get_peers(info_hash).await {
5427 Ok(rx) => rx,
5428 Err(e) => {
5429 debug!(
5430 %info_hash,
5431 "metadata resolver: failed to start DHT get_peers: {e}"
5432 );
5433 return;
5434 }
5435 };
5436
5437 let peer_id = irontide_core::PeerId::generate().0;
5438 match crate::metadata_resolver::resolve_metadata(
5439 info_hash,
5440 peer_id,
5441 peer_rx,
5442 factory,
5443 connect_timeout,
5444 crate::metadata_resolver::DEFAULT_MAX_CONCURRENT,
5445 )
5446 .await
5447 {
5448 Ok((meta, peers)) => {
5449 let info_bytes = if let Some(b) = meta.info_bytes {
5450 b.to_vec()
5451 } else {
5452 match irontide_bencode::to_bytes(&meta.info) {
5453 Ok(bytes) => bytes,
5454 Err(e) => {
5455 debug!(
5456 %info_hash,
5457 "metadata resolver: failed to re-encode info dict: {e}"
5458 );
5459 return;
5460 }
5461 }
5462 };
5463 debug!(
5464 %info_hash,
5465 num_peers = peers.len(),
5466 "metadata resolver: pre-resolved metadata, sending to torrent actor"
5467 );
5468 handle.send_pre_resolved_metadata(info_bytes, peers);
5469 }
5470 Err(e) => {
5471 debug!(
5472 %info_hash,
5473 "metadata resolver: failed to resolve metadata: {e}"
5474 );
5475 }
5476 }
5477 });
5478 }
5479
5480 async fn handle_remove_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5481 let entry = self
5482 .torrents
5483 .remove(&info_hash)
5484 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5485 self.info_hash_registry.remove(&info_hash);
5486 let was_auto_managed = entry.auto_managed;
5487 let removed_position = entry.queue_position;
5488 entry.handle.shutdown().await?;
5489 self.disk_manager.unregister_torrent(info_hash).await;
5490
5491 if was_auto_managed && removed_position >= 0 {
5493 let mut entries = self.queue_entries();
5494 let changed = crate::queue::remove_position(&mut entries, removed_position);
5495 self.apply_queue_changes(&changed);
5496 }
5497
5498 let resume_dir = self.effective_resume_dir();
5502 if let Err(e) = crate::resume_file::delete_resume_file(&resume_dir, &info_hash) {
5503 if e.kind() != std::io::ErrorKind::NotFound {
5505 warn!(%info_hash, "failed to delete resume file on removal: {e}");
5506 }
5507 }
5508
5509 info!(%info_hash, "torrent removed from session");
5510 post_alert(
5511 &self.alert_tx,
5512 &self.alert_mask,
5513 AlertKind::TorrentRemoved { info_hash },
5514 );
5515 Ok(())
5516 }
5517
5518 async fn dispatch_add_torrent_m170(
5532 &mut self,
5533 params: AddTorrentParams,
5534 reply: oneshot::Sender<crate::Result<Id20>>,
5535 ) {
5536 let (resolved_dir, resolved_category) =
5539 match self.resolve_download_dir_and_category(¶ms) {
5540 Ok(x) => x,
5541 Err(e) => {
5542 let _ = reply.send(Err(e));
5543 return;
5544 }
5545 };
5546
5547 let AddTorrentParams {
5548 source,
5549 tags,
5550 paused,
5551 skip_checking: _, ..
5553 } = params;
5554
5555 let paused = paused.unwrap_or(self.settings.default_add_paused);
5558
5559 match source {
5560 AddSource::Magnet(uri) => {
5561 let result: crate::Result<Id20> = async {
5563 let magnet = irontide_core::Magnet::parse(&uri)?;
5564 let info_hash = magnet.info_hash();
5565 self.reject_if_in_deletion_grace(info_hash)?;
5566 let id = self.handle_add_magnet(magnet, resolved_dir, tags).await?;
5567 self.apply_post_add_m170(id, resolved_category, paused);
5568 Ok(id)
5569 }
5570 .await;
5571 let _ = reply.send(result);
5572 }
5573 AddSource::Bytes(bytes) => {
5574 let setup: crate::Result<AddTorrentPrepBundle> = (|| {
5576 let meta = irontide_core::torrent_from_bytes_any(&bytes)?;
5577 let info_hash = meta
5578 .as_v1()
5579 .map_or_else(|| meta.info_hashes().best_v1(), |v| v.info_hash);
5580 self.reject_if_in_deletion_grace(info_hash)?;
5581 if self.torrents.contains_key(&info_hash) {
5582 return Err(crate::Error::DuplicateTorrent(info_hash));
5583 }
5584 if self.torrents.len() >= self.settings.max_torrents {
5585 return Err(crate::Error::SessionAtCapacity(self.settings.max_torrents));
5586 }
5587 Ok(self.build_add_torrent_prep_bundle(
5588 meta,
5589 None,
5590 resolved_dir,
5591 tags,
5592 Some(M170PostAdd {
5593 category: resolved_category,
5594 paused,
5595 }),
5596 ))
5597 })();
5598 match setup {
5599 Ok(bundle) => self.try_spawn_add_torrent(bundle, reply),
5600 Err(e) => {
5601 let _ = reply.send(Err(e));
5602 }
5603 }
5604 }
5605 }
5606 }
5607
5608 fn resolve_download_dir_and_category(
5611 &self,
5612 params: &AddTorrentParams,
5613 ) -> crate::Result<(Option<PathBuf>, Option<String>)> {
5614 match (¶ms.download_dir, ¶ms.category) {
5615 (Some(explicit), cat) => {
5616 Ok((Some(explicit.clone()), cat.clone()))
5619 }
5620 (None, Some(name)) => {
5621 let registry = self.category_registry.read();
5622 match registry.get(name) {
5623 Some(meta) => Ok((Some(meta.save_path.clone()), Some(name.clone()))),
5624 None => Err(crate::Error::CategoryNotFound(name.clone())),
5625 }
5626 }
5627 (None, None) => Ok((None, None)),
5628 }
5629 }
5630
5631 fn reject_if_in_deletion_grace(&self, info_hash: Id20) -> crate::Result<()> {
5634 if self.deletion_grace.lock().contains(&info_hash) {
5635 return Err(crate::Error::TorrentBeingRemoved(info_hash));
5636 }
5637 Ok(())
5638 }
5639
5640 fn apply_post_add_m170(&self, info_hash: Id20, category: Option<String>, paused: bool) {
5644 if let Some(entry) = self.torrents.get(&info_hash) {
5645 if let Some(name) = category {
5649 let handle = entry.handle.clone();
5650 tokio::spawn(async move {
5651 if let Err(e) = handle.set_category(Some(name)).await {
5652 warn!(%info_hash, "failed to propagate category: {e}");
5653 }
5654 });
5655 }
5656 if paused {
5657 let handle = entry.handle.clone();
5658 tokio::spawn(async move {
5659 if let Err(e) = handle.pause().await {
5660 warn!(%info_hash, "failed to pause on add: {e}");
5661 }
5662 });
5663 }
5664 }
5665 }
5666
5667 async fn handle_create_category(
5669 &self,
5670 name: String,
5671 save_path: PathBuf,
5672 ) -> Result<(), crate::category_manager::CategoryError> {
5673 {
5674 let mut registry = self.category_registry.write();
5675 registry.create(name, save_path)?;
5676 }
5677 self.persist_category_registry().await
5678 }
5679
5680 async fn handle_edit_category(
5682 &self,
5683 name: String,
5684 save_path: PathBuf,
5685 ) -> Result<(), crate::category_manager::CategoryError> {
5686 {
5687 let mut registry = self.category_registry.write();
5688 registry.edit(&name, save_path)?;
5689 }
5690 self.persist_category_registry().await
5691 }
5692
5693 async fn handle_remove_categories(&self, names: Vec<String>) -> Vec<String> {
5697 let removed: Vec<String> = {
5698 let mut registry = self.category_registry.write();
5699 registry.remove(&names)
5700 };
5701 if removed.is_empty() {
5702 return removed;
5703 }
5704
5705 for entry in self.torrents.values() {
5709 let handle = entry.handle.clone();
5710 let to_check: Vec<String> = removed.clone();
5711 tokio::spawn(async move {
5712 if let Ok(stats) = handle.stats().await
5713 && let Some(current) = stats.category
5714 && to_check.iter().any(|n| n.as_str() == current.as_str())
5715 && let Err(e) = handle.set_category(None).await
5716 {
5717 warn!(
5718 cat = %current,
5719 "failed to clear category label after removeCategories: {e}"
5720 );
5721 }
5722 });
5723 }
5724
5725 if let Err(e) = self.persist_category_registry().await {
5726 warn!("failed to persist category registry after remove: {e}");
5727 }
5728 removed
5729 }
5730
5731 async fn persist_category_registry(
5733 &self,
5734 ) -> Result<(), crate::category_manager::CategoryError> {
5735 let registry = Arc::clone(&self.category_registry);
5736 let snapshot = registry.read().clone();
5739 tokio::task::spawn_blocking(move || snapshot.save())
5740 .await
5741 .map_err(|join_err| {
5742 crate::category_manager::CategoryError::Persistence(std::io::Error::other(format!(
5743 "category registry save join error: {join_err}"
5744 )))
5745 })?
5746 }
5747
5748 async fn handle_delete_tags(&self, names: Vec<String>) -> Vec<String> {
5755 let removed = {
5756 let mut reg = self.tag_registry.write();
5757 reg.delete(&names)
5758 };
5759 if !removed.is_empty() {
5760 let to_remove: std::collections::HashSet<String> = removed.iter().cloned().collect();
5761 for entry in self.torrents.values() {
5762 let handle = entry.handle.clone();
5763 let to_remove = to_remove.clone();
5764 tokio::spawn(async move {
5765 if let Ok(stats) = handle.stats().await {
5766 let new_tags: Vec<String> = stats
5767 .tags
5768 .into_iter()
5769 .filter(|t| !to_remove.contains(t))
5770 .collect();
5771 if let Err(e) = handle.set_tags(new_tags).await {
5772 tracing::warn!(error = %e, "failed to apply tag deletion to torrent");
5773 }
5774 }
5775 });
5776 }
5777 if let Err(e) = self.persist_tag_registry().await {
5778 tracing::warn!(error = %e, "persist tag registry after DeleteTags");
5779 }
5780 }
5781 removed
5782 }
5783
5784 async fn handle_add_tags_to_torrents(
5796 &self,
5797 info_hashes: Vec<Id20>,
5798 tags_to_add: Vec<String>,
5799 ) -> crate::Result<()> {
5800 for hash in info_hashes {
5801 let Some(entry) = self.torrents.get(&hash) else {
5802 continue;
5803 };
5804 let current = entry.handle.stats().await?;
5805 let mut new_tags = current.tags;
5806 for t in &tags_to_add {
5807 if !new_tags.contains(t) {
5808 new_tags.push(t.clone());
5809 }
5810 }
5811 new_tags.sort();
5812 new_tags.dedup();
5813 entry.handle.set_tags(new_tags).await?;
5814 }
5815 Ok(())
5816 }
5817
5818 async fn handle_remove_tags_from_torrents(
5827 &self,
5828 info_hashes: Vec<Id20>,
5829 tags_to_remove: Vec<String>,
5830 ) -> crate::Result<()> {
5831 for hash in info_hashes {
5832 let Some(entry) = self.torrents.get(&hash) else {
5833 continue;
5834 };
5835 let current = entry.handle.stats().await?;
5836 let new_tags: Vec<String> = current
5837 .tags
5838 .into_iter()
5839 .filter(|t| !tags_to_remove.contains(t))
5840 .collect();
5841 entry.handle.set_tags(new_tags).await?;
5842 }
5843 Ok(())
5844 }
5845
5846 async fn persist_tag_registry(&self) -> Result<(), crate::tag_manager::TagError> {
5849 let to_save: crate::tag_manager::TagRegistry = { self.tag_registry.read().clone() };
5850 tokio::task::spawn_blocking(move || to_save.save())
5851 .await
5852 .unwrap_or_else(|_| {
5853 Err(crate::tag_manager::TagError::Persistence(
5854 std::io::Error::other("spawn_blocking failed"),
5855 ))
5856 })
5857 }
5858
5859 async fn handle_remove_torrent_with_files(&mut self, info_hash: Id20) -> crate::Result<()> {
5861 let handle = {
5871 let entry = self
5872 .torrents
5873 .get(&info_hash)
5874 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5875 entry.handle.clone()
5876 };
5877 let file_paths: Vec<PathBuf> = match handle.get_meta().await {
5878 Ok(Some(meta)) => meta
5879 .info
5880 .files()
5881 .iter()
5882 .map(|f| f.path.iter().collect::<PathBuf>())
5883 .collect(),
5884 Ok(None) | Err(_) => Vec::new(),
5887 };
5888 let download_dir = self.settings.download_dir.clone();
5889 let _ = handle.pause().await;
5890
5891 self.deletion_grace.lock().insert(info_hash);
5895
5896 let remove_result = self.handle_remove_torrent(info_hash).await;
5899 if let Err(e) = &remove_result {
5900 warn!(
5901 %info_hash,
5902 error = %e,
5903 "remove_torrent_with_files: in-memory removal failed; continuing with file delete"
5904 );
5905 }
5906
5907 let grace = Arc::clone(&self.deletion_grace);
5912 tokio::task::spawn_blocking(move || {
5913 irontide_storage::delete_torrent_files_sync(download_dir, file_paths);
5914 grace.lock().remove(&info_hash);
5915 });
5916
5917 Ok(())
5918 }
5919
5920 async fn handle_pause_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5921 let entry = self
5922 .torrents
5923 .get(&info_hash)
5924 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5925 entry.handle.pause().await
5926 }
5927
5928 async fn handle_resume_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5929 let entry = self
5930 .torrents
5931 .get(&info_hash)
5932 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5933 entry.handle.resume().await
5934 }
5935
5936 async fn handle_force_resume_torrent(&mut self, info_hash: Id20) -> crate::Result<()> {
5937 let entry = self
5938 .torrents
5939 .get(&info_hash)
5940 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5941 entry
5942 .handle
5943 .cmd_tx
5944 .send(crate::types::TorrentCommand::ForceResume)
5945 .await
5946 .map_err(|_| crate::Error::Shutdown)
5947 }
5948
5949 async fn handle_set_torrent_seed_ratio(
5950 &self,
5951 info_hash: Id20,
5952 limit: Option<f64>,
5953 ) -> crate::Result<()> {
5954 let entry = self
5955 .torrents
5956 .get(&info_hash)
5957 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5958 let (tx, rx) = oneshot::channel();
5959 entry
5960 .handle
5961 .cmd_tx
5962 .send(crate::types::TorrentCommand::SetSeedRatioLimit { limit, reply: tx })
5963 .await
5964 .map_err(|_| crate::Error::Shutdown)?;
5965 rx.await.map_err(|_| crate::Error::Shutdown)
5966 }
5967
5968 async fn handle_move_torrent_storage(
5969 &self,
5970 info_hash: Id20,
5971 new_path: std::path::PathBuf,
5972 ) -> crate::Result<()> {
5973 let entry = self
5974 .torrents
5975 .get(&info_hash)
5976 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5977 entry.handle.move_storage(new_path).await
5978 }
5979
5980 async fn handle_torrent_stats(&self, info_hash: Id20) -> crate::Result<TorrentStats> {
5981 let entry = self
5982 .torrents
5983 .get(&info_hash)
5984 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
5985 let mut stats = entry.handle.stats().await?;
5986 stats.queue_position = entry.queue_position;
5988 stats.auto_managed = entry.auto_managed;
5989 Ok(stats)
5990 }
5991
5992 async fn handle_torrent_info(&self, info_hash: Id20) -> crate::Result<TorrentInfo> {
5993 let meta = self.get_entry_meta(info_hash).await?;
5998 let files: Vec<FileInfo> = if let Some(ref file_list) = meta.info.files {
5999 file_list
6000 .iter()
6001 .map(|f| FileInfo {
6002 path: f.path.iter().collect::<PathBuf>(),
6003 length: f.length,
6004 })
6005 .collect()
6006 } else {
6007 vec![FileInfo {
6008 path: PathBuf::from(&meta.info.name),
6009 length: meta.info.total_length(),
6010 }]
6011 };
6012
6013 Ok(TorrentInfo {
6014 info_hash,
6015 name: meta.info.name.clone(),
6016 total_length: meta.info.total_length(),
6017 piece_length: meta.info.piece_length,
6018 num_pieces: meta.info.num_pieces() as u32,
6019 files,
6020 private: meta.info.private == Some(1),
6021 })
6022 }
6023
6024 fn update_session_gauges(&self) {
6026 use crate::stats::{
6027 DHT_NODES, DHT_NODES_V4, DHT_NODES_V6, PEER_NUM_BANNED, SES_ACTIVE_TORRENTS,
6028 SES_NUM_TORRENTS,
6029 };
6030 let c = &self.counters;
6031 c.set(SES_NUM_TORRENTS, self.torrents.len() as i64);
6032 c.set(SES_ACTIVE_TORRENTS, self.torrents.len() as i64);
6033
6034 let dht_nodes = i64::from(self.dht_v4.is_some()) + i64::from(self.dht_v6.is_some());
6036 c.set(DHT_NODES, dht_nodes);
6037 c.set(DHT_NODES_V4, i64::from(self.dht_v4.is_some()));
6038 c.set(DHT_NODES_V6, i64::from(self.dht_v6.is_some()));
6039
6040 let ban_count = self.ban_manager.read().banned_list().len() as i64;
6042 c.set(PEER_NUM_BANNED, ban_count);
6043 }
6044
6045 fn fire_stats_alert(&self) {
6047 self.update_session_gauges();
6048 let values = self.counters.snapshot();
6049 crate::alert::post_alert(
6050 &self.alert_tx,
6051 &self.alert_mask,
6052 crate::alert::AlertKind::SessionStatsAlert { values },
6053 );
6054 }
6055
6056 async fn fire_sample_infohashes(&self) {
6058 let ((Some(dht), _) | (_, Some(dht))) = (&self.dht_v4, &self.dht_v6) else {
6059 return;
6060 };
6061 let mut buf = [0u8; 20];
6062 irontide_core::random_bytes(&mut buf);
6063 let target = Id20::from(buf);
6064 match dht.sample_infohashes(target).await {
6065 Ok(result) => {
6066 post_alert(
6067 &self.alert_tx,
6068 &self.alert_mask,
6069 AlertKind::DhtSampleInfohashes {
6070 num_samples: result.samples.len(),
6071 total_estimate: result.num,
6072 },
6073 );
6074 }
6075 Err(e) => {
6076 debug!("sample_infohashes failed: {e}");
6077 }
6078 }
6079 }
6080
6081 async fn make_session_stats(&self) -> SessionStats {
6082 self.update_session_gauges();
6083
6084 let mut total_downloaded = 0u64;
6085 let mut total_uploaded = 0u64;
6086
6087 for entry in self.torrents.values() {
6088 if let Ok(stats) = entry.handle.stats().await {
6089 total_downloaded += stats.downloaded;
6090 total_uploaded += stats.uploaded;
6091 }
6092 }
6093
6094 SessionStats {
6095 active_torrents: self.torrents.len(),
6096 total_downloaded,
6097 total_uploaded,
6098 dht_nodes: usize::from(self.dht_v4.is_some()) + usize::from(self.dht_v6.is_some()),
6099 }
6100 }
6101
6102 async fn make_debug_state(&self) -> crate::types::DebugState {
6106 use crate::stats::{
6107 DISPATCH_ACQUIRE_NONE_TOTAL, DISPATCH_ACQUIRE_TOTAL, DISPATCH_ACQUIRE_US,
6108 DISPATCH_NOTIFY_WAKEUP_TOTAL,
6109 };
6110
6111 let snap = self.counters.snapshot();
6113 let dispatch = crate::types::DebugDispatchState {
6114 acquire_total: snap[DISPATCH_ACQUIRE_TOTAL],
6115 acquire_none_total: snap[DISPATCH_ACQUIRE_NONE_TOTAL],
6116 acquire_us: snap[DISPATCH_ACQUIRE_US],
6117 notify_wakeup_total: snap[DISPATCH_NOTIFY_WAKEUP_TOTAL],
6118 pieces_queued: 0,
6119 pieces_inflight: 0,
6120 };
6121
6122 let mut torrents = Vec::with_capacity(self.torrents.len());
6123 for (&info_hash, entry) in &self.torrents {
6124 let Ok(Ok(stats)) =
6126 tokio::time::timeout(std::time::Duration::from_millis(500), entry.handle.stats())
6127 .await
6128 else {
6129 continue;
6130 };
6131
6132 let peers_raw = match tokio::time::timeout(
6134 std::time::Duration::from_millis(500),
6135 entry.handle.get_peer_info(),
6136 )
6137 .await
6138 {
6139 Ok(Ok(p)) => p,
6140 _ => Vec::new(),
6141 };
6142
6143 let peers: Vec<crate::types::DebugPeerState> = peers_raw
6144 .iter()
6145 .map(|p| crate::types::DebugPeerState {
6146 addr: p.addr,
6147 in_flight: p.in_flight_requests,
6148 target_depth: p.target_pipeline_depth,
6149 choking: p.peer_choking,
6150 download_rate: p.download_rate,
6151 })
6152 .collect();
6153
6154 let mut per_torrent_dispatch = dispatch.clone();
6155 per_torrent_dispatch.pieces_queued = stats.dispatch_pieces_queued;
6156 per_torrent_dispatch.pieces_inflight = stats.dispatch_pieces_inflight;
6157
6158 torrents.push(crate::types::DebugTorrentState {
6159 info_hash: info_hash.to_hex(),
6160 state: format!("{:?}", stats.state),
6161 num_peers: stats.peers_connected,
6162 dispatch: per_torrent_dispatch,
6163 peers,
6164 });
6165 }
6166
6167 crate::types::DebugState { torrents }
6168 }
6169
6170 async fn handle_save_torrent_resume(
6171 &self,
6172 info_hash: Id20,
6173 ) -> crate::Result<irontide_core::FastResumeData> {
6174 let entry = self
6175 .torrents
6176 .get(&info_hash)
6177 .ok_or(crate::Error::TorrentNotFound(info_hash))?;
6178 let mut resume = entry.handle.save_resume_data().await?;
6179 resume.queue_position = i64::from(entry.queue_position);
6182 resume.auto_managed = i64::from(entry.auto_managed);
6183 Ok(resume)
6184 }
6185
6186 async fn handle_save_session_state(&self) -> crate::Result<crate::persistence::SessionState> {
6187 use crate::persistence::SessionState;
6188
6189 let mut torrents = Vec::new();
6190 for (info_hash, entry) in &self.torrents {
6191 match entry.handle.save_resume_data().await {
6192 Ok(rd) => torrents.push(rd),
6193 Err(e) => {
6194 warn!(%info_hash, "failed to save resume data: {e}");
6195 }
6196 }
6197 }
6198
6199 let (banned_peers, peer_strikes) = {
6201 let ban_mgr = self.ban_manager.read();
6202 let banned_peers: Vec<String> = ban_mgr
6203 .banned_list()
6204 .iter()
6205 .map(std::string::ToString::to_string)
6206 .collect();
6207 let peer_strikes: Vec<crate::persistence::PeerStrikeEntry> = ban_mgr
6208 .strikes_map()
6209 .iter()
6210 .map(|(ip, &count)| crate::persistence::PeerStrikeEntry {
6211 ip: ip.to_string(),
6212 count: i64::from(count),
6213 })
6214 .collect();
6215 (banned_peers, peer_strikes)
6216 };
6217
6218 let mut dht_entries = Vec::new();
6219 let mut dht_node_id = None;
6220 if let Some(ref dht) = self.dht_v4 {
6221 if let Ok(stats) = dht.stats().await {
6223 dht_node_id = Some(stats.node_id.to_hex());
6224 }
6225 for (_id, addr) in dht.get_routing_nodes().await {
6226 dht_entries.push(crate::persistence::DhtNodeEntry {
6227 host: addr.ip().to_string(),
6228 port: i64::from(addr.port()),
6229 });
6230 }
6231 }
6232 if let Some(ref dht) = self.dht_v6 {
6233 for (_id, addr) in dht.get_routing_nodes().await {
6234 dht_entries.push(crate::persistence::DhtNodeEntry {
6235 host: addr.ip().to_string(),
6236 port: i64::from(addr.port()),
6237 });
6238 }
6239 }
6240
6241 Ok(SessionState {
6242 dht_nodes: dht_entries,
6243 dht_node_id,
6244 torrents,
6245 banned_peers,
6246 peer_strikes,
6247 })
6248 }
6249
6250 fn effective_resume_dir(&self) -> PathBuf {
6252 self.settings
6253 .resume_data_dir
6254 .clone()
6255 .unwrap_or_else(crate::resume_file::default_resume_dir)
6256 }
6257
6258 async fn handle_load_resume_state(&mut self) -> crate::Result<ResumeLoadResult> {
6264 let resume_dir = self.effective_resume_dir();
6265 let paths = crate::resume_file::scan_resume_dir(&resume_dir);
6266
6267 let mut restored = 0usize;
6268 let mut skipped = 0usize;
6269 let mut failed = 0usize;
6270
6271 for path in &paths {
6272 let file_name = path
6273 .file_name()
6274 .and_then(|n| n.to_str())
6275 .unwrap_or("<unknown>");
6276
6277 let bytes = match std::fs::read(path) {
6279 Ok(b) => b,
6280 Err(e) => {
6281 warn!(file = %file_name, "failed to read resume file: {e}");
6282 failed = failed.saturating_add(1);
6283 continue;
6284 }
6285 };
6286
6287 let rd = match crate::resume_file::deserialize_resume(&bytes) {
6288 Ok(rd) => rd,
6289 Err(e) => {
6290 warn!(file = %file_name, "failed to deserialize resume file: {e}");
6291 failed = failed.saturating_add(1);
6292 continue;
6293 }
6294 };
6295
6296 if let Some(meta) = crate::resume_file::reconstruct_torrent_meta(&rd) {
6298 let info_hash = meta.info_hash;
6299 let pieces = rd.pieces.clone();
6300 let torrent_meta = irontide_core::TorrentMeta::V1(meta);
6301
6302 let restore_dir = if rd.save_path.is_empty() {
6304 None
6305 } else {
6306 Some(PathBuf::from(&rd.save_path))
6307 };
6308 let restore_tags = rd.tags.clone();
6313 match self
6314 .handle_add_torrent(torrent_meta, None, restore_dir, restore_tags)
6315 .await
6316 {
6317 Ok(added_hash) => {
6318 if !pieces.is_empty()
6320 && let Some(entry) = self.torrents.get(&added_hash)
6321 && let Err(e) = entry.handle.restore_resume_bitmap(pieces).await
6322 {
6323 warn!(
6324 %info_hash,
6325 "failed to restore piece bitmap, torrent will recheck: {e}"
6326 );
6327 }
6328 if let Some(ref cat) = rd.category
6330 && let Some(entry) = self.torrents.get(&added_hash)
6331 {
6332 let handle = entry.handle.clone();
6333 let cat_owned = cat.clone();
6334 tokio::spawn(async move {
6335 let _ = handle.set_category(Some(cat_owned)).await;
6336 });
6337 }
6338 if !rd.web_seed_stats.is_empty()
6341 && let Some(entry) = self.torrents.get(&added_hash)
6342 {
6343 let handle = entry.handle.clone();
6344 let stats_owned = rd.web_seed_stats.clone();
6345 tokio::spawn(async move {
6346 let _ = handle.restore_web_seed_stats(stats_owned).await;
6347 });
6348 }
6349 if self.settings.queueing_enabled
6350 && let Some(entry) = self.torrents.get(&added_hash)
6351 {
6352 let _ = entry.handle.queue().await;
6353 }
6354 if let Some(entry) = self.torrents.get_mut(&added_hash) {
6355 entry.queue_position = rd.queue_position as i32;
6356 entry.auto_managed = rd.auto_managed != 0;
6357 }
6358 info!(%info_hash, "restored torrent from resume file");
6359 restored = restored.saturating_add(1);
6360 }
6361 Err(crate::Error::DuplicateTorrent(_)) => {
6362 debug!(%info_hash, "skipped duplicate torrent from resume");
6363 skipped = skipped.saturating_add(1);
6364 }
6365 Err(e) => {
6366 warn!(%info_hash, "failed to add restored torrent: {e}");
6367 failed = failed.saturating_add(1);
6368 }
6369 }
6370 } else if let Some(magnet) = crate::resume_file::reconstruct_magnet(&rd) {
6371 let info_hash = magnet.info_hash();
6373 let restore_dir = if rd.save_path.is_empty() {
6374 None
6375 } else {
6376 Some(PathBuf::from(&rd.save_path))
6377 };
6378 let restore_tags = rd.tags.clone();
6380 match self
6381 .handle_add_magnet(magnet, restore_dir, restore_tags)
6382 .await
6383 {
6384 Ok(added_hash) => {
6385 if let Some(ref cat) = rd.category
6387 && let Some(entry) = self.torrents.get(&added_hash)
6388 {
6389 let handle = entry.handle.clone();
6390 let cat_owned = cat.clone();
6391 tokio::spawn(async move {
6392 let _ = handle.set_category(Some(cat_owned)).await;
6393 });
6394 }
6395 if !rd.web_seed_stats.is_empty()
6399 && let Some(entry) = self.torrents.get(&added_hash)
6400 {
6401 let handle = entry.handle.clone();
6402 let stats_owned = rd.web_seed_stats.clone();
6403 tokio::spawn(async move {
6404 let _ = handle.restore_web_seed_stats(stats_owned).await;
6405 });
6406 }
6407 if self.settings.queueing_enabled
6408 && let Some(entry) = self.torrents.get(&added_hash)
6409 {
6410 let _ = entry.handle.queue().await;
6411 }
6412 if let Some(entry) = self.torrents.get_mut(&added_hash) {
6413 entry.queue_position = rd.queue_position as i32;
6414 entry.auto_managed = rd.auto_managed != 0;
6415 }
6416 info!(%info_hash, "restored magnet from resume file");
6417 restored = restored.saturating_add(1);
6418 }
6419 Err(crate::Error::DuplicateTorrent(_)) => {
6420 debug!(%info_hash, "skipped duplicate magnet from resume");
6421 skipped = skipped.saturating_add(1);
6422 }
6423 Err(e) => {
6424 warn!(%info_hash, "failed to add restored magnet: {e}");
6425 failed = failed.saturating_add(1);
6426 }
6427 }
6428 } else {
6429 warn!(file = %file_name, "resume file has no valid info dict and no valid info hash");
6430 failed = failed.saturating_add(1);
6431 }
6432 }
6433
6434 {
6438 let mut entries: Vec<(Id20, i32)> = self
6439 .torrents
6440 .iter()
6441 .filter(|(_, e)| e.auto_managed)
6442 .map(|(h, e)| (*h, e.queue_position))
6443 .collect();
6444 entries.sort_by_key(|&(_, pos)| pos);
6445 for (new_pos, (hash, _)) in entries.into_iter().enumerate() {
6446 if let Some(entry) = self.torrents.get_mut(&hash) {
6447 entry.queue_position = new_pos as i32;
6448 }
6449 }
6450 }
6451
6452 info!(restored, skipped, failed, "resume state loaded");
6453 Ok(ResumeLoadResult {
6454 restored,
6455 skipped,
6456 failed,
6457 })
6458 }
6459
6460 async fn save_dirty_resume_files(&mut self) -> usize {
6464 let resume_dir = self.effective_resume_dir();
6465
6466 if let Err(e) = std::fs::create_dir_all(resume_dir.join("torrents")) {
6467 warn!("failed to create resume dir: {e}");
6468 return 0;
6469 }
6470
6471 let mut saved = 0usize;
6472 let info_hashes: Vec<Id20> = self.torrents.keys().copied().collect();
6474
6475 for info_hash in &info_hashes {
6476 let Some(entry) = self.torrents.get(info_hash) else {
6477 continue;
6478 };
6479
6480 let needs_save = match entry.handle.stats().await {
6482 Ok(stats) => stats.need_save_resume,
6483 Err(_) => continue,
6484 };
6485 if !needs_save {
6486 continue;
6487 }
6488
6489 let mut rd = match entry.handle.save_resume_data().await {
6491 Ok(rd) => rd,
6492 Err(e) => {
6493 warn!(%info_hash, "failed to build resume data: {e}");
6494 continue;
6495 }
6496 };
6497 rd.queue_position = i64::from(entry.queue_position);
6498 rd.auto_managed = i64::from(entry.auto_managed);
6499
6500 let bytes = match crate::resume_file::serialize_resume(&rd) {
6502 Ok(b) => b,
6503 Err(e) => {
6504 warn!(%info_hash, "failed to serialize resume data: {e}");
6505 continue;
6506 }
6507 };
6508
6509 let path = crate::resume_file::resume_file_path(&resume_dir, info_hash);
6511 if let Err(e) = crate::resume_file::atomic_write(&path, &bytes) {
6512 warn!(%info_hash, "failed to write resume file: {e}");
6513 continue;
6514 }
6515
6516 if let Err(e) = entry.handle.clear_save_resume_flag().await {
6518 warn!(%info_hash, "failed to clear save_resume flag: {e}");
6519 }
6520
6521 saved = saved.saturating_add(1);
6522 }
6523 saved
6524 }
6525
6526 fn handle_apply_settings(&mut self, new: Settings) -> crate::Result<()> {
6549 new.validate()?;
6552
6553 let old_upload_rate = self.settings.upload_rate_limit;
6557 let old_download_rate = self.settings.download_rate_limit;
6558 let old_alert_mask = self.settings.alert_mask;
6559 let old_settings = self.settings.clone();
6560 let old_settings_for_delta = self.settings.clone();
6561
6562 let new_upload_rate = new.upload_rate_limit;
6563 let new_download_rate = new.download_rate_limit;
6564 let new_alert_mask = new.alert_mask;
6565
6566 let upload_bucket = Arc::clone(&self.global_upload_bucket);
6571 let download_bucket = Arc::clone(&self.global_download_bucket);
6572 let alert_mask = Arc::clone(&self.alert_mask);
6573
6574 let phase1: crate::apply::Phase<Self> = crate::apply::Phase {
6575 name: "rate_limits_and_mask",
6576 forward: Box::new(move |this: &mut Self| {
6577 if new_upload_rate != old_upload_rate {
6578 upload_bucket.lock().set_rate(new_upload_rate);
6579 }
6580 if new_download_rate != old_download_rate {
6581 download_bucket.lock().set_rate(new_download_rate);
6582 }
6583 if new_alert_mask != old_alert_mask {
6584 alert_mask.store(new_alert_mask.bits(), Ordering::Relaxed);
6585 }
6586 this.settings = new;
6587 Ok(())
6588 }),
6589 rollback: Box::new(move |this: &mut Self| {
6590 this.settings = old_settings;
6592 if new_alert_mask != old_alert_mask {
6593 this.alert_mask
6594 .store(old_alert_mask.bits(), Ordering::Relaxed);
6595 }
6596 if new_download_rate != old_download_rate {
6597 this.global_download_bucket
6598 .lock()
6599 .set_rate(old_download_rate);
6600 }
6601 if new_upload_rate != old_upload_rate {
6602 this.global_upload_bucket.lock().set_rate(old_upload_rate);
6603 }
6604 }),
6605 };
6606
6607 let phases = vec![phase1];
6609
6610 match crate::apply::apply_phases_with_rollback(self, phases) {
6611 Ok(()) => {
6612 let _ = self.notification_settings_tx.send(self.settings.clone());
6620
6621 if (old_settings_for_delta.enable_dht != self.settings.enable_dht
6623 || old_settings_for_delta.anonymous_mode != self.settings.anonymous_mode)
6624 && (!self.settings.enable_dht || self.settings.anonymous_mode)
6625 {
6626 tracing::info!("DHT disabled via settings");
6627 self.dht_v4 = None;
6628 self.dht_v6 = None;
6629 self.dht_v4_broadcast.replace(None);
6630 self.dht_v6_broadcast.replace(None);
6631 }
6632
6633 self.max_connections_global.store(
6639 self.settings.max_connections_global,
6640 std::sync::atomic::Ordering::SeqCst,
6641 );
6642
6643 let delta =
6644 crate::types::SettingsDelta::from_diff(&old_settings_for_delta, &self.settings);
6645 if delta.save_resume_interval_secs.is_some() {
6646 self.resume_save_notify.notify_one();
6647 }
6648 if let Some(enabled) = delta.ip_filter_enabled {
6649 self.ip_filter.write().enabled = enabled;
6650 }
6651 if delta.watched_folder.is_some() || delta.delete_torrent_after_add.is_some() {
6657 self.watched_folder_changed.notify_one();
6658 }
6659 if !delta.is_empty() {
6660 let mut failed: Vec<irontide_core::Id20> = Vec::new();
6667 for (hash, entry) in &self.torrents {
6668 if entry
6669 .handle
6670 .cmd_tx
6671 .try_send(crate::types::TorrentCommand::UpdateSettings(delta.clone()))
6672 .is_err()
6673 {
6674 failed.push(*hash);
6675 }
6676 }
6677 if !failed.is_empty() {
6678 tracing::warn!(
6679 count = failed.len(),
6680 "SettingsDelta fan-out: per-torrent channel saturated; \
6681 affected torrents will pick up the change on the next apply"
6682 );
6683 }
6684 }
6685 post_alert(&self.alert_tx, &self.alert_mask, AlertKind::SettingsChanged);
6686 Ok(())
6687 }
6688 Err(crate::apply::ApplyError::ValidationFailed(msg)) => {
6689 Err(crate::Error::InvalidSettings(msg))
6690 }
6691 Err(e) => Err(crate::Error::Config(format!("apply settings: {e}"))),
6692 }
6693 }
6694
6695 fn queue_entries(&self) -> Vec<crate::queue::QueueEntry> {
6697 self.torrents
6698 .iter()
6699 .filter(|(_, e)| e.auto_managed)
6700 .map(|(&hash, e)| crate::queue::QueueEntry {
6701 info_hash: hash,
6702 position: e.queue_position,
6703 })
6704 .collect()
6705 }
6706
6707 fn handle_set_queue_position(&mut self, info_hash: Id20, pos: i32) -> crate::Result<()> {
6708 if !self.torrents.contains_key(&info_hash) {
6709 return Err(crate::Error::TorrentNotFound(info_hash));
6710 }
6711 let mut entries = self.queue_entries();
6712 let changed = crate::queue::set_position(&mut entries, info_hash, pos);
6713 self.apply_queue_changes(&changed);
6714 Ok(())
6715 }
6716
6717 fn handle_queue_move(&mut self, info_hash: Id20, op: QueueMoveFn) -> crate::Result<()> {
6718 if !self.torrents.contains_key(&info_hash) {
6719 return Err(crate::Error::TorrentNotFound(info_hash));
6720 }
6721 let mut entries = self.queue_entries();
6722 let changed = op(&mut entries, info_hash);
6723 self.apply_queue_changes(&changed);
6724 Ok(())
6725 }
6726
6727 fn apply_queue_changes(&mut self, changed: &[(Id20, i32, i32)]) {
6729 for &(hash, old_pos, new_pos) in changed {
6730 if let Some(entry) = self.torrents.get_mut(&hash) {
6731 entry.queue_position = new_pos;
6732 }
6733 crate::alert::post_alert(
6734 &self.alert_tx,
6735 &self.alert_mask,
6736 crate::alert::AlertKind::TorrentQueuePositionChanged {
6737 info_hash: hash,
6738 old_pos,
6739 new_pos,
6740 },
6741 );
6742 }
6743 }
6744
6745 async fn evaluate_queue(&mut self) {
6746 if !self.settings.queueing_enabled {
6747 return;
6748 }
6749 let now = tokio::time::Instant::now();
6750 let startup_duration = std::time::Duration::from_secs(self.settings.auto_manage_startup);
6751 let mut candidates = Vec::new();
6752
6753 let hashes: Vec<Id20> = self.torrents.keys().copied().collect();
6755
6756 for &info_hash in &hashes {
6757 let (queue_position, started_at) = {
6758 let Some(entry) = self.torrents.get(&info_hash) else {
6759 continue;
6760 };
6761 if !entry.auto_managed {
6762 continue;
6763 }
6764 (entry.queue_position, entry.started_at)
6765 };
6766
6767 let stats = match self.torrents.get(&info_hash) {
6769 Some(entry) => match entry.handle.stats().await {
6770 Ok(s) => s,
6771 Err(_) => continue,
6772 },
6773 None => continue,
6774 };
6775
6776 let category = match stats.state {
6777 TorrentState::Checking | TorrentState::FetchingMetadata => {
6778 crate::queue::QueueCategory::Checking
6779 }
6780 TorrentState::Downloading => crate::queue::QueueCategory::Downloading,
6781 TorrentState::Seeding | TorrentState::Complete => {
6782 crate::queue::QueueCategory::Seeding
6783 }
6784 TorrentState::Queued => {
6785 if stats.progress >= 1.0 {
6786 crate::queue::QueueCategory::Seeding
6787 } else {
6788 crate::queue::QueueCategory::Downloading
6789 }
6790 }
6791 TorrentState::Paused | TorrentState::Stopped | TorrentState::Sharing => continue,
6792 };
6793
6794 let is_active = !matches!(stats.state, TorrentState::Paused | TorrentState::Queued);
6795
6796 let alpha = self.settings.queue_rate_ewma_alpha.clamp(0.0, 1.0);
6798 let (smoothed_dl, smoothed_ul) = if let Some(entry) = self.torrents.get_mut(&info_hash)
6799 {
6800 let raw_dl = stats.download_rate as f64;
6801 let raw_ul = stats.upload_rate as f64;
6802 entry.smoothed_download_rate =
6803 alpha.mul_add(raw_dl, (1.0 - alpha) * entry.smoothed_download_rate);
6804 entry.smoothed_upload_rate =
6805 alpha.mul_add(raw_ul, (1.0 - alpha) * entry.smoothed_upload_rate);
6806 (entry.smoothed_download_rate, entry.smoothed_upload_rate)
6807 } else {
6808 continue;
6809 };
6810
6811 let past_startup = started_at.is_none_or(|t| now.duration_since(t) > startup_duration);
6812
6813 let is_inactive = past_startup
6814 && match category {
6815 crate::queue::QueueCategory::Downloading => {
6816 (smoothed_dl as u64) < self.settings.inactive_down_rate
6817 }
6818 crate::queue::QueueCategory::Seeding => {
6819 (smoothed_ul as u64) < self.settings.inactive_up_rate
6820 }
6821 crate::queue::QueueCategory::Checking => false,
6822 };
6823
6824 let anti_flap_duration = if category == crate::queue::QueueCategory::Seeding {
6825 std::time::Duration::from_secs(self.settings.seed_queue_min_active_secs)
6826 } else {
6827 startup_duration
6828 };
6829 let recently_started =
6830 started_at.is_some_and(|t| now.duration_since(t) < anti_flap_duration);
6831
6832 let seed_rank = if category == crate::queue::QueueCategory::Seeding {
6833 Some(crate::queue::compute_seed_rank(
6834 stats.num_complete,
6835 stats.num_incomplete,
6836 ))
6837 } else {
6838 None
6839 };
6840
6841 candidates.push(crate::queue::QueueCandidate {
6842 info_hash,
6843 position: queue_position,
6844 category,
6845 is_active,
6846 is_inactive,
6847 recently_started,
6848 seed_rank,
6849 });
6850 }
6851
6852 let config = crate::queue::QueueConfig {
6853 active_downloads: self.settings.active_downloads,
6854 active_seeds: self.settings.active_seeds,
6855 active_checking: self.settings.active_checking,
6856 active_limit: self.settings.active_limit,
6857 dont_count_slow: self.settings.dont_count_slow_torrents,
6858 prefer_seeds: self.settings.auto_manage_prefer_seeds,
6859 };
6860 let mut decision = crate::queue::evaluate(&candidates, &config);
6861 crate::queue::apply_preemption(&mut decision, &candidates);
6862
6863 for hash in &decision.to_pause {
6865 if let Some(entry) = self.torrents.get(hash) {
6866 let _ = entry.handle.queue().await;
6867 }
6868 post_alert(
6869 &self.alert_tx,
6870 &self.alert_mask,
6871 AlertKind::TorrentAutoManaged {
6872 info_hash: *hash,
6873 paused: true,
6874 },
6875 );
6876 }
6877
6878 for hash in &decision.to_resume {
6879 if let Some(entry) = self.torrents.get_mut(hash) {
6880 let _ = entry.handle.resume().await;
6881 entry.started_at = Some(tokio::time::Instant::now());
6882 }
6883 post_alert(
6884 &self.alert_tx,
6885 &self.alert_mask,
6886 AlertKind::TorrentAutoManaged {
6887 info_hash: *hash,
6888 paused: false,
6889 },
6890 );
6891 }
6892 }
6893
6894 fn handle_identified_inbound(&self, conn: crate::listener::IdentifiedConnection) {
6896 if let Some(entry) = self.torrents.get(&conn.info_hash) {
6897 debug!(%conn.addr, %conn.info_hash, "routing validated inbound peer");
6898 let handle = entry.handle.clone();
6899 tokio::spawn(async move {
6900 let _ = handle.send_incoming_peer(conn.stream, conn.addr).await;
6901 });
6902 } else {
6903 debug!(%conn.addr, %conn.info_hash, "validated peer for removed torrent, dropping");
6905 }
6906 }
6907
6908 async fn handle_ssl_incoming(
6915 &mut self,
6916 stream: crate::transport::BoxedStream,
6917 addr: std::net::SocketAddr,
6918 ) {
6919 use tokio_rustls::LazyConfigAcceptor;
6920
6921 let acceptor = LazyConfigAcceptor::new(rustls::server::Acceptor::default(), stream);
6922
6923 let start_handshake = match acceptor.await {
6924 Ok(sh) => sh,
6925 Err(e) => {
6926 debug!(%addr, error = %e, "SSL ClientHello read failed");
6927 return;
6928 }
6929 };
6930
6931 let client_hello = start_handshake.client_hello();
6933 let sni = if let Some(name) = client_hello.server_name() {
6934 name.to_string()
6935 } else {
6936 debug!(%addr, "SSL connection missing SNI");
6937 return;
6938 };
6939
6940 let Ok(info_hash) = Id20::from_hex(&sni) else {
6942 debug!(%addr, sni = %sni, "SSL SNI is not a valid info hash");
6943 return;
6944 };
6945
6946 let Some(torrent) = self.torrents.get(&info_hash) else {
6948 debug!(%addr, %info_hash, "SSL connection for unknown torrent");
6949 return;
6950 };
6951
6952 let meta = match torrent.handle.get_meta().await {
6959 Ok(Some(m)) => m,
6960 Ok(None) => {
6961 debug!(%addr, %info_hash, "SSL connection for torrent still resolving metadata");
6962 return;
6963 }
6964 Err(_) => {
6965 debug!(%addr, %info_hash, "SSL connection but TorrentActor shut down");
6966 return;
6967 }
6968 };
6969 let ssl_cert = if let Some(cert) = meta.ssl_cert.as_ref() {
6970 cert.clone()
6971 } else {
6972 debug!(%addr, %info_hash, "SSL connection for non-SSL torrent (no ssl_cert in info dict)");
6973 return;
6974 };
6975
6976 let server_config = if let Some(mgr) = self.ssl_manager.as_ref() {
6978 match mgr.server_config(&ssl_cert) {
6979 Ok(cfg) => cfg,
6980 Err(e) => {
6981 warn!(%addr, %info_hash, error = %e, "failed to build SSL server config");
6982 return;
6983 }
6984 }
6985 } else {
6986 debug!(%addr, "SSL manager not initialized");
6987 return;
6988 };
6989
6990 let tls_stream = match start_handshake.into_stream(server_config).await {
6992 Ok(s) => s,
6993 Err(e) => {
6994 warn!(%addr, %info_hash, error = %e, "SSL handshake failed");
6995 post_alert(
6996 &self.alert_tx,
6997 &self.alert_mask,
6998 AlertKind::SslTorrentError {
6999 info_hash,
7000 message: format!("inbound TLS handshake from {addr}: {e}"),
7001 },
7002 );
7003 return;
7004 }
7005 };
7006
7007 let _ = torrent.handle.spawn_ssl_peer(addr, tls_stream).await;
7009 }
7010
7011 async fn handle_dht_put_immutable(&self, value: Vec<u8>) -> crate::Result<Id20> {
7012 let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7013 match dht.put_immutable(value.clone()).await {
7014 Ok(target) => {
7015 post_alert(
7016 &self.alert_tx,
7017 &self.alert_mask,
7018 AlertKind::DhtPutComplete { target },
7019 );
7020 Ok(target)
7021 }
7022 Err(e) => {
7023 let target = irontide_core::sha1(&value);
7024 post_alert(
7025 &self.alert_tx,
7026 &self.alert_mask,
7027 AlertKind::DhtItemError {
7028 target,
7029 message: e.to_string(),
7030 },
7031 );
7032 Err(crate::Error::Dht(e))
7033 }
7034 }
7035 }
7036
7037 async fn handle_dht_get_immutable(&self, target: Id20) -> crate::Result<Option<Vec<u8>>> {
7038 let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7039 match dht.get_immutable(target).await {
7040 Ok(value) => {
7041 post_alert(
7042 &self.alert_tx,
7043 &self.alert_mask,
7044 AlertKind::DhtGetResult {
7045 target,
7046 value: value.clone(),
7047 },
7048 );
7049 Ok(value)
7050 }
7051 Err(e) => {
7052 post_alert(
7053 &self.alert_tx,
7054 &self.alert_mask,
7055 AlertKind::DhtItemError {
7056 target,
7057 message: e.to_string(),
7058 },
7059 );
7060 Err(crate::Error::Dht(e))
7061 }
7062 }
7063 }
7064
7065 async fn handle_dht_put_mutable(
7066 &self,
7067 keypair_bytes: [u8; 32],
7068 value: Vec<u8>,
7069 seq: i64,
7070 salt: Vec<u8>,
7071 ) -> crate::Result<Id20> {
7072 let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7073 match dht.put_mutable(keypair_bytes, value, seq, salt).await {
7074 Ok(target) => {
7075 post_alert(
7076 &self.alert_tx,
7077 &self.alert_mask,
7078 AlertKind::DhtMutablePutComplete { target, seq },
7079 );
7080 Ok(target)
7081 }
7082 Err(e) => {
7083 post_alert(
7084 &self.alert_tx,
7085 &self.alert_mask,
7086 AlertKind::DhtItemError {
7087 target: Id20::from([0u8; 20]),
7088 message: e.to_string(),
7089 },
7090 );
7091 Err(crate::Error::Dht(e))
7092 }
7093 }
7094 }
7095
7096 async fn handle_dht_get_mutable(
7097 &self,
7098 public_key: [u8; 32],
7099 salt: Vec<u8>,
7100 ) -> crate::Result<Option<(Vec<u8>, i64)>> {
7101 let dht = self.dht_v4.as_ref().ok_or(crate::Error::DhtDisabled)?;
7102 let target = irontide_dht::compute_mutable_target(&public_key, &salt);
7103 match dht.get_mutable(public_key, salt).await {
7104 Ok(result) => {
7105 let (value, seq) = match &result {
7106 Some((v, s)) => (Some(v.clone()), Some(*s)),
7107 None => (None, None),
7108 };
7109 post_alert(
7110 &self.alert_tx,
7111 &self.alert_mask,
7112 AlertKind::DhtMutableGetResult {
7113 target,
7114 value,
7115 seq,
7116 public_key,
7117 },
7118 );
7119 Ok(result)
7120 }
7121 Err(e) => {
7122 post_alert(
7123 &self.alert_tx,
7124 &self.alert_mask,
7125 AlertKind::DhtItemError {
7126 target,
7127 message: e.to_string(),
7128 },
7129 );
7130 Err(crate::Error::Dht(e))
7131 }
7132 }
7133 }
7134
7135 async fn shutdown_all(&mut self) {
7136 let save_count = self.save_dirty_resume_files().await;
7138 if save_count > 0 {
7139 info!(save_count, "saved resume files on shutdown");
7140 }
7141
7142 for (info_hash, entry) in self.torrents.drain() {
7143 debug!(%info_hash, "shutting down torrent");
7144 let _ = entry.handle.shutdown().await;
7145 }
7146 if let Some(ref dht) = self.dht_v4 {
7147 let _ = dht.shutdown().await;
7148 }
7149 if let Some(ref dht) = self.dht_v6 {
7150 let _ = dht.shutdown().await;
7151 }
7152 if let Some(ref nat) = self.nat {
7153 nat.shutdown().await;
7154 }
7155 if let Some(ref lsd) = self.lsd {
7156 lsd.shutdown().await;
7157 }
7158 if let Some(ref socket) = self.utp_socket
7159 && let Err(e) = socket.shutdown().await
7160 {
7161 debug!(error = %e, "uTP socket shutdown error");
7162 }
7163 if let Some(ref socket) = self.utp_socket_v6
7164 && let Err(e) = socket.shutdown().await
7165 {
7166 debug!(error = %e, "uTP v6 socket shutdown error");
7167 }
7168 self.disk_manager.shutdown().await;
7169 }
7170}
7171
7172async fn recv_nat_event(
7175 rx: &mut Option<mpsc::Receiver<irontide_nat::NatEvent>>,
7176) -> irontide_nat::NatEvent {
7177 match rx {
7178 Some(r) => match r.recv().await {
7179 Some(event) => event,
7180 None => std::future::pending().await,
7181 },
7182 None => std::future::pending().await,
7183 }
7184}
7185
7186async fn recv_dht_ip(
7188 rx: &mut Option<mpsc::Receiver<std::net::IpAddr>>,
7189) -> Option<std::net::IpAddr> {
7190 match rx {
7191 Some(r) => r.recv().await,
7192 None => std::future::pending().await,
7193 }
7194}
7195
7196async fn prepare_add_torrent_off_actor(
7216 bundle: AddTorrentPrepBundle,
7217) -> crate::Result<PreparedAddTorrent> {
7218 let AddTorrentPrepBundle {
7219 torrent_meta,
7220 storage_override,
7221 torrent_config,
7222 disk_manager,
7223 dht_v4_broadcast,
7224 dht_v6_broadcast,
7225 global_up,
7226 global_down,
7227 slot_tuner,
7228 alert_tx,
7229 alert_mask,
7230 utp_socket,
7231 utp_socket_v6,
7232 ban_manager,
7233 ip_filter,
7234 plugins,
7235 sam_session,
7236 ssl_manager,
7237 factory,
7238 hash_pool,
7239 counters,
7240 m170_post,
7241 } = bundle;
7242
7243 let version = torrent_meta.version();
7244 let meta_v2 = torrent_meta.as_v2().cloned();
7245
7246 let meta = if let Some(v1) = torrent_meta.as_v1() {
7250 v1.clone()
7251 } else {
7252 let v2 = torrent_meta.as_v2().unwrap();
7253 synthesize_v1_from_v2(v2)
7254 };
7255 let info_hash = meta.info_hash;
7256 let is_private = meta.info.private == Some(1);
7257
7258 let storage: Arc<dyn TorrentStorage> = if let Some(s) = storage_override {
7260 s
7261 } else {
7262 let lengths = Lengths::new(
7263 meta.info.total_length(),
7264 meta.info.piece_length,
7265 DEFAULT_CHUNK_SIZE,
7266 );
7267 let files = meta.info.files();
7268 let file_paths: Vec<PathBuf> = files
7269 .iter()
7270 .map(|f| f.path.iter().collect::<PathBuf>())
7271 .collect();
7272 let file_lengths: Vec<u64> = files.iter().map(|f| f.length).collect();
7273 let prealloc_mode = torrent_config.preallocate_mode.unwrap_or_else(|| {
7274 irontide_storage::PreallocateMode::from(
7275 torrent_config.storage_mode == irontide_core::StorageMode::Full,
7276 )
7277 });
7278 match irontide_storage::FilesystemStorage::new(
7279 &torrent_config.download_dir,
7280 file_paths,
7281 file_lengths,
7282 lengths.clone(),
7283 None,
7284 prealloc_mode,
7285 torrent_config.filesystem_direct_io,
7286 ) {
7287 Ok(s) => Arc::new(s),
7288 Err(e) => {
7289 warn!("failed to create filesystem storage: {e}, falling back to memory");
7290 Arc::new(irontide_storage::MemoryStorage::new(lengths))
7291 }
7292 }
7293 };
7294 let disk_handle = disk_manager.register_torrent(info_hash, storage).await;
7295
7296 let handle = TorrentHandle::from_torrent(
7297 meta.clone(),
7298 version,
7299 meta_v2,
7300 disk_handle,
7301 disk_manager,
7302 torrent_config,
7303 dht_v4_broadcast.subscribe(),
7304 dht_v6_broadcast.subscribe(),
7305 global_up,
7306 global_down,
7307 slot_tuner,
7308 alert_tx.clone(),
7309 Arc::clone(&alert_mask),
7310 utp_socket,
7311 utp_socket_v6,
7312 ban_manager,
7313 ip_filter,
7314 plugins,
7315 sam_session,
7316 ssl_manager,
7317 factory,
7318 Some(hash_pool),
7319 counters,
7320 )
7321 .await?;
7322
7323 post_alert(
7340 &alert_tx,
7341 &alert_mask,
7342 AlertKind::TorrentAdded {
7343 info_hash,
7344 name: meta.info.name.clone(),
7345 },
7346 );
7347 Ok(PreparedAddTorrent {
7348 handle,
7349 info_hash,
7350 is_private,
7351 m170_post,
7352 })
7353}
7354
7355fn synthesize_v1_from_v2(v2: &irontide_core::TorrentMetaV2) -> irontide_core::TorrentMetaV1 {
7356 use irontide_core::{FileEntry, InfoDict};
7357
7358 let info_hash = v2.info_hashes.best_v1();
7359
7360 let v2_files = v2.info.files();
7362 let file_entries: Vec<FileEntry> = v2_files
7363 .iter()
7364 .map(|f| FileEntry {
7365 length: f.attr.length,
7366 path: f.path.clone(),
7367 attr: None,
7368 mtime: None,
7369 symlink_path: None,
7370 })
7371 .collect();
7372
7373 let num_pieces = v2.info.num_pieces() as usize;
7376 let pieces = vec![0u8; num_pieces * 20];
7377
7378 let info = InfoDict {
7379 name: v2.info.name.clone(),
7380 piece_length: v2.info.piece_length,
7381 pieces,
7382 length: if file_entries.len() == 1 {
7383 Some(file_entries[0].length)
7384 } else {
7385 None
7386 },
7387 files: if file_entries.len() > 1 {
7388 Some(file_entries)
7389 } else {
7390 None
7391 },
7392 private: None,
7393 source: None,
7394 ssl_cert: v2.ssl_cert.clone(),
7395 similar: Vec::new(),
7396 collections: Vec::new(),
7397 };
7398
7399 irontide_core::TorrentMetaV1 {
7400 info_hash,
7401 announce: v2.announce.clone(),
7402 announce_list: v2.announce_list.clone(),
7403 comment: v2.comment.clone(),
7404 created_by: v2.created_by.clone(),
7405 creation_date: v2.creation_date,
7406 info,
7407 info_bytes: None,
7408 url_list: Vec::new(),
7409 httpseeds: Vec::new(),
7410 ssl_cert: v2.ssl_cert.clone(),
7411 }
7412}
7413
7414#[cfg(test)]
7415mod tests {
7416 use super::*;
7417 use crate::types::TorrentState;
7418 use irontide_core::{DEFAULT_CHUNK_SIZE, Lengths, TorrentMetaV1, torrent_from_bytes};
7419 use irontide_storage::MemoryStorage;
7420 use std::time::Duration;
7421
7422 fn make_test_torrent(data: &[u8], piece_length: u64) -> TorrentMetaV1 {
7423 use serde::Serialize;
7424
7425 #[derive(Serialize)]
7426 struct Info<'a> {
7427 length: u64,
7428 name: &'a str,
7429 #[serde(rename = "piece length")]
7430 piece_length: u64,
7431 #[serde(with = "serde_bytes")]
7432 pieces: &'a [u8],
7433 }
7434
7435 #[derive(Serialize)]
7436 struct Torrent<'a> {
7437 info: Info<'a>,
7438 }
7439
7440 let mut pieces = Vec::new();
7441 let mut offset = 0;
7442 while offset < data.len() {
7443 let end = (offset + piece_length as usize).min(data.len());
7444 let hash = irontide_core::sha1(&data[offset..end]);
7445 pieces.extend_from_slice(hash.as_bytes());
7446 offset = end;
7447 }
7448
7449 let t = Torrent {
7450 info: Info {
7451 length: data.len() as u64,
7452 name: "test",
7453 piece_length,
7454 pieces: &pieces,
7455 },
7456 };
7457
7458 let bytes = irontide_bencode::to_bytes(&t).unwrap();
7459 torrent_from_bytes(&bytes).unwrap()
7460 }
7461
7462 fn make_storage(data: &[u8], piece_length: u64) -> Arc<MemoryStorage> {
7463 let lengths = Lengths::new(data.len() as u64, piece_length, DEFAULT_CHUNK_SIZE);
7464 Arc::new(MemoryStorage::new(lengths))
7465 }
7466
7467 static TEST_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7468
7469 fn test_settings() -> Settings {
7470 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7471 let pid = std::process::id();
7472 let dl_dir = std::env::temp_dir().join(format!("irontide-session-lib-dl-{pid}-{n}"));
7473 let resume_dir =
7474 std::env::temp_dir().join(format!("irontide-session-lib-resume-{pid}-{n}"));
7475 let _ = std::fs::remove_dir_all(&dl_dir);
7476 let _ = std::fs::remove_dir_all(&resume_dir);
7477 let _ = std::fs::create_dir_all(&dl_dir);
7478
7479 Settings {
7480 listen_port: 0,
7481 download_dir: dl_dir,
7482 resume_data_dir: Some(resume_dir),
7483 max_torrents: 10,
7484 enable_dht: false,
7485 enable_pex: false,
7486 enable_lsd: false,
7487 enable_fast_extension: false,
7488 enable_utp: false,
7489 enable_upnp: false,
7490 enable_natpmp: false,
7491 enable_ipv6: false,
7492 alert_channel_size: 64,
7493 disk_io_threads: 2,
7494 storage_mode: irontide_core::StorageMode::Sparse,
7495 disk_cache_size: 1024 * 1024,
7496 ..Settings::default()
7497 }
7498 }
7499
7500 #[tokio::test]
7503 async fn session_start_and_shutdown() {
7504 let session = SessionHandle::start(test_settings()).await.unwrap();
7505 let stats = session.session_stats().await.unwrap();
7506 assert_eq!(stats.active_torrents, 0);
7507 session.shutdown().await.unwrap();
7508 }
7509
7510 #[tokio::test]
7511 async fn peer_unchoke_durations_returns_none_for_missing_torrent() {
7512 let session = SessionHandle::start(test_settings()).await.unwrap();
7513 let bogus = Id20([0u8; 20]);
7514 let result = session.peer_unchoke_durations(bogus).await.unwrap();
7515 assert!(
7516 result.is_none(),
7517 "missing torrent must yield None, not an empty map"
7518 );
7519 session.shutdown().await.unwrap();
7520 }
7521
7522 #[tokio::test]
7523 async fn peer_unchoke_durations_returns_empty_map_for_known_torrent_with_no_peers() {
7524 let session = SessionHandle::start(test_settings()).await.unwrap();
7525 let data = vec![0xAB; 16384];
7526 let meta = make_test_torrent(&data, 16384);
7527 let storage = make_storage(&data, 16384);
7528 let info_hash = session
7529 .add_torrent_with_meta(meta.into(), Some(storage))
7530 .await
7531 .unwrap();
7532 let result = session
7533 .peer_unchoke_durations(info_hash)
7534 .await
7535 .unwrap()
7536 .expect("known torrent must yield Some, even with no peers");
7537 assert!(
7538 result.is_empty(),
7539 "fresh torrent with no peers has no unchoke history"
7540 );
7541 session.shutdown().await.unwrap();
7542 }
7543
7544 #[tokio::test]
7547 async fn add_and_list_torrent() {
7548 let session = SessionHandle::start(test_settings()).await.unwrap();
7549 let data = vec![0xAB; 16384];
7550 let meta = make_test_torrent(&data, 16384);
7551 let expected_hash = meta.info_hash;
7552
7553 let storage = make_storage(&data, 16384);
7554 let info_hash = session
7555 .add_torrent_with_meta(meta.into(), Some(storage))
7556 .await
7557 .unwrap();
7558 assert_eq!(info_hash, expected_hash);
7559
7560 let list = session.list_torrents().await.unwrap();
7561 assert_eq!(list.len(), 1);
7562 assert!(list.contains(&info_hash));
7563
7564 session.shutdown().await.unwrap();
7565 }
7566
7567 #[tokio::test]
7570 async fn remove_torrent() {
7571 let session = SessionHandle::start(test_settings()).await.unwrap();
7572 let data = vec![0xAB; 16384];
7573 let meta = make_test_torrent(&data, 16384);
7574 let storage = make_storage(&data, 16384);
7575
7576 let info_hash = session
7577 .add_torrent_with_meta(meta.into(), Some(storage))
7578 .await
7579 .unwrap();
7580 session.remove_torrent(info_hash).await.unwrap();
7581
7582 tokio::time::sleep(Duration::from_millis(50)).await;
7583
7584 let list = session.list_torrents().await.unwrap();
7585 assert!(list.is_empty());
7586
7587 session.shutdown().await.unwrap();
7588 }
7589
7590 #[tokio::test]
7593 async fn duplicate_torrent_rejected() {
7594 let session = SessionHandle::start(test_settings()).await.unwrap();
7595 let data = vec![0xAB; 16384];
7596 let meta = make_test_torrent(&data, 16384);
7597 let storage1 = make_storage(&data, 16384);
7598 let storage2 = make_storage(&data, 16384);
7599
7600 session
7601 .add_torrent_with_meta(meta.clone().into(), Some(storage1))
7602 .await
7603 .unwrap();
7604 let result = session
7605 .add_torrent_with_meta(meta.into(), Some(storage2))
7606 .await;
7607 assert!(result.is_err());
7608 assert!(result.unwrap_err().to_string().contains("duplicate"));
7609
7610 session.shutdown().await.unwrap();
7611 }
7612
7613 #[tokio::test]
7616 async fn session_at_capacity() {
7617 let mut config = test_settings();
7618 config.max_torrents = 1;
7619 let session = SessionHandle::start(config).await.unwrap();
7620
7621 let data1 = vec![0xAA; 16384];
7622 let meta1 = make_test_torrent(&data1, 16384);
7623 let storage1 = make_storage(&data1, 16384);
7624 session
7625 .add_torrent_with_meta(meta1.into(), Some(storage1))
7626 .await
7627 .unwrap();
7628
7629 let data2 = vec![0xBB; 16384];
7630 let meta2 = make_test_torrent(&data2, 16384);
7631 let storage2 = make_storage(&data2, 16384);
7632 let result = session
7633 .add_torrent_with_meta(meta2.into(), Some(storage2))
7634 .await;
7635 assert!(result.is_err());
7636 assert!(result.unwrap_err().to_string().contains("capacity"));
7637
7638 session.shutdown().await.unwrap();
7639 }
7640
7641 #[tokio::test]
7644 async fn torrent_stats_via_session() {
7645 let session = SessionHandle::start(test_settings()).await.unwrap();
7646 let data = vec![0xAB; 32768];
7647 let meta = make_test_torrent(&data, 16384);
7648 let storage = make_storage(&data, 16384);
7649
7650 let info_hash = session
7651 .add_torrent_with_meta(meta.into(), Some(storage))
7652 .await
7653 .unwrap();
7654 let stats = session.torrent_stats(info_hash).await.unwrap();
7655 assert_eq!(stats.state, TorrentState::Downloading);
7656 assert_eq!(stats.pieces_total, 2);
7657
7658 session.shutdown().await.unwrap();
7659 }
7660
7661 #[tokio::test]
7664 async fn torrent_info_via_session() {
7665 let session = SessionHandle::start(test_settings()).await.unwrap();
7666 let data = vec![0xAB; 32768];
7667 let meta = make_test_torrent(&data, 16384);
7668 let storage = make_storage(&data, 16384);
7669
7670 let info_hash = session
7671 .add_torrent_with_meta(meta.into(), Some(storage))
7672 .await
7673 .unwrap();
7674 let info = session.torrent_info(info_hash).await.unwrap();
7675 assert_eq!(info.info_hash, info_hash);
7676 assert_eq!(info.name, "test");
7677 assert_eq!(info.total_length, 32768);
7678 assert_eq!(info.num_pieces, 2);
7679 assert!(!info.private);
7680 assert_eq!(info.files.len(), 1);
7681 assert_eq!(info.files[0].length, 32768);
7682
7683 session.shutdown().await.unwrap();
7684 }
7685
7686 #[tokio::test]
7689 async fn pause_resume_via_session() {
7690 let session = SessionHandle::start(test_settings()).await.unwrap();
7691 let data = vec![0xAB; 16384];
7692 let meta = make_test_torrent(&data, 16384);
7693 let storage = make_storage(&data, 16384);
7694
7695 let info_hash = session
7696 .add_torrent_with_meta(meta.into(), Some(storage))
7697 .await
7698 .unwrap();
7699
7700 session.pause_torrent(info_hash).await.unwrap();
7701 tokio::time::sleep(Duration::from_millis(50)).await;
7702 let stats = session.torrent_stats(info_hash).await.unwrap();
7703 assert_eq!(stats.state, TorrentState::Paused);
7704
7705 session.resume_torrent(info_hash).await.unwrap();
7706 tokio::time::sleep(Duration::from_millis(50)).await;
7707 let stats = session.torrent_stats(info_hash).await.unwrap();
7708 assert_eq!(stats.state, TorrentState::Downloading);
7709
7710 session.shutdown().await.unwrap();
7711 }
7712
7713 #[tokio::test]
7716 async fn not_found_errors() {
7717 let session = SessionHandle::start(test_settings()).await.unwrap();
7718 let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
7719
7720 assert!(session.torrent_stats(fake_hash).await.is_err());
7721 assert!(session.torrent_info(fake_hash).await.is_err());
7722 assert!(session.pause_torrent(fake_hash).await.is_err());
7723 assert!(session.resume_torrent(fake_hash).await.is_err());
7724 assert!(session.remove_torrent(fake_hash).await.is_err());
7725
7726 session.shutdown().await.unwrap();
7727 }
7728
7729 #[tokio::test]
7732 async fn session_stats_aggregate() {
7733 let session = SessionHandle::start(test_settings()).await.unwrap();
7734
7735 let data1 = vec![0xAA; 16384];
7736 let meta1 = make_test_torrent(&data1, 16384);
7737 let storage1 = make_storage(&data1, 16384);
7738 session
7739 .add_torrent_with_meta(meta1.into(), Some(storage1))
7740 .await
7741 .unwrap();
7742
7743 let data2 = vec![0xBB; 16384];
7744 let meta2 = make_test_torrent(&data2, 16384);
7745 let storage2 = make_storage(&data2, 16384);
7746 session
7747 .add_torrent_with_meta(meta2.into(), Some(storage2))
7748 .await
7749 .unwrap();
7750
7751 let stats = session.session_stats().await.unwrap();
7752 assert_eq!(stats.active_torrents, 2);
7753
7754 session.shutdown().await.unwrap();
7755 }
7756
7757 #[tokio::test]
7760 async fn add_magnet_and_list() {
7761 use irontide_core::Magnet;
7762
7763 let session = SessionHandle::start(test_settings()).await.unwrap();
7764 let magnet = Magnet {
7765 info_hashes: irontide_core::InfoHashes::v1_only(
7766 Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
7767 ),
7768 display_name: Some("test-magnet".into()),
7769 trackers: vec![],
7770 peers: vec![],
7771 selected_files: None,
7772 };
7773 let expected_hash = magnet.info_hash();
7774
7775 let info_hash = session.add_magnet(magnet).await.unwrap();
7776 assert_eq!(info_hash, expected_hash);
7777
7778 let list = session.list_torrents().await.unwrap();
7779 assert_eq!(list.len(), 1);
7780 assert!(list.contains(&info_hash));
7781
7782 let err = session.torrent_info(info_hash).await.unwrap_err();
7784 assert!(err.to_string().contains("metadata not yet available"));
7785
7786 session.shutdown().await.unwrap();
7787 }
7788
7789 #[tokio::test]
7792 async fn add_magnet_duplicate_rejected() {
7793 use irontide_core::Magnet;
7794
7795 let session = SessionHandle::start(test_settings()).await.unwrap();
7796 let magnet = Magnet {
7797 info_hashes: irontide_core::InfoHashes::v1_only(
7798 Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap(),
7799 ),
7800 display_name: Some("test-magnet".into()),
7801 trackers: vec![],
7802 peers: vec![],
7803 selected_files: None,
7804 };
7805
7806 session.add_magnet(magnet.clone()).await.unwrap();
7807 let result = session.add_magnet(magnet).await;
7808 assert!(result.is_err());
7809 assert!(result.unwrap_err().to_string().contains("duplicate"));
7810
7811 session.shutdown().await.unwrap();
7812 }
7813
7814 #[tokio::test]
7817 async fn session_with_lsd_enabled() {
7818 use irontide_core::Magnet;
7819
7820 let mut config = test_settings();
7822 config.enable_lsd = true;
7823
7824 let session = SessionHandle::start(config).await.unwrap();
7825
7826 let data = vec![0xAB; 16384];
7828 let meta = make_test_torrent(&data, 16384);
7829 let storage = make_storage(&data, 16384);
7830 session
7831 .add_torrent_with_meta(meta.into(), Some(storage))
7832 .await
7833 .unwrap();
7834
7835 let magnet = Magnet {
7837 info_hashes: irontide_core::InfoHashes::v1_only(
7838 Id20::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(),
7839 ),
7840 display_name: Some("lsd-test".into()),
7841 trackers: vec![],
7842 peers: vec![],
7843 selected_files: None,
7844 };
7845 session.add_magnet(magnet).await.unwrap();
7846
7847 let list = session.list_torrents().await.unwrap();
7848 assert_eq!(list.len(), 2);
7849
7850 session.shutdown().await.unwrap();
7851 }
7852
7853 #[tokio::test]
7856 async fn add_v2_only_torrent() {
7857 use irontide_bencode::BencodeValue;
7858 use std::collections::BTreeMap;
7859
7860 let session = SessionHandle::start(test_settings()).await.unwrap();
7861
7862 let mut attr_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
7864 attr_map.insert(b"length".to_vec(), BencodeValue::Integer(16384));
7865 let mut file_node: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
7866 file_node.insert(b"".to_vec(), BencodeValue::Dict(attr_map));
7867 let mut ft_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
7868 ft_map.insert(b"test.dat".to_vec(), BencodeValue::Dict(file_node));
7869
7870 let mut info_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
7871 info_map.insert(b"file tree".to_vec(), BencodeValue::Dict(ft_map));
7872 info_map.insert(b"meta version".to_vec(), BencodeValue::Integer(2));
7873 info_map.insert(b"name".to_vec(), BencodeValue::Bytes(b"v2test".to_vec()));
7874 info_map.insert(b"piece length".to_vec(), BencodeValue::Integer(16384));
7875
7876 let mut root_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
7877 root_map.insert(b"info".to_vec(), BencodeValue::Dict(info_map));
7878
7879 let bytes = irontide_bencode::to_bytes(&BencodeValue::Dict(root_map)).unwrap();
7880 let meta = irontide_core::torrent_from_bytes_any(&bytes).unwrap();
7881 assert!(meta.is_v2());
7882
7883 let info_hash = session.add_torrent_with_meta(meta, None).await.unwrap();
7885 let list = session.list_torrents().await.unwrap();
7886 assert!(list.contains(&info_hash));
7887
7888 session.shutdown().await.unwrap();
7889 }
7890
7891 #[tokio::test]
7894 async fn save_torrent_resume_data_via_session() {
7895 let session = SessionHandle::start(test_settings()).await.unwrap();
7896 let data = vec![0xAB; 32768];
7897 let meta = make_test_torrent(&data, 16384);
7898 let info_hash = meta.info_hash;
7899 let storage = make_storage(&data, 16384);
7900 session
7901 .add_torrent_with_meta(meta.into(), Some(storage))
7902 .await
7903 .unwrap();
7904
7905 let rd = session.save_torrent_resume_data(info_hash).await.unwrap();
7906 assert_eq!(rd.info_hash, info_hash.as_bytes().as_slice());
7907 assert_eq!(rd.name, "test");
7908 assert_eq!(rd.file_format, "libtorrent resume file");
7909 assert_eq!(rd.file_version, 1);
7910 assert!(!rd.pieces.is_empty());
7911 assert_eq!(rd.paused, 0);
7912
7913 session.shutdown().await.unwrap();
7914 }
7915
7916 #[tokio::test]
7919 async fn save_session_state_captures_all_torrents() {
7920 let session = SessionHandle::start(test_settings()).await.unwrap();
7921
7922 let data1 = vec![0xAA; 16384];
7923 let meta1 = make_test_torrent(&data1, 16384);
7924 let storage1 = make_storage(&data1, 16384);
7925 session
7926 .add_torrent_with_meta(meta1.into(), Some(storage1))
7927 .await
7928 .unwrap();
7929
7930 let data2 = vec![0xBB; 16384];
7931 let meta2 = make_test_torrent(&data2, 16384);
7932 let storage2 = make_storage(&data2, 16384);
7933 session
7934 .add_torrent_with_meta(meta2.into(), Some(storage2))
7935 .await
7936 .unwrap();
7937
7938 let state = session.save_session_state().await.unwrap();
7939 assert_eq!(state.torrents.len(), 2);
7940
7941 for rd in &state.torrents {
7942 assert_eq!(rd.file_format, "libtorrent resume file");
7943 assert_eq!(rd.info_hash.len(), 20);
7944 }
7945
7946 session.shutdown().await.unwrap();
7947 }
7948
7949 #[tokio::test]
7952 async fn save_resume_data_not_found() {
7953 let session = SessionHandle::start(test_settings()).await.unwrap();
7954 let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
7955 let result = session.save_torrent_resume_data(fake_hash).await;
7956 assert!(result.is_err());
7957 assert!(result.unwrap_err().to_string().contains("not found"));
7958 session.shutdown().await.unwrap();
7959 }
7960
7961 #[tokio::test]
7964 async fn subscribe_receives_torrent_added_alert() {
7965 use crate::alert::AlertKind;
7966
7967 let session = SessionHandle::start(test_settings()).await.unwrap();
7968 let mut alerts = session.subscribe();
7969
7970 let data = vec![0xAB; 16384];
7971 let meta = make_test_torrent(&data, 16384);
7972 let storage = make_storage(&data, 16384);
7973 let _info_hash = session
7974 .add_torrent_with_meta(meta.into(), Some(storage))
7975 .await
7976 .unwrap();
7977
7978 let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
7979 .await
7980 .unwrap()
7981 .unwrap();
7982 assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
7983 session.shutdown().await.unwrap();
7984 }
7985
7986 #[tokio::test]
7989 async fn subscribe_receives_torrent_removed_alert() {
7990 use crate::alert::AlertKind;
7991 use crate::types::TorrentState;
7992
7993 let session = SessionHandle::start(test_settings()).await.unwrap();
7994 let mut alerts = session.subscribe();
7995
7996 let data = vec![0xAB; 16384];
7997 let meta = make_test_torrent(&data, 16384);
7998 let storage = make_storage(&data, 16384);
7999 let info_hash = session
8000 .add_torrent_with_meta(meta.into(), Some(storage))
8001 .await
8002 .unwrap();
8003
8004 while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_secs(1), alerts.recv()).await {
8006 if matches!(
8007 a.kind,
8008 AlertKind::StateChanged {
8009 new_state: TorrentState::Downloading,
8010 ..
8011 }
8012 ) {
8013 break;
8014 }
8015 }
8016
8017 session.remove_torrent(info_hash).await.unwrap();
8018
8019 loop {
8021 let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8022 .await
8023 .unwrap()
8024 .unwrap();
8025 if matches!(alert.kind, AlertKind::TorrentRemoved { .. }) {
8026 break;
8027 }
8028 }
8029 session.shutdown().await.unwrap();
8030 }
8031
8032 #[tokio::test]
8035 async fn multiple_subscribers_each_receive_alerts() {
8036 use crate::alert::AlertKind;
8037
8038 let session = SessionHandle::start(test_settings()).await.unwrap();
8039 let mut sub1 = session.subscribe();
8040 let mut sub2 = session.subscribe();
8041
8042 let data = vec![0xAB; 16384];
8043 let meta = make_test_torrent(&data, 16384);
8044 let storage = make_storage(&data, 16384);
8045 session
8046 .add_torrent_with_meta(meta.into(), Some(storage))
8047 .await
8048 .unwrap();
8049
8050 let a1 = tokio::time::timeout(Duration::from_secs(2), sub1.recv())
8051 .await
8052 .unwrap()
8053 .unwrap();
8054 let a2 = tokio::time::timeout(Duration::from_secs(2), sub2.recv())
8055 .await
8056 .unwrap()
8057 .unwrap();
8058
8059 assert!(matches!(a1.kind, AlertKind::TorrentAdded { .. }));
8060 assert!(matches!(a2.kind, AlertKind::TorrentAdded { .. }));
8061 session.shutdown().await.unwrap();
8062 }
8063
8064 #[tokio::test]
8067 async fn set_alert_mask_filters_at_runtime() {
8068 use crate::alert::{AlertCategory, AlertKind};
8069
8070 let session = SessionHandle::start(test_settings()).await.unwrap();
8071 let mut alerts = session.subscribe();
8072
8073 let data = vec![0xAB; 16384];
8075 let meta = make_test_torrent(&data, 16384);
8076 let storage = make_storage(&data, 16384);
8077 session
8078 .add_torrent_with_meta(meta.into(), Some(storage))
8079 .await
8080 .unwrap();
8081
8082 let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8083 .await
8084 .unwrap()
8085 .unwrap();
8086 assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8087
8088 while tokio::time::timeout(Duration::from_millis(200), alerts.recv())
8090 .await
8091 .is_ok()
8092 {}
8093
8094 session.set_alert_mask(AlertCategory::empty());
8096
8097 let data2 = vec![0xBB; 16384];
8098 let meta2 = make_test_torrent(&data2, 16384);
8099 let storage2 = make_storage(&data2, 16384);
8100 session
8101 .add_torrent_with_meta(meta2.into(), Some(storage2))
8102 .await
8103 .unwrap();
8104
8105 let result = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await;
8107 assert!(result.is_err(), "should have timed out with empty mask");
8108
8109 session.set_alert_mask(AlertCategory::STATUS);
8111
8112 let data3 = vec![0xCC; 16384];
8113 let meta3 = make_test_torrent(&data3, 16384);
8114 let storage3 = make_storage(&data3, 16384);
8115 session
8116 .add_torrent_with_meta(meta3.into(), Some(storage3))
8117 .await
8118 .unwrap();
8119
8120 let alert = tokio::time::timeout(Duration::from_secs(2), alerts.recv())
8121 .await
8122 .unwrap()
8123 .unwrap();
8124 assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8125
8126 session.shutdown().await.unwrap();
8127 }
8128
8129 #[tokio::test]
8132 async fn alert_stream_filters_per_subscriber() {
8133 use crate::alert::{AlertCategory, AlertKind};
8134
8135 let session = SessionHandle::start(test_settings()).await.unwrap();
8136
8137 let mut status_sub = session.subscribe_filtered(AlertCategory::STATUS);
8139 let mut peer_sub = session.subscribe_filtered(AlertCategory::PEER);
8141
8142 let data = vec![0xAB; 16384];
8143 let meta = make_test_torrent(&data, 16384);
8144 let storage = make_storage(&data, 16384);
8145 session
8146 .add_torrent_with_meta(meta.into(), Some(storage))
8147 .await
8148 .unwrap();
8149
8150 let alert = tokio::time::timeout(Duration::from_secs(2), status_sub.recv())
8152 .await
8153 .unwrap()
8154 .unwrap();
8155 assert!(matches!(alert.kind, AlertKind::TorrentAdded { .. }));
8156
8157 let result = tokio::time::timeout(Duration::from_millis(200), peer_sub.recv()).await;
8159 assert!(
8160 result.is_err(),
8161 "PEER subscriber should not get STATUS alerts"
8162 );
8163
8164 session.shutdown().await.unwrap();
8165 }
8166
8167 #[tokio::test]
8170 async fn state_changed_tracks_transitions() {
8171 use crate::alert::AlertKind;
8172
8173 let session = SessionHandle::start(test_settings()).await.unwrap();
8174 let mut alerts = session.subscribe();
8175
8176 let data = vec![0xAB; 16384];
8177 let meta = make_test_torrent(&data, 16384);
8178 let storage = make_storage(&data, 16384);
8179 let info_hash = session
8180 .add_torrent_with_meta(meta.into(), Some(storage))
8181 .await
8182 .unwrap();
8183
8184 let _ = tokio::time::timeout(Duration::from_secs(1), alerts.recv())
8186 .await
8187 .unwrap();
8188
8189 session.pause_torrent(info_hash).await.unwrap();
8191 tokio::time::sleep(Duration::from_millis(100)).await;
8192
8193 let mut state_changes = Vec::new();
8195 let mut paused_alerts = Vec::new();
8196 while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await
8197 {
8198 match &a.kind {
8199 AlertKind::StateChanged {
8200 prev_state,
8201 new_state,
8202 ..
8203 } => {
8204 state_changes.push((*prev_state, *new_state));
8205 }
8206 AlertKind::TorrentPaused { .. } => {
8207 paused_alerts.push(a);
8208 }
8209 _ => {} }
8211 }
8212
8213 assert!(
8214 state_changes.contains(&(TorrentState::Downloading, TorrentState::Paused)),
8215 "expected Downloading→Paused, got: {state_changes:?}"
8216 );
8217 assert!(!paused_alerts.is_empty(), "expected TorrentPaused alert");
8218
8219 session.resume_torrent(info_hash).await.unwrap();
8221 tokio::time::sleep(Duration::from_millis(100)).await;
8222
8223 let mut resume_state_changes = Vec::new();
8224 let mut resumed_alerts = Vec::new();
8225 while let Ok(Ok(a)) = tokio::time::timeout(Duration::from_millis(200), alerts.recv()).await
8226 {
8227 match &a.kind {
8228 AlertKind::StateChanged {
8229 prev_state,
8230 new_state,
8231 ..
8232 } => {
8233 resume_state_changes.push((*prev_state, *new_state));
8234 }
8235 AlertKind::TorrentResumed { .. } => {
8236 resumed_alerts.push(a);
8237 }
8238 _ => {}
8239 }
8240 }
8241
8242 assert!(
8243 resume_state_changes.contains(&(TorrentState::Paused, TorrentState::Downloading)),
8244 "expected Paused→Downloading, got: {resume_state_changes:?}"
8245 );
8246 assert!(!resumed_alerts.is_empty(), "expected TorrentResumed alert");
8247
8248 session.shutdown().await.unwrap();
8249 }
8250
8251 #[tokio::test]
8252 async fn session_config_creates_utp_socket() {
8253 let mut config = test_settings();
8255 config.enable_utp = true;
8256 let session = SessionHandle::start(config).await.unwrap();
8257 let stats = session.session_stats().await.unwrap();
8258 assert_eq!(stats.active_torrents, 0);
8259 session.shutdown().await.unwrap();
8260 }
8261
8262 #[test]
8263 fn settings_nat_defaults() {
8264 let s = Settings::default();
8265 assert!(s.enable_upnp, "enable_upnp should default to true");
8266 assert!(s.enable_natpmp, "enable_natpmp should default to true");
8267 }
8268
8269 #[tokio::test]
8270 async fn session_with_nat_disabled() {
8271 let config = test_settings();
8272 assert!(!config.enable_upnp);
8274 assert!(!config.enable_natpmp);
8275 let session = SessionHandle::start(config).await.unwrap();
8276 let stats = session.session_stats().await.unwrap();
8277 assert_eq!(stats.active_torrents, 0);
8278 session.shutdown().await.unwrap();
8279 }
8280
8281 #[test]
8284 fn anonymous_mode_disables_discovery() {
8285 let mut config = test_settings();
8286 config.anonymous_mode = true;
8287 config.enable_dht = true;
8288 config.enable_lsd = true;
8289 config.enable_upnp = true;
8290 config.enable_natpmp = true;
8291
8292 if config.anonymous_mode {
8295 config.enable_dht = false;
8296 config.enable_lsd = false;
8297 config.enable_upnp = false;
8298 config.enable_natpmp = false;
8299 }
8300
8301 assert!(!config.enable_dht);
8302 assert!(!config.enable_lsd);
8303 assert!(!config.enable_upnp);
8304 assert!(!config.enable_natpmp);
8305 }
8306
8307 #[tokio::test]
8308 async fn anonymous_mode_session_starts_with_discovery_disabled() {
8309 let mut config = test_settings();
8310 config.anonymous_mode = true;
8311 config.enable_dht = true;
8313 config.enable_lsd = true;
8314
8315 let session = SessionHandle::start(config).await.unwrap();
8316 let stats = session.session_stats().await.unwrap();
8317 assert_eq!(stats.active_torrents, 0);
8318 session.shutdown().await.unwrap();
8319 }
8320
8321 #[test]
8322 fn force_proxy_requires_proxy_configured() {
8323 let mut config = test_settings();
8324 config.force_proxy = true;
8325 config.proxy = crate::proxy::ProxyConfig::default(); assert_eq!(config.proxy.proxy_type, crate::proxy::ProxyType::None);
8329 assert!(config.force_proxy);
8330 }
8332
8333 #[tokio::test]
8334 async fn force_proxy_errors_without_proxy() {
8335 let mut config = test_settings();
8336 config.force_proxy = true;
8337 let result = SessionHandle::start(config).await;
8340 assert!(result.is_err());
8341 match result {
8342 Err(e) => assert!(
8343 e.to_string().contains("force_proxy"),
8344 "error should mention force_proxy: {e}"
8345 ),
8346 Ok(_) => panic!("expected error"),
8347 }
8348 }
8349
8350 #[test]
8351 fn force_proxy_disables_features() {
8352 let mut config = test_settings();
8353 config.force_proxy = true;
8354 config.proxy = crate::proxy::ProxyConfig {
8355 proxy_type: crate::proxy::ProxyType::Socks5,
8356 hostname: "proxy.example.com".into(),
8357 port: 1080,
8358 ..Default::default()
8359 };
8360 config.enable_dht = true;
8361 config.enable_lsd = true;
8362 config.enable_upnp = true;
8363 config.enable_natpmp = true;
8364
8365 if config.force_proxy {
8367 config.enable_upnp = false;
8368 config.enable_natpmp = false;
8369 config.enable_dht = false;
8370 config.enable_lsd = false;
8371 }
8372
8373 assert!(!config.enable_dht);
8374 assert!(!config.enable_lsd);
8375 assert!(!config.enable_upnp);
8376 assert!(!config.enable_natpmp);
8377 }
8378
8379 #[test]
8380 fn proxy_config_round_trip() {
8381 let s = Settings {
8382 proxy: crate::proxy::ProxyConfig {
8383 proxy_type: crate::proxy::ProxyType::Socks5Password,
8384 hostname: "localhost".into(),
8385 port: 9050,
8386 username: Some("user".into()),
8387 password: Some("pass".into()),
8388 ..Default::default()
8389 },
8390 force_proxy: true,
8391 anonymous_mode: true,
8392 ..test_settings()
8393 };
8394
8395 assert_eq!(s.proxy.proxy_type, crate::proxy::ProxyType::Socks5Password);
8396 assert_eq!(s.proxy.hostname, "localhost");
8397 assert_eq!(s.proxy.port, 9050);
8398 assert!(s.force_proxy);
8399 assert!(s.anonymous_mode);
8400 assert_eq!(s.proxy.to_url(), "socks5://user:pass@localhost:9050");
8401 }
8402
8403 #[tokio::test]
8404 async fn apply_settings_runtime() {
8405 let session = SessionHandle::start(test_settings()).await.unwrap();
8406 let original = session.settings().await.unwrap();
8407 assert_eq!(original.max_torrents, 10);
8408
8409 let mut new = original.clone();
8410 new.max_torrents = 200;
8411 new.upload_rate_limit = 1_000_000;
8412 session.apply_settings(new).await.unwrap();
8413
8414 let updated = session.settings().await.unwrap();
8415 assert_eq!(updated.max_torrents, 200);
8416 assert_eq!(updated.upload_rate_limit, 1_000_000);
8417
8418 session.shutdown().await.unwrap();
8419 }
8420
8421 #[tokio::test]
8422 async fn apply_settings_validation_error() {
8423 let session = SessionHandle::start(test_settings()).await.unwrap();
8424
8425 let bad = Settings {
8427 force_proxy: true,
8428 ..Settings::default()
8429 };
8430 let result = session.apply_settings(bad).await;
8431 assert!(result.is_err());
8432
8433 let current = session.settings().await.unwrap();
8435 assert!(!current.force_proxy);
8436
8437 session.shutdown().await.unwrap();
8438 }
8439
8440 #[tokio::test]
8443 async fn session_stats_counters_accessible() {
8444 let session = SessionHandle::start(test_settings()).await.unwrap();
8445 let counters = session.counters();
8446 let _ = counters.uptime_secs();
8450 assert_eq!(counters.len(), crate::stats::NUM_METRICS);
8451 session.shutdown().await.unwrap();
8452 }
8453
8454 #[tokio::test]
8455 async fn post_session_stats_fires_alert() {
8456 use crate::alert::{AlertCategory, AlertKind};
8457
8458 let session = SessionHandle::start(test_settings()).await.unwrap();
8459 let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
8460
8461 session.post_session_stats().await.unwrap();
8462
8463 let alert = tokio::time::timeout(Duration::from_secs(2), stats_sub.recv())
8464 .await
8465 .expect("timed out waiting for SessionStatsAlert")
8466 .expect("recv error");
8467 assert!(
8468 matches!(alert.kind, AlertKind::SessionStatsAlert { ref values } if values.len() == crate::stats::NUM_METRICS),
8469 "expected SessionStatsAlert with {} values, got {:?}",
8470 crate::stats::NUM_METRICS,
8471 alert.kind,
8472 );
8473 session.shutdown().await.unwrap();
8474 }
8475
8476 #[tokio::test]
8477 async fn session_stats_include_torrent_count() {
8478 use crate::alert::{AlertCategory, AlertKind};
8479
8480 let session = SessionHandle::start(test_settings()).await.unwrap();
8481 let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
8482
8483 let data = vec![0xAB; 16384];
8485 let meta = make_test_torrent(&data, 16384);
8486 let storage = make_storage(&data, 16384);
8487 session
8488 .add_torrent_with_meta(meta.into(), Some(storage))
8489 .await
8490 .unwrap();
8491
8492 session.post_session_stats().await.unwrap();
8493
8494 let alert = tokio::time::timeout(Duration::from_secs(2), stats_sub.recv())
8495 .await
8496 .expect("timed out waiting for SessionStatsAlert")
8497 .expect("recv error");
8498 match alert.kind {
8499 AlertKind::SessionStatsAlert { values } => {
8500 assert!(
8501 values[crate::stats::SES_NUM_TORRENTS] > 0,
8502 "SES_NUM_TORRENTS should be > 0 after adding a torrent, got {}",
8503 values[crate::stats::SES_NUM_TORRENTS],
8504 );
8505 }
8506 other => panic!("expected SessionStatsAlert, got {other:?}"),
8507 }
8508 session.shutdown().await.unwrap();
8509 }
8510
8511 #[tokio::test]
8512 async fn stats_timer_disabled_when_zero() {
8513 use crate::alert::AlertCategory;
8514
8515 let mut config = test_settings();
8516 config.stats_report_interval = 0;
8517 let session = SessionHandle::start(config).await.unwrap();
8518 let mut stats_sub = session.subscribe_filtered(AlertCategory::STATS);
8519
8520 let result = tokio::time::timeout(Duration::from_millis(200), stats_sub.recv()).await;
8522 assert!(
8523 result.is_err(),
8524 "no SessionStatsAlert should fire when stats_report_interval is 0"
8525 );
8526 session.shutdown().await.unwrap();
8527 }
8528
8529 #[tokio::test]
8530 async fn sample_infohashes_timer_disabled_when_zero() {
8531 use crate::alert::AlertCategory;
8532
8533 let mut config = test_settings();
8534 config.dht_sample_infohashes_interval = 0;
8535 let session = SessionHandle::start(config).await.unwrap();
8536 let mut dht_sub = session.subscribe_filtered(AlertCategory::DHT);
8537
8538 let result = tokio::time::timeout(Duration::from_millis(200), dht_sub.recv()).await;
8540 assert!(
8541 result.is_err(),
8542 "no DhtSampleInfohashes alert should fire when interval is 0"
8543 );
8544 session.shutdown().await.unwrap();
8545 }
8546
8547 #[tokio::test]
8550 async fn open_file_not_found() {
8551 let session = SessionHandle::start(test_settings()).await.unwrap();
8552 let fake_hash = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8553 let result = session.open_file(fake_hash, 0).await;
8554 assert!(result.is_err());
8555 let err = result.err().unwrap();
8556 assert!(err.to_string().contains("not found"));
8557 session.shutdown().await.unwrap();
8558 }
8559
8560 #[tokio::test]
8563 async fn open_file_routes_to_torrent() {
8564 let session = SessionHandle::start(test_settings()).await.unwrap();
8565 let data = vec![0xAB; 32768];
8566 let meta = make_test_torrent(&data, 16384);
8567 let storage = make_storage(&data, 16384);
8568
8569 let info_hash = session
8570 .add_torrent_with_meta(meta.into(), Some(storage))
8571 .await
8572 .unwrap();
8573
8574 let stream = session.open_file(info_hash, 0).await;
8576 assert!(stream.is_ok(), "open_file should succeed for file_index 0");
8577
8578 let result = session.open_file(info_hash, 999).await;
8580 assert!(
8581 result.is_err(),
8582 "open_file should fail for invalid file_index"
8583 );
8584
8585 session.shutdown().await.unwrap();
8586 }
8587
8588 #[tokio::test]
8591 async fn session_force_reannounce() {
8592 let session = SessionHandle::start(test_settings()).await.unwrap();
8593 let data = vec![0xAB; 16384];
8594 let meta = make_test_torrent(&data, 16384);
8595 let storage = make_storage(&data, 16384);
8596 let info_hash = session
8597 .add_torrent_with_meta(meta.into(), Some(storage))
8598 .await
8599 .unwrap();
8600
8601 let result = session.force_reannounce(info_hash).await;
8603 assert!(
8604 result.is_ok(),
8605 "force_reannounce should succeed: {result:?}"
8606 );
8607
8608 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8610 assert!(session.force_reannounce(fake).await.is_err());
8611
8612 session.shutdown().await.unwrap();
8613 }
8614
8615 #[tokio::test]
8618 async fn session_tracker_list() {
8619 let session = SessionHandle::start(test_settings()).await.unwrap();
8620 let data = vec![0xAB; 16384];
8621 let meta = make_test_torrent(&data, 16384);
8622 let storage = make_storage(&data, 16384);
8623 let info_hash = session
8624 .add_torrent_with_meta(meta.into(), Some(storage))
8625 .await
8626 .unwrap();
8627
8628 let trackers = session.tracker_list(info_hash).await.unwrap();
8630 assert!(trackers.is_empty(), "test torrent has no trackers");
8631
8632 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8634 assert!(session.tracker_list(fake).await.is_err());
8635
8636 session.shutdown().await.unwrap();
8637 }
8638
8639 #[tokio::test]
8642 async fn session_scrape() {
8643 let session = SessionHandle::start(test_settings()).await.unwrap();
8644 let data = vec![0xAB; 16384];
8645 let meta = make_test_torrent(&data, 16384);
8646 let storage = make_storage(&data, 16384);
8647 let info_hash = session
8648 .add_torrent_with_meta(meta.into(), Some(storage))
8649 .await
8650 .unwrap();
8651
8652 let scrape = session.scrape(info_hash).await.unwrap();
8654 assert!(scrape.is_none(), "test torrent has no trackers to scrape");
8655
8656 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8658 assert!(session.scrape(fake).await.is_err());
8659
8660 session.shutdown().await.unwrap();
8661 }
8662
8663 #[tokio::test]
8666 async fn session_set_file_priority() {
8667 let session = SessionHandle::start(test_settings()).await.unwrap();
8668 let data = vec![0xAB; 16384];
8669 let meta = make_test_torrent(&data, 16384);
8670 let storage = make_storage(&data, 16384);
8671 let info_hash = session
8672 .add_torrent_with_meta(meta.into(), Some(storage))
8673 .await
8674 .unwrap();
8675
8676 let result = session
8678 .set_file_priority(info_hash, 0, irontide_core::FilePriority::Normal)
8679 .await;
8680 assert!(
8681 result.is_ok(),
8682 "set_file_priority should succeed: {result:?}"
8683 );
8684
8685 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8687 assert!(
8688 session
8689 .set_file_priority(fake, 0, irontide_core::FilePriority::Normal)
8690 .await
8691 .is_err()
8692 );
8693
8694 session.shutdown().await.unwrap();
8695 }
8696
8697 #[tokio::test]
8700 async fn session_file_priorities() {
8701 let session = SessionHandle::start(test_settings()).await.unwrap();
8702 let data = vec![0xAB; 16384];
8703 let meta = make_test_torrent(&data, 16384);
8704 let storage = make_storage(&data, 16384);
8705 let info_hash = session
8706 .add_torrent_with_meta(meta.into(), Some(storage))
8707 .await
8708 .unwrap();
8709
8710 let priorities = session.file_priorities(info_hash).await.unwrap();
8712 assert_eq!(
8713 priorities.len(),
8714 1,
8715 "single-file torrent should have 1 file priority"
8716 );
8717 assert_eq!(priorities[0], irontide_core::FilePriority::Normal);
8718
8719 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8721 assert!(session.file_priorities(fake).await.is_err());
8722
8723 session.shutdown().await.unwrap();
8724 }
8725
8726 #[tokio::test]
8729 async fn set_download_limit_zero_means_unlimited() {
8730 let session = SessionHandle::start(test_settings()).await.unwrap();
8731 let data = vec![0xAB; 16384];
8732 let meta = make_test_torrent(&data, 16384);
8733 let storage = make_storage(&data, 16384);
8734 let info_hash = session
8735 .add_torrent_with_meta(meta.into(), Some(storage))
8736 .await
8737 .unwrap();
8738
8739 session.set_download_limit(info_hash, 50_000).await.unwrap();
8741 session.set_download_limit(info_hash, 0).await.unwrap();
8742 let limit = session.download_limit(info_hash).await.unwrap();
8743 assert_eq!(limit, 0, "0 means unlimited");
8744
8745 session.shutdown().await.unwrap();
8746 }
8747
8748 #[tokio::test]
8751 async fn set_upload_limit_persists() {
8752 let session = SessionHandle::start(test_settings()).await.unwrap();
8753 let data = vec![0xAB; 16384];
8754 let meta = make_test_torrent(&data, 16384);
8755 let storage = make_storage(&data, 16384);
8756 let info_hash = session
8757 .add_torrent_with_meta(meta.into(), Some(storage))
8758 .await
8759 .unwrap();
8760
8761 session.set_upload_limit(info_hash, 100_000).await.unwrap();
8762 let limit = session.upload_limit(info_hash).await.unwrap();
8763 assert_eq!(limit, 100_000);
8764
8765 session.shutdown().await.unwrap();
8766 }
8767
8768 #[tokio::test]
8771 async fn download_limit_default_is_zero() {
8772 let session = SessionHandle::start(test_settings()).await.unwrap();
8773 let data = vec![0xAB; 16384];
8774 let meta = make_test_torrent(&data, 16384);
8775 let storage = make_storage(&data, 16384);
8776 let info_hash = session
8777 .add_torrent_with_meta(meta.into(), Some(storage))
8778 .await
8779 .unwrap();
8780
8781 let limit = session.download_limit(info_hash).await.unwrap();
8783 assert_eq!(limit, 0, "default download limit should be 0 (unlimited)");
8784
8785 session.shutdown().await.unwrap();
8786 }
8787
8788 #[tokio::test]
8791 async fn rate_limit_round_trip() {
8792 let session = SessionHandle::start(test_settings()).await.unwrap();
8793 let data = vec![0xAB; 16384];
8794 let meta = make_test_torrent(&data, 16384);
8795 let storage = make_storage(&data, 16384);
8796 let info_hash = session
8797 .add_torrent_with_meta(meta.into(), Some(storage))
8798 .await
8799 .unwrap();
8800
8801 session
8803 .set_download_limit(info_hash, 1_000_000)
8804 .await
8805 .unwrap();
8806 session.set_upload_limit(info_hash, 500_000).await.unwrap();
8807
8808 let dl = session.download_limit(info_hash).await.unwrap();
8810 let ul = session.upload_limit(info_hash).await.unwrap();
8811 assert_eq!(dl, 1_000_000);
8812 assert_eq!(ul, 500_000);
8813
8814 session
8816 .set_download_limit(info_hash, 2_000_000)
8817 .await
8818 .unwrap();
8819 let dl = session.download_limit(info_hash).await.unwrap();
8820 assert_eq!(dl, 2_000_000);
8821
8822 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
8824 assert!(session.download_limit(fake).await.is_err());
8825 assert!(session.upload_limit(fake).await.is_err());
8826 assert!(session.set_download_limit(fake, 100).await.is_err());
8827 assert!(session.set_upload_limit(fake, 100).await.is_err());
8828
8829 session.shutdown().await.unwrap();
8830 }
8831
8832 #[tokio::test]
8835 async fn sequential_download_toggle() {
8836 let session = SessionHandle::start(test_settings()).await.unwrap();
8837 let data = vec![0xAB; 16384];
8838 let meta = make_test_torrent(&data, 16384);
8839 let storage = make_storage(&data, 16384);
8840 let info_hash = session
8841 .add_torrent_with_meta(meta.into(), Some(storage))
8842 .await
8843 .unwrap();
8844
8845 session
8847 .set_sequential_download(info_hash, true)
8848 .await
8849 .unwrap();
8850 assert!(session.is_sequential_download(info_hash).await.unwrap());
8851
8852 session
8854 .set_sequential_download(info_hash, false)
8855 .await
8856 .unwrap();
8857 assert!(!session.is_sequential_download(info_hash).await.unwrap());
8858
8859 session.shutdown().await.unwrap();
8860 }
8861
8862 #[tokio::test]
8865 async fn super_seeding_toggle() {
8866 let session = SessionHandle::start(test_settings()).await.unwrap();
8867 let data = vec![0xAB; 16384];
8868 let meta = make_test_torrent(&data, 16384);
8869 let storage = make_storage(&data, 16384);
8870 let info_hash = session
8871 .add_torrent_with_meta(meta.into(), Some(storage))
8872 .await
8873 .unwrap();
8874
8875 session.set_super_seeding(info_hash, true).await.unwrap();
8877 assert!(session.is_super_seeding(info_hash).await.unwrap());
8878
8879 session.set_super_seeding(info_hash, false).await.unwrap();
8881 assert!(!session.is_super_seeding(info_hash).await.unwrap());
8882
8883 session.shutdown().await.unwrap();
8884 }
8885
8886 #[tokio::test]
8889 async fn sequential_download_default_false() {
8890 let session = SessionHandle::start(test_settings()).await.unwrap();
8891 let data = vec![0xAB; 16384];
8892 let meta = make_test_torrent(&data, 16384);
8893 let storage = make_storage(&data, 16384);
8894 let info_hash = session
8895 .add_torrent_with_meta(meta.into(), Some(storage))
8896 .await
8897 .unwrap();
8898
8899 assert!(!session.is_sequential_download(info_hash).await.unwrap());
8901
8902 session.shutdown().await.unwrap();
8903 }
8904
8905 #[tokio::test]
8908 async fn super_seeding_default_false() {
8909 let session = SessionHandle::start(test_settings()).await.unwrap();
8910 let data = vec![0xAB; 16384];
8911 let meta = make_test_torrent(&data, 16384);
8912 let storage = make_storage(&data, 16384);
8913 let info_hash = session
8914 .add_torrent_with_meta(meta.into(), Some(storage))
8915 .await
8916 .unwrap();
8917
8918 assert!(!session.is_super_seeding(info_hash).await.unwrap());
8920
8921 session.shutdown().await.unwrap();
8922 }
8923
8924 #[tokio::test]
8927 async fn seed_mode_flips_user_flag() {
8928 let session = SessionHandle::start(test_settings()).await.unwrap();
8929 let data = vec![0xAB; 16384];
8930 let meta = make_test_torrent(&data, 16384);
8931 let storage = make_storage(&data, 16384);
8932 let info_hash = session
8933 .add_torrent_with_meta(meta.into(), Some(storage))
8934 .await
8935 .unwrap();
8936
8937 let stats_before = session.torrent_stats(info_hash).await.unwrap();
8939 assert!(
8940 !stats_before.user_seed_mode,
8941 "new torrent should not start in user seed mode"
8942 );
8943
8944 session.set_seed_mode(info_hash, true).await.unwrap();
8946 let stats_on = session.torrent_stats(info_hash).await.unwrap();
8947 assert!(
8948 stats_on.user_seed_mode,
8949 "stats should reflect user_seed_mode=true after enabling"
8950 );
8951
8952 session.set_seed_mode(info_hash, false).await.unwrap();
8954 let stats_off = session.torrent_stats(info_hash).await.unwrap();
8955 assert!(
8956 !stats_off.user_seed_mode,
8957 "stats should reflect user_seed_mode=false after disabling"
8958 );
8959
8960 session.shutdown().await.unwrap();
8961 }
8962
8963 #[tokio::test]
8966 async fn seed_mode_round_trip() {
8967 let session = SessionHandle::start(test_settings()).await.unwrap();
8971 let data = vec![0xAB; 16384];
8972 let meta = make_test_torrent(&data, 16384);
8973 let storage = make_storage(&data, 16384);
8974 let info_hash = session
8975 .add_torrent_with_meta(meta.into(), Some(storage))
8976 .await
8977 .unwrap();
8978
8979 for (i, enabled) in [true, false, true, true, false].iter().enumerate() {
8980 session.set_seed_mode(info_hash, *enabled).await.unwrap();
8981 let stats = session.torrent_stats(info_hash).await.unwrap();
8982 assert_eq!(
8983 stats.user_seed_mode, *enabled,
8984 "iteration {i}: stats.user_seed_mode should track the toggle"
8985 );
8986 }
8987
8988 session.shutdown().await.unwrap();
8989 }
8990
8991 #[tokio::test]
8994 async fn seed_mode_missing_info_hash_errors() {
8995 let session = SessionHandle::start(test_settings()).await.unwrap();
8996 let fake =
8997 irontide_core::Id20::from_hex("ffffffffffffffffffffffffffffffffffffffff").unwrap();
8998 let err = session
8999 .set_seed_mode(fake, true)
9000 .await
9001 .expect_err("set_seed_mode on unknown info hash must return an error");
9002 match err {
9003 crate::Error::TorrentNotFound(h) => assert_eq!(h, fake),
9004 other => panic!("expected TorrentNotFound, got {other:?}"),
9005 }
9006 session.shutdown().await.unwrap();
9007 }
9008
9009 #[tokio::test]
9012 async fn seed_mode_idempotent() {
9013 let session = SessionHandle::start(test_settings()).await.unwrap();
9015 let data = vec![0xAB; 16384];
9016 let meta = make_test_torrent(&data, 16384);
9017 let storage = make_storage(&data, 16384);
9018 let info_hash = session
9019 .add_torrent_with_meta(meta.into(), Some(storage))
9020 .await
9021 .unwrap();
9022
9023 session.set_seed_mode(info_hash, true).await.unwrap();
9025 session.set_seed_mode(info_hash, true).await.unwrap();
9026 assert!(
9027 session
9028 .torrent_stats(info_hash)
9029 .await
9030 .unwrap()
9031 .user_seed_mode
9032 );
9033
9034 session.set_seed_mode(info_hash, false).await.unwrap();
9036 session.set_seed_mode(info_hash, false).await.unwrap();
9037 assert!(
9038 !session
9039 .torrent_stats(info_hash)
9040 .await
9041 .unwrap()
9042 .user_seed_mode
9043 );
9044
9045 session.shutdown().await.unwrap();
9046 }
9047
9048 #[tokio::test]
9051 async fn add_tracker_increases_count() {
9052 let session = SessionHandle::start(test_settings()).await.unwrap();
9053 let data = vec![0xAB; 16384];
9054 let meta = make_test_torrent(&data, 16384);
9055 let storage = make_storage(&data, 16384);
9056 let info_hash = session
9057 .add_torrent_with_meta(meta.into(), Some(storage))
9058 .await
9059 .unwrap();
9060
9061 let before = session.tracker_list(info_hash).await.unwrap();
9063 assert!(before.is_empty());
9064
9065 session
9067 .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9068 .await
9069 .unwrap();
9070
9071 let after = session.tracker_list(info_hash).await.unwrap();
9072 assert_eq!(after.len(), 1);
9073 assert_eq!(after[0].url, "udp://tracker.example.com:6969/announce");
9074
9075 session.shutdown().await.unwrap();
9076 }
9077
9078 #[tokio::test]
9081 async fn replace_trackers_replaces_all() {
9082 let session = SessionHandle::start(test_settings()).await.unwrap();
9083 let data = vec![0xAB; 16384];
9084 let meta = make_test_torrent(&data, 16384);
9085 let storage = make_storage(&data, 16384);
9086 let info_hash = session
9087 .add_torrent_with_meta(meta.into(), Some(storage))
9088 .await
9089 .unwrap();
9090
9091 session
9093 .add_tracker(info_hash, "udp://tracker1.example.com:6969/announce".into())
9094 .await
9095 .unwrap();
9096 session
9097 .add_tracker(info_hash, "http://tracker2.example.com/announce".into())
9098 .await
9099 .unwrap();
9100 assert_eq!(session.tracker_list(info_hash).await.unwrap().len(), 2);
9101
9102 session
9104 .replace_trackers(
9105 info_hash,
9106 vec!["http://replacement.example.com/announce".into()],
9107 )
9108 .await
9109 .unwrap();
9110
9111 let after = session.tracker_list(info_hash).await.unwrap();
9112 assert_eq!(after.len(), 1);
9113 assert_eq!(after[0].url, "http://replacement.example.com/announce");
9114
9115 session.shutdown().await.unwrap();
9116 }
9117
9118 #[tokio::test]
9121 async fn add_tracker_deduplicates() {
9122 let session = SessionHandle::start(test_settings()).await.unwrap();
9123 let data = vec![0xAB; 16384];
9124 let meta = make_test_torrent(&data, 16384);
9125 let storage = make_storage(&data, 16384);
9126 let info_hash = session
9127 .add_torrent_with_meta(meta.into(), Some(storage))
9128 .await
9129 .unwrap();
9130
9131 session
9133 .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9134 .await
9135 .unwrap();
9136 session
9137 .add_tracker(info_hash, "udp://tracker.example.com:6969/announce".into())
9138 .await
9139 .unwrap();
9140
9141 let trackers = session.tracker_list(info_hash).await.unwrap();
9143 assert_eq!(trackers.len(), 1);
9144
9145 session.shutdown().await.unwrap();
9146 }
9147
9148 #[tokio::test]
9151 async fn info_hashes_matches_added_torrent() {
9152 let session = SessionHandle::start(test_settings()).await.unwrap();
9153 let data = vec![0xAB; 16384];
9154 let meta = make_test_torrent(&data, 16384);
9155 let expected_v1 = meta.info_hash;
9156 let storage = make_storage(&data, 16384);
9157
9158 let info_hash = session
9159 .add_torrent_with_meta(meta.into(), Some(storage))
9160 .await
9161 .unwrap();
9162 let hashes = session.info_hashes(info_hash).await.unwrap();
9163 assert_eq!(hashes.v1, Some(expected_v1));
9164 assert!(hashes.v2.is_none());
9166
9167 session.shutdown().await.unwrap();
9168 }
9169
9170 #[tokio::test]
9173 async fn torrent_file_returns_meta() {
9174 let session = SessionHandle::start(test_settings()).await.unwrap();
9175 let data = vec![0xAB; 32768];
9176 let meta = make_test_torrent(&data, 16384);
9177 let storage = make_storage(&data, 16384);
9178
9179 let info_hash = session
9180 .add_torrent_with_meta(meta.into(), Some(storage))
9181 .await
9182 .unwrap();
9183 let torrent = session.torrent_file(info_hash).await.unwrap();
9184 assert!(torrent.is_some());
9185 let torrent = torrent.unwrap();
9186 assert_eq!(torrent.info_hash, info_hash);
9187 assert_eq!(torrent.info.name, "test");
9188 assert_eq!(torrent.info.total_length(), 32768);
9189
9190 session.shutdown().await.unwrap();
9191 }
9192
9193 #[tokio::test]
9196 async fn torrent_file_none_before_metadata() {
9197 let session = SessionHandle::start(test_settings()).await.unwrap();
9198 let magnet = irontide_core::Magnet::parse(
9199 "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&dn=test",
9200 )
9201 .unwrap();
9202
9203 let info_hash = session.add_magnet(magnet).await.unwrap();
9204 let torrent = session.torrent_file(info_hash).await.unwrap();
9205 assert!(torrent.is_none());
9207
9208 session.shutdown().await.unwrap();
9209 }
9210
9211 #[tokio::test]
9214 async fn force_dht_announce_no_error() {
9215 let session = SessionHandle::start(test_settings()).await.unwrap();
9216 let data = vec![0xAB; 16384];
9217 let meta = make_test_torrent(&data, 16384);
9218 let storage = make_storage(&data, 16384);
9219 let info_hash = session
9220 .add_torrent_with_meta(meta.into(), Some(storage))
9221 .await
9222 .unwrap();
9223
9224 let result = session.force_dht_announce(info_hash).await;
9226 assert!(
9227 result.is_ok(),
9228 "force_dht_announce should succeed: {result:?}"
9229 );
9230
9231 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9233 assert!(session.force_dht_announce(fake).await.is_err());
9234
9235 session.shutdown().await.unwrap();
9236 }
9237
9238 #[tokio::test]
9241 async fn force_lsd_announce_no_error() {
9242 let session = SessionHandle::start(test_settings()).await.unwrap();
9243 let data = vec![0xAB; 16384];
9244 let meta = make_test_torrent(&data, 16384);
9245 let storage = make_storage(&data, 16384);
9246 let info_hash = session
9247 .add_torrent_with_meta(meta.into(), Some(storage))
9248 .await
9249 .unwrap();
9250
9251 let result = session.force_lsd_announce(info_hash).await;
9253 assert!(
9254 result.is_ok(),
9255 "force_lsd_announce should succeed: {result:?}"
9256 );
9257
9258 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9260 assert!(session.force_lsd_announce(fake).await.is_err());
9261
9262 session.shutdown().await.unwrap();
9263 }
9264
9265 #[tokio::test]
9268 async fn read_piece_after_download() {
9269 let data = vec![0xCD; 32768]; let meta = make_test_torrent(&data, 16384);
9271 let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
9272 let storage = Arc::new(MemoryStorage::new(lengths));
9273 storage.write_chunk(0, 0, &data[..16384]).unwrap();
9275 storage.write_chunk(1, 0, &data[16384..]).unwrap();
9276
9277 let session = SessionHandle::start(test_settings()).await.unwrap();
9278 let info_hash = session
9279 .add_torrent_with_meta(meta.into(), Some(storage))
9280 .await
9281 .unwrap();
9282
9283 let piece_data = session.read_piece(info_hash, 0).await.unwrap();
9285 assert_eq!(piece_data.len(), 16384);
9286 assert!(piece_data.iter().all(|&b| b == 0xCD));
9287
9288 let piece_data = session.read_piece(info_hash, 1).await.unwrap();
9290 assert_eq!(piece_data.len(), 16384);
9291 assert!(piece_data.iter().all(|&b| b == 0xCD));
9292
9293 let result = session.read_piece(info_hash, 999).await;
9295 assert!(result.is_err(), "read_piece out of range should fail");
9296
9297 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9299 assert!(session.read_piece(fake, 0).await.is_err());
9300
9301 session.shutdown().await.unwrap();
9302 }
9303
9304 #[tokio::test]
9307 async fn flush_cache_completes() {
9308 let session = SessionHandle::start(test_settings()).await.unwrap();
9309 let data = vec![0xAB; 16384];
9310 let meta = make_test_torrent(&data, 16384);
9311 let storage = make_storage(&data, 16384);
9312 let info_hash = session
9313 .add_torrent_with_meta(meta.into(), Some(storage))
9314 .await
9315 .unwrap();
9316
9317 let result = session.flush_cache(info_hash).await;
9319 assert!(result.is_ok(), "flush_cache should succeed: {result:?}");
9320
9321 let fake = Id20::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
9323 assert!(session.flush_cache(fake).await.is_err());
9324
9325 session.shutdown().await.unwrap();
9326 }
9327
9328 fn test_settings_with_dht() -> Settings {
9331 let mut s = test_settings();
9332 s.enable_dht = true;
9333 s
9334 }
9335
9336 fn test_settings_with_lsd() -> Settings {
9337 let mut s = test_settings();
9338 s.enable_lsd = true;
9339 s
9340 }
9341
9342 #[tokio::test]
9343 async fn test_dht_disabled_returns_error() {
9344 let session = SessionHandle::start(test_settings()).await.unwrap();
9345
9346 let err = session
9348 .dht_put_immutable(b"test".to_vec())
9349 .await
9350 .unwrap_err();
9351 assert!(
9352 format!("{err:?}").contains("DhtDisabled"),
9353 "expected DhtDisabled, got {err:?}"
9354 );
9355
9356 let target = Id20::from([0u8; 20]);
9357 let err = session.dht_get_immutable(target).await.unwrap_err();
9358 assert!(
9359 format!("{err:?}").contains("DhtDisabled"),
9360 "expected DhtDisabled, got {err:?}"
9361 );
9362
9363 let err = session
9364 .dht_put_mutable([42u8; 32], b"val".to_vec(), 1, Vec::new())
9365 .await
9366 .unwrap_err();
9367 assert!(
9368 format!("{err:?}").contains("DhtDisabled"),
9369 "expected DhtDisabled, got {err:?}"
9370 );
9371
9372 let err = session
9373 .dht_get_mutable([42u8; 32], Vec::new())
9374 .await
9375 .unwrap_err();
9376 assert!(
9377 format!("{err:?}").contains("DhtDisabled"),
9378 "expected DhtDisabled, got {err:?}"
9379 );
9380
9381 session.shutdown().await.unwrap();
9382 }
9383
9384 #[tokio::test]
9385 async fn test_dht_put_get_immutable_round_trip() {
9386 let session = SessionHandle::start(test_settings_with_dht())
9387 .await
9388 .unwrap();
9389
9390 let value = b"hello BEP 44".to_vec();
9392 let target = session.dht_put_immutable(value.clone()).await.unwrap();
9393
9394 let got = session.dht_get_immutable(target).await.unwrap();
9397 assert_eq!(got, Some(value));
9398
9399 session.shutdown().await.unwrap();
9400 }
9401
9402 #[tokio::test]
9403 async fn test_dht_put_immutable_fires_alert() {
9404 use crate::alert::{AlertCategory, AlertKind};
9405
9406 let session = SessionHandle::start(test_settings_with_dht())
9407 .await
9408 .unwrap();
9409 let mut alerts = session.subscribe_filtered(AlertCategory::DHT);
9410
9411 let value = b"alert test".to_vec();
9412 let target = session.dht_put_immutable(value).await.unwrap();
9413
9414 let alert = tokio::time::timeout(Duration::from_secs(5), alerts.recv())
9416 .await
9417 .expect("timeout waiting for alert")
9418 .expect("alert channel closed");
9419
9420 match alert.kind {
9421 AlertKind::DhtPutComplete { target: t } => {
9422 assert_eq!(t, target);
9423 }
9424 other => panic!("expected DhtPutComplete, got {other:?}"),
9425 }
9426
9427 session.shutdown().await.unwrap();
9428 }
9429
9430 fn make_private_torrent(data: &[u8], piece_length: u64) -> TorrentMetaV1 {
9434 use serde::Serialize;
9435
9436 #[derive(Serialize)]
9437 struct Info<'a> {
9438 length: u64,
9439 name: &'a str,
9440 #[serde(rename = "piece length")]
9441 piece_length: u64,
9442 #[serde(with = "serde_bytes")]
9443 pieces: &'a [u8],
9444 private: i64,
9445 }
9446
9447 #[derive(Serialize)]
9448 struct Torrent<'a> {
9449 info: Info<'a>,
9450 }
9451
9452 let mut pieces = Vec::new();
9453 let mut offset = 0;
9454 while offset < data.len() {
9455 let end = (offset + piece_length as usize).min(data.len());
9456 let hash = irontide_core::sha1(&data[offset..end]);
9457 pieces.extend_from_slice(hash.as_bytes());
9458 offset = end;
9459 }
9460
9461 let t = Torrent {
9462 info: Info {
9463 length: data.len() as u64,
9464 name: "private-test",
9465 piece_length,
9466 pieces: &pieces,
9467 private: 1,
9468 },
9469 };
9470
9471 let bytes = irontide_bencode::to_bytes(&t).unwrap();
9472 torrent_from_bytes(&bytes).unwrap()
9473 }
9474
9475 #[test]
9476 fn is_private_true_via_parsed_meta() {
9477 let data = vec![0xAB; 16384];
9479 let meta = make_private_torrent(&data, 16384);
9480 assert_eq!(
9481 meta.info.private,
9482 Some(1),
9483 "private field should be Some(1)"
9484 );
9485 }
9486
9487 #[test]
9488 fn is_private_false_for_public_torrent() {
9489 let data = vec![0xAB; 16384];
9491 let meta = make_test_torrent(&data, 16384);
9492 assert_eq!(
9493 meta.info.private, None,
9494 "public torrent should have no private flag"
9495 );
9496 }
9497
9498 #[test]
9499 fn private_torrent_config_disables_lsd() {
9500 let config = TorrentConfig::default();
9502 assert!(
9503 config.enable_lsd,
9504 "default TorrentConfig should have LSD enabled"
9505 );
9506 }
9507
9508 #[tokio::test]
9509 async fn force_lsd_announce_private_torrent_returns_error() {
9510 let session = SessionHandle::start(test_settings()).await.unwrap();
9511 let data = vec![0xAB; 16384];
9512 let meta = make_private_torrent(&data, 16384);
9513 let storage = make_storage(&data, 16384);
9514 let info_hash = session
9515 .add_torrent_with_meta(meta.into(), Some(storage))
9516 .await
9517 .unwrap();
9518
9519 let result = session.force_lsd_announce(info_hash).await;
9521 assert!(
9522 result.is_err(),
9523 "force_lsd_announce on private torrent should return error, got: {result:?}"
9524 );
9525 let err_str = format!("{:?}", result.unwrap_err());
9526 assert!(
9527 err_str.contains("InvalidSettings") || err_str.contains("LSD disabled"),
9528 "expected InvalidSettings error, got: {err_str}"
9529 );
9530
9531 session.shutdown().await.unwrap();
9532 }
9533
9534 #[tokio::test]
9535 async fn force_lsd_announce_public_torrent_does_not_trigger_bep27_error() {
9536 let session = SessionHandle::start(test_settings_with_lsd())
9546 .await
9547 .unwrap();
9548 let data = vec![0xAB; 16384];
9549 let meta = make_test_torrent(&data, 16384);
9550 let storage = make_storage(&data, 16384);
9551 let info_hash = session
9552 .add_torrent_with_meta(meta.into(), Some(storage))
9553 .await
9554 .unwrap();
9555
9556 let result = session.force_lsd_announce(info_hash).await;
9557 if let Err(e) = &result {
9558 assert!(
9559 !format!("{e:?}").contains("LSD disabled for private torrent"),
9560 "public torrent must NOT trigger BEP 27 error; got {e:?}"
9561 );
9562 }
9563
9564 session.shutdown().await.unwrap();
9565 }
9566
9567 #[tokio::test]
9568 async fn force_dht_announce_private_torrent_returns_error() {
9569 let session = SessionHandle::start(test_settings_with_dht())
9570 .await
9571 .unwrap();
9572 let data = vec![0xAB; 16384];
9573 let meta = make_private_torrent(&data, 16384);
9574 let storage = make_storage(&data, 16384);
9575 let info_hash = session
9576 .add_torrent_with_meta(meta.into(), Some(storage))
9577 .await
9578 .unwrap();
9579
9580 let result = session.force_dht_announce(info_hash).await;
9582 assert!(
9583 result.is_err(),
9584 "force_dht_announce on private torrent should return error, got: {result:?}"
9585 );
9586 let err_str = format!("{:?}", result.unwrap_err());
9587 assert!(
9588 err_str.contains("InvalidSettings")
9589 || err_str.contains("DHT disabled for private torrent"),
9590 "expected InvalidSettings / DHT-disabled error, got: {err_str}"
9591 );
9592
9593 session.shutdown().await.unwrap();
9594 }
9595
9596 #[tokio::test]
9597 async fn force_dht_announce_public_torrent_does_not_trigger_bep27_error() {
9598 let session = SessionHandle::start(test_settings_with_dht())
9599 .await
9600 .unwrap();
9601 let data = vec![0xAB; 16384];
9602 let meta = make_test_torrent(&data, 16384);
9603 let storage = make_storage(&data, 16384);
9604 let info_hash = session
9605 .add_torrent_with_meta(meta.into(), Some(storage))
9606 .await
9607 .unwrap();
9608
9609 let result = session.force_dht_announce(info_hash).await;
9610 if let Err(e) = &result {
9616 assert!(
9617 !format!("{e:?}").contains("DHT disabled for private torrent"),
9618 "public torrent must NOT trigger BEP 27 error; got {e:?}"
9619 );
9620 }
9621
9622 session.shutdown().await.unwrap();
9623 }
9624
9625 fn resume_test_settings(dir: &std::path::Path) -> Settings {
9628 Settings {
9629 resume_data_dir: Some(dir.to_path_buf()),
9630 save_resume_interval_secs: 0, ..test_settings()
9632 }
9633 }
9634
9635 #[tokio::test]
9636 async fn save_resume_state_empty_session_returns_zero() {
9637 let tmp = tempfile::TempDir::new().unwrap();
9638 let session = SessionHandle::start(resume_test_settings(tmp.path()))
9639 .await
9640 .unwrap();
9641
9642 let count = session.save_resume_state().await.unwrap();
9643 assert_eq!(count, 0, "empty session should save 0 resume files");
9644
9645 session.shutdown().await.unwrap();
9646 }
9647
9648 #[tokio::test]
9649 async fn save_resume_state_saves_dirty_torrents() {
9650 let tmp = tempfile::TempDir::new().unwrap();
9651 let session = SessionHandle::start(resume_test_settings(tmp.path()))
9652 .await
9653 .unwrap();
9654
9655 let data1 = vec![0xAA; 16384];
9657 let meta1 = make_test_torrent(&data1, 16384);
9658 let hash1 = meta1.info_hash;
9659 let storage1 = make_storage(&data1, 16384);
9660 session
9661 .add_torrent_with_meta(meta1.into(), Some(storage1))
9662 .await
9663 .unwrap();
9664
9665 let data2 = vec![0xBB; 16384];
9666 let meta2 = make_test_torrent(&data2, 16384);
9667 let hash2 = meta2.info_hash;
9668 let storage2 = make_storage(&data2, 16384);
9669 session
9670 .add_torrent_with_meta(meta2.into(), Some(storage2))
9671 .await
9672 .unwrap();
9673
9674 tokio::time::sleep(Duration::from_millis(50)).await;
9677
9678 let count = session.save_resume_state().await.unwrap();
9679 assert!(count <= 2, "should save at most 2 resume files");
9683
9684 let torrents_dir = tmp.path().join("torrents");
9686 if count > 0 {
9687 assert!(torrents_dir.exists(), "torrents/ directory should exist");
9688 }
9689
9690 let path1 = crate::resume_file::resume_file_path(tmp.path(), &hash1);
9692 let path2 = crate::resume_file::resume_file_path(tmp.path(), &hash2);
9693 let files_exist = usize::from(path1.exists()) + usize::from(path2.exists());
9694 assert_eq!(
9695 files_exist, count,
9696 "number of files on disk should match returned count"
9697 );
9698
9699 session.shutdown().await.unwrap();
9700 }
9701
9702 #[tokio::test]
9703 async fn save_resume_state_round_trip() {
9704 let tmp = tempfile::TempDir::new().unwrap();
9705 let session = SessionHandle::start(resume_test_settings(tmp.path()))
9706 .await
9707 .unwrap();
9708
9709 let data = vec![0xCD; 32768];
9710 let meta = make_test_torrent(&data, 16384);
9711 let info_hash = meta.info_hash;
9712 let storage = make_storage(&data, 16384);
9713 session
9714 .add_torrent_with_meta(meta.into(), Some(storage))
9715 .await
9716 .unwrap();
9717
9718 tokio::time::sleep(Duration::from_millis(50)).await;
9720
9721 let count = session.save_resume_state().await.unwrap();
9722
9723 if count > 0 {
9725 let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
9726 assert!(path.exists(), "resume file should exist after save");
9727
9728 let bytes = std::fs::read(&path).unwrap();
9729 let rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
9730 assert_eq!(
9731 rd.info_hash,
9732 info_hash.as_bytes().to_vec(),
9733 "deserialized info_hash should match"
9734 );
9735 assert_eq!(rd.name, "test", "deserialized name should match");
9736 }
9737
9738 session.shutdown().await.unwrap();
9739 }
9740
9741 #[tokio::test]
9742 async fn save_resume_state_clears_dirty_flag() {
9743 let tmp = tempfile::TempDir::new().unwrap();
9744 let session = SessionHandle::start(resume_test_settings(tmp.path()))
9745 .await
9746 .unwrap();
9747
9748 let data = vec![0xEE; 16384];
9749 let meta = make_test_torrent(&data, 16384);
9750 let storage = make_storage(&data, 16384);
9751 session
9752 .add_torrent_with_meta(meta.into(), Some(storage))
9753 .await
9754 .unwrap();
9755
9756 tokio::time::sleep(Duration::from_millis(50)).await;
9757
9758 let first_count = session.save_resume_state().await.unwrap();
9759
9760 let second_count = session.save_resume_state().await.unwrap();
9762 assert_eq!(
9763 second_count, 0,
9764 "second save should return 0 after dirty flag cleared (first saved {first_count})"
9765 );
9766
9767 session.shutdown().await.unwrap();
9768 }
9769
9770 #[tokio::test]
9771 async fn save_resume_state_second_save_skips_clean() {
9772 let tmp = tempfile::TempDir::new().unwrap();
9773 let session = SessionHandle::start(resume_test_settings(tmp.path()))
9774 .await
9775 .unwrap();
9776
9777 let data1 = vec![0xAA; 16384];
9778 let meta1 = make_test_torrent(&data1, 16384);
9779 let storage1 = make_storage(&data1, 16384);
9780 session
9781 .add_torrent_with_meta(meta1.into(), Some(storage1))
9782 .await
9783 .unwrap();
9784
9785 let data2 = vec![0xBB; 16384];
9786 let meta2 = make_test_torrent(&data2, 16384);
9787 let storage2 = make_storage(&data2, 16384);
9788 session
9789 .add_torrent_with_meta(meta2.into(), Some(storage2))
9790 .await
9791 .unwrap();
9792
9793 tokio::time::sleep(Duration::from_millis(50)).await;
9794
9795 let first = session.save_resume_state().await.unwrap();
9797
9798 let second = session.save_resume_state().await.unwrap();
9800 assert_eq!(
9801 second, 0,
9802 "second save should skip all clean torrents (first saved {first})"
9803 );
9804
9805 session.shutdown().await.unwrap();
9806 }
9807
9808 #[tokio::test]
9813 async fn load_resume_empty_dir_returns_zeros() {
9814 let tmp = tempfile::TempDir::new().unwrap();
9815 let mut settings = test_settings();
9816 settings.resume_data_dir = Some(tmp.path().to_path_buf());
9817
9818 let session = SessionHandle::start(settings).await.unwrap();
9819 let result = session.load_resume_state().await.unwrap();
9820 assert_eq!(result.restored, 0);
9821 assert_eq!(result.skipped, 0);
9822 assert_eq!(result.failed, 0);
9823
9824 session.shutdown().await.unwrap();
9825 }
9826
9827 #[tokio::test]
9830 async fn load_resume_corrupt_file_counted_as_failed() {
9831 let tmp = tempfile::TempDir::new().unwrap();
9832 let torrents_dir = tmp.path().join("torrents");
9833 std::fs::create_dir_all(&torrents_dir).unwrap();
9834
9835 let mut settings = test_settings();
9836 settings.resume_data_dir = Some(tmp.path().to_path_buf());
9837
9838 let session = SessionHandle::start(settings).await.unwrap();
9840
9841 tokio::time::sleep(Duration::from_millis(50)).await;
9844
9845 std::fs::write(
9848 torrents_dir.join("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef.resume"),
9849 b"this is not valid bencode",
9850 )
9851 .unwrap();
9852
9853 let result = session.load_resume_state().await.unwrap();
9854 assert_eq!(result.restored, 0);
9855 assert_eq!(result.skipped, 0);
9856 assert_eq!(result.failed, 1);
9857
9858 session.shutdown().await.unwrap();
9859 }
9860
9861 #[tokio::test]
9864 async fn load_resume_duplicate_skipped() {
9865 let tmp = tempfile::TempDir::new().unwrap();
9866 let mut settings = test_settings();
9867 settings.resume_data_dir = Some(tmp.path().to_path_buf());
9868
9869 let session = SessionHandle::start(settings).await.unwrap();
9870
9871 let data = vec![0xAB; 16384];
9873 let meta = make_test_torrent(&data, 16384);
9874 let info_hash = meta.info_hash;
9875 let storage = make_storage(&data, 16384);
9876 session
9877 .add_torrent_with_meta(meta.into(), Some(storage))
9878 .await
9879 .unwrap();
9880
9881 tokio::time::sleep(Duration::from_millis(50)).await;
9883
9884 let _ = session.save_resume_state().await;
9886
9887 let result = session.load_resume_state().await.unwrap();
9889 assert!(
9890 session.list_torrents().await.unwrap().contains(&info_hash),
9891 "original torrent should still exist"
9892 );
9893 assert_eq!(result.skipped, 1, "duplicate should be skipped");
9894 assert_eq!(result.failed, 0);
9895
9896 session.shutdown().await.unwrap();
9897 }
9898
9899 #[test]
9902 fn reconstruct_torrent_meta_returns_some_with_correct_fields() {
9903 use crate::resume_file::reconstruct_torrent_meta;
9904 use irontide_core::FastResumeData;
9905
9906 let data = vec![0xAB; 16384];
9907 let meta = make_test_torrent(&data, 16384);
9908 let info_hash = meta.info_hash;
9909
9910 let info_bytes = irontide_bencode::to_bytes(&meta.info).unwrap();
9912 let mut rd = FastResumeData::new(
9913 info_hash.as_bytes().to_vec(),
9914 "test-torrent".into(),
9915 "/downloads".into(),
9916 );
9917 rd.info = Some(info_bytes);
9918 rd.trackers = vec![
9919 vec!["http://tracker1.example.com/announce".into()],
9920 vec!["http://tracker2.example.com/announce".into()],
9921 ];
9922 rd.url_seeds = vec!["http://seed.example.com/".into()];
9923 rd.http_seeds = vec!["http://httpseed.example.com/".into()];
9924
9925 let reconstructed = reconstruct_torrent_meta(&rd).expect("should reconstruct");
9926
9927 assert_eq!(reconstructed.info_hash, info_hash);
9928 assert_eq!(
9929 reconstructed.announce.as_deref(),
9930 Some("http://tracker1.example.com/announce")
9931 );
9932 assert!(reconstructed.announce_list.is_some());
9933 assert_eq!(reconstructed.announce_list.as_ref().unwrap().len(), 2);
9934 assert_eq!(
9935 reconstructed.url_list,
9936 vec!["http://seed.example.com/".to_string()]
9937 );
9938 assert_eq!(
9939 reconstructed.httpseeds,
9940 vec!["http://httpseed.example.com/".to_string()]
9941 );
9942 assert!(reconstructed.info_bytes.is_some());
9943 assert!(reconstructed.comment.is_none());
9944 assert!(reconstructed.created_by.is_none());
9945 assert!(reconstructed.creation_date.is_none());
9946 }
9947
9948 #[test]
9951 fn reconstruct_torrent_meta_returns_none_without_info() {
9952 use crate::resume_file::reconstruct_torrent_meta;
9953 use irontide_core::FastResumeData;
9954
9955 let rd = FastResumeData::new(vec![0xAB; 20], "magnet".into(), "/tmp".into());
9956 assert!(rd.info.is_none());
9958 assert!(reconstruct_torrent_meta(&rd).is_none());
9959 }
9960
9961 #[test]
9964 fn reconstruct_magnet_returns_some_with_correct_fields() {
9965 use crate::resume_file::reconstruct_magnet;
9966 use irontide_core::FastResumeData;
9967
9968 let mut rd = FastResumeData::new(vec![0xCC; 20], "my-torrent".into(), "/downloads".into());
9969 rd.trackers = vec![
9970 vec!["http://tracker1.com/announce".into()],
9971 vec![
9972 "http://tracker2.com/announce".into(),
9973 "http://tracker3.com/announce".into(),
9974 ],
9975 ];
9976
9977 let magnet = reconstruct_magnet(&rd).expect("should reconstruct magnet");
9978
9979 assert!(magnet.info_hashes.v1.is_some());
9980 assert!(magnet.info_hashes.v2.is_none());
9981 assert_eq!(magnet.display_name.as_deref(), Some("my-torrent"));
9982 assert_eq!(magnet.trackers.len(), 3);
9984 assert!(magnet.peers.is_empty());
9985 assert!(magnet.selected_files.is_none());
9986 }
9987
9988 #[test]
9991 fn reconstruct_magnet_preserves_info_hash2() {
9992 use crate::resume_file::reconstruct_magnet;
9993 use irontide_core::FastResumeData;
9994
9995 let mut rd = FastResumeData::new(vec![0xDD; 20], "v2-magnet".into(), "/tmp".into());
9996 rd.info_hash2 = Some(vec![0xEE; 32]);
9997
9998 let magnet = reconstruct_magnet(&rd).expect("should reconstruct");
9999 assert!(magnet.info_hashes.v1.is_some());
10000 assert!(magnet.info_hashes.v2.is_some());
10001
10002 let v2 = magnet.info_hashes.v2.unwrap();
10003 assert_eq!(v2.as_bytes(), &[0xEE; 32]);
10004 }
10005
10006 #[test]
10009 fn reconstruct_magnet_empty_name_is_none() {
10010 use crate::resume_file::reconstruct_magnet;
10011 use irontide_core::FastResumeData;
10012
10013 let rd = FastResumeData::new(vec![0xFF; 20], String::new(), "/tmp".into());
10014 let magnet = reconstruct_magnet(&rd).expect("should reconstruct");
10015 assert!(
10016 magnet.display_name.is_none(),
10017 "empty name should map to None"
10018 );
10019 }
10020
10021 #[tokio::test]
10026 async fn shutdown_saves_resume_files() {
10027 let tmp = tempfile::TempDir::new().unwrap();
10028 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10029 .await
10030 .unwrap();
10031
10032 let data = vec![0xAB; 16384];
10033 let meta = make_test_torrent(&data, 16384);
10034 let info_hash = meta.info_hash;
10035 let storage = make_storage(&data, 16384);
10036 session
10037 .add_torrent_with_meta(meta.into(), Some(storage))
10038 .await
10039 .unwrap();
10040
10041 session.pause_torrent(info_hash).await.unwrap();
10043 tokio::time::sleep(Duration::from_millis(50)).await;
10044 session.resume_torrent(info_hash).await.unwrap();
10045 tokio::time::sleep(Duration::from_millis(50)).await;
10046
10047 let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10048
10049 session.shutdown().await.unwrap();
10053 tokio::time::sleep(Duration::from_millis(200)).await;
10054
10055 assert!(path.exists(), "resume file should exist after shutdown");
10056 }
10057
10058 #[tokio::test]
10061 async fn auto_restore_on_startup() {
10062 let tmp = tempfile::TempDir::new().unwrap();
10063
10064 let info_hash;
10065 {
10066 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10068 .await
10069 .unwrap();
10070
10071 let data = vec![0xAB; 16384];
10072 let meta = make_test_torrent(&data, 16384);
10073 info_hash = meta.info_hash;
10074 let storage = make_storage(&data, 16384);
10075 session
10076 .add_torrent_with_meta(meta.into(), Some(storage))
10077 .await
10078 .unwrap();
10079
10080 tokio::time::sleep(Duration::from_millis(50)).await;
10081 let _ = session.save_resume_state().await;
10082 session.shutdown().await.unwrap();
10083 }
10084
10085 let path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10087 assert!(path.exists(), "resume file should exist before restart");
10088
10089 {
10090 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10092 .await
10093 .unwrap();
10094
10095 tokio::time::sleep(Duration::from_millis(100)).await;
10097
10098 let list = session.list_torrents().await.unwrap();
10099 assert!(
10100 list.contains(&info_hash),
10101 "torrent should be auto-restored on startup"
10102 );
10103
10104 session.shutdown().await.unwrap();
10105 }
10106 }
10107
10108 #[tokio::test]
10111 async fn shutdown_with_readonly_resume_dir_completes() {
10112 let tmp = tempfile::TempDir::new().unwrap();
10113 let readonly_dir = PathBuf::from("/proc/irontide-test-nonexistent");
10116 let mut settings = test_settings();
10117 settings.resume_data_dir = Some(readonly_dir);
10118
10119 let session = SessionHandle::start(settings).await.unwrap();
10120
10121 let data = vec![0xAB; 16384];
10122 let meta = make_test_torrent(&data, 16384);
10123 let storage = make_storage(&data, 16384);
10124 session
10125 .add_torrent_with_meta(meta.into(), Some(storage))
10126 .await
10127 .unwrap();
10128
10129 tokio::time::sleep(Duration::from_millis(50)).await;
10130
10131 session.shutdown().await.unwrap();
10134
10135 drop(tmp);
10137 }
10138
10139 #[tokio::test]
10142 async fn orphan_resume_file_deleted_on_startup() {
10143 let tmp = tempfile::TempDir::new().unwrap();
10144 let torrents_dir = tmp.path().join("torrents");
10145 std::fs::create_dir_all(&torrents_dir).unwrap();
10146
10147 let orphan_path = torrents_dir.join("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef.resume");
10155 std::fs::write(&orphan_path, b"not valid bencode").unwrap();
10156 assert!(orphan_path.exists(), "orphan file should exist before test");
10157
10158 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10159 .await
10160 .unwrap();
10161
10162 tokio::time::sleep(Duration::from_millis(100)).await;
10164
10165 assert!(
10166 !orphan_path.exists(),
10167 "orphan resume file should be deleted on startup"
10168 );
10169
10170 session.shutdown().await.unwrap();
10171 }
10172
10173 #[tokio::test]
10182 async fn multi_torrent_save_load_round_trip() {
10183 let tmp = tempfile::TempDir::new().unwrap();
10184
10185 let datasets: [u8; 3] = [0xAA, 0xBB, 0xCC];
10187 let mut hashes = Vec::with_capacity(3);
10188
10189 {
10190 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10192 .await
10193 .unwrap();
10194
10195 for &byte in &datasets {
10196 let data = vec![byte; 16384];
10197 let meta = make_test_torrent(&data, 16384);
10198 let info_hash = meta.info_hash;
10199 let storage = make_storage(&data, 16384);
10200 session
10201 .add_torrent_with_meta(meta.into(), Some(storage))
10202 .await
10203 .unwrap();
10204 hashes.push(info_hash);
10205 }
10206
10207 tokio::time::sleep(Duration::from_millis(100)).await;
10209
10210 let saved = session.save_resume_state().await.unwrap();
10211 assert_eq!(saved, 3, "all 3 torrents should be saved");
10212
10213 let files = crate::resume_file::scan_resume_dir(tmp.path());
10215 assert_eq!(files.len(), 3, "3 .resume files should be on disk");
10216
10217 for hash in &hashes {
10218 let path = crate::resume_file::resume_file_path(tmp.path(), hash);
10219 assert!(
10220 path.exists(),
10221 "resume file for {} should exist",
10222 hex::encode(hash.as_bytes())
10223 );
10224 }
10225
10226 session.shutdown().await.unwrap();
10227 }
10228
10229 {
10230 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10238 .await
10239 .unwrap();
10240
10241 tokio::time::sleep(Duration::from_millis(200)).await;
10243
10244 let list = session.list_torrents().await.unwrap();
10245 assert_eq!(list.len(), 3, "all 3 torrents should be auto-restored");
10246
10247 for hash in &hashes {
10248 assert!(
10249 list.contains(hash),
10250 "torrent {} should be present after restore",
10251 hex::encode(hash.as_bytes())
10252 );
10253 }
10254
10255 session.shutdown().await.unwrap();
10256 }
10257 }
10258
10259 #[tokio::test]
10265 async fn corrupt_one_of_three_resume_files() {
10266 let tmp = tempfile::TempDir::new().unwrap();
10267
10268 let datasets: [u8; 3] = [0xDD, 0xEE, 0xFF];
10269 let mut hashes = Vec::with_capacity(3);
10270
10271 {
10272 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10274 .await
10275 .unwrap();
10276
10277 for &byte in &datasets {
10278 let data = vec![byte; 16384];
10279 let meta = make_test_torrent(&data, 16384);
10280 let info_hash = meta.info_hash;
10281 let storage = make_storage(&data, 16384);
10282 session
10283 .add_torrent_with_meta(meta.into(), Some(storage))
10284 .await
10285 .unwrap();
10286 hashes.push(info_hash);
10287 }
10288
10289 tokio::time::sleep(Duration::from_millis(100)).await;
10290
10291 let saved = session.save_resume_state().await.unwrap();
10292 assert_eq!(saved, 3, "all 3 torrents should be saved");
10293
10294 session.shutdown().await.unwrap();
10295 }
10296
10297 let corrupt_path = crate::resume_file::resume_file_path(tmp.path(), &hashes[1]);
10299 assert!(
10300 corrupt_path.exists(),
10301 "file to corrupt must exist before overwrite"
10302 );
10303 std::fs::write(&corrupt_path, b"CORRUPTED GARBAGE DATA 0xDEAD").unwrap();
10304
10305 {
10306 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10309 .await
10310 .unwrap();
10311
10312 tokio::time::sleep(Duration::from_millis(200)).await;
10314
10315 let list = session.list_torrents().await.unwrap();
10316 assert_eq!(
10317 list.len(),
10318 2,
10319 "2 torrents should be restored (1 corrupt skipped)"
10320 );
10321
10322 assert!(
10324 list.contains(&hashes[0]),
10325 "first torrent should be restored"
10326 );
10327 assert!(
10328 list.contains(&hashes[2]),
10329 "third torrent should be restored"
10330 );
10331
10332 assert!(
10334 !list.contains(&hashes[1]),
10335 "corrupted torrent should not be restored"
10336 );
10337
10338 assert!(
10340 !corrupt_path.exists(),
10341 "corrupt resume file should be deleted by orphan cleanup"
10342 );
10343
10344 session.shutdown().await.unwrap();
10345 }
10346 }
10347
10348 #[tokio::test]
10355 async fn remove_torrent_deletes_resume_file() {
10356 let tmp = tempfile::TempDir::new().unwrap();
10357
10358 let data = vec![0x42; 16384];
10359 let meta = make_test_torrent(&data, 16384);
10360 let info_hash = meta.info_hash;
10361 let storage = make_storage(&data, 16384);
10362
10363 let session = SessionHandle::start(resume_test_settings(tmp.path()))
10364 .await
10365 .unwrap();
10366
10367 session
10368 .add_torrent_with_meta(meta.into(), Some(storage))
10369 .await
10370 .unwrap();
10371
10372 tokio::time::sleep(Duration::from_millis(100)).await;
10374
10375 let saved = session.save_resume_state().await.unwrap();
10376 assert!(saved > 0, "torrent should be saved to a resume file");
10377
10378 let resume_path = crate::resume_file::resume_file_path(tmp.path(), &info_hash);
10379 assert!(resume_path.exists(), "resume file should exist after save");
10380
10381 session.remove_torrent(info_hash).await.unwrap();
10383 tokio::time::sleep(Duration::from_millis(50)).await;
10384
10385 let list = session.list_torrents().await.unwrap();
10386 assert!(
10387 !list.contains(&info_hash),
10388 "torrent should be gone from session after removal"
10389 );
10390
10391 assert!(
10392 !resume_path.exists(),
10393 "resume file should be deleted when torrent is removed"
10394 );
10395
10396 let remaining = crate::resume_file::scan_resume_dir(tmp.path());
10398 assert!(
10399 remaining.is_empty(),
10400 "no resume files should remain after removing the only torrent"
10401 );
10402
10403 session.shutdown().await.unwrap();
10404 }
10405
10406 fn test_settings_isolated_resume(resume_dir: &std::path::Path) -> Settings {
10412 Settings {
10413 resume_data_dir: Some(resume_dir.to_path_buf()),
10414 ..test_settings()
10415 }
10416 }
10417
10418 #[tokio::test]
10419 async fn remove_torrent_with_files_deletes_disk_files() {
10420 let download_dir = tempfile::tempdir().unwrap();
10424 let resume_dir = tempfile::tempdir().unwrap();
10425 let mut settings = test_settings_isolated_resume(resume_dir.path());
10426 settings.download_dir = download_dir.path().to_path_buf();
10427 let session = SessionHandle::start(settings).await.unwrap();
10428
10429 let data = vec![0xAB_u8; 16384];
10430 let meta = make_test_torrent(&data, 16384);
10431 let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
10432 let storage: Arc<dyn TorrentStorage> = Arc::new(
10433 irontide_storage::FilesystemStorage::new(
10434 download_dir.path(),
10435 vec![PathBuf::from("test")],
10436 vec![data.len() as u64],
10437 lengths,
10438 None,
10439 irontide_storage::PreallocateMode::None,
10440 false,
10441 )
10442 .unwrap(),
10443 );
10444
10445 storage.write_chunk(0, 0, &data).unwrap();
10448
10449 let info_hash = session
10450 .add_torrent_with_meta(meta.into(), Some(storage))
10451 .await
10452 .unwrap();
10453
10454 let file_on_disk = download_dir.path().join("test");
10455 assert!(file_on_disk.exists(), "file should exist before delete");
10456
10457 session.remove_torrent_with_files(info_hash).await.unwrap();
10458
10459 for _ in 0..20 {
10461 if !file_on_disk.exists() {
10462 break;
10463 }
10464 tokio::time::sleep(Duration::from_millis(50)).await;
10465 }
10466 assert!(
10467 !file_on_disk.exists(),
10468 "file should have been removed from disk"
10469 );
10470 assert!(
10471 download_dir.path().exists(),
10472 "download_dir root must never be removed"
10473 );
10474
10475 session.shutdown().await.unwrap();
10476 }
10477
10478 #[tokio::test]
10479 async fn remove_torrent_with_files_tolerates_already_deleted_files() {
10480 let download_dir = tempfile::tempdir().unwrap();
10484 let resume_dir = tempfile::tempdir().unwrap();
10485 let mut settings = test_settings_isolated_resume(resume_dir.path());
10486 settings.download_dir = download_dir.path().to_path_buf();
10487 let session = SessionHandle::start(settings).await.unwrap();
10488
10489 let data = vec![0xCD_u8; 16384];
10490 let meta = make_test_torrent(&data, 16384);
10491 let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
10492 let storage: Arc<dyn TorrentStorage> = Arc::new(
10493 irontide_storage::FilesystemStorage::new(
10494 download_dir.path(),
10495 vec![PathBuf::from("test")],
10496 vec![data.len() as u64],
10497 lengths,
10498 None,
10499 irontide_storage::PreallocateMode::None,
10500 false,
10501 )
10502 .unwrap(),
10503 );
10504 let info_hash = session
10505 .add_torrent_with_meta(meta.into(), Some(storage))
10506 .await
10507 .unwrap();
10508
10509 std::fs::remove_file(download_dir.path().join("test")).unwrap();
10511
10512 let result = session.remove_torrent_with_files(info_hash).await;
10514 assert!(
10515 result.is_ok(),
10516 "remove_torrent_with_files must return Ok on missing files"
10517 );
10518
10519 session.shutdown().await.unwrap();
10520 }
10521
10522 #[tokio::test]
10523 async fn remove_torrent_with_files_grace_guards_fast_re_add() {
10524 use serde::Serialize;
10531
10532 #[derive(Serialize)]
10533 struct Info<'a> {
10534 length: u64,
10535 name: &'a str,
10536 #[serde(rename = "piece length")]
10537 piece_length: u64,
10538 #[serde(with = "serde_bytes")]
10539 pieces: &'a [u8],
10540 }
10541 #[derive(Serialize)]
10542 struct Torrent<'a> {
10543 info: Info<'a>,
10544 }
10545
10546 let download_dir = tempfile::tempdir().unwrap();
10547 let resume_dir = tempfile::tempdir().unwrap();
10548 let mut settings = test_settings_isolated_resume(resume_dir.path());
10549 settings.download_dir = download_dir.path().to_path_buf();
10550 let session = SessionHandle::start(settings).await.unwrap();
10551
10552 let data = vec![0xEE_u8; 16384];
10555 let meta = make_test_torrent(&data, 16384);
10556 let lengths = Lengths::new(data.len() as u64, 16384, DEFAULT_CHUNK_SIZE);
10557 let storage: Arc<dyn TorrentStorage> = Arc::new(
10558 irontide_storage::FilesystemStorage::new(
10559 download_dir.path(),
10560 vec![PathBuf::from("test")],
10561 vec![data.len() as u64],
10562 lengths,
10563 None,
10564 irontide_storage::PreallocateMode::None,
10565 false,
10566 )
10567 .unwrap(),
10568 );
10569 let mut pieces = Vec::new();
10572 let hash = irontide_core::sha1(&data);
10573 pieces.extend_from_slice(hash.as_bytes());
10574 let bytes = irontide_bencode::to_bytes(&Torrent {
10575 info: Info {
10576 length: data.len() as u64,
10577 name: "test",
10578 piece_length: 16384,
10579 pieces: &pieces,
10580 },
10581 })
10582 .unwrap();
10583
10584 let info_hash = session
10585 .add_torrent_with_meta(meta.into(), Some(storage))
10586 .await
10587 .unwrap();
10588
10589 session.remove_torrent_with_files(info_hash).await.unwrap();
10592
10593 let params = AddTorrentParams::bytes(bytes);
10599 let result = session.add_torrent(params).await;
10600 match result {
10601 Ok(_) => {
10602 }
10604 Err(crate::Error::TorrentBeingRemoved(h)) => {
10605 assert_eq!(h, info_hash, "grace error must name the same hash");
10606 }
10607 Err(e) => panic!("unexpected error on re-add: {e}"),
10608 }
10609
10610 session.shutdown().await.unwrap();
10611 }
10612
10613 #[cfg(feature = "test-util")]
10624 fn make_debug_inject_info() -> (Vec<u8>, Id20) {
10625 use serde::Serialize;
10626
10627 #[derive(Serialize)]
10628 struct Info<'a> {
10629 length: u64,
10630 name: &'a str,
10631 #[serde(rename = "piece length")]
10632 piece_length: u64,
10633 #[serde(with = "serde_bytes")]
10634 pieces: &'a [u8],
10635 }
10636
10637 let data = vec![0xAB_u8; 1024];
10638 let piece_hash = irontide_core::sha1(&data);
10639 let mut pieces = Vec::new();
10640 pieces.extend_from_slice(piece_hash.as_bytes());
10641
10642 let info = Info {
10643 length: data.len() as u64,
10644 name: "sync-inject-test",
10645 piece_length: 1024,
10646 pieces: &pieces,
10647 };
10648
10649 let info_bytes = irontide_bencode::to_bytes(&info).unwrap();
10650 let info_hash = irontide_core::sha1(&info_bytes);
10651 (info_bytes, info_hash)
10652 }
10653
10654 #[cfg(feature = "test-util")]
10655 #[tokio::test]
10656 async fn debug_inject_metadata_resolves_magnet_meta_synchronously() {
10657 use crate::session::AddTorrentParams;
10658
10659 let (info_bytes, info_hash) = make_debug_inject_info();
10660
10661 let resume_dir = tempfile::tempdir().unwrap();
10665 let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
10666 .await
10667 .unwrap();
10668
10669 let magnet_uri = format!(
10670 "magnet:?xt=urn:btih:{}&dn=sync-inject-test",
10671 info_hash.to_hex()
10672 );
10673 let added = session
10674 .add_torrent(AddTorrentParams::magnet(magnet_uri))
10675 .await
10676 .unwrap();
10677 assert_eq!(
10678 added, info_hash,
10679 "magnet info hash must equal synth info hash"
10680 );
10681
10682 session
10687 .debug_inject_metadata(info_hash, info_bytes)
10688 .await
10689 .expect("debug_inject_metadata must succeed");
10690
10691 let meta = session
10692 .torrent_file(info_hash)
10693 .await
10694 .expect("torrent_file call")
10695 .expect("metadata must be present immediately after sync inject");
10696 assert_eq!(meta.info_hash, info_hash);
10697
10698 session.shutdown().await.unwrap();
10699 }
10700
10701 #[cfg(feature = "test-util")]
10702 #[tokio::test]
10703 async fn debug_inject_metadata_returns_torrent_not_found_for_unknown_hash() {
10704 let resume_dir = tempfile::tempdir().unwrap();
10705 let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
10706 .await
10707 .unwrap();
10708
10709 let bogus = Id20::from_hex("ffffffffffffffffffffffffffffffffffffffff").unwrap();
10710 let result = session.debug_inject_metadata(bogus, vec![]).await;
10711 assert!(
10712 matches!(result, Err(crate::Error::TorrentNotFound(_))),
10713 "expected TorrentNotFound for unknown hash; got {result:?}"
10714 );
10715
10716 session.shutdown().await.unwrap();
10717 }
10718
10719 #[cfg(feature = "test-util")]
10734 fn build_synth_info_bytes_with_options(
10735 name: &str,
10736 length_bytes: u64,
10737 piece_length: u64,
10738 private: Option<i64>,
10739 ssl_cert: Option<Vec<u8>>,
10740 ) -> Vec<u8> {
10741 use serde::Serialize;
10742
10743 #[derive(Serialize)]
10744 struct Info {
10745 length: u64,
10746 name: String,
10747 #[serde(rename = "piece length")]
10748 piece_length: u64,
10749 pieces: serde_bytes::ByteBuf,
10750 #[serde(skip_serializing_if = "Option::is_none")]
10751 private: Option<i64>,
10752 #[serde(rename = "ssl-cert", skip_serializing_if = "Option::is_none")]
10753 ssl_cert: Option<serde_bytes::ByteBuf>,
10754 }
10755
10756 let num_pieces = length_bytes.div_ceil(piece_length);
10761 let zero_piece_hash = irontide_core::sha1(&vec![0_u8; piece_length as usize]);
10762 let mut pieces = Vec::with_capacity(20 * num_pieces as usize);
10763 for _ in 0..num_pieces {
10764 pieces.extend_from_slice(zero_piece_hash.as_bytes());
10765 }
10766
10767 let info = Info {
10768 length: length_bytes,
10769 name: name.to_owned(),
10770 piece_length,
10771 pieces: serde_bytes::ByteBuf::from(pieces),
10772 private,
10773 ssl_cert: ssl_cert.map(serde_bytes::ByteBuf::from),
10774 };
10775 irontide_bencode::to_bytes(&info).expect("bencode synth info dict")
10776 }
10777
10778 #[cfg(feature = "test-util")]
10779 #[tokio::test]
10780 async fn ssl_cert_propagates_to_meta_after_inject() {
10781 use crate::session::AddTorrentParams;
10782
10783 let resume_dir = tempfile::tempdir().unwrap();
10784 let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
10785 .await
10786 .unwrap();
10787
10788 let cert_pem = b"-----BEGIN CERT-----\nfake\n-----END CERT-----\n".to_vec();
10789 let info_bytes = build_synth_info_bytes_with_options(
10790 "ssl-fixture",
10791 16_384,
10792 16_384,
10793 None,
10794 Some(cert_pem.clone()),
10795 );
10796 let info_hash = irontide_core::sha1(&info_bytes);
10797
10798 let magnet = format!("magnet:?xt=urn:btih:{}&dn=ssl-fixture", info_hash.to_hex());
10799 let added = session
10800 .add_torrent(AddTorrentParams::magnet(magnet))
10801 .await
10802 .unwrap();
10803 assert_eq!(
10804 added, info_hash,
10805 "magnet info hash must equal synth info hash"
10806 );
10807
10808 session
10809 .debug_inject_metadata(info_hash, info_bytes)
10810 .await
10811 .expect("debug_inject_metadata must succeed");
10812
10813 let meta = session
10814 .torrent_file(info_hash)
10815 .await
10816 .expect("torrent_file Ok")
10817 .expect("metadata must be present immediately after sync inject");
10818 assert_eq!(
10819 meta.info.ssl_cert.as_ref(),
10820 Some(&cert_pem),
10821 "ssl_cert from synth info dict must propagate to meta.info.ssl_cert"
10822 );
10823
10824 session.shutdown().await.unwrap();
10825 }
10826
10827 #[cfg(feature = "test-util")]
10828 #[tokio::test]
10829 async fn ssl_cert_absent_remains_none_in_meta_after_inject() {
10830 use crate::session::AddTorrentParams;
10831
10832 let resume_dir = tempfile::tempdir().unwrap();
10833 let session = SessionHandle::start(test_settings_isolated_resume(resume_dir.path()))
10834 .await
10835 .unwrap();
10836
10837 let info_bytes =
10838 build_synth_info_bytes_with_options("no-ssl-fixture", 16_384, 16_384, None, None);
10839 let info_hash = irontide_core::sha1(&info_bytes);
10840
10841 let magnet = format!(
10842 "magnet:?xt=urn:btih:{}&dn=no-ssl-fixture",
10843 info_hash.to_hex()
10844 );
10845 let added = session
10846 .add_torrent(AddTorrentParams::magnet(magnet))
10847 .await
10848 .unwrap();
10849 assert_eq!(
10850 added, info_hash,
10851 "magnet info hash must equal synth info hash"
10852 );
10853
10854 session
10855 .debug_inject_metadata(info_hash, info_bytes)
10856 .await
10857 .expect("debug_inject_metadata must succeed");
10858
10859 let meta = session
10860 .torrent_file(info_hash)
10861 .await
10862 .expect("torrent_file Ok")
10863 .expect("metadata must be present immediately after sync inject");
10864 assert!(
10865 meta.info.ssl_cert.is_none(),
10866 "absent ssl-cert in info dict must remain None in meta; got {:?}",
10867 meta.info.ssl_cert
10868 );
10869
10870 session.shutdown().await.unwrap();
10871 }
10872
10873 #[tokio::test]
10876 async fn init_throttle_queues_restored_torrents() {
10877 let tmp = tempfile::TempDir::new().unwrap();
10878 let resume_dir = tmp.path().to_path_buf();
10879
10880 {
10882 let mut settings = resume_test_settings(&resume_dir);
10883 settings.queueing_enabled = false;
10884 let session = SessionHandle::start(settings).await.unwrap();
10885 for i in 0u8..5 {
10886 let data = vec![i.wrapping_add(0xA0); 16384];
10887 let meta = make_test_torrent(&data, 16384);
10888 let storage = make_storage(&data, 16384);
10889 session
10890 .add_torrent_with_meta(meta.into(), Some(storage))
10891 .await
10892 .unwrap();
10893 }
10894 tokio::time::sleep(Duration::from_millis(100)).await;
10895 let saved = session.save_resume_state().await.unwrap();
10896 assert!(saved >= 3, "should save most resume files, got {saved}");
10897 session.shutdown().await.unwrap();
10898 }
10899
10900 {
10902 let mut settings = resume_test_settings(&resume_dir);
10903 settings.queueing_enabled = true;
10904 settings.active_checking = 2;
10905 settings.active_downloads = 2;
10906 settings.active_seeds = 2;
10907 settings.active_limit = 4;
10908 let session = SessionHandle::start(settings).await.unwrap();
10909 tokio::time::sleep(Duration::from_millis(200)).await;
10910
10911 let list = session.list_torrent_summaries().await.unwrap();
10912 let queued = list
10913 .iter()
10914 .filter(|t| t.state == TorrentState::Queued)
10915 .count();
10916 let active = list
10917 .iter()
10918 .filter(|t| t.state != TorrentState::Queued)
10919 .count();
10920
10921 assert!(
10922 queued > 0,
10923 "at least one torrent should be Queued, but all {active} are active"
10924 );
10925 assert!(
10926 active <= 4,
10927 "active torrents ({active}) should not exceed active_limit (4)"
10928 );
10929 session.shutdown().await.unwrap();
10930 }
10931 }
10932
10933 #[tokio::test]
10934 async fn init_throttle_disabled_restores_all_immediately() {
10935 let tmp = tempfile::TempDir::new().unwrap();
10936 let resume_dir = tmp.path().to_path_buf();
10937
10938 {
10940 let settings = resume_test_settings(&resume_dir);
10941 let session = SessionHandle::start(settings).await.unwrap();
10942 for i in 0u8..3 {
10943 let data = vec![i.wrapping_add(0xC0); 16384];
10944 let meta = make_test_torrent(&data, 16384);
10945 let storage = make_storage(&data, 16384);
10946 session
10947 .add_torrent_with_meta(meta.into(), Some(storage))
10948 .await
10949 .unwrap();
10950 }
10951 tokio::time::sleep(Duration::from_millis(100)).await;
10952 session.save_resume_state().await.unwrap();
10953 session.shutdown().await.unwrap();
10954 }
10955
10956 {
10958 let mut settings = resume_test_settings(&resume_dir);
10959 settings.queueing_enabled = false;
10960 let session = SessionHandle::start(settings).await.unwrap();
10961 tokio::time::sleep(Duration::from_millis(200)).await;
10962
10963 let list = session.list_torrent_summaries().await.unwrap();
10964 let queued = list
10965 .iter()
10966 .filter(|t| t.state == TorrentState::Queued)
10967 .count();
10968 assert_eq!(
10969 queued, 0,
10970 "with queueing disabled, no torrents should be Queued"
10971 );
10972 session.shutdown().await.unwrap();
10973 }
10974 }
10975
10976 #[tokio::test]
10977 async fn checking_complete_triggers_immediate_eval() {
10978 use crate::alert::AlertKind;
10979
10980 let mut settings = test_settings();
10981 settings.queueing_enabled = true;
10982 settings.active_checking = 1;
10983 settings.active_downloads = 5;
10984 settings.active_seeds = 5;
10985 settings.active_limit = 10;
10986 settings.auto_manage_interval = 300;
10987 let session = SessionHandle::start(settings).await.unwrap();
10988 let mut alerts = session.subscribe();
10989
10990 let mut hashes = Vec::new();
10992 for i in 0u8..3 {
10993 let data = vec![i.wrapping_add(0xD0); 16384];
10994 let meta = make_test_torrent(&data, 16384);
10995 let storage = make_storage(&data, 16384);
10996 let h = session
10997 .add_torrent_with_meta(meta.into(), Some(storage))
10998 .await
10999 .unwrap();
11000 hashes.push(h);
11001 }
11002
11003 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
11007 let mut saw_checking_transition = false;
11008 while tokio::time::Instant::now() < deadline {
11009 if let Ok(Ok(alert)) =
11010 tokio::time::timeout(Duration::from_millis(500), alerts.recv()).await
11011 && matches!(
11012 alert.kind,
11013 AlertKind::StateChanged {
11014 prev_state: TorrentState::Checking,
11015 ..
11016 }
11017 )
11018 {
11019 saw_checking_transition = true;
11020 break;
11021 }
11022 }
11023
11024 assert!(
11025 saw_checking_transition,
11026 "should have seen a Checking→* state transition"
11027 );
11028
11029 tokio::time::sleep(Duration::from_millis(200)).await;
11033
11034 let list = session.list_torrent_summaries().await.unwrap();
11035 let active = list
11036 .iter()
11037 .filter(|t| t.state != TorrentState::Queued)
11038 .count();
11039 assert!(
11040 active >= 1,
11041 "at least one torrent should be active after checking-complete trigger"
11042 );
11043
11044 session.shutdown().await.unwrap();
11045 }
11046
11047 #[tokio::test]
11050 async fn resume_restores_queue_position() {
11051 let tmp = tempfile::TempDir::new().unwrap();
11052 let resume_dir = tmp.path().to_path_buf();
11053
11054 let data = vec![0xF0; 16384];
11055 let meta = make_test_torrent(&data, 16384);
11056 let info_hash = meta.info_hash;
11057
11058 {
11060 let settings = resume_test_settings(&resume_dir);
11061 let session = SessionHandle::start(settings).await.unwrap();
11062 let storage = make_storage(&data, 16384);
11063 session
11064 .add_torrent_with_meta(meta.clone().into(), Some(storage))
11065 .await
11066 .unwrap();
11067 session.set_queue_position(info_hash, 3).await.unwrap();
11068 tokio::time::sleep(Duration::from_millis(100)).await;
11069 session.save_resume_state().await.unwrap();
11070 session.shutdown().await.unwrap();
11071 }
11072
11073 {
11075 let settings = resume_test_settings(&resume_dir);
11076 let session = SessionHandle::start(settings).await.unwrap();
11077 tokio::time::sleep(Duration::from_millis(200)).await;
11078
11079 let pos = session.queue_position(info_hash).await.unwrap();
11080 assert_eq!(pos, 0, "single torrent renormalizes to position 0");
11083 session.shutdown().await.unwrap();
11084 }
11085 }
11086
11087 #[tokio::test]
11088 async fn resume_restores_auto_managed_false() {
11089 let tmp = tempfile::TempDir::new().unwrap();
11090 let resume_dir = tmp.path().to_path_buf();
11091
11092 let data = vec![0xF1; 16384];
11093 let meta = make_test_torrent(&data, 16384);
11094 let info_hash = meta.info_hash;
11095
11096 {
11098 let settings = resume_test_settings(&resume_dir);
11099 let session = SessionHandle::start(settings).await.unwrap();
11100 let storage = make_storage(&data, 16384);
11101 session
11102 .add_torrent_with_meta(meta.clone().into(), Some(storage))
11103 .await
11104 .unwrap();
11105 tokio::time::sleep(Duration::from_millis(100)).await;
11109 session.save_resume_state().await.unwrap();
11110 session.shutdown().await.unwrap();
11111 }
11112
11113 {
11115 let path = crate::resume_file::resume_file_path(&resume_dir, &info_hash);
11116 if path.exists() {
11117 let bytes = std::fs::read(&path).unwrap();
11118 let mut rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
11119 rd.auto_managed = 0;
11120 let patched = crate::resume_file::serialize_resume(&rd).unwrap();
11121 std::fs::write(&path, patched).unwrap();
11122 }
11123 }
11124
11125 {
11127 let settings = resume_test_settings(&resume_dir);
11128 let session = SessionHandle::start(settings).await.unwrap();
11129 tokio::time::sleep(Duration::from_millis(200)).await;
11130
11131 let stats = session.torrent_stats(info_hash).await.unwrap();
11132 assert!(
11133 !stats.auto_managed,
11134 "auto_managed should be false after restore"
11135 );
11136 session.shutdown().await.unwrap();
11137 }
11138 }
11139
11140 #[tokio::test]
11141 async fn resume_renormalizes_duplicate_positions() {
11142 let tmp = tempfile::TempDir::new().unwrap();
11143 let resume_dir = tmp.path().to_path_buf();
11144
11145 let mut hashes = Vec::new();
11147 {
11148 let settings = resume_test_settings(&resume_dir);
11149 let session = SessionHandle::start(settings).await.unwrap();
11150 for i in 0u8..3 {
11151 let data = vec![i.wrapping_add(0xE0); 16384];
11152 let meta = make_test_torrent(&data, 16384);
11153 let storage = make_storage(&data, 16384);
11154 let h = session
11155 .add_torrent_with_meta(meta.into(), Some(storage))
11156 .await
11157 .unwrap();
11158 hashes.push(h);
11159 }
11160 tokio::time::sleep(Duration::from_millis(100)).await;
11161 session.save_resume_state().await.unwrap();
11162 session.shutdown().await.unwrap();
11163 }
11164
11165 for hash in &hashes {
11167 let path = crate::resume_file::resume_file_path(&resume_dir, hash);
11168 if path.exists() {
11169 let bytes = std::fs::read(&path).unwrap();
11170 let mut rd = crate::resume_file::deserialize_resume(&bytes).unwrap();
11171 rd.queue_position = 0;
11172 let patched = crate::resume_file::serialize_resume(&rd).unwrap();
11173 std::fs::write(&path, patched).unwrap();
11174 }
11175 }
11176
11177 {
11179 let settings = resume_test_settings(&resume_dir);
11180 let session = SessionHandle::start(settings).await.unwrap();
11181 tokio::time::sleep(Duration::from_millis(200)).await;
11182
11183 let mut positions = Vec::new();
11184 for hash in &hashes {
11185 if let Ok(pos) = session.queue_position(*hash).await {
11186 positions.push(pos);
11187 }
11188 }
11189 positions.sort_unstable();
11190 let expected: Vec<i32> = (0..positions.len() as i32).collect();
11191 assert_eq!(
11192 positions, expected,
11193 "positions should be contiguous 0..N-1 after renormalization"
11194 );
11195 session.shutdown().await.unwrap();
11196 }
11197 }
11198
11199 #[test]
11202 fn ewma_smooths_transient_drop() {
11203 let alpha = 0.3_f64;
11204 let prev = 100_000.0_f64;
11205 let sample = 0.0_f64;
11206 let smoothed = alpha.mul_add(sample, (1.0 - alpha) * prev);
11207 assert!(
11208 (smoothed - 70_000.0).abs() < 1.0,
11209 "smoothed rate should be ~70000, got {smoothed}"
11210 );
11211 }
11212
11213 #[test]
11214 fn ewma_alpha_one_equals_raw() {
11215 let alpha = 1.0_f64;
11216 let prev = 100_000.0_f64;
11217 let sample = 42_000.0_f64;
11218 let smoothed = alpha.mul_add(sample, (1.0 - alpha) * prev);
11219 assert!(
11220 (smoothed - sample).abs() < 0.001,
11221 "alpha=1.0 should produce raw rate, got {smoothed}"
11222 );
11223 }
11224
11225 #[test]
11228 fn seed_anti_flap_uses_longer_duration() {
11229 let seed_queue_min_active_secs = 1800_u64;
11230 let auto_manage_startup = 60_u64;
11231 let started_5_min_ago = std::time::Duration::from_mins(5);
11232 let seed_duration = std::time::Duration::from_secs(seed_queue_min_active_secs);
11233
11234 assert!(
11237 started_5_min_ago < seed_duration,
11238 "5 min < 30 min, seeding torrent should be recently_started"
11239 );
11240
11241 let dl_duration = std::time::Duration::from_secs(auto_manage_startup);
11244 assert!(
11245 started_5_min_ago > dl_duration,
11246 "5 min > 60s, downloading torrent should NOT be recently_started"
11247 );
11248 }
11249
11250 #[test]
11251 fn download_anti_flap_uses_startup_duration() {
11252 let auto_manage_startup = 60_u64;
11253 let started_5_min_ago = std::time::Duration::from_mins(5);
11254 let dl_duration = std::time::Duration::from_secs(auto_manage_startup);
11255 assert!(
11256 started_5_min_ago > dl_duration,
11257 "downloading torrent started 5 min ago should NOT be recently_started"
11258 );
11259 }
11260
11261 #[test]
11264 fn classify_restart_required_upnp_change() {
11265 let old = Settings::default();
11266 let mut new = old.clone();
11267 new.enable_upnp = !old.enable_upnp;
11268 assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11269 assert_eq!(classify_restart_required(&old, &new), vec!["upnp"]);
11270 }
11271
11272 #[test]
11273 fn classify_restart_required_natpmp_change() {
11274 let old = Settings::default();
11275 let mut new = old.clone();
11276 new.enable_natpmp = !old.enable_natpmp;
11277 assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11278 assert_eq!(classify_restart_required(&old, &new), vec!["natpmp"]);
11279 }
11280
11281 #[test]
11282 fn classify_immediate_max_connec_global_change() {
11283 let old = Settings::default();
11284 let mut new = old.clone();
11285 new.max_connections_global = if old.max_connections_global == 500 {
11286 501
11287 } else {
11288 500
11289 };
11290 assert_eq!(classify_immediate(&old, &new), vec!["max_connec_global"]);
11291 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11292 }
11293
11294 #[test]
11295 fn classify_immediate_max_uploads_per_torrent_change() {
11296 let old = Settings::default();
11300 let mut new = old.clone();
11301 new.max_uploads_per_torrent = 4;
11302 assert_eq!(
11303 classify_immediate(&old, &new),
11304 vec!["max_uploads_per_torrent"]
11305 );
11306 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11307 }
11308
11309 #[test]
11310 fn classify_restart_required_proxy_type_change() {
11311 let old = Settings::default();
11312 let mut new = old.clone();
11313 new.proxy.proxy_type = crate::proxy::ProxyType::Socks5;
11314 assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11315 assert_eq!(classify_restart_required(&old, &new), vec!["proxy_type"]);
11316 }
11317
11318 #[test]
11319 fn classify_restart_required_proxy_credentials_change() {
11320 let old = Settings::default();
11321 let mut new = old.clone();
11322 new.proxy.username = Some("alice".into());
11323 new.proxy.password = Some("secret".into());
11324 assert_eq!(classify_immediate(&old, &new), Vec::<&str>::new());
11325 let restart = classify_restart_required(&old, &new);
11326 let set: std::collections::HashSet<&str> = restart.iter().copied().collect();
11329 assert_eq!(
11330 set,
11331 ["proxy_username", "proxy_password"]
11332 .into_iter()
11333 .collect::<std::collections::HashSet<_>>()
11334 );
11335 }
11336
11337 #[test]
11338 fn classify_combined_immediate_and_restart() {
11339 let old = Settings::default();
11343 let mut new = old.clone();
11344 new.max_connections_global = old.max_connections_global + 1;
11345 new.max_uploads_per_torrent = 4;
11346 new.enable_upnp = !old.enable_upnp;
11347 new.proxy.proxy_type = crate::proxy::ProxyType::Http;
11348
11349 let immediate = classify_immediate(&old, &new);
11350 let imm_set: std::collections::HashSet<&str> = immediate.iter().copied().collect();
11351 assert_eq!(
11352 imm_set,
11353 ["max_connec_global", "max_uploads_per_torrent"]
11354 .into_iter()
11355 .collect::<std::collections::HashSet<_>>()
11356 );
11357 let restart = classify_restart_required(&old, &new);
11358 let set: std::collections::HashSet<&str> = restart.iter().copied().collect();
11359 assert_eq!(
11360 set,
11361 ["upnp", "proxy_type"]
11362 .into_iter()
11363 .collect::<std::collections::HashSet<_>>()
11364 );
11365 }
11366
11367 #[test]
11370 fn classify_immediate_seed_time_limit_change() {
11371 let old = Settings::default();
11372 let mut new = old.clone();
11373 new.seed_time_limit_secs = Some(3600);
11374 assert_eq!(classify_immediate(&old, &new), vec!["max_seeding_time"]);
11375 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11376 }
11377
11378 #[test]
11379 fn classify_immediate_inactive_seed_time_limit_change() {
11380 let old = Settings::default();
11381 let mut new = old.clone();
11382 new.inactive_seed_time_limit_secs = Some(1800);
11383 assert_eq!(
11384 classify_immediate(&old, &new),
11385 vec!["max_inactive_seeding_time"]
11386 );
11387 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11388 }
11389
11390 #[test]
11396 fn classify_immediate_save_resume_interval_change() {
11397 let old = Settings::default();
11401 let mut new = old.clone();
11402 new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(60);
11403 assert_eq!(
11404 classify_immediate(&old, &new),
11405 vec!["save_resume_interval"]
11406 );
11407 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11408 }
11409
11410 #[test]
11411 fn classify_immediate_hashing_threads_change() {
11412 let old = Settings::default();
11418 let mut new = old.clone();
11419 new.hashing_threads = old.hashing_threads.saturating_add(2);
11420 assert_eq!(classify_immediate(&old, &new), vec!["hashing_threads"]);
11421 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11422 }
11423
11424 #[test]
11425 fn classify_immediate_ip_filter_enabled_change() {
11426 let old = Settings::default();
11431 let mut new = old.clone();
11432 new.ip_filter_enabled = !old.ip_filter_enabled;
11433 assert_eq!(
11434 classify_immediate(&old, &new),
11435 vec!["ip_filter_enabled"]
11436 );
11437 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11438 }
11439
11440 #[test]
11441 fn settings_delta_from_diff_includes_save_resume_interval() {
11442 use crate::types::SettingsDelta;
11445 let old = Settings::default();
11446 let mut new = old.clone();
11447 new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(30);
11448 let d = SettingsDelta::from_diff(&old, &new);
11449 assert_eq!(d.save_resume_interval_secs, Some(new.save_resume_interval_secs));
11450 assert!(d.hashing_threads.is_none());
11451 assert!(d.ip_filter_enabled.is_none());
11452 assert!(!d.is_empty());
11453 }
11454
11455 #[test]
11456 fn settings_delta_from_diff_includes_hashing_threads() {
11457 use crate::types::SettingsDelta;
11460 let old = Settings::default();
11461 let mut new = old.clone();
11462 new.hashing_threads = old.hashing_threads.saturating_add(1);
11463 let d = SettingsDelta::from_diff(&old, &new);
11464 assert_eq!(d.hashing_threads, Some(new.hashing_threads));
11465 assert!(d.save_resume_interval_secs.is_none());
11466 assert!(d.ip_filter_enabled.is_none());
11467 assert!(!d.is_empty());
11468 }
11469
11470 #[test]
11471 fn settings_delta_from_diff_includes_ip_filter_enabled() {
11472 use crate::types::SettingsDelta;
11475 let old = Settings::default();
11476 let mut new = old.clone();
11477 new.ip_filter_enabled = !old.ip_filter_enabled;
11478 let d = SettingsDelta::from_diff(&old, &new);
11479 assert_eq!(d.ip_filter_enabled, Some(new.ip_filter_enabled));
11480 assert!(d.save_resume_interval_secs.is_none());
11481 assert!(d.hashing_threads.is_none());
11482 assert!(!d.is_empty());
11483 }
11484
11485 #[test]
11486 fn settings_delta_is_empty_honours_m225_fields() {
11487 use crate::types::SettingsDelta;
11490 let mut d = SettingsDelta::default();
11491 assert!(d.is_empty());
11492 d.save_resume_interval_secs = Some(120);
11493 assert!(!d.is_empty());
11494 d = SettingsDelta::default();
11495 d.hashing_threads = Some(8);
11496 assert!(!d.is_empty());
11497 d = SettingsDelta::default();
11498 d.ip_filter_enabled = Some(false);
11499 assert!(!d.is_empty());
11500 }
11501
11502 fn m226_delta_and_classify_check<F>(mutate: F, alias: &'static str)
11508 where
11509 F: FnOnce(&mut Settings),
11510 {
11511 use crate::types::SettingsDelta;
11512 let old = Settings::default();
11513 let mut new = old.clone();
11514 mutate(&mut new);
11515 let d = SettingsDelta::from_diff(&old, &new);
11516 assert!(!d.is_empty(), "{alias}: delta must not be empty after toggle");
11517 let imm = classify_immediate(&old, &new);
11518 assert!(
11519 imm.contains(&alias),
11520 "{alias}: classify_immediate must contain alias, got {imm:?}"
11521 );
11522 let rr = classify_restart_required(&old, &new);
11523 assert!(
11524 !rr.contains(&alias),
11525 "{alias}: must NOT appear in classify_restart_required"
11526 );
11527 }
11528
11529 #[test]
11530 fn m226_notify_on_complete_immediate() {
11531 m226_delta_and_classify_check(|s| s.notify_on_complete = true, "notify_on_complete");
11532 }
11533
11534 #[test]
11535 fn m226_notify_on_error_immediate() {
11536 m226_delta_and_classify_check(|s| s.notify_on_error = true, "notify_on_error");
11537 }
11538
11539 #[test]
11540 fn m226_on_complete_program_immediate() {
11541 m226_delta_and_classify_check(
11542 |s| s.on_complete_program = Some(std::path::PathBuf::from("/usr/local/bin/finish")),
11543 "on_complete_program",
11544 );
11545 }
11546
11547 #[test]
11548 fn m226_use_incomplete_dir_immediate() {
11549 m226_delta_and_classify_check(|s| s.use_incomplete_dir = true, "use_incomplete_dir");
11550 }
11551
11552 #[test]
11553 fn m226_incomplete_dir_immediate() {
11554 m226_delta_and_classify_check(
11555 |s| s.incomplete_dir = Some(std::path::PathBuf::from("/tmp/inc")),
11556 "incomplete_dir",
11557 );
11558 }
11559
11560 #[test]
11561 fn m226_default_skip_hash_check_immediate() {
11562 m226_delta_and_classify_check(
11563 |s| s.default_skip_hash_check = true,
11564 "default_skip_hash_check",
11565 );
11566 }
11567
11568 #[test]
11569 fn m226_incomplete_extension_enabled_immediate() {
11570 m226_delta_and_classify_check(
11572 |s| s.incomplete_extension_enabled = false,
11573 "incomplete_extension_enabled",
11574 );
11575 }
11576
11577 #[test]
11578 fn m226_watched_folder_immediate() {
11579 m226_delta_and_classify_check(
11580 |s| s.watched_folder = Some(std::path::PathBuf::from("/tmp/watched")),
11581 "watched_folder",
11582 );
11583 }
11584
11585 #[test]
11586 fn m226_delete_torrent_after_add_immediate() {
11587 m226_delta_and_classify_check(
11588 |s| s.delete_torrent_after_add = true,
11589 "delete_torrent_after_add",
11590 );
11591 }
11592
11593 #[test]
11594 fn m226_move_completed_enabled_immediate() {
11595 m226_delta_and_classify_check(
11596 |s| s.move_completed_enabled = true,
11597 "move_completed_enabled",
11598 );
11599 }
11600
11601 #[test]
11602 fn m226_move_completed_to_immediate() {
11603 m226_delta_and_classify_check(
11604 |s| s.move_completed_to = Some(std::path::PathBuf::from("/tmp/done")),
11605 "move_completed_to",
11606 );
11607 }
11608
11609 #[test]
11610 fn m226_ip_filter_auto_refresh_immediate() {
11611 m226_delta_and_classify_check(
11612 |s| s.ip_filter_auto_refresh = true,
11613 "ip_filter_auto_refresh",
11614 );
11615 }
11616
11617 #[test]
11618 fn m226_web_ui_https_enabled_immediate() {
11619 m226_delta_and_classify_check(|s| s.web_ui_https_enabled = true, "web_ui_https_enabled");
11620 }
11621
11622 #[test]
11623 fn m226_network_interface_immediate() {
11624 m226_delta_and_classify_check(
11625 |s| s.network_interface = Some("eth0".into()),
11626 "network_interface",
11627 );
11628 }
11629
11630 #[test]
11631 fn m226_default_add_paused_immediate() {
11632 m226_delta_and_classify_check(|s| s.default_add_paused = true, "default_add_paused");
11633 }
11634
11635 #[test]
11636 fn m226_delta_clears_optional_path_incomplete_dir() {
11637 use crate::types::SettingsDelta;
11640 let old = Settings {
11641 incomplete_dir: Some(std::path::PathBuf::from("/foo")),
11642 ..Settings::default()
11643 };
11644 let new = Settings {
11645 incomplete_dir: None,
11646 ..old.clone()
11647 };
11648 let d = SettingsDelta::from_diff(&old, &new);
11649 assert_eq!(d.incomplete_dir, Some(None), "must signal clear to None");
11650 assert!(!d.is_empty());
11651 }
11652
11653 #[test]
11654 fn m226_delta_clears_optional_path_watched_folder() {
11655 use crate::types::SettingsDelta;
11657 let old = Settings {
11658 watched_folder: Some(std::path::PathBuf::from("/tmp/watch")),
11659 ..Settings::default()
11660 };
11661 let new = Settings {
11662 watched_folder: None,
11663 ..old.clone()
11664 };
11665 let d = SettingsDelta::from_diff(&old, &new);
11666 assert_eq!(d.watched_folder, Some(None));
11667 assert!(!d.is_empty());
11668 }
11669
11670 #[test]
11671 fn m226_delta_is_empty_honours_new_fields() {
11672 use crate::types::SettingsDelta;
11674 let mut d = SettingsDelta::default();
11675 assert!(d.is_empty());
11676 d.notify_on_complete = Some(true);
11677 assert!(!d.is_empty());
11678 d = SettingsDelta::default();
11679 d.watched_folder = Some(None); assert!(!d.is_empty());
11681 d = SettingsDelta::default();
11682 d.default_add_paused = Some(true);
11683 assert!(!d.is_empty());
11684 }
11685
11686 #[test]
11687 fn m226_no_fields_appear_in_restart_required() {
11688 type Mutation = fn(&mut Settings);
11691 let mutations: [Mutation; 15] = [
11692 |s| s.notify_on_complete = true,
11693 |s| s.notify_on_error = true,
11694 |s| s.on_complete_program = Some(std::path::PathBuf::from("/p")),
11695 |s| s.use_incomplete_dir = true,
11696 |s| s.incomplete_dir = Some(std::path::PathBuf::from("/i")),
11697 |s| s.default_skip_hash_check = true,
11698 |s| s.incomplete_extension_enabled = false,
11699 |s| s.watched_folder = Some(std::path::PathBuf::from("/w")),
11700 |s| s.delete_torrent_after_add = true,
11701 |s| s.move_completed_enabled = true,
11702 |s| s.move_completed_to = Some(std::path::PathBuf::from("/m")),
11703 |s| s.ip_filter_auto_refresh = true,
11704 |s| s.web_ui_https_enabled = true,
11705 |s| s.network_interface = Some("eth0".into()),
11706 |s| s.default_add_paused = true,
11707 ];
11708 let old = Settings::default();
11709 for (idx, m) in mutations.iter().enumerate() {
11710 let mut new = old.clone();
11711 m(&mut new);
11712 let rr = classify_restart_required(&old, &new);
11713 assert!(
11714 rr.is_empty(),
11715 "mutation #{idx}: M226 fields must not surface restart_required, got {rr:?}"
11716 );
11717 }
11718 }
11719
11720 #[test]
11721 fn classify_immediate_seed_time_and_inactive_combined() {
11722 let old = Settings::default();
11725 let mut new = old.clone();
11726 new.seed_time_limit_secs = Some(7200);
11727 new.inactive_seed_time_limit_secs = Some(900);
11728 let imm = classify_immediate(&old, &new);
11729 let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
11730 assert_eq!(
11731 set,
11732 ["max_seeding_time", "max_inactive_seeding_time"]
11733 .into_iter()
11734 .collect::<std::collections::HashSet<_>>()
11735 );
11736 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11737 }
11738
11739 #[test]
11740 fn classify_combined_seed_time_and_hashing_both_immediate() {
11741 let old = Settings::default();
11745 let mut new = old.clone();
11746 new.seed_time_limit_secs = Some(1200);
11747 new.hashing_threads = old.hashing_threads.saturating_add(2);
11748 let imm = classify_immediate(&old, &new);
11749 let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
11750 assert_eq!(
11751 set,
11752 ["max_seeding_time", "hashing_threads"]
11753 .into_iter()
11754 .collect::<std::collections::HashSet<_>>()
11755 );
11756 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11757 }
11758
11759 #[test]
11760 fn classify_combined_hashing_and_save_resume_both_immediate() {
11761 let old = Settings::default();
11765 let mut new = old.clone();
11766 new.hashing_threads = old.hashing_threads.saturating_add(3);
11767 new.save_resume_interval_secs = old.save_resume_interval_secs.saturating_add(120);
11768 let imm = classify_immediate(&old, &new);
11769 let set: std::collections::HashSet<&str> = imm.iter().copied().collect();
11770 assert_eq!(
11771 set,
11772 ["hashing_threads", "save_resume_interval"]
11773 .into_iter()
11774 .collect::<std::collections::HashSet<_>>()
11775 );
11776 assert_eq!(classify_restart_required(&old, &new), Vec::<&str>::new());
11777 }
11778
11779 fn m226_make_torrent_bytes(data: &[u8], piece_length: u64) -> Vec<u8> {
11792 use serde::Serialize;
11793
11794 #[derive(Serialize)]
11795 struct Info<'a> {
11796 length: u64,
11797 name: &'a str,
11798 #[serde(rename = "piece length")]
11799 piece_length: u64,
11800 #[serde(with = "serde_bytes")]
11801 pieces: &'a [u8],
11802 }
11803 #[derive(Serialize)]
11804 struct Torrent<'a> {
11805 info: Info<'a>,
11806 }
11807
11808 let mut pieces = Vec::new();
11809 let mut offset = 0;
11810 while offset < data.len() {
11811 let end = (offset + piece_length as usize).min(data.len());
11812 let hash = irontide_core::sha1(&data[offset..end]);
11813 pieces.extend_from_slice(hash.as_bytes());
11814 offset = end;
11815 }
11816
11817 irontide_bencode::to_bytes(&Torrent {
11818 info: Info {
11819 length: data.len() as u64,
11820 name: "m226-test",
11821 piece_length,
11822 pieces: &pieces,
11823 },
11824 })
11825 .unwrap()
11826 }
11827
11828 #[tokio::test]
11831 async fn add_torrent_with_default_add_paused_true_pauses_torrent() {
11832 let mut settings = test_settings();
11833 settings.default_add_paused = true;
11834 let session = SessionHandle::start(settings).await.unwrap();
11835
11836 let data = vec![0xAB; 16384];
11837 let bytes = m226_make_torrent_bytes(&data, 16384);
11838 let info_hash = session
11839 .add_torrent(AddTorrentParams::bytes(bytes))
11840 .await
11841 .unwrap();
11842
11843 tokio::time::sleep(Duration::from_millis(100)).await;
11846 let stats = session.torrent_stats(info_hash).await.unwrap();
11847 assert_eq!(
11848 stats.state,
11849 TorrentState::Paused,
11850 "engine default_add_paused=true must pause the torrent when caller \
11851 passes AddTorrentParams::bytes() without an explicit .paused(...)"
11852 );
11853
11854 session.shutdown().await.unwrap();
11855 }
11856
11857 #[tokio::test]
11861 async fn add_torrent_with_explicit_paused_false_resumes_despite_default() {
11862 let mut settings = test_settings();
11863 settings.default_add_paused = true;
11864 let session = SessionHandle::start(settings).await.unwrap();
11865
11866 let data = vec![0xCD; 16384];
11867 let bytes = m226_make_torrent_bytes(&data, 16384);
11868 let info_hash = session
11869 .add_torrent(AddTorrentParams::bytes(bytes).paused(false))
11870 .await
11871 .unwrap();
11872
11873 tokio::time::sleep(Duration::from_millis(100)).await;
11876 let stats = session.torrent_stats(info_hash).await.unwrap();
11877 assert_ne!(
11878 stats.state,
11879 TorrentState::Paused,
11880 "explicit .paused(false) must override default_add_paused=true; \
11881 got state={:?}",
11882 stats.state
11883 );
11884
11885 session.shutdown().await.unwrap();
11886 }
11887
11888 #[tokio::test]
11892 async fn add_torrent_with_explicit_paused_true_pauses_despite_default_false() {
11893 let mut settings = test_settings();
11894 settings.default_add_paused = false;
11895 let session = SessionHandle::start(settings).await.unwrap();
11896
11897 let data = vec![0xEF; 16384];
11898 let bytes = m226_make_torrent_bytes(&data, 16384);
11899 let info_hash = session
11900 .add_torrent(AddTorrentParams::bytes(bytes).paused(true))
11901 .await
11902 .unwrap();
11903
11904 tokio::time::sleep(Duration::from_millis(100)).await;
11905 let stats = session.torrent_stats(info_hash).await.unwrap();
11906 assert_eq!(
11907 stats.state,
11908 TorrentState::Paused,
11909 "explicit .paused(true) must pause even when \
11910 default_add_paused=false"
11911 );
11912
11913 session.shutdown().await.unwrap();
11914 }
11915}