Skip to main content

irontide_session/
tracker_manager.rs

1//! Per-torrent tracker announce lifecycle management.
2//!
3//! Parses tracker URLs from torrent metadata, manages announce intervals,
4//! and handles exponential backoff on failure.
5
6use std::net::SocketAddr;
7use std::time::{Duration, Instant};
8
9use serde::Serialize;
10use tokio::sync::mpsc;
11use tracing::{debug, warn};
12
13use irontide_core::{Id20, InfoHashes, TorrentMetaV1};
14use irontide_tracker::{AnnounceEvent, AnnounceRequest, HttpTracker, UdpTracker};
15
16/// Maximum backoff duration for failed trackers.
17const MAX_BACKOFF: Duration = Duration::from_secs(30 * 60); // 30 minutes
18
19/// Initial backoff duration after a tracker failure.
20const INITIAL_BACKOFF: Duration = Duration::from_secs(30);
21
22/// Default re-announce interval if tracker doesn't specify one.
23const DEFAULT_INTERVAL: Duration = Duration::from_secs(30 * 60); // 30 minutes
24
25/// Protocol type for a tracker URL.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum TrackerProtocol {
28    Http,
29    Udp,
30}
31
32/// State of a single tracker.
33#[derive(Debug, Clone)]
34enum TrackerState {
35    /// Ready for announce.
36    NeedsAnnounce,
37    /// Successfully announced, waiting for re-announce.
38    Active,
39    /// Failed, backing off.
40    Failed { _error: String },
41}
42
43/// A single tracker entry with its state.
44#[derive(Debug, Clone)]
45struct TrackerEntry {
46    url: String,
47    tier: usize,
48    protocol: TrackerProtocol,
49    state: TrackerState,
50    tracker_id: Option<String>,
51    next_announce: Instant,
52    interval: Duration,
53    backoff: Duration,
54    scrape_info: Option<irontide_tracker::ScrapeInfo>,
55    consecutive_failures: u32,
56}
57
58/// Public tracker status (simplified view of internal TrackerState).
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60pub enum TrackerStatus {
61    /// Tracker has not been contacted yet.
62    NotContacted,
63    /// Last announce succeeded.
64    Working,
65    /// Last announce failed.
66    Error,
67}
68
69/// Public info about a single tracker.
70#[derive(Debug, Clone, Serialize)]
71pub struct TrackerInfo {
72    /// Tracker announce URL.
73    pub url: String,
74    /// Tier index (lower = higher priority).
75    pub tier: usize,
76    /// Current status of this tracker.
77    pub status: TrackerStatus,
78    /// Number of seeders reported by the tracker (from scrape).
79    pub seeders: Option<u32>,
80    /// Number of leechers reported by the tracker (from scrape).
81    pub leechers: Option<u32>,
82    /// Total completed downloads reported by the tracker (from scrape).
83    pub downloaded: Option<u32>,
84    /// Seconds until the next scheduled announce.
85    pub next_announce_secs: u64,
86    /// Number of consecutive announce failures.
87    pub consecutive_failures: u32,
88}
89
90/// Per-tracker announce outcome (success with peer count, or error message).
91#[derive(Debug, Clone)]
92pub(crate) struct TrackerOutcome {
93    pub url: String,
94    pub result: Result<usize, String>,
95}
96
97/// Result of announcing to all trackers: aggregated peers + per-tracker outcomes.
98#[derive(Debug, Clone)]
99pub(crate) struct AnnounceResult {
100    pub peers: Vec<SocketAddr>,
101    pub outcomes: Vec<TrackerOutcome>,
102}
103
104/// M143: Single tracker's announce result, streamed back to the actor.
105///
106/// Each batch contains the result from one tracker announce — either a list of
107/// peer addresses on success, or an error. The `tracker_idx` field identifies
108/// which tracker entry to update.
109#[derive(Debug)]
110pub(crate) struct TrackerPeerBatch {
111    /// Index into `TrackerManager::trackers` for state updates.
112    pub tracker_idx: usize,
113    /// Tracker announce URL (for alert reporting).
114    pub url: String,
115    /// Result: peers + interval + tracker_id + seeders + leechers, or error.
116    pub result: Result<AnnounceOk, irontide_tracker::Error>,
117}
118
119/// Successful announce response from a single tracker.
120pub(crate) type AnnounceOk = (
121    Vec<SocketAddr>,
122    u32,
123    Option<String>,
124    Option<u32>,
125    Option<u32>,
126);
127
128/// Per-torrent tracker manager.
129///
130/// Handles the announce lifecycle for all trackers associated with a torrent:
131/// parsing URLs from metadata, scheduling announces, and managing backoff.
132pub(crate) struct TrackerManager {
133    trackers: Vec<TrackerEntry>,
134    info_hash: Id20,
135    info_hashes: InfoHashes,
136    peer_id: Id20,
137    port: u16,
138    http_client: HttpTracker,
139    udp_client: UdpTracker,
140    anonymous_mode: bool,
141    dscp: u8,
142    /// I2P destination Base64 for BEP 7 tracker announces.
143    i2p_destination: Option<String>,
144}
145
146impl TrackerManager {
147    /// Create a TrackerManager from torrent metadata (unfiltered, for tests only).
148    ///
149    /// Parses `announce` and `announce_list` (BEP 12) fields, deduplicates URLs,
150    /// and classifies each as HTTP or UDP.
151    #[cfg(test)]
152    pub fn from_torrent(meta: &TorrentMetaV1, peer_id: Id20, port: u16) -> Self {
153        let mut trackers = Vec::new();
154        let mut seen_urls = std::collections::HashSet::new();
155
156        // BEP 12: announce_list takes priority if present
157        if let Some(ref tiers) = meta.announce_list {
158            for (tier_idx, tier) in tiers.iter().enumerate() {
159                for url in tier {
160                    let url = url.trim().to_string();
161                    if url.is_empty() || !seen_urls.insert(url.clone()) {
162                        continue;
163                    }
164                    if let Some(protocol) = classify_url(&url) {
165                        trackers.push(TrackerEntry {
166                            url,
167                            tier: tier_idx,
168                            protocol,
169                            state: TrackerState::NeedsAnnounce,
170                            tracker_id: None,
171                            next_announce: Instant::now(),
172                            interval: DEFAULT_INTERVAL,
173                            backoff: Duration::ZERO,
174                            scrape_info: None,
175                            consecutive_failures: 0,
176                        });
177                    }
178                }
179            }
180        }
181
182        // Fallback: single announce URL (only if not already in announce_list)
183        if let Some(ref url) = meta.announce {
184            let url = url.trim().to_string();
185            if !url.is_empty()
186                && seen_urls.insert(url.clone())
187                && let Some(protocol) = classify_url(&url)
188            {
189                trackers.push(TrackerEntry {
190                    url,
191                    tier: if trackers.is_empty() {
192                        0
193                    } else {
194                        trackers.last().unwrap().tier + 1
195                    },
196                    protocol,
197                    state: TrackerState::NeedsAnnounce,
198                    tracker_id: None,
199                    next_announce: Instant::now(),
200                    interval: DEFAULT_INTERVAL,
201                    backoff: Duration::ZERO,
202                    scrape_info: None,
203                    consecutive_failures: 0,
204                });
205            }
206        }
207
208        TrackerManager {
209            trackers,
210            info_hash: meta.info_hash,
211            info_hashes: InfoHashes::v1_only(meta.info_hash),
212            peer_id,
213            port,
214            http_client: HttpTracker::new(),
215            udp_client: UdpTracker::new(),
216            anonymous_mode: false,
217            dscp: 0,
218            i2p_destination: None,
219        }
220    }
221
222    /// Create a TrackerManager from torrent metadata with URL security filtering.
223    ///
224    /// Same as [`from_torrent`](Self::from_torrent), but each URL is validated
225    /// through [`validate_tracker_url`](crate::url_guard::validate_tracker_url).
226    /// URLs that fail validation are logged at warn level and skipped.
227    #[allow(dead_code)] // Wired in during Task 3 (TorrentActor integration).
228    pub fn from_torrent_filtered(
229        meta: &TorrentMetaV1,
230        peer_id: Id20,
231        port: u16,
232        security: &crate::url_guard::UrlSecurityConfig,
233        dscp: u8,
234        anonymous_mode: bool,
235    ) -> Self {
236        let mut trackers = Vec::new();
237        let mut seen_urls = std::collections::HashSet::new();
238
239        // BEP 12: announce_list takes priority if present
240        if let Some(ref tiers) = meta.announce_list {
241            for (tier_idx, tier) in tiers.iter().enumerate() {
242                for url in tier {
243                    let url = url.trim().to_string();
244                    if url.is_empty() || !seen_urls.insert(url.clone()) {
245                        continue;
246                    }
247                    if let Err(e) = crate::url_guard::validate_tracker_url(&url, security) {
248                        warn!(%url, %e, "tracker URL rejected by security policy");
249                        continue;
250                    }
251                    if let Some(protocol) = classify_url(&url) {
252                        trackers.push(TrackerEntry {
253                            url,
254                            tier: tier_idx,
255                            protocol,
256                            state: TrackerState::NeedsAnnounce,
257                            tracker_id: None,
258                            next_announce: Instant::now(),
259                            interval: DEFAULT_INTERVAL,
260                            backoff: Duration::ZERO,
261                            scrape_info: None,
262                            consecutive_failures: 0,
263                        });
264                    }
265                }
266            }
267        }
268
269        // Fallback: single announce URL (only if not already in announce_list)
270        if let Some(ref url) = meta.announce {
271            let url = url.trim().to_string();
272            if !url.is_empty() && seen_urls.insert(url.clone()) {
273                if let Err(e) = crate::url_guard::validate_tracker_url(&url, security) {
274                    warn!(%url, %e, "tracker URL rejected by security policy");
275                } else if let Some(protocol) = classify_url(&url) {
276                    trackers.push(TrackerEntry {
277                        url,
278                        tier: if trackers.is_empty() {
279                            0
280                        } else {
281                            trackers.last().unwrap().tier + 1
282                        },
283                        protocol,
284                        state: TrackerState::NeedsAnnounce,
285                        tracker_id: None,
286                        next_announce: Instant::now(),
287                        interval: DEFAULT_INTERVAL,
288                        backoff: Duration::ZERO,
289                        scrape_info: None,
290                        consecutive_failures: 0,
291                    });
292                }
293            }
294        }
295
296        TrackerManager {
297            trackers,
298            info_hash: meta.info_hash,
299            info_hashes: InfoHashes::v1_only(meta.info_hash),
300            peer_id,
301            port,
302            http_client: if anonymous_mode {
303                HttpTracker::with_anonymous()
304            } else {
305                HttpTracker::new()
306            },
307            udp_client: UdpTracker::new().with_dscp(dscp),
308            anonymous_mode,
309            dscp,
310            i2p_destination: None,
311        }
312    }
313
314    /// Create an empty TrackerManager (for magnet links before metadata arrives).
315    pub fn empty(
316        info_hash: Id20,
317        peer_id: Id20,
318        port: u16,
319        dscp: u8,
320        anonymous_mode: bool,
321    ) -> Self {
322        TrackerManager {
323            trackers: Vec::new(),
324            info_hash,
325            info_hashes: InfoHashes::v1_only(info_hash),
326            peer_id,
327            port,
328            http_client: if anonymous_mode {
329                HttpTracker::with_anonymous()
330            } else {
331                HttpTracker::new()
332            },
333            udp_client: UdpTracker::new().with_dscp(dscp),
334            anonymous_mode,
335            dscp,
336            i2p_destination: None,
337        }
338    }
339
340    /// Populate trackers from metadata once it's been fetched (unfiltered, for tests only).
341    #[cfg(test)]
342    pub fn set_metadata(&mut self, meta: &TorrentMetaV1) {
343        let fresh = Self::from_torrent(meta, self.peer_id, self.port);
344        self.trackers = fresh.trackers;
345    }
346
347    /// Populate trackers from metadata with URL security filtering (magnet link flow).
348    pub fn set_metadata_filtered(
349        &mut self,
350        meta: &TorrentMetaV1,
351        security: &crate::url_guard::UrlSecurityConfig,
352    ) {
353        let fresh = Self::from_torrent_filtered(
354            meta,
355            self.peer_id,
356            self.port,
357            security,
358            self.dscp,
359            self.anonymous_mode,
360        );
361        self.trackers = fresh.trackers;
362    }
363
364    /// Set the full info hashes for dual-swarm support (hybrid torrents).
365    pub fn set_info_hashes(&mut self, info_hashes: InfoHashes) {
366        self.info_hashes = info_hashes;
367    }
368
369    /// Set the I2P destination Base64 string for BEP 7 tracker announces.
370    pub fn set_i2p_destination(&mut self, dest: Option<String>) {
371        self.i2p_destination = dest;
372    }
373
374    /// Number of configured trackers.
375    #[cfg(test)]
376    pub fn tracker_count(&self) -> usize {
377        self.trackers.len()
378    }
379
380    /// Duration until the next tracker needs an announce.
381    ///
382    /// Returns `None` if there are no trackers.
383    pub fn next_announce_in(&self) -> Option<Duration> {
384        self.trackers
385            .iter()
386            .map(|t| t.next_announce.saturating_duration_since(Instant::now()))
387            .min()
388    }
389
390    /// Announce to all trackers that are due.
391    ///
392    /// For hybrid torrents, announces both v1 and v2 info hashes separately
393    /// to reach peers in both swarms. Returns all discovered peer addresses (deduplicated).
394    pub async fn announce(
395        &mut self,
396        event: AnnounceEvent,
397        uploaded: u64,
398        downloaded: u64,
399        left: u64,
400    ) -> AnnounceResult {
401        let mut all_peers = Vec::new();
402        let mut seen_peers = std::collections::HashSet::new();
403        let mut all_outcomes = Vec::new();
404
405        // Always announce with the primary (v1) info hash
406        let result = self
407            .announce_with_hash(self.info_hash, event, uploaded, downloaded, left)
408            .await;
409        for peer in result.peers {
410            if seen_peers.insert(peer) {
411                all_peers.push(peer);
412            }
413        }
414        all_outcomes.extend(result.outcomes);
415
416        // Dual-swarm: also announce with v2 hash (truncated) if hybrid
417        if self.info_hashes.is_hybrid()
418            && let Some(v2) = self.info_hashes.v2
419        {
420            let v2_as_v1 = Id20(v2.0[..20].try_into().unwrap());
421            // Only announce the v2 hash if it differs from the v1 hash
422            if v2_as_v1 != self.info_hash {
423                let result = self
424                    .announce_with_hash(v2_as_v1, event, uploaded, downloaded, left)
425                    .await;
426                for peer in result.peers {
427                    if seen_peers.insert(peer) {
428                        all_peers.push(peer);
429                    }
430                }
431                all_outcomes.extend(result.outcomes);
432            }
433        }
434
435        AnnounceResult {
436            peers: all_peers,
437            outcomes: all_outcomes,
438        }
439    }
440
441    /// Returns the port to announce (0 when anonymous mode is active).
442    fn announce_port(&self) -> u16 {
443        if self.anonymous_mode { 0 } else { self.port }
444    }
445
446    /// Internal: announce with a specific info hash to all due trackers.
447    async fn announce_with_hash(
448        &mut self,
449        hash: Id20,
450        event: AnnounceEvent,
451        uploaded: u64,
452        downloaded: u64,
453        left: u64,
454    ) -> AnnounceResult {
455        let req = AnnounceRequest {
456            info_hash: hash,
457            peer_id: self.peer_id,
458            port: self.announce_port(),
459            uploaded,
460            downloaded,
461            left,
462            event,
463            num_want: None,
464            compact: true,
465            i2p_destination: self.i2p_destination.clone(),
466        };
467        let now = Instant::now();
468
469        // Spawn all eligible tracker announces in parallel
470        let mut join_set =
471            tokio::task::JoinSet::<(usize, Result<AnnounceOk, irontide_tracker::Error>)>::new();
472
473        for (idx, tracker) in self.trackers.iter().enumerate() {
474            // For the secondary (v2) hash, always announce (don't skip based on next_announce,
475            // since the timer tracks the primary hash). For the primary hash, respect the timer.
476            if hash == self.info_hash && tracker.next_announce > now {
477                continue;
478            }
479
480            let http_client = self.http_client.clone();
481            let udp_client = self.udp_client.clone();
482            let url = tracker.url.clone();
483            let protocol = tracker.protocol;
484            let req = req.clone();
485
486            join_set.spawn(async move {
487                let result = match protocol {
488                    TrackerProtocol::Http => Self::announce_http(&http_client, &url, &req).await,
489                    TrackerProtocol::Udp => Self::announce_udp(&udp_client, &url, &req).await,
490                };
491                (idx, result)
492            });
493        }
494
495        // Collect results and update tracker state
496        let mut all_peers = Vec::new();
497        let mut seen_peers = std::collections::HashSet::new();
498        let mut outcomes = Vec::new();
499
500        while let Some(Ok((idx, result))) = join_set.join_next().await {
501            let tracker = &mut self.trackers[idx];
502            match result {
503                Ok((peers, interval, tracker_id, seeders, leechers)) => {
504                    let num_peers = peers.len();
505                    debug!(
506                        url = %tracker.url,
507                        peer_count = num_peers,
508                        interval,
509                        %hash,
510                        "tracker announce success"
511                    );
512                    // Only update tracker state for the primary hash
513                    if hash == self.info_hash {
514                        tracker.state = TrackerState::Active;
515                        tracker.interval = Duration::from_secs(interval as u64);
516                        tracker.next_announce = now + tracker.interval;
517                        tracker.backoff = Duration::ZERO;
518                        tracker.consecutive_failures = 0;
519                        if let Some(id) = tracker_id {
520                            tracker.tracker_id = Some(id);
521                        }
522                        if seeders.is_some() || leechers.is_some() {
523                            let prev_downloaded =
524                                tracker.scrape_info.map(|s| s.downloaded).unwrap_or(0);
525                            tracker.scrape_info = Some(irontide_tracker::ScrapeInfo {
526                                complete: seeders.unwrap_or(0),
527                                incomplete: leechers.unwrap_or(0),
528                                downloaded: prev_downloaded,
529                            });
530                        }
531                    }
532
533                    for peer in peers {
534                        if seen_peers.insert(peer) {
535                            all_peers.push(peer);
536                        }
537                    }
538                    outcomes.push(TrackerOutcome {
539                        url: tracker.url.clone(),
540                        result: Ok(num_peers),
541                    });
542                }
543                Err(e) => {
544                    let msg = e.to_string();
545                    warn!(url = %tracker.url, error = %msg, %hash, "tracker announce failed");
546                    // Only update failure state for the primary hash
547                    if hash == self.info_hash {
548                        tracker.state = TrackerState::Failed {
549                            _error: msg.clone(),
550                        };
551                        tracker.consecutive_failures += 1;
552                        tracker.backoff = if tracker.backoff.is_zero() {
553                            INITIAL_BACKOFF
554                        } else {
555                            (tracker.backoff * 2).min(MAX_BACKOFF)
556                        };
557                        tracker.next_announce = now + tracker.backoff;
558                    }
559                    outcomes.push(TrackerOutcome {
560                        url: tracker.url.clone(),
561                        result: Err(msg),
562                    });
563                }
564            }
565        }
566
567        AnnounceResult {
568            peers: all_peers,
569            outcomes,
570        }
571    }
572
573    /// Convenience: announce with Started event.
574    #[allow(dead_code)]
575    pub async fn announce_started(
576        &mut self,
577        uploaded: u64,
578        downloaded: u64,
579        left: u64,
580    ) -> AnnounceResult {
581        self.announce(AnnounceEvent::Started, uploaded, downloaded, left)
582            .await
583    }
584
585    /// Convenience: announce with Completed event.
586    pub async fn announce_completed(&mut self, uploaded: u64, downloaded: u64) -> AnnounceResult {
587        self.announce(AnnounceEvent::Completed, uploaded, downloaded, 0)
588            .await
589    }
590
591    /// Convenience: announce with Stopped event (best-effort, errors ignored).
592    pub async fn announce_stopped(&mut self, uploaded: u64, downloaded: u64, left: u64) {
593        let _ = self
594            .announce(AnnounceEvent::Stopped, uploaded, downloaded, left)
595            .await;
596    }
597
598    // ---- M143: Non-blocking streaming announce ----
599
600    /// Start a non-blocking announce that streams results through a channel.
601    ///
602    /// Spawns tracker requests as a background tokio task. Each tracker's
603    /// response is sent as a [`TrackerPeerBatch`] through the returned receiver
604    /// as soon as that tracker responds. The actor's `select!` loop is never
605    /// blocked — it processes each batch via [`process_tracker_result`].
606    ///
607    /// For hybrid torrents, both v1 and v2 (truncated) info hashes are
608    /// announced so peers from both swarms are discovered.
609    pub fn start_announce(
610        &mut self,
611        event: AnnounceEvent,
612        uploaded: u64,
613        downloaded: u64,
614        left: u64,
615    ) -> mpsc::Receiver<TrackerPeerBatch> {
616        let (tx, rx) = mpsc::channel(32);
617        let now = Instant::now();
618
619        // Collect due trackers into a vec we can move into the spawned task.
620        let mut tasks: Vec<(usize, String, TrackerProtocol)> = Vec::new();
621        for (idx, tracker) in self.trackers.iter().enumerate() {
622            if tracker.next_announce > now {
623                continue;
624            }
625            tasks.push((idx, tracker.url.clone(), tracker.protocol));
626        }
627
628        // Mark all due trackers as "announcing" — push next_announce far enough
629        // ahead to prevent double-announce before results come back. The actual
630        // interval will be set when `process_tracker_result` handles the response.
631        for &(idx, _, _) in &tasks {
632            self.trackers[idx].next_announce = now + Duration::from_secs(120);
633        }
634
635        // Nothing to do — drop tx immediately so rx returns None.
636        if tasks.is_empty() {
637            return rx;
638        }
639
640        let info_hash = self.info_hash;
641        let info_hashes = self.info_hashes.clone();
642        let peer_id = self.peer_id;
643        let port = self.announce_port();
644        let i2p_dest = self.i2p_destination.clone();
645        let http_client = self.http_client.clone();
646        let udp_client = self.udp_client.clone();
647
648        tokio::spawn(async move {
649            let req = AnnounceRequest {
650                info_hash,
651                peer_id,
652                port,
653                uploaded,
654                downloaded,
655                left,
656                event,
657                num_want: None,
658                compact: true,
659                i2p_destination: i2p_dest.clone(),
660            };
661
662            // Primary (v1) info hash
663            Self::spawn_tracker_announces(&tx, &tasks, &req, &http_client, &udp_client).await;
664
665            // Dual-swarm: also announce with v2 hash (truncated) if hybrid
666            if info_hashes.is_hybrid()
667                && let Some(v2) = info_hashes.v2
668            {
669                let v2_as_v1 = Id20(v2.0[..20].try_into().expect("Id32 always has >= 20 bytes"));
670                if v2_as_v1 != info_hash {
671                    let mut v2_req = req;
672                    v2_req.info_hash = v2_as_v1;
673                    Self::spawn_tracker_announces(&tx, &tasks, &v2_req, &http_client, &udp_client)
674                        .await;
675                }
676            }
677            // tx drops here → rx returns None → actor clears tracker_result_rx
678        });
679
680        rx
681    }
682
683    /// Spawn a JoinSet of tracker announces for one info hash and stream
684    /// results through `tx`. This is an internal helper for `start_announce`.
685    async fn spawn_tracker_announces(
686        tx: &mpsc::Sender<TrackerPeerBatch>,
687        tasks: &[(usize, String, TrackerProtocol)],
688        req: &AnnounceRequest,
689        http_client: &HttpTracker,
690        udp_client: &UdpTracker,
691    ) {
692        let mut join_set = tokio::task::JoinSet::new();
693
694        for &(idx, ref url, protocol) in tasks {
695            let http = http_client.clone();
696            let udp = udp_client.clone();
697            let url = url.clone();
698            let req = req.clone();
699
700            join_set.spawn(async move {
701                let result = match protocol {
702                    TrackerProtocol::Http => Self::announce_http(&http, &url, &req).await,
703                    TrackerProtocol::Udp => Self::announce_udp(&udp, &url, &req).await,
704                };
705                TrackerPeerBatch {
706                    tracker_idx: idx,
707                    url,
708                    result,
709                }
710            });
711        }
712
713        while let Some(join_result) = join_set.join_next().await {
714            if let Ok(batch) = join_result {
715                // If the actor dropped its rx (e.g. torrent stopped), bail out.
716                if tx.send(batch).await.is_err() {
717                    break;
718                }
719            }
720        }
721    }
722
723    /// Process a single streaming tracker result.
724    ///
725    /// Updates tracker state (interval, backoff, scrape info) and returns
726    /// the discovered peers along with a `TrackerOutcome` for alert firing.
727    pub fn process_tracker_result(
728        &mut self,
729        batch: TrackerPeerBatch,
730    ) -> (Vec<SocketAddr>, TrackerOutcome) {
731        let Some(tracker) = self.trackers.get_mut(batch.tracker_idx) else {
732            // Tracker was removed while announce was in-flight (e.g. replace_all).
733            return (
734                Vec::new(),
735                TrackerOutcome {
736                    url: batch.url,
737                    result: Err("tracker removed during announce".to_string()),
738                },
739            );
740        };
741
742        match batch.result {
743            Ok((peers, interval, tracker_id, seeders, leechers)) => {
744                let num_peers = peers.len();
745                debug!(
746                    url = %tracker.url,
747                    peer_count = num_peers,
748                    interval,
749                    "streaming tracker announce success"
750                );
751                tracker.state = TrackerState::Active;
752                tracker.interval = Duration::from_secs(u64::from(interval));
753                tracker.next_announce = Instant::now() + tracker.interval;
754                tracker.backoff = Duration::ZERO;
755                tracker.consecutive_failures = 0;
756                if let Some(id) = tracker_id {
757                    tracker.tracker_id = Some(id);
758                }
759                if seeders.is_some() || leechers.is_some() {
760                    let prev_downloaded = tracker.scrape_info.map(|s| s.downloaded).unwrap_or(0);
761                    tracker.scrape_info = Some(irontide_tracker::ScrapeInfo {
762                        complete: seeders.unwrap_or(0),
763                        incomplete: leechers.unwrap_or(0),
764                        downloaded: prev_downloaded,
765                    });
766                }
767
768                let outcome = TrackerOutcome {
769                    url: batch.url,
770                    result: Ok(num_peers),
771                };
772                (peers, outcome)
773            }
774            Err(e) => {
775                let msg = e.to_string();
776                warn!(url = %tracker.url, error = %msg, "streaming tracker announce failed");
777                tracker.state = TrackerState::Failed {
778                    _error: msg.clone(),
779                };
780                tracker.consecutive_failures = tracker.consecutive_failures.saturating_add(1);
781                tracker.backoff = if tracker.backoff.is_zero() {
782                    INITIAL_BACKOFF
783                } else {
784                    (tracker.backoff.saturating_mul(2)).min(MAX_BACKOFF)
785                };
786                tracker.next_announce = Instant::now() + tracker.backoff;
787
788                let outcome = TrackerOutcome {
789                    url: batch.url,
790                    result: Err(msg),
791                };
792                (Vec::new(), outcome)
793            }
794        }
795    }
796
797    // ---- Internal announce helpers ----
798
799    async fn announce_http(
800        client: &HttpTracker,
801        url: &str,
802        req: &AnnounceRequest,
803    ) -> Result<AnnounceOk, irontide_tracker::Error> {
804        let resp = client.announce(url, req).await?;
805        Ok((
806            resp.response.peers,
807            resp.response.interval,
808            resp.tracker_id,
809            resp.response.seeders,
810            resp.response.leechers,
811        ))
812    }
813
814    async fn announce_udp(
815        client: &UdpTracker,
816        url: &str,
817        req: &AnnounceRequest,
818    ) -> Result<AnnounceOk, irontide_tracker::Error> {
819        // UDP tracker URLs are like "udp://tracker.example.com:6969/announce"
820        // UdpTracker::announce expects "host:port"
821        let addr = parse_udp_addr(url);
822        let resp = client.announce(&addr, req).await?;
823        Ok((
824            resp.response.peers,
825            resp.response.interval,
826            None,
827            resp.response.seeders,
828            resp.response.leechers,
829        ))
830    }
831
832    // ---- New public methods ----
833
834    /// Get a list of all configured trackers with their status.
835    pub fn tracker_list(&self) -> Vec<TrackerInfo> {
836        self.trackers
837            .iter()
838            .map(|t| {
839                let status = match t.state {
840                    TrackerState::NeedsAnnounce => TrackerStatus::NotContacted,
841                    TrackerState::Active => TrackerStatus::Working,
842                    TrackerState::Failed { .. } => TrackerStatus::Error,
843                };
844                TrackerInfo {
845                    url: t.url.clone(),
846                    tier: t.tier,
847                    status,
848                    seeders: t.scrape_info.map(|s| s.complete),
849                    leechers: t.scrape_info.map(|s| s.incomplete),
850                    downloaded: t.scrape_info.map(|s| s.downloaded),
851                    next_announce_secs: t
852                        .next_announce
853                        .saturating_duration_since(Instant::now())
854                        .as_secs(),
855                    consecutive_failures: t.consecutive_failures,
856                }
857            })
858            .collect()
859    }
860
861    /// Force all trackers to re-announce immediately.
862    pub fn force_reannounce(&mut self) {
863        let now = Instant::now();
864        for tracker in &mut self.trackers {
865            tracker.next_announce = now;
866        }
867    }
868
869    /// Add a new tracker URL (e.g. from lt_trackers exchange).
870    ///
871    /// Returns `true` if the URL was added, `false` if empty, unknown protocol, or duplicate.
872    pub fn add_tracker_url(&mut self, url: &str) -> bool {
873        let url = url.trim();
874        if url.is_empty() {
875            return false;
876        }
877        let Some(protocol) = classify_url(url) else {
878            return false;
879        };
880        // Deduplicate
881        if self.trackers.iter().any(|t| t.url == url) {
882            return false;
883        }
884        let new_tier = self.trackers.last().map(|t| t.tier + 1).unwrap_or(0);
885        self.trackers.push(TrackerEntry {
886            url: url.to_string(),
887            tier: new_tier,
888            protocol,
889            state: TrackerState::NeedsAnnounce,
890            tracker_id: None,
891            next_announce: Instant::now(),
892            interval: DEFAULT_INTERVAL,
893            backoff: Duration::ZERO,
894            scrape_info: None,
895            consecutive_failures: 0,
896        });
897        true
898    }
899
900    /// Replace all trackers with a new set of URLs.
901    ///
902    /// Clears the existing tracker list and adds each URL via `add_tracker_url`,
903    /// which handles validation and deduplication.
904    pub fn replace_all(&mut self, urls: Vec<String>) {
905        self.trackers.clear();
906        for url in &urls {
907            self.add_tracker_url(url);
908        }
909    }
910
911    /// Add a new tracker URL with URL security validation.
912    ///
913    /// Returns `true` if the URL was added, `false` if it failed validation,
914    /// was empty, had an unknown protocol, or was a duplicate.
915    #[allow(dead_code)] // Wired in during Task 3 (TorrentActor integration).
916    pub fn add_tracker_url_validated(
917        &mut self,
918        url: &str,
919        security: &crate::url_guard::UrlSecurityConfig,
920    ) -> bool {
921        let url = url.trim();
922        if url.is_empty() {
923            return false;
924        }
925        if let Err(e) = crate::url_guard::validate_tracker_url(url, security) {
926            warn!(%url, %e, "tracker URL rejected by security policy");
927            return false;
928        }
929        self.add_tracker_url(url)
930    }
931
932    /// Scrape trackers to get seeder/leecher counts.
933    ///
934    /// Tries each tracker until one succeeds. Returns `(url, ScrapeInfo)` from first success.
935    pub async fn scrape(&self) -> Option<(String, irontide_tracker::ScrapeInfo)> {
936        for tracker in &self.trackers {
937            let result = match tracker.protocol {
938                TrackerProtocol::Http => self
939                    .http_client
940                    .scrape(&tracker.url, &[self.info_hash])
941                    .await
942                    .ok()
943                    .and_then(|resp| resp.files.get(&self.info_hash).copied()),
944                TrackerProtocol::Udp => {
945                    let addr = parse_udp_addr(&tracker.url);
946                    self.udp_client
947                        .scrape(&addr, &[self.info_hash])
948                        .await
949                        .ok()
950                        .and_then(|resp| resp.results.into_iter().next())
951                }
952            };
953            if let Some(info) = result {
954                return Some((tracker.url.clone(), info));
955            }
956        }
957        None
958    }
959}
960
961/// Classify a tracker URL as HTTP or UDP.
962fn classify_url(url: &str) -> Option<TrackerProtocol> {
963    if url.starts_with("http://") || url.starts_with("https://") {
964        Some(TrackerProtocol::Http)
965    } else if url.starts_with("udp://") {
966        Some(TrackerProtocol::Udp)
967    } else {
968        None // Unknown protocol, skip
969    }
970}
971
972/// Extract "host:port" from a UDP tracker URL.
973///
974/// Input: "udp://tracker.example.com:6969/announce"
975/// Output: "tracker.example.com:6969"
976fn parse_udp_addr(url: &str) -> String {
977    let without_scheme = url.strip_prefix("udp://").unwrap_or(url);
978    // Strip path (everything after host:port)
979    match without_scheme.find('/') {
980        Some(idx) => without_scheme[..idx].to_string(),
981        None => without_scheme.to_string(),
982    }
983}
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988    use irontide_core::{Id20, InfoHashes};
989
990    /// Helper to build a minimal TorrentMetaV1 with given tracker URLs.
991    fn torrent_with_trackers(
992        announce: Option<&str>,
993        announce_list: Option<Vec<Vec<&str>>>,
994    ) -> TorrentMetaV1 {
995        use serde::Serialize;
996
997        let data = vec![0u8; 16384];
998        let hash = irontide_core::sha1(&data);
999        let mut pieces = Vec::new();
1000        pieces.extend_from_slice(hash.as_bytes());
1001
1002        #[derive(Serialize)]
1003        struct Info<'a> {
1004            length: u64,
1005            name: &'a str,
1006            #[serde(rename = "piece length")]
1007            piece_length: u64,
1008            #[serde(with = "serde_bytes")]
1009            pieces: &'a [u8],
1010        }
1011
1012        #[derive(Serialize)]
1013        struct Torrent<'a> {
1014            #[serde(skip_serializing_if = "Option::is_none")]
1015            announce: Option<&'a str>,
1016            info: Info<'a>,
1017        }
1018
1019        let t = Torrent {
1020            announce,
1021            info: Info {
1022                length: 16384,
1023                name: "test",
1024                piece_length: 16384,
1025                pieces: &pieces,
1026            },
1027        };
1028
1029        let bytes = irontide_bencode::to_bytes(&t).unwrap();
1030        let mut meta = irontide_core::torrent_from_bytes(&bytes).unwrap();
1031        meta.announce_list = announce_list.map(|tiers| {
1032            tiers
1033                .into_iter()
1034                .map(|tier| tier.into_iter().map(String::from).collect())
1035                .collect()
1036        });
1037        if announce.is_some() {
1038            meta.announce = announce.map(String::from);
1039        }
1040        meta
1041    }
1042
1043    fn test_peer_id() -> Id20 {
1044        Id20::from_hex("0102030405060708091011121314151617181920").unwrap()
1045    }
1046
1047    #[test]
1048    fn parse_single_announce_url() {
1049        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1050        let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1051        assert_eq!(mgr.tracker_count(), 1);
1052        assert_eq!(mgr.trackers[0].protocol, TrackerProtocol::Http);
1053        assert_eq!(mgr.trackers[0].tier, 0);
1054    }
1055
1056    #[test]
1057    fn parse_announce_list_tiers() {
1058        let meta = torrent_with_trackers(
1059            None,
1060            Some(vec![
1061                vec![
1062                    "http://tier0-a.example.com/announce",
1063                    "http://tier0-b.example.com/announce",
1064                ],
1065                vec!["udp://tier1.example.com:6969/announce"],
1066            ]),
1067        );
1068        let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1069        assert_eq!(mgr.tracker_count(), 3);
1070        assert_eq!(mgr.trackers[0].tier, 0);
1071        assert_eq!(mgr.trackers[1].tier, 0);
1072        assert_eq!(mgr.trackers[2].tier, 1);
1073        assert_eq!(mgr.trackers[2].protocol, TrackerProtocol::Udp);
1074    }
1075
1076    #[test]
1077    fn classify_http_url() {
1078        assert_eq!(classify_url("http://t.co/a"), Some(TrackerProtocol::Http));
1079        assert_eq!(classify_url("https://t.co/a"), Some(TrackerProtocol::Http));
1080    }
1081
1082    #[test]
1083    fn classify_udp_url() {
1084        assert_eq!(
1085            classify_url("udp://t.co:6969/a"),
1086            Some(TrackerProtocol::Udp)
1087        );
1088    }
1089
1090    #[test]
1091    fn classify_unknown_url() {
1092        assert_eq!(classify_url("wss://t.co/a"), None);
1093    }
1094
1095    #[test]
1096    fn deduplicate_urls() {
1097        let meta = torrent_with_trackers(
1098            Some("http://tracker.example.com/announce"),
1099            Some(vec![vec!["http://tracker.example.com/announce"]]),
1100        );
1101        let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1102        // URL appears in both announce and announce_list — should be deduplicated
1103        assert_eq!(mgr.tracker_count(), 1);
1104    }
1105
1106    #[test]
1107    fn empty_announce_list() {
1108        let meta = torrent_with_trackers(None, None);
1109        let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1110        assert_eq!(mgr.tracker_count(), 0);
1111        assert_eq!(mgr.next_announce_in(), None);
1112    }
1113
1114    #[test]
1115    fn next_announce_timing() {
1116        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1117        let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1118        // Newly created — should be ready to announce immediately
1119        let next = mgr.next_announce_in().unwrap();
1120        assert!(next <= Duration::from_millis(10));
1121    }
1122
1123    #[test]
1124    fn backoff_on_failure() {
1125        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1126        let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1127
1128        // Simulate a failure by directly setting state
1129        mgr.trackers[0].state = TrackerState::Failed {
1130            _error: "connection refused".into(),
1131        };
1132        mgr.trackers[0].backoff = INITIAL_BACKOFF;
1133        mgr.trackers[0].next_announce = Instant::now() + INITIAL_BACKOFF;
1134
1135        let next = mgr.next_announce_in().unwrap();
1136        // Should be approximately INITIAL_BACKOFF (30s), give or take
1137        assert!(next >= Duration::from_secs(29));
1138        assert!(next <= Duration::from_secs(31));
1139    }
1140
1141    #[test]
1142    fn backoff_max_cap() {
1143        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1144        let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1145
1146        // Simulate many failures — backoff should cap at MAX_BACKOFF
1147        mgr.trackers[0].backoff = Duration::from_secs(20 * 60); // 20 min
1148        // Double would be 40 min, but cap is 30 min
1149        let doubled = (mgr.trackers[0].backoff * 2).min(MAX_BACKOFF);
1150        assert_eq!(doubled, MAX_BACKOFF);
1151    }
1152
1153    #[test]
1154    fn parse_udp_addr_strips_scheme_and_path() {
1155        assert_eq!(
1156            parse_udp_addr("udp://tracker.example.com:6969/announce"),
1157            "tracker.example.com:6969"
1158        );
1159        assert_eq!(parse_udp_addr("udp://example.com:1234"), "example.com:1234");
1160    }
1161
1162    #[test]
1163    fn empty_manager_for_magnet() {
1164        let info_hash = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
1165        let mgr = TrackerManager::empty(info_hash, test_peer_id(), 6881, 0, false);
1166        assert_eq!(mgr.tracker_count(), 0);
1167    }
1168
1169    #[test]
1170    fn set_metadata_populates_trackers() {
1171        let info_hash = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
1172        let mut mgr = TrackerManager::empty(info_hash, test_peer_id(), 6881, 0, false);
1173        assert_eq!(mgr.tracker_count(), 0);
1174
1175        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1176        mgr.set_metadata(&meta);
1177        assert_eq!(mgr.tracker_count(), 1);
1178    }
1179
1180    #[test]
1181    fn tracker_list_returns_info() {
1182        let meta = torrent_with_trackers(
1183            None,
1184            Some(vec![
1185                vec!["http://tracker1.example.com/announce"],
1186                vec!["udp://tracker2.example.com:6969/announce"],
1187            ]),
1188        );
1189        let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1190        let list = mgr.tracker_list();
1191        assert_eq!(list.len(), 2);
1192        assert_eq!(list[0].status, TrackerStatus::NotContacted);
1193        assert_eq!(list[0].seeders, None);
1194        assert_eq!(list[0].tier, 0);
1195        assert_eq!(list[1].tier, 1);
1196    }
1197
1198    #[test]
1199    fn force_reannounce_resets_timers() {
1200        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1201        let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1202        // Push next_announce far into the future
1203        mgr.trackers[0].next_announce = Instant::now() + Duration::from_secs(3600);
1204        assert!(mgr.next_announce_in().unwrap() > Duration::from_secs(3500));
1205        mgr.force_reannounce();
1206        let next = mgr.next_announce_in().unwrap();
1207        assert!(next <= Duration::from_millis(10));
1208    }
1209
1210    #[test]
1211    fn add_tracker_url_new() {
1212        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1213        let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1214        assert_eq!(mgr.tracker_count(), 1);
1215        let added = mgr.add_tracker_url("http://new-tracker.example.com/announce");
1216        assert!(added);
1217        assert_eq!(mgr.tracker_count(), 2);
1218        assert_eq!(mgr.trackers[1].tier, 1); // new tier
1219    }
1220
1221    #[test]
1222    fn add_tracker_url_duplicate() {
1223        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1224        let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1225        let added = mgr.add_tracker_url("http://tracker.example.com/announce");
1226        assert!(!added);
1227        assert_eq!(mgr.tracker_count(), 1);
1228    }
1229
1230    #[test]
1231    fn tracker_manager_stores_info_hashes() {
1232        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1233        let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1234        assert!(!mgr.info_hashes.is_hybrid());
1235
1236        let v2 = irontide_core::Id32::from_hex(
1237            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1238        )
1239        .unwrap();
1240        mgr.set_info_hashes(InfoHashes::hybrid(meta.info_hash, v2));
1241        assert!(mgr.info_hashes.is_hybrid());
1242    }
1243
1244    #[test]
1245    fn add_tracker_url_empty() {
1246        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1247        let mut mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1248        assert!(!mgr.add_tracker_url(""));
1249        assert!(!mgr.add_tracker_url("   "));
1250        assert_eq!(mgr.tracker_count(), 1);
1251    }
1252
1253    fn ssrf_config() -> crate::url_guard::UrlSecurityConfig {
1254        crate::url_guard::UrlSecurityConfig {
1255            ssrf_mitigation: true,
1256            allow_idna: false,
1257            validate_https_trackers: true,
1258        }
1259    }
1260
1261    #[test]
1262    fn localhost_tracker_announce_path_accepted() {
1263        // A localhost URL with /announce path should be accepted by the filter.
1264        let meta = torrent_with_trackers(Some("http://127.0.0.1:8080/announce"), None);
1265        let cfg = ssrf_config();
1266        let mgr =
1267            TrackerManager::from_torrent_filtered(&meta, test_peer_id(), 6881, &cfg, 0, false);
1268        assert_eq!(mgr.tracker_count(), 1);
1269        assert_eq!(mgr.trackers[0].url, "http://127.0.0.1:8080/announce");
1270    }
1271
1272    #[test]
1273    fn localhost_tracker_bad_path_filtered() {
1274        // A localhost URL with a non-/announce path should be rejected,
1275        // while a global URL should pass.
1276        let meta = torrent_with_trackers(
1277            None,
1278            Some(vec![vec![
1279                "http://127.0.0.1:8080/api/admin",
1280                "http://tracker.example.com/announce",
1281            ]]),
1282        );
1283        let cfg = ssrf_config();
1284        let mgr =
1285            TrackerManager::from_torrent_filtered(&meta, test_peer_id(), 6881, &cfg, 0, false);
1286        assert_eq!(mgr.tracker_count(), 1);
1287        assert_eq!(mgr.trackers[0].url, "http://tracker.example.com/announce");
1288    }
1289
1290    #[test]
1291    fn add_tracker_url_validates() {
1292        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1293        let cfg = ssrf_config();
1294        let mut mgr =
1295            TrackerManager::from_torrent_filtered(&meta, test_peer_id(), 6881, &cfg, 0, false);
1296        assert_eq!(mgr.tracker_count(), 1);
1297
1298        // Valid URL should be added.
1299        assert!(mgr.add_tracker_url_validated("http://other.example.com/announce", &cfg));
1300        assert_eq!(mgr.tracker_count(), 2);
1301
1302        // Localhost with bad path should be rejected.
1303        assert!(!mgr.add_tracker_url_validated("http://127.0.0.1:8080/api/admin", &cfg));
1304        assert_eq!(mgr.tracker_count(), 2);
1305
1306        // UDP URL should pass (UDP skips SSRF checks).
1307        assert!(mgr.add_tracker_url_validated("udp://tracker.example.com:6969/announce", &cfg));
1308        assert_eq!(mgr.tracker_count(), 3);
1309    }
1310
1311    #[test]
1312    fn anonymous_mode_zeroes_announce_port() {
1313        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1314        let cfg = ssrf_config();
1315        let mgr = TrackerManager::from_torrent_filtered(&meta, test_peer_id(), 6881, &cfg, 0, true);
1316        assert!(mgr.anonymous_mode);
1317        assert_eq!(mgr.announce_port(), 0);
1318    }
1319
1320    #[test]
1321    fn normal_mode_includes_port() {
1322        let meta = torrent_with_trackers(Some("http://tracker.example.com/announce"), None);
1323        let mgr = TrackerManager::from_torrent(&meta, test_peer_id(), 6881);
1324        assert!(!mgr.anonymous_mode);
1325        assert_eq!(mgr.announce_port(), 6881);
1326    }
1327
1328    #[test]
1329    fn empty_manager_with_dscp_and_anonymous() {
1330        let info_hash = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
1331        let mgr = TrackerManager::empty(info_hash, test_peer_id(), 6881, 0x2E, true);
1332        assert!(mgr.anonymous_mode);
1333        assert_eq!(mgr.dscp, 0x2E);
1334        assert_eq!(mgr.announce_port(), 0);
1335    }
1336}