diff --git a/crates/libtortillas/src/engine/mod.rs b/crates/libtortillas/src/engine/mod.rs index 10dd76c..ae465bc 100644 --- a/crates/libtortillas/src/engine/mod.rs +++ b/crates/libtortillas/src/engine/mod.rs @@ -26,7 +26,7 @@ //! //! // Add a torrent from a magnet URI //! let torrent = engine -//! .add_torrent("magnet:?xt=urn:btih:...") +//! .add_torrent(TorrentSource::magnet("magnet:?xt=urn:btih:...")) //! .await //! .expect("Failed to add torrent"); //! @@ -36,6 +36,7 @@ mod actor; mod messages; +mod source; use std::{net::SocketAddr, path::PathBuf}; @@ -44,12 +45,11 @@ use bon; use kameo::actor::{ActorRef, Spawn}; pub(crate) use messages::*; use serde::{Deserialize, Serialize}; -use tracing::error; +pub use source::*; use self::commands::{CreateTorrent, ExportEngine, StartAll}; use crate::{ errors::EngineError, - metainfo::{MetaInfo, TorrentFile}, peer::PeerId, settings::Settings, torrent::{PieceStorageStrategy, Torrent, TorrentExport}, @@ -205,15 +205,8 @@ impl Engine { /// automatically contacts trackers and connects to peers. The spawned /// [Torrent Actor](Torrent) will be controlled by the [Engine]. /// - /// This function accepts the following as input: - /// - A remote URL to a torrent file over HTTP/HTTPS - /// - The path, either absolute or relative, to a local torrent file - /// - A magnet URI - /// - /// If the inputted value is a remote url to a torrent file, this function - /// requests the bytes and deserializes them into a [TorrentFile]. If - /// it isn't, we assume that it is either a magnet URI or a path to a - /// torrent file, and pass the string to [MetaInfo::new]. + /// This function accepts a typed [`TorrentSource`] so frontends can pass + /// explicit user intent instead of relying on string-prefix detection. /// /// /// # Examples @@ -227,7 +220,7 @@ impl Engine { /// let engine = Engine::default(); /// let torrent_link = "https://example.com/example.torrent"; /// let torrent = engine - /// .add_torrent(torrent_link) + /// .add_torrent(TorrentSource::remote_torrent_url(torrent_link)) /// .await /// .expect("Failed to add torrent"); /// @@ -244,36 +237,15 @@ impl Engine { /// let engine = Engine::default(); /// let magnet_uri = "magnet:?xt=?????"; /// let torrent = engine - /// .add_torrent(magnet_uri) + /// .add_torrent(TorrentSource::magnet(magnet_uri)) /// .await /// .expect("Failed to add torrent"); /// /// println!("Started torrenting: {}", torrent.key()); /// } /// ``` - pub async fn add_torrent(&self, metainfo: impl ToString) -> Result { - let metainfo = metainfo.to_string(); - // File paths should either start with "/" or "./", and magnet URIs start - // with "magnet:", so a check like this should be entirely appropriate. - let metainfo = if metainfo.starts_with("http") { - let torrent_file_bytes = reqwest::get(&metainfo) - .await - .map_err(EngineError::MetaInfoFetchError)? - .bytes() - .await - .map_err(EngineError::MetaInfoFetchError)?; - let torrent_file = TorrentFile::parse(&torrent_file_bytes); - torrent_file.map_err(|_| { - error!(remote_url = metainfo); - EngineError::MetaInfoDeserializeError - })? - } else { - MetaInfo::new(metainfo.clone()).await.map_err(|_| { - error!(magnet_uri_or_file = metainfo); - EngineError::MetaInfoDeserializeError - })? - }; - + pub async fn add_torrent(&self, source: TorrentSource) -> Result { + let metainfo = source.into_metainfo().await?; let info_hash = metainfo.info_hash()?; let torrent_ref = self @@ -320,3 +292,41 @@ impl Default for Engine { pub struct EngineExport { pub torrents: Vec, } + +#[cfg(test)] +mod tests { + use crate::{ + engine::{Engine, TorrentSource}, + errors::EngineError, + testing::{BIG_BUCK_BUNNY_INFO_HASH, BIG_BUCK_BUNNY_TORRENT_FILE, torrent_fixture_path}, + }; + + #[tokio::test] + async fn engine_when_torrent_source_is_file_path_then_adds_torrent() { + let engine = Engine::builder().autostart(false).build(); + let source = + TorrentSource::torrent_file_path(torrent_fixture_path(BIG_BUCK_BUNNY_TORRENT_FILE)); + + let torrent = engine.add_torrent(source).await.unwrap(); + let export = engine.export().await.unwrap(); + + assert_eq!(torrent.info_hash().to_hex(), BIG_BUCK_BUNNY_INFO_HASH); + assert_eq!(export.torrents.len(), 1); + } + + #[tokio::test] + async fn engine_when_torrent_source_type_does_not_match_then_returns_typed_error() { + let engine = Engine::builder().autostart(false).build(); + let source = TorrentSource::remote_torrent_url("magnet:?xt=urn:btih:not-a-url"); + + let error = engine.add_torrent(source).await.unwrap_err(); + + assert!(matches!( + error, + EngineError::InvalidTorrentSource { + source_type: "remote URL", + .. + } + )); + } +} diff --git a/crates/libtortillas/src/engine/source.rs b/crates/libtortillas/src/engine/source.rs new file mode 100644 index 0000000..5cea4c8 --- /dev/null +++ b/crates/libtortillas/src/engine/source.rs @@ -0,0 +1,309 @@ +use std::{path::PathBuf, time::Duration}; + +use bytes::Bytes; +use reqwest::Url; +use tracing::error; + +use crate::{ + errors::EngineError, + metainfo::{MagnetUri, MetaInfo, TorrentFile}, +}; + +/// Timeout for remote torrent file fetches. +const REMOTE_TORRENT_FETCH_TIMEOUT: Duration = Duration::from_secs(30); +/// Maximum size for a remote torrent file (10 MB). +const MAX_TORRENT_FILE_SIZE: usize = 10 * 1024 * 1024; + +/// Explicit source type for adding a torrent to an [`Engine`](super::Engine). +/// +/// Frontends should map user input into one of these variants before calling +/// [`Engine::add_torrent`](super::Engine::add_torrent). That keeps UI intent +/// separate from parsing and avoids guessing whether a string is a URL, path, +/// or magnet URI. +/// +/// ``` +/// use std::path::PathBuf; +/// +/// use libtortillas::prelude::TorrentSource; +/// +/// enum UserTorrentInput { +/// PastedMagnet(String), +/// PickedTorrentFile(PathBuf), +/// DroppedTorrentFile(Vec), +/// RemoteTorrentUrl(String), +/// } +/// +/// fn source_for(input: UserTorrentInput) -> TorrentSource { +/// match input { +/// UserTorrentInput::PastedMagnet(uri) => TorrentSource::magnet(uri), +/// UserTorrentInput::PickedTorrentFile(path) => TorrentSource::torrent_file_path(path), +/// UserTorrentInput::DroppedTorrentFile(bytes) => TorrentSource::torrent_file_bytes(bytes), +/// UserTorrentInput::RemoteTorrentUrl(url) => TorrentSource::remote_torrent_url(url), +/// } +/// } +/// ``` +#[derive(Debug, Clone)] +pub enum TorrentSource { + /// A `magnet:` URI entered or pasted by the user. + Magnet(String), + /// A local `.torrent` file path selected by the user. + TorrentFilePath(PathBuf), + /// Raw bytes of a `.torrent` file already loaded by the frontend. + TorrentFileBytes(Bytes), + /// An HTTP or HTTPS URL pointing to a remote `.torrent` file. + RemoteTorrentUrl(String), +} + +impl TorrentSource { + /// Creates a source from a `magnet:` URI. + #[must_use] + pub fn magnet(uri: impl Into) -> Self { + Self::Magnet(uri.into()) + } + + /// Creates a source from a local `.torrent` file path. + #[must_use] + pub fn torrent_file_path(path: impl Into) -> Self { + Self::TorrentFilePath(path.into()) + } + + /// Creates a source from already-loaded `.torrent` file bytes. + #[must_use] + pub fn torrent_file_bytes(bytes: impl Into) -> Self { + Self::TorrentFileBytes(bytes.into()) + } + + /// Creates a source from an HTTP or HTTPS URL to a remote `.torrent` file. + #[must_use] + pub fn remote_torrent_url(url: impl Into) -> Self { + Self::RemoteTorrentUrl(url.into()) + } + + pub(crate) async fn into_metainfo(self) -> Result { + match self { + Self::Magnet(uri) => metainfo_from_magnet(uri), + Self::TorrentFilePath(path) => metainfo_from_path(path).await, + Self::TorrentFileBytes(bytes) => metainfo_from_bytes(&bytes), + Self::RemoteTorrentUrl(url) => metainfo_from_remote_url(url).await, + } + } +} + +fn metainfo_from_magnet(uri: String) -> Result { + if !uri.starts_with("magnet:") { + return Err(EngineError::InvalidTorrentSource { + source_type: "magnet URI", + reason: "expected a source beginning with `magnet:`".to_string(), + }); + } + + MagnetUri::parse(uri.clone()).map_err(|error| { + error!(magnet_uri = uri, %error); + EngineError::InvalidTorrentSource { + source_type: "magnet URI", + reason: format!("failed to parse magnet URI: {}", error), + } + }) +} + +async fn metainfo_from_path(path: PathBuf) -> Result { + TorrentFile::read(path.clone()).await.map_err(|error| { + error!(torrent_file_path = %path.display(), %error); + EngineError::MetaInfoDeserializeError + }) +} + +fn metainfo_from_bytes(bytes: &[u8]) -> Result { + TorrentFile::parse(bytes).map_err(|error| { + error!(%error); + EngineError::MetaInfoDeserializeError + }) +} + +async fn metainfo_from_remote_url(url: String) -> Result { + validate_remote_torrent_url(&url)?; + + let client = reqwest::Client::builder() + .timeout(REMOTE_TORRENT_FETCH_TIMEOUT) + .build() + .map_err(EngineError::MetaInfoFetchError)?; + + let response = client + .get(&url) + .send() + .await + .map_err(EngineError::MetaInfoFetchError)?; + + if !response.status().is_success() { + return Err(EngineError::MetaInfoFetchError( + reqwest::Error::from(response.error_for_status().unwrap_err()), + )); + } + + let content_length = response.content_length().unwrap_or(0); + if content_length > MAX_TORRENT_FILE_SIZE as u64 { + return Err(EngineError::InvalidTorrentSource { + source_type: "remote URL", + reason: format!( + "torrent file too large: {} bytes (max {} bytes)", + content_length, MAX_TORRENT_FILE_SIZE + ), + }); + } + + let mut torrent_file_bytes = Vec::new(); + let mut stream = response.bytes_stream(); + use futures::StreamExt; + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(EngineError::MetaInfoFetchError)?; + if torrent_file_bytes.len() + chunk.len() > MAX_TORRENT_FILE_SIZE { + return Err(EngineError::InvalidTorrentSource { + source_type: "remote URL", + reason: format!( + "torrent file exceeds maximum size of {} bytes", + MAX_TORRENT_FILE_SIZE + ), + }); + } + torrent_file_bytes.extend_from_slice(&chunk); + } + + metainfo_from_bytes(&torrent_file_bytes).inspect_err(|_| { + error!(remote_url = url); + }) +} + +fn validate_remote_torrent_url(url: &str) -> Result<(), EngineError> { + let parsed_url = Url::parse(url).map_err(|error| EngineError::InvalidTorrentSource { + source_type: "remote URL", + reason: error.to_string(), + })?; + + match parsed_url.scheme() { + "http" | "https" => Ok(()), + scheme => Err(EngineError::InvalidTorrentSource { + source_type: "remote URL", + reason: format!("expected http or https URL, got `{scheme}`"), + }), + } +} + +#[cfg(test)] +mod tests { + use tokio::fs; + + use super::*; + use crate::{ + metainfo::MetaInfo, + testing::{BIG_BUCK_BUNNY_INFO_HASH, BIG_BUCK_BUNNY_TORRENT_FILE, torrent_fixture_path}, + }; + + #[tokio::test] + async fn torrent_source_when_source_is_torrent_file_path_then_loads_metainfo() { + let source = + TorrentSource::torrent_file_path(torrent_fixture_path(BIG_BUCK_BUNNY_TORRENT_FILE)); + + let metainfo = source.into_metainfo().await.unwrap(); + + assert_info_hash(metainfo); + } + + #[tokio::test] + async fn torrent_source_when_source_is_torrent_file_bytes_then_loads_metainfo() { + let bytes = fs::read(torrent_fixture_path(BIG_BUCK_BUNNY_TORRENT_FILE)) + .await + .unwrap(); + let source = TorrentSource::torrent_file_bytes(bytes); + + let metainfo = source.into_metainfo().await.unwrap(); + + assert_info_hash(metainfo); + } + + #[tokio::test] + async fn torrent_source_when_source_is_magnet_uri_then_loads_metainfo() { + let source = TorrentSource::magnet(crate::testing::BIG_BUCK_BUNNY_MAGNET); + + let metainfo = source.into_metainfo().await.unwrap(); + + assert_info_hash(metainfo); + } + + #[tokio::test] + async fn torrent_source_when_magnet_variant_has_path_then_returns_typed_error() { + let source = TorrentSource::magnet("./tests/torrents/big-buck-bunny.torrent"); + + let error = source.into_metainfo().await.unwrap_err(); + + assert!(matches!( + error, + EngineError::InvalidTorrentSource { + source_type: "magnet URI", + .. + } + )); + } + + #[tokio::test] + async fn torrent_source_when_remote_url_is_not_http_then_returns_typed_error() { + let source = TorrentSource::remote_torrent_url("file:///tmp/example.torrent"); + + let error = source.into_metainfo().await.unwrap_err(); + + assert!(matches!( + error, + EngineError::InvalidTorrentSource { + source_type: "remote URL", + .. + } + )); + } + + #[tokio::test] + async fn torrent_source_when_remote_url_is_valid_http_then_downloads_and_loads_metainfo() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + // Read the torrent file bytes + let torrent_bytes = fs::read(torrent_fixture_path(BIG_BUCK_BUNNY_TORRENT_FILE)) + .await + .unwrap(); + + // Spin up a simple HTTP server + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("http://{}/test.torrent", addr); + + // Spawn server task + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buf = vec![0u8; 1024]; + let _ = socket.read(&mut buf).await.unwrap(); + + // Send HTTP response with torrent file + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/x-bittorrent\r\n\r\n", + torrent_bytes.len() + ); + socket.write_all(response.as_bytes()).await.unwrap(); + socket.write_all(&torrent_bytes).await.unwrap(); + socket.flush().await.unwrap(); + }); + + // Give the server a moment to start + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + + // Test the remote URL source + let source = TorrentSource::remote_torrent_url(url); + let metainfo = source.into_metainfo().await.unwrap(); + + assert_info_hash(metainfo); + } + + fn assert_info_hash(metainfo: MetaInfo) { + let info_hash = metainfo.info_hash().unwrap(); + + assert_eq!(info_hash.to_hex(), BIG_BUCK_BUNNY_INFO_HASH); + } +} diff --git a/crates/libtortillas/src/errors.rs b/crates/libtortillas/src/errors.rs index 9be6be1..e760707 100644 --- a/crates/libtortillas/src/errors.rs +++ b/crates/libtortillas/src/errors.rs @@ -48,6 +48,13 @@ pub enum EngineError { #[error("Failed to deserialize meta info")] MetaInfoDeserializeError, + /// A typed torrent source was invalid for its declared source type. + #[error("Invalid {source_type} torrent source: {reason}")] + InvalidTorrentSource { + source_type: &'static str, + reason: String, + }, + /// Tried to start torrenting, but a torrent with the same info hash already /// exists #[error("Torrent already exists: {0}")]