Skip to main content

aria2_core/engine/
bt_progress_info_file.rs

1//! BT download progress persistence system
2//!
3//! Provides functionality to save BT download progress to `.aria2` text format files,
4//! with atomic write support and automatic detection of C++ binary format vs text format.
5
6use std::fmt::{Display, Formatter};
7use std::fs;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10use std::time::SystemTime;
11
12use crate::error::{Aria2Error, FatalError, Result};
13use tracing::{debug, info, warn};
14
15/// BT download progress Peer address information
16#[derive(Clone, Debug)]
17pub struct PeerAddr {
18    /// IP address
19    pub ip: String,
20    /// Port number
21    pub port: u16,
22}
23
24impl Display for PeerAddr {
25    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
26        write!(f, "{}:{}", self.ip, self.port)
27    }
28}
29
30/// BT download statistics
31#[derive(Clone, Debug, Default)]
32pub struct DownloadStats {
33    /// Uploaded bytes
34    pub uploaded_bytes: u64,
35    /// Downloaded bytes
36    pub downloaded_bytes: u64,
37    /// Upload speed (bytes/sec)
38    pub upload_speed: f64,
39    /// Download speed (bytes/sec)
40    pub download_speed: f64,
41    /// Elapsed time (seconds)
42    pub elapsed_seconds: u64,
43}
44
45/// BT download progress data structure
46///
47/// Contains all state information for BT download, used for persisting to disk.
48#[derive(Clone, Debug)]
49pub struct BtProgress {
50    /// Torrent info_hash
51    pub info_hash: [u8; 20],
52    /// Downloaded piece bitmap
53    pub bitfield: Vec<u8>,
54    /// Connected peer list
55    pub peers: Vec<PeerAddr>,
56    /// Download statistics
57    pub stats: DownloadStats,
58    /// Length of each piece
59    pub piece_length: u32,
60    /// Total size
61    pub total_size: u64,
62    /// Total number of pieces
63    pub num_pieces: u32,
64    /// Save time
65    pub save_time: SystemTime,
66    /// Format version number
67    pub version: u32,
68}
69
70impl Default for BtProgress {
71    fn default() -> Self {
72        BtProgress {
73            info_hash: [0u8; 20],
74            bitfield: Vec::new(),
75            peers: Vec::new(),
76            stats: DownloadStats::default(),
77            piece_length: 0,
78            total_size: 0,
79            num_pieces: 0,
80            save_time: SystemTime::UNIX_EPOCH,
81            version: 1,
82        }
83    }
84}
85
86impl BtProgress {
87    /// Convert info_hash to 40-character hex string
88    ///
89    /// # Returns
90    ///
91    /// Returns lowercase hex string representation
92    pub fn to_hex_hash(&self) -> String {
93        self.info_hash
94            .iter()
95            .map(|byte| format!("{:02x}", byte))
96            .collect()
97    }
98
99    /// Calculate completion percentage
100    ///
101    /// Calculates download completion ratio based on bits set in bitfield.
102    ///
103    /// # Returns
104    ///
105    /// Returns completion ratio between 0.0 and 1.0
106    pub fn completion_ratio(&self) -> f64 {
107        if self.num_pieces == 0 || self.bitfield.is_empty() {
108            return 0.0;
109        }
110
111        let mut set_bits = 0u32;
112        for &byte in &self.bitfield {
113            set_bits += byte.count_ones();
114        }
115
116        set_bits as f64 / self.num_pieces as f64
117    }
118}
119
120/// BT progress file manager
121///
122/// Manages BT download progress save, load, delete, and list operations.
123/// Supports atomic writes to ensure existing progress files are not corrupted in abnormal situations.
124pub struct BtProgressManager {
125    /// Progress file storage directory
126    progress_dir: PathBuf,
127}
128
129impl BtProgressManager {
130    /// Create new BT progress manager
131    ///
132    /// Automatically creates specified directory if it doesn't exist.
133    ///
134    /// # Arguments
135    ///
136    /// * `progress_dir` - Progress file storage directory path
137    ///
138    /// # Errors
139    ///
140    /// Returns error when unable to create directory
141    pub fn new(progress_dir: &Path) -> Result<Self> {
142        fs::create_dir_all(progress_dir).map_err(|e| {
143            Aria2Error::Fatal(FatalError::Config(format!(
144                "Failed to create progress directory {}: {}",
145                progress_dir.display(),
146                e
147            )))
148        })?;
149
150        info!(path = %progress_dir.display(), "BT progress manager initialized");
151
152        Ok(BtProgressManager {
153            progress_dir: progress_dir.to_path_buf(),
154        })
155    }
156
157    /// Generate progress file path
158    pub fn get_progress_file_path(&self, info_hash: &[u8; 20]) -> PathBuf {
159        let hex_hash: String = info_hash.iter().map(|b| format!("{:02x}", b)).collect();
160        self.progress_dir.join(format!("{}.aria2", hex_hash))
161    }
162
163    /// Save BT download progress to file
164    ///
165    /// Uses atomic write strategy: writes to temporary file first, then replaces original file
166    /// via rename operation, ensuring existing progress files are not corrupted if exceptions
167    /// occur during write process.
168    ///
169    /// # Arguments
170    ///
171    /// * `info_hash` - Torrent info_hash (20 bytes)
172    /// * `progress` - Progress data to save
173    ///
174    /// # Errors
175    ///
176    /// Returns error when file write fails
177    pub fn save_progress(&self, info_hash: &[u8; 20], progress: &BtProgress) -> Result<()> {
178        let file_path = self.get_progress_file_path(info_hash);
179
180        debug!(
181            path = %file_path.display(),
182            hash = %progress.to_hex_hash(),
183            "Saving BT progress"
184        );
185
186        // Write to temporary file - use unique temp file name to avoid race conditions in concurrent writes
187        let tmp_path = {
188            let timestamp = std::time::SystemTime::now()
189                .duration_since(std::time::UNIX_EPOCH)
190                .unwrap_or_default()
191                .as_nanos();
192            let thread_id = std::thread::current().id();
193            // Use thread ID hash + timestamp for uniqueness
194            let unique_suffix = format!(
195                "{:x}.tmp.{}",
196                timestamp,
197                // Format thread ID as a simple hash
198                format!("{:?}", thread_id)
199                    .chars()
200                    .filter(|c| c.is_ascii_hexdigit())
201                    .collect::<String>()
202            );
203            file_path.with_extension(unique_suffix)
204        };
205
206        let content = self.serialize_progress(progress);
207        {
208            let mut file = fs::File::create(&tmp_path).map_err(|e| {
209                Aria2Error::Io(format!("Failed to create temporary progress file: {}", e))
210            })?;
211
212            file.write_all(content.as_bytes())
213                .map_err(|e| Aria2Error::Io(format!("Failed to write progress data: {}", e)))?;
214
215            file.flush().map_err(|e| {
216                Aria2Error::Io(format!("Failed to flush progress file buffer: {}", e))
217            })?;
218        }
219
220        // Atomic rename
221        fs::rename(&tmp_path, &file_path).map_err(|e| {
222            // Clean up temporary file
223            let _ = fs::remove_file(&tmp_path);
224            Aria2Error::Io(format!("Failed to rename progress file: {}", e))
225        })?;
226
227        info!(
228            path = %file_path.display(),
229            pieces = progress.num_pieces,
230            ratio = progress.completion_ratio(),
231            "BT progress saved successfully"
232        );
233
234        Ok(())
235    }
236
237    /// Serialize BtProgress to text format with optimized string building
238    ///
239    /// Uses `write!` macro instead of `format!` + `push_str` to reduce memory allocations.
240    /// Pre-allocates output buffer based on estimated size to avoid repeated reallocations.
241    fn serialize_progress(&self, progress: &BtProgress) -> String {
242        // Pre-allocate buffer with estimated size to minimize reallocations
243        // Base overhead + per-peer and per-byte estimates for dynamic sections
244        let estimated_size = 512 + progress.peers.len() * 24 + progress.bitfield.len() * 3;
245        let mut output = String::with_capacity(estimated_size);
246
247        // [Download] section - use write! for direct writing without intermediate String
248        use std::fmt::Write;
249        output.push_str("[Download]\n");
250        let _ = writeln!(output, "info_hash={}", progress.to_hex_hash());
251        let _ = writeln!(output, "version={}", progress.version);
252        let _ = writeln!(output, "num_pieces={}", progress.num_pieces);
253        let _ = writeln!(output, "piece_length={}", progress.piece_length);
254        let _ = writeln!(output, "total_size={}", progress.total_size);
255        let _ = writeln!(output, "downloaded={}", progress.stats.downloaded_bytes);
256        let _ = writeln!(output, "uploaded={}", progress.stats.uploaded_bytes);
257        let _ = writeln!(output, "elapsed={}", progress.stats.elapsed_seconds);
258
259        // Serialize bitfield as hex using write! in loop (avoids collecting into intermediate String)
260        let _ = write!(output, "bitfield=");
261        for &byte in &progress.bitfield {
262            let _ = write!(output, "{:02x}", byte);
263        }
264        let _ = writeln!(output);
265
266        // [Peers] section - use write! for each peer entry
267        output.push_str("[Peers]\n");
268        for peer in &progress.peers {
269            let _ = writeln!(output, "{}", peer);
270        }
271
272        output
273    }
274
275    /// Load BT download progress file
276    ///
277    /// Automatically detects C++ binary format and text format, and parses correctly.
278    /// Validates whether info_hash in file matches provided parameter.
279    ///
280    /// # Arguments
281    ///
282    /// * `info_hash` - Expected info_hash (20 bytes) for validation
283    ///
284    /// # Returns
285    ///
286    /// Returns loaded progress data
287    ///
288    /// # Errors
289    ///
290    /// - File doesn't exist or read failure
291    /// - File format invalid or corrupted
292    /// - info_hash mismatch
293    pub fn load_progress(&self, info_hash: &[u8; 20]) -> Result<BtProgress> {
294        let file_path = self.get_progress_file_path(info_hash);
295
296        debug!(
297            path = %file_path.display(),
298            hash = %info_hash.iter().map(|b| format!("{:02x}", b)).collect::<String>(),
299            "Loading BT progress"
300        );
301
302        let content = match fs::read_to_string(&file_path) {
303            Ok(content) => content,
304            Err(e) => {
305                // Try reading as binary format
306                if e.kind() == std::io::ErrorKind::InvalidData {
307                    return self.load_binary_format(info_hash, &file_path);
308                }
309                return Err(Aria2Error::Io(format!(
310                    "Failed to read progress file: {}",
311                    e
312                )));
313            }
314        };
315
316        self.parse_text_format(info_hash, &content, &file_path)
317    }
318
319    /// Parse text format
320    fn parse_text_format(
321        &self,
322        expected_hash: &[u8; 20],
323        content: &str,
324        file_path: &Path,
325    ) -> Result<BtProgress> {
326        let mut progress = BtProgress {
327            info_hash: *expected_hash,
328            bitfield: Vec::new(),
329            peers: Vec::new(),
330            stats: DownloadStats::default(),
331            piece_length: 0,
332            total_size: 0,
333            num_pieces: 0,
334            save_time: SystemTime::now(),
335            version: 1,
336        };
337
338        let mut current_section = String::new();
339
340        for line in content.lines() {
341            let line = line.trim();
342
343            // Skip empty lines
344            if line.is_empty() {
345                continue;
346            }
347
348            // 检测 section header
349            if line.starts_with('[') && line.ends_with(']') {
350                current_section = line[1..line.len() - 1].to_string();
351                continue;
352            }
353
354            match current_section.as_str() {
355                "Download" => {
356                    if let Some((key, value)) = line.split_once('=') {
357                        match key.trim() {
358                            "info_hash" => {
359                                // Validate info_hash match
360                                let file_hash = value.trim().to_lowercase();
361                                let expected_hex: String =
362                                    expected_hash.iter().map(|b| format!("{:02x}", b)).collect();
363                                if file_hash != expected_hex {
364                                    return Err(Aria2Error::Io(format!(
365                                        "Progress file info_hash mismatch: file={}, expected={}",
366                                        file_hash, expected_hex
367                                    )));
368                                }
369                            }
370                            "version" => {
371                                progress.version = value.trim().parse::<u32>().unwrap_or(1);
372                            }
373                            "num_pieces" => {
374                                progress.num_pieces = value.trim().parse::<u32>().unwrap_or(0);
375                            }
376                            "piece_length" => {
377                                progress.piece_length = value.trim().parse::<u32>().unwrap_or(0);
378                            }
379                            "total_size" => {
380                                progress.total_size = value.trim().parse::<u64>().unwrap_or(0);
381                            }
382                            "downloaded" => {
383                                progress.stats.downloaded_bytes =
384                                    value.trim().parse::<u64>().unwrap_or(0);
385                            }
386                            "uploaded" => {
387                                progress.stats.uploaded_bytes =
388                                    value.trim().parse::<u64>().unwrap_or(0);
389                            }
390                            "elapsed" => {
391                                progress.stats.elapsed_seconds =
392                                    value.trim().parse::<u64>().unwrap_or(0);
393                            }
394                            "bitfield" => {
395                                progress.bitfield = self.parse_bitfield_hex(value.trim());
396                            }
397                            _ => {}
398                        }
399                    }
400                }
401                "Peers" => {
402                    let peer = self.parse_peer_addr(line);
403                    if let Some(p) = peer {
404                        progress.peers.push(p);
405                    }
406                }
407                _ => {}
408            }
409        }
410
411        info!(
412            path = %file_path.display(),
413            pieces = progress.num_pieces,
414            ratio = progress.completion_ratio(),
415            "BT progress loaded successfully"
416        );
417
418        Ok(progress)
419    }
420
421    /// Try loading binary format (C++ compatible format)
422    fn load_binary_format(&self, _info_hash: &[u8; 20], _file_path: &Path) -> Result<BtProgress> {
423        // Binary format currently not supported, return error prompting user to use new format
424        Err(Aria2Error::Io(
425            "Old C++ binary format not supported, please use text format".to_string(),
426        ))
427    }
428
429    /// Parse bitfield hex string
430    fn parse_bitfield_hex(&self, hex_str: &str) -> Vec<u8> {
431        let hex_str = hex_str.trim();
432        if hex_str.is_empty() {
433            return Vec::new();
434        }
435
436        (0..hex_str.len())
437            .step_by(2)
438            .filter_map(|i| {
439                if i + 1 < hex_str.len() {
440                    u8::from_str_radix(&hex_str[i..i + 2], 16).ok()
441                } else {
442                    None
443                }
444            })
445            .collect()
446    }
447
448    /// Parse peer address string
449    fn parse_peer_addr(&self, addr_str: &str) -> Option<PeerAddr> {
450        let addr_str = addr_str.trim();
451        if addr_str.is_empty() {
452            return None;
453        }
454
455        // Find last colon (IPv6 addresses may contain multiple colons)
456        if let Some(colon_pos) = addr_str.rfind(':') {
457            let ip = addr_str[..colon_pos].trim().to_string();
458            let port: u16 = addr_str[colon_pos + 1..].trim().parse().ok()?;
459
460            Some(PeerAddr { ip, port })
461        } else {
462            None
463        }
464    }
465
466    /// Delete progress file for specified info_hash
467    ///
468    /// # Arguments
469    ///
470    /// * `info_hash` - info_hash of torrent to delete (20 bytes)
471    ///
472    /// # Errors
473    ///
474    /// Returns error when file deletion fails
475    pub fn remove_progress(&self, info_hash: &[u8; 20]) -> Result<()> {
476        let file_path = self.get_progress_file_path(info_hash);
477
478        debug!(
479            hash = %info_hash.iter().map(|b| format!("{:02x}", b)).collect::<String>(),
480            "Deleting BT progress"
481        );
482
483        if file_path.exists() {
484            fs::remove_file(&file_path)
485                .map_err(|e| Aria2Error::Io(format!("Failed to delete progress file: {}", e)))?;
486
487            info!(path = %file_path.display(), "BT progress file deleted");
488        } else {
489            warn!(path = %file_path.display(), "Progress file does not exist");
490        }
491
492        Ok(())
493    }
494
495    /// List all saved progress files
496    ///
497    /// Scans all `.aria2` files in progress directory, extracts their info_hash.
498    ///
499    /// # Returns
500    ///
501    /// Returns list of info_hash for all saved progress
502    pub fn list_saved_progresses(&self) -> Vec<[u8; 20]> {
503        let mut hashes = Vec::new();
504
505        if let Ok(entries) = fs::read_dir(&self.progress_dir) {
506            for entry in entries.flatten() {
507                let path = entry.path();
508                if let Some(name) = path.file_name() {
509                    let name_str = name.to_string_lossy();
510                    if let Some(hex_hash) = name_str.strip_suffix(".aria2") {
511                        // Extract hex hash from filename
512                        // Remove ".aria2"
513                        if let Ok(hash) = Self::hex_to_info_hash(hex_hash) {
514                            hashes.push(hash);
515                        } else {
516                            warn!(
517                                filename = %name_str,
518                                "Cannot parse info_hash from progress file name"
519                            );
520                        }
521                    }
522                }
523            }
524        }
525
526        debug!(count = hashes.len(), "Listed all saved BT progress");
527
528        hashes
529    }
530
531    /// Convert hex string to info_hash
532    fn hex_to_info_hash(hex_str: &str) -> std::result::Result<[u8; 20], ()> {
533        if hex_str.len() != 40 {
534            return Err(());
535        }
536
537        let mut hash = [0u8; 20];
538        for (i, byte) in hash.iter_mut().enumerate() {
539            *byte = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16).map_err(|_| ())?;
540        }
541
542        Ok(hash)
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    #[test]
551    fn test_peer_addr_display() {
552        let peer = PeerAddr {
553            ip: "192.168.1.1".to_string(),
554            port: 6881,
555        };
556        assert_eq!(format!("{}", peer), "192.168.1.1:6881");
557    }
558
559    #[test]
560    fn test_bt_progress_to_hex_hash() {
561        let progress = BtProgress {
562            info_hash: [
563                0xAB, 0xCD, 0x12, 0x34, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
564                0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
565            ],
566            ..Default::default()
567        };
568        assert_eq!(
569            progress.to_hex_hash(),
570            "abcd123400000000000000000000000000000000"
571        );
572    }
573
574    #[test]
575    fn test_completion_ratio_zero_pieces() {
576        let progress = BtProgress {
577            num_pieces: 0,
578            bitfield: vec![],
579            ..Default::default()
580        };
581        assert_eq!(progress.completion_ratio(), 0.0);
582    }
583
584    #[test]
585    fn test_completion_ratio_full() {
586        // 4 pieces all downloaded
587        let progress = BtProgress {
588            num_pieces: 4,
589            bitfield: vec![0xFF], // 8 bits, but only 4 pieces
590            ..Default::default()
591        };
592        // Should be 4/4 = 1.0, but will actually calculate all set bits
593        assert!(progress.completion_ratio() > 0.0);
594    }
595
596    #[test]
597    fn test_parse_bitfield_hex() {
598        let manager = create_test_manager();
599        let result = manager.parse_bitfield_hex("ff00ff");
600        assert_eq!(result, vec![0xFF, 0x00, 0xFF]);
601    }
602
603    #[test]
604    fn test_parse_bitfield_empty() {
605        let manager = create_test_manager();
606        let result = manager.parse_bitfield_hex("");
607        assert!(result.is_empty());
608    }
609
610    #[test]
611    fn test_parse_peer_addr_ipv4() {
612        let manager = create_test_manager();
613        let peer = manager.parse_peer_addr("192.168.1.100:6881").unwrap();
614        assert_eq!(peer.ip, "192.168.1.100");
615        assert_eq!(peer.port, 6881);
616    }
617
618    #[test]
619    fn test_parse_peer_addr_invalid() {
620        let manager = create_test_manager();
621        assert!(manager.parse_peer_addr("invalid").is_none());
622        assert!(manager.parse_peer_addr("").is_none());
623    }
624
625    #[test]
626    fn test_hex_to_info_hash_valid() {
627        let hex = "abcdef1234567890abcdef1234567890abcdef12";
628        let hash = BtProgressManager::hex_to_info_hash(hex).unwrap();
629        assert_eq!(
630            hash,
631            [
632                0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x56,
633                0x78, 0x90, 0xAB, 0xCD, 0xEF, 0x12
634            ]
635        );
636    }
637
638    #[test]
639    fn test_hex_to_info_hash_invalid_length() {
640        assert!(BtProgressManager::hex_to_info_hash("abc123").is_err());
641    }
642
643    fn create_test_manager() -> BtProgressManager {
644        let dir = std::env::temp_dir().join("bt_progress_test_12345");
645        let _ = fs::create_dir_all(&dir);
646        BtProgressManager::new(&dir).unwrap()
647    }
648}