1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
//! HTTP/HTTPS Tracker Client with Event State Machine
//!
//! This module provides a stateful tracker client that supports both HTTP and HTTPS
//! tracker URLs, with proper event lifecycle management (Started -> Completed -> Stopped).
//!
//! # Event State Machine
//!
//! The tracker follows a well-defined event sequence:
//! - **Started**: Sent when download begins (first announce)
//! - **Completed**: Sent when all pieces are downloaded successfully
//! - **Stopped**: Sent when download is cancelled or removed
//! - **None**: Regular interval announces (no specific event)
use std::time::{Duration, Instant};
use tracing::{debug, info};
/// Tracker announce events as defined in BEP 3 / BEP 15.
///
/// These events control the lifecycle of tracker announcements.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrackerEvent {
/// Download started - sent on first announce
Started,
/// Download stopped/cancelled - sent on shutdown
Stopped,
/// Download completed - sent when all pieces downloaded
Completed,
/// No specific event - used for regular interval announces
None,
}
impl TrackerEvent {
/// Convert event to the string value expected by trackers
pub fn as_str(&self) -> &'static str {
match self {
TrackerEvent::Started => "started",
TrackerEvent::Stopped => "stopped",
TrackerEvent::Completed => "completed",
TrackerEvent::None => "",
}
}
}
/// State machine for tracking announce events and intervals.
///
/// Manages the current event state, timing of announces, and ensures
/// proper event sequencing according to BitTorrent specification.
#[derive(Debug)]
pub struct TrackerState {
/// Current event to send on next announce
pub current_event: TrackerEvent,
/// Timestamp of last successful announce
pub last_announce_time: Option<Instant>,
/// Minimum interval between announces (from tracker response)
pub min_interval_secs: u64,
/// Normal interval between announces (from tracker response)
pub interval_secs: u64,
/// Total number of announces sent
pub announce_count: u64,
/// Whether we have already sent the 'completed' event
completed_sent: bool,
/// Whether we have already sent the 'stopped' event
stopped_sent: bool,
}
impl Default for TrackerState {
fn default() -> Self {
Self {
current_event: TrackerEvent::Started, // First announce should be Started
last_announce_time: None,
min_interval_secs: 300, // Default 5 minutes
interval_secs: 1800, // Default 30 minutes
announce_count: 0,
completed_sent: false,
stopped_sent: false,
}
}
}
impl TrackerState {
/// Create a new tracker state with default values
pub fn new() -> Self {
Self::default()
}
/// Check if it's time to send another announce based on interval constraints
///
/// Returns true if enough time has elapsed since the last announce
/// according to min_interval_secs
pub fn should_announce(&self) -> bool {
match self.last_announce_time {
Some(last) => {
let elapsed = last.elapsed().as_secs();
elapsed >= self.min_interval_secs
}
None => true, // Never announced before
}
}
/// Get the recommended wait time until next announce
pub fn secs_until_next_announce(&self) -> u64 {
match self.last_announce_time {
Some(last) => {
let elapsed = last.elapsed().as_secs();
if elapsed >= self.min_interval_secs {
0
} else {
self.min_interval_secs.saturating_sub(elapsed)
}
}
None => 0,
}
}
/// Mark that an announce has been sent and advance the event state
///
/// This updates internal timing and transitions the event state:
/// - After Started -> None (for regular announces)
/// - After Completed -> None
/// - After Stopped -> stays Stopped (terminal state)
pub fn record_announce(&mut self, event: TrackerEvent) {
self.current_event = match event {
TrackerEvent::Started => {
debug!("TrackerState: Recorded Started event");
TrackerEvent::None // Next announce has no special event
}
TrackerEvent::Completed => {
self.completed_sent = true;
info!("TrackerState: Recorded Completed event");
TrackerEvent::None // Next announce has no special event
}
TrackerEvent::Stopped => {
self.stopped_sent = true;
info!("TrackerState: Recorded Stopped event (terminal)");
TrackerEvent::Stopped // Stay in stopped state
}
TrackerEvent::None => TrackerEvent::None,
};
self.last_announce_time = Some(Instant::now());
self.announce_count += 1;
}
/// Update interval values from tracker response
///
/// Trackers return `interval` (recommended) and optionally `min interval`
/// which constrain how often we may re-announce.
pub fn update_intervals(&mut self, interval: Option<u64>, min_interval: Option<u64>) {
if let Some(iv) = interval {
// Sanity check: don't allow extremely short intervals (< 60s)
self.interval_secs = iv.max(60);
debug!("TrackerState: Updated interval to {}s", self.interval_secs);
}
if let Some(mi) = min_interval {
// Sanity check: don't allow extremely short min intervals (< 30s)
self.min_interval_secs = mi.max(30);
debug!(
"TrackerState: Updated min_interval to {}s",
self.min_interval_secs
);
}
}
/// Transition to completed state - call when all pieces are downloaded
pub fn mark_completed(&mut self) {
if !self.completed_sent {
self.current_event = TrackerEvent::Completed;
info!("TrackerState: Transitioning to Completed event");
}
}
/// Transition to stopped state - call on shutdown/cancel
pub fn mark_stopped(&mut self) {
if !self.stopped_sent {
self.current_event = TrackerEvent::Stopped;
info!("TrackerState: Transitioning to Stopped event");
}
}
/// Reset state for a new download session
pub fn reset(&mut self) {
*self = Self::default();
}
/// Check if the tracker is in terminal (stopped) state
pub fn is_stopped(&self) -> bool {
self.stopped_sent
}
}
/// Detect whether a tracker URL uses HTTPS scheme.
///
/// reqwest supports HTTPS natively with default features (native-tls),
/// but this function allows explicit checking for logging or configuration purposes.
///
/// # Arguments
/// * `url` - The tracker URL to check
///
/// # Returns
/// true if the URL starts with "https://", false otherwise
pub fn is_https_tracker(url: &str) -> bool {
url.to_lowercase().starts_with("https://")
}
/// Build a reqwest::Client configured appropriately for the given URL scheme.
///
/// For HTTPS URLs, returns a standard client (reqwest handles TLS by default).
/// For HTTP URLs, returns a standard client without TLS requirements.
///
/// # Arguments
/// * `timeout_secs` - Request timeout in seconds
pub fn build_tracker_client(timeout_secs: u64) -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(|e| format!("Failed to build HTTP client: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_https_tracker_url_detected() {
assert!(is_https_tracker("https://tracker.example.com/announce"));
assert!(is_https_tracker("HTTPS://TRACKER.EXAMPLE.COM/ANNOUNCE"));
assert!(!is_https_tracker("http://tracker.example.com/announce"));
assert!(!is_https_tracker("udp://tracker.example.com:1337/announce"));
}
#[test]
fn test_tracker_event_as_str() {
assert_eq!(TrackerEvent::Started.as_str(), "started");
assert_eq!(TrackerEvent::Stopped.as_str(), "stopped");
assert_eq!(TrackerEvent::Completed.as_str(), "completed");
assert_eq!(TrackerEvent::None.as_str(), "");
}
#[test]
fn test_tracker_state_default() {
let state = TrackerState::new();
assert_eq!(state.current_event, TrackerEvent::Started);
assert!(state.last_announce_time.is_none());
assert_eq!(state.announce_count, 0);
assert!(!state.completed_sent);
assert!(!state.stopped_sent);
}
#[test]
fn test_tracker_state_should_announce_initially() {
let state = TrackerState::new();
assert!(
state.should_announce(),
"Should be able to announce immediately on fresh state"
);
}
#[test]
fn test_tracker_state_sequence_started_to_completed() {
let mut state = TrackerState::new();
// Initial state should be Started
assert_eq!(state.current_event, TrackerEvent::Started);
// Record started event
state.record_announce(TrackerEvent::Started);
assert_eq!(state.announce_count, 1);
assert_eq!(state.current_event, TrackerEvent::None); // Transitions to None
// Now mark as completed
state.mark_completed();
assert_eq!(state.current_event, TrackerEvent::Completed);
assert!(!state.completed_sent); // Not sent yet
// Record completed event
state.record_announce(TrackerEvent::Completed);
assert_eq!(state.announce_count, 2);
assert!(state.completed_sent);
assert_eq!(state.current_event, TrackerEvent::None); // Transitions back to None
}
#[test]
fn test_tracker_event_stopped_on_cancel() {
let mut state = TrackerState::new();
// Simulate started
state.record_announce(TrackerEvent::Started);
// Cancel the download
state.mark_stopped();
assert_eq!(state.current_event, TrackerEvent::Stopped);
assert!(!state.stopped_sent); // Not sent yet
// Record stopped event
state.record_announce(TrackerEvent::Stopped);
assert!(state.stopped_sent);
// Stopped is terminal - should stay stopped
assert_eq!(state.current_event, TrackerEvent::Stopped);
}
#[test]
fn test_min_interval_respected() {
let mut state = TrackerState::new();
state.min_interval_secs = 10;
// Initial announce
assert!(state.should_announce());
state.record_announce(TrackerEvent::Started);
// Immediately after - should NOT be allowed
assert!(
!state.should_announce(),
"Should not re-announce before min_interval"
);
// Check that secs_until_next_announce is positive
let wait = state.secs_until_next_announce();
assert!(wait > 0, "Should need to wait at least some seconds");
assert!(wait <= 10, "Should not wait more than min_interval");
}
#[test]
fn test_update_intervals() {
let mut state = TrackerState::new();
assert_eq!(state.interval_secs, 1800); // Default 30 min
assert_eq!(state.min_interval_secs, 300); // Default 5 min
state.update_intervals(Some(900), Some(300));
assert_eq!(state.interval_secs, 900);
assert_eq!(state.min_interval_secs, 300);
// Test floor enforcement
state.update_intervals(Some(10), Some(5));
assert_eq!(state.interval_secs, 60); // Floor at 60s
assert_eq!(state.min_interval_secs, 30); // Floor at 30s
}
#[test]
fn test_build_tracker_client_succeeds() {
let client = build_tracker_client(30);
assert!(client.is_ok(), "Should build client with valid timeout");
}
#[test]
fn test_is_stopped_terminal_state() {
let mut state = TrackerState::new();
assert!(!state.is_stopped());
state.mark_stopped();
state.record_announce(TrackerEvent::Stopped);
assert!(state.is_stopped());
}
#[test]
fn test_reset_clears_state() {
let mut state = TrackerState::new();
state.record_announce(TrackerEvent::Started);
state.mark_completed();
state.record_announce(TrackerEvent::Completed);
state.reset();
assert_eq!(state.current_event, TrackerEvent::Started);
assert_eq!(state.announce_count, 0);
assert!(!state.completed_sent);
assert!(!state.stopped_sent);
}
#[test]
fn test_multiple_completes_only_one_event() {
let mut state = TrackerState::new();
state.record_announce(TrackerEvent::Started);
// First complete
state.mark_completed();
assert_eq!(state.current_event, TrackerEvent::Completed);
state.record_announce(TrackerEvent::Completed);
// Second complete attempt should not trigger another Completed event
state.mark_completed();
assert_eq!(
state.current_event,
TrackerEvent::None,
"Second mark_completed should not change state"
);
}
}