Skip to content

Commit 42840d4

Browse files
committed
test: [#261]: do not allow uploading two torrents with the same canonical infohash
If you upload a torrent, the infohash migth change if the `info` dictionary contains custom fields. The Index removes non-standard custom fields, and that generates a new infohash for the torrent. If you upload a second torrent which is different from a previous one only in the custom fields, the same canonical infohash will be generated, so the torrent will be rejected as duplicated. The new original infohash will be stored in the database.
1 parent 3b7a762 commit 42840d4

File tree

3 files changed

+144
-6
lines changed

3 files changed

+144
-6
lines changed

tests/common/contexts/torrent/file.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,21 @@ use serde::Deserialize;
88
use which::which;
99

1010
/// Attributes parsed from a torrent file.
11-
#[derive(Deserialize, Clone)]
11+
#[derive(Deserialize, Clone, Debug)]
1212
pub struct TorrentFileInfo {
1313
pub name: String,
1414
pub comment: Option<String>,
15-
pub creation_date: u64,
16-
pub created_by: String,
15+
pub creation_date: Option<u64>,
16+
pub created_by: Option<String>,
1717
pub source: Option<String>,
1818
pub info_hash: String,
1919
pub torrent_size: u64,
2020
pub content_size: u64,
2121
pub private: bool,
2222
pub tracker: Option<String>,
23-
pub announce_list: Vec<Vec<String>>,
23+
pub announce_list: Option<Vec<Vec<String>>>,
2424
pub update_url: Option<String>,
25-
pub dht_nodes: Vec<String>,
25+
pub dht_nodes: Option<Vec<String>>,
2626
pub piece_size: u64,
2727
pub piece_count: u64,
2828
pub file_count: u64,

tests/common/contexts/torrent/fixtures.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ use std::fs::File;
22
use std::io::Write;
33
use std::path::{Path, PathBuf};
44

5+
use serde::{Deserialize, Serialize};
6+
use serde_bytes::ByteBuf;
57
use tempfile::{tempdir, TempDir};
8+
use torrust_index_backend::services::hasher::sha1;
9+
use torrust_index_backend::utils::hex::into_bytes;
610
use uuid::Uuid;
711

812
use super::file::{create_torrent, parse_torrent, TorrentFileInfo};
@@ -94,6 +98,45 @@ impl TestTorrent {
9498
}
9599
}
96100

101+
pub fn with_custom_info_dict_field(id: Uuid, file_contents: &str, custom: &str) -> Self {
102+
let temp_dir = temp_dir();
103+
104+
let torrents_dir_path = temp_dir.path().to_owned();
105+
106+
// Create the torrent in memory
107+
let torrent = TestTorrentWithCustomInfoField::with_contents(id, file_contents, custom);
108+
109+
// Bencode the torrent
110+
let torrent_data = TestTorrentWithCustomInfoField::encode(&torrent).unwrap();
111+
112+
// Torrent temporary file path
113+
let filename = format!("file-{id}.txt.torrent");
114+
let torrent_path = torrents_dir_path.join(filename.clone());
115+
116+
// Write the torrent file to the temporary file
117+
let mut file = File::create(torrent_path.clone()).unwrap();
118+
file.write_all(&torrent_data).unwrap();
119+
120+
// Load torrent binary file
121+
let torrent_file = BinaryFile::from_file_at_path(&torrent_path);
122+
123+
// Load torrent file metadata
124+
let torrent_info = parse_torrent(&torrent_path);
125+
126+
let torrent_to_index = TorrentIndexInfo {
127+
title: format!("title-{id}"),
128+
description: format!("description-{id}"),
129+
category: software_predefined_category_name(),
130+
torrent_file,
131+
name: filename,
132+
};
133+
134+
TestTorrent {
135+
file_info: torrent_info,
136+
index_info: torrent_to_index,
137+
}
138+
}
139+
97140
pub fn info_hash(&self) -> InfoHash {
98141
self.file_info.info_hash.clone()
99142
}
@@ -128,3 +171,65 @@ pub fn random_txt_file(dir: &Path, id: &Uuid) -> String {
128171
pub fn temp_dir() -> TempDir {
129172
tempdir().unwrap()
130173
}
174+
175+
/// A minimal torrent file with a custom field in the info dict.
176+
///
177+
/// ```json
178+
/// {
179+
/// "info": {
180+
/// "length": 602515,
181+
/// "name": "mandelbrot_set_01",
182+
/// "piece length": 32768,
183+
/// "pieces": "<hex>8A 88 32 BE ED 05 5F AA C4 AF 4A 90 4B 9A BF 0D EC 83 42 1C 73 39 05 B8 D6 20 2C 1B D1 8A 53 28 1F B5 D4 23 0A 23 C8 DB AC C4 E6 6B 16 12 08 C7 A4 AD 64 45 70 ED 91 0D F1 38 E7 DF 0C 1A D0 C9 23 27 7C D1 F9 D4 E5 A1 5F F5 E5 A0 E4 9E FB B1 43 F5 4B AD 0E D4 9D CB 49 F7 E6 7B BA 30 5F AF F9 88 56 FB 45 9A B4 95 92 3E 2C 7F DA A6 D3 82 E7 63 A3 BB 4B 28 F3 57 C7 CB 7D 8C 06 E3 46 AB D7 E8 8E 8A 8C 9F C7 E6 C5 C5 64 82 ED 47 BB 2A F1 B7 3F A5 3C 5B 9C AF 43 EC 2A E1 08 68 9A 49 C8 BF 1B 07 AD BE E9 2D 7E BE 9C 18 7F 4C A1 97 0E 54 3A 18 94 0E 60 8D 5C 69 0E 41 46 0D 3C 9A 37 F6 81 62 4F 95 C0 73 92 CA 9A D5 A9 89 AC 8B 85 12 53 0B FB E2 96 26 3E 26 A6 5B 70 53 48 65 F3 6C 27 0F 6B BD 1C EE EB 1A 9D 5F 77 A8 D8 AF D8 14 82 4A E0 B4 62 BC F1 A5 F5 F2 C7 60 F8 38 C8 5B 0B A9 07 DD 86 FA C0 7B F0 26 D7 D1 9A 42 C3 1F 9F B9 59 83 10 62 41 E9 06 3C 6D A1 19 75 01 57 25 9E B7 FE DF 91 04 D4 51 4B 6D 44 02 8D 31 8E 84 26 95 0F 30 31 F0 2C 16 39 BD 53 1D CF D3 5E 3E 41 A9 1E 14 3F 73 24 AC 5E 9E FC 4D C5 70 45 0F 45 8B 9B 52 E6 D0 26 47 8F 43 08 9E 2A 7C C5 92 D5 86 36 FE 48 E9 B8 86 84 92 23 49 5B EE C4 31 B2 1D 10 75 8E 4C 07 84 8F</hex>",
184+
/// "custom": "custom03"
185+
/// }
186+
/// }
187+
/// ```
188+
///
189+
/// Changing the value of the `custom` field will change the info-hash of the torrent.
190+
#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)]
191+
pub struct TestTorrentWithCustomInfoField {
192+
pub info: InfoDictWithCustomField,
193+
}
194+
195+
/// A minimal torrent info dict with a custom field.
196+
#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]
197+
pub struct InfoDictWithCustomField {
198+
#[serde(default)]
199+
pub length: i64,
200+
#[serde(default)]
201+
pub name: String,
202+
#[serde(rename = "piece length")]
203+
pub piece_length: i64,
204+
#[serde(default)]
205+
pub pieces: ByteBuf,
206+
#[serde(default)]
207+
pub custom: String,
208+
}
209+
210+
impl TestTorrentWithCustomInfoField {
211+
pub fn with_contents(id: Uuid, file_contents: &str, custom: &str) -> Self {
212+
let sha1_of_file_contents = sha1(file_contents);
213+
let pieces = into_bytes(&sha1_of_file_contents).expect("sha1 of test torrent contents cannot be converted to bytes");
214+
215+
Self {
216+
info: InfoDictWithCustomField {
217+
length: i64::try_from(file_contents.len()).expect("file contents size in bytes cannot exceed i64::MAX"),
218+
name: format!("file-{id}.txt"),
219+
piece_length: 16384,
220+
pieces: ByteBuf::from(pieces),
221+
custom: custom.to_owned(),
222+
},
223+
}
224+
}
225+
226+
pub fn encode(torrent: &Self) -> Result<Vec<u8>, serde_bencode::Error> {
227+
match serde_bencode::to_bytes(torrent) {
228+
Ok(bencode_bytes) => Ok(bencode_bytes),
229+
Err(e) => {
230+
eprintln!("{e:?}");
231+
Err(e)
232+
}
233+
}
234+
}
235+
}

tests/e2e/web/api/v1/contexts/torrent/contract.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,10 +293,11 @@ mod for_authenticated_users {
293293

294294
use torrust_index_backend::utils::parse_torrent::decode_torrent;
295295
use torrust_index_backend::web::api;
296+
use uuid::Uuid;
296297

297298
use crate::common::asserts::assert_json_error_response;
298299
use crate::common::client::Client;
299-
use crate::common::contexts::torrent::fixtures::random_torrent;
300+
use crate::common::contexts::torrent::fixtures::{random_torrent, TestTorrent};
300301
use crate::common::contexts::torrent::forms::UploadTorrentMultipartForm;
301302
use crate::common::contexts::torrent::responses::UploadedTorrentResponse;
302303
use crate::e2e::environment::TestEnv;
@@ -410,6 +411,38 @@ mod for_authenticated_users {
410411
assert_eq!(response.status, 400);
411412
}
412413

414+
#[tokio::test]
415+
async fn it_should_not_allow_uploading_a_torrent_whose_canonical_info_hash_already_exists() {
416+
let mut env = TestEnv::new();
417+
env.start(api::Version::V1).await;
418+
419+
if !env.provides_a_tracker() {
420+
println!("test skipped. It requires a tracker to be running.");
421+
return;
422+
}
423+
424+
let uploader = new_logged_in_user(&env).await;
425+
let client = Client::authenticated(&env.server_socket_addr().unwrap(), &uploader.token);
426+
427+
let id1 = Uuid::new_v4();
428+
429+
// Upload the first torrent
430+
let first_torrent = TestTorrent::with_custom_info_dict_field(id1, "data", "custom 01");
431+
let first_torrent_title = first_torrent.index_info.title.clone();
432+
let form: UploadTorrentMultipartForm = first_torrent.index_info.into();
433+
let _response = client.upload_torrent(form.into()).await;
434+
435+
// Upload the second torrent with the same canonical info-hash as the first one.
436+
// We need to change the title otherwise the torrent will be rejected
437+
// because of the duplicate title.
438+
let mut torrent_with_the_same_canonical_info_hash = TestTorrent::with_custom_info_dict_field(id1, "data", "custom 02");
439+
torrent_with_the_same_canonical_info_hash.index_info.title = format!("{first_torrent_title}-clone");
440+
let form: UploadTorrentMultipartForm = torrent_with_the_same_canonical_info_hash.index_info.into();
441+
let response = client.upload_torrent(form.into()).await;
442+
443+
assert_eq!(response.status, 400);
444+
}
445+
413446
#[tokio::test]
414447
async fn it_should_allow_authenticated_users_to_download_a_torrent_with_a_personal_announce_url() {
415448
let mut env = TestEnv::new();

0 commit comments

Comments
 (0)