Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ uuid = { version = "1", features = ["v4"] }
colored = "2.1.0"
url = "2.5.0"
tempfile = "3.9.0"
clap = { version = "4.4.18", features = ["derive"]}
anyhow = "1.0.79"

[dev-dependencies]
criterion = { version = "0.5.1", features = ["async_tokio"] }
Expand Down
89 changes: 75 additions & 14 deletions src/bin/http_tracker_client.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,64 @@
use std::env;
//! HTTP Tracker client:
//!
//! Examples:
//!
//! `Announce` request:
//!
//! ```text
//! cargo run --bin http_tracker_client announce http://127.0.0.1:7070 9c38422213e30bff212b30c360d26f9a02136422 | jq
//! ```
//!
//! `Scrape` request:
//!
//! ```text
//! cargo run --bin http_tracker_client scrape http://127.0.0.1:7070 9c38422213e30bff212b30c360d26f9a02136422 | jq
//! ```
use std::str::FromStr;

use anyhow::Context;
use clap::{Parser, Subcommand};
use reqwest::Url;
use torrust_tracker::shared::bit_torrent::info_hash::InfoHash;
use torrust_tracker::shared::bit_torrent::tracker::http::client::requests::announce::QueryBuilder;
use torrust_tracker::shared::bit_torrent::tracker::http::client::responses::announce::Announce;
use torrust_tracker::shared::bit_torrent::tracker::http::client::Client;
use torrust_tracker::shared::bit_torrent::tracker::http::client::responses::scrape;
use torrust_tracker::shared::bit_torrent::tracker::http::client::{requests, Client};

#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
#[command(subcommand)]
command: Command,
}

#[derive(Subcommand, Debug)]
enum Command {
Announce { tracker_url: String, info_hash: String },
Scrape { tracker_url: String, info_hashes: Vec<String> },
}

#[tokio::main]
async fn main() {
let args: Vec<String> = env::args().collect();
if args.len() != 3 {
eprintln!("Error: invalid number of arguments!");
eprintln!("Usage: cargo run --bin http_tracker_client <HTTP_TRACKER_URL> <INFO_HASH>");
eprintln!("Example: cargo run --bin http_tracker_client https://tracker.torrust-demo.com 9c38422213e30bff212b30c360d26f9a02136422");
std::process::exit(1);
async fn main() -> anyhow::Result<()> {
let args = Args::parse();

match args.command {
Command::Announce { tracker_url, info_hash } => {
announce_command(tracker_url, info_hash).await?;
}
Command::Scrape {
tracker_url,
info_hashes,
} => {
scrape_command(&tracker_url, &info_hashes).await?;
}
}
Ok(())
}

let base_url = Url::parse(&args[1]).expect("arg 1 should be a valid HTTP tracker base URL");
let info_hash = InfoHash::from_str(&args[2]).expect("arg 2 should be a valid infohash");
async fn announce_command(tracker_url: String, info_hash: String) -> anyhow::Result<()> {
let base_url = Url::parse(&tracker_url).context("failed to parse HTTP tracker base URL")?;
let info_hash =
InfoHash::from_str(&info_hash).expect("Invalid infohash. Example infohash: `9c38422213e30bff212b30c360d26f9a02136422`");

let response = Client::new(base_url)
.announce(&QueryBuilder::with_default_values().with_info_hash(&info_hash).query())
Expand All @@ -27,9 +67,30 @@ async fn main() {
let body = response.bytes().await.unwrap();

let announce_response: Announce = serde_bencode::from_bytes(&body)
.unwrap_or_else(|_| panic!("response body should be a valid announce response, got \"{:#?}\"", &body));
.unwrap_or_else(|_| panic!("response body should be a valid announce response, got: \"{:#?}\"", &body));

let json = serde_json::to_string(&announce_response).context("failed to serialize scrape response into JSON")?;

println!("{json}");

Ok(())
}

async fn scrape_command(tracker_url: &str, info_hashes: &[String]) -> anyhow::Result<()> {
let base_url = Url::parse(tracker_url).context("failed to parse HTTP tracker base URL")?;

let query = requests::scrape::Query::try_from(info_hashes).context("failed to parse infohashes")?;

let response = Client::new(base_url).scrape(&query).await;

let body = response.bytes().await.unwrap();

let scrape_response = scrape::Response::try_from_bencoded(&body)
.unwrap_or_else(|_| panic!("response body should be a valid scrape response, got: \"{:#?}\"", &body));

let json = serde_json::to_string(&scrape_response).context("failed to serialize scrape response into JSON")?;

let json = serde_json::to_string(&announce_response).expect("announce response should be a valid JSON");
println!("{json}");

print!("{json}");
Ok(())
}
33 changes: 32 additions & 1 deletion src/shared/bit_torrent/tracker/http/client/requests/scrape.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use std::fmt;
use std::convert::TryFrom;
use std::error::Error;
use std::fmt::{self};
use std::str::FromStr;

use crate::shared::bit_torrent::info_hash::InfoHash;
Expand All @@ -14,6 +16,35 @@ impl fmt::Display for Query {
}
}

#[derive(Debug)]
#[allow(dead_code)]
pub struct ConversionError(String);

impl fmt::Display for ConversionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Invalid infohash: {}", self.0)
}
}

impl Error for ConversionError {}

impl TryFrom<&[String]> for Query {
type Error = ConversionError;

fn try_from(info_hashes: &[String]) -> Result<Self, Self::Error> {
let mut validated_info_hashes: Vec<ByteArray20> = Vec::new();

for info_hash in info_hashes {
let validated_info_hash = InfoHash::from_str(info_hash).map_err(|_| ConversionError(info_hash.clone()))?;
validated_info_hashes.push(validated_info_hash.0);
}

Ok(Self {
info_hash: validated_info_hashes,
})
}
}

/// HTTP Tracker Scrape Request:
///
/// <https://www.bittorrent.org/beps/bep_0048.html>
Expand Down
31 changes: 29 additions & 2 deletions src/shared/bit_torrent/tracker/http/client/responses/scrape.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
use std::collections::HashMap;
use std::fmt::Write;
use std::str;

use serde::{self, Deserialize, Serialize};
use serde::ser::SerializeMap;
use serde::{self, Deserialize, Serialize, Serializer};
use serde_bencode::value::Value;

use crate::shared::bit_torrent::tracker::http::{ByteArray20, InfoHash};

#[derive(Debug, PartialEq, Default)]
#[derive(Debug, PartialEq, Default, Deserialize)]
pub struct Response {
pub files: HashMap<ByteArray20, File>,
}
Expand Down Expand Up @@ -60,6 +62,31 @@ struct DeserializedResponse {
pub files: Value,
}

// Custom serialization for Response
impl Serialize for Response {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut map = serializer.serialize_map(Some(self.files.len()))?;
for (key, value) in &self.files {
// Convert ByteArray20 key to hex string
let hex_key = byte_array_to_hex_string(key);
map.serialize_entry(&hex_key, value)?;
}
map.end()
}
}

// Helper function to convert ByteArray20 to hex string
fn byte_array_to_hex_string(byte_array: &ByteArray20) -> String {
let mut hex_string = String::with_capacity(byte_array.len() * 2);
for byte in byte_array {
write!(hex_string, "{byte:02x}").expect("Writing to string should never fail");
}
hex_string
}

#[derive(Default)]
pub struct ResponseBuilder {
response: Response,
Expand Down