torrust_index/models/
torrent_file.rs

1use serde::{Deserialize, Serialize};
2use serde_bencode::ser;
3use serde_bytes::ByteBuf;
4use sha1::{Digest, Sha1};
5use tracing::error;
6use url::Url;
7
8use super::info_hash::InfoHash;
9use crate::utils::hex::{from_bytes, into_bytes};
10
11#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)]
12pub struct Torrent {
13    pub info: TorrentInfoDictionary, //
14    #[serde(default)]
15    pub announce: Option<String>,
16    #[serde(default)]
17    pub nodes: Option<Vec<(String, i64)>>,
18    #[serde(default)]
19    pub encoding: Option<String>,
20    #[serde(default)]
21    pub httpseeds: Option<Vec<String>>,
22    #[serde(default)]
23    #[serde(rename = "announce-list")]
24    pub announce_list: Option<Vec<Vec<String>>>,
25    #[serde(default)]
26    #[serde(rename = "creation date")]
27    pub creation_date: Option<i64>,
28    #[serde(default)]
29    pub comment: Option<String>,
30    #[serde(default)]
31    #[serde(rename = "created by")]
32    pub created_by: Option<String>,
33}
34
35#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]
36pub struct TorrentInfoDictionary {
37    pub name: String,
38    #[serde(default)]
39    pub pieces: Option<ByteBuf>,
40    #[serde(rename = "piece length")]
41    pub piece_length: i64,
42    #[serde(default)]
43    pub md5sum: Option<String>,
44    #[serde(default)]
45    pub length: Option<i64>,
46    #[serde(default)]
47    pub files: Option<Vec<TorrentFile>>,
48    #[serde(default)]
49    pub private: Option<u8>,
50    #[serde(default)]
51    pub path: Option<Vec<String>>,
52    #[serde(default)]
53    #[serde(rename = "root hash")]
54    pub root_hash: Option<String>,
55    #[serde(default)]
56    pub source: Option<String>,
57}
58
59#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]
60pub struct TorrentFile {
61    pub path: Vec<String>,
62    pub length: i64,
63    #[serde(default)]
64    pub md5sum: Option<String>,
65}
66
67impl Torrent {
68    /// It hydrates a `Torrent` struct from the database data.
69    ///
70    /// # Panics
71    ///
72    /// This function will panic if the `torrent_info.pieces` is not a valid
73    /// hex string.
74    #[must_use]
75    pub fn from_database(
76        db_torrent: &DbTorrent,
77        torrent_files: &[TorrentFile],
78        torrent_announce_urls: Vec<Vec<String>>,
79        torrent_http_seed_urls: Vec<String>,
80        torrent_nodes: Vec<(String, i64)>,
81    ) -> Self {
82        let pieces_or_root_hash = if db_torrent.is_bep_30 == 0 {
83            if let Some(pieces) = &db_torrent.pieces {
84                pieces.clone()
85            } else {
86                error!("Invalid torrent #{}. Null `pieces` in database", db_torrent.torrent_id);
87                String::new()
88            }
89        } else {
90            // A BEP-30 torrent
91            if let Some(root_hash) = &db_torrent.root_hash {
92                root_hash.clone()
93            } else {
94                error!("Invalid torrent #{}. Null `root_hash` in database", db_torrent.torrent_id);
95                String::new()
96            }
97        };
98
99        let info_dict = TorrentInfoDictionary::with(
100            &db_torrent.name,
101            db_torrent.piece_length,
102            db_torrent.private,
103            db_torrent.is_bep_30,
104            &pieces_or_root_hash,
105            torrent_files,
106        );
107
108        Self {
109            info: info_dict,
110            announce: None,
111            nodes: if torrent_nodes.is_empty() { None } else { Some(torrent_nodes) },
112            encoding: db_torrent.encoding.clone(),
113            httpseeds: if torrent_http_seed_urls.is_empty() {
114                None
115            } else {
116                Some(torrent_http_seed_urls)
117            },
118            announce_list: Some(torrent_announce_urls),
119            creation_date: db_torrent.creation_date,
120            comment: db_torrent.comment.clone(),
121            created_by: db_torrent.created_by.clone(),
122        }
123    }
124
125    /// Includes the tracker URL a the main tracker in the torrent.
126    ///
127    /// It will be the URL in the `announce` field and also the first URL in the
128    /// `announce_list`.
129    pub fn include_url_as_main_tracker(&mut self, tracker_url: &Url) {
130        self.set_announce_to(tracker_url);
131        self.add_url_to_front_of_announce_list(tracker_url);
132    }
133
134    /// Sets the announce url to the tracker url.
135    pub fn set_announce_to(&mut self, tracker_url: &Url) {
136        self.announce = Some(tracker_url.to_owned().to_string());
137    }
138
139    /// Adds a new tracker URL to the front of the `announce_list`, removes duplicates,
140    /// and cleans up any empty inner lists.
141    ///
142    /// In practice, it's common for the `announce_list` to include the URL from
143    /// the `announce` field as one of its entries, often in the first tier,
144    /// to ensure that this primary tracker is always used. However, this is not
145    /// a strict requirement of the `BitTorrent` protocol; it's more of a
146    /// convention followed by some torrent creators for redundancy and to
147    /// ensure better availability of trackers.    
148    pub fn add_url_to_front_of_announce_list(&mut self, tracker_url: &Url) {
149        if let Some(list) = &mut self.announce_list {
150            // Remove the tracker URL from existing lists
151            for inner_list in list.iter_mut() {
152                inner_list.retain(|url| *url != tracker_url.to_string());
153            }
154
155            // Prepend a new vector containing the tracker_url
156            let vec = vec![tracker_url.to_owned().to_string()];
157            list.insert(0, vec);
158
159            // Remove any empty inner lists
160            list.retain(|inner_list| !inner_list.is_empty());
161        }
162    }
163
164    /// Removes all other trackers if the torrent is private.
165    pub fn reset_announce_list_if_private(&mut self) {
166        if self.is_private() {
167            self.announce_list = None;
168        }
169    }
170
171    fn is_private(&self) -> bool {
172        if let Some(private) = self.info.private {
173            if private == 1 {
174                return true;
175            }
176        }
177        false
178    }
179
180    /// It calculates the info hash of the torrent file.
181    ///
182    /// # Panics
183    ///
184    /// This function will panic if the `info` part of the torrent file cannot be serialized.
185    #[must_use]
186    pub fn calculate_info_hash_as_bytes(&self) -> [u8; 20] {
187        let info_bencoded = ser::to_bytes(&self.info).expect("variable `info` was not able to be serialized.");
188        let mut hasher = Sha1::new();
189        hasher.update(info_bencoded);
190        let sum_hex = hasher.finalize();
191        let mut sum_bytes: [u8; 20] = Default::default();
192        sum_bytes.copy_from_slice(sum_hex.as_slice());
193        sum_bytes
194    }
195
196    #[must_use]
197    pub fn canonical_info_hash(&self) -> InfoHash {
198        self.calculate_info_hash_as_bytes().into()
199    }
200
201    #[must_use]
202    pub fn canonical_info_hash_hex(&self) -> String {
203        self.canonical_info_hash().to_hex_string()
204    }
205
206    #[must_use]
207    pub fn file_size(&self) -> i64 {
208        match self.info.length {
209            Some(length) => length,
210            None => match &self.info.files {
211                None => 0,
212                Some(files) => {
213                    let mut file_size = 0;
214                    for file in files {
215                        file_size += file.length;
216                    }
217                    file_size
218                }
219            },
220        }
221    }
222
223    /// It returns the announce urls of the torrent file.
224    ///
225    /// # Panics
226    ///
227    /// This function will panic if both the `announce_list` and the `announce` are `None`.
228    #[must_use]
229    pub fn announce_urls(&self) -> Vec<String> {
230        match &self.announce_list {
231            Some(list) => list.clone().into_iter().flatten().collect::<Vec<String>>(),
232            None => vec![self.announce.clone().expect("variable `announce` should not be None")],
233        }
234    }
235
236    #[must_use]
237    pub fn is_a_single_file_torrent(&self) -> bool {
238        self.info.is_a_single_file_torrent()
239    }
240
241    #[must_use]
242    pub fn is_a_multiple_file_torrent(&self) -> bool {
243        self.info.is_a_multiple_file_torrent()
244    }
245}
246
247impl TorrentInfoDictionary {
248    /// Constructor.
249    ///
250    /// # Panics
251    ///
252    /// This function will panic if:
253    ///
254    /// - The `pieces` field is not a valid hex string.
255    /// - For single files torrents the `TorrentFile` path is empty.
256    #[must_use]
257    pub fn with(
258        name: &str,
259        piece_length: i64,
260        private: Option<u8>,
261        is_bep_30: i64,
262        pieces_or_root_hash: &str,
263        files: &[TorrentFile],
264    ) -> Self {
265        let mut info_dict = Self {
266            name: name.to_string(),
267            pieces: None,
268            piece_length,
269            md5sum: None,
270            length: None,
271            files: None,
272            private,
273            path: None,
274            root_hash: None,
275            source: None,
276        };
277
278        // BEP 30: <http://www.bittorrent.org/beps/bep_0030.html>.
279        // Torrent file can only hold a `pieces` key or a `root hash` key
280        if is_bep_30 == 0 {
281            let buffer = into_bytes(pieces_or_root_hash).expect("variable `torrent_info.pieces` is not a valid hex string");
282            info_dict.pieces = Some(ByteBuf::from(buffer));
283        } else {
284            info_dict.root_hash = Some(pieces_or_root_hash.to_owned());
285        }
286
287        // either set the single file or the multiple files information
288        if files.len() == 1 {
289            let torrent_file = files
290                .first()
291                .expect("vector `torrent_files` should have at least one element");
292
293            info_dict.md5sum.clone_from(&torrent_file.md5sum); // DevSkim: ignore DS126858
294
295            info_dict.length = Some(torrent_file.length);
296
297            let path = if torrent_file
298                .path
299                .first()
300                .as_ref()
301                .expect("the vector for the `path` should have at least one element")
302                .is_empty()
303            {
304                None
305            } else {
306                Some(torrent_file.path.clone())
307            };
308
309            info_dict.path = path;
310        } else {
311            info_dict.files = Some(files.to_vec());
312        }
313
314        info_dict
315    }
316
317    /// torrent file can only hold a pieces key or a root hash key:
318    /// [BEP 39](http://www.bittorrent.org/beps/bep_0030.html)
319    #[must_use]
320    pub fn get_pieces_as_string(&self) -> String {
321        match &self.pieces {
322            None => String::new(),
323            Some(byte_buf) => from_bytes(byte_buf.as_ref()),
324        }
325    }
326
327    /// torrent file can only hold a pieces key or a root hash key:
328    /// [BEP 39](http://www.bittorrent.org/beps/bep_0030.html)
329    #[must_use]
330    pub fn get_root_hash_as_string(&self) -> String {
331        match &self.root_hash {
332            None => String::new(),
333            Some(root_hash) => root_hash.clone(),
334        }
335    }
336
337    /// It returns true if the torrent is a BEP-30 torrent.
338    #[must_use]
339    pub fn is_bep_30(&self) -> bool {
340        self.root_hash.is_some()
341    }
342
343    #[must_use]
344    pub fn is_a_single_file_torrent(&self) -> bool {
345        self.length.is_some()
346    }
347
348    #[must_use]
349    pub fn is_a_multiple_file_torrent(&self) -> bool {
350        self.files.is_some()
351    }
352}
353
354#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
355pub struct DbTorrent {
356    pub torrent_id: i64,
357    pub info_hash: String,
358    pub name: String,
359    pub pieces: Option<String>,
360    pub root_hash: Option<String>,
361    pub piece_length: i64,
362    #[serde(default)]
363    pub private: Option<u8>,
364    pub is_bep_30: i64,
365    pub comment: Option<String>,
366    pub creation_date: Option<i64>,
367    pub created_by: Option<String>,
368    pub encoding: Option<String>,
369}
370
371#[allow(clippy::module_name_repetitions)]
372#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
373pub struct DbTorrentFile {
374    pub path: Option<String>,
375    pub length: i64,
376    #[serde(default)]
377    pub md5sum: Option<String>,
378}
379
380#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
381pub struct DbTorrentAnnounceUrl {
382    pub tracker_url: String,
383}
384
385#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
386pub struct DbTorrentHttpSeedUrl {
387    pub seed_url: String,
388}
389
390#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
391pub struct DbTorrentNode {
392    pub node_ip: String,
393    pub node_port: i64,
394}
395
396#[cfg(test)]
397mod tests {
398
399    mod info_hash_calculation_for_version_v1 {
400
401        use serde_bytes::ByteBuf;
402
403        use crate::models::torrent_file::{Torrent, TorrentInfoDictionary};
404
405        #[test]
406        fn the_parsed_torrent_file_should_calculated_the_torrent_info_hash() {
407            /* The sample.txt content (`mandelbrot`):
408
409               ```
410               6d616e64656c62726f740a
411               ```
412
413               The sample.txt.torrent content:
414
415               ```
416               6431303a6372656174656420627931383a71426974746f7272656e742076
417               342e352e3431333a6372656174696f6e2064617465693136393131343935
418               373265343a696e666f64363a6c656e67746869313165343a6e616d653130
419               3a73616d706c652e74787431323a7069656365206c656e67746869313633
420               383465363a70696563657332303ad491587f1c42dff0cb0ff5c2b8cefe22
421               b3ad310a6565
422               ```
423
424               ```json
425               {
426                 "created by": "qBittorrent v4.5.4",
427                 "creation date": 1691149572,
428                 "info": {
429                   "length": 11,
430                   "name": "sample.txt",
431                   "piece length": 16384,
432                   "pieces": "<hex>D4 91 58 7F 1C 42 DF F0 CB 0F F5 C2 B8 CE FE 22 B3 AD 31 0A</hex>"
433                 }
434               }
435               ```
436            */
437
438            let sample_data_in_txt_file = "mandelbrot\n";
439
440            let info = TorrentInfoDictionary {
441                name: "sample.txt".to_string(),
442                pieces: Some(ByteBuf::from(vec![
443                    // D4 91  58   7F  1C  42   DF   F0   CB  0F   F5   C2   B8   CE   FE  22   B3   AD  31  0A  // hex
444                    212, 145, 88, 127, 28, 66, 223, 240, 203, 15, 245, 194, 184, 206, 254, 34, 179, 173, 49, 10, // dec
445                ])),
446                piece_length: 16384,
447                md5sum: None,
448                length: Some(sample_data_in_txt_file.len().try_into().unwrap()),
449                files: None,
450                private: None,
451                path: None,
452                root_hash: None,
453                source: None,
454            };
455
456            let torrent = Torrent {
457                info: info.clone(),
458                announce: None,
459                announce_list: Some(vec![]),
460                creation_date: None,
461                comment: None,
462                created_by: None,
463                nodes: None,
464                encoding: None,
465                httpseeds: None,
466            };
467
468            assert_eq!(torrent.canonical_info_hash_hex(), "79fa9e4a2927804fe4feab488a76c8c2d3d1cdca");
469        }
470
471        mod infohash_should_be_calculated_for {
472
473            use serde_bytes::ByteBuf;
474
475            use crate::models::torrent_file::{Torrent, TorrentFile, TorrentInfoDictionary};
476
477            #[test]
478            fn a_simple_single_file_torrent() {
479                let sample_data_in_txt_file = "mandelbrot\n";
480
481                let info = TorrentInfoDictionary {
482                    name: "sample.txt".to_string(),
483                    pieces: Some(ByteBuf::from(vec![
484                        // D4 91  58   7F  1C  42   DF   F0   CB  0F   F5   C2   B8   CE   FE  22   B3   AD  31  0A  // hex
485                        212, 145, 88, 127, 28, 66, 223, 240, 203, 15, 245, 194, 184, 206, 254, 34, 179, 173, 49, 10, // dec
486                    ])),
487                    piece_length: 16384,
488                    md5sum: None,
489                    length: Some(sample_data_in_txt_file.len().try_into().unwrap()),
490                    files: None,
491                    private: None,
492                    path: None,
493                    root_hash: None,
494                    source: None,
495                };
496
497                let torrent = Torrent {
498                    info: info.clone(),
499                    announce: None,
500                    announce_list: Some(vec![]),
501                    creation_date: None,
502                    comment: None,
503                    created_by: None,
504                    nodes: None,
505                    encoding: None,
506                    httpseeds: None,
507                };
508
509                assert_eq!(torrent.canonical_info_hash_hex(), "79fa9e4a2927804fe4feab488a76c8c2d3d1cdca");
510            }
511
512            #[test]
513            fn a_simple_multi_file_torrent() {
514                let sample_data_in_txt_file = "mandelbrot\n";
515
516                let info = TorrentInfoDictionary {
517                    name: "sample".to_string(),
518                    pieces: Some(ByteBuf::from(vec![
519                        // D4 91  58   7F  1C  42   DF   F0   CB  0F   F5   C2   B8   CE   FE  22   B3   AD  31  0A  // hex
520                        212, 145, 88, 127, 28, 66, 223, 240, 203, 15, 245, 194, 184, 206, 254, 34, 179, 173, 49, 10, // dec
521                    ])),
522                    piece_length: 16384,
523                    md5sum: None,
524                    length: None,
525                    files: Some(vec![TorrentFile {
526                        path: vec!["sample.txt".to_string()],
527                        length: sample_data_in_txt_file.len().try_into().unwrap(),
528                        md5sum: None,
529                    }]),
530                    private: None,
531                    path: None,
532                    root_hash: None,
533                    source: None,
534                };
535
536                let torrent = Torrent {
537                    info: info.clone(),
538                    announce: None,
539                    announce_list: Some(vec![]),
540                    creation_date: None,
541                    comment: None,
542                    created_by: None,
543                    nodes: None,
544                    encoding: None,
545                    httpseeds: None,
546                };
547
548                assert_eq!(torrent.canonical_info_hash_hex(), "aa2aca91ab650c4d249c475ca3fa604f2ccb0d2a");
549            }
550
551            #[test]
552            fn a_simple_single_file_torrent_with_a_source() {
553                let sample_data_in_txt_file = "mandelbrot\n";
554
555                let info = TorrentInfoDictionary {
556                    name: "sample.txt".to_string(),
557                    pieces: Some(ByteBuf::from(vec![
558                        // D4 91  58   7F  1C  42   DF   F0   CB  0F   F5   C2   B8   CE   FE  22   B3   AD  31  0A  // hex
559                        212, 145, 88, 127, 28, 66, 223, 240, 203, 15, 245, 194, 184, 206, 254, 34, 179, 173, 49, 10, // dec
560                    ])),
561                    piece_length: 16384,
562                    md5sum: None,
563                    length: Some(sample_data_in_txt_file.len().try_into().unwrap()),
564                    files: None,
565                    private: None,
566                    path: None,
567                    root_hash: None,
568                    source: Some("ABC".to_string()), // The tracker three-letter code
569                };
570
571                let torrent = Torrent {
572                    info: info.clone(),
573                    announce: None,
574                    announce_list: Some(vec![]),
575                    creation_date: None,
576                    comment: None,
577                    created_by: None,
578                    nodes: None,
579                    encoding: None,
580                    httpseeds: None,
581                };
582
583                assert_eq!(torrent.canonical_info_hash_hex(), "ccc1cf4feb59f3fa85c96c9be1ebbafcfe8a9cc8");
584            }
585
586            #[test]
587            fn a_simple_single_file_private_torrent() {
588                let sample_data_in_txt_file = "mandelbrot\n";
589
590                let info = TorrentInfoDictionary {
591                    name: "sample.txt".to_string(),
592                    pieces: Some(ByteBuf::from(vec![
593                        // D4 91  58   7F  1C  42   DF   F0   CB  0F   F5   C2   B8   CE   FE  22   B3   AD  31  0A  // hex
594                        212, 145, 88, 127, 28, 66, 223, 240, 203, 15, 245, 194, 184, 206, 254, 34, 179, 173, 49, 10, // dec
595                    ])),
596                    piece_length: 16384,
597                    md5sum: None,
598                    length: Some(sample_data_in_txt_file.len().try_into().unwrap()),
599                    files: None,
600                    private: Some(1),
601                    path: None,
602                    root_hash: None,
603                    source: None,
604                };
605
606                let torrent = Torrent {
607                    info: info.clone(),
608                    announce: None,
609                    announce_list: Some(vec![]),
610                    creation_date: None,
611                    comment: None,
612                    created_by: None,
613                    nodes: None,
614                    encoding: None,
615                    httpseeds: None,
616                };
617
618                assert_eq!(torrent.canonical_info_hash_hex(), "d3a558d0a19aaa23ba6f9f430f40924d10fefa86");
619            }
620        }
621    }
622}