chore: initial sanitized public snapshot

This commit is contained in:
Aria2 Rust Pro Contributors
2026-07-18 15:24:15 +08:00
commit e489b29e01
321 changed files with 76890 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "aria2-rust-pro-protocol"
version.workspace = true
edition.workspace = true
license.workspace = true
description.workspace = true
readme.workspace = true
keywords.workspace = true
categories.workspace = true
rust-version.workspace = true
[lib]
name = "aria2_rust_pro_protocol"
path = "src/lib.rs"
[dependencies]
adler2 = "2"
crc32fast = "1"
md-5 = "0.10"
quick-xml = "0.38"
sha1 = "0.10"
sha2 = "0.10"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
aria2-rust-pro-storage.workspace = true
[lints]
workspace = true
@@ -0,0 +1,59 @@
//! Authentication models shared across protocol connectors.
#![forbid(unsafe_code)]
use std::collections::HashMap;
/// Authentication scheme recognized by the protocol layer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AuthScheme {
/// HTTP Basic authentication.
Basic,
/// HTTP Digest authentication.
Digest,
/// Bearer-token authentication.
Bearer,
/// SPNEGO or Negotiate authentication.
Negotiate,
/// NTLM authentication.
Ntlm,
/// OAuth2-derived bearer flows.
OAuth2,
/// Caller accepts any supported scheme.
Any,
}
/// Authentication material supplied to a protocol connector.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AuthCredentialModel {
/// Scheme the credential applies to.
pub scheme: AuthScheme,
/// Optional username component.
pub username: Option<String>,
/// Optional password or shared secret.
pub password: Option<String>,
/// Optional opaque bearer token.
pub token: Option<String>,
/// Optional authentication realm.
pub realm: Option<String>,
}
/// Authentication challenge emitted by a server.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AuthChallengeModel {
/// Challenged scheme.
pub scheme: AuthScheme,
/// Optional realm attached to the challenge.
pub realm: Option<String>,
/// Additional challenge parameters keyed by attribute name.
pub parameters: HashMap<String, String>,
}
/// Cached credential entry associated with an origin.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AuthCacheEntry {
/// Origin or protection-space key.
pub origin: String,
/// Credential cached for the origin.
pub credential: AuthCredentialModel,
}
+23
View File
@@ -0,0 +1,23 @@
//! BitTorrent-oriented re-exports from the protocol crate.
#![forbid(unsafe_code)]
pub use crate::{
bt_metalink::{
BtMetalinkError, MagnetUri, MetalinkDocument, ParserStatus, ProtocolSupportMatrix,
ProtocolSupportState, TorrentMetadata, TorrentMetadataError, protocol_support_matrix,
},
magnet::{MagnetBootstrapModel, MagnetMetadataModel, MagnetUriModel, parse_magnet_bootstrap},
metalink::{
MetalinkChecksumModel, MetalinkDocumentModel, MetalinkFileModel, MetalinkParseResult,
MetalinkParserModel, MetalinkResourceModel,
},
torrent::{
DhtMessageModel, PeerWireExtensionHandshakeModel, PeerWireMessageModel,
PeerWireMetadataMessageModel, PeerWireMetadataMessageType, TorrentBootstrapModel,
TorrentFileEntryModel, TorrentHashModel, TorrentInfoModel, TorrentMessageModel,
TorrentMetadataModel, TorrentPeerModel, TorrentPieceModel, TorrentTrackerModel,
parse_torrent_bootstrap,
},
tracker::{DhtNodeModel, TrackerPeerListModel, TrackerRequestModel},
};
@@ -0,0 +1,391 @@
//! Compatibility wrappers that bridge BitTorrent, magnet, and Metalink models.
#![forbid(unsafe_code)]
use std::fmt::{Display, Formatter};
use crate::{
magnet::{MagnetUriModel, parse_magnet_uri},
metalink::{MetalinkDocumentModel, parse_metalink_document},
torrent::{TorrentMetadataModel, parse_torrent_metadata},
};
/// Declares how far a protocol family has progressed in the current implementation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtocolSupportState {
/// The protocol is known but not yet wired into the workspace.
Planned,
/// Parsing and model registration exist, but transfer execution is pending.
Registered,
/// A compatibility skeleton is present and exposes the public API shape.
Skeleton,
}
/// Summarizes protocol readiness across BitTorrent-adjacent inputs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProtocolSupportMatrix {
/// Current state of `.torrent` metadata handling.
pub bit_torrent: ProtocolSupportState,
/// Current state of magnet URI handling.
pub magnet: ProtocolSupportState,
/// Current state of Metalink XML handling.
pub metalink: ProtocolSupportState,
}
/// Returns the current protocol support matrix exposed by this compatibility layer.
#[must_use]
pub const fn protocol_support_matrix() -> ProtocolSupportMatrix {
ProtocolSupportMatrix {
bit_torrent: ProtocolSupportState::Skeleton,
magnet: ProtocolSupportState::Registered,
metalink: ProtocolSupportState::Registered,
}
}
/// Compatibility wrapper for parsed magnet URI metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MagnetUri {
/// Parsed BTIH info hash.
pub info_hash: String,
/// Optional display name from the `dn` query field.
pub display_name: Option<String>,
/// Ordered tracker URLs from `tr` query fields.
pub trackers: Vec<String>,
/// Ordered web-seed URLs from `ws` query fields.
pub web_seeds: Vec<String>,
}
/// Compatibility wrapper for parsed Metalink documents.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MetalinkDocument {
/// Root element name used for diagnostics.
pub root_element: String,
/// Parser state describing whether a real model was produced.
pub status: ParserStatus,
/// Parsed Metalink document model when parsing succeeded.
pub document: Option<MetalinkDocumentModel>,
}
/// Records whether a compatibility parser stayed stubbed or produced a model.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParserStatus {
/// Parsing support is registered but no real model was built.
RegisteredStub,
/// Parsing produced a protocol-layer document model.
ParsedModel,
}
/// Errors raised while converting BitTorrent-adjacent formats into compatibility models.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BtMetalinkError {
/// The magnet URI was malformed.
InvalidMagnet {
/// Parser-specific explanation of the magnet failure.
reason: String,
},
/// The requested torrent feature is not yet implemented.
UnsupportedTorrentBinary,
/// The Metalink document was malformed or unsupported.
InvalidMetalink {
/// Parser-specific explanation of the Metalink failure.
reason: String,
},
}
impl Display for BtMetalinkError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidMagnet { reason } => write!(f, "invalid magnet: {reason}"),
Self::UnsupportedTorrentBinary => {
f.write_str("torrent binary parsing is not implemented")
}
Self::InvalidMetalink { reason } => write!(f, "invalid metalink: {reason}"),
}
}
}
impl std::error::Error for BtMetalinkError {}
impl MagnetUri {
/// Builds the compatibility wrapper from the protocol-layer model.
#[must_use]
pub fn from_model(model: MagnetUriModel) -> Self {
Self {
info_hash: model.info_hash,
display_name: model.display_name,
trackers: model.trackers,
web_seeds: model.web_seeds,
}
}
/// Parses a magnet URI into the compatibility skeleton.
///
/// # Errors
///
/// Returns an error when the `magnet:?` prefix is missing or the URI does
/// not contain an `xt=urn:btih:<hash>` value.
pub fn parse(input: &str) -> Result<Self, BtMetalinkError> {
parse_magnet_uri(input).map(Self::from_model)
}
}
impl MetalinkDocument {
/// Builds the compatibility wrapper from the protocol-layer Metalink model.
#[must_use]
pub fn from_model(model: MetalinkDocumentModel) -> Self {
Self {
root_element: "metalink".to_owned(),
status: ParserStatus::ParsedModel,
document: Some(model),
}
}
/// Parses Metalink XML text through the protocol-layer Metalink parser.
///
/// # Errors
///
/// Returns an error when the input is not a valid Metalink document or
/// when the document has no actionable resources.
pub fn parse(input: &str) -> Result<Self, BtMetalinkError> {
let document = parse_metalink_document(input)
.map_err(|reason| BtMetalinkError::InvalidMetalink { reason })?;
Ok(Self {
root_element: "metalink".to_owned(),
status: ParserStatus::ParsedModel,
document: Some(document),
})
}
}
/// Compatibility wrapper for parsed torrent metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TorrentMetadata {
/// Parsed torrent metadata model.
pub model: TorrentMetadataModel,
}
/// Errors raised while parsing `.torrent` metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TorrentMetadataError {
/// The torrent payload was syntactically invalid.
Invalid {
/// Parser-specific explanation of the torrent failure.
reason: String,
},
/// The payload used an unsupported feature.
Unsupported {
/// Name of the unsupported torrent feature.
feature: &'static str,
},
}
impl Display for TorrentMetadataError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid { reason } => write!(f, "invalid torrent metadata: {reason}"),
Self::Unsupported { feature } => write!(f, "{feature} is not implemented"),
}
}
}
impl std::error::Error for TorrentMetadataError {}
impl TorrentMetadata {
/// Wraps a protocol-layer torrent model.
#[must_use]
pub fn from_model(model: TorrentMetadataModel) -> Self {
Self { model }
}
/// Returns a shared reference to the underlying torrent metadata model.
#[must_use]
pub fn as_model(&self) -> &TorrentMetadataModel {
&self.model
}
/// Consumes the wrapper and returns the underlying torrent metadata model.
#[must_use]
pub fn into_model(self) -> TorrentMetadataModel {
self.model
}
/// Parses torrent binary metadata through the shared protocol-layer parser.
///
/// # Errors
///
/// Returns a structured invalid-metadata error when the payload cannot be parsed.
pub fn parse(input: &[u8]) -> Result<Self, TorrentMetadataError> {
parse_torrent_metadata(input)
.map(Self::from_model)
.map_err(|error| TorrentMetadataError::Invalid { reason: error })
}
}
#[cfg(test)]
mod tests {
use super::{
MagnetUri, MetalinkDocument, ParserStatus, TorrentMetadata, TorrentMetadataError,
parse_magnet_uri,
};
use crate::{
TorrentFileEntryModel, TorrentInfoModel, TorrentMetadataModel, TorrentPeerModel,
TorrentTrackerModel,
};
#[test]
fn magnet_wrapper_parse_matches_magnet_model_fields() {
let input = "magnet:?xt=urn:btih:0123456789abcdef&dn=Ubuntu%2024.04&tr=http%3A%2F%2Ftracker.example.org%2Fannounce&ws=https%3A%2F%2Fcdn.example.org%2Fubuntu.iso";
let parsed = MagnetUri::parse(input).expect("magnet wrapper should parse");
let model = parse_magnet_uri(input).expect("protocol magnet parser should parse");
assert_eq!(parsed.info_hash, model.info_hash);
assert_eq!(parsed.display_name, model.display_name);
assert_eq!(parsed.trackers, model.trackers);
assert_eq!(parsed.web_seeds, model.web_seeds);
}
#[test]
fn torrent_wrapper_round_trips_model_without_changing_shape() {
let model = TorrentMetadataModel {
info: TorrentInfoModel {
name: "sample".to_owned(),
piece_length: 16,
pieces: vec![[0_u8; 20]],
files: vec![TorrentFileEntryModel {
path: "sample.bin".to_owned(),
length: 16,
piece_offset: Some(0),
selected: true,
}],
hash: None,
private: false,
},
announce: Some("http://tracker.example.org/announce".to_owned()),
trackers: vec![TorrentTrackerModel {
url: "http://tracker.example.org/announce".to_owned(),
tier: Some(1),
id: None,
seeders: None,
leechers: None,
}],
peers: vec![TorrentPeerModel {
peer_id: None,
ip: "127.0.0.1".to_owned(),
port: 6881,
client_name: None,
interested: false,
choked: true,
}],
dht_nodes: vec!["router.example.org:6881".to_owned()],
pieces: Vec::new(),
creation_date: None,
comment: None,
};
let wrapper = TorrentMetadata::from_model(model.clone());
assert_eq!(wrapper.as_model(), &model);
assert_eq!(wrapper.into_model(), model);
}
#[test]
fn torrent_wrapper_surfaces_parse_errors_as_invalid_metadata() {
let error = TorrentMetadata::parse(b"not-a-torrent").expect_err("bad torrent should fail");
match error {
TorrentMetadataError::Invalid { reason } => assert!(!reason.is_empty()),
TorrentMetadataError::Unsupported { feature } => {
panic!("unexpected torrent error variant: unsupported feature {feature}")
}
}
}
#[test]
fn metalink_wrapper_parse_returns_real_parsed_model() {
let parsed = MetalinkDocument::parse(
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0" xmlns="urn:ietf:params:xml:ns:metalink">
<identity>wrapped fixture</identity>
<file name="wrapped.iso">
<description>real parser payload</description>
<url priority="1" location="us">https://mirror.example.com/wrapped.iso</url>
</file>
</metalink>"#,
)
.expect("metalink wrapper should parse through the real model");
assert_eq!(parsed.root_element, "metalink");
assert_eq!(parsed.status, ParserStatus::ParsedModel);
let document = parsed
.document
.expect("wrapper should retain parsed document");
assert_eq!(document.version.as_deref(), Some("4.0"));
assert_eq!(document.identity.as_deref(), Some("wrapped fixture"));
assert_eq!(document.files.len(), 1);
assert_eq!(document.files[0].name, "wrapped.iso");
assert_eq!(
document.files[0].description.as_deref(),
Some("real parser payload")
);
assert_eq!(
document.files[0].resources[0].url,
"https://mirror.example.com/wrapped.iso"
);
}
#[test]
fn metalink_wrapper_preserves_normalized_file_metadata_and_resource_hints() {
let parsed = MetalinkDocument::parse(
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0" xmlns="urn:ietf:params:xml:ns:metalink">
<identity>wrapper fixture</identity>
<file name=" wrapped.iso ">
<identity> release-42 </identity>
<signature><![CDATA[SIG-WRAP]]></signature>
<hash type="SHA256"> AA BB </hash>
<url location=" us " maxconnections="8">https://mirror.example.com/wrapped.iso</url>
</file>
</metalink>"#,
)
.expect("metalink wrapper should preserve normalized parser output");
let document = parsed
.document
.expect("wrapper should retain parsed document");
assert_eq!(document.identity.as_deref(), Some("wrapper fixture"));
assert_eq!(document.files[0].name, "wrapped.iso");
assert_eq!(document.files[0].identifier.as_deref(), Some("release-42"));
assert_eq!(document.files[0].signatures, vec!["SIG-WRAP".to_owned()]);
assert_eq!(document.files[0].checksums[0].algorithm, "sha-256");
assert_eq!(document.files[0].checksums[0].value, "aabb");
assert_eq!(
document.files[0].resources[0].location.as_deref(),
Some("us")
);
assert_eq!(document.files[0].resources[0].max_connections, Some(8));
assert_eq!(
document.files[0].resources[0].type_hint.as_deref(),
Some("https")
);
}
#[test]
fn metalink_wrapper_parse_rejects_invalid_root_only_stub_shape() {
let error = MetalinkDocument::parse(
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0" xmlns="urn:ietf:params:xml:ns:metalink">
<file name="empty.iso">
<url priority="1"> </url>
</file>
</metalink>"#,
)
.expect_err("root-only stub success should be rejected");
assert!(
error
.to_string()
.contains("metalink document contains no resource urls"),
"unexpected error: {error}"
);
}
}
@@ -0,0 +1,88 @@
//! Downloader traits plus real and fixture-backed transport implementations.
#![forbid(unsafe_code)]
pub(super) use std::{
collections::{BTreeMap, HashMap},
env,
error::Error as StdError,
fs::{File, OpenOptions},
io::SeekFrom,
sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicU64, Ordering},
},
time::{Duration, SystemTime, UNIX_EPOCH},
};
pub(super) use aria2_rust_pro_storage::{ByteSink, ObservedByteSink, ObservedFileSink};
pub(super) use reqwest::{
NoProxy, Proxy,
blocking::{Client, Response},
header::{HeaderMap, HeaderName, HeaderValue, RANGE},
};
/// Monotonic suffix used to keep streamed fixture temp paths unique even when
/// wall-clock precision collapses under parallel test execution.
static NEXT_TEMP_STREAM_SINK_ID: AtomicU64 = AtomicU64::new(0);
/// Process-wide cache for optional live HTTP timing diagnostics.
static HTTP_TIMING_PROBE_ENABLED: OnceLock<bool> = OnceLock::new();
/// Maximum normalized request shapes retained for repeated live HTTP requests.
const MAX_PREPARED_REQUEST_CACHE_ENTRIES: usize = 512;
/// Maximum proxy-specific clients retained to preserve connection pooling.
const MAX_PROXY_CLIENT_CACHE_ENTRIES: usize = 128;
/// Default idle connection budget kept per host for repeated live HTTP range work.
const LIVE_HTTP_POOL_MAX_IDLE_PER_HOST: usize = 32;
/// Idle timeout used to keep same-host range fanout warm across short runtime bursts.
const LIVE_HTTP_POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
/// TCP keepalive used for long-lived live HTTP sessions.
const LIVE_HTTP_TCP_KEEPALIVE: Duration = Duration::from_secs(30);
pub(super) use crate::{
auth::AuthCredentialModel,
ftp::{FtpConfigModel, FtpRequestModel, FtpResponseModel},
http::{
ChecksumSpec, ContentRangeSpec, HttpHeader, HttpRequestModel, HttpResponseHeaders,
HttpResponseModel, HttpTransferTaskModel, HttpVersion, RangeSpec, RangeUnit, ResponseBody,
},
metalink::MetalinkDocumentModel,
sftp::{SftpConfigModel, SftpRequestModel, SftpResponseModel},
torrent::TorrentMetadataModel,
transport::TransportError,
};
/// Transfer-facing traits shared by the downloader implementations.
mod contracts;
/// Core connector-backed downloader implementations and request normalization helpers.
mod core_downloader;
/// Fixture-backed downloader used by tests and local runtime smokes.
mod fixture_downloader;
/// Live reqwest-backed downloader connector implementation.
mod reqwest_connector;
pub use self::contracts::{
AuthProvider, ChecksumVerifier, Downloader, FtpConnector, HttpConnector, HttpsConnector,
MetalinkConnector, RetryStrategyProvider, SftpConnector, TorrentConnector,
};
pub use self::core_downloader::{
ConnectorBackedDownloader, HttpOnlyDownloader, NullHttpConnector, NullHttpsConnector,
};
pub use self::fixture_downloader::{FixtureHttpDownloader, FixtureStep, HttpFixtureResponseSpec};
pub use self::reqwest_connector::ReqwestHttpConnector;
#[cfg(test)]
fn execute_streamed_body(body: &[u8], checksum: Option<&ChecksumSpec>) -> ResponseBody {
fixture_downloader::execute_streamed_body(body, checksum)
}
#[cfg(test)]
fn temp_stream_sink_path() -> std::path::PathBuf {
fixture_downloader::temp_stream_sink_path()
}
#[cfg(test)]
fn request_body_bytes(body: &crate::http::HttpBody) -> Option<Vec<u8>> {
reqwest_connector::request_body_bytes(body)
}
#[cfg(test)]
mod downloader_tests;
@@ -0,0 +1,98 @@
use super::{
AuthCredentialModel, ChecksumSpec, FtpConfigModel, FtpRequestModel, FtpResponseModel,
HttpRequestModel, HttpResponseModel, HttpTransferTaskModel, MetalinkDocumentModel,
SftpConfigModel, SftpRequestModel, SftpResponseModel, TorrentMetadataModel, TransportError,
};
/// Verifies a checksum against downloaded payload bytes.
pub trait ChecksumVerifier {
/// Validates `payload` against `spec`.
fn verify_checksum(&self, spec: &ChecksumSpec, payload: &[u8]) -> Result<(), TransportError>;
}
/// Derives retry behavior for one HTTP request.
pub trait RetryStrategyProvider {
/// Returns the retry strategy that should apply to `request`.
fn retry_strategy(&self, request: &HttpRequestModel) -> crate::http::RetryStrategy;
}
/// Resolves credentials for one origin.
pub trait AuthProvider {
/// Returns the credential configured for `origin`, when one exists.
fn credential_for(&self, origin: &str) -> Option<AuthCredentialModel>;
}
/// Connects plain HTTP requests.
pub trait HttpConnector {
/// Executes one HTTP request and returns the normalized response model.
fn connect_http(&self, request: &HttpRequestModel)
-> Result<HttpResponseModel, TransportError>;
}
/// Connects HTTPS requests.
pub trait HttpsConnector {
/// Executes one HTTPS request and returns the normalized response model.
fn connect_https(
&self,
request: &HttpRequestModel,
) -> Result<HttpResponseModel, TransportError>;
}
/// Connects FTP requests.
pub trait FtpConnector {
/// Executes one FTP request with the supplied session config.
fn connect_ftp(
&self,
config: &FtpConfigModel,
request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError>;
}
/// Connects SFTP requests.
pub trait SftpConnector {
/// Executes one SFTP request with the supplied session config.
fn connect_sftp(
&self,
config: &SftpConfigModel,
request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError>;
}
/// Fetches Metalink documents through the transport layer.
pub trait MetalinkConnector {
/// Resolves one Metalink document into an HTTP-style response model.
fn connect_metalink(
&self,
document: &MetalinkDocumentModel,
) -> Result<HttpResponseModel, TransportError>;
}
/// Fetches torrent metadata through the transport layer.
pub trait TorrentConnector {
/// Resolves one torrent metadata document into an HTTP-style response model.
fn connect_torrent(
&self,
metadata: &TorrentMetadataModel,
) -> Result<HttpResponseModel, TransportError>;
}
/// High-level transfer runner used by the CLI and integration fixtures.
pub trait Downloader {
/// Starts one HTTP or HTTPS transfer.
fn start_http_transfer(
&self,
task: &HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError>;
/// Starts one FTP transfer.
fn start_ftp_transfer(
&self,
config: &FtpConfigModel,
request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError>;
/// Starts one SFTP transfer.
fn start_sftp_transfer(
&self,
config: &SftpConfigModel,
request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError>;
}
@@ -0,0 +1,193 @@
use super::{
Downloader, FtpConfigModel, FtpRequestModel, FtpResponseModel, HttpConnector, HttpRequestModel,
HttpResponseModel, HttpTransferTaskModel, HttpsConnector, SftpConfigModel, SftpRequestModel,
SftpResponseModel, TransportError,
};
#[derive(Clone, Copy, Debug, Default)]
/// Placeholder HTTP connector that always reports that no connector is configured.
pub struct NullHttpConnector;
impl HttpConnector for NullHttpConnector {
fn connect_http(
&self,
request: &HttpRequestModel,
) -> Result<HttpResponseModel, TransportError> {
Err(TransportError {
kind: crate::transport::TransportErrorKind::NotConnected,
message: format!("no http connector configured for {}", request.url),
source: None,
context: None,
})
}
}
#[derive(Clone, Copy, Debug, Default)]
/// Placeholder HTTPS connector that always reports that no connector is configured.
pub struct NullHttpsConnector;
impl HttpsConnector for NullHttpsConnector {
fn connect_https(
&self,
request: &HttpRequestModel,
) -> Result<HttpResponseModel, TransportError> {
Err(TransportError {
kind: crate::transport::TransportErrorKind::NotConnected,
message: format!("no https connector configured for {}", request.url),
source: None,
context: None,
})
}
}
#[derive(Clone, Debug, Default)]
/// Downloader wrapper that routes HTTP and HTTPS requests through connector implementations.
pub struct ConnectorBackedDownloader<HC, HSC> {
/// Connector used for plain HTTP requests.
http: HC,
/// Connector used for HTTPS requests.
https: HSC,
}
impl<HC, HSC> ConnectorBackedDownloader<HC, HSC> {
#[must_use]
/// Builds a downloader from plain HTTP and HTTPS connector implementations.
pub const fn new(http: HC, https: HSC) -> Self {
Self { http, https }
}
}
#[derive(Clone, Debug, Default)]
/// Downloader that only supports HTTP(S) transfers.
pub struct HttpOnlyDownloader {
/// Connector-backed executor used by the HTTP-only wrapper.
inner: ConnectorBackedDownloader<NullHttpConnector, NullHttpsConnector>,
}
impl HttpOnlyDownloader {
#[must_use]
/// Builds the HTTP-only downloader.
pub fn new() -> Self {
Self {
inner: ConnectorBackedDownloader::new(NullHttpConnector, NullHttpsConnector),
}
}
}
impl Downloader for HttpOnlyDownloader {
fn start_http_transfer(
&self,
task: &HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError> {
self.inner.start_http_transfer(task)
}
fn start_ftp_transfer(
&self,
_config: &FtpConfigModel,
_request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError> {
Err(TransportError {
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
message: "ftp transfer is not supported by the HTTP-only downloader".to_owned(),
source: None,
context: None,
})
}
fn start_sftp_transfer(
&self,
_config: &SftpConfigModel,
_request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError> {
Err(TransportError {
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
message: "sftp transfer is not supported by the HTTP-only downloader".to_owned(),
source: None,
context: None,
})
}
}
impl<HC, HSC> Downloader for ConnectorBackedDownloader<HC, HSC>
where
HC: HttpConnector,
HSC: HttpsConnector,
{
fn start_http_transfer(
&self,
task: &HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError> {
match request_scheme(&task.request.url) {
Some("http") => self
.http
.connect_http(&task.request)
.map(|response| normalize_http_response_for_execution(task, response)),
Some("https") => self
.https
.connect_https(&task.request)
.map(|response| normalize_http_response_for_execution(task, response)),
Some(scheme) => Err(TransportError {
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
message: format!("unsupported http transfer scheme: {scheme}"),
source: None,
context: None,
}),
None => Err(TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("request url has no scheme: {}", task.request.url),
source: None,
context: None,
}),
}
}
fn start_ftp_transfer(
&self,
_config: &FtpConfigModel,
_request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError> {
Err(TransportError {
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
message: "ftp transfer is not implemented".to_owned(),
source: None,
context: None,
})
}
fn start_sftp_transfer(
&self,
_config: &SftpConfigModel,
_request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError> {
Err(TransportError {
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
message: "sftp transfer is not implemented".to_owned(),
source: None,
context: None,
})
}
}
/// Injects transfer-task metadata such as checksum hooks into one HTTP response model.
pub(super) fn normalize_http_response_for_execution(
task: &HttpTransferTaskModel,
mut response: HttpResponseModel,
) -> HttpResponseModel {
if response.checksum.is_none()
&& let Some(checksum) = task
.checksum_hook
.as_ref()
.filter(|checksum| checksum.enabled)
.map(|checksum| checksum.spec.clone())
{
response.checksum = Some(checksum);
}
response
}
/// Extracts the lowercase URI scheme prefix from one request URL when present.
pub(super) fn request_scheme(url: &str) -> Option<&str> {
url.split_once("://").map(|(scheme, _)| scheme)
}
@@ -0,0 +1,953 @@
use std::{
collections::BTreeSet,
io::{Read, Write},
net::TcpListener,
thread,
};
use super::{
ConnectorBackedDownloader, Downloader, FixtureHttpDownloader, FixtureStep, HttpConnector,
HttpFixtureResponseSpec, HttpsConnector, ReqwestHttpConnector,
};
use crate::{
ftp::{FtpCommandModel, FtpResponseModel},
http::{
HttpBody, HttpMethod, HttpRequestHeaders, HttpRequestModel, HttpResponseHeaders,
HttpTransferTaskModel, HttpVersion, ProxyConfig, ResponseBody, RetryPolicy, RetryStrategy,
},
sftp::{SftpCommandModel, SftpResponseModel},
transport::{TransportError, TransportErrorKind},
};
#[derive(Clone, Debug, Default)]
struct HttpOkConnector;
impl HttpConnector for HttpOkConnector {
fn connect_http(
&self,
request: &HttpRequestModel,
) -> Result<crate::http::HttpResponseModel, TransportError> {
Ok(crate::http::HttpResponseModel {
status: 200,
reason: format!("HTTP {}", request.url),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Empty,
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
})
}
}
#[derive(Clone, Debug, Default)]
struct HttpsOkConnector;
impl HttpsConnector for HttpsOkConnector {
fn connect_https(
&self,
request: &HttpRequestModel,
) -> Result<crate::http::HttpResponseModel, TransportError> {
Ok(crate::http::HttpResponseModel {
status: 200,
reason: format!("HTTPS {}", request.url),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Empty,
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
})
}
}
fn retry() -> RetryStrategy {
RetryStrategy {
policy: RetryPolicy {
max_attempts: 1,
initial_backoff_ms: 0,
max_backoff_ms: 0,
retry_on_3xx: false,
retry_on_4xx: false,
retry_on_5xx: false,
retry_on_network_error: false,
retry_on_timeout: false,
},
jitter: None,
max_elapsed_ms: None,
}
}
fn task(url: &str) -> HttpTransferTaskModel {
let request = HttpRequestModel {
method: HttpMethod::Get,
url: url.to_owned(),
version: HttpVersion::Http11,
headers: HttpRequestHeaders {
headers: Vec::new(),
},
query: std::collections::HashMap::new(),
range: None,
body: HttpBody::Empty,
retry: retry(),
auth: None,
proxy: None,
response_sink: None,
};
HttpTransferTaskModel {
task_id: "gid".to_owned(),
request,
response_headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Empty,
resume_state: None,
retry_attempts: vec![],
checksum_hook: None,
max_connections: 1,
retry: retry(),
}
}
fn proxy_config(port: u16) -> ProxyConfig {
ProxyConfig {
scheme: "http".to_owned(),
host: "127.0.0.1".to_owned(),
port,
username: None,
password: None,
bypass_hosts: vec![],
no_proxy: false,
}
}
fn closed_loopback_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.expect("ephemeral port should bind")
.local_addr()
.expect("local addr should exist")
.port()
}
#[test]
fn connector_backed_downloader_routes_http_and_https() {
let downloader = ConnectorBackedDownloader::new(HttpOkConnector, HttpsOkConnector);
let http = downloader
.start_http_transfer(&task("http://example.org/file"))
.expect("http connector should be used");
let https = downloader
.start_http_transfer(&task("https://example.org/file"))
.expect("https connector should be used");
assert!(http.reason.starts_with("HTTP "));
assert!(https.reason.starts_with("HTTPS "));
}
#[derive(Clone, Debug, Default)]
struct InlineBodyConnector;
impl HttpConnector for InlineBodyConnector {
fn connect_http(
&self,
_request: &HttpRequestModel,
) -> Result<crate::http::HttpResponseModel, TransportError> {
Ok(crate::http::HttpResponseModel {
status: 200,
reason: "HTTP inline".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Inline(b"abc".to_vec()),
content_range: None,
partial_content: false,
checksum: Some(crate::http::ChecksumSpec {
algorithm: "sha-1".to_owned(),
expected_hex: "a9993e364706816aba3e25717850c26c9cd0d89d".to_owned(),
actual_hex: None,
}),
redirected_from: None,
})
}
}
#[test]
fn connector_backed_downloader_keeps_inline_body_when_no_stream_sink_is_needed() {
let downloader = ConnectorBackedDownloader::new(InlineBodyConnector, HttpsOkConnector);
let response = downloader
.start_http_transfer(&task("http://example.org/inline"))
.expect("http connector should be normalized");
match &response.body {
ResponseBody::Inline(bytes) => assert_eq!(bytes, b"abc"),
other => panic!("expected inline body after normalization, got {other:?}"),
}
assert_eq!(
response.completion_model().state,
crate::http::HttpCompletionState::Verified
);
}
#[test]
fn connector_backed_downloader_injects_checksum_hook_when_response_omits_checksum() {
let downloader = ConnectorBackedDownloader::new(InlineBodyConnector, HttpsOkConnector);
let mut task = task("http://example.org/inline");
task.checksum_hook = Some(crate::http::ChecksumHookModel {
spec: crate::http::ChecksumSpec {
algorithm: "sha-1".to_owned(),
expected_hex: "a9993e364706816aba3e25717850c26c9cd0d89d".to_owned(),
actual_hex: None,
},
enabled: true,
});
let response = downloader
.start_http_transfer(&task)
.expect("http connector should be normalized");
assert_eq!(
response
.checksum
.as_ref()
.map(|checksum| checksum.expected_hex.as_str()),
Some("a9993e364706816aba3e25717850c26c9cd0d89d")
);
assert!(response.completion_model().checksum_verified);
}
#[test]
fn reqwest_connector_fetches_real_http_body_into_streamed_response() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("client should connect");
let mut request = [0_u8; 1024];
let _ = stream.read(&mut request);
let response = b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabc";
stream.write_all(response).expect("response should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = ConnectorBackedDownloader::new(connector.clone(), connector);
let response = downloader
.start_http_transfer(&task(&format!("http://{addr}/live")))
.expect("live request should succeed");
match &response.body {
ResponseBody::Streamed {
expected_len,
observed_len,
observed_digest,
temp_path,
} => {
assert_eq!(*expected_len, Some(3));
assert_eq!(*observed_len, Some(3));
assert!(observed_digest.is_none());
assert!(temp_path.is_some());
}
other => panic!("expected streamed body from live connector, got {other:?}"),
}
assert_eq!(response.status, 200);
assert_eq!(response.completion_model().completed_length, 3);
handle.join().expect("server thread should join");
}
#[test]
fn reqwest_connector_can_stream_live_http_body_directly_to_target_file() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("client should connect");
let mut request = [0_u8; 1024];
let _ = stream.read(&mut request);
let response = b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabc";
stream.write_all(response).expect("response should write");
});
let target_path = super::temp_stream_sink_path();
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let downloader = ConnectorBackedDownloader::new(connector.clone(), connector);
let mut direct_task = task(&format!("http://{addr}/live-direct"));
direct_task.request.response_sink = Some(crate::http::HttpResponseSinkTarget {
target_path: target_path.clone(),
});
let response = downloader
.start_http_transfer(&direct_task)
.expect("live request should succeed");
match &response.body {
ResponseBody::Streamed {
expected_len,
observed_len,
observed_digest,
temp_path,
} => {
assert_eq!(*expected_len, Some(3));
assert_eq!(*observed_len, Some(3));
assert!(observed_digest.is_none());
assert!(temp_path.is_none());
}
other => panic!("expected streamed body from live connector, got {other:?}"),
}
assert_eq!(
std::fs::read(&target_path).expect("target bytes should read"),
b"abc"
);
let _ = std::fs::remove_file(&target_path);
handle.join().expect("server thread should join");
}
#[test]
fn reqwest_connector_preserves_range_request_and_parses_416_total_length() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("client should connect");
let mut buf = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).expect("socket should read");
if read == 0 {
break;
}
buf.extend_from_slice(&chunk[..read]);
if buf.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let request_text = String::from_utf8_lossy(&buf).to_lowercase();
assert!(request_text.contains("range: bytes=4096-"));
let response =
b"HTTP/1.1 416 Range Not Satisfiable\r\nContent-Range: bytes */4096\r\nContent-Length: 0\r\n\r\n";
stream.write_all(response).expect("response should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let mut request = task(&format!("http://{addr}/range-reject")).request;
request.range = Some(crate::http::RangeSpec {
start: 4096,
end_inclusive: None,
unit: crate::http::RangeUnit::Bytes,
});
let response = connector
.connect_http(&request)
.expect("416 response should still be modeled");
assert_eq!(response.status, 416);
assert_eq!(response.total_length(), Some(4096));
assert_eq!(response.completed_length(), 0);
assert!(
response
.content_range
.as_ref()
.expect("content-range should parse")
.unsatisfied
);
handle.join().expect("server thread should join");
}
#[test]
fn reqwest_connector_sends_text_body_and_headers() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("client should connect");
let mut buf = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).expect("socket should read");
if read == 0 {
break;
}
buf.extend_from_slice(&chunk[..read]);
if buf.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let header_end = buf
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
.expect("headers should terminate");
let request_text = String::from_utf8_lossy(&buf[..header_end]).to_lowercase();
assert!(request_text.contains("post /submit http/1.1"));
assert!(request_text.contains("x-test: alpha"));
assert!(request_text.contains("content-length: 4"));
while buf.len() < header_end + 4 {
let read = stream.read(&mut chunk).expect("socket should read body");
if read == 0 {
break;
}
buf.extend_from_slice(&chunk[..read]);
}
assert_eq!(&buf[header_end..header_end + 4], b"ping");
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
.expect("response should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let mut request = task(&format!("http://{addr}/submit")).request;
request.method = HttpMethod::Post;
request.body = HttpBody::Text("ping".to_owned());
request.headers.headers.push(crate::http::HttpHeader {
name: "x-test".to_owned(),
value: "alpha".to_owned(),
kind: crate::http::HeaderKind::Request,
});
let response = connector
.connect_http(&request)
.expect("live post should succeed");
assert_eq!(response.status, 200);
assert_eq!(response.completed_length(), 2);
handle.join().expect("server thread should join");
}
#[test]
fn request_body_bytes_does_not_allocate_placeholder_payload_for_stream_body() {
assert!(
super::request_body_bytes(&HttpBody::Stream {
expected_len: Some(1024 * 1024)
})
.is_none()
);
}
#[test]
fn reqwest_connector_applies_sorted_query_parameters_to_request_url() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("client should connect");
let mut buf = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).expect("socket should read");
if read == 0 {
break;
}
buf.extend_from_slice(&chunk[..read]);
if buf.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let request_text = String::from_utf8_lossy(&buf).to_lowercase();
assert!(request_text.contains("get /search?alpha=1&beta=2 http/1.1"));
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
.expect("response should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let mut request = task(&format!("http://{addr}/search")).request;
request.query.insert("beta".to_owned(), "2".to_owned());
request.query.insert("alpha".to_owned(), "1".to_owned());
let response = connector
.connect_http(&request)
.expect("live request with query should succeed");
assert_eq!(response.status, 200);
handle.join().expect("server thread should join");
}
#[test]
fn reqwest_connector_tracks_redirect_origin_after_following_redirect() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let first_location = format!("http://{addr}/final");
let handle = thread::spawn(move || {
let (mut first, _) = listener.accept().expect("first client should connect");
let mut first_buf = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = first.read(&mut chunk).expect("first socket should read");
if read == 0 {
break;
}
first_buf.extend_from_slice(&chunk[..read]);
if first_buf.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let first_request = String::from_utf8_lossy(&first_buf).to_lowercase();
assert!(first_request.contains("get /redirect http/1.1"));
let redirect = format!(
"HTTP/1.1 302 Found\r\nLocation: {first_location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
first
.write_all(redirect.as_bytes())
.expect("redirect response should write");
let (mut second, _) = listener.accept().expect("second client should connect");
let mut second_buf = Vec::new();
loop {
let read = second.read(&mut chunk).expect("second socket should read");
if read == 0 {
break;
}
second_buf.extend_from_slice(&chunk[..read]);
if second_buf.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let second_request = String::from_utf8_lossy(&second_buf).to_lowercase();
assert!(second_request.contains("get /final http/1.1"));
second
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nfinal")
.expect("final response should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let expected_redirect = format!("http://{addr}/redirect");
let request = task(&expected_redirect).request;
let response = connector
.connect_http(&request)
.expect("redirected request should succeed");
assert_eq!(response.status, 200);
assert_eq!(
response.redirected_from.as_deref(),
Some(expected_redirect.as_str())
);
handle.join().expect("server thread should join");
}
#[test]
fn reqwest_connector_maps_http10_responses_to_http10_model_version() {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("client should connect");
let mut request = [0_u8; 1024];
let _ = stream.read(&mut request);
let response = b"HTTP/1.0 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nold";
stream.write_all(response).expect("response should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let response = connector
.connect_http(&task(&format!("http://{addr}/http10")).request)
.expect("http/1.0 request should succeed");
assert_eq!(response.status, 200);
assert_eq!(response.version, HttpVersion::Http10);
handle.join().expect("server thread should join");
}
#[test]
fn reqwest_connector_uses_request_proxy_for_http_requests() {
let proxy_listener = TcpListener::bind("127.0.0.1:0").expect("proxy should bind");
let proxy_addr = proxy_listener
.local_addr()
.expect("proxy addr should exist");
let target_port = closed_loopback_port();
let handle = thread::spawn(move || {
let (mut stream, _) = proxy_listener
.accept()
.expect("proxy client should connect");
let mut buf = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).expect("proxy socket should read");
if read == 0 {
break;
}
buf.extend_from_slice(&chunk[..read]);
if buf.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let header_end = buf
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
.expect("proxy request should have headers");
let request_text = String::from_utf8_lossy(&buf[..header_end]).to_lowercase();
assert!(request_text.contains(&format!(
"get http://127.0.0.1:{target_port}/proxied http/1.1"
)));
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 9\r\n\r\nproxied-ok")
.expect("proxy response should write");
});
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let mut request = task(&format!("http://127.0.0.1:{target_port}/proxied")).request;
request.proxy = Some(proxy_config(proxy_addr.port()));
let response = connector
.connect_http(&request)
.expect("request should route through proxy");
assert_eq!(response.status, 200);
assert_eq!(response.completed_length(), 9);
handle.join().expect("proxy thread should join");
}
#[test]
fn reqwest_connector_reuses_proxy_specific_clients_for_matching_proxy_config() {
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let mut request = task("http://127.0.0.1:9/proxy-cache").request;
request.proxy = Some(proxy_config(closed_loopback_port()));
let _first = connector
.client_for_request(&request)
.expect("first proxy client should build");
let _second = connector
.client_for_request(&request)
.expect("second proxy client should reuse cache");
assert_eq!(connector.cached_proxy_client_count(), 1);
}
#[test]
fn reqwest_connector_prepares_request_without_waiting_for_cache_lock() {
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let request = task("http://example.org/prepared-cache").request;
let _prepared_cache_guard = connector
.prepared_requests
.lock()
.expect("prepared request cache lock should succeed");
let prepared = connector.prepared_live_request_for(&request);
assert!(prepared.is_some());
}
#[test]
fn reqwest_connector_maps_proxy_connect_failure_to_proxy_failed() {
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let mut request = task("http://127.0.0.1:9/proxy-failure").request;
request.proxy = Some(proxy_config(closed_loopback_port()));
let error = connector
.connect_http(&request)
.expect_err("proxy connect should fail");
assert_eq!(error.kind, TransportErrorKind::ProxyFailed);
}
#[test]
fn reqwest_connector_maps_dns_resolution_failure_to_dns_failed() {
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
let request = task("http://no-such-host.invalid/dns-failure").request;
let error = connector
.connect_http(&request)
.expect_err("dns lookup should fail");
assert_eq!(error.kind, TransportErrorKind::DnsFailed);
}
#[test]
fn fixture_downloader_supports_https_too() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register("https://example.org/file", b"payload");
let response = downloader
.start_http_transfer(&task("https://example.org/file"))
.expect("https fixture should resolve");
assert_eq!(response.status, 200);
}
#[test]
fn connector_backed_downloader_rejects_missing_scheme() {
let downloader = ConnectorBackedDownloader::new(HttpOkConnector, HttpsOkConnector);
let error = downloader
.start_http_transfer(&task("example.org/file"))
.expect_err("missing scheme should fail");
assert_eq!(error.kind, TransportErrorKind::ProtocolViolation);
}
#[test]
fn fixture_script_can_model_206_with_content_range() {
let downloader = FixtureHttpDownloader::new();
downloader.register_partial_content("https://example.org/resume.bin", b"cdef", 2, 5, 8);
let response = downloader
.start_http_transfer(&task("https://example.org/resume.bin"))
.expect("partial fixture should resolve");
assert_eq!(response.status, 206);
assert_eq!(response.reason, "Partial Content");
assert!(
response
.headers
.headers
.iter()
.any(|h| h.name == "content-range" && h.value == "bytes 2-5/8")
);
}
#[test]
fn fixture_script_can_model_416_with_unsatisfied_content_range() {
let downloader = FixtureHttpDownloader::new();
downloader.register_script(
"https://example.org/range-reject.bin",
[FixtureStep::ok(HttpFixtureResponseSpec {
status: 416,
reason: "Range Not Satisfiable".to_owned(),
headers: vec![
crate::http::HttpHeader {
name: "content-range".to_owned(),
value: "bytes */8192".to_owned(),
kind: crate::http::HeaderKind::Response,
},
crate::http::HttpHeader {
name: "content-length".to_owned(),
value: "0".to_owned(),
kind: crate::http::HeaderKind::Response,
},
],
body: Vec::new(),
checksum: None,
streamed: false,
})],
);
let response = downloader
.start_http_transfer(&task("https://example.org/range-reject.bin"))
.expect("416 fixture should resolve");
assert_eq!(response.status, 416);
assert_eq!(response.total_length(), Some(8192));
assert_eq!(response.completed_length(), 0);
assert!(
response
.content_range
.as_ref()
.expect("content-range should parse")
.unsatisfied
);
}
#[test]
fn fixture_script_supports_transient_failure_then_success() {
let downloader = FixtureHttpDownloader::new();
downloader.register_transient_failure_then_ok(
"https://example.org/retry.bin",
TransportErrorKind::Timeout,
"transient timeout",
b"ok-after-retry",
);
let first = downloader.start_http_transfer(&task("https://example.org/retry.bin"));
let first_err = first.expect_err("first attempt should fail");
assert_eq!(first_err.kind, TransportErrorKind::Timeout);
let second = downloader
.start_http_transfer(&task("https://example.org/retry.bin"))
.expect("second attempt should succeed");
assert_eq!(second.status, 200);
}
#[test]
fn fixture_script_can_emit_streamed_observed_checksum_truth() {
let downloader = FixtureHttpDownloader::new();
downloader.register_streamed_ok_with_checksum(
"https://example.org/streamed.bin",
b"abc",
"md5",
"900150983cd24fb0d6963f7d28e17f72",
);
let response = downloader
.start_http_transfer(&task("https://example.org/streamed.bin"))
.expect("streamed fixture should resolve");
match response.body {
ResponseBody::Streamed {
expected_len,
observed_len,
ref observed_digest,
ref temp_path,
} => {
assert_eq!(expected_len, Some(3));
assert_eq!(observed_len, Some(3));
assert_eq!(
observed_digest.as_deref(),
Some("900150983cd24fb0d6963f7d28e17f72")
);
assert!(temp_path.is_some());
}
other => panic!("expected streamed body, got {other:?}"),
}
assert_eq!(
response.completion_model().state,
crate::http::HttpCompletionState::Verified
);
}
#[test]
fn streamed_execution_truth_comes_from_sink_writes_not_declared_body_len() {
let checksum = crate::http::ChecksumSpec {
algorithm: "sha1".to_owned(),
expected_hex: String::new(),
actual_hex: None,
};
let response_body = super::execute_streamed_body(b"hello-sink", Some(&checksum));
match response_body {
ResponseBody::Streamed {
expected_len,
observed_len,
observed_digest,
ref temp_path,
} => {
assert_eq!(expected_len, Some(10));
assert_eq!(observed_len, Some(10));
assert_eq!(
observed_digest.as_deref(),
Some("381cf617458c906e12825ac22e9c621e7bba2390")
);
assert!(temp_path.is_some());
}
other => panic!("expected streamed body, got {other:?}"),
}
}
#[test]
fn temp_stream_sink_path_stays_unique_across_rapid_calls() {
let paths = (0..512)
.map(|_| super::temp_stream_sink_path())
.collect::<Vec<_>>();
let unique = paths.iter().cloned().collect::<BTreeSet<_>>();
assert_eq!(unique.len(), paths.len());
}
#[test]
fn fixture_script_can_pin_last_step_for_additional_attempts() {
let downloader = FixtureHttpDownloader::new();
downloader.register_script(
"https://example.org/retry-stable.bin",
[
FixtureStep::err(TransportErrorKind::ConnectionReset, "reset once"),
FixtureStep::ok(HttpFixtureResponseSpec::ok(b"stable".to_vec())),
],
);
let _ = downloader.start_http_transfer(&task("https://example.org/retry-stable.bin"));
let second = downloader
.start_http_transfer(&task("https://example.org/retry-stable.bin"))
.expect("second attempt should pass");
let third = downloader
.start_http_transfer(&task("https://example.org/retry-stable.bin"))
.expect("third attempt should still pass");
assert_eq!(second.status, 200);
assert_eq!(third.status, 200);
}
#[test]
fn fixture_downloader_can_serve_ftp_transfer() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register_ftp(
"ftp://example.org:21/file.bin",
FtpResponseModel {
code: 226,
message: "transfer complete".to_owned(),
data: Some(b"ftp-body".to_vec()),
path: Some("/file.bin".to_owned()),
transferable: true,
},
);
let response = downloader
.start_ftp_transfer(
&crate::ftp::FtpConfigModel {
host: "example.org".to_owned(),
port: 21,
username: None,
password: None,
secure: false,
mode: crate::ftp::FtpMode::Passive,
initial_cwd: None,
proxy: None,
tls: None,
retry: retry(),
},
&crate::ftp::FtpRequestModel {
command: FtpCommandModel::Retr("/file.bin".to_owned()),
path: Some("/file.bin".to_owned()),
headers: Vec::new(),
},
)
.expect("ftp fixture should resolve");
assert_eq!(response.code, 226);
assert_eq!(response.data.as_deref(), Some(&b"ftp-body"[..]));
}
#[test]
fn fixture_downloader_can_serve_sftp_transfer() {
let mut downloader = FixtureHttpDownloader::new();
downloader.register_sftp(
"sftp://example.org:22/file.bin",
SftpResponseModel {
ok: true,
message: "sftp ok".to_owned(),
payload: Some(b"sftp-body".to_vec()),
path: Some("/file.bin".to_owned()),
transferable: true,
},
);
let response = downloader
.start_sftp_transfer(
&crate::sftp::SftpConfigModel {
host: "example.org".to_owned(),
port: 22,
username: None,
password: None,
private_key_path: None,
known_hosts_path: None,
strict_host_key_checking: true,
proxy: None,
tls: None,
retry: retry(),
},
&crate::sftp::SftpRequestModel {
command: SftpCommandModel::Read {
path: "/file.bin".to_owned(),
offset: 0,
length: 1024,
},
path: Some("/file.bin".to_owned()),
headers: Vec::new(),
},
)
.expect("sftp fixture should resolve");
assert!(response.ok);
assert_eq!(response.payload.as_deref(), Some(&b"sftp-body"[..]));
}
@@ -0,0 +1,665 @@
use super::{
BTreeMap, ByteSink, ChecksumSpec, ContentRangeSpec, Downloader, FtpConfigModel,
FtpRequestModel, FtpResponseModel, HttpConnector, HttpHeader, HttpRequestModel,
HttpResponseHeaders, HttpResponseModel, HttpTransferTaskModel, HttpVersion, HttpsConnector,
Mutex, NEXT_TEMP_STREAM_SINK_ID, ObservedByteSink, ObservedFileSink, Ordering, RangeSpec,
RangeUnit, ResponseBody, SftpConfigModel, SftpRequestModel, SftpResponseModel, SystemTime,
TransportError, UNIX_EPOCH,
core_downloader::{normalize_http_response_for_execution, request_scheme},
env,
};
#[derive(Debug, Default)]
/// Fixture-backed downloader used by tests and local runtime smokes.
pub struct FixtureHttpDownloader {
/// Inline HTTP and HTTPS fixtures keyed by URL.
fixtures: BTreeMap<String, Vec<u8>>,
/// FTP fixtures keyed by URL.
ftp_fixtures: BTreeMap<String, FtpResponseModel>,
/// SFTP fixtures keyed by URL.
sftp_fixtures: BTreeMap<String, SftpResponseModel>,
/// Scripted HTTP fixture responses keyed by URL.
scripts: Mutex<BTreeMap<String, FixtureScript>>,
}
impl FixtureHttpDownloader {
#[must_use]
/// Builds an empty fixture registry.
pub fn new() -> Self {
Self {
fixtures: BTreeMap::new(),
ftp_fixtures: BTreeMap::new(),
sftp_fixtures: BTreeMap::new(),
scripts: Mutex::new(BTreeMap::new()),
}
}
/// Registers a simple inline HTTP fixture for `url`.
pub fn register(&mut self, url: impl Into<String>, body: impl AsRef<[u8]>) {
self.fixtures.insert(url.into(), body.as_ref().to_vec());
}
/// Registers an FTP fixture response for `url`.
pub fn register_ftp(&mut self, url: impl Into<String>, response: FtpResponseModel) {
self.ftp_fixtures.insert(url.into(), response);
}
/// Registers an SFTP fixture response for `url`.
pub fn register_sftp(&mut self, url: impl Into<String>, response: SftpResponseModel) {
self.sftp_fixtures.insert(url.into(), response);
}
/// Registers a scripted sequence of responses for `url`.
pub fn register_script(
&self,
url: impl Into<String>,
steps: impl IntoIterator<Item = FixtureStep>,
) {
let script = FixtureScript::new(steps);
if let Ok(mut scripts) = self.scripts.lock() {
scripts.insert(url.into(), script);
}
}
/// Registers a single partial-content HTTP fixture for `url`.
pub fn register_partial_content(
&self,
url: impl Into<String>,
body: impl AsRef<[u8]>,
start: u64,
end_inclusive: u64,
total: u64,
) {
self.register_script(
url,
[FixtureStep::ok(HttpFixtureResponseSpec::partial_content(
body.as_ref().to_vec(),
start,
end_inclusive,
total,
))],
);
}
/// Registers a successful inline HTTP fixture with explicit checksum metadata.
pub fn register_ok_with_checksum(
&self,
url: impl Into<String>,
body: impl AsRef<[u8]>,
algorithm: impl Into<String>,
expected_hex: impl Into<String>,
actual_hex: Option<impl Into<String>>,
) {
self.register_script(
url,
[FixtureStep::ok(
HttpFixtureResponseSpec::ok(body.as_ref().to_vec()).with_checksum(ChecksumSpec {
algorithm: algorithm.into(),
expected_hex: expected_hex.into(),
actual_hex: actual_hex.map(Into::into),
}),
)],
);
}
/// Registers a successful streamed HTTP fixture with explicit checksum metadata.
pub fn register_streamed_ok_with_checksum(
&self,
url: impl Into<String>,
body: impl AsRef<[u8]>,
algorithm: impl Into<String>,
expected_hex: impl Into<String>,
) {
self.register_script(
url,
[FixtureStep::ok(
HttpFixtureResponseSpec::streamed_ok(body.as_ref().to_vec()).with_checksum(
ChecksumSpec {
algorithm: algorithm.into(),
expected_hex: expected_hex.into(),
actual_hex: None,
},
),
)],
);
}
/// Registers a transient failure followed by a successful inline response.
pub fn register_transient_failure_then_ok(
&self,
url: impl Into<String>,
kind: crate::transport::TransportErrorKind,
message: impl Into<String>,
body: impl AsRef<[u8]>,
) {
self.register_script(
url,
[
FixtureStep::err(kind, message),
FixtureStep::ok(HttpFixtureResponseSpec::ok(body.as_ref().to_vec())),
],
);
}
/// Resolves a registered fixture body or scripted response for one HTTP transfer task.
fn fixture_response(
&self,
task: &HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError> {
let url = &task.request.url;
if let Ok(mut scripts) = self.scripts.lock()
&& let Some(script) = scripts.get_mut(url)
{
return script.next_response();
}
let Some(body) = self.fixtures.get(url) else {
return Err(TransportError {
kind: crate::transport::TransportErrorKind::NotConnected,
message: format!("no fixture registered for {url}"),
source: None,
context: None,
});
};
if let Some(range) = task.request.range.as_ref() {
return response_for_range(url, body, range);
}
Ok(HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![HttpHeader {
name: "content-length".to_owned(),
value: body.len().to_string(),
kind: crate::http::HeaderKind::Response,
}],
},
body: ResponseBody::Inline(body.clone()),
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
})
}
}
/// Slices fixture bytes according to an optional HTTP range request.
fn response_for_range(
url: &str,
body: &[u8],
range: &RangeSpec,
) -> Result<HttpResponseModel, TransportError> {
if !matches!(range.unit, RangeUnit::Bytes) {
return Err(TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!(
"fixture range unit not supported for {url}: {:?}",
range.unit
),
source: None,
context: None,
});
}
if body.is_empty() {
return Ok(HttpResponseModel {
status: 200,
reason: "OK".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![HttpHeader {
name: "content-length".to_owned(),
value: "0".to_owned(),
kind: crate::http::HeaderKind::Response,
}],
},
body: ResponseBody::Empty,
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
});
}
let start = usize::try_from(range.start).map_err(|_| TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!(
"fixture range starts past addressable memory for {url}: {}",
range.start
),
source: None,
context: None,
})?;
if start >= body.len() {
return Err(TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("fixture range starts past body for {url}: {start}"),
source: None,
context: None,
});
}
let last_index = body.len().saturating_sub(1);
let end_inclusive = range
.end_inclusive
.and_then(|end| usize::try_from(end).ok())
.unwrap_or(last_index)
.min(last_index);
let end_inclusive = end_inclusive.max(start);
let slice = body
.get(start..=end_inclusive)
.ok_or_else(|| TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("fixture range slice is invalid for {url}: {start}..={end_inclusive}"),
source: None,
context: None,
})?
.to_vec();
let total = u64::try_from(body.len()).unwrap_or(u64::MAX);
Ok(HttpResponseModel {
status: 206,
reason: "Partial Content".to_owned(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: vec![
HttpHeader {
name: "content-length".to_owned(),
value: slice.len().to_string(),
kind: crate::http::HeaderKind::Response,
},
HttpHeader {
name: "content-range".to_owned(),
value: format!("bytes {start}-{end_inclusive}/{total}"),
kind: crate::http::HeaderKind::Response,
},
],
},
body: ResponseBody::Inline(slice),
content_range: Some(ContentRangeSpec {
unit: RangeUnit::Bytes,
start: u64::try_from(start).unwrap_or(u64::MAX),
end_inclusive: u64::try_from(end_inclusive).unwrap_or(u64::MAX),
total_size: Some(total),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
})
}
#[derive(Clone, Debug)]
/// Ordered script that can emit fixture responses across repeated attempts.
struct FixtureScript {
/// Ordered scripted fixture steps.
steps: Vec<FixtureStep>,
/// Cursor pointing at the next step to emit.
cursor: usize,
}
impl FixtureScript {
/// Builds a fixture script from an ordered step sequence.
fn new(steps: impl IntoIterator<Item = FixtureStep>) -> Self {
Self {
steps: steps.into_iter().collect(),
cursor: 0,
}
}
/// Returns the next scripted response, pinning to the final step once exhausted.
fn next_response(&mut self) -> Result<HttpResponseModel, TransportError> {
if self.steps.is_empty() {
return Err(TransportError {
kind: crate::transport::TransportErrorKind::NotConnected,
message: "fixture script is empty".to_owned(),
source: None,
context: None,
});
}
let idx = self.cursor.min(self.steps.len() - 1);
if self.cursor < self.steps.len() - 1 {
self.cursor += 1;
}
self.steps[idx].to_result()
}
}
#[derive(Clone, Debug)]
/// One scripted fixture step for the HTTP fixture downloader.
pub enum FixtureStep {
/// Emits a successful HTTP response described by the fixture spec.
Response(HttpFixtureResponseSpec),
/// Emits a transport error with the provided kind and message.
Error {
/// Error kind surfaced by the scripted step.
kind: crate::transport::TransportErrorKind,
/// Human-readable error message surfaced by the scripted step.
message: String,
},
}
impl FixtureStep {
#[must_use]
/// Builds a successful fixture step from a response spec.
pub const fn ok(spec: HttpFixtureResponseSpec) -> Self {
Self::Response(spec)
}
#[must_use]
/// Builds an error fixture step from a transport error kind and message.
pub fn err(kind: crate::transport::TransportErrorKind, message: impl Into<String>) -> Self {
Self::Error {
kind,
message: message.into(),
}
}
/// Converts one scripted step into the response or error it represents.
fn to_result(&self) -> Result<HttpResponseModel, TransportError> {
match self {
Self::Response(spec) => Ok(spec.to_http_response()),
Self::Error { kind, message } => Err(TransportError {
kind: *kind,
message: message.clone(),
source: None,
context: None,
}),
}
}
}
#[derive(Clone, Debug)]
/// Declarative HTTP response fixture used by `FixtureHttpDownloader`.
pub struct HttpFixtureResponseSpec {
/// HTTP status code emitted by the fixture.
pub(super) status: u16,
/// HTTP reason phrase emitted by the fixture.
pub(super) reason: String,
/// Response headers emitted by the fixture.
pub(super) headers: Vec<HttpHeader>,
/// Inline payload bytes used by the fixture.
pub(super) body: Vec<u8>,
/// Optional checksum metadata attached to the fixture response.
pub(super) checksum: Option<ChecksumSpec>,
/// Whether the fixture should materialize a streamed response body.
pub(super) streamed: bool,
}
impl HttpFixtureResponseSpec {
#[must_use]
/// Builds a successful inline-body HTTP fixture.
pub fn ok(body: Vec<u8>) -> Self {
Self {
status: 200,
reason: "OK".to_owned(),
headers: vec![HttpHeader {
name: "content-length".to_owned(),
value: body.len().to_string(),
kind: crate::http::HeaderKind::Response,
}],
body,
checksum: None,
streamed: false,
}
}
#[must_use]
/// Builds a successful streamed-body HTTP fixture.
pub fn streamed_ok(body: Vec<u8>) -> Self {
Self {
status: 200,
reason: "OK".to_owned(),
headers: vec![HttpHeader {
name: "content-length".to_owned(),
value: body.len().to_string(),
kind: crate::http::HeaderKind::Response,
}],
body,
checksum: None,
streamed: true,
}
}
#[must_use]
/// Builds a `206 Partial Content` HTTP fixture.
pub fn partial_content(body: Vec<u8>, start: u64, end_inclusive: u64, total: u64) -> Self {
Self {
status: 206,
reason: "Partial Content".to_owned(),
headers: vec![
HttpHeader {
name: "content-length".to_owned(),
value: body.len().to_string(),
kind: crate::http::HeaderKind::Response,
},
HttpHeader {
name: "content-range".to_owned(),
value: format!("bytes {start}-{end_inclusive}/{total}"),
kind: crate::http::HeaderKind::Response,
},
],
body,
checksum: None,
streamed: false,
}
}
#[must_use]
/// Attaches checksum metadata to the fixture response.
pub fn with_checksum(mut self, checksum: ChecksumSpec) -> Self {
self.checksum = Some(checksum);
self
}
/// Converts the declarative fixture into a concrete HTTP response model.
fn to_http_response(&self) -> HttpResponseModel {
let body = if self.streamed {
execute_streamed_body(&self.body, self.checksum.as_ref())
} else {
ResponseBody::Inline(self.body.clone())
};
HttpResponseModel {
status: self.status,
reason: self.reason.clone(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders {
headers: self.headers.clone(),
},
body,
content_range: parse_content_range(&self.headers),
partial_content: self.status == 206,
checksum: self.checksum.clone(),
redirected_from: None,
}
}
}
/// Materializes fixture bytes into an observed streamed body representation.
pub(super) fn execute_streamed_body(body: &[u8], checksum: Option<&ChecksumSpec>) -> ResponseBody {
let temp_path = temp_stream_sink_path();
let streamed = ObservedFileSink::create(&temp_path).map_or_else(
|_| {
let mut sink = ObservedByteSink::with_unbounded_retention();
ByteSink::write(&mut sink, body).expect("observed sink write is infallible");
let observed_len = sink.observed_len();
let observed_digest =
checksum.and_then(|spec| spec.compute_actual_hex(sink.retained()));
(observed_len, observed_digest, None)
},
|mut sink| {
ByteSink::write(&mut sink, body).expect("observed file sink write is infallible");
let observed_len = sink.observed_len();
let observed_digest =
checksum.and_then(|spec| spec.compute_actual_hex(sink.retained()));
(observed_len, observed_digest, Some(temp_path))
},
);
ResponseBody::Streamed {
expected_len: Some(u64::try_from(body.len()).unwrap_or(u64::MAX)),
observed_len: Some(streamed.0),
observed_digest: streamed.1,
temp_path: streamed.2,
}
}
/// Allocates a best-effort temporary file path for streamed fixture bodies.
pub(super) fn temp_stream_sink_path() -> std::path::PathBuf {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or_default();
let sequence = NEXT_TEMP_STREAM_SINK_ID.fetch_add(1, Ordering::Relaxed);
env::temp_dir().join(format!(
"aria2-rust-pro-streamed-{}-{}-{}.bin",
std::process::id(),
stamp,
sequence
))
}
/// Parses a `Content-Range` response header into the protocol-layer range model.
pub(super) fn parse_content_range(headers: &[HttpHeader]) -> Option<ContentRangeSpec> {
let value = headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("content-range"))?
.value
.trim();
let rest = value.strip_prefix("bytes ")?;
let (range_part, total_part) = rest.split_once('/')?;
let total_size = if total_part == "*" {
None
} else {
Some(total_part.parse::<u64>().ok()?)
};
if range_part == "*" {
return Some(ContentRangeSpec {
unit: RangeUnit::Bytes,
start: 0,
end_inclusive: 0,
total_size,
unsatisfied: true,
});
}
let (start, end) = range_part.split_once('-')?;
let start = start.parse::<u64>().ok()?;
let end_inclusive = end.parse::<u64>().ok()?;
Some(ContentRangeSpec {
unit: RangeUnit::Bytes,
start,
end_inclusive,
total_size,
unsatisfied: false,
})
}
impl HttpConnector for FixtureHttpDownloader {
fn connect_http(
&self,
request: &HttpRequestModel,
) -> Result<HttpResponseModel, TransportError> {
let retry = request.retry;
self.fixture_response(&HttpTransferTaskModel {
task_id: String::new(),
request: request.clone(),
response_headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Empty,
resume_state: None,
retry_attempts: Vec::new(),
checksum_hook: None,
max_connections: 1,
retry,
})
}
}
impl HttpsConnector for FixtureHttpDownloader {
fn connect_https(
&self,
request: &HttpRequestModel,
) -> Result<HttpResponseModel, TransportError> {
let retry = request.retry;
self.fixture_response(&HttpTransferTaskModel {
task_id: String::new(),
request: request.clone(),
response_headers: HttpResponseHeaders {
headers: Vec::new(),
},
body: ResponseBody::Empty,
resume_state: None,
retry_attempts: Vec::new(),
checksum_hook: None,
max_connections: 1,
retry,
})
}
}
impl Downloader for FixtureHttpDownloader {
fn start_http_transfer(
&self,
task: &HttpTransferTaskModel,
) -> Result<HttpResponseModel, TransportError> {
match request_scheme(&task.request.url) {
Some("http" | "https") => self
.fixture_response(task)
.map(|response| normalize_http_response_for_execution(task, response)),
Some(scheme) => Err(TransportError {
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
message: format!("fixture downloader does not support scheme: {scheme}"),
source: None,
context: None,
}),
None => Err(TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("request url has no scheme: {}", task.request.url),
source: None,
context: None,
}),
}
}
fn start_ftp_transfer(
&self,
config: &FtpConfigModel,
request: &FtpRequestModel,
) -> Result<FtpResponseModel, TransportError> {
let path = request.path.as_deref().unwrap_or_default();
let url = format!("ftp://{}:{}{}", config.host, config.port, path);
self.ftp_fixtures
.get(&url)
.cloned()
.ok_or_else(|| TransportError {
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
message: format!("ftp fixture not registered for {url}"),
source: None,
context: None,
})
}
fn start_sftp_transfer(
&self,
config: &SftpConfigModel,
request: &SftpRequestModel,
) -> Result<SftpResponseModel, TransportError> {
let path = request.path.as_deref().unwrap_or_default();
let url = format!("sftp://{}:{}{}", config.host, config.port, path);
self.sftp_fixtures
.get(&url)
.cloned()
.ok_or_else(|| TransportError {
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
message: format!("sftp fixture not registered for {url}"),
source: None,
context: None,
})
}
}
@@ -0,0 +1,641 @@
use super::{
Arc, Client, File, HTTP_TIMING_PROBE_ENABLED, HashMap, HeaderMap, HeaderName, HeaderValue,
HttpConnector, HttpHeader, HttpRequestModel, HttpResponseHeaders, HttpResponseModel,
HttpVersion, HttpsConnector, LIVE_HTTP_POOL_IDLE_TIMEOUT, LIVE_HTTP_POOL_MAX_IDLE_PER_HOST,
LIVE_HTTP_TCP_KEEPALIVE, MAX_PREPARED_REQUEST_CACHE_ENTRIES, MAX_PROXY_CLIENT_CACHE_ENTRIES,
Mutex, NoProxy, OpenOptions, Proxy, RANGE, Response, ResponseBody, SeekFrom, StdError,
TransportError, env,
fixture_downloader::{parse_content_range, temp_stream_sink_path},
};
use std::io::Seek as _;
#[derive(Clone, Debug)]
/// Live reqwest-backed connector for HTTP and HTTPS requests.
pub struct ReqwestHttpConnector {
/// Reused client for the common no-proxy request path.
default_client: Client,
/// Reused clients for proxy-specific request paths.
proxy_clients: Arc<Mutex<HashMap<ProxyClientCacheKey, Client>>>,
/// Reused reqwest URL/header preparation keyed by immutable request shape.
pub(super) prepared_requests:
Arc<Mutex<HashMap<PreparedLiveHttpRequestKey, Arc<PreparedLiveHttpRequest>>>>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
/// Stable cache key for proxy-specific reqwest clients.
struct ProxyClientCacheKey {
/// Proxy URL scheme.
scheme: String,
/// Proxy host name or address.
host: String,
/// Proxy TCP port.
port: u16,
/// Optional proxy username.
username: Option<String>,
/// Optional proxy password.
password: Option<String>,
/// Hosts bypassed by this proxy.
bypass_hosts: Vec<String>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
/// Stable cache key for normalized live HTTP request preparation.
pub(super) struct PreparedLiveHttpRequestKey {
/// Base request URL before query map application.
url: String,
/// Stable sorted query parameters.
query: Vec<(String, String)>,
/// Stable sorted request headers.
headers: Vec<(String, String)>,
}
#[derive(Clone, Debug)]
/// Prepared reqwest request pieces reusable across repeated equivalent requests.
pub(super) struct PreparedLiveHttpRequest {
/// Parsed request URL with stable query parameters applied.
requested_url: reqwest::Url,
/// Validated reqwest header map.
headers: HeaderMap,
}
impl ReqwestHttpConnector {
/// Builds a connector after validating that a reqwest client can be created.
///
/// # Errors
///
/// Returns an error when the underlying reqwest client cannot be built.
pub fn new() -> Result<Self, TransportError> {
let default_client = build_reqwest_client(None)?;
Ok(Self {
default_client,
proxy_clients: Arc::new(Mutex::new(HashMap::new())),
prepared_requests: Arc::new(Mutex::new(HashMap::new())),
})
}
/// Selects either the shared default client or a proxy-specific client.
pub(super) fn client_for_request(
&self,
request: &HttpRequestModel,
) -> Result<Client, TransportError> {
if let Some(proxy) = request.proxy.as_ref().filter(|proxy| !proxy.no_proxy) {
return self.proxy_client_for(proxy);
}
Ok(self.default_client.clone())
}
/// Returns a cached or newly built client for one proxy configuration.
fn proxy_client_for(&self, proxy: &crate::http::ProxyConfig) -> Result<Client, TransportError> {
let key = ProxyClientCacheKey::from_config(proxy);
if let Some(client) = self
.proxy_clients
.lock()
.ok()
.and_then(|proxy_clients| proxy_clients.get(&key).cloned())
{
return Ok(client);
}
let client = build_reqwest_client(Some(proxy))?;
if let Ok(mut proxy_clients) = self.proxy_clients.lock() {
if proxy_clients.len() >= MAX_PROXY_CLIENT_CACHE_ENTRIES {
if let Some(cached) = proxy_clients.get(&key) {
return Ok(cached.clone());
}
proxy_clients.clear();
}
let cached = proxy_clients.entry(key).or_insert_with(|| client.clone());
return Ok(cached.clone());
}
Ok(client)
}
#[cfg(test)]
/// Returns the number of cached proxy-specific clients for cache tests.
pub(super) fn cached_proxy_client_count(&self) -> usize {
self.proxy_clients
.lock()
.map(|proxy_clients| proxy_clients.len())
.unwrap_or_default()
}
/// Returns cached parsed URL/header state for repeated equivalent requests.
pub(super) fn prepared_live_request_for(
&self,
request: &HttpRequestModel,
) -> Option<Arc<PreparedLiveHttpRequest>> {
let key = match self.prepared_requests.try_lock() {
Ok(prepared_requests) => {
let key = PreparedLiveHttpRequestKey::from_request(request);
if let Some(prepared) = prepared_requests.get(&key).cloned() {
return Some(prepared);
}
key
}
Err(_) => {
return PreparedLiveHttpRequest::from_request(request).map(Arc::new);
}
};
let prepared = Arc::new(PreparedLiveHttpRequest::from_request(request)?);
if let Ok(mut prepared_requests) = self.prepared_requests.try_lock() {
if prepared_requests.len() >= MAX_PREPARED_REQUEST_CACHE_ENTRIES {
if let Some(cached) = prepared_requests.get(&key) {
return Some(cached.clone());
}
prepared_requests.clear();
}
if let Some(cached) = prepared_requests.get(&key) {
return Some(cached.clone());
}
prepared_requests.insert(key, prepared.clone());
}
Some(prepared)
}
}
impl ProxyClientCacheKey {
/// Builds a stable key from protocol-layer proxy configuration.
fn from_config(proxy: &crate::http::ProxyConfig) -> Self {
Self {
scheme: proxy.scheme.clone(),
host: proxy.host.clone(),
port: proxy.port,
username: proxy.username.clone(),
password: proxy.password.clone(),
bypass_hosts: proxy.bypass_hosts.clone(),
}
}
}
impl PreparedLiveHttpRequestKey {
/// Builds a stable key from immutable request URL/query/header data.
fn from_request(request: &HttpRequestModel) -> Self {
let mut query = request
.query
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<Vec<_>>();
if query.len() > 1 {
query.sort_unstable();
}
let mut headers = request
.headers
.headers
.iter()
.map(|header| (header.name.clone(), header.value.clone()))
.collect::<Vec<_>>();
if headers.len() > 1 {
headers.sort_unstable();
}
Self {
url: request.url.clone(),
query,
headers,
}
}
}
impl PreparedLiveHttpRequest {
/// Parses and validates reusable URL/header state from one request.
fn from_request(request: &HttpRequestModel) -> Option<Self> {
let requested_url = request_url_with_query(request).ok()?;
let headers = http_headers_from_request(&request.headers.headers).ok()?;
Some(Self {
requested_url,
headers,
})
}
}
impl Default for ReqwestHttpConnector {
fn default() -> Self {
Self::new().expect("reqwest client should build")
}
}
impl HttpConnector for ReqwestHttpConnector {
fn connect_http(
&self,
request: &HttpRequestModel,
) -> Result<HttpResponseModel, TransportError> {
let prepared = self.prepared_live_request_for(request);
live_http_response(
self.client_for_request(request)?,
request,
prepared.as_deref(),
)
}
}
impl HttpsConnector for ReqwestHttpConnector {
fn connect_https(
&self,
request: &HttpRequestModel,
) -> Result<HttpResponseModel, TransportError> {
let prepared = self.prepared_live_request_for(request);
live_http_response(
self.client_for_request(request)?,
request,
prepared.as_deref(),
)
}
}
/// Builds one reqwest client with the protocol-layer defaults and optional proxy.
///
/// # Errors
///
/// Returns an error when reqwest rejects the configured client or proxy settings.
fn build_reqwest_client(
proxy: Option<&crate::http::ProxyConfig>,
) -> Result<Client, TransportError> {
let mut builder = Client::builder()
.no_proxy()
.tcp_nodelay(true)
.tcp_keepalive(LIVE_HTTP_TCP_KEEPALIVE)
.pool_max_idle_per_host(LIVE_HTTP_POOL_MAX_IDLE_PER_HOST)
.pool_idle_timeout(LIVE_HTTP_POOL_IDLE_TIMEOUT)
.redirect(reqwest::redirect::Policy::limited(10));
if let Some(proxy) = proxy.filter(|proxy| !proxy.no_proxy) {
builder = builder.proxy(reqwest_proxy_from_config(proxy)?);
}
builder.build().map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("failed to build reqwest client: {error}"),
source: Some(error.to_string()),
context: None,
})
}
/// Converts the protocol-layer proxy model into a reqwest proxy configuration.
fn reqwest_proxy_from_config(proxy: &crate::http::ProxyConfig) -> Result<Proxy, TransportError> {
let proxy_url = format!("{}://{}:{}", proxy.scheme, proxy.host, proxy.port);
let mut reqwest_proxy = match proxy.scheme.as_str() {
"http" => Proxy::http(&proxy_url).map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("invalid http proxy config for {proxy_url}: {error}"),
source: Some(error.to_string()),
context: None,
})?,
"https" => Proxy::https(&proxy_url).map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("invalid https proxy config for {proxy_url}: {error}"),
source: Some(error.to_string()),
context: None,
})?,
other => {
return Err(TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("unsupported proxy scheme: {other}"),
source: None,
context: None,
});
}
};
if !proxy.bypass_hosts.is_empty() {
reqwest_proxy = reqwest_proxy.no_proxy(NoProxy::from_string(&proxy.bypass_hosts.join(",")));
}
if let Some(username) = proxy.username.as_deref() {
reqwest_proxy =
reqwest_proxy.basic_auth(username, proxy.password.as_deref().unwrap_or_default());
}
Ok(reqwest_proxy)
}
/// Executes one live HTTP request through reqwest and normalizes the response model.
fn live_http_response(
client: Client,
request: &HttpRequestModel,
prepared_live_request: Option<&PreparedLiveHttpRequest>,
) -> Result<HttpResponseModel, TransportError> {
let timing_probe = *HTTP_TIMING_PROBE_ENABLED
.get_or_init(|| env::var_os("ARIA2_RUST_PRO_HTTP_TIMING").is_some());
let overall_started = timing_probe.then(std::time::Instant::now);
let client_started = timing_probe.then(std::time::Instant::now);
let client_elapsed_ms = client_started
.as_ref()
.map(|started| started.elapsed().as_millis())
.unwrap_or_default();
let requested_url = if let Some(prepared) = prepared_live_request {
prepared.requested_url.clone()
} else {
request_url_with_query(request)?
};
let method = match request.method {
crate::http::HttpMethod::Get => reqwest::Method::GET,
crate::http::HttpMethod::Head => reqwest::Method::HEAD,
crate::http::HttpMethod::Post => reqwest::Method::POST,
crate::http::HttpMethod::Put => reqwest::Method::PUT,
crate::http::HttpMethod::Delete => reqwest::Method::DELETE,
};
let mut builder = client.request(method, requested_url.clone());
builder = builder.headers(if let Some(prepared) = prepared_live_request {
prepared.headers.clone()
} else {
http_headers_from_request(&request.headers.headers)?
});
if let Some(body) = request_body_bytes(&request.body) {
builder = builder.body(body);
}
if let Some(range) = &request.range {
let range_value = range.end_inclusive.map_or_else(
|| format!("bytes={}-", range.start),
|end| format!("bytes={}-{}", range.start, end),
);
builder = builder.header(RANGE, range_value);
}
if let Some(auth) = &request.auth
&& let Some(username) = auth.username.as_deref()
{
builder = builder.basic_auth(username, auth.password.as_deref());
}
let send_started = timing_probe.then(std::time::Instant::now);
let response = builder
.send()
.map_err(|error| transport_error_from_reqwest(error, request))?;
let send_elapsed_ms = send_started
.as_ref()
.map(|started| started.elapsed().as_millis())
.unwrap_or_default();
let normalize_started = timing_probe.then(std::time::Instant::now);
let response = http_response_from_reqwest(response, request, &requested_url)?;
if let Some(total_started) = overall_started.as_ref() {
eprintln!(
"http connector timing url={} client_ms={} send_ms={} normalize_ms={} total_ms={}",
request.url,
client_elapsed_ms,
send_elapsed_ms,
normalize_started
.as_ref()
.map(|started| started.elapsed().as_millis())
.unwrap_or_default(),
total_started.elapsed().as_millis(),
);
}
Ok(response)
}
/// Rebuilds the request URL with its query map in stable key order.
fn request_url_with_query(request: &HttpRequestModel) -> Result<reqwest::Url, TransportError> {
let mut url = reqwest::Url::parse(&request.url).map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("invalid request url {}: {error}", request.url),
source: Some(error.to_string()),
context: None,
})?;
if !request.query.is_empty() {
let mut query_pairs = request
.query
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect::<Vec<_>>();
query_pairs.sort_unstable();
{
let mut pairs = url.query_pairs_mut();
for (key, value) in query_pairs {
pairs.append_pair(key, value);
}
}
}
Ok(url)
}
/// Converts protocol-layer request headers into a reqwest header map.
fn http_headers_from_request(headers: &[HttpHeader]) -> Result<HeaderMap, TransportError> {
let mut map = HeaderMap::with_capacity(headers.len());
for header in headers {
let name =
HeaderName::from_bytes(header.name.as_bytes()).map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("invalid request header name {}: {error}", header.name),
source: Some(error.to_string()),
context: None,
})?;
let value = HeaderValue::from_str(&header.value).map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::ProtocolViolation,
message: format!("invalid request header value for {}: {error}", header.name),
source: Some(error.to_string()),
context: None,
})?;
map.append(name, value);
}
Ok(map)
}
/// Extracts an owned request payload when the body model is inline or textual.
pub(super) fn request_body_bytes(body: &crate::http::HttpBody) -> Option<Vec<u8>> {
match body {
crate::http::HttpBody::Empty | crate::http::HttpBody::Stream { .. } => None,
crate::http::HttpBody::Text(text) => Some(text.clone().into_bytes()),
crate::http::HttpBody::Binary(bytes) => Some(bytes.clone()),
}
}
/// Maps a reqwest failure into the transport error model with request context.
fn transport_error_from_reqwest(
error: reqwest::Error,
request: &HttpRequestModel,
) -> TransportError {
let kind = transport_error_kind_from_reqwest(&error, request.proxy.as_ref());
TransportError {
kind,
message: format!("http request failed for {}: {error}", request.url),
source: Some(error.to_string()),
context: None,
}
}
/// Classifies a reqwest failure into the closest transport error kind.
fn transport_error_kind_from_reqwest(
error: &reqwest::Error,
proxy: Option<&crate::http::ProxyConfig>,
) -> crate::transport::TransportErrorKind {
let chain_text = reqwest_error_chain_text(error);
let is_proxy_configured = proxy.is_some_and(|proxy| !proxy.no_proxy);
if error.is_timeout() {
crate::transport::TransportErrorKind::Timeout
} else if contains_any(
&chain_text,
&[
"tls",
"certificate",
"handshake",
"unknown ca",
"invalid peer",
],
) {
crate::transport::TransportErrorKind::TlsFailed
} else if contains_any(
&chain_text,
&[
"dns",
"resolve",
"lookup address",
"name or service not known",
"no such host",
],
) {
crate::transport::TransportErrorKind::DnsFailed
} else if is_proxy_configured
&& (error.is_connect() || contains_any(&chain_text, &["proxy", "tunnel", "socks"]))
{
crate::transport::TransportErrorKind::ProxyFailed
} else if error.is_connect() {
crate::transport::TransportErrorKind::ConnectionReset
} else if error.is_builder() {
crate::transport::TransportErrorKind::ProtocolViolation
} else {
crate::transport::TransportErrorKind::Io
}
}
/// Flattens one reqwest/std-error chain into a lowercased diagnostic string.
fn reqwest_error_chain_text(error: &dyn StdError) -> String {
let mut text = error.to_string().to_lowercase();
let mut source = error.source();
while let Some(err) = source {
text.push_str(" | ");
text.push_str(&err.to_string().to_lowercase());
source = err.source();
}
text
}
/// Returns whether the haystack contains any candidate substring.
fn contains_any(text: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| text.contains(needle))
}
/// Normalizes a reqwest response into the protocol-layer HTTP response model.
fn http_response_from_reqwest(
mut response: Response,
request: &HttpRequestModel,
requested_url: &reqwest::Url,
) -> Result<HttpResponseModel, TransportError> {
let status = response.status();
let reason = status
.canonical_reason()
.unwrap_or("HTTP response")
.to_owned();
let mut headers = Vec::with_capacity(response.headers().len());
for (name, value) in response.headers() {
if let Ok(text) = value.to_str() {
headers.push(HttpHeader {
name: name.to_string(),
value: text.to_owned(),
kind: crate::http::HeaderKind::Response,
});
}
}
let expected_len = response.content_length();
let content_range = parse_content_range(&headers);
let partial_content = status.as_u16() == 206;
let direct_write_offset = content_range
.as_ref()
.map(|range| range.start)
.or_else(|| request.range.as_ref().map(|range| range.start))
.unwrap_or(0);
let (observed_len, temp_path) = if let Some(response_sink) = request.response_sink.as_ref() {
let mut sink = if direct_write_offset == 0 {
OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&response_sink.target_path)
} else {
OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&response_sink.target_path)
}
.map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::Io,
message: format!(
"failed to open direct response sink for {} at {}: {error}",
request.url,
response_sink.target_path.display()
),
source: Some(error.to_string()),
context: None,
})?;
sink.seek(SeekFrom::Start(direct_write_offset))
.map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::Io,
message: format!(
"failed to seek direct response sink for {} at {}: {error}",
request.url,
response_sink.target_path.display()
),
source: Some(error.to_string()),
context: None,
})?;
let observed_len = response
.copy_to(&mut sink)
.map_err(|error| transport_error_from_reqwest(error, request))?;
(observed_len, None)
} else {
let temp_path = temp_stream_sink_path();
let mut sink = File::create(&temp_path).map_err(|error| TransportError {
kind: crate::transport::TransportErrorKind::Io,
message: format!(
"failed to create streamed sink for {}: {error}",
request.url
),
source: Some(error.to_string()),
context: None,
})?;
let observed_len = response
.copy_to(&mut sink)
.map_err(|error| transport_error_from_reqwest(error, request))?;
(observed_len, Some(temp_path))
};
Ok(HttpResponseModel {
status: status.as_u16(),
reason,
version: http_version_from_reqwest(response.version()),
headers: HttpResponseHeaders { headers },
body: ResponseBody::Streamed {
expected_len,
observed_len: Some(observed_len),
observed_digest: None,
temp_path,
},
content_range,
partial_content,
checksum: None,
redirected_from: (response.url().as_str() != requested_url.as_str())
.then(|| requested_url.as_str().to_owned()),
})
}
/// Maps reqwest's HTTP version enum into the protocol-layer version model.
fn http_version_from_reqwest(version: reqwest::Version) -> HttpVersion {
match version {
reqwest::Version::HTTP_09 | reqwest::Version::HTTP_10 => HttpVersion::Http10,
reqwest::Version::HTTP_2 => HttpVersion::Http2,
reqwest::Version::HTTP_3 => HttpVersion::Http3,
_ => HttpVersion::Http11,
}
}
+106
View File
@@ -0,0 +1,106 @@
//! FTP request, response, and session models.
#![forbid(unsafe_code)]
use crate::{
auth::AuthCredentialModel,
http::{HttpHeader, ProxyConfig, RetryStrategy, TlsConfig},
};
/// Transfer mode used by an FTP session.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FtpMode {
/// Passive mode where the server accepts the data connection.
Passive,
/// Active mode where the client accepts the data connection.
Active,
}
/// Connection and retry settings for an FTP endpoint.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FtpConfigModel {
/// Remote host name or IP.
pub host: String,
/// Remote control-port number.
pub port: u16,
/// Optional username for login.
pub username: Option<String>,
/// Optional password for login.
pub password: Option<String>,
/// Whether FTPS or other secure transport is expected.
pub secure: bool,
/// Active or passive data-channel mode.
pub mode: FtpMode,
/// Initial working directory after login.
pub initial_cwd: Option<String>,
/// Optional proxy configuration.
pub proxy: Option<ProxyConfig>,
/// Optional TLS tuning parameters.
pub tls: Option<TlsConfig>,
/// Retry strategy for failed requests.
pub retry: RetryStrategy,
}
/// FTP command issued within a request.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum FtpCommandModel {
/// `USER <name>`.
User(String),
/// `PASS <secret>`.
Pass(String),
/// `PWD`.
Pwd,
/// `CWD <path>`.
Cwd(String),
/// `LIST [path]`.
List(Option<String>),
/// `SIZE <path>`.
Size(String),
/// `REST <offset>`.
Rest(u64),
/// `RETR <path>`.
Retr(String),
/// `QUIT`.
Quit,
/// Caller-supplied custom FTP command text.
Custom(String),
}
/// FTP session state captured by the protocol layer.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FtpSessionModel {
/// Stable session identifier.
pub session_id: String,
/// Resolved endpoint configuration.
pub config: FtpConfigModel,
/// Optional authenticated credential.
pub auth: Option<AuthCredentialModel>,
/// Default headers propagated into requests.
pub default_headers: Vec<HttpHeader>,
}
/// FTP request envelope passed into a connector.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FtpRequestModel {
/// Command to execute.
pub command: FtpCommandModel,
/// Optional path or target associated with the command.
pub path: Option<String>,
/// Additional logical headers attached to the request.
pub headers: Vec<HttpHeader>,
}
/// FTP response material returned by a connector.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FtpResponseModel {
/// Numeric FTP status code.
pub code: u16,
/// Human-readable server message.
pub message: String,
/// Optional payload bytes such as directory listings or file contents.
pub data: Option<Vec<u8>>,
/// Optional path associated with the response.
pub path: Option<String>,
/// Whether the response can carry transferable data.
pub transferable: bool,
}
@@ -0,0 +1,22 @@
//! HTTP protocol models, transfer state, and checksum helpers.
#![forbid(unsafe_code)]
/// Checksum parsing and validation helpers for HTTP transfers.
mod checksum;
/// Shared HTTP request and response data models.
mod model;
/// Transfer-progress tracking and aggregation helpers.
mod progress;
#[cfg(test)]
mod tests;
pub use self::model::{
AuthChallenge, AuthCredential, AuthScheme, ChecksumHookModel, ChecksumSpec, ContentRangeSpec,
Cookie, HeaderKind, HttpBody, HttpCompletionModel, HttpCompletionState, HttpHeader, HttpMethod,
HttpRequestHeaders, HttpRequestModel, HttpResponseHeaders, HttpResponseModel,
HttpResponseSinkTarget, HttpRetryAttemptDetailModel, HttpSegmentProgressModel,
HttpSessionModel, HttpTransferProgressModel, HttpTransferTaskModel, HttpVersion, ProxyConfig,
RangeSpec, RangeUnit, ResponseBody, ResumeState, RetryAttempt, RetryPolicy, RetryReason,
RetryStrategy, TlsConfig,
};
@@ -0,0 +1,110 @@
use adler2::Adler32;
use crc32fast::Hasher as Crc32Hasher;
use md5::Md5;
use sha1::Sha1;
use sha2::{Digest, Sha224, Sha256, Sha384, Sha512};
use super::model::{ChecksumSpec, ResponseBody};
impl ChecksumSpec {
#[must_use]
/// Returns whether the observed and expected digests match.
pub fn is_verified(&self) -> bool {
self.actual_hex
.as_deref()
.is_some_and(|actual| actual.eq_ignore_ascii_case(&self.expected_hex))
}
#[must_use]
/// Computes the payload digest using the configured algorithm.
pub fn compute_actual_hex(&self, payload: &[u8]) -> Option<String> {
checksum_hex(&self.algorithm, payload)
}
#[must_use]
/// Returns whether `payload` matches the expected digest when supported.
pub fn verify_payload(&self, payload: &[u8]) -> Option<bool> {
self.compute_actual_hex(payload)
.map(|actual| actual.eq_ignore_ascii_case(&self.expected_hex))
}
}
/// Compares a streamed body's observed digest with the expected checksum.
pub(super) fn streamed_checksum_verification(
checksum: &ChecksumSpec,
body: &ResponseBody,
) -> Option<bool> {
match body {
ResponseBody::Streamed {
observed_digest: Some(actual),
..
} => Some(actual.eq_ignore_ascii_case(&checksum.expected_hex)),
_ => None,
}
}
/// Computes a lowercase hexadecimal digest for the requested checksum algorithm.
#[must_use]
pub(super) fn checksum_hex(algorithm: &str, payload: &[u8]) -> Option<String> {
let algorithm = algorithm.trim();
let digest = if matches_checksum_algorithm(algorithm, &["sha1", "sha-1", "sha"]) {
let mut hasher = Sha1::new();
hasher.update(payload);
hasher.finalize().to_vec()
} else if matches_checksum_algorithm(algorithm, &["sha224", "sha-224"]) {
let mut hasher = Sha224::new();
hasher.update(payload);
hasher.finalize().to_vec()
} else if matches_checksum_algorithm(algorithm, &["sha256", "sha-256"]) {
let mut hasher = Sha256::new();
hasher.update(payload);
hasher.finalize().to_vec()
} else if matches_checksum_algorithm(algorithm, &["sha384", "sha-384"]) {
let mut hasher = Sha384::new();
hasher.update(payload);
hasher.finalize().to_vec()
} else if matches_checksum_algorithm(algorithm, &["sha512", "sha-512"]) {
let mut hasher = Sha512::new();
hasher.update(payload);
hasher.finalize().to_vec()
} else if algorithm.eq_ignore_ascii_case("md5") {
let mut hasher = Md5::new();
hasher.update(payload);
hasher.finalize().to_vec()
} else if algorithm.eq_ignore_ascii_case("adler32") {
let mut hasher = Adler32::new();
hasher.write_slice(payload);
hasher.checksum().to_be_bytes().to_vec()
} else if algorithm.eq_ignore_ascii_case("crc32") {
let mut hasher = Crc32Hasher::new();
hasher.update(payload);
hasher.finalize().to_be_bytes().to_vec()
} else {
return None;
};
Some(bytes_to_hex(&digest))
}
/// Returns whether a checksum algorithm matches any accepted spelling.
fn matches_checksum_algorithm(algorithm: &str, accepted: &[&str]) -> bool {
accepted
.iter()
.any(|candidate| algorithm.eq_ignore_ascii_case(candidate))
}
/// Hex-encodes digest bytes using lowercase hexadecimal.
#[must_use]
fn bytes_to_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(char::from(HEX[usize::from(byte >> 4)]));
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
out
}
/// Saturates a `usize` length into `u64`.
pub(super) fn usize_to_u64(value: usize) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}
@@ -0,0 +1,509 @@
use std::{collections::HashMap, path::PathBuf};
pub use crate::auth::{
AuthChallengeModel as AuthChallenge, AuthCredentialModel as AuthCredential, AuthScheme,
};
/// Classifies how one header participates in an HTTP exchange.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HeaderKind {
/// Header belongs to the request.
Request,
/// Header belongs to the response.
Response,
/// Header is valid for both directions.
General,
}
/// One normalized HTTP header field.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpHeader {
/// Lower-level header name.
pub name: String,
/// Raw header value.
pub value: String,
/// Header classification within the exchange.
pub kind: HeaderKind,
}
/// HTTP methods supported by the protocol layer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HttpMethod {
/// `GET`
Get,
/// `HEAD`
Head,
/// `POST`
Post,
/// `PUT`
Put,
/// `DELETE`
Delete,
}
/// HTTP versions surfaced by the transport layer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HttpVersion {
/// HTTP/1.0
Http10,
/// HTTP/1.1
Http11,
/// HTTP/2
Http2,
/// HTTP/3
Http3,
}
/// One request byte or piece range.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RangeSpec {
/// Inclusive start offset.
pub start: u64,
/// Optional inclusive end offset.
pub end_inclusive: Option<u64>,
/// Unit used by the range.
pub unit: RangeUnit,
}
impl RangeSpec {
#[must_use]
/// Returns whether the range omits an explicit end bound.
pub const fn is_open_ended(&self) -> bool {
self.end_inclusive.is_none()
}
#[must_use]
/// Returns the requested length when the end bound is known.
pub fn length_hint(&self) -> Option<u64> {
self.end_inclusive
.map(|end| end.saturating_sub(self.start).saturating_add(1))
}
}
/// Units supported by HTTP-style range models.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RangeUnit {
/// Byte-oriented ranges.
Bytes,
/// Piece-oriented ranges used by higher-level scheduling.
Pieces,
}
/// Parsed `Content-Range` response metadata.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ContentRangeSpec {
/// Unit reported by the server.
pub unit: RangeUnit,
/// Inclusive start offset returned by the server.
pub start: u64,
/// Inclusive end offset returned by the server.
pub end_inclusive: u64,
/// Total object size when known.
pub total_size: Option<u64>,
/// Whether the response represents an unsatisfied range.
pub unsatisfied: bool,
}
impl ContentRangeSpec {
#[must_use]
/// Returns the completed length implied by the range payload.
pub const fn completed_length(&self) -> u64 {
if self.unsatisfied {
0
} else {
self.end_inclusive.saturating_add(1)
}
}
#[must_use]
/// Returns whether the range is explicitly unsatisfied.
pub const fn is_unsatisfied(&self) -> bool {
self.unsatisfied
}
}
/// Resume metadata carried into one HTTP transfer attempt.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ResumeState {
/// Requested starting offset for the retry or resumed request.
pub requested_offset: u64,
/// Offset actually accepted by the remote server.
pub accepted_offset: Option<u64>,
/// Whether the attempt truly resumed instead of restarting from zero.
pub resumed: bool,
}
/// Retry policy knobs applied to HTTP work.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RetryPolicy {
/// Maximum number of attempts.
pub max_attempts: u32,
/// Initial backoff delay in milliseconds.
pub initial_backoff_ms: u64,
/// Maximum backoff delay in milliseconds.
pub max_backoff_ms: u64,
/// Whether `3xx` responses are retryable.
pub retry_on_3xx: bool,
/// Whether `4xx` responses are retryable.
pub retry_on_4xx: bool,
/// Whether `5xx` responses are retryable.
pub retry_on_5xx: bool,
/// Whether transport-level network errors are retryable.
pub retry_on_network_error: bool,
/// Whether timeout failures are retryable.
pub retry_on_timeout: bool,
}
/// Fully-resolved retry behavior for one request.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RetryStrategy {
/// Base retry policy.
pub policy: RetryPolicy,
/// Optional jitter value in milliseconds.
pub jitter: Option<u64>,
/// Optional upper bound on total retry elapsed time in milliseconds.
pub max_elapsed_ms: Option<u64>,
}
/// Normalized reasons for retrying one transfer attempt.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RetryReason {
/// A transport-level network failure occurred.
NetworkError,
/// The request timed out.
Timeout,
/// The server responded with a retryable `3xx`.
Http3xx,
/// The server responded with a retryable `4xx`.
Http4xx,
/// The server responded with a retryable `5xx`.
Http5xx,
/// Partial-content semantics did not match the requested resume state.
PartialContentMismatch,
/// Another retryable condition occurred.
Other,
}
/// One recorded retry attempt.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RetryAttempt {
/// Attempt number starting at one.
pub attempt: u32,
/// Retry reason for the attempt.
pub reason: RetryReason,
/// Optional HTTP status observed during the attempt.
pub status: Option<u16>,
/// Optional backoff delay in milliseconds before the next attempt.
pub backoff_ms: Option<u64>,
}
/// Final or intermediate completion state for one HTTP response.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HttpCompletionState {
/// Response is not yet complete.
Incomplete,
/// Response is usable but only partial.
Partial,
/// Response is complete without checksum verification.
Complete,
/// Response is complete and checksum-verified.
Verified,
}
/// Derived completion summary for one HTTP response.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct HttpCompletionModel {
/// Overall completion state.
pub state: HttpCompletionState,
/// Total payload length when known.
pub total_length: Option<u64>,
/// Number of completed bytes.
pub completed_length: u64,
/// Whether the response used partial-content semantics.
pub partial_content: bool,
/// Whether the status code indicates terminal success.
pub terminal_success: bool,
/// Whether checksum metadata was present.
pub checksum_seen: bool,
/// Whether the checksum could be verified successfully.
pub checksum_verified: bool,
}
/// Segment-level progress view for one transfer snapshot.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct HttpSegmentProgressModel {
/// Range originally requested from the server.
pub requested_range: Option<RangeSpec>,
/// Requested starting offset.
pub requested_offset: u64,
/// Offset accepted by the server when present.
pub accepted_offset: Option<u64>,
/// Completed offset derived from the current response.
pub completed_offset: Option<u64>,
/// Whether the transfer is actively resuming instead of restarting.
pub resumed: bool,
}
/// Retry-attempt detail enriched with segment and resume context.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct HttpRetryAttemptDetailModel {
/// Base retry-attempt data.
pub base: RetryAttempt,
/// Range requested for the attempt.
pub requested_range: Option<RangeSpec>,
/// Requested starting offset for the attempt.
pub requested_offset: u64,
/// Offset accepted by the server when present.
pub accepted_offset: Option<u64>,
/// Resume metadata captured for the attempt.
pub resume_state: Option<ResumeState>,
}
/// Snapshot of one in-flight or completed HTTP transfer.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpTransferProgressModel {
/// Stable task identifier.
pub task_id: String,
/// Request model associated with the transfer.
pub request: HttpRequestModel,
/// Segment-level progress details.
pub segment: HttpSegmentProgressModel,
/// Retry-attempt history with contextual detail.
pub retry_attempts: Vec<HttpRetryAttemptDetailModel>,
/// Maximum number of concurrent connections permitted for the task.
pub max_connections: u16,
/// Optional checksum hook attached to the transfer.
pub checksum_hook: Option<ChecksumHookModel>,
/// Derived completion summary when a response exists.
pub completion: Option<HttpCompletionModel>,
}
/// Proxy configuration projected into HTTP requests.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProxyConfig {
/// Proxy scheme such as `http` or `socks5`.
pub scheme: String,
/// Proxy host name or IP.
pub host: String,
/// Proxy port.
pub port: u16,
/// Optional proxy username.
pub username: Option<String>,
/// Optional proxy password.
pub password: Option<String>,
/// Hosts that should bypass the proxy.
pub bypass_hosts: Vec<String>,
/// Whether proxying is disabled for the request.
pub no_proxy: bool,
}
/// One normalized HTTP cookie.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Cookie {
/// Cookie name.
pub name: String,
/// Cookie value.
pub value: String,
/// Optional domain constraint.
pub domain: Option<String>,
/// Optional path constraint.
pub path: Option<String>,
/// Whether the cookie requires a secure transport.
pub secure: bool,
/// Whether the cookie is `HttpOnly`.
pub http_only: bool,
/// Optional same-site policy marker.
pub same_site: Option<String>,
/// Expiration time as a Unix timestamp when present.
pub expires_unix_epoch: Option<u64>,
}
/// TLS behavior attached to one HTTP session.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TlsConfig {
/// Whether peer certificates must be verified.
pub verify_peer: bool,
/// Whether host name verification is enabled.
pub verify_host: bool,
/// Minimum TLS version when constrained.
pub min_version: Option<String>,
/// Maximum TLS version when constrained.
pub max_version: Option<String>,
/// Optional CA bundle path.
pub ca_file: Option<String>,
/// Optional client certificate path.
pub cert_file: Option<String>,
/// Optional client key path.
pub key_file: Option<String>,
}
/// Ordered collection of request headers.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpRequestHeaders {
/// Stored request headers.
pub headers: Vec<HttpHeader>,
}
/// Ordered collection of response headers.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpResponseHeaders {
/// Stored response headers.
pub headers: Vec<HttpHeader>,
}
/// Request-body representation for HTTP transfers.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum HttpBody {
/// No request body.
Empty,
/// UTF-8 text request body.
Text(String),
/// Arbitrary binary request body.
Binary(Vec<u8>),
/// Streaming body with an optional declared length.
Stream {
/// Declared body length when the caller knows it.
expected_len: Option<u64>,
},
}
/// Expected and observed checksum metadata for one payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ChecksumSpec {
/// Hash algorithm name.
pub algorithm: String,
/// Expected digest hex string.
pub expected_hex: String,
/// Observed digest hex string when known.
pub actual_hex: Option<String>,
}
/// Optional checksum hook attached to a transfer.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ChecksumHookModel {
/// Checksum specification to evaluate.
pub spec: ChecksumSpec,
/// Whether the hook is enabled.
pub enabled: bool,
}
/// Optional direct-write target for one live HTTP response body.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpResponseSinkTarget {
/// Final output path that should receive the response body directly.
pub target_path: PathBuf,
}
/// Fully normalized HTTP request model.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpRequestModel {
/// HTTP method.
pub method: HttpMethod,
/// Fully-qualified request URL.
pub url: String,
/// Requested HTTP version.
pub version: HttpVersion,
/// Explicit request headers.
pub headers: HttpRequestHeaders,
/// Query parameters to attach to the URL.
pub query: HashMap<String, String>,
/// Optional range metadata.
pub range: Option<RangeSpec>,
/// Request body.
pub body: HttpBody,
/// Retry strategy for the request.
pub retry: RetryStrategy,
/// Optional origin credential.
pub auth: Option<AuthCredential>,
/// Optional proxy configuration.
pub proxy: Option<ProxyConfig>,
/// Optional direct-write sink for live response persistence.
pub response_sink: Option<HttpResponseSinkTarget>,
}
/// Session-scoped defaults that shape HTTP execution.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpSessionModel {
/// Stable session identifier.
pub session_id: String,
/// Optional user-agent string.
pub user_agent: Option<String>,
/// Default headers applied to requests.
pub default_headers: Vec<HttpHeader>,
/// Cookies carried by the session.
pub cookies: Vec<Cookie>,
/// Optional default credential.
pub auth: Option<AuthCredential>,
/// Optional default proxy configuration.
pub proxy: Option<ProxyConfig>,
/// Optional TLS behavior for the session.
pub tls: Option<TlsConfig>,
/// Default retry strategy for the session.
pub retry: RetryStrategy,
}
/// Response-body representation used by the protocol layer.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ResponseBody {
/// No response payload.
Empty,
/// Inline retained payload bytes.
Inline(Vec<u8>),
/// Streamed payload metadata with optional retained artifacts.
Streamed {
/// Declared content length when known.
expected_len: Option<u64>,
/// Observed byte count written through the sink.
observed_len: Option<u64>,
/// Observed digest when computed by the sink.
observed_digest: Option<String>,
/// Optional temporary file path holding the streamed body.
temp_path: Option<PathBuf>,
},
}
/// Executable HTTP transfer task passed into downloaders.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpTransferTaskModel {
/// Stable task identifier.
pub task_id: String,
/// Request model for the task.
pub request: HttpRequestModel,
/// Response headers already associated with the task.
pub response_headers: HttpResponseHeaders,
/// Current response body state.
pub body: ResponseBody,
/// Resume metadata when resuming is in play.
pub resume_state: Option<ResumeState>,
/// Retry-attempt history.
pub retry_attempts: Vec<RetryAttempt>,
/// Optional checksum hook.
pub checksum_hook: Option<ChecksumHookModel>,
/// Maximum allowed concurrent connections.
pub max_connections: u16,
/// Retry strategy for the task.
pub retry: RetryStrategy,
}
/// Normalized HTTP response model produced by connectors and fixtures.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpResponseModel {
/// Numeric HTTP status code.
pub status: u16,
/// Human-readable reason phrase.
pub reason: String,
/// Negotiated HTTP version.
pub version: HttpVersion,
/// Response headers.
pub headers: HttpResponseHeaders,
/// Response body representation.
pub body: ResponseBody,
/// Parsed `Content-Range` metadata when present.
pub content_range: Option<ContentRangeSpec>,
/// Whether the response used partial-content semantics.
pub partial_content: bool,
/// Optional checksum metadata.
pub checksum: Option<ChecksumSpec>,
/// Original URL before redirects when one occurred.
pub redirected_from: Option<String>,
}
@@ -0,0 +1,177 @@
use super::{
checksum::{streamed_checksum_verification, usize_to_u64},
model::{
HttpCompletionModel, HttpCompletionState, HttpResponseModel, HttpRetryAttemptDetailModel,
HttpSegmentProgressModel, HttpTransferProgressModel, HttpTransferTaskModel, ResponseBody,
},
};
impl HttpTransferTaskModel {
#[must_use]
/// Returns the effective requested offset for the task.
pub fn requested_offset(&self) -> u64 {
self.resume_state
.as_ref()
.map(|state| state.requested_offset)
.or_else(|| self.request.range.as_ref().map(|range| range.start))
.unwrap_or_default()
}
#[must_use]
/// Returns retry attempts enriched with resume and range context.
pub fn retry_attempt_details(&self) -> Vec<HttpRetryAttemptDetailModel> {
let requested_offset = self.requested_offset();
let requested_range = self.request.range;
let accepted_offset = self
.resume_state
.as_ref()
.and_then(|state| state.accepted_offset);
let resume_state = self.resume_state;
self.retry_attempts
.iter()
.copied()
.map(|base| HttpRetryAttemptDetailModel {
base,
requested_range,
requested_offset,
accepted_offset,
resume_state,
})
.collect()
}
#[must_use]
/// Builds a progress snapshot from the current task and optional response.
pub fn progress_snapshot(
&self,
response: Option<&HttpResponseModel>,
) -> HttpTransferProgressModel {
let completion = response.map(HttpResponseModel::completion_model);
let completed_offset = response.map(HttpResponseModel::completed_length);
let (accepted_offset, resumed) = response.map_or_else(
|| {
(
self.resume_state
.as_ref()
.and_then(|state| state.accepted_offset),
self.resume_state
.as_ref()
.is_some_and(|state| state.resumed),
)
},
|response| {
if response.status == 206 {
let accepted_offset = response
.content_range
.as_ref()
.and_then(|range| (!range.is_unsatisfied()).then_some(range.start));
(
accepted_offset,
accepted_offset.is_some()
|| self
.resume_state
.as_ref()
.is_some_and(|state| state.resumed),
)
} else {
(None, false)
}
},
);
HttpTransferProgressModel {
task_id: self.task_id.clone(),
request: self.request.clone(),
segment: HttpSegmentProgressModel {
requested_range: self.request.range,
requested_offset: self.requested_offset(),
accepted_offset,
completed_offset,
resumed,
},
retry_attempts: self.retry_attempt_details(),
max_connections: self.max_connections,
checksum_hook: self.checksum_hook.clone(),
completion,
}
}
}
impl HttpResponseModel {
#[must_use]
/// Returns the total payload length when the response exposes it.
pub fn total_length(&self) -> Option<u64> {
self.content_range
.as_ref()
.and_then(|range| range.total_size)
.or_else(|| match &self.body {
ResponseBody::Inline(bytes) => Some(usize_to_u64(bytes.len())),
ResponseBody::Streamed {
expected_len,
observed_len,
..
} => expected_len.or(*observed_len),
ResponseBody::Empty => None,
})
}
#[must_use]
/// Returns the completed payload length represented by the response.
pub fn completed_length(&self) -> u64 {
self.content_range
.as_ref()
.map(super::model::ContentRangeSpec::completed_length)
.or_else(|| match &self.body {
ResponseBody::Inline(bytes) => Some(usize_to_u64(bytes.len())),
ResponseBody::Streamed { observed_len, .. } => *observed_len,
ResponseBody::Empty => Some(0),
})
.unwrap_or_default()
}
#[must_use]
/// Returns inline body bytes when they are retained in memory.
pub fn body_bytes(&self) -> Option<&[u8]> {
match &self.body {
ResponseBody::Empty => Some(&[]),
ResponseBody::Inline(bytes) => Some(bytes),
ResponseBody::Streamed { .. } => None,
}
}
#[must_use]
/// Derives a completion summary from the response payload and metadata.
pub fn completion_model(&self) -> HttpCompletionModel {
let total_length = self.total_length();
let completed_length = self.completed_length();
let checksum_seen = self.checksum.is_some();
let checksum_verified = self.checksum.as_ref().is_some_and(|checksum| {
self.body_bytes()
.and_then(|bytes| checksum.verify_payload(bytes))
.or_else(|| streamed_checksum_verification(checksum, &self.body))
.unwrap_or_else(|| checksum.is_verified())
});
let terminal_success = (200..300).contains(&self.status);
let complete_enough = total_length.is_none_or(|total| completed_length >= total);
let state = if !terminal_success {
HttpCompletionState::Incomplete
} else if checksum_verified && complete_enough {
HttpCompletionState::Verified
} else if complete_enough {
HttpCompletionState::Complete
} else {
HttpCompletionState::Partial
};
HttpCompletionModel {
state,
total_length,
completed_length,
partial_content: self.partial_content,
terminal_success,
checksum_seen,
checksum_verified,
}
}
}
@@ -0,0 +1,489 @@
use std::collections::HashMap;
use super::*;
fn sample_retry_strategy() -> RetryStrategy {
RetryStrategy {
policy: RetryPolicy {
max_attempts: 5,
initial_backoff_ms: 100,
max_backoff_ms: 5_000,
retry_on_3xx: false,
retry_on_4xx: false,
retry_on_5xx: true,
retry_on_network_error: true,
retry_on_timeout: true,
},
jitter: Some(25),
max_elapsed_ms: Some(60_000),
}
}
fn sample_request() -> HttpRequestModel {
HttpRequestModel {
method: HttpMethod::Get,
url: "https://example.invalid/file.bin".to_string(),
version: HttpVersion::Http11,
headers: HttpRequestHeaders { headers: vec![] },
query: HashMap::new(),
range: Some(RangeSpec {
start: 4096,
end_inclusive: None,
unit: RangeUnit::Bytes,
}),
body: HttpBody::Empty,
retry: sample_retry_strategy(),
auth: None,
proxy: None,
response_sink: None,
}
}
#[test]
fn response_model_carries_partial_content_and_content_range() {
let response = HttpResponseModel {
status: 206,
reason: "Partial Content".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Streamed {
expected_len: Some(1024),
observed_len: Some(1024),
observed_digest: None,
temp_path: None,
},
content_range: Some(ContentRangeSpec {
unit: RangeUnit::Bytes,
start: 4096,
end_inclusive: 5119,
total_size: Some(10_000),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
};
assert!(response.partial_content);
assert_eq!(
response.content_range,
Some(ContentRangeSpec {
unit: RangeUnit::Bytes,
start: 4096,
end_inclusive: 5119,
total_size: Some(10_000),
unsatisfied: false,
})
);
}
#[test]
fn response_completion_model_distinguishes_partial_and_verified() {
let partial = HttpResponseModel {
status: 206,
reason: "Partial Content".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Inline(b"12345".to_vec()),
content_range: Some(ContentRangeSpec {
unit: RangeUnit::Bytes,
start: 0,
end_inclusive: 4,
total_size: Some(10),
unsatisfied: false,
}),
partial_content: true,
checksum: None,
redirected_from: None,
};
let verified = HttpResponseModel {
status: 200,
reason: "OK".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Inline(b"abc".to_vec()),
content_range: None,
partial_content: false,
checksum: Some(ChecksumSpec {
algorithm: "sha-1".to_string(),
expected_hex: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(),
actual_hex: None,
}),
redirected_from: None,
};
assert_eq!(
partial.completion_model().state,
HttpCompletionState::Partial
);
assert_eq!(partial.completion_model().completed_length, 5);
assert_eq!(
verified.completion_model().state,
HttpCompletionState::Verified
);
assert!(verified.completion_model().checksum_verified);
}
#[test]
fn range_rejection_keeps_total_length_truth_without_reporting_progress() {
let response = HttpResponseModel {
status: 416,
reason: "Range Not Satisfiable".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Empty,
content_range: Some(ContentRangeSpec {
unit: RangeUnit::Bytes,
start: 0,
end_inclusive: 0,
total_size: Some(8192),
unsatisfied: true,
}),
partial_content: false,
checksum: None,
redirected_from: None,
};
let completion = response.completion_model();
assert_eq!(completion.total_length, Some(8192));
assert_eq!(completion.completed_length, 0);
assert_eq!(completion.state, HttpCompletionState::Incomplete);
}
#[test]
fn checksum_verification_uses_inline_payload_bytes() {
let checksum = ChecksumSpec {
algorithm: "sha-256".to_string(),
expected_hex: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
.to_string(),
actual_hex: None,
};
assert_eq!(checksum.verify_payload(b"abc"), Some(true));
assert_eq!(checksum.verify_payload(b"abcd"), Some(false));
}
#[test]
fn streamed_response_completion_uses_observed_length_and_digest() {
let response = HttpResponseModel {
status: 200,
reason: "OK".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Streamed {
expected_len: Some(12),
observed_len: Some(12),
observed_digest: Some("9251ad9cddb52f55d2c6b96280c781e7".to_string()),
temp_path: None,
},
content_range: None,
partial_content: false,
checksum: Some(ChecksumSpec {
algorithm: "md5".to_string(),
expected_hex: "9251ad9cddb52f55d2c6b96280c781e7".to_string(),
actual_hex: None,
}),
redirected_from: None,
};
let completion = response.completion_model();
assert_eq!(completion.completed_length, 12);
assert_eq!(completion.total_length, Some(12));
assert_eq!(completion.state, HttpCompletionState::Verified);
assert!(completion.checksum_verified);
}
#[test]
fn streamed_completion_uses_observed_len_without_expected_len() {
let response = HttpResponseModel {
status: 200,
reason: "OK".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Streamed {
expected_len: None,
observed_len: Some(4096),
observed_digest: None,
temp_path: None,
},
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
};
let completion = response.completion_model();
assert_eq!(completion.total_length, Some(4096));
assert_eq!(completion.completed_length, 4096);
assert_eq!(completion.state, HttpCompletionState::Complete);
}
#[test]
fn streamed_completion_does_not_treat_expected_len_as_completed_len() {
let response = HttpResponseModel {
status: 200,
reason: "OK".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Streamed {
expected_len: Some(4096),
observed_len: None,
observed_digest: None,
temp_path: None,
},
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
};
let completion = response.completion_model();
assert_eq!(completion.total_length, Some(4096));
assert_eq!(completion.completed_length, 0);
assert_eq!(completion.state, HttpCompletionState::Partial);
}
#[test]
fn streamed_checksum_verifies_from_observed_digest_without_inline_body() {
let response = HttpResponseModel {
status: 200,
reason: "OK".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Streamed {
expected_len: None,
observed_len: Some(128),
observed_digest: Some(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad".to_string(),
),
temp_path: None,
},
content_range: None,
partial_content: false,
checksum: Some(ChecksumSpec {
algorithm: "sha-256".to_string(),
expected_hex: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
.to_string(),
actual_hex: None,
}),
redirected_from: None,
};
let completion = response.completion_model();
assert!(completion.checksum_seen);
assert!(completion.checksum_verified);
assert_eq!(completion.state, HttpCompletionState::Verified);
}
#[test]
fn transfer_task_tracks_resume_offsets() {
let task = HttpTransferTaskModel {
task_id: "task-resume-1".to_string(),
request: sample_request(),
response_headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Streamed {
expected_len: None,
observed_len: None,
observed_digest: None,
temp_path: None,
},
resume_state: Some(ResumeState {
requested_offset: 8192,
accepted_offset: Some(8192),
resumed: true,
}),
retry_attempts: vec![],
checksum_hook: None,
max_connections: 4,
retry: sample_retry_strategy(),
};
let resume_state = task.resume_state.expect("resume state should exist");
assert!(resume_state.resumed);
assert_eq!(resume_state.requested_offset, 8192);
assert_eq!(resume_state.accepted_offset, Some(8192));
}
#[test]
fn transfer_task_tracks_retry_attempt_history() {
let task = HttpTransferTaskModel {
task_id: "task-retry-1".to_string(),
request: sample_request(),
response_headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Streamed {
expected_len: None,
observed_len: None,
observed_digest: None,
temp_path: None,
},
resume_state: None,
retry_attempts: vec![
RetryAttempt {
attempt: 1,
reason: RetryReason::Timeout,
status: None,
backoff_ms: Some(100),
},
RetryAttempt {
attempt: 2,
reason: RetryReason::Http5xx,
status: Some(503),
backoff_ms: Some(250),
},
],
checksum_hook: None,
max_connections: 4,
retry: sample_retry_strategy(),
};
assert_eq!(task.retry_attempts.len(), 2);
assert_eq!(task.retry_attempts[0].reason, RetryReason::Timeout);
assert_eq!(task.retry_attempts[1].status, Some(503));
assert_eq!(task.retry_attempts[1].backoff_ms, Some(250));
}
#[test]
fn transfer_task_progress_snapshot_tracks_segment_progress_and_retry_context() {
let request = HttpRequestModel {
method: HttpMethod::Get,
url: "https://example.invalid/segment.bin".to_string(),
version: HttpVersion::Http11,
headers: HttpRequestHeaders { headers: vec![] },
query: HashMap::new(),
range: Some(RangeSpec {
start: 8192,
end_inclusive: Some(12_287),
unit: RangeUnit::Bytes,
}),
body: HttpBody::Empty,
retry: sample_retry_strategy(),
auth: None,
proxy: None,
response_sink: None,
};
let task = HttpTransferTaskModel {
task_id: "task-progress-1".to_string(),
request,
response_headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Streamed {
expected_len: None,
observed_len: None,
observed_digest: None,
temp_path: None,
},
resume_state: Some(ResumeState {
requested_offset: 8192,
accepted_offset: Some(8192),
resumed: true,
}),
retry_attempts: vec![
RetryAttempt {
attempt: 1,
reason: RetryReason::Timeout,
status: None,
backoff_ms: Some(100),
},
RetryAttempt {
attempt: 2,
reason: RetryReason::Http5xx,
status: Some(503),
backoff_ms: Some(250),
},
],
checksum_hook: Some(ChecksumHookModel {
spec: ChecksumSpec {
algorithm: "sha-256".to_string(),
expected_hex: "abc123".to_string(),
actual_hex: None,
},
enabled: true,
}),
max_connections: 4,
retry: sample_retry_strategy(),
};
let response = HttpResponseModel {
status: 206,
reason: "Partial Content".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Inline(b"abcdefghijkl".to_vec()),
content_range: Some(ContentRangeSpec {
unit: RangeUnit::Bytes,
start: 8192,
end_inclusive: 12_203,
total_size: Some(16_384),
unsatisfied: false,
}),
partial_content: true,
checksum: Some(ChecksumSpec {
algorithm: "sha-256".to_string(),
expected_hex: "abc123".to_string(),
actual_hex: Some("abc123".to_string()),
}),
redirected_from: None,
};
let progress = task.progress_snapshot(Some(&response));
assert_eq!(progress.task_id, "task-progress-1");
assert_eq!(progress.segment.requested_offset, 8192);
assert_eq!(progress.segment.accepted_offset, Some(8192));
assert_eq!(progress.segment.completed_offset, Some(12_204));
assert!(progress.segment.resumed);
assert_eq!(progress.retry_attempts.len(), 2);
assert_eq!(progress.retry_attempts[0].requested_offset, 8192);
assert_eq!(progress.retry_attempts[1].accepted_offset, Some(8192));
assert_eq!(
progress
.completion
.as_ref()
.expect("completion should be present")
.state,
HttpCompletionState::Partial
);
}
#[test]
fn progress_snapshot_clears_resume_truth_when_server_ignores_requested_range() {
let task = HttpTransferTaskModel {
task_id: "task-progress-range-ignored".to_string(),
request: sample_request(),
response_headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Empty,
resume_state: Some(ResumeState {
requested_offset: 4096,
accepted_offset: Some(4096),
resumed: true,
}),
retry_attempts: vec![],
checksum_hook: None,
max_connections: 1,
retry: sample_retry_strategy(),
};
let response = HttpResponseModel {
status: 200,
reason: "OK".to_string(),
version: HttpVersion::Http11,
headers: HttpResponseHeaders { headers: vec![] },
body: ResponseBody::Inline(vec![b'x'; 16_384]),
content_range: None,
partial_content: false,
checksum: None,
redirected_from: None,
};
let progress = task.progress_snapshot(Some(&response));
assert_eq!(progress.segment.requested_offset, 4096);
assert_eq!(progress.segment.accepted_offset, None);
assert_eq!(progress.segment.completed_offset, Some(16_384));
assert!(!progress.segment.resumed);
assert_eq!(
progress
.completion
.as_ref()
.expect("completion should exist")
.state,
HttpCompletionState::Complete
);
}
+134
View File
@@ -0,0 +1,134 @@
//! Protocol-layer models and helpers for the `aria2-rust-pro` workspace.
//!
//! This crate centralizes transport-facing request/response types plus parser
//! and serialization helpers shared by higher-level crates.
#![forbid(unsafe_code)]
#![expect(
clippy::arithmetic_side_effects,
clippy::indexing_slicing,
clippy::integer_division,
clippy::missing_const_for_fn,
clippy::missing_errors_doc,
clippy::module_name_repetitions,
clippy::multiple_crate_versions,
clippy::needless_pass_by_value,
clippy::result_large_err,
clippy::struct_excessive_bools,
reason = "protocol models intentionally mirror aria2 wire/config semantics where strict style lints obscure compatibility"
)]
/// Authentication challenge and credential models.
pub mod auth;
/// BitTorrent-facing re-exports and compatibility aliases.
pub mod bt;
/// Compatibility wrappers that bridge BitTorrent, magnet, and Metalink models.
pub mod bt_metalink;
/// Downloader traits and transport-backed implementations.
pub mod downloader;
/// FTP protocol request, response, and configuration models.
pub mod ftp;
/// HTTP protocol models, transfer state, and checksum helpers.
pub mod http;
/// Magnet URI parsing and serialization helpers.
pub mod magnet;
/// Metalink document parsing and resource selection helpers.
pub mod metalink;
/// Session-scoped transport and preference models.
pub mod session;
/// SFTP protocol request, response, and configuration models.
pub mod sftp;
/// Torrent metadata, peer-wire, and DHT message models.
pub mod torrent;
/// Tracker and DHT request parsing plus transport helpers.
pub mod tracker;
/// Generic transport connector abstractions and error models.
pub mod transport;
/// Logical protocol families recognized by the protocol layer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Protocol {
/// Plain HTTP transfers.
Http,
/// HTTP transfers over TLS.
Https,
/// FTP transfers.
Ftp,
/// SFTP transfers over SSH.
Sftp,
/// Metalink document processing.
Metalink,
/// `.torrent`-backed `BitTorrent` transfers.
BitTorrent,
/// Magnet URI bootstraps for `BitTorrent` transfers.
Magnet,
/// Local file inputs.
File,
}
impl Protocol {
/// Returns the canonical lowercase protocol name used in serialized forms.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Http => "http",
Self::Https => "https",
Self::Ftp => "ftp",
Self::Sftp => "sftp",
Self::Metalink => "metalink",
Self::BitTorrent => "bittorrent",
Self::Magnet => "magnet",
Self::File => "file",
}
}
}
pub use auth::{AuthChallengeModel, AuthCredentialModel, AuthScheme};
pub use bt_metalink::{
BtMetalinkError, MagnetUri, MetalinkDocument, ParserStatus, ProtocolSupportMatrix,
ProtocolSupportState, TorrentMetadata, TorrentMetadataError, protocol_support_matrix,
};
pub use downloader::{
AuthProvider, ChecksumVerifier, Downloader, FixtureHttpDownloader, FtpConnector, HttpConnector,
HttpOnlyDownloader, HttpsConnector, MetalinkConnector, ReqwestHttpConnector,
RetryStrategyProvider, SftpConnector, TorrentConnector,
};
pub use ftp::{
FtpCommandModel, FtpConfigModel, FtpMode, FtpRequestModel, FtpResponseModel, FtpSessionModel,
};
pub use http::{
ChecksumHookModel, ChecksumSpec, ContentRangeSpec, Cookie, HeaderKind, HttpBody,
HttpCompletionState, HttpHeader, HttpMethod, HttpRequestHeaders, HttpRequestModel,
HttpResponseHeaders, HttpResponseModel, HttpSessionModel, HttpTransferTaskModel, HttpVersion,
ProxyConfig, RangeSpec, RangeUnit, ResponseBody, ResumeState, RetryAttempt, RetryPolicy,
RetryReason, RetryStrategy, TlsConfig,
};
pub use magnet::{MagnetMetadataModel, MagnetUriModel};
pub use metalink::{
MetalinkChecksumModel, MetalinkDocumentModel, MetalinkFileModel, MetalinkParseResult,
MetalinkParserModel, MetalinkResourceModel, metalink_download_plan, parse_metalink_document,
preferred_download_candidate, preferred_resource_for_file,
};
pub use session::{
ClientModel, ServerModel, ServerSessionModel, SessionLimits, SessionModel, SessionScope,
SessionState, SessionTransportPreference,
};
pub use sftp::{
SftpCommandModel, SftpConfigModel, SftpRequestModel, SftpResponseModel, SftpSessionModel,
};
pub use torrent::{
DhtMessageModel, PeerWireMessageModel, TorrentFileEntryModel, TorrentHashModel,
TorrentInfoModel, TorrentMessageModel, TorrentMetadataModel, TorrentPeerModel,
TorrentPieceModel, TorrentTrackerModel, parse_torrent_metadata,
};
pub use tracker::{
DhtNodeModel, DhtTransport, ReqwestTrackerTransport, TrackerParseError, TrackerPeerListModel,
TrackerRequestModel, TrackerResponseModel, TrackerScrapeFileModel, TrackerScrapeModel,
TrackerTransport,
};
pub use transport::{
PeerWireTransportConnector, PeerWireTransportRequest, PeerWireTransportResponse,
StdDhtTransport, StdTcpPeerWireTransportConnector, StdUdpTransportConnector, TransportBody,
TransportConnector, TransportEndpoint, TransportError, TransportErrorContext,
TransportErrorKind, TransportRequest, TransportResponse, TransportResult, TransportScheme,
TransportStream, UdpTransportConnector, UdpTransportRequest, UdpTransportResponse,
};
@@ -0,0 +1,584 @@
//! Magnet URI parsing and serialization helpers.
#![forbid(unsafe_code)]
use crate::bt_metalink::BtMetalinkError;
use crate::{
torrent::{PeerWireExtensionHandshakeModel, TorrentPeerModel, TorrentTrackerModel},
tracker::DhtNodeModel,
};
/// Parsed representation of a magnet URI.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MagnetUriModel {
/// `BitTorrent` info-hash extracted from `xt=urn:btih:...`.
pub info_hash: String,
/// Optional display name from `dn=`.
pub display_name: Option<String>,
/// Tracker URLs from `tr=`.
pub trackers: Vec<String>,
/// Web-seed URLs from `ws=`.
pub web_seeds: Vec<String>,
/// Optional exact-topic or keyword field from `kt=`/`x.pe=`.
pub exact_topic: Option<String>,
}
/// Higher-level magnet metadata used by callers that already know payload size.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MagnetMetadataModel {
/// Canonical parsed URI.
pub uri: MagnetUriModel,
/// Known payload length when available.
pub known_length: Option<u64>,
/// Additional origin or source descriptors.
pub sources: Vec<String>,
}
/// Fully-shaped magnet bootstrap data ready for CLI or dispatcher orchestration.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MagnetBootstrapModel {
/// Canonical parsed magnet URI model.
pub uri: MagnetUriModel,
/// Lowercase hexadecimal 20-byte `BitTorrent` info-hash.
pub info_hash_hex: String,
/// Raw 20-byte `BitTorrent` info-hash.
pub info_hash_bytes: [u8; 20],
/// Tracker rows shaped with stable tier indices.
pub trackers: Vec<TorrentTrackerModel>,
/// Parsed peer endpoints from repeated `x.pe=` hints.
pub peer_hints: Vec<TorrentPeerModel>,
/// The same `x.pe=` hints re-shaped as DHT/bootstrap nodes.
pub peer_hint_nodes: Vec<DhtNodeModel>,
}
impl MagnetUriModel {
/// Parses a magnet URI into the protocol model.
///
/// # Errors
///
/// Returns an error when the input is not a valid magnet URI.
pub fn from_uri(input: &str) -> Result<Self, BtMetalinkError> {
parse_magnet_uri(input)
}
/// Returns the number of embedded tracker URLs.
#[must_use]
pub fn tracker_count(&self) -> usize {
self.trackers.len()
}
/// Decodes the magnet `btih` token into its raw 20-byte info-hash.
///
/// # Errors
///
/// Returns an error when the magnet does not contain a valid hexadecimal or base32 BTIH.
pub fn info_hash_bytes(&self) -> Result<[u8; 20], BtMetalinkError> {
decode_btih_token(&self.info_hash)
}
/// Returns the info-hash normalized to lowercase hexadecimal.
///
/// # Errors
///
/// Returns an error when the stored BTIH token is not a valid `BitTorrent` info-hash.
pub fn canonical_info_hash_hex(&self) -> Result<String, BtMetalinkError> {
self.info_hash_bytes().map(|hash| hex_encode_lower(&hash))
}
/// Shapes tracker URLs into stable tier-indexed tracker rows.
#[must_use]
pub fn tracker_models(&self) -> Vec<TorrentTrackerModel> {
self.trackers
.iter()
.enumerate()
.map(|(index, tracker)| TorrentTrackerModel {
url: tracker.clone(),
tier: Some(u32::try_from(index).unwrap_or(u32::MAX)),
id: None,
seeders: None,
leechers: None,
})
.collect()
}
/// Builds orchestration-oriented bootstrap data from the parsed magnet model.
///
/// This variant only uses data preserved by [`MagnetUriModel`], so peer hints are empty.
///
/// # Errors
///
/// Returns an error when the stored BTIH token is invalid.
pub fn bootstrap(&self) -> Result<MagnetBootstrapModel, BtMetalinkError> {
Ok(MagnetBootstrapModel {
uri: self.clone(),
info_hash_hex: self.canonical_info_hash_hex()?,
info_hash_bytes: self.info_hash_bytes()?,
trackers: self.tracker_models(),
peer_hints: Vec::new(),
peer_hint_nodes: Vec::new(),
})
}
/// Serializes the model back into a magnet URI string.
#[must_use]
pub fn to_uri(&self) -> String {
let mut query = Vec::new();
query.push(format!(
"xt={}",
percent_encode_query_value(&format!("urn:btih:{}", self.info_hash))
));
if let Some(display_name) = &self.display_name {
query.push(format!("dn={}", percent_encode_query_value(display_name)));
}
for tracker in &self.trackers {
query.push(format!("tr={}", percent_encode_query_value(tracker)));
}
for web_seed in &self.web_seeds {
query.push(format!("ws={}", percent_encode_query_value(web_seed)));
}
if let Some(exact_topic) = &self.exact_topic {
query.push(format!("kt={}", percent_encode_query_value(exact_topic)));
}
format!("magnet:?{}", query.join("&"))
}
}
impl MagnetMetadataModel {
/// Wraps a parsed URI together with an optional known payload length.
#[must_use]
pub fn from_uri(uri: MagnetUriModel, known_length: Option<u64>) -> Self {
Self {
uri,
known_length,
sources: Vec::new(),
}
}
/// Updates the known metadata length from an extended handshake when advertised.
pub fn apply_extension_handshake(&mut self, handshake: &PeerWireExtensionHandshakeModel) {
if let Some(metadata_size) = handshake.metadata_size {
self.known_length = Some(u64::from(metadata_size));
}
}
/// Serializes the wrapped URI back into a magnet string.
#[must_use]
pub fn to_uri(&self) -> String {
self.uri.to_uri()
}
}
/// Parses a magnet URI string into a [`MagnetUriModel`].
///
/// # Errors
///
/// Returns an error when the URI is missing the `magnet:?` prefix or a valid
/// `xt=urn:btih:<hash>` entry.
pub fn parse_magnet_uri(input: &str) -> Result<MagnetUriModel, BtMetalinkError> {
parse_magnet_fields(input).map(|fields| MagnetUriModel {
info_hash: fields.info_hash,
display_name: fields.display_name,
trackers: fields.trackers,
web_seeds: fields.web_seeds,
exact_topic: fields
.keyword_topic
.or_else(|| fields.peer_hints.last().cloned()),
})
}
/// Parses a magnet URI into a richer bootstrap model for live BT orchestration.
///
/// # Errors
///
/// Returns an error when the magnet URI is malformed or the BTIH / peer hints are invalid.
pub fn parse_magnet_bootstrap(input: &str) -> Result<MagnetBootstrapModel, BtMetalinkError> {
let fields = parse_magnet_fields(input)?;
let uri = MagnetUriModel {
info_hash: fields.info_hash,
display_name: fields.display_name,
trackers: fields.trackers,
web_seeds: fields.web_seeds,
exact_topic: fields
.keyword_topic
.or_else(|| fields.peer_hints.last().cloned()),
};
let mut bootstrap = uri.bootstrap()?;
let peer_hints = fields
.peer_hints
.iter()
.map(|raw| {
TorrentPeerModel::from_endpoint(raw).map_err(|reason| BtMetalinkError::InvalidMagnet {
reason: format!("invalid x.pe peer hint {raw:?}: {reason}"),
})
})
.collect::<Result<Vec<_>, _>>()?;
let peer_hint_nodes = peer_hints
.iter()
.map(TorrentPeerModel::to_dht_node)
.collect::<Vec<_>>();
bootstrap.peer_hints = peer_hints;
bootstrap.peer_hint_nodes = peer_hint_nodes;
Ok(bootstrap)
}
/// Decodes a magnet query fragment using percent-decoding plus `+` as space.
fn percent_decode(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' && index + 2 < bytes.len() {
let hi = bytes[index + 1];
let lo = bytes[index + 2];
if let (Some(hi), Some(lo)) = (hex_value(hi), hex_value(lo)) {
output.push(char::from((hi << 4) | lo));
index += 3;
continue;
}
}
if bytes[index] == b'+' {
output.push(' ');
index += 1;
continue;
}
output.push(char::from(bytes[index]));
index += 1;
}
output
}
/// Percent-encodes one magnet query value while preserving URL-safe delimiters.
fn percent_encode_query_value(input: &str) -> String {
let mut output = String::with_capacity(input.len());
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b':' | b'/' => {
output.push(char::from(byte));
}
b' ' => output.push_str("%20"),
_ => {
output.push('%');
output.push(char::from(HEX[usize::from(byte >> 4)]));
output.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
}
}
output
}
/// Uppercase hexadecimal digits used by the percent encoder.
const HEX: &[u8; 16] = b"0123456789ABCDEF";
/// Converts one ASCII hex digit into its numeric nibble value.
const fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
/// Parsed magnet query fields before they are shaped into public models.
struct MagnetParseFields {
/// Raw `btih` token captured from the `xt=` field.
info_hash: String,
/// Optional display name decoded from `dn=`.
display_name: Option<String>,
/// Tracker URLs collected from repeated `tr=` fields.
trackers: Vec<String>,
/// Web-seed URLs collected from repeated `ws=` fields.
web_seeds: Vec<String>,
/// Optional keyword topic decoded from `kt=`.
keyword_topic: Option<String>,
/// Peer bootstrap hints collected from repeated `x.pe=` fields.
peer_hints: Vec<String>,
}
/// Parses raw magnet query fields while preserving repeated `x.pe=` entries.
fn parse_magnet_fields(input: &str) -> Result<MagnetParseFields, BtMetalinkError> {
let payload = input
.strip_prefix("magnet:?")
.ok_or_else(|| BtMetalinkError::InvalidMagnet {
reason: "missing magnet:? prefix".to_owned(),
})?;
let mut info_hash = None;
let mut display_name = None;
let mut trackers = Vec::new();
let mut web_seeds = Vec::new();
let mut keyword_topic = None;
let mut peer_hints = Vec::new();
for pair in payload.split('&') {
let Some((key, value)) = pair.split_once('=') else {
continue;
};
match key {
"xt" if value.starts_with("urn:btih:") => {
info_hash = Some(value.trim_start_matches("urn:btih:").to_owned());
}
"dn" => display_name = Some(percent_decode(value)),
"tr" => trackers.push(percent_decode(value)),
"ws" => web_seeds.push(percent_decode(value)),
"kt" => keyword_topic = Some(percent_decode(value)),
"x.pe" => peer_hints.push(percent_decode(value)),
_ => {}
}
}
let info_hash = info_hash.ok_or_else(|| BtMetalinkError::InvalidMagnet {
reason: "missing xt=urn:btih:<hash>".to_owned(),
})?;
Ok(MagnetParseFields {
info_hash,
display_name,
trackers,
web_seeds,
keyword_topic,
peer_hints,
})
}
/// Decodes one BTIH token into its 20-byte info-hash form.
fn decode_btih_token(input: &str) -> Result<[u8; 20], BtMetalinkError> {
let normalized = input
.chars()
.filter(char::is_ascii_alphanumeric)
.collect::<String>();
if normalized.len() == 40 && normalized.chars().all(|ch| ch.is_ascii_hexdigit()) {
let mut out = [0_u8; 20];
for (index, chunk) in normalized.as_bytes().chunks_exact(2).enumerate() {
let hi = hex_value(chunk[0]).ok_or_else(|| BtMetalinkError::InvalidMagnet {
reason: format!("invalid hex digit in btih token: {input}"),
})?;
let lo = hex_value(chunk[1]).ok_or_else(|| BtMetalinkError::InvalidMagnet {
reason: format!("invalid hex digit in btih token: {input}"),
})?;
out[index] = (hi << 4) | lo;
}
return Ok(out);
}
if normalized.len() == 32 {
return decode_base32_btih(&normalized);
}
Err(BtMetalinkError::InvalidMagnet {
reason: format!("btih token must be 40 hex or 32 base32 characters, got {input}"),
})
}
/// Decodes an RFC 4648 base32 BTIH token into raw bytes.
fn decode_base32_btih(input: &str) -> Result<[u8; 20], BtMetalinkError> {
let mut out = [0_u8; 20];
let mut accumulator = 0_u64;
let mut bits = 0_u32;
let mut written = 0_usize;
for byte in input.bytes() {
let value = match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a',
b'2'..=b'7' => byte - b'2' + 26,
_ => {
return Err(BtMetalinkError::InvalidMagnet {
reason: format!("invalid base32 digit in btih token: {input}"),
});
}
};
accumulator = (accumulator << 5) | u64::from(value);
bits += 5;
while bits >= 8 {
bits -= 8;
if written >= out.len() {
return Err(BtMetalinkError::InvalidMagnet {
reason: format!("base32 btih token decoded longer than 20 bytes: {input}"),
});
}
out[written] = u8::try_from((accumulator >> bits) & 0xff)
.expect("masked base32 byte must fit into u8");
written += 1;
}
}
if written != out.len() {
return Err(BtMetalinkError::InvalidMagnet {
reason: format!("base32 btih token decoded to {written} bytes instead of 20"),
});
}
Ok(out)
}
/// Encodes bytes as lowercase hexadecimal text.
fn hex_encode_lower(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(nibble_to_hex(byte >> 4));
out.push(nibble_to_hex(byte & 0x0f));
}
out
}
/// Formats one nibble as a lowercase hexadecimal digit.
fn nibble_to_hex(nibble: u8) -> char {
match nibble {
0..=9 => char::from(b'0' + nibble),
10..=15 => char::from(b'a' + (nibble - 10)),
_ => '?',
}
}
#[cfg(test)]
mod tests {
use super::{MagnetMetadataModel, MagnetUriModel, parse_magnet_bootstrap, parse_magnet_uri};
#[test]
fn parses_magnet_uri_into_model() {
let uri = parse_magnet_uri(
"magnet:?xt=urn:btih:0123456789abcdef&dn=Ubuntu%2024.04&tr=http%3A%2F%2Ftracker.example.org%2Fannounce&ws=https%3A%2F%2Fcdn.example.org%2Fubuntu.iso",
)
.expect("magnet uri should parse");
assert_eq!(uri.info_hash, "0123456789abcdef");
assert_eq!(uri.display_name.as_deref(), Some("Ubuntu 24.04"));
assert_eq!(uri.trackers.len(), 1);
assert_eq!(uri.web_seeds.len(), 1);
}
#[test]
fn model_constructor_tracks_torrent_count() {
let model = MagnetUriModel {
info_hash: "deadbeef".to_owned(),
display_name: None,
trackers: vec!["http://tracker.example.org/announce".to_owned()],
web_seeds: Vec::new(),
exact_topic: Some("urn:btih:deadbeef".to_owned()),
};
assert_eq!(model.tracker_count(), 1);
assert_eq!(
model.to_uri(),
"magnet:?xt=urn:btih:deadbeef&tr=http://tracker.example.org/announce&kt=urn:btih:deadbeef"
);
}
#[test]
fn magnet_metadata_wraps_uri() {
let uri = MagnetUriModel {
info_hash: "0123456789abcdef".to_owned(),
display_name: Some("Ubuntu".to_owned()),
trackers: Vec::new(),
web_seeds: Vec::new(),
exact_topic: None,
};
let metadata = MagnetMetadataModel::from_uri(uri.clone(), Some(123));
assert_eq!(metadata.uri, uri);
assert_eq!(metadata.known_length, Some(123));
assert_eq!(
metadata.to_uri(),
"magnet:?xt=urn:btih:0123456789abcdef&dn=Ubuntu"
);
}
#[test]
fn magnet_metadata_can_apply_known_length_from_extended_handshake() {
let uri = MagnetUriModel {
info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(),
display_name: Some("Ubuntu".to_owned()),
trackers: vec!["udp://tracker.example.org:6969".to_owned()],
web_seeds: Vec::new(),
exact_topic: None,
};
let mut metadata = MagnetMetadataModel::from_uri(uri, None);
let handshake = crate::torrent::PeerWireExtensionHandshakeModel {
extensions: std::collections::BTreeMap::from([("ut_metadata".to_owned(), 3_u8)]),
client_name: Some("aria2-rust-pro".to_owned()),
metadata_size: Some(48_321),
request_queue: Some(64),
};
metadata.apply_extension_handshake(&handshake);
assert_eq!(metadata.known_length, Some(48_321));
}
#[test]
fn parses_realistic_uri_with_multiple_trackers_and_keyword_topic() {
let text = "magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567&dn=Arch+Linux+ISO&tr=udp%3A%2F%2Ftracker.one.example%3A1337%2Fannounce&tr=https%3A%2F%2Ftracker.two.example%2Fannounce&ws=https%3A%2F%2Fcdn.example.org%2Farch.iso&kt=linux+iso";
let model = parse_magnet_uri(text).expect("realistic magnet should parse");
assert_eq!(model.info_hash, "0123456789ABCDEF0123456789ABCDEF01234567");
assert_eq!(model.display_name.as_deref(), Some("Arch Linux ISO"));
assert_eq!(
model.trackers,
vec![
"udp://tracker.one.example:1337/announce".to_owned(),
"https://tracker.two.example/announce".to_owned(),
]
);
assert_eq!(model.web_seeds, vec!["https://cdn.example.org/arch.iso"]);
assert_eq!(model.exact_topic.as_deref(), Some("linux iso"));
}
#[test]
fn parser_accepts_x_pe_and_roundtrips_as_kt() {
let parsed = parse_magnet_uri(
"magnet:?xt=urn:btih:89abcdef0123456789abcdef0123456789abcdef&dn=Ubuntu%2026.04&tr=http%3A%2F%2Ft1.example%2Fa&tr=http%3A%2F%2Ft2.example%2Fa&ws=https%3A%2F%2Fseed.example%2Fubuntu.iso&x.pe=ubuntu%20lts",
)
.expect("x.pe should parse");
assert_eq!(parsed.exact_topic.as_deref(), Some("ubuntu lts"));
assert_eq!(parsed.trackers.len(), 2);
let roundtrip = parse_magnet_uri(&parsed.to_uri()).expect("roundtrip should parse");
assert_eq!(roundtrip, parsed);
}
#[test]
fn magnet_bootstrap_normalizes_info_hash_and_extracts_peer_hints() {
let bootstrap = parse_magnet_bootstrap(
"magnet:?xt=urn:btih:00112233445566778899AABBCCDDEEFF00112233&dn=magnet-bootstrap.iso&tr=http%3A%2F%2Ftracker-a.example.org%2Fannounce&tr=udp%3A%2F%2Ftracker-b.example.org%3A6969&x.pe=198.51.100.9%3A51413&x.pe=%5B2001%3Adb8%3A%3A9%5D%3A51413",
)
.expect("bootstrap magnet should parse");
assert_eq!(
bootstrap.info_hash_hex,
"00112233445566778899aabbccddeeff00112233"
);
assert_eq!(bootstrap.info_hash_bytes[0], 0x00);
assert_eq!(bootstrap.info_hash_bytes[19], 0x33);
assert_eq!(bootstrap.trackers.len(), 2);
assert_eq!(bootstrap.trackers[0].tier, Some(0));
assert_eq!(bootstrap.trackers[1].tier, Some(1));
assert_eq!(bootstrap.peer_hints.len(), 2);
assert_eq!(bootstrap.peer_hints[0].ip, "198.51.100.9");
assert_eq!(bootstrap.peer_hints[0].port, 51413);
assert_eq!(bootstrap.peer_hints[1].ip, "2001:db8::9");
assert_eq!(bootstrap.peer_hints[1].port, 51413);
assert_eq!(bootstrap.peer_hint_nodes[0].to_spec(), "198.51.100.9:51413");
assert_eq!(
bootstrap.peer_hint_nodes[1].to_spec(),
"[2001:db8::9]:51413"
);
}
#[test]
fn magnet_info_hash_helper_decodes_base32_btih() {
let parsed =
parse_magnet_uri("magnet:?xt=urn:btih:AERUKZ4JVPG66AJDIVTYTK6N54ASGRLH&dn=base32.iso")
.expect("base32 magnet should parse");
assert_eq!(
parsed
.canonical_info_hash_hex()
.expect("base32 btih should normalize"),
"0123456789abcdef0123456789abcdef01234567"
);
assert_eq!(
parsed.info_hash_bytes().expect("base32 btih should decode"),
[
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab,
0xcd, 0xef, 0x01, 0x23, 0x45, 0x67,
]
);
}
}
@@ -0,0 +1,23 @@
//! Metalink document parsing and download-candidate selection helpers.
#![forbid(unsafe_code)]
/// Shared Metalink document and resource data models.
mod model;
/// URL and metadata normalization helpers for parsed Metalink files.
mod normalization;
/// XML parsing entry points for Metalink documents.
mod parser;
/// Download-candidate planning and ranking helpers.
mod planner;
#[cfg(test)]
mod tests;
pub use self::model::{
MetalinkChecksumModel, MetalinkDocumentModel, MetalinkDownloadPlanEntry, MetalinkFileModel,
MetalinkParseResult, MetalinkParserModel, MetalinkResourceModel,
};
pub use self::parser::parse_metalink_document;
pub use self::planner::{
metalink_download_plan, preferred_download_candidate, preferred_resource_for_file,
};
@@ -0,0 +1,137 @@
use crate::http::ChecksumSpec;
use super::parser::parse_metalink_document;
/// Checksum entry parsed from a Metalink file description.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetalinkChecksumModel {
/// Checksum algorithm name normalized for downstream use.
pub algorithm: String,
/// Expected checksum value as provided by the document.
pub value: String,
/// Whether the checksum has already been verified by another stage.
pub verified: bool,
}
/// Mirror or source URI candidate parsed from a Metalink file description.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetalinkResourceModel {
/// Download URI.
pub url: String,
/// Optional location hint such as a country or region code.
pub location: Option<String>,
/// Optional mirror priority where lower values are preferred.
pub priority: Option<u32>,
/// Optional maximum per-resource connection count.
pub max_connections: Option<u32>,
/// Whether the resource is marked private.
pub private: bool,
/// Optional resource type hint such as `http` or `ftp`.
pub type_hint: Option<String>,
/// Optional language hint.
pub language: Option<String>,
}
/// File entry parsed from a Metalink document.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetalinkFileModel {
/// Output filename suggested by the document.
pub name: String,
/// Optional declared file size in bytes.
pub size: Option<u64>,
/// Checksums associated with the file.
pub checksums: Vec<MetalinkChecksumModel>,
/// Candidate download resources for the file.
pub resources: Vec<MetalinkResourceModel>,
/// Detached signature payloads or references.
pub signatures: Vec<String>,
/// Optional identity field scoped to this file.
pub identifier: Option<String>,
/// Optional human-readable description.
pub description: Option<String>,
}
/// Parsed Metalink document model.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetalinkDocumentModel {
/// Metalink version attribute from the root element.
pub version: Option<String>,
/// Files declared by the document.
pub files: Vec<MetalinkFileModel>,
/// Optional document-wide identity.
pub identity: Option<String>,
/// Optional publisher string.
pub publisher: Option<String>,
/// Optional generator string.
pub generator: Option<String>,
/// Optional publication timestamp.
pub published_at: Option<String>,
}
/// Parser settings and last-known error state for Metalink parsing.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetalinkParserModel {
/// Whether downstream callers expect strict validation.
pub strict: bool,
/// Last parse error captured by the parser facade.
pub last_error: Option<String>,
/// Whether partial models may be accepted by callers.
pub allow_partial: bool,
}
/// Result wrapper returned by the parser facade.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetalinkParseResult {
/// Parsed document when successful.
pub document: Option<MetalinkDocumentModel>,
/// Parser state after the attempted parse.
pub parser: MetalinkParserModel,
}
/// Single-file download plan derived from a Metalink document.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetalinkDownloadPlanEntry {
/// Target file name.
pub file_name: String,
/// Optional declared size in bytes.
pub size: Option<u64>,
/// Preferred checksum supported by the protocol layer.
pub checksum: Option<ChecksumSpec>,
/// Ordered list of candidate URIs.
pub uris: Vec<String>,
/// Optional identity field carried into the plan.
pub identifier: Option<String>,
/// Optional description carried into the plan.
pub description: Option<String>,
}
impl MetalinkParserModel {
/// Creates a parser facade with the requested strictness settings.
#[must_use]
pub const fn new(strict: bool, allow_partial: bool) -> Self {
Self {
strict,
last_error: None,
allow_partial,
}
}
/// Parses a Metalink document while capturing the last parse error on failure.
#[must_use]
pub fn parse(&self, input: &str) -> MetalinkParseResult {
let mut parser = self.clone();
match parse_metalink_document(input) {
Ok(document) => MetalinkParseResult {
document: Some(document),
parser,
},
Err(error) => {
parser.last_error = Some(error);
MetalinkParseResult {
document: None,
parser,
}
}
}
}
}
@@ -0,0 +1,96 @@
/// Decodes one XML local-name byte slice into owned UTF-8-lossy text.
pub(super) fn decode_local_name(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
/// Decodes arbitrary XML text bytes into owned UTF-8-lossy text.
pub(super) fn decode_bytes(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
/// Trims surrounding whitespace from parser text content.
pub(super) fn normalize_text(value: &str) -> String {
value.trim().to_owned()
}
/// Normalizes an optional resource location hint.
pub(super) fn normalize_location(value: &str) -> Option<String> {
let value = normalize_text(value);
if value.is_empty() {
None
} else {
Some(value.to_ascii_lowercase())
}
}
/// Normalizes an optional resource language hint.
pub(super) fn normalize_language(value: &str) -> Option<String> {
let value = normalize_text(value);
if value.is_empty() {
None
} else {
Some(value.to_ascii_lowercase())
}
}
/// Normalizes an optional explicit resource type hint.
pub(super) fn normalize_resource_type(value: &str) -> Option<String> {
let value = normalize_text(value);
if value.is_empty() {
None
} else {
Some(value.to_ascii_lowercase())
}
}
/// Normalizes Metalink checksum algorithm names to stable downstream forms.
pub(super) fn normalize_checksum_algorithm(value: &str) -> Option<String> {
let normalized = normalize_text(value)
.replace(['_', ' '], "")
.to_ascii_lowercase();
match normalized.as_str() {
"" => None,
"sha1" => Some("sha-1".to_owned()),
"sha256" => Some("sha-256".to_owned()),
"sha512" => Some("sha-512".to_owned()),
other if other.starts_with("sha-") => Some(other.to_owned()),
other => Some(other.to_owned()),
}
}
/// Removes separators and lowercases a checksum payload.
pub(super) fn normalize_checksum_value(value: &str) -> String {
value
.chars()
.filter(|ch| !ch.is_ascii_whitespace())
.collect::<String>()
.to_ascii_lowercase()
}
/// Infers a resource type hint from a resource URL scheme.
pub(super) fn infer_resource_type_from_url(url: &str) -> Option<String> {
let scheme = url.split(':').next()?.trim();
if scheme.is_empty() {
None
} else {
Some(scheme.to_ascii_lowercase())
}
}
/// Returns whether a Metalink boolean attribute should be treated as enabled.
pub(super) fn is_truthy(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes"
)
}
/// Returns whether the current XML element stack contains `target`.
pub(super) fn stack_contains(stack: &[String], target: &str) -> bool {
stack.iter().any(|entry| entry == target)
}
/// Returns whether an optional string is absent or only whitespace.
pub(super) fn is_blank_opt(value: Option<&str>) -> bool {
value.is_none_or(str::is_empty)
}
@@ -0,0 +1,310 @@
use quick_xml::{
Reader,
events::{BytesEnd, BytesStart, Event},
};
use super::{
model::{
MetalinkChecksumModel, MetalinkDocumentModel, MetalinkFileModel, MetalinkResourceModel,
},
normalization::{
decode_bytes, decode_local_name, infer_resource_type_from_url, is_truthy,
normalize_checksum_algorithm, normalize_checksum_value, normalize_language,
normalize_location, normalize_resource_type, normalize_text, stack_contains,
},
};
/// Parses a Metalink XML document into the protocol-layer model.
///
/// # Errors
///
/// Returns an error when the XML is malformed or the document contains no
/// actionable file/resource entries.
pub fn parse_metalink_document(input: &str) -> Result<MetalinkDocumentModel, String> {
let mut reader = Reader::from_str(input);
reader.config_mut().trim_text(true);
let mut document = MetalinkDocumentModel {
version: None,
files: Vec::new(),
identity: None,
publisher: None,
generator: None,
published_at: None,
};
let mut current_file: Option<MetalinkFileModel> = None;
let mut current_checksum_algorithm: Option<String> = None;
let mut element_stack = Vec::new();
loop {
match reader.read_event() {
Ok(Event::Start(event)) => handle_start_event(
&event,
&mut document,
&mut current_file,
&mut current_checksum_algorithm,
&mut element_stack,
),
Ok(Event::Text(text)) => {
let raw_value = text
.decode()
.map_err(|error| error.to_string())?
.into_owned();
apply_parser_text(
&raw_value,
&element_stack,
&mut document,
&mut current_file,
current_checksum_algorithm.as_deref(),
);
}
Ok(Event::CData(text)) => {
let raw_value = decode_bytes(text.as_ref());
apply_parser_text(
&raw_value,
&element_stack,
&mut document,
&mut current_file,
current_checksum_algorithm.as_deref(),
);
}
Ok(Event::End(event)) => handle_end_event(
&event,
&mut document,
&mut current_file,
&mut current_checksum_algorithm,
&mut element_stack,
),
Ok(Event::Eof) => break,
Err(error) => return Err(error.to_string()),
_ => {}
}
}
validate_document(document)
}
/// Applies one XML start event to the in-progress Metalink parse state.
fn handle_start_event(
event: &BytesStart<'_>,
document: &mut MetalinkDocumentModel,
current_file: &mut Option<MetalinkFileModel>,
current_checksum_algorithm: &mut Option<String>,
element_stack: &mut Vec<String>,
) {
let name = decode_local_name(event.local_name().as_ref());
match name.as_str() {
"metalink" => {
document.version = event
.attributes()
.flatten()
.find(|attr| attr.key.local_name().as_ref() == b"version")
.map(|attr| normalize_text(&decode_bytes(attr.value.as_ref())));
}
"file" => {
*current_file = Some(MetalinkFileModel {
name: file_name_from_start(event),
size: None,
checksums: Vec::new(),
resources: Vec::new(),
signatures: Vec::new(),
identifier: None,
description: None,
});
}
"url" => push_resource_placeholder(event, current_file),
"hash" => {
*current_checksum_algorithm = checksum_algorithm_from_start(event, element_stack);
}
_ => {}
}
element_stack.push(name);
}
/// Extracts and normalizes the `name` attribute from a `<file>` start tag.
fn file_name_from_start(event: &BytesStart<'_>) -> String {
event
.attributes()
.flatten()
.find(|attr| attr.key.local_name().as_ref() == b"name")
.map(|attr| normalize_text(&decode_bytes(attr.value.as_ref())))
.unwrap_or_default()
}
/// Appends a resource placeholder to the current file when a `<url>` tag begins.
fn push_resource_placeholder(event: &BytesStart<'_>, current_file: &mut Option<MetalinkFileModel>) {
if let Some(file) = current_file.as_mut() {
file.resources.push(resource_from_start(event));
}
}
/// Builds a resource model from one `<url>` start tag and its attributes.
fn resource_from_start(event: &BytesStart<'_>) -> MetalinkResourceModel {
let mut location = None;
let mut priority = None;
let mut max_connections = None;
let mut private = false;
let mut type_hint = None;
let mut language = None;
for attr in event.attributes().flatten() {
let key = decode_local_name(attr.key.local_name().as_ref());
let value = normalize_text(&decode_bytes(attr.value.as_ref()));
match key.as_str() {
"location" => location = normalize_location(&value),
"priority" => priority = value.parse().ok(),
"maxconnections" => max_connections = value.parse().ok(),
"private" => private = is_truthy(&value),
"type" => type_hint = normalize_resource_type(&value),
"lang" => language = normalize_language(&value),
_ => {}
}
}
MetalinkResourceModel {
url: String::new(),
location,
priority,
max_connections,
private,
type_hint,
language,
}
}
/// Captures the checksum algorithm for a `<hash>` tag outside `<pieces>`.
fn checksum_algorithm_from_start(
event: &BytesStart<'_>,
element_stack: &[String],
) -> Option<String> {
if stack_contains(element_stack, "pieces") {
return None;
}
event
.attributes()
.flatten()
.find(|attr| attr.key.local_name().as_ref() == b"type")
.and_then(|attr| normalize_checksum_algorithm(&decode_bytes(attr.value.as_ref())))
}
/// Routes decoded XML text into the shared text-value application helper.
fn apply_parser_text(
raw_value: &str,
element_stack: &[String],
document: &mut MetalinkDocumentModel,
current_file: &mut Option<MetalinkFileModel>,
current_checksum_algorithm: Option<&str>,
) {
apply_text_value(
raw_value,
element_stack,
document,
current_file,
current_checksum_algorithm,
);
}
/// Applies one XML end event to the in-progress Metalink parse state.
fn handle_end_event(
event: &BytesEnd<'_>,
document: &mut MetalinkDocumentModel,
current_file: &mut Option<MetalinkFileModel>,
current_checksum_algorithm: &mut Option<String>,
element_stack: &mut Vec<String>,
) {
let name = decode_local_name(event.local_name().as_ref());
if name == "file"
&& let Some(file) = current_file.take()
{
document.files.push(file);
}
if name == "hash" {
*current_checksum_algorithm = None;
}
let _ = element_stack.pop();
}
/// Rejects parsed documents that contain no actionable file or resource entries.
fn validate_document(document: MetalinkDocumentModel) -> Result<MetalinkDocumentModel, String> {
if document.files.is_empty() {
return Err("metalink document contains no files".to_owned());
}
if document.files.iter().all(file_has_no_resource_urls) {
return Err("metalink document contains no resource urls".to_owned());
}
Ok(document)
}
/// Returns whether a file has no non-empty resource URLs.
fn file_has_no_resource_urls(file: &MetalinkFileModel) -> bool {
file.resources.is_empty()
|| file
.resources
.iter()
.all(|resource| resource.url.is_empty())
}
/// Applies normalized text content to the current document or file context.
fn apply_text_value(
raw_value: &str,
element_stack: &[String],
document: &mut MetalinkDocumentModel,
current_file: &mut Option<MetalinkFileModel>,
current_checksum_algorithm: Option<&str>,
) {
let value = normalize_text(raw_value);
if value.is_empty() {
return;
}
let current_element = element_stack.last().map_or("", String::as_str);
match current_element {
"identity" => {
if let Some(file) = current_file.as_mut() {
file.identifier = Some(value);
} else {
document.identity = Some(value);
}
}
"publisher" => document.publisher = Some(value),
"generator" => document.generator = Some(value),
"published" => document.published_at = Some(value),
"size" => {
if let Some(file) = current_file.as_mut() {
file.size = value.parse().ok();
}
}
"url" => {
if let Some(file) = current_file.as_mut()
&& let Some(resource) = file.resources.last_mut()
{
resource.url = value;
if resource.type_hint.is_none() {
resource.type_hint = infer_resource_type_from_url(&resource.url);
}
}
}
"hash" => {
if !stack_contains(element_stack, "pieces")
&& let Some(file) = current_file.as_mut()
{
file.checksums.push(MetalinkChecksumModel {
algorithm: current_checksum_algorithm.unwrap_or("unknown").to_owned(),
value: normalize_checksum_value(&value),
verified: false,
});
}
}
"description" => {
if let Some(file) = current_file.as_mut() {
file.description = Some(value);
}
}
"signature" => {
if let Some(file) = current_file.as_mut() {
file.signatures.push(value);
}
}
_ => {}
}
}
@@ -0,0 +1,114 @@
use std::cmp::Reverse;
use crate::http::ChecksumSpec;
use super::{
model::{
MetalinkDocumentModel, MetalinkDownloadPlanEntry, MetalinkFileModel, MetalinkResourceModel,
},
normalization::is_blank_opt,
};
/// Returns the preferred resource for a file according to priority and richness hints.
#[must_use]
pub fn preferred_resource_for_file(file: &MetalinkFileModel) -> Option<&MetalinkResourceModel> {
file.resources
.iter()
.enumerate()
.filter(|(_, resource)| !resource.url.trim().is_empty())
.min_by_key(|(index, resource)| {
(
resource.priority.unwrap_or(u32::MAX),
resource.private,
Reverse(resource.max_connections.unwrap_or(0)),
is_blank_opt(resource.location.as_deref()),
is_blank_opt(resource.type_hint.as_deref()),
is_blank_opt(resource.language.as_deref()),
*index,
)
})
.map(|(_, resource)| resource)
}
/// Returns the first file/resource pair that can be downloaded from the document.
#[must_use]
pub fn preferred_download_candidate(
document: &MetalinkDocumentModel,
) -> Option<(&MetalinkFileModel, &MetalinkResourceModel)> {
document
.files
.iter()
.find_map(|file| preferred_resource_for_file(file).map(|resource| (file, resource)))
}
/// Builds a per-file download plan with ordered fallback URIs.
#[must_use]
pub fn metalink_download_plan(document: &MetalinkDocumentModel) -> Vec<MetalinkDownloadPlanEntry> {
document
.files
.iter()
.filter_map(metalink_download_plan_entry_for_file)
.collect()
}
/// Builds one plan entry for a file when at least one actionable URI exists.
fn metalink_download_plan_entry_for_file(
file: &MetalinkFileModel,
) -> Option<MetalinkDownloadPlanEntry> {
let preferred = preferred_resource_for_file(file)?;
let mut uris = Vec::new();
push_unique_uri(&mut uris, &preferred.url);
for resource in &file.resources {
push_unique_uri(&mut uris, &resource.url);
}
if uris.is_empty() {
return None;
}
Some(MetalinkDownloadPlanEntry {
file_name: file.name.clone(),
size: file.size,
checksum: file_supported_checksum(file),
uris,
identifier: file.identifier.clone(),
description: file.description.clone(),
})
}
/// Adds a trimmed URI candidate once while preserving first-seen order.
fn push_unique_uri(uris: &mut Vec<String>, candidate: &str) {
let trimmed = candidate.trim();
if trimmed.is_empty() || uris.iter().any(|existing| existing == trimmed) {
return;
}
uris.push(trimmed.to_owned());
}
/// Selects the first checksum whose algorithm is supported by the protocol layer.
fn file_supported_checksum(file: &MetalinkFileModel) -> Option<ChecksumSpec> {
file.checksums.iter().find_map(|checksum| {
supported_checksum_algorithm(&checksum.algorithm).then(|| ChecksumSpec {
algorithm: checksum.algorithm.clone(),
expected_hex: checksum.value.clone(),
actual_hex: None,
})
})
}
/// Returns whether a normalized checksum algorithm is supported downstream.
fn supported_checksum_algorithm(algorithm: &str) -> bool {
matches!(
algorithm,
"sha"
| "sha-1"
| "sha-224"
| "sha-256"
| "sha-384"
| "sha-512"
| "md5"
| "adler32"
| "adler-32"
| "crc32"
| "crc-32"
)
}
@@ -0,0 +1,448 @@
use super::{
MetalinkDocumentModel, MetalinkFileModel, MetalinkParserModel, MetalinkResourceModel,
metalink_download_plan, parse_metalink_document, preferred_download_candidate,
preferred_resource_for_file,
};
#[test]
fn parse_metalink4_fixture_matches_golden_document() {
let document = parse_metalink_document(
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0" xmlns="urn:ietf:params:xml:ns:metalink">
<identity>Aria2 reference</identity>
<publisher>aria2 project</publisher>
<generator>golden-fixture</generator>
<published>2026-05-26</published>
<file name="ubuntu.iso">
<size>1024</size>
<description>Ubuntu desktop image</description>
<hash type="sha-256">aaaaaaaa</hash>
<url location="jp" priority="2" type="https">https://mirror.jp/ubuntu.iso</url>
<url location="us" priority="1" type="https">https://mirror.us/ubuntu.iso</url>
</file>
<file name="ignored.txt">
<size>12</size>
<url priority="1"> </url>
</file>
</metalink>"#,
)
.expect("metalink should parse");
assert_eq!(document.version.as_deref(), Some("4.0"));
assert_eq!(document.identity.as_deref(), Some("Aria2 reference"));
assert_eq!(document.publisher.as_deref(), Some("aria2 project"));
assert_eq!(document.generator.as_deref(), Some("golden-fixture"));
assert_eq!(document.published_at.as_deref(), Some("2026-05-26"));
assert_eq!(document.files.len(), 2);
assert_eq!(document.files[0].name, "ubuntu.iso");
assert_eq!(document.files[0].size, Some(1024));
assert_eq!(
document.files[0].description.as_deref(),
Some("Ubuntu desktop image")
);
assert_eq!(document.files[0].checksums[0].algorithm, "sha-256");
assert_eq!(document.files[0].checksums[0].value, "aaaaaaaa");
assert_eq!(
document.files[0].resources[0].url,
"https://mirror.jp/ubuntu.iso"
);
assert_eq!(
document.files[0].resources[0].location.as_deref(),
Some("jp")
);
assert_eq!(document.files[0].resources[0].priority, Some(2));
assert_eq!(
document.files[0].resources[1].url,
"https://mirror.us/ubuntu.iso"
);
assert_eq!(
document.files[0].resources[1].location.as_deref(),
Some("us")
);
assert_eq!(document.files[0].resources[1].priority, Some(1));
assert_eq!(document.files[1].name, "ignored.txt");
assert_eq!(document.files[1].size, Some(12));
assert_eq!(document.files[1].resources.len(), 1);
assert_eq!(document.files[1].resources[0].url, "");
assert_eq!(document.files[1].resources[0].priority, Some(1));
}
#[test]
fn parse_metalink3_fixture_matches_golden_document() {
let document = parse_metalink_document(
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="3.0" xmlns="http://www.metalinker.org/">
<files>
<file name="archive.iso">
<size>2048</size>
<version>1.0</version>
<language>en</language>
<os>linux</os>
<verification>
<hash type="sha-1">bbbbbbbb</hash>
</verification>
<resources>
<url location="de" priority="2" maxconnections="4" type="ftp">ftp://mirror.de/archive.iso</url>
<url location="us" priority="1" type="http">http://mirror.us/archive.iso</url>
</resources>
<description>Archive package</description>
</file>
</files>
</metalink>"#,
)
.expect("metalink should parse");
assert_eq!(document.version.as_deref(), Some("3.0"));
assert_eq!(document.files.len(), 1);
assert_eq!(document.files[0].name, "archive.iso");
assert_eq!(document.files[0].size, Some(2048));
assert_eq!(
document.files[0].description.as_deref(),
Some("Archive package")
);
assert_eq!(document.files[0].checksums[0].algorithm, "sha-1");
assert_eq!(document.files[0].checksums[0].value, "bbbbbbbb");
assert_eq!(
document.files[0].resources[0].url,
"ftp://mirror.de/archive.iso"
);
assert_eq!(
document.files[0].resources[0].location.as_deref(),
Some("de")
);
assert_eq!(document.files[0].resources[0].priority, Some(2));
assert_eq!(document.files[0].resources[0].max_connections, Some(4));
assert_eq!(
document.files[0].resources[0].type_hint.as_deref(),
Some("ftp")
);
assert_eq!(
document.files[0].resources[1].url,
"http://mirror.us/archive.iso"
);
assert_eq!(
document.files[0].resources[1].location.as_deref(),
Some("us")
);
assert_eq!(document.files[0].resources[1].priority, Some(1));
assert_eq!(
document.files[0].resources[1].type_hint.as_deref(),
Some("http")
);
}
#[test]
fn parse_metalink_normalizes_file_metadata_and_ignores_piece_hashes() {
let document = parse_metalink_document(
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0" xmlns="urn:ietf:params:xml:ns:metalink">
<identity>Document Identity</identity>
<file name=" normalized.iso ">
<identity> release-2026 </identity>
<hash type="SHA256"> AA BB CC DD </hash>
<pieces type="sha-1" length="262144">
<hash piece="0">piece-hash-should-be-ignored</hash>
</pieces>
<signature><![CDATA[SIG-A]]></signature>
<url location=" us " lang=" EN " maxconnections="8" private="TRUE"><![CDATA[https://mirror.example.com/normalized.iso]]></url>
<url location="ca" maxconnections="2" type="FTP">ftp://mirror.example.com/normalized.iso</url>
</file>
</metalink>"#,
)
.expect("normalized metalink fixture should parse");
assert_eq!(document.identity.as_deref(), Some("Document Identity"));
assert_eq!(document.files.len(), 1);
assert_eq!(document.files[0].name, "normalized.iso");
assert_eq!(
document.files[0].identifier.as_deref(),
Some("release-2026")
);
assert_eq!(document.files[0].signatures, vec!["SIG-A".to_owned()]);
assert_eq!(document.files[0].checksums.len(), 1);
assert_eq!(document.files[0].checksums[0].algorithm, "sha-256");
assert_eq!(document.files[0].checksums[0].value, "aabbccdd");
assert_eq!(
document.files[0].resources[0].url,
"https://mirror.example.com/normalized.iso"
);
assert_eq!(
document.files[0].resources[0].location.as_deref(),
Some("us")
);
assert_eq!(
document.files[0].resources[0].language.as_deref(),
Some("en")
);
assert_eq!(document.files[0].resources[0].max_connections, Some(8));
assert!(document.files[0].resources[0].private);
assert_eq!(
document.files[0].resources[0].type_hint.as_deref(),
Some("https")
);
assert_eq!(
document.files[0].resources[1].type_hint.as_deref(),
Some("ftp")
);
}
#[test]
fn parser_model_records_error_for_invalid_document() {
let parser = MetalinkParserModel::new(true, false);
let result = parser.parse("<metalink></metalink>");
assert!(result.document.is_none());
assert!(result.parser.last_error.is_some());
}
#[test]
fn preferred_resource_for_file_prefers_lower_priority_then_metadata_tiebreakers() {
let file = MetalinkFileModel {
name: "ubuntu.iso".to_owned(),
size: None,
checksums: Vec::new(),
resources: vec![
MetalinkResourceModel {
url: "https://mirror.jp/ubuntu.iso".to_owned(),
location: Some("jp".to_owned()),
priority: Some(2),
max_connections: None,
private: false,
type_hint: Some("https".to_owned()),
language: None,
},
MetalinkResourceModel {
url: "https://mirror.us/ubuntu.iso".to_owned(),
location: Some("us".to_owned()),
priority: Some(1),
max_connections: None,
private: false,
type_hint: Some("https".to_owned()),
language: None,
},
MetalinkResourceModel {
url: "https://mirror.eu/ubuntu.iso".to_owned(),
location: None,
priority: Some(1),
max_connections: None,
private: false,
type_hint: None,
language: None,
},
],
signatures: Vec::new(),
identifier: None,
description: None,
};
let selected = preferred_resource_for_file(&file).expect("should pick best resource");
assert_eq!(selected.url, "https://mirror.us/ubuntu.iso");
}
#[test]
fn preferred_resource_for_file_prefers_higher_max_connections_when_priority_ties() {
let file = MetalinkFileModel {
name: "parallel.iso".to_owned(),
size: None,
checksums: Vec::new(),
resources: vec![
MetalinkResourceModel {
url: "https://mirror-a.example.com/parallel.iso".to_owned(),
location: Some("US".to_owned()),
priority: Some(1),
max_connections: Some(2),
private: false,
type_hint: Some("https".to_owned()),
language: Some("en".to_owned()),
},
MetalinkResourceModel {
url: "https://mirror-b.example.com/parallel.iso".to_owned(),
location: Some("US".to_owned()),
priority: Some(1),
max_connections: Some(8),
private: false,
type_hint: Some("https".to_owned()),
language: Some("en".to_owned()),
},
],
signatures: Vec::new(),
identifier: None,
description: None,
};
let selected =
preferred_resource_for_file(&file).expect("should prefer higher max-connections");
assert_eq!(selected.url, "https://mirror-b.example.com/parallel.iso");
}
#[test]
fn preferred_resource_for_file_ignores_empty_urls() {
let file = MetalinkFileModel {
name: "example.iso".to_owned(),
size: None,
checksums: Vec::new(),
resources: vec![
MetalinkResourceModel {
url: " ".to_owned(),
location: Some("us".to_owned()),
priority: Some(1),
max_connections: None,
private: false,
type_hint: Some("https".to_owned()),
language: None,
},
MetalinkResourceModel {
url: "https://cdn.example.com/example.iso".to_owned(),
location: None,
priority: Some(2),
max_connections: None,
private: false,
type_hint: None,
language: None,
},
],
signatures: Vec::new(),
identifier: None,
description: None,
};
let selected = preferred_resource_for_file(&file).expect("should skip empty url");
assert_eq!(selected.url, "https://cdn.example.com/example.iso");
}
#[test]
fn preferred_download_candidate_returns_first_actionable_file_candidate() {
let document = MetalinkDocumentModel {
version: Some("4.0".to_owned()),
files: vec![
MetalinkFileModel {
name: "ignored.bin".to_owned(),
size: None,
checksums: Vec::new(),
resources: vec![MetalinkResourceModel {
url: String::new(),
location: Some("jp".to_owned()),
priority: Some(1),
max_connections: None,
private: false,
type_hint: Some("https".to_owned()),
language: None,
}],
signatures: Vec::new(),
identifier: None,
description: None,
},
MetalinkFileModel {
name: "picked.bin".to_owned(),
size: None,
checksums: Vec::new(),
resources: vec![
MetalinkResourceModel {
url: "https://mirror-b.example.com/picked.bin".to_owned(),
location: None,
priority: Some(1),
max_connections: None,
private: false,
type_hint: Some("https".to_owned()),
language: None,
},
MetalinkResourceModel {
url: "https://mirror-a.example.com/picked.bin".to_owned(),
location: Some("us".to_owned()),
priority: Some(1),
max_connections: None,
private: false,
type_hint: None,
language: None,
},
],
signatures: Vec::new(),
identifier: None,
description: None,
},
],
identity: None,
publisher: None,
generator: None,
published_at: None,
};
let (file, resource) =
preferred_download_candidate(&document).expect("should find actionable candidate");
assert_eq!(file.name, "picked.bin");
assert_eq!(resource.url, "https://mirror-a.example.com/picked.bin");
}
#[test]
fn preferred_download_candidate_prefers_first_file_with_actionable_resource_from_fixture() {
let document = parse_metalink_document(
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0" xmlns="urn:ietf:params:xml:ns:metalink">
<file name="ignored.bin">
<url priority="1"> </url>
</file>
<file name="picked.bin">
<url priority="2">https://mirror.example.com/picked.bin</url>
<url priority="1" location="us">https://mirror.us.example.com/picked.bin</url>
</file>
</metalink>"#,
)
.expect("fixture metalink should parse");
let (file, resource) =
preferred_download_candidate(&document).expect("should find actionable candidate");
assert_eq!(file.name, "picked.bin");
assert_eq!(resource.url, "https://mirror.us.example.com/picked.bin");
}
#[test]
fn metalink_download_plan_expands_actionable_files_and_preserves_checksum_defaults() {
let document = parse_metalink_document(
r#"<?xml version="1.0" encoding="utf-8"?>
<metalink version="4.0">
<file name="alpha.bin">
<hash type="md5">900150983cd24fb0d6963f7d28e17f72</hash>
<url priority="9">http://fallback.example.org/alpha.bin</url>
<url priority="1">http://mirror.example.org/alpha.bin</url>
</file>
<file name="ignored.bin">
<url priority="1"></url>
</file>
<file name="beta.bin">
<hash type="crc32">3610a686</hash>
<url priority="1">https://example.org/beta.bin</url>
<url priority="2">https://backup.example.org/beta.bin</url>
</file>
</metalink>"#,
)
.expect("fixture should parse");
let plan = metalink_download_plan(&document);
assert_eq!(plan.len(), 2);
assert_eq!(plan[0].file_name, "alpha.bin");
assert_eq!(
plan[0].uris,
vec![
"http://mirror.example.org/alpha.bin".to_owned(),
"http://fallback.example.org/alpha.bin".to_owned(),
]
);
assert_eq!(
plan[0]
.checksum
.as_ref()
.map(|checksum| checksum.algorithm.as_str()),
Some("md5")
);
assert_eq!(plan[1].file_name, "beta.bin");
assert_eq!(
plan[1]
.checksum
.as_ref()
.map(|checksum| checksum.algorithm.as_str()),
Some("crc32")
);
}
@@ -0,0 +1,120 @@
//! Session models shared by protocol-aware download workflows.
#![forbid(unsafe_code)]
use crate::{
auth::AuthCredentialModel,
http::{Cookie, HttpHeader, ProxyConfig, RetryStrategy, TlsConfig},
};
/// Coarse lifecycle state for a transport session.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SessionState {
/// Session exists but has not started work.
Idle,
/// Session is establishing a connection.
Connecting,
/// Session is actively transferring data.
Active,
/// Session is paused.
Paused,
/// Session completed successfully.
Completed,
/// Session ended with a failure.
Failed,
}
/// Scope at which a session model applies.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SessionScope {
/// Global client-wide scope.
Global,
/// Protocol-family scope.
Protocol,
/// Single transfer scope.
Transfer,
}
/// Operational limits attached to a session.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SessionLimits {
/// Maximum simultaneous connections.
pub max_connections: u16,
/// Maximum parallel downloads across the session.
pub max_parallel_downloads: u16,
/// Delay before reconnecting after a failure, in milliseconds.
pub reconnect_delay_ms: u64,
}
/// Preferred protocol ordering within a session.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SessionTransportPreference {
/// Protocol identifier string.
pub protocol: String,
/// Smaller numbers indicate higher preference.
pub priority: u8,
/// Whether this protocol is enabled.
pub enabled: bool,
}
/// End-user session configuration and live state.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SessionModel {
/// Stable session identifier.
pub session_id: String,
/// Current lifecycle state.
pub state: SessionState,
/// Scope for this session.
pub scope: SessionScope,
/// Optional user-agent string.
pub user_agent: Option<String>,
/// Default headers applied to requests.
pub headers: Vec<HttpHeader>,
/// Persisted or injected cookies.
pub cookies: Vec<Cookie>,
/// Optional authentication material.
pub auth: Option<AuthCredentialModel>,
/// Optional proxy configuration.
pub proxy: Option<ProxyConfig>,
/// Optional TLS configuration.
pub tls: Option<TlsConfig>,
/// Retry behavior for requests in the session.
pub retry: RetryStrategy,
/// Operational session limits.
pub limits: SessionLimits,
/// Ordered transport preferences.
pub preferred_transports: Vec<SessionTransportPreference>,
}
/// Remote server descriptor associated with a session.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ServerModel {
/// Human-readable server name.
pub name: String,
/// Host name or IP address.
pub host: String,
/// Listen port.
pub port: u16,
/// Whether TLS is enabled.
pub tls_enabled: bool,
}
/// Client descriptor that can be associated with server sessions.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClientModel {
/// Stable client identifier.
pub client_id: String,
/// Preferred session id when one exists.
pub preferred_session: Option<String>,
/// Optional server currently associated with the client.
pub server: Option<ServerModel>,
}
/// Collection of sessions known for a given server.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ServerSessionModel {
/// Server whose sessions are being reported.
pub server: ServerModel,
/// Sessions currently associated with the server.
pub sessions: Vec<SessionModel>,
}
+107
View File
@@ -0,0 +1,107 @@
//! SFTP request, response, and session models.
#![forbid(unsafe_code)]
use crate::{
auth::AuthCredentialModel,
http::{HttpHeader, ProxyConfig, RetryStrategy, TlsConfig},
};
/// Connection, authentication, and retry settings for an SFTP endpoint.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SftpConfigModel {
/// Remote host name or IP.
pub host: String,
/// Remote SSH port.
pub port: u16,
/// Optional username for login.
pub username: Option<String>,
/// Optional password for login.
pub password: Option<String>,
/// Optional path to a private key file.
pub private_key_path: Option<String>,
/// Optional known-hosts file path.
pub known_hosts_path: Option<String>,
/// Whether host-key validation is strict.
pub strict_host_key_checking: bool,
/// Optional proxy configuration.
pub proxy: Option<ProxyConfig>,
/// Optional TLS tuning data when the transport stack uses it.
pub tls: Option<TlsConfig>,
/// Retry strategy for failed requests.
pub retry: RetryStrategy,
}
/// SFTP command issued within a request.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SftpCommandModel {
/// Establish a connection.
Connect,
/// Read metadata for a path.
Stat(String),
/// Read symlink-aware metadata for a path.
Lstat(String),
/// Read a directory listing.
ReadDir(String),
/// Open a remote path.
Open(String),
/// Read a byte range from a remote path.
Read {
/// Target path.
path: String,
/// Starting byte offset.
offset: u64,
/// Maximum number of bytes to read.
length: u64,
},
/// Close an open handle identified by path or token.
Close(String),
/// Rename a path.
Rename {
/// Source path.
from: String,
/// Destination path.
to: String,
},
/// Remove a path.
Remove(String),
}
/// SFTP session state captured by the protocol layer.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SftpSessionModel {
/// Stable session identifier.
pub session_id: String,
/// Resolved endpoint configuration.
pub config: SftpConfigModel,
/// Optional authenticated credential.
pub auth: Option<AuthCredentialModel>,
/// Default headers propagated into requests.
pub default_headers: Vec<HttpHeader>,
}
/// SFTP request envelope passed into a connector.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SftpRequestModel {
/// Command to execute.
pub command: SftpCommandModel,
/// Optional primary path associated with the command.
pub path: Option<String>,
/// Additional logical headers attached to the request.
pub headers: Vec<HttpHeader>,
}
/// SFTP response material returned by a connector.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SftpResponseModel {
/// Whether the command succeeded.
pub ok: bool,
/// Human-readable status or error message.
pub message: String,
/// Optional payload bytes such as file contents.
pub payload: Option<Vec<u8>>,
/// Optional path associated with the response.
pub path: Option<String>,
/// Whether the response can carry transferable data.
pub transferable: bool,
}
@@ -0,0 +1,39 @@
//! Torrent metadata, peer-wire framing, and DHT message helpers.
#![forbid(unsafe_code)]
/// Internal torrent bencode value parsing and encoding helpers.
mod bencode;
/// Distributed-hash-table message models and compact-node helpers.
mod dht;
/// Torrent metadata/bootstrap parsing and derivation helpers.
mod metadata;
/// Parsed torrent models and runtime-adjacent helper methods.
mod model;
/// `BitTorrent` peer-wire framing and metadata-exchange helpers.
mod peer_wire;
/// Shared torrent-local decoding and conversion helpers.
mod utils;
#[cfg(test)]
use self::dht::compact::{decode_compact_dht_nodes, encode_compact_dht_nodes};
pub use self::dht::{
DhtAnnouncePeerQueryModel, DhtCompactNodeModel, DhtErrorModel, DhtFindNodeQueryModel,
DhtFindNodeResponseModel, DhtGetPeersQueryModel, DhtGetPeersResponseModel, DhtMessageBody,
DhtMessageModel, DhtPingQueryModel, DhtPingResponseModel, DhtQueryModel, DhtResponseModel,
};
pub use self::metadata::{parse_torrent_bootstrap, parse_torrent_metadata};
pub use self::model::{
TorrentBootstrapModel, TorrentFileEntryModel, TorrentHashModel, TorrentInfoModel,
TorrentMetadataModel, TorrentPeerModel, TorrentPieceModel, TorrentTrackerModel,
};
pub use self::peer_wire::{
PEER_WIRE_METADATA_PIECE_SIZE, PeerWireBitfieldModel, PeerWireBlockRequestModel,
PeerWireExtensionHandshakeModel, PeerWireExtensionMessageModel, PeerWireFrameHeaderModel,
PeerWireHandshakeModel, PeerWireMessageKind, PeerWireMessageModel,
PeerWireMetadataMessageModel, PeerWireMetadataMessageType, PeerWirePieceBlockModel,
PeerWireUnknownMessageModel, TorrentMessageModel,
};
#[cfg(test)]
mod tests;
@@ -0,0 +1,214 @@
use std::collections::BTreeMap;
/// Internal torrent bencode dictionary keyed by normalized string keys.
pub(super) type TorrentBencodeDict = BTreeMap<String, BencodeValue>;
/// Internal bencode value representation used while parsing torrent metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) enum BencodeValue {
/// Signed integer literal.
Int(i64),
/// Raw byte string payload.
Bytes(Vec<u8>),
/// Ordered list of nested bencode values.
List(Vec<Self>),
/// Dictionary keyed by normalized torrent strings.
Dict(TorrentBencodeDict),
}
/// Encodes a torrent-style bencode value into raw bytes.
pub(super) fn encode_bencode_value(value: &BencodeValue, out: &mut Vec<u8>) {
match value {
BencodeValue::Int(number) => {
out.push(b'i');
out.extend_from_slice(number.to_string().as_bytes());
out.push(b'e');
}
BencodeValue::Bytes(bytes) => {
out.extend_from_slice(bytes.len().to_string().as_bytes());
out.push(b':');
out.extend_from_slice(bytes);
}
BencodeValue::List(values) => {
out.push(b'l');
for value in values {
encode_bencode_value(value, out);
}
out.push(b'e');
}
BencodeValue::Dict(dict) => encode_bencode_dict(dict, out),
}
}
/// Encodes a torrent-style bencode dictionary into raw bytes.
pub(super) fn encode_bencode_dict(dict: &TorrentBencodeDict, out: &mut Vec<u8>) {
out.push(b'd');
for (key, value) in dict {
out.extend_from_slice(key.len().to_string().as_bytes());
out.push(b':');
out.extend_from_slice(key.as_bytes());
encode_bencode_value(value, out);
}
out.push(b'e');
}
/// Encodes a torrent-style bencode dictionary as a root value.
pub(super) fn encode_bencode_root(dict: &TorrentBencodeDict) -> Vec<u8> {
let mut out = Vec::new();
encode_bencode_dict(dict, &mut out);
out
}
/// Parses the torrent root dictionary and captures the raw `info` dictionary bytes.
pub(super) fn parse_root_dict(input: &[u8]) -> Result<(TorrentBencodeDict, Option<&[u8]>), String> {
if input.first().copied() != Some(b'd') {
return Err("torrent root must be dictionary".to_owned());
}
let mut cursor = 1;
let mut map = BTreeMap::new();
let mut info_raw = None;
while cursor < input.len() {
if input[cursor] == b'e' {
cursor += 1;
if cursor != input.len() {
return Err("trailing bytes after root dictionary".to_owned());
}
return Ok((map, info_raw));
}
let (key_bytes, next) = parse_bytes(input, cursor)?;
cursor = next;
let key = String::from_utf8(key_bytes).map_err(|_| "invalid dictionary key".to_owned())?;
let value_start = cursor;
let (value, end) = parse_value(input, cursor)?;
if key == "info" && matches!(value, BencodeValue::Dict(_)) {
info_raw = input.get(value_start..end);
}
cursor = end;
map.insert(key, value);
}
Err("unterminated dictionary".to_owned())
}
/// Parses one root bencode dictionary and allows trailing bytes after the dictionary.
pub(super) fn parse_bencode_root_prefix(
input: &[u8],
) -> Result<(TorrentBencodeDict, usize), String> {
let (value, consumed) = parse_value(input, 0)?;
match value {
BencodeValue::Dict(dict) => Ok((dict, consumed)),
_ => Err("bencode root must be a dictionary".to_owned()),
}
}
/// Parses one complete root bencode dictionary.
pub(super) fn parse_bencode_root_exact(input: &[u8]) -> Result<TorrentBencodeDict, String> {
let (dict, consumed) = parse_bencode_root_prefix(input)?;
if consumed != input.len() {
return Err("trailing bytes after bencode dictionary".to_owned());
}
Ok(dict)
}
/// Parses one torrent bencode value and returns the decoded value plus next index.
fn parse_value(input: &[u8], index: usize) -> Result<(BencodeValue, usize), String> {
match input.get(index).copied() {
Some(b'i') => parse_int(input, index),
Some(b'l') => parse_list(input, index),
Some(b'd') => parse_dict(input, index).map(|(map, end)| (BencodeValue::Dict(map), end)),
Some(b'0'..=b'9') => {
parse_bytes(input, index).map(|(bytes, end)| (BencodeValue::Bytes(bytes), end))
}
_ => Err("invalid bencode value".to_owned()),
}
}
/// Parses one torrent bencode integer starting at `index`.
fn parse_int(input: &[u8], index: usize) -> Result<(BencodeValue, usize), String> {
let mut cursor = index + 1;
while cursor < input.len() && input[cursor] != b'e' {
cursor += 1;
}
if cursor >= input.len() {
return Err("unterminated integer".to_owned());
}
let number = std::str::from_utf8(&input[index + 1..cursor])
.map_err(|_| "invalid integer".to_owned())?
.parse::<i64>()
.map_err(|_| "invalid integer".to_owned())?;
Ok((BencodeValue::Int(number), cursor + 1))
}
/// Parses one torrent bencode list starting at `index`.
fn parse_list(input: &[u8], index: usize) -> Result<(BencodeValue, usize), String> {
let mut cursor = index + 1;
let mut values = Vec::new();
while cursor < input.len() {
if input[cursor] == b'e' {
return Ok((BencodeValue::List(values), cursor + 1));
}
let (value, end) = parse_value(input, cursor)?;
values.push(value);
cursor = end;
}
Err("unterminated list".to_owned())
}
/// Parses one torrent bencode dictionary starting at `index`.
fn parse_dict(input: &[u8], index: usize) -> Result<(TorrentBencodeDict, usize), String> {
let mut cursor = index + 1;
let mut map = BTreeMap::new();
while cursor < input.len() {
if input[cursor] == b'e' {
return Ok((map, cursor + 1));
}
let (key_bytes, next) = parse_bytes(input, cursor)?;
cursor = next;
let key = String::from_utf8(key_bytes).map_err(|_| "invalid dictionary key".to_owned())?;
let (value, end) = parse_value(input, cursor)?;
cursor = end;
map.insert(key, value);
}
Err("unterminated dictionary".to_owned())
}
/// Parses one torrent bencode byte string starting at `index`.
fn parse_bytes(input: &[u8], index: usize) -> Result<(Vec<u8>, usize), String> {
let mut cursor = index;
while cursor < input.len() && input[cursor].is_ascii_digit() {
cursor += 1;
}
if cursor == index || cursor >= input.len() || input[cursor] != b':' {
return Err("invalid bencode byte string".to_owned());
}
let len = std::str::from_utf8(&input[index..cursor])
.map_err(|_| "invalid byte string length".to_owned())?
.parse::<usize>()
.map_err(|_| "invalid byte string length".to_owned())?;
let start = cursor + 1;
let end = start.saturating_add(len);
if end > input.len() {
return Err("truncated byte string".to_owned());
}
Ok((input[start..end].to_vec(), end))
}
/// Looks up a byte-string field inside a torrent bencode dictionary.
pub(super) fn dict_bytes<'a>(dict: &'a TorrentBencodeDict, key: &str) -> Option<&'a [u8]> {
match dict.get(key) {
Some(BencodeValue::Bytes(bytes)) => Some(bytes.as_slice()),
_ => None,
}
}
/// Looks up an integer field inside a torrent bencode dictionary.
pub(super) fn dict_int(dict: &TorrentBencodeDict, key: &str) -> Option<i64> {
match dict.get(key) {
Some(BencodeValue::Int(value)) => Some(*value),
_ => None,
}
}
@@ -0,0 +1,157 @@
use std::collections::BTreeMap;
use crate::tracker::DhtNodeModel;
use super::utils::hex_encode;
/// DHT bencode codec helpers.
mod codec;
/// Compact-node and compact-peer conversion helpers.
pub(super) mod compact;
/// DHT message builders plus wire-format parsing helpers.
mod message;
/// One DHT message with a transaction id and typed body.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtMessageModel {
/// Opaque DHT transaction id.
pub transaction_id: Vec<u8>,
/// Typed message body.
pub body: DhtMessageBody,
}
/// Supported DHT message body variants.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DhtMessageBody {
/// Outbound or inbound query.
Query(DhtQueryModel),
/// Successful response payload.
Response(DhtResponseModel),
/// Error response payload.
Error(DhtErrorModel),
}
/// Supported DHT query variants.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DhtQueryModel {
/// `ping` query.
Ping(DhtPingQueryModel),
/// `find_node` query.
FindNode(DhtFindNodeQueryModel),
/// `get_peers` query.
GetPeers(DhtGetPeersQueryModel),
/// `announce_peer` query.
AnnouncePeer(DhtAnnouncePeerQueryModel),
}
/// DHT `ping` query payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtPingQueryModel {
/// Querying node id.
pub node_id: Vec<u8>,
}
/// DHT `find_node` query payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtFindNodeQueryModel {
/// Querying node id.
pub node_id: Vec<u8>,
/// Target node id being searched.
pub target: Vec<u8>,
}
/// DHT `get_peers` query payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtGetPeersQueryModel {
/// Querying node id.
pub node_id: Vec<u8>,
/// Torrent info-hash being searched.
pub info_hash: Vec<u8>,
}
/// DHT `announce_peer` query payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtAnnouncePeerQueryModel {
/// Querying node id.
pub node_id: Vec<u8>,
/// Torrent info-hash being announced.
pub info_hash: Vec<u8>,
/// Advertised listening port.
pub port: u16,
/// Tracker-issued or routing token.
pub token: Vec<u8>,
/// Whether the sender requested implied-port semantics.
pub implied_port: bool,
}
/// Supported DHT response variants.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DhtResponseModel {
/// `ping` or `announce_peer` response payload.
Ping(DhtPingResponseModel),
/// `find_node` response payload.
FindNode(DhtFindNodeResponseModel),
/// `get_peers` response payload.
GetPeers(DhtGetPeersResponseModel),
}
/// DHT `ping` response payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtPingResponseModel {
/// Responding node id.
pub node_id: Vec<u8>,
}
/// One compact DHT node entry.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DhtCompactNodeModel {
/// Remote node id.
pub node_id: [u8; 20],
/// IPv4 address bytes.
pub address: [u8; 4],
/// UDP port in host byte order.
pub port: u16,
}
/// DHT `find_node` response payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtFindNodeResponseModel {
/// Responding node id.
pub node_id: Vec<u8>,
/// Returned compact nodes.
pub nodes: Vec<DhtCompactNodeModel>,
}
/// DHT `get_peers` response payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtGetPeersResponseModel {
/// Responding node id.
pub node_id: Vec<u8>,
/// Optional token to reuse in `announce_peer`.
pub token: Option<Vec<u8>>,
/// Optional compact-node blob.
pub nodes: Option<Vec<u8>>,
/// Optional compact-peer values.
pub values: Vec<Vec<u8>>,
}
/// DHT error payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtErrorModel {
/// Numeric error code.
pub code: i64,
/// Human-readable error message.
pub message: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Internal bencode value representation used while parsing DHT payloads.
enum DhtBencodeValue {
/// Signed integer literal.
Int(i64),
/// Raw byte string payload.
Bytes(Vec<u8>),
/// Ordered list of nested bencode values.
List(Vec<Self>),
/// Dictionary keyed by raw byte strings.
Dict(BTreeMap<Vec<u8>, Self>),
}
@@ -0,0 +1,177 @@
use std::collections::BTreeMap;
use super::DhtBencodeValue;
/// Encodes one DHT bencode value into a byte buffer.
fn dht_encode_value(value: &DhtBencodeValue, out: &mut Vec<u8>) {
match value {
DhtBencodeValue::Int(number) => {
out.push(b'i');
out.extend_from_slice(number.to_string().as_bytes());
out.push(b'e');
}
DhtBencodeValue::Bytes(bytes) => {
out.extend_from_slice(bytes.len().to_string().as_bytes());
out.push(b':');
out.extend_from_slice(bytes);
}
DhtBencodeValue::List(values) => {
out.push(b'l');
for item in values {
dht_encode_value(item, out);
}
out.push(b'e');
}
DhtBencodeValue::Dict(values) => {
out.push(b'd');
for (key, value) in values {
out.extend_from_slice(key.len().to_string().as_bytes());
out.push(b':');
out.extend_from_slice(key);
dht_encode_value(value, out);
}
out.push(b'e');
}
}
}
/// Encodes a DHT dictionary into canonical bencode bytes.
pub(super) fn dht_encode_dict(dict: &BTreeMap<Vec<u8>, DhtBencodeValue>) -> Vec<u8> {
let mut out = Vec::new();
dht_encode_value(&DhtBencodeValue::Dict(dict.clone()), &mut out);
out
}
/// Parses one DHT bencode value and returns the decoded value plus next index.
pub(super) fn dht_parse_value(
input: &[u8],
index: usize,
) -> Result<(DhtBencodeValue, usize), String> {
match input.get(index).copied() {
Some(b'i') => dht_parse_int(input, index + 1),
Some(b'l') => dht_parse_list(input, index + 1),
Some(b'd') => dht_parse_dict(input, index + 1),
Some(byte) if byte.is_ascii_digit() => dht_parse_bytes(input, index),
Some(_) => Err("invalid dht bencode value".to_owned()),
None => Err("unexpected end of dht bencode input".to_owned()),
}
}
/// Parses one DHT bencode integer starting at `index`.
fn dht_parse_int(input: &[u8], index: usize) -> Result<(DhtBencodeValue, usize), String> {
let mut cursor = index;
while cursor < input.len() && input[cursor] != b'e' {
cursor += 1;
}
if cursor >= input.len() {
return Err("unterminated dht integer".to_owned());
}
let text = std::str::from_utf8(&input[index..cursor])
.map_err(|_| "invalid dht integer bytes".to_owned())?;
let value = text
.parse::<i64>()
.map_err(|_| "invalid dht integer value".to_owned())?;
Ok((DhtBencodeValue::Int(value), cursor + 1))
}
/// Parses one DHT bencode list starting at `index`.
fn dht_parse_list(input: &[u8], index: usize) -> Result<(DhtBencodeValue, usize), String> {
let mut values = Vec::new();
let mut cursor = index;
while cursor < input.len() {
if input[cursor] == b'e' {
return Ok((DhtBencodeValue::List(values), cursor + 1));
}
let (value, next) = dht_parse_value(input, cursor)?;
values.push(value);
cursor = next;
}
Err("unterminated dht list".to_owned())
}
/// Parses one DHT bencode dictionary starting at `index`.
fn dht_parse_dict(input: &[u8], index: usize) -> Result<(DhtBencodeValue, usize), String> {
let mut map = BTreeMap::new();
let mut cursor = index;
while cursor < input.len() {
if input[cursor] == b'e' {
return Ok((DhtBencodeValue::Dict(map), cursor + 1));
}
let (key, key_end) = dht_parse_bytes_raw(input, cursor)?;
let (value, value_end) = dht_parse_value(input, key_end)?;
map.insert(key, value);
cursor = value_end;
}
Err("unterminated dht dictionary".to_owned())
}
/// Parses one DHT bencode byte string starting at `index`.
fn dht_parse_bytes(input: &[u8], index: usize) -> Result<(DhtBencodeValue, usize), String> {
let (bytes, next) = dht_parse_bytes_raw(input, index)?;
Ok((DhtBencodeValue::Bytes(bytes), next))
}
/// Parses one raw DHT bencode byte string and returns its bytes plus next index.
fn dht_parse_bytes_raw(input: &[u8], index: usize) -> Result<(Vec<u8>, usize), String> {
let mut cursor = index;
while cursor < input.len() && input[cursor].is_ascii_digit() {
cursor += 1;
}
if cursor == index || cursor >= input.len() || input[cursor] != b':' {
return Err("invalid dht byte string".to_owned());
}
let length = std::str::from_utf8(&input[index..cursor])
.map_err(|_| "invalid dht byte string length".to_owned())?
.parse::<usize>()
.map_err(|_| "invalid dht byte string length".to_owned())?;
let start = cursor + 1;
let end = start.saturating_add(length);
if end > input.len() {
return Err("truncated dht byte string".to_owned());
}
Ok((input[start..end].to_vec(), end))
}
/// Looks up a raw byte-string field inside a DHT dictionary.
pub(super) fn dht_dict_get_bytes(
dict: &BTreeMap<Vec<u8>, DhtBencodeValue>,
key: &[u8],
) -> Option<Vec<u8>> {
match dict.get(key) {
Some(DhtBencodeValue::Bytes(bytes)) => Some(bytes.clone()),
_ => None,
}
}
/// Looks up an integer field inside a DHT dictionary.
pub(super) fn dht_dict_get_int(
dict: &BTreeMap<Vec<u8>, DhtBencodeValue>,
key: &[u8],
) -> Option<i64> {
match dict.get(key) {
Some(DhtBencodeValue::Int(value)) => Some(*value),
_ => None,
}
}
/// Looks up a nested dictionary field inside a DHT dictionary.
pub(super) fn dht_dict_get_dict<'a>(
dict: &'a BTreeMap<Vec<u8>, DhtBencodeValue>,
key: &[u8],
) -> Option<&'a BTreeMap<Vec<u8>, DhtBencodeValue>> {
match dict.get(key) {
Some(DhtBencodeValue::Dict(value)) => Some(value),
_ => None,
}
}
/// Looks up a list field inside a DHT dictionary.
pub(super) fn dht_dict_get_list(
dict: &BTreeMap<Vec<u8>, DhtBencodeValue>,
key: &[u8],
) -> Option<Vec<DhtBencodeValue>> {
match dict.get(key) {
Some(DhtBencodeValue::List(values)) => Some(values.clone()),
_ => None,
}
}
@@ -0,0 +1,114 @@
use super::hex_encode;
use super::{
DhtCompactNodeModel, DhtFindNodeResponseModel, DhtGetPeersResponseModel, DhtNodeModel,
};
use crate::torrent::TorrentPeerModel;
impl DhtCompactNodeModel {
/// Converts the compact node entry into a higher-level DHT node model.
#[must_use]
pub fn to_dht_node(self) -> DhtNodeModel {
DhtNodeModel {
node_id: hex_encode(&self.node_id),
address: format!(
"{}.{}.{}.{}",
self.address[0], self.address[1], self.address[2], self.address[3]
),
port: self.port,
}
}
}
impl DhtFindNodeResponseModel {
/// Shapes compact DHT node entries into higher-level node models.
#[must_use]
pub fn dht_nodes(&self) -> Vec<DhtNodeModel> {
self.nodes
.iter()
.copied()
.map(DhtCompactNodeModel::to_dht_node)
.collect()
}
}
impl DhtGetPeersResponseModel {
/// Decodes compact peer-contact payloads into higher-level peer rows.
///
/// # Errors
///
/// Returns an error when any compact peer payload is malformed.
pub fn peer_contacts(&self) -> Result<Vec<TorrentPeerModel>, String> {
parse_compact_peer_contacts(&self.values)
}
/// Decodes the optional compact-node blob into higher-level DHT node rows.
///
/// # Errors
///
/// Returns an error when the compact-node blob is malformed.
pub fn dht_nodes(&self) -> Result<Vec<DhtNodeModel>, String> {
let Some(nodes) = &self.nodes else {
return Ok(Vec::new());
};
decode_compact_dht_nodes(nodes).map(|nodes| {
nodes
.into_iter()
.map(DhtCompactNodeModel::to_dht_node)
.collect()
})
}
}
/// Encodes compact DHT nodes into the BEP 5 26-byte-per-node representation.
pub(in super::super) fn encode_compact_dht_nodes(nodes: &[DhtCompactNodeModel]) -> Vec<u8> {
let mut bytes = Vec::with_capacity(nodes.len() * 26);
for node in nodes {
bytes.extend_from_slice(&node.node_id);
bytes.extend_from_slice(&node.address);
bytes.extend_from_slice(&node.port.to_be_bytes());
}
bytes
}
/// Decodes compact DHT nodes from the BEP 5 26-byte-per-node representation.
pub(in super::super) fn decode_compact_dht_nodes(
input: &[u8],
) -> Result<Vec<DhtCompactNodeModel>, String> {
if !input.len().is_multiple_of(26) {
return Err("compact dht node list length must be a multiple of 26".to_owned());
}
let mut nodes = Vec::with_capacity(input.len() / 26);
for chunk in input.chunks_exact(26) {
let mut node_id = [0_u8; 20];
node_id.copy_from_slice(&chunk[..20]);
let mut address = [0_u8; 4];
address.copy_from_slice(&chunk[20..24]);
nodes.push(DhtCompactNodeModel {
node_id,
address,
port: u16::from_be_bytes([chunk[24], chunk[25]]),
});
}
Ok(nodes)
}
/// Decodes compact BEP 5 peer-contact payloads into higher-level peer models.
fn parse_compact_peer_contacts(values: &[Vec<u8>]) -> Result<Vec<TorrentPeerModel>, String> {
let mut peers = Vec::new();
for value in values {
if value.len() % 6 != 0 {
return Err("compact peer list length must be a multiple of 6".to_owned());
}
for chunk in value.chunks_exact(6) {
peers.push(TorrentPeerModel {
peer_id: None,
ip: format!("{}.{}.{}.{}", chunk[0], chunk[1], chunk[2], chunk[3]),
port: u16::from_be_bytes([chunk[4], chunk[5]]),
client_name: None,
interested: false,
choked: false,
});
}
}
Ok(peers)
}
@@ -0,0 +1,488 @@
use super::codec::{
dht_dict_get_bytes, dht_dict_get_dict, dht_dict_get_int, dht_dict_get_list, dht_encode_dict,
dht_parse_value,
};
use super::compact::{decode_compact_dht_nodes, encode_compact_dht_nodes};
use std::collections::BTreeMap;
use super::{
DhtAnnouncePeerQueryModel, DhtBencodeValue, DhtCompactNodeModel, DhtErrorModel,
DhtFindNodeQueryModel, DhtFindNodeResponseModel, DhtGetPeersQueryModel,
DhtGetPeersResponseModel, DhtMessageBody, DhtMessageModel, DhtPingQueryModel,
DhtPingResponseModel, DhtQueryModel, DhtResponseModel,
};
impl DhtMessageModel {
#[must_use]
/// Builds a DHT `ping` query.
pub fn ping_query(transaction_id: impl Into<Vec<u8>>, node_id: impl Into<Vec<u8>>) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Query(DhtQueryModel::Ping(DhtPingQueryModel {
node_id: node_id.into(),
})),
}
}
#[must_use]
/// Builds a DHT `find_node` query.
pub fn find_node_query(
transaction_id: impl Into<Vec<u8>>,
node_id: impl Into<Vec<u8>>,
target: impl Into<Vec<u8>>,
) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Query(DhtQueryModel::FindNode(DhtFindNodeQueryModel {
node_id: node_id.into(),
target: target.into(),
})),
}
}
#[must_use]
/// Builds a DHT `get_peers` query.
pub fn get_peers_query(
transaction_id: impl Into<Vec<u8>>,
node_id: impl Into<Vec<u8>>,
info_hash: impl Into<Vec<u8>>,
) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Query(DhtQueryModel::GetPeers(DhtGetPeersQueryModel {
node_id: node_id.into(),
info_hash: info_hash.into(),
})),
}
}
#[must_use]
/// Builds a DHT `announce_peer` query.
pub fn announce_peer_query(
transaction_id: impl Into<Vec<u8>>,
node_id: impl Into<Vec<u8>>,
info_hash: impl Into<Vec<u8>>,
port: u16,
token: impl Into<Vec<u8>>,
implied_port: bool,
) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(DhtAnnouncePeerQueryModel {
node_id: node_id.into(),
info_hash: info_hash.into(),
port,
token: token.into(),
implied_port,
})),
}
}
#[must_use]
/// Builds a DHT `ping` response.
pub fn ping_response(transaction_id: impl Into<Vec<u8>>, node_id: impl Into<Vec<u8>>) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Response(DhtResponseModel::Ping(DhtPingResponseModel {
node_id: node_id.into(),
})),
}
}
#[must_use]
/// Builds a DHT `find_node` response.
pub fn find_node_response(
transaction_id: impl Into<Vec<u8>>,
node_id: impl Into<Vec<u8>>,
nodes: Vec<DhtCompactNodeModel>,
) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Response(DhtResponseModel::FindNode(DhtFindNodeResponseModel {
node_id: node_id.into(),
nodes,
})),
}
}
#[must_use]
/// Builds a DHT `get_peers` response.
pub fn get_peers_response(
transaction_id: impl Into<Vec<u8>>,
node_id: impl Into<Vec<u8>>,
token: Option<Vec<u8>>,
nodes: Option<Vec<u8>>,
values: Vec<Vec<u8>>,
) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Response(DhtResponseModel::GetPeers(DhtGetPeersResponseModel {
node_id: node_id.into(),
token,
nodes,
values,
})),
}
}
#[must_use]
/// Builds a DHT `announce_peer` response.
pub fn announce_peer_response(
transaction_id: impl Into<Vec<u8>>,
node_id: impl Into<Vec<u8>>,
) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Response(DhtResponseModel::Ping(DhtPingResponseModel {
node_id: node_id.into(),
})),
}
}
#[must_use]
/// Builds a DHT error response.
pub fn error_response(
transaction_id: impl Into<Vec<u8>>,
code: i64,
message: impl Into<String>,
) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Error(DhtErrorModel {
code,
message: message.into(),
}),
}
}
#[must_use]
/// Returns the raw DHT transaction id bytes.
pub fn transaction_id(&self) -> &[u8] {
&self.transaction_id
}
#[must_use]
/// Returns the DHT query method name when the message body is a query.
pub fn method(&self) -> Option<&'static str> {
match &self.body {
DhtMessageBody::Query(DhtQueryModel::Ping(_)) => Some("ping"),
DhtMessageBody::Query(DhtQueryModel::FindNode(_)) => Some("find_node"),
DhtMessageBody::Query(DhtQueryModel::GetPeers(_)) => Some("get_peers"),
DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(_)) => Some("announce_peer"),
_ => None,
}
}
#[must_use]
/// Returns whether this message body is a DHT query.
pub fn is_query(&self) -> bool {
matches!(self.body, DhtMessageBody::Query(_))
}
#[must_use]
/// Serializes the DHT message as a bencoded root dictionary.
pub fn to_bencode_bytes(&self) -> Vec<u8> {
dht_encode_dict(&self.as_bencode_root())
}
/// Parses a DHT message from a bencoded payload.
///
/// # Errors
///
/// Returns an error when the payload is not a supported DHT message dictionary.
pub fn from_bencode_bytes(input: &[u8]) -> Result<Self, String> {
let (value, next) = dht_parse_value(input, 0)?;
if next != input.len() {
return Err("trailing bytes after dht message".to_owned());
}
let DhtBencodeValue::Dict(root) = value else {
return Err("dht message must be a bencoded dictionary".to_owned());
};
let transaction_id = dht_dict_get_bytes(&root, b"t")
.ok_or_else(|| "missing dht transaction id".to_owned())?;
let message_type =
dht_dict_get_bytes(&root, b"y").ok_or_else(|| "missing dht message type".to_owned())?;
match message_type.as_slice() {
b"q" => parse_dht_query_message(transaction_id, &root),
b"r" => parse_dht_response_message(transaction_id, &root),
b"e" => parse_dht_error_message(transaction_id, &root),
_ => Err("unsupported dht message type".to_owned()),
}
}
#[expect(
clippy::too_many_lines,
reason = "torrent roundtrip test keeps the end-to-end fixture in one place for auditability"
)]
/// Rebuilds the DHT message as the bencode root dictionary used on the wire.
fn as_bencode_root(&self) -> BTreeMap<Vec<u8>, DhtBencodeValue> {
let mut root = BTreeMap::new();
root.insert(
b"t".to_vec(),
DhtBencodeValue::Bytes(self.transaction_id.clone()),
);
match &self.body {
DhtMessageBody::Query(query) => {
root.insert(b"y".to_vec(), DhtBencodeValue::Bytes(b"q".to_vec()));
match query {
DhtQueryModel::Ping(query) => {
root.insert(b"q".to_vec(), DhtBencodeValue::Bytes(b"ping".to_vec()));
let mut args = BTreeMap::new();
args.insert(
b"id".to_vec(),
DhtBencodeValue::Bytes(query.node_id.clone()),
);
root.insert(b"a".to_vec(), DhtBencodeValue::Dict(args));
}
DhtQueryModel::FindNode(query) => {
root.insert(b"q".to_vec(), DhtBencodeValue::Bytes(b"find_node".to_vec()));
let mut args = BTreeMap::new();
args.insert(
b"id".to_vec(),
DhtBencodeValue::Bytes(query.node_id.clone()),
);
args.insert(
b"target".to_vec(),
DhtBencodeValue::Bytes(query.target.clone()),
);
root.insert(b"a".to_vec(), DhtBencodeValue::Dict(args));
}
DhtQueryModel::GetPeers(query) => {
root.insert(b"q".to_vec(), DhtBencodeValue::Bytes(b"get_peers".to_vec()));
let mut args = BTreeMap::new();
args.insert(
b"id".to_vec(),
DhtBencodeValue::Bytes(query.node_id.clone()),
);
args.insert(
b"info_hash".to_vec(),
DhtBencodeValue::Bytes(query.info_hash.clone()),
);
root.insert(b"a".to_vec(), DhtBencodeValue::Dict(args));
}
DhtQueryModel::AnnouncePeer(query) => {
root.insert(
b"q".to_vec(),
DhtBencodeValue::Bytes(b"announce_peer".to_vec()),
);
let mut args = BTreeMap::new();
args.insert(
b"id".to_vec(),
DhtBencodeValue::Bytes(query.node_id.clone()),
);
args.insert(
b"info_hash".to_vec(),
DhtBencodeValue::Bytes(query.info_hash.clone()),
);
args.insert(
b"port".to_vec(),
DhtBencodeValue::Int(i64::from(query.port)),
);
args.insert(
b"token".to_vec(),
DhtBencodeValue::Bytes(query.token.clone()),
);
if query.implied_port {
args.insert(b"implied_port".to_vec(), DhtBencodeValue::Int(1));
}
root.insert(b"a".to_vec(), DhtBencodeValue::Dict(args));
}
}
}
DhtMessageBody::Response(response) => {
root.insert(b"y".to_vec(), DhtBencodeValue::Bytes(b"r".to_vec()));
let mut payload = BTreeMap::new();
match response {
DhtResponseModel::Ping(response) => {
payload.insert(
b"id".to_vec(),
DhtBencodeValue::Bytes(response.node_id.clone()),
);
}
DhtResponseModel::FindNode(response) => {
payload.insert(
b"id".to_vec(),
DhtBencodeValue::Bytes(response.node_id.clone()),
);
if !response.nodes.is_empty() {
payload.insert(
b"nodes".to_vec(),
DhtBencodeValue::Bytes(encode_compact_dht_nodes(&response.nodes)),
);
}
}
DhtResponseModel::GetPeers(response) => {
payload.insert(
b"id".to_vec(),
DhtBencodeValue::Bytes(response.node_id.clone()),
);
if let Some(token) = &response.token {
payload
.insert(b"token".to_vec(), DhtBencodeValue::Bytes(token.clone()));
}
if let Some(nodes) = &response.nodes {
payload
.insert(b"nodes".to_vec(), DhtBencodeValue::Bytes(nodes.clone()));
}
if !response.values.is_empty() {
payload.insert(
b"values".to_vec(),
DhtBencodeValue::List(
response
.values
.iter()
.cloned()
.map(DhtBencodeValue::Bytes)
.collect(),
),
);
}
}
}
root.insert(b"r".to_vec(), DhtBencodeValue::Dict(payload));
}
DhtMessageBody::Error(error) => {
root.insert(b"y".to_vec(), DhtBencodeValue::Bytes(b"e".to_vec()));
root.insert(
b"e".to_vec(),
DhtBencodeValue::List(vec![
DhtBencodeValue::Int(error.code),
DhtBencodeValue::Bytes(error.message.as_bytes().to_vec()),
]),
);
}
}
root
}
}
/// Parses a DHT query message body from the decoded root dictionary.
fn parse_dht_query_message(
transaction_id: Vec<u8>,
root: &BTreeMap<Vec<u8>, DhtBencodeValue>,
) -> Result<DhtMessageModel, String> {
let method =
dht_dict_get_bytes(root, b"q").ok_or_else(|| "missing dht query method".to_owned())?;
let arguments =
dht_dict_get_dict(root, b"a").ok_or_else(|| "missing dht query arguments".to_owned())?;
let body = match method.as_slice() {
b"ping" => {
let node_id = dht_dict_get_bytes(arguments, b"id")
.ok_or_else(|| "missing dht ping id".to_owned())?;
DhtMessageBody::Query(DhtQueryModel::Ping(DhtPingQueryModel { node_id }))
}
b"find_node" => {
let node_id = dht_dict_get_bytes(arguments, b"id")
.ok_or_else(|| "missing dht find_node id".to_owned())?;
let target = dht_dict_get_bytes(arguments, b"target")
.ok_or_else(|| "missing dht find_node target".to_owned())?;
DhtMessageBody::Query(DhtQueryModel::FindNode(DhtFindNodeQueryModel {
node_id,
target,
}))
}
b"get_peers" => {
let node_id = dht_dict_get_bytes(arguments, b"id")
.ok_or_else(|| "missing dht get_peers id".to_owned())?;
let info_hash = dht_dict_get_bytes(arguments, b"info_hash")
.ok_or_else(|| "missing dht get_peers info_hash".to_owned())?;
DhtMessageBody::Query(DhtQueryModel::GetPeers(DhtGetPeersQueryModel {
node_id,
info_hash,
}))
}
b"announce_peer" => {
let node_id = dht_dict_get_bytes(arguments, b"id")
.ok_or_else(|| "missing dht announce_peer id".to_owned())?;
let info_hash = dht_dict_get_bytes(arguments, b"info_hash")
.ok_or_else(|| "missing dht announce_peer info_hash".to_owned())?;
let port = dht_dict_get_int(arguments, b"port")
.ok_or_else(|| "missing dht announce_peer port".to_owned())?;
let token = dht_dict_get_bytes(arguments, b"token")
.ok_or_else(|| "missing dht announce_peer token".to_owned())?;
let implied_port =
dht_dict_get_int(arguments, b"implied_port").is_some_and(|value| value != 0);
DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(DhtAnnouncePeerQueryModel {
node_id,
info_hash,
port: u16::try_from(port)
.map_err(|_| "dht announce_peer port out of range".to_owned())?,
token,
implied_port,
}))
}
_ => return Err("unsupported dht query method".to_owned()),
};
Ok(DhtMessageModel {
transaction_id,
body,
})
}
/// Parses a DHT response message body from the decoded root dictionary.
fn parse_dht_response_message(
transaction_id: Vec<u8>,
root: &BTreeMap<Vec<u8>, DhtBencodeValue>,
) -> Result<DhtMessageModel, String> {
let payload =
dht_dict_get_dict(root, b"r").ok_or_else(|| "missing dht response body".to_owned())?;
let node_id =
dht_dict_get_bytes(payload, b"id").ok_or_else(|| "missing dht response id".to_owned())?;
let token = dht_dict_get_bytes(payload, b"token");
let nodes = dht_dict_get_bytes(payload, b"nodes");
let values = dht_dict_get_list(payload, b"values")
.unwrap_or_default()
.into_iter()
.map(|value| match value {
DhtBencodeValue::Bytes(bytes) => Ok(bytes),
_ => Err("dht response values entries must be byte strings".to_owned()),
})
.collect::<Result<Vec<_>, _>>()?;
let response = if token.is_some() || !values.is_empty() {
DhtResponseModel::GetPeers(DhtGetPeersResponseModel {
node_id,
token,
nodes,
values,
})
} else if let Some(nodes) = nodes {
DhtResponseModel::FindNode(DhtFindNodeResponseModel {
node_id,
nodes: decode_compact_dht_nodes(&nodes)?,
})
} else {
DhtResponseModel::Ping(DhtPingResponseModel { node_id })
};
Ok(DhtMessageModel {
transaction_id,
body: DhtMessageBody::Response(response),
})
}
/// Parses a DHT error message body from the decoded root dictionary.
fn parse_dht_error_message(
transaction_id: Vec<u8>,
root: &BTreeMap<Vec<u8>, DhtBencodeValue>,
) -> Result<DhtMessageModel, String> {
let errors =
dht_dict_get_list(root, b"e").ok_or_else(|| "missing dht error payload".to_owned())?;
if errors.len() != 2 {
return Err("dht error payload must have [code, message]".to_owned());
}
let code = match errors.first() {
Some(DhtBencodeValue::Int(value)) => *value,
_ => return Err("dht error code must be an integer".to_owned()),
};
let message = match errors.get(1) {
Some(DhtBencodeValue::Bytes(bytes)) => String::from_utf8_lossy(bytes).into_owned(),
_ => return Err("dht error message must be bytes".to_owned()),
};
Ok(DhtMessageModel {
transaction_id,
body: DhtMessageBody::Error(DhtErrorModel { code, message }),
})
}
@@ -0,0 +1,185 @@
use sha1::{Digest, Sha1};
use super::{
bencode::{BencodeValue, TorrentBencodeDict, dict_bytes, dict_int, parse_root_dict},
model::{
TorrentBootstrapModel, TorrentFileEntryModel, TorrentHashModel, TorrentInfoModel,
TorrentMetadataModel, TorrentPieceModel, TorrentTrackerModel,
},
utils::{bytes_to_string, hex_encode, i64_to_u64, value_to_string},
};
/// Parses a `.torrent` payload into structured metadata.
///
/// # Errors
///
/// Returns an error when the bencoded payload is malformed or lacks the required info dictionary.
pub fn parse_torrent_metadata(input: &[u8]) -> Result<TorrentMetadataModel, String> {
let (root, info_raw) = parse_root_dict(input)?;
let info_map = match root.get("info") {
Some(BencodeValue::Dict(map)) => map,
Some(_) => return Err("torrent info must be a dictionary".to_owned()),
None => return Err("missing torrent info dictionary".to_owned()),
};
let info_hash_hex = hex_encode(&Sha1::digest(
info_raw.ok_or_else(|| "missing info bytes".to_owned())?,
));
let info = parse_info_model(info_map, info_hash_hex);
let announce = dict_bytes(&root, "announce").map(bytes_to_string);
let creation_date = dict_bytes(&root, "creation date").map(bytes_to_string);
let comment = dict_bytes(&root, "comment").map(bytes_to_string);
Ok(TorrentMetadataModel {
pieces: build_piece_models(&info),
info,
announce,
trackers: parse_trackers(&root),
peers: Vec::new(),
dht_nodes: parse_dht_nodes(&root),
creation_date,
comment,
})
}
/// Parses a `.torrent` payload and immediately shapes it into bootstrap-ready metadata.
///
/// # Errors
///
/// Returns an error when the `.torrent` payload is malformed or the derived bootstrap fields
/// cannot be shaped.
pub fn parse_torrent_bootstrap(input: &[u8]) -> Result<TorrentBootstrapModel, String> {
parse_torrent_metadata(input)?.bootstrap()
}
/// Builds the higher-level torrent info model from the parsed `info` dictionary.
fn parse_info_model(info: &TorrentBencodeDict, info_hash_hex: String) -> TorrentInfoModel {
let name = dict_bytes(info, "name")
.map(bytes_to_string)
.unwrap_or_default();
let piece_length = i64_to_u64(dict_int(info, "piece length").unwrap_or_default());
let pieces = dict_bytes(info, "pieces")
.map(|bytes| {
bytes
.chunks_exact(20)
.map(|chunk| {
let mut hash = [0_u8; 20];
hash.copy_from_slice(chunk);
hash
})
.collect()
})
.unwrap_or_default();
let private = dict_int(info, "private").is_some_and(|value| value != 0);
let hash = Some(TorrentHashModel {
info_hash_hex,
info_hash_base32: None,
});
let files = if let Some(BencodeValue::List(entries)) = info.get("files") {
let mut offset = 0_u64;
entries
.iter()
.filter_map(|entry| match entry {
BencodeValue::Dict(file) => {
let length = i64_to_u64(dict_int(file, "length").unwrap_or_default());
let path = match file.get("path") {
Some(BencodeValue::List(parts)) => parts
.iter()
.map(value_to_string)
.collect::<Vec<_>>()
.join("/"),
_ => String::new(),
};
let item = TorrentFileEntryModel {
path,
length,
piece_offset: Some(offset),
selected: true,
};
offset = offset.saturating_add(length);
Some(item)
}
_ => None,
})
.collect()
} else {
vec![TorrentFileEntryModel {
path: name.clone(),
length: i64_to_u64(dict_int(info, "length").unwrap_or_default()),
piece_offset: Some(0),
selected: true,
}]
};
TorrentInfoModel {
name,
piece_length,
pieces,
files,
hash,
private,
}
}
/// Derives piece descriptors with offsets and effective lengths from torrent metadata.
fn build_piece_models(info: &TorrentInfoModel) -> Vec<TorrentPieceModel> {
info.pieces
.iter()
.enumerate()
.filter_map(|(index, hash)| {
u32::try_from(index).ok().map(|index| TorrentPieceModel {
index,
hash: *hash,
length: info.piece_length,
})
})
.collect()
}
/// Parses the primary announce URL plus announce-list tiers into stable tracker entries.
fn parse_trackers(root: &TorrentBencodeDict) -> Vec<TorrentTrackerModel> {
let mut trackers = Vec::new();
if let Some(url) = dict_bytes(root, "announce").map(bytes_to_string) {
trackers.push(TorrentTrackerModel {
url,
tier: Some(0),
id: None,
seeders: None,
leechers: None,
});
}
if let Some(BencodeValue::List(tiers)) = root.get("announce-list") {
for (tier_index, tier) in tiers.iter().enumerate() {
if let BencodeValue::List(urls) = tier {
let tier = u32::try_from(tier_index)
.ok()
.and_then(|value| value.checked_add(1));
for url in urls {
trackers.push(TorrentTrackerModel {
url: value_to_string(url),
tier,
id: None,
seeders: None,
leechers: None,
});
}
}
}
}
trackers
}
/// Parses DHT bootstrap nodes from the optional `nodes` field.
fn parse_dht_nodes(root: &TorrentBencodeDict) -> Vec<String> {
match root.get("nodes") {
Some(BencodeValue::List(nodes)) => nodes
.iter()
.filter_map(|node| match node {
BencodeValue::List(parts) => parts.first().zip(parts.get(1)).map(|(host, port)| {
format!("{}:{}", value_to_string(host), value_to_string(port))
}),
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
@@ -0,0 +1,281 @@
use crate::{
magnet::MagnetUriModel,
tracker::{DhtNodeModel, TrackerRequestModel},
};
use super::utils::decode_hex_20_array;
/// Derived info-hash encodings for one parsed torrent info dictionary.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TorrentHashModel {
/// Lowercase hexadecimal SHA-1 info-hash.
pub info_hash_hex: String,
/// Optional base32-encoded SHA-1 info-hash.
pub info_hash_base32: Option<String>,
}
/// One torrent piece with its index, SHA-1 hash, and visible byte length.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TorrentPieceModel {
/// Zero-based piece index.
pub index: u32,
/// Raw 20-byte SHA-1 piece hash.
pub hash: [u8; 20],
/// Declared byte length of the piece.
pub length: u64,
}
/// One file entry from the torrent info dictionary.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TorrentFileEntryModel {
/// Normalized relative path of the file inside the torrent payload.
pub path: String,
/// Declared byte length of the file.
pub length: u64,
/// Byte offset where this file begins within the concatenated torrent payload.
pub piece_offset: Option<u64>,
/// Whether the file is currently selected for download.
pub selected: bool,
}
/// Parsed contents of the torrent info dictionary.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TorrentInfoModel {
/// Display name of the torrent or root directory.
pub name: String,
/// Declared piece length in bytes.
pub piece_length: u64,
/// Raw SHA-1 piece hashes in info-dictionary order.
pub pieces: Vec<[u8; 20]>,
/// File list represented by the torrent.
pub files: Vec<TorrentFileEntryModel>,
/// Precomputed info-hash encodings, when the raw info dictionary was available.
pub hash: Option<TorrentHashModel>,
/// Whether the torrent declares the private flag.
pub private: bool,
}
/// One announce or scrape tracker entry associated with the torrent.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TorrentTrackerModel {
/// Tracker URL.
pub url: String,
/// Optional announce-list tier index.
pub tier: Option<u32>,
/// Optional tracker id returned by the tracker.
pub id: Option<String>,
/// Optional reported seeder count.
pub seeders: Option<u32>,
/// Optional reported leecher count.
pub leechers: Option<u32>,
}
/// One peer surfaced through torrent runtime or tracker responses.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TorrentPeerModel {
/// Optional 20-byte peer id.
pub peer_id: Option<[u8; 20]>,
/// Peer IP address in string form.
pub ip: String,
/// Peer port.
pub port: u16,
/// Optional peer client name.
pub client_name: Option<String>,
/// Whether the peer is interested in local pieces.
pub interested: bool,
/// Whether the peer is currently choking the local side.
pub choked: bool,
}
/// Full parsed torrent metadata plus runtime-adjacent tracker and peer surfaces.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TorrentMetadataModel {
/// Parsed info dictionary.
pub info: TorrentInfoModel,
/// Primary announce URL, if present.
pub announce: Option<String>,
/// Flattened tracker list with stable tier annotations.
pub trackers: Vec<TorrentTrackerModel>,
/// Known peers currently associated with the torrent.
pub peers: Vec<TorrentPeerModel>,
/// DHT bootstrap nodes from the `nodes` list, normalized as `host:port` strings.
pub dht_nodes: Vec<String>,
/// Derived piece models with offsets and lengths.
pub pieces: Vec<TorrentPieceModel>,
/// Optional creation date text carried by the torrent.
pub creation_date: Option<String>,
/// Optional comment text carried by the torrent.
pub comment: Option<String>,
}
/// Higher-level `.torrent` bootstrap data suitable for dispatcher / CLI handoff.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TorrentBootstrapModel {
/// Fully parsed torrent metadata.
pub metadata: TorrentMetadataModel,
/// Lowercase hexadecimal SHA-1 info-hash.
pub info_hash_hex: String,
/// Raw 20-byte SHA-1 info-hash.
pub info_hash_bytes: [u8; 20],
/// Magnet projection derived from the torrent metadata.
pub magnet: MagnetUriModel,
/// Parsed DHT node models ready for DHT/bootstrap orchestration.
pub dht_nodes: Vec<DhtNodeModel>,
}
impl TorrentPeerModel {
/// Parses a peer endpoint from `host:port` or `[ipv6]:port` syntax.
///
/// # Errors
///
/// Returns an error when the endpoint is malformed.
pub fn from_endpoint(raw: &str) -> Result<Self, String> {
let node = DhtNodeModel::from_spec(raw).map_err(|error| error.to_string())?;
Ok(Self {
peer_id: None,
ip: node.address,
port: node.port,
client_name: None,
interested: false,
choked: false,
})
}
/// Formats the peer as a stable endpoint string.
#[must_use]
pub fn endpoint(&self) -> String {
self.to_dht_node().to_spec()
}
/// Shapes the peer endpoint into a DHT/bootstrap node model.
#[must_use]
pub fn to_dht_node(&self) -> DhtNodeModel {
DhtNodeModel {
node_id: String::new(),
address: self.ip.clone(),
port: self.port,
}
}
}
impl TorrentMetadataModel {
#[must_use]
/// Returns the total payload length across all torrent files.
pub fn total_length(&self) -> u64 {
self.info.files.iter().map(|file| file.length).sum()
}
/// Returns the torrent info-hash as lowercase hexadecimal text.
#[must_use]
pub fn info_hash_hex(&self) -> Option<&str> {
self.info
.hash
.as_ref()
.map(|hash| hash.info_hash_hex.as_str())
}
/// Decodes the torrent info-hash into its raw 20-byte SHA-1 representation.
///
/// # Errors
///
/// Returns an error when the torrent metadata does not carry an info-hash.
pub fn info_hash_bytes(&self) -> Result<[u8; 20], String> {
decode_hex_20_array(
self.info_hash_hex()
.ok_or_else(|| "torrent metadata is missing info-hash".to_owned())?,
)
}
/// Returns the tracker URLs in stable announce order.
#[must_use]
pub fn tracker_urls(&self) -> Vec<String> {
self.trackers
.iter()
.map(|tracker| tracker.url.clone())
.collect()
}
/// Returns the first tracker URL when present.
#[must_use]
pub fn primary_tracker_url(&self) -> Option<&str> {
self.trackers.first().map(|tracker| tracker.url.as_str())
}
/// Projects the torrent metadata into a canonical magnet URI model.
///
/// # Errors
///
/// Returns an error when the torrent metadata does not carry an info-hash.
pub fn magnet_uri_model(&self) -> Result<MagnetUriModel, String> {
Ok(MagnetUriModel {
info_hash: self
.info_hash_hex()
.ok_or_else(|| "torrent metadata is missing info-hash".to_owned())?
.to_owned(),
display_name: Some(self.info.name.clone()),
trackers: self.tracker_urls(),
web_seeds: Vec::new(),
exact_topic: None,
})
}
/// Parses stored DHT node specs into higher-level node models.
///
/// # Errors
///
/// Returns an error when any stored node spec is malformed.
pub fn dht_node_models(&self) -> Result<Vec<DhtNodeModel>, String> {
self.dht_nodes
.iter()
.map(|node| DhtNodeModel::from_spec(node).map_err(|error| error.to_string()))
.collect()
}
/// Shapes the torrent metadata into a bootstrap model suitable for `.torrent` handoff.
///
/// # Errors
///
/// Returns an error when the torrent metadata does not carry a usable info-hash or contains
/// malformed DHT node specs.
pub fn bootstrap(&self) -> Result<TorrentBootstrapModel, String> {
Ok(TorrentBootstrapModel {
metadata: self.clone(),
info_hash_hex: self
.info_hash_hex()
.ok_or_else(|| "torrent metadata is missing info-hash".to_owned())?
.to_owned(),
info_hash_bytes: self.info_hash_bytes()?,
magnet: self.magnet_uri_model()?,
dht_nodes: self.dht_node_models()?,
})
}
#[must_use]
/// Builds a tracker announce request from the parsed torrent metadata.
pub fn tracker_request(
&self,
announce_url: impl Into<String>,
peer_id: impl Into<String>,
port: u16,
uploaded: u64,
downloaded: u64,
) -> TrackerRequestModel {
TrackerRequestModel {
announce_url: announce_url.into(),
info_hash: self
.info
.hash
.as_ref()
.map(|hash| hash.info_hash_hex.clone())
.unwrap_or_default(),
peer_id: peer_id.into(),
port,
uploaded,
downloaded,
left: self.total_length().saturating_sub(downloaded),
event: None,
compact: true,
numwant: Some(50),
}
}
}
@@ -0,0 +1,223 @@
use std::collections::BTreeMap;
use super::{
bencode::{
BencodeValue, dict_bytes, dict_int, encode_bencode_root, parse_bencode_root_exact,
parse_bencode_root_prefix,
},
utils::{bytes_to_string, i64_to_u64},
};
/// BEP 10 extension-protocol handshake and metadata helpers.
mod extension;
/// Peer-wire frame parsing and serialization helpers.
mod framing;
/// `BitTorrent` handshake parsing and serialization helpers.
mod handshake;
/// Generic torrent message wrapper reused by higher-level peer-wire helpers.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TorrentMessageModel {
/// Canonical internal message type name.
pub message_type: String,
/// Raw payload bytes excluding transport framing.
pub payload: Vec<u8>,
}
/// `BitTorrent` peer-wire handshake header.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PeerWireHandshakeModel {
/// Reserved extension bits.
pub reserved: [u8; 8],
/// 20-byte torrent info-hash.
pub info_hash: [u8; 20],
/// 20-byte local peer id.
pub peer_id: [u8; 20],
}
/// One parsed peer-wire message, optionally associated with a peer id.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerWireMessageModel {
/// Optional peer id attached by higher-level wrappers.
pub peer_id: Option<[u8; 20]>,
/// Parsed message payload.
pub message: TorrentMessageModel,
}
/// Lightweight inspection result for a framed peer-wire message.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PeerWireFrameHeaderModel {
/// Optional peer-wire message id. `None` represents keepalive.
pub message_id: Option<u8>,
/// Payload length excluding the length prefix and optional message id byte.
pub payload_len: usize,
}
/// Packed peer-wire bitfield bytes.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerWireBitfieldModel {
/// Raw bitfield bytes in network order.
pub bytes: Vec<u8>,
}
/// Piece request or cancel coordinates for the peer-wire protocol.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PeerWireBlockRequestModel {
/// Zero-based piece index.
pub piece_index: u32,
/// Byte offset within the piece.
pub block_offset: u32,
/// Requested block length in bytes.
pub block_length: u32,
}
/// Piece payload delivered through the peer-wire protocol.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerWirePieceBlockModel {
/// Zero-based piece index.
pub piece_index: u32,
/// Byte offset within the piece.
pub block_offset: u32,
/// Raw block bytes.
pub block: Vec<u8>,
}
/// Extension-protocol message payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerWireExtensionMessageModel {
/// Extension message id.
pub extension_message_id: u8,
/// Extension payload bytes after the extension id.
pub payload: Vec<u8>,
}
/// Parsed extended-handshake payload from BEP 10.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerWireExtensionHandshakeModel {
/// Named extension ids announced under the `m` dictionary.
pub extensions: BTreeMap<String, u8>,
/// Optional peer/client version string from `v`.
pub client_name: Option<String>,
/// Optional BEP 9 metadata byte length.
pub metadata_size: Option<u32>,
/// Optional request queue depth from `reqq`.
pub request_queue: Option<u32>,
}
/// BEP 9 `ut_metadata` message type.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PeerWireMetadataMessageType {
/// Requests one metadata piece.
Request,
/// Carries one metadata piece payload.
Data,
/// Rejects one metadata piece request.
Reject,
}
impl PeerWireMetadataMessageType {
/// Returns the BEP 9 wire value for the metadata message type.
#[must_use]
pub const fn wire_value(self) -> u8 {
match self {
Self::Request => 0,
Self::Data => 1,
Self::Reject => 2,
}
}
/// Decodes one BEP 9 wire value into the typed metadata message kind.
fn from_wire_value(value: i64) -> Result<Self, String> {
match value {
0 => Ok(Self::Request),
1 => Ok(Self::Data),
2 => Ok(Self::Reject),
_ => Err(format!("unsupported ut_metadata msg_type: {value}")),
}
}
}
/// Default BEP 9 metadata piece size in bytes.
pub const PEER_WIRE_METADATA_PIECE_SIZE: u32 = 16 * 1024;
/// Parsed BEP 9 `ut_metadata` message with header and optional payload bytes.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerWireMetadataMessageModel {
/// Metadata message subtype.
pub message_type: PeerWireMetadataMessageType,
/// Metadata piece index addressed by the message.
pub piece: u32,
/// Total metadata byte length when included in `data` messages.
pub total_size: Option<u32>,
/// Metadata payload bytes for `data` messages.
pub payload: Vec<u8>,
}
/// Unknown peer-wire message payload retained losslessly.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerWireUnknownMessageModel {
/// Raw peer-wire message id.
pub message_id: u8,
/// Unparsed message payload bytes.
pub payload: Vec<u8>,
}
/// Supported peer-wire message variants.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PeerWireMessageKind {
/// Zero-length keepalive frame.
KeepAlive,
/// Choke control message.
Choke,
/// Unchoke control message.
Unchoke,
/// Interested control message.
Interested,
/// Not-interested control message.
NotInterested,
/// `have` message naming one completed piece.
Have(u32),
/// `bitfield` message.
Bitfield(PeerWireBitfieldModel),
/// `request` message.
Request(PeerWireBlockRequestModel),
/// `piece` message.
Piece(PeerWirePieceBlockModel),
/// `cancel` message.
Cancel(PeerWireBlockRequestModel),
/// `port` DHT advertisement message.
Port(u16),
/// Extension-protocol message.
Extension(PeerWireExtensionMessageModel),
/// Unknown message preserved losslessly.
Unknown(PeerWireUnknownMessageModel),
}
/// Canonical protocol string embedded in peer-wire handshakes.
const PEER_WIRE_PROTOCOL_NAME: &str = "BitTorrent protocol";
/// Byte length of [`PEER_WIRE_PROTOCOL_NAME`].
const PEER_WIRE_PROTOCOL_LEN: u8 = 19;
/// Handshake bytes following the protocol-length octet and protocol string.
const PEER_WIRE_HANDSHAKE_PREFIX_LEN: usize = 49;
/// Peer-wire message id for `choke`.
const PEER_WIRE_CHOKE_ID: u8 = 0;
/// Peer-wire message id for `unchoke`.
const PEER_WIRE_UNCHOKE_ID: u8 = 1;
/// Peer-wire message id for `interested`.
const PEER_WIRE_INTERESTED_ID: u8 = 2;
/// Peer-wire message id for `not interested`.
const PEER_WIRE_NOT_INTERESTED_ID: u8 = 3;
/// Peer-wire message id for `have`.
const PEER_WIRE_HAVE_ID: u8 = 4;
/// Peer-wire message id for `bitfield`.
const PEER_WIRE_BITFIELD_ID: u8 = 5;
/// Peer-wire message id for `request`.
const PEER_WIRE_REQUEST_ID: u8 = 6;
/// Peer-wire message id for `piece`.
const PEER_WIRE_PIECE_ID: u8 = 7;
/// Peer-wire message id for `cancel`.
const PEER_WIRE_CANCEL_ID: u8 = 8;
/// Peer-wire message id for `port`.
const PEER_WIRE_PORT_ID: u8 = 9;
/// Peer-wire message id for extension-protocol payloads.
const PEER_WIRE_EXTENSION_ID: u8 = 20;
@@ -0,0 +1,291 @@
use std::collections::BTreeMap;
use super::{
BencodeValue, PEER_WIRE_METADATA_PIECE_SIZE, PeerWireExtensionHandshakeModel,
PeerWireExtensionMessageModel, PeerWireMetadataMessageModel, PeerWireMetadataMessageType,
bytes_to_string, dict_bytes, dict_int, encode_bencode_root, i64_to_u64,
parse_bencode_root_exact, parse_bencode_root_prefix,
};
impl PeerWireExtensionHandshakeModel {
/// Returns the announced `ut_metadata` extension id when present.
#[must_use]
pub fn ut_metadata_id(&self) -> Option<u8> {
self.extensions
.get("ut_metadata")
.copied()
.filter(|id| *id != 0)
}
/// Returns the advertised metadata payload size as piece count when present.
#[must_use]
pub fn metadata_piece_count(&self) -> Option<u32> {
self.metadata_size.map(metadata_piece_count)
}
/// Serializes the handshake into a BEP 10 bencoded dictionary.
#[must_use]
pub fn to_bencode_bytes(&self) -> Vec<u8> {
let mut root = BTreeMap::new();
let mut extensions = BTreeMap::new();
for (name, id) in &self.extensions {
extensions.insert(name.clone(), BencodeValue::Int(i64::from(*id)));
}
root.insert("m".to_owned(), BencodeValue::Dict(extensions));
if let Some(client_name) = &self.client_name {
root.insert(
"v".to_owned(),
BencodeValue::Bytes(client_name.as_bytes().to_vec()),
);
}
if let Some(metadata_size) = self.metadata_size {
root.insert(
"metadata_size".to_owned(),
BencodeValue::Int(i64::from(metadata_size)),
);
}
if let Some(request_queue) = self.request_queue {
root.insert(
"reqq".to_owned(),
BencodeValue::Int(i64::from(request_queue)),
);
}
encode_bencode_root(&root)
}
/// Parses a BEP 10 extended-handshake dictionary.
///
/// # Errors
///
/// Returns an error when the payload is not a valid handshake dictionary.
pub fn from_bencode_bytes(input: &[u8]) -> Result<Self, String> {
let root = parse_bencode_root_exact(input)?;
let mut extensions = BTreeMap::new();
if let Some(BencodeValue::Dict(values)) = root.get("m") {
for (name, value) in values {
if let BencodeValue::Int(id) = value {
let id_u8 = u8::try_from(*id)
.map_err(|_| format!("extension id for {name} does not fit u8"))?;
extensions.insert(name.clone(), id_u8);
}
}
}
Ok(Self {
extensions,
client_name: dict_bytes(&root, "v").map(bytes_to_string),
metadata_size: dict_int(&root, "metadata_size")
.map(i64_to_u64)
.map(u32::try_from)
.transpose()
.map_err(|_| "metadata_size does not fit u32".to_owned())?,
request_queue: dict_int(&root, "reqq")
.map(i64_to_u64)
.map(u32::try_from)
.transpose()
.map_err(|_| "reqq does not fit u32".to_owned())?,
})
}
/// Wraps the handshake as a peer-wire extension message with extended-message id `0`.
#[must_use]
pub fn to_peer_wire_message(&self) -> PeerWireExtensionMessageModel {
PeerWireExtensionMessageModel {
extension_message_id: 0,
payload: self.to_bencode_bytes(),
}
}
/// Parses a peer-wire extended handshake message.
///
/// # Errors
///
/// Returns an error when the message is not the extension handshake or the payload is invalid.
pub fn from_peer_wire_message(message: &PeerWireExtensionMessageModel) -> Result<Self, String> {
if message.extension_message_id != 0 {
return Err(format!(
"extended handshake must use extension message id 0, got {}",
message.extension_message_id
));
}
Self::from_bencode_bytes(&message.payload)
}
}
impl PeerWireMetadataMessageModel {
/// Builds a BEP 9 metadata request for one piece index.
#[must_use]
pub fn request(piece: u32) -> Self {
Self {
message_type: PeerWireMetadataMessageType::Request,
piece,
total_size: None,
payload: Vec::new(),
}
}
/// Builds a BEP 9 metadata data message for one piece index.
#[must_use]
pub fn data(piece: u32, total_size: u32, payload: Vec<u8>) -> Self {
Self {
message_type: PeerWireMetadataMessageType::Data,
piece,
total_size: Some(total_size),
payload,
}
}
/// Builds a BEP 9 metadata reject message for one piece index.
#[must_use]
pub fn reject(piece: u32) -> Self {
Self {
message_type: PeerWireMetadataMessageType::Reject,
piece,
total_size: None,
payload: Vec::new(),
}
}
/// Wraps the metadata message into a peer-wire extension payload using the supplied id.
#[must_use]
pub fn to_peer_wire_message(&self, extension_message_id: u8) -> PeerWireExtensionMessageModel {
PeerWireExtensionMessageModel {
extension_message_id,
payload: self.to_bencode_bytes(),
}
}
/// Serializes the BEP 9 header plus any trailing metadata payload.
#[must_use]
pub fn to_bencode_bytes(&self) -> Vec<u8> {
let mut root = BTreeMap::new();
root.insert(
"msg_type".to_owned(),
BencodeValue::Int(i64::from(self.message_type.wire_value())),
);
root.insert("piece".to_owned(), BencodeValue::Int(i64::from(self.piece)));
if self.message_type == PeerWireMetadataMessageType::Data
&& let Some(total_size) = self.total_size
{
root.insert(
"total_size".to_owned(),
BencodeValue::Int(i64::from(total_size)),
);
}
let mut out = encode_bencode_root(&root);
if self.message_type == PeerWireMetadataMessageType::Data {
out.extend_from_slice(&self.payload);
}
out
}
/// Parses a BEP 9 message from raw extension payload bytes.
///
/// # Errors
///
/// Returns an error when the message header is malformed or contains unsupported values.
pub fn from_bencode_bytes(input: &[u8]) -> Result<Self, String> {
let (root, consumed) = parse_bencode_root_prefix(input)?;
let message_type_raw =
dict_int(&root, "msg_type").ok_or_else(|| "missing ut_metadata msg_type".to_owned())?;
let message_type = PeerWireMetadataMessageType::from_wire_value(message_type_raw)?;
let piece = dict_int(&root, "piece")
.ok_or_else(|| "missing ut_metadata piece".to_owned())
.map(i64_to_u64)
.and_then(|value| {
u32::try_from(value).map_err(|_| "ut_metadata piece does not fit u32".to_owned())
})?;
let total_size = dict_int(&root, "total_size")
.map(i64_to_u64)
.map(u32::try_from)
.transpose()
.map_err(|_| "ut_metadata total_size does not fit u32".to_owned())?;
let payload = input[consumed..].to_vec();
if message_type != PeerWireMetadataMessageType::Data && total_size.is_some() {
return Err(
"ut_metadata request/reject messages must not include total_size".to_owned(),
);
}
if message_type != PeerWireMetadataMessageType::Data && !payload.is_empty() {
return Err(
"ut_metadata request/reject messages must not carry trailing payload".to_owned(),
);
}
if message_type == PeerWireMetadataMessageType::Data {
let total_size = total_size
.ok_or_else(|| "ut_metadata data messages must include total_size".to_owned())?;
let expected_payload_len = metadata_piece_len(piece, total_size)?;
if payload.len() != expected_payload_len {
return Err(format!(
"ut_metadata data payload length {} does not match expected {} bytes for piece {}",
payload.len(),
expected_payload_len,
piece
));
}
}
Ok(Self {
message_type,
piece,
total_size,
payload,
})
}
/// Parses a peer-wire extension message as a BEP 9 metadata message.
///
/// # Errors
///
/// Returns an error when the extension id mismatches or the payload is malformed.
pub fn from_peer_wire_message(
message: &PeerWireExtensionMessageModel,
expected_extension_message_id: u8,
) -> Result<Self, String> {
if expected_extension_message_id == 0 {
return Err("ut_metadata cannot use peer-wire extension message id 0".to_owned());
}
if message.extension_message_id != expected_extension_message_id {
return Err(format!(
"ut_metadata message expected extension id {expected_extension_message_id}, got {}",
message.extension_message_id
));
}
Self::from_bencode_bytes(&message.payload)
}
}
/// Returns the number of metadata pieces required to carry `total_size` bytes.
#[must_use]
fn metadata_piece_count(total_size: u32) -> u32 {
if total_size == 0 {
return 0;
}
(total_size - 1) / PEER_WIRE_METADATA_PIECE_SIZE + 1
}
/// Returns the expected payload length for one metadata piece.
///
/// # Errors
///
/// Returns an error when the total size is zero or the requested piece is out of range.
fn metadata_piece_len(piece: u32, total_size: u32) -> Result<usize, String> {
if total_size == 0 {
return Err("ut_metadata total_size must be positive".to_owned());
}
let piece_count = metadata_piece_count(total_size);
if piece >= piece_count {
return Err(format!(
"ut_metadata piece {piece} is out of range for total_size {total_size}"
));
}
let piece_size = PEER_WIRE_METADATA_PIECE_SIZE;
let base_offset = piece
.checked_mul(piece_size)
.ok_or_else(|| "ut_metadata piece offset overflow".to_owned())?;
let remaining = total_size - base_offset;
let expected_len = remaining.min(piece_size);
usize::try_from(expected_len)
.map_err(|_| "ut_metadata payload length does not fit usize".to_owned())
}
@@ -0,0 +1,506 @@
use super::{
PEER_WIRE_BITFIELD_ID, PEER_WIRE_CANCEL_ID, PEER_WIRE_CHOKE_ID, PEER_WIRE_EXTENSION_ID,
PEER_WIRE_HAVE_ID, PEER_WIRE_INTERESTED_ID, PEER_WIRE_NOT_INTERESTED_ID, PEER_WIRE_PIECE_ID,
PEER_WIRE_PORT_ID, PEER_WIRE_REQUEST_ID, PEER_WIRE_UNCHOKE_ID, PeerWireBitfieldModel,
PeerWireBlockRequestModel, PeerWireExtensionMessageModel, PeerWireFrameHeaderModel,
PeerWireMessageKind, PeerWireMessageModel, PeerWirePieceBlockModel,
PeerWireUnknownMessageModel, TorrentMessageModel,
};
impl PeerWireMessageModel {
#[must_use]
/// Wraps one parsed torrent message with an optional peer id.
pub fn new(peer_id: Option<[u8; 20]>, message: TorrentMessageModel) -> Self {
Self { peer_id, message }
}
/// Parses one framed peer-wire message and reports the number of bytes consumed.
///
/// # Errors
///
/// Returns an error when the frame header or payload is malformed.
pub fn parse_frame(input: &[u8]) -> Result<(Self, usize), String> {
let (message, consumed) = TorrentMessageModel::parse_peer_wire_frame(input)?;
Ok((
Self {
peer_id: None,
message,
},
consumed,
))
}
/// Parses one complete framed peer-wire message.
///
/// # Errors
///
/// Returns an error when the frame is malformed or contains trailing bytes.
pub fn parse_frame_exact(input: &[u8]) -> Result<Self, String> {
let (message, consumed) = Self::parse_frame(input)?;
if consumed != input.len() {
return Err("trailing bytes after peer-wire frame".to_owned());
}
Ok(message)
}
/// Serializes the wrapped message as a framed peer-wire payload.
///
/// # Errors
///
/// Returns an error when the inner message cannot be represented as peer-wire bytes.
pub fn serialize_frame(&self) -> Result<Vec<u8>, String> {
self.message.serialize_peer_wire_frame()
}
}
impl PeerWireBitfieldModel {
#[must_use]
/// Builds a bitfield from per-piece completion flags.
pub fn from_piece_flags(flags: &[bool]) -> Self {
let mut bytes = vec![0_u8; flags.len().div_ceil(8)];
for (index, &present) in flags.iter().enumerate() {
if present {
bytes[index / 8] |= 1 << (7 - (index % 8));
}
}
Self { bytes }
}
#[must_use]
/// Returns the maximum number of pieces represented by this bitfield.
pub fn piece_capacity(&self) -> usize {
self.bytes.len() * 8
}
#[must_use]
/// Returns whether the bitfield marks `piece_index` as present.
pub fn has_piece(&self, piece_index: usize) -> bool {
let byte = piece_index / 8;
let bit = piece_index % 8;
self.bytes
.get(byte)
.is_some_and(|value| value & (1 << (7 - bit)) != 0)
}
#[must_use]
/// Expands the bitfield into per-piece completion flags.
pub fn to_piece_flags(&self, piece_count: usize) -> Vec<bool> {
(0..piece_count)
.map(|index| self.has_piece(index))
.collect()
}
}
impl TorrentMessageModel {
#[must_use]
/// Builds an internal torrent message from a peer-wire message variant.
pub fn from_peer_wire_kind(kind: PeerWireMessageKind) -> Self {
match kind {
PeerWireMessageKind::KeepAlive => Self {
message_type: "keepalive".to_owned(),
payload: Vec::new(),
},
PeerWireMessageKind::Choke => Self {
message_type: "choke".to_owned(),
payload: Vec::new(),
},
PeerWireMessageKind::Unchoke => Self {
message_type: "unchoke".to_owned(),
payload: Vec::new(),
},
PeerWireMessageKind::Interested => Self {
message_type: "interested".to_owned(),
payload: Vec::new(),
},
PeerWireMessageKind::NotInterested => Self {
message_type: "not_interested".to_owned(),
payload: Vec::new(),
},
PeerWireMessageKind::Have(piece_index) => Self {
message_type: "have".to_owned(),
payload: piece_index.to_be_bytes().to_vec(),
},
PeerWireMessageKind::Bitfield(bitfield) => Self {
message_type: "bitfield".to_owned(),
payload: bitfield.bytes,
},
PeerWireMessageKind::Request(request) => Self {
message_type: "request".to_owned(),
payload: encode_block_request_payload(&request),
},
PeerWireMessageKind::Piece(piece) => Self {
message_type: "piece".to_owned(),
payload: encode_piece_payload(&piece),
},
PeerWireMessageKind::Cancel(request) => Self {
message_type: "cancel".to_owned(),
payload: encode_block_request_payload(&request),
},
PeerWireMessageKind::Port(port) => Self {
message_type: "port".to_owned(),
payload: port.to_be_bytes().to_vec(),
},
PeerWireMessageKind::Extension(extension) => {
let mut payload = Vec::with_capacity(1 + extension.payload.len());
payload.push(extension.extension_message_id);
payload.extend_from_slice(&extension.payload);
Self {
message_type: "extension".to_owned(),
payload,
}
}
PeerWireMessageKind::Unknown(message) => Self {
message_type: format!("unknown:{}", message.message_id),
payload: message.payload,
},
}
}
/// Reconstructs the typed peer-wire message kind from the internal message payload.
///
/// # Errors
///
/// Returns an error when the message type or payload shape is unsupported.
pub fn peer_wire_kind(&self) -> Result<PeerWireMessageKind, String> {
peer_wire_kind_from_raw(&self.message_type, &self.payload)
}
/// Inspects a framed peer-wire message header without fully decoding the payload.
///
/// # Errors
///
/// Returns an error when the frame is truncated or malformed.
pub fn inspect_peer_wire_frame(
input: &[u8],
) -> Result<(PeerWireFrameHeaderModel, usize), String> {
if input.len() < 4 {
return Err("truncated peer-wire frame: missing length prefix".to_owned());
}
let frame_len =
usize::try_from(u32::from_be_bytes([input[0], input[1], input[2], input[3]]))
.map_err(|_| "peer-wire frame length does not fit usize".to_owned())?;
let total_len = 4_usize
.checked_add(frame_len)
.ok_or_else(|| "peer-wire frame length overflow".to_owned())?;
if input.len() < total_len {
return Err(format!(
"truncated peer-wire frame: expected {total_len} bytes, got {}",
input.len()
));
}
if frame_len == 0 {
return Ok((
PeerWireFrameHeaderModel {
message_id: None,
payload_len: 0,
},
total_len,
));
}
let message_id = input[4];
Ok((
PeerWireFrameHeaderModel {
message_id: Some(message_id),
payload_len: frame_len - 1,
},
total_len,
))
}
/// Parses a framed peer-wire message and reports the number of consumed bytes.
///
/// # Errors
///
/// Returns an error when the frame is truncated or malformed.
pub fn parse_peer_wire_frame(input: &[u8]) -> Result<(Self, usize), String> {
let (header, consumed) = Self::inspect_peer_wire_frame(input)?;
let Some(message_id) = header.message_id else {
return Ok((
Self::from_peer_wire_kind(PeerWireMessageKind::KeepAlive),
consumed,
));
};
let payload = &input[5..consumed];
let kind = peer_wire_kind_from_message_id(message_id, payload)?;
Ok((Self::from_peer_wire_kind(kind), consumed))
}
/// Parses one complete framed peer-wire message.
///
/// # Errors
///
/// Returns an error when the frame is truncated, malformed, or has trailing bytes.
pub fn parse_peer_wire_frame_exact(input: &[u8]) -> Result<Self, String> {
let (message, consumed) = Self::parse_peer_wire_frame(input)?;
if consumed != input.len() {
return Err("trailing bytes after peer-wire frame".to_owned());
}
Ok(message)
}
/// Serializes the message as a framed peer-wire payload.
///
/// # Errors
///
/// Returns an error when the message cannot be represented as a supported peer-wire frame.
pub fn serialize_peer_wire_frame(&self) -> Result<Vec<u8>, String> {
let kind = self.peer_wire_kind()?;
serialize_peer_wire_kind(&kind)
}
}
/// Serializes one peer-wire message kind into a framed peer-wire payload.
fn serialize_peer_wire_kind(kind: &PeerWireMessageKind) -> Result<Vec<u8>, String> {
let mut payload = Vec::new();
let message_id = match kind {
PeerWireMessageKind::KeepAlive => None,
PeerWireMessageKind::Choke => Some(PEER_WIRE_CHOKE_ID),
PeerWireMessageKind::Unchoke => Some(PEER_WIRE_UNCHOKE_ID),
PeerWireMessageKind::Interested => Some(PEER_WIRE_INTERESTED_ID),
PeerWireMessageKind::NotInterested => Some(PEER_WIRE_NOT_INTERESTED_ID),
PeerWireMessageKind::Have(piece_index) => {
payload.extend_from_slice(&piece_index.to_be_bytes());
Some(PEER_WIRE_HAVE_ID)
}
PeerWireMessageKind::Bitfield(bitfield) => {
payload.extend_from_slice(&bitfield.bytes);
Some(PEER_WIRE_BITFIELD_ID)
}
PeerWireMessageKind::Request(request) => {
payload.extend_from_slice(&encode_block_request_payload(request));
Some(PEER_WIRE_REQUEST_ID)
}
PeerWireMessageKind::Piece(piece) => {
payload.extend_from_slice(&encode_piece_payload(piece));
Some(PEER_WIRE_PIECE_ID)
}
PeerWireMessageKind::Cancel(request) => {
payload.extend_from_slice(&encode_block_request_payload(request));
Some(PEER_WIRE_CANCEL_ID)
}
PeerWireMessageKind::Port(port) => {
payload.extend_from_slice(&port.to_be_bytes());
Some(PEER_WIRE_PORT_ID)
}
PeerWireMessageKind::Extension(extension) => {
payload.push(extension.extension_message_id);
payload.extend_from_slice(&extension.payload);
Some(PEER_WIRE_EXTENSION_ID)
}
PeerWireMessageKind::Unknown(message) => {
payload.extend_from_slice(&message.payload);
Some(message.message_id)
}
};
let Some(message_id) = message_id else {
return Ok(vec![0, 0, 0, 0]);
};
let frame_len = 1 + payload.len();
let frame_len_u32 = u32::try_from(frame_len)
.map_err(|_| "peer-wire frame exceeds u32 length prefix".to_owned())?;
let mut bytes = Vec::with_capacity(4 + frame_len);
bytes.extend_from_slice(&frame_len_u32.to_be_bytes());
bytes.push(message_id);
bytes.extend_from_slice(&payload);
Ok(bytes)
}
/// Interprets one internal message-type label and payload as a peer-wire message kind.
fn peer_wire_kind_from_raw(
message_type: &str,
payload: &[u8],
) -> Result<PeerWireMessageKind, String> {
match message_type {
"keepalive" => {
expect_empty_payload("keepalive", payload).map(|()| PeerWireMessageKind::KeepAlive)
}
"choke" => expect_empty_payload("choke", payload).map(|()| PeerWireMessageKind::Choke),
"unchoke" => {
expect_empty_payload("unchoke", payload).map(|()| PeerWireMessageKind::Unchoke)
}
"interested" => {
expect_empty_payload("interested", payload).map(|()| PeerWireMessageKind::Interested)
}
"not_interested" | "not-interested" => expect_empty_payload("not_interested", payload)
.map(|()| PeerWireMessageKind::NotInterested),
"have" => parse_have_payload(payload),
"bitfield" => Ok(PeerWireMessageKind::Bitfield(PeerWireBitfieldModel {
bytes: payload.to_vec(),
})),
"request" => {
parse_block_request_payload("request", payload).map(PeerWireMessageKind::Request)
}
"piece" => parse_piece_payload(payload).map(PeerWireMessageKind::Piece),
"cancel" => parse_block_request_payload("cancel", payload).map(PeerWireMessageKind::Cancel),
"port" => parse_port_payload(payload),
"extension" => parse_extension_payload(payload),
_ => parse_unknown_message_id(message_type).map_or_else(
|| {
Err(format!(
"unsupported peer-wire message type: {message_type}"
))
},
|message_id| {
Ok(PeerWireMessageKind::Unknown(PeerWireUnknownMessageModel {
message_id,
payload: payload.to_vec(),
}))
},
),
}
}
/// Interprets a peer-wire message id and payload as a typed peer-wire message kind.
fn peer_wire_kind_from_message_id(
message_id: u8,
payload: &[u8],
) -> Result<PeerWireMessageKind, String> {
match message_id {
PEER_WIRE_CHOKE_ID => {
expect_empty_payload("choke", payload).map(|()| PeerWireMessageKind::Choke)
}
PEER_WIRE_UNCHOKE_ID => {
expect_empty_payload("unchoke", payload).map(|()| PeerWireMessageKind::Unchoke)
}
PEER_WIRE_INTERESTED_ID => {
expect_empty_payload("interested", payload).map(|()| PeerWireMessageKind::Interested)
}
PEER_WIRE_NOT_INTERESTED_ID => expect_empty_payload("not_interested", payload)
.map(|()| PeerWireMessageKind::NotInterested),
PEER_WIRE_HAVE_ID => parse_have_payload(payload),
PEER_WIRE_BITFIELD_ID => Ok(PeerWireMessageKind::Bitfield(PeerWireBitfieldModel {
bytes: payload.to_vec(),
})),
PEER_WIRE_REQUEST_ID => {
parse_block_request_payload("request", payload).map(PeerWireMessageKind::Request)
}
PEER_WIRE_PIECE_ID => parse_piece_payload(payload).map(PeerWireMessageKind::Piece),
PEER_WIRE_CANCEL_ID => {
parse_block_request_payload("cancel", payload).map(PeerWireMessageKind::Cancel)
}
PEER_WIRE_PORT_ID => parse_port_payload(payload),
PEER_WIRE_EXTENSION_ID => parse_extension_payload(payload),
_ => Ok(PeerWireMessageKind::Unknown(PeerWireUnknownMessageModel {
message_id,
payload: payload.to_vec(),
})),
}
}
/// Verifies that a peer-wire control payload is empty.
fn expect_empty_payload(name: &str, payload: &[u8]) -> Result<(), String> {
if payload.is_empty() {
Ok(())
} else {
Err(format!(
"peer-wire {name} payload must be empty, got {} bytes",
payload.len()
))
}
}
/// Parses a `have` payload into its piece index variant.
fn parse_have_payload(payload: &[u8]) -> Result<PeerWireMessageKind, String> {
let piece_index = read_u32(payload, "have", 0)?;
Ok(PeerWireMessageKind::Have(piece_index))
}
/// Parses a `request` or `cancel` payload into block coordinates.
fn parse_block_request_payload(
name: &str,
payload: &[u8],
) -> Result<PeerWireBlockRequestModel, String> {
if payload.len() != 12 {
return Err(format!(
"peer-wire {name} payload must be 12 bytes, got {}",
payload.len()
));
}
Ok(PeerWireBlockRequestModel {
piece_index: read_u32(payload, name, 0)?,
block_offset: read_u32(payload, name, 4)?,
block_length: read_u32(payload, name, 8)?,
})
}
/// Parses a `piece` payload into block coordinates plus data.
fn parse_piece_payload(payload: &[u8]) -> Result<PeerWirePieceBlockModel, String> {
if payload.len() < 8 {
return Err(format!(
"peer-wire piece payload must be at least 8 bytes, got {}",
payload.len()
));
}
Ok(PeerWirePieceBlockModel {
piece_index: read_u32(payload, "piece", 0)?,
block_offset: read_u32(payload, "piece", 4)?,
block: payload[8..].to_vec(),
})
}
/// Parses a `port` payload into the corresponding peer-wire message variant.
fn parse_port_payload(payload: &[u8]) -> Result<PeerWireMessageKind, String> {
if payload.len() != 2 {
return Err(format!(
"peer-wire port payload must be 2 bytes, got {}",
payload.len()
));
}
Ok(PeerWireMessageKind::Port(u16::from_be_bytes([
payload[0], payload[1],
])))
}
/// Parses an extension-protocol payload into the typed extension message variant.
fn parse_extension_payload(payload: &[u8]) -> Result<PeerWireMessageKind, String> {
let Some((&extension_message_id, rest)) = payload.split_first() else {
return Err("peer-wire extension payload must include extension message id".to_owned());
};
Ok(PeerWireMessageKind::Extension(
PeerWireExtensionMessageModel {
extension_message_id,
payload: rest.to_vec(),
},
))
}
/// Parses a synthetic `unknown:<id>` message-type label into a raw peer-wire id.
fn parse_unknown_message_id(message_type: &str) -> Option<u8> {
message_type
.strip_prefix("unknown:")
.and_then(|value| value.parse::<u8>().ok())
}
/// Encodes request or cancel block coordinates into peer-wire payload bytes.
fn encode_block_request_payload(request: &PeerWireBlockRequestModel) -> Vec<u8> {
let mut payload = Vec::with_capacity(12);
payload.extend_from_slice(&request.piece_index.to_be_bytes());
payload.extend_from_slice(&request.block_offset.to_be_bytes());
payload.extend_from_slice(&request.block_length.to_be_bytes());
payload
}
/// Encodes a piece block into peer-wire payload bytes.
fn encode_piece_payload(piece: &PeerWirePieceBlockModel) -> Vec<u8> {
let mut payload = Vec::with_capacity(8 + piece.block.len());
payload.extend_from_slice(&piece.piece_index.to_be_bytes());
payload.extend_from_slice(&piece.block_offset.to_be_bytes());
payload.extend_from_slice(&piece.block);
payload
}
/// Reads one big-endian `u32` from a peer-wire payload.
fn read_u32(payload: &[u8], name: &str, start: usize) -> Result<u32, String> {
let end = start + 4;
let bytes = payload
.get(start..end)
.ok_or_else(|| format!("peer-wire {name} payload truncated at byte offset {start}"))?;
Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
}
@@ -0,0 +1,122 @@
use super::{
PEER_WIRE_HANDSHAKE_PREFIX_LEN, PEER_WIRE_PROTOCOL_LEN, PEER_WIRE_PROTOCOL_NAME,
PeerWireHandshakeModel,
};
impl PeerWireHandshakeModel {
#[must_use]
/// Builds a handshake with all reserved bits cleared.
pub fn new(info_hash: [u8; 20], peer_id: [u8; 20]) -> Self {
Self {
reserved: [0; 8],
info_hash,
peer_id,
}
}
/// Returns a handshake with the extension-protocol bit enabled.
#[must_use]
pub fn with_extension_protocol_enabled(mut self) -> Self {
self.reserved[5] |= 0x10;
self
}
/// Returns a handshake with the DHT bit enabled.
#[must_use]
pub fn with_dht_enabled(mut self) -> Self {
self.reserved[7] |= 0x01;
self
}
/// Serializes the handshake to its peer-wire byte representation.
///
/// # Panics
///
/// Panics if the fixed peer-wire protocol name no longer fits into a single-byte
/// length prefix.
#[must_use]
pub fn serialize(&self) -> Vec<u8> {
let mut bytes = Vec::with_capacity(PEER_WIRE_HANDSHAKE_PREFIX_LEN + 19);
bytes.push(PEER_WIRE_PROTOCOL_LEN);
bytes.extend_from_slice(PEER_WIRE_PROTOCOL_NAME.as_bytes());
bytes.extend_from_slice(&self.reserved);
bytes.extend_from_slice(&self.info_hash);
bytes.extend_from_slice(&self.peer_id);
bytes
}
/// Parses one complete peer-wire handshake from `input`.
///
/// # Errors
///
/// Returns an error when the frame is truncated, malformed, or contains trailing bytes.
pub fn parse(input: &[u8]) -> Result<Self, String> {
let (handshake, consumed) = Self::parse_prefix(input)?;
if consumed != input.len() {
return Err("trailing bytes after peer-wire handshake".to_owned());
}
Ok(handshake)
}
/// Parses a peer-wire handshake prefix and returns the consumed byte count.
///
/// # Errors
///
/// Returns an error when the frame is truncated or malformed.
pub fn parse_prefix(input: &[u8]) -> Result<(Self, usize), String> {
let Some(&protocol_len_byte) = input.first() else {
return Err("truncated peer-wire handshake: missing protocol length".to_owned());
};
let protocol_len = usize::from(protocol_len_byte);
let total_len = PEER_WIRE_HANDSHAKE_PREFIX_LEN + protocol_len;
if input.len() < total_len {
return Err(format!(
"truncated peer-wire handshake: expected {total_len} bytes, got {}",
input.len()
));
}
let protocol = &input[1..=protocol_len];
if protocol_len != PEER_WIRE_PROTOCOL_NAME.len() {
return Err(format!(
"invalid peer-wire protocol length: expected {}, got {protocol_len}",
PEER_WIRE_PROTOCOL_NAME.len()
));
}
if protocol != PEER_WIRE_PROTOCOL_NAME.as_bytes() {
return Err("invalid peer-wire protocol header".to_owned());
}
let reserved_start = 1 + protocol_len;
let mut reserved = [0_u8; 8];
reserved.copy_from_slice(&input[reserved_start..reserved_start + 8]);
let info_hash_start = reserved_start + 8;
let mut info_hash = [0_u8; 20];
info_hash.copy_from_slice(&input[info_hash_start..info_hash_start + 20]);
let peer_id_start = info_hash_start + 20;
let mut peer_id = [0_u8; 20];
peer_id.copy_from_slice(&input[peer_id_start..peer_id_start + 20]);
Ok((
Self {
reserved,
info_hash,
peer_id,
},
total_len,
))
}
#[must_use]
/// Returns whether the extension-protocol reserved bit is enabled.
pub fn extension_protocol_enabled(&self) -> bool {
self.reserved[5] & 0x10 != 0
}
#[must_use]
/// Returns whether the DHT reserved bit is enabled.
pub fn dht_enabled(&self) -> bool {
self.reserved[7] & 0x01 != 0
}
}
@@ -0,0 +1,721 @@
use std::collections::BTreeMap;
use super::{
DhtAnnouncePeerQueryModel, DhtCompactNodeModel, DhtFindNodeResponseModel,
DhtGetPeersResponseModel, DhtMessageBody, DhtMessageModel, DhtQueryModel, DhtResponseModel,
PeerWireBitfieldModel, PeerWireBlockRequestModel, PeerWireExtensionHandshakeModel,
PeerWireExtensionMessageModel, PeerWireFrameHeaderModel, PeerWireHandshakeModel,
PeerWireMessageKind, PeerWireMessageModel, PeerWireMetadataMessageModel,
PeerWireMetadataMessageType, PeerWirePieceBlockModel, PeerWireUnknownMessageModel,
TorrentMessageModel, decode_compact_dht_nodes, encode_compact_dht_nodes,
parse_torrent_bootstrap, parse_torrent_metadata,
};
#[test]
fn dht_ping_query_roundtrip_serializes_and_parses() {
let message = DhtMessageModel::ping_query(b"aa".to_vec(), vec![0x11; 20]);
assert_eq!(message.method(), Some("ping"));
assert!(message.is_query());
let encoded = message.to_bencode_bytes();
let decoded = DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded ping should parse");
assert_eq!(decoded, message);
assert_eq!(decoded.transaction_id(), b"aa");
}
#[test]
fn dht_ping_response_roundtrip_serializes_and_parses() {
let message = DhtMessageModel::ping_response(b"pr".to_vec(), vec![0x44; 20]);
let encoded = message.to_bencode_bytes();
let decoded = DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded ping should parse");
assert_eq!(decoded, message);
assert!(matches!(
decoded.body,
DhtMessageBody::Response(DhtResponseModel::Ping(_))
));
}
#[test]
fn dht_find_node_query_roundtrip_serializes_and_parses() {
let message = DhtMessageModel::find_node_query(b"fn".to_vec(), vec![0x22; 20], vec![0x33; 20]);
assert_eq!(message.method(), Some("find_node"));
let encoded = message.to_bencode_bytes();
let decoded =
DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded find_node should parse");
assert_eq!(decoded, message);
}
#[test]
fn dht_announce_peer_query_roundtrip_serializes_and_parses() {
let message = DhtMessageModel::announce_peer_query(
b"ap".to_vec(),
vec![0x11; 20],
vec![0x22; 20],
6881,
b"tok".to_vec(),
true,
);
assert_eq!(message.method(), Some("announce_peer"));
let encoded = message.to_bencode_bytes();
let decoded =
DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded announce_peer should parse");
assert_eq!(decoded, message);
assert!(matches!(
decoded.body,
DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(DhtAnnouncePeerQueryModel {
implied_port: true,
port: 6881,
..
}))
));
}
#[test]
fn dht_get_peers_query_roundtrip_serializes_and_parses() {
let message = DhtMessageModel::get_peers_query(b"gp".to_vec(), vec![0x22; 20], vec![0x33; 20]);
assert_eq!(message.method(), Some("get_peers"));
assert!(matches!(
&message.body,
DhtMessageBody::Query(DhtQueryModel::GetPeers(_))
));
let encoded = message.to_bencode_bytes();
let decoded =
DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded get_peers should parse");
assert_eq!(decoded, message);
}
#[test]
fn dht_get_peers_response_roundtrip_preserves_nodes_values_and_token() {
let message = DhtMessageModel::get_peers_response(
b"r1".to_vec(),
vec![0x44; 20],
Some(b"tok".to_vec()),
Some(vec![0xaa, 0xbb, 0xcc, 0xdd]),
vec![vec![127, 0, 0, 1, 0x1a, 0xe1]],
);
let encoded = message.to_bencode_bytes();
let decoded =
DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded response should parse");
assert_eq!(decoded, message);
assert!(matches!(
decoded.body,
DhtMessageBody::Response(DhtResponseModel::GetPeers(DhtGetPeersResponseModel {
token: Some(_),
nodes: Some(_),
..
}))
));
}
#[test]
fn dht_find_node_response_roundtrip_preserves_compact_nodes() {
let nodes = vec![
DhtCompactNodeModel {
node_id: [0x11; 20],
address: [127, 0, 0, 1],
port: 6881,
},
DhtCompactNodeModel {
node_id: [0x22; 20],
address: [192, 0, 2, 1],
port: 51413,
},
];
let message = DhtMessageModel::find_node_response(b"fnr".to_vec(), vec![0x33; 20], nodes);
let encoded = message.to_bencode_bytes();
let decoded = DhtMessageModel::from_bencode_bytes(&encoded)
.expect("encoded find_node response should parse");
assert_eq!(decoded, message);
assert!(matches!(
decoded.body,
DhtMessageBody::Response(DhtResponseModel::FindNode(DhtFindNodeResponseModel {
nodes: ref parsed_nodes,
..
})) if parsed_nodes.len() == 2
));
}
#[test]
fn compact_dht_node_codec_roundtrip_serializes_bytes_in_network_order() {
let node = DhtCompactNodeModel {
node_id: [0x7f; 20],
address: [198, 51, 100, 7],
port: 51413,
};
let bytes = encode_compact_dht_nodes(std::slice::from_ref(&node));
assert_eq!(bytes.len(), 26);
let decoded = decode_compact_dht_nodes(&bytes).expect("compact node codec should parse");
assert_eq!(decoded, vec![node]);
}
#[test]
fn dht_error_roundtrip_serializes_and_parses() {
let message = DhtMessageModel::error_response(b"e1".to_vec(), 203, "protocol error");
let encoded = message.to_bencode_bytes();
let decoded =
DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded error should parse");
assert_eq!(decoded, message);
assert_eq!(decoded.method(), None);
}
#[test]
fn dht_parse_rejects_unsupported_query_method() {
let payload = b"d1:ad2:id20:aaaaaaaaaaaaaaaaaaaae1:q4:find1:t2:aa1:y1:qe";
let error =
DhtMessageModel::from_bencode_bytes(payload).expect_err("unsupported method should fail");
assert!(error.contains("unsupported dht query method"));
}
#[test]
fn parses_single_file_torrent_metadata() {
let torrent = br"d8:announce35:http://tracker.example.org/announce4:infod4:name10:ubuntu.iso12:piece lengthi16384e6:lengthi32768e6:pieces40:aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbee";
let metadata = parse_torrent_metadata(torrent).expect("torrent metadata should parse");
assert_eq!(metadata.info.name, "ubuntu.iso");
assert_eq!(metadata.info.piece_length, 16384);
assert_eq!(metadata.info.files.len(), 1);
assert_eq!(
metadata.announce.as_deref(),
Some("http://tracker.example.org/announce")
);
assert_eq!(metadata.trackers.len(), 1);
assert_eq!(metadata.pieces.len(), 2);
assert_eq!(
metadata
.info
.hash
.as_ref()
.map(|hash| hash.info_hash_hex.len()),
Some(40)
);
}
#[test]
fn parses_announce_list_tiers_with_stable_tier_indices() {
let torrent = br"d8:announce35:http://tracker.example.org/announce13:announce-listll30:udp://tier1-a.example.org:696935:http://tier1-b.example.org/announceel30:udp://tier2-a.example.org:6969ee4:infod6:lengthi4096e4:name8:mini.iso12:piece lengthi1024e6:pieces20:aaaaaaaaaaaaaaaaaaaaee";
let metadata = parse_torrent_metadata(torrent).expect("torrent metadata should parse");
assert_eq!(metadata.trackers.len(), 4);
assert_eq!(
metadata.trackers[0].url,
"http://tracker.example.org/announce"
);
assert_eq!(metadata.trackers[0].tier, Some(0));
assert_eq!(metadata.trackers[1].url, "udp://tier1-a.example.org:6969");
assert_eq!(metadata.trackers[1].tier, Some(1));
assert_eq!(
metadata.trackers[2].url,
"http://tier1-b.example.org/announce"
);
assert_eq!(metadata.trackers[2].tier, Some(1));
assert_eq!(metadata.trackers[3].url, "udp://tier2-a.example.org:6969");
assert_eq!(metadata.trackers[3].tier, Some(2));
}
#[test]
fn parses_multi_file_paths_and_piece_offsets() {
let torrent = br"d8:announce35:http://tracker.example.org/announce4:infod5:filesld6:lengthi123e4:pathl4:dir110:file-a.bineed6:lengthi200e4:pathl4:dir24:subd10:file-b.bineed6:lengthi45e4:pathl10:readme.txteee4:name6:bundle12:piece lengthi128e6:pieces60:aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbccccccccccccccccccccee";
let metadata = parse_torrent_metadata(torrent).expect("multi-file torrent should parse");
assert_eq!(metadata.info.files.len(), 3);
assert_eq!(metadata.info.files[0].path, "dir1/file-a.bin");
assert_eq!(metadata.info.files[0].length, 123);
assert_eq!(metadata.info.files[0].piece_offset, Some(0));
assert_eq!(metadata.info.files[1].path, "dir2/subd/file-b.bin");
assert_eq!(metadata.info.files[1].length, 200);
assert_eq!(metadata.info.files[1].piece_offset, Some(123));
assert_eq!(metadata.info.files[2].path, "readme.txt");
assert_eq!(metadata.info.files[2].length, 45);
assert_eq!(metadata.info.files[2].piece_offset, Some(323));
assert_eq!(metadata.total_length(), 368);
}
#[test]
fn parses_dht_nodes_from_nodes_list_when_present() {
let torrent = br"d8:announce35:http://tracker.example.org/announce5:nodesll17:router.bittorrenti6881eel14:node.local.lani51413eee4:infod6:lengthi2048e4:name8:node.iso12:piece lengthi1024e6:pieces20:aaaaaaaaaaaaaaaaaaaaee";
let metadata = parse_torrent_metadata(torrent).expect("torrent with nodes should parse");
assert_eq!(metadata.dht_nodes.len(), 2);
assert_eq!(metadata.dht_nodes[0], "router.bittorrent:6881");
assert_eq!(metadata.dht_nodes[1], "node.local.lan:51413");
}
#[test]
fn torrent_bootstrap_exposes_magnet_info_hash_and_dht_models() {
let torrent = br"d8:announce35:http://tracker.example.org/announce13:announce-listll30:udp://tier1-a.example.org:696935:http://tier1-b.example.org/announceee5:nodesll17:router.bittorrenti6881eel14:node.local.lani51413eee4:infod6:lengthi2048e4:name8:node.iso12:piece lengthi1024e6:pieces40:aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbee";
let bootstrap = parse_torrent_bootstrap(torrent).expect("torrent bootstrap should parse");
assert_eq!(bootstrap.metadata.info.name, "node.iso");
assert_eq!(
bootstrap.info_hash_hex,
bootstrap
.metadata
.info
.hash
.as_ref()
.expect("torrent hash should exist")
.info_hash_hex
);
assert_eq!(bootstrap.info_hash_bytes.len(), 20);
assert_eq!(
bootstrap.magnet.trackers,
vec![
"http://tracker.example.org/announce".to_owned(),
"udp://tier1-a.example.org:6969".to_owned(),
"http://tier1-b.example.org/announce".to_owned(),
]
);
assert_eq!(bootstrap.dht_nodes[0].to_spec(), "router.bittorrent:6881");
assert_eq!(bootstrap.dht_nodes[1].to_spec(), "node.local.lan:51413");
}
#[test]
fn dht_get_peers_response_helpers_decode_peer_values_and_nodes() {
let response = DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x11; 20],
Some(b"tok".to_vec()),
Some(vec![
0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, 0xa0, 0xb0, 0xc0, 0xd0, 0xe0,
0xf0, 0x00, 0x01, 0x02, 0x03, 0x04, 192, 0, 2, 10, 0x1a, 0xe1,
]),
vec![vec![198, 51, 100, 9, 0xc8, 0xd5]],
);
let DhtMessageBody::Response(DhtResponseModel::GetPeers(payload)) = response.body else {
panic!("expected get_peers response");
};
let peers = payload
.peer_contacts()
.expect("compact peers should decode");
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].ip, "198.51.100.9");
assert_eq!(peers[0].port, 51413);
let nodes = payload.dht_nodes().expect("compact nodes should decode");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].node_id, "102030405060708090a0b0c0d0e0f00001020304");
assert_eq!(nodes[0].to_spec(), "192.0.2.10:6881");
}
#[test]
fn peer_wire_handshake_roundtrip_preserves_reserved_bits_and_ids() {
let handshake = PeerWireHandshakeModel {
reserved: [0, 0, 0, 0, 0, 0x10, 0, 0x01],
info_hash: [0x11; 20],
peer_id: [0x22; 20],
};
let bytes = handshake.serialize();
let parsed =
PeerWireHandshakeModel::parse(&bytes).expect("handshake bytes should parse cleanly");
assert_eq!(parsed, handshake);
assert!(parsed.extension_protocol_enabled());
assert!(parsed.dht_enabled());
}
#[test]
fn peer_wire_handshake_rejects_truncated_and_invalid_protocol_bytes() {
let truncated = vec![19, b'B', b'i'];
assert!(
PeerWireHandshakeModel::parse(&truncated)
.expect_err("truncated handshake should fail")
.contains("truncated")
);
let mut invalid = PeerWireHandshakeModel {
reserved: [0; 8],
info_hash: [1; 20],
peer_id: [2; 20],
}
.serialize();
invalid[1] = b'X';
assert!(
PeerWireHandshakeModel::parse(&invalid)
.expect_err("invalid protocol name should fail")
.contains("protocol")
);
}
#[test]
fn peer_wire_keepalive_roundtrip_uses_zero_length_frame() {
let message = TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::KeepAlive);
let bytes = message
.serialize_peer_wire_frame()
.expect("keepalive should serialize");
assert_eq!(bytes, vec![0, 0, 0, 0]);
let parsed =
TorrentMessageModel::parse_peer_wire_frame_exact(&bytes).expect("keepalive should parse");
assert_eq!(parsed, message);
assert_eq!(parsed.peer_wire_kind(), Ok(PeerWireMessageKind::KeepAlive));
}
#[test]
fn peer_wire_inspect_frame_reports_message_id_payload_len_and_consumed_bytes() {
let keepalive_header = TorrentMessageModel::inspect_peer_wire_frame(&[0, 0, 0, 0])
.expect("keepalive frame header should parse");
assert_eq!(
keepalive_header,
(
PeerWireFrameHeaderModel {
message_id: None,
payload_len: 0,
},
4
)
);
let request = TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Request(
PeerWireBlockRequestModel {
piece_index: 1,
block_offset: 2,
block_length: 16_384,
},
));
let mut request_frame = request
.serialize_peer_wire_frame()
.expect("request frame should serialize");
request_frame.extend_from_slice(&[0x99, 0x88, 0x77]);
let request_header = TorrentMessageModel::inspect_peer_wire_frame(&request_frame)
.expect("request frame header should parse");
assert_eq!(
request_header,
(
PeerWireFrameHeaderModel {
message_id: Some(6),
payload_len: 12,
},
17
)
);
}
#[test]
fn peer_wire_inspect_frame_rejects_truncated_prefix_or_payload() {
assert!(
TorrentMessageModel::inspect_peer_wire_frame(&[0, 0, 0])
.expect_err("missing length prefix should fail")
.contains("missing length prefix")
);
assert!(
TorrentMessageModel::inspect_peer_wire_frame(&[0, 0, 0, 1])
.expect_err("missing message id should fail")
.contains("truncated")
);
}
#[test]
fn peer_wire_control_messages_roundtrip_through_wrapper_surface() {
let cases = [
PeerWireMessageKind::Choke,
PeerWireMessageKind::Unchoke,
PeerWireMessageKind::Interested,
PeerWireMessageKind::NotInterested,
];
for kind in cases {
let wire = PeerWireMessageModel::new(
Some([0x44; 20]),
TorrentMessageModel::from_peer_wire_kind(kind.clone()),
);
let bytes = wire
.serialize_frame()
.expect("control peer-wire frame should serialize");
let parsed = PeerWireMessageModel::parse_frame_exact(&bytes)
.expect("control peer-wire frame should parse");
assert_eq!(parsed.peer_id, None);
assert_eq!(parsed.message.peer_wire_kind(), Ok(kind));
}
}
#[test]
fn peer_wire_bitfield_helpers_pack_bits_msb_first() {
let flags = [
true, false, true, true, false, false, false, true, true, false,
];
let bitfield = PeerWireBitfieldModel::from_piece_flags(&flags);
assert_eq!(bitfield.bytes, vec![0b1011_0001, 0b1000_0000]);
assert_eq!(bitfield.piece_capacity(), 16);
assert!(bitfield.has_piece(0));
assert!(bitfield.has_piece(8));
assert!(!bitfield.has_piece(9));
assert_eq!(bitfield.to_piece_flags(flags.len()), flags);
let message =
TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Bitfield(bitfield.clone()));
let roundtrip = TorrentMessageModel::parse_peer_wire_frame_exact(
&message
.serialize_peer_wire_frame()
.expect("bitfield should serialize"),
)
.expect("bitfield should roundtrip");
assert_eq!(
roundtrip.peer_wire_kind(),
Ok(PeerWireMessageKind::Bitfield(bitfield))
);
}
#[test]
fn peer_wire_have_request_and_cancel_roundtrip() {
let have = TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Have(77));
let request = PeerWireBlockRequestModel {
piece_index: 7,
block_offset: 16_384,
block_length: 4_096,
};
let request_message =
TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Request(request));
let cancel_message =
TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Cancel(request));
assert_eq!(
TorrentMessageModel::parse_peer_wire_frame_exact(
&have
.serialize_peer_wire_frame()
.expect("have should serialize"),
)
.expect("have should parse")
.peer_wire_kind(),
Ok(PeerWireMessageKind::Have(77))
);
assert_eq!(
TorrentMessageModel::parse_peer_wire_frame_exact(
&request_message
.serialize_peer_wire_frame()
.expect("request should serialize"),
)
.expect("request should parse")
.peer_wire_kind(),
Ok(PeerWireMessageKind::Request(request))
);
assert_eq!(
TorrentMessageModel::parse_peer_wire_frame_exact(
&cancel_message
.serialize_peer_wire_frame()
.expect("cancel should serialize"),
)
.expect("cancel should parse")
.peer_wire_kind(),
Ok(PeerWireMessageKind::Cancel(request))
);
}
#[test]
fn peer_wire_piece_port_extension_and_unknown_roundtrip() {
let piece = PeerWirePieceBlockModel {
piece_index: 5,
block_offset: 32_768,
block: b"block-data".to_vec(),
};
let extension = PeerWireExtensionMessageModel {
extension_message_id: 3,
payload: b"ut_metadata".to_vec(),
};
let unknown = PeerWireUnknownMessageModel {
message_id: 99,
payload: vec![9, 8, 7, 6],
};
let piece_message =
TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Piece(piece.clone()));
let port_message = TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Port(51413));
let extension_message =
TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Extension(extension.clone()));
let unknown_message =
TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Unknown(unknown.clone()));
assert_eq!(
TorrentMessageModel::parse_peer_wire_frame_exact(
&piece_message
.serialize_peer_wire_frame()
.expect("piece should serialize"),
)
.expect("piece should parse")
.peer_wire_kind(),
Ok(PeerWireMessageKind::Piece(piece))
);
assert_eq!(
TorrentMessageModel::parse_peer_wire_frame_exact(
&port_message
.serialize_peer_wire_frame()
.expect("port should serialize"),
)
.expect("port should parse")
.peer_wire_kind(),
Ok(PeerWireMessageKind::Port(51413))
);
assert_eq!(
TorrentMessageModel::parse_peer_wire_frame_exact(
&extension_message
.serialize_peer_wire_frame()
.expect("extension should serialize"),
)
.expect("extension should parse")
.peer_wire_kind(),
Ok(PeerWireMessageKind::Extension(extension))
);
assert_eq!(
TorrentMessageModel::parse_peer_wire_frame_exact(
&unknown_message
.serialize_peer_wire_frame()
.expect("unknown should serialize"),
)
.expect("unknown should parse")
.peer_wire_kind(),
Ok(PeerWireMessageKind::Unknown(unknown))
);
}
#[test]
fn extension_handshake_and_ut_metadata_messages_roundtrip() {
let handshake = PeerWireExtensionHandshakeModel {
extensions: BTreeMap::from([
("ut_metadata".to_owned(), 3_u8),
("ut_pex".to_owned(), 1_u8),
]),
client_name: Some("aria2-rust-pro".to_owned()),
metadata_size: Some(32_768),
request_queue: Some(32),
};
let handshake_message = handshake.to_peer_wire_message();
assert_eq!(handshake_message.extension_message_id, 0);
let parsed_handshake =
PeerWireExtensionHandshakeModel::from_peer_wire_message(&handshake_message)
.expect("extended handshake should parse");
assert_eq!(parsed_handshake, handshake);
assert_eq!(parsed_handshake.ut_metadata_id(), Some(3));
assert_eq!(parsed_handshake.metadata_piece_count(), Some(2));
let request = PeerWireMetadataMessageModel::request(7);
let parsed_request =
PeerWireMetadataMessageModel::from_peer_wire_message(&request.to_peer_wire_message(3), 3)
.expect("metadata request should parse");
assert_eq!(
parsed_request.message_type,
PeerWireMetadataMessageType::Request
);
assert_eq!(parsed_request.piece, 7);
assert!(parsed_request.payload.is_empty());
let data = PeerWireMetadataMessageModel::data(0, 11, b"piece-bytes".to_vec());
let parsed_data =
PeerWireMetadataMessageModel::from_peer_wire_message(&data.to_peer_wire_message(3), 3)
.expect("metadata data should parse");
assert_eq!(parsed_data.message_type, PeerWireMetadataMessageType::Data);
assert_eq!(parsed_data.piece, 0);
assert_eq!(parsed_data.total_size, Some(11));
assert_eq!(parsed_data.payload, b"piece-bytes");
let reject = PeerWireMetadataMessageModel::reject(4);
let parsed_reject =
PeerWireMetadataMessageModel::from_peer_wire_message(&reject.to_peer_wire_message(3), 3)
.expect("metadata reject should parse");
assert_eq!(
parsed_reject.message_type,
PeerWireMetadataMessageType::Reject
);
assert_eq!(parsed_reject.piece, 4);
}
#[test]
fn extended_handshake_treats_zero_ut_metadata_id_as_disabled() {
let handshake = PeerWireExtensionHandshakeModel {
extensions: BTreeMap::from([
("ut_metadata".to_owned(), 0_u8),
("ut_pex".to_owned(), 1_u8),
]),
client_name: Some("aria2-rust-pro".to_owned()),
metadata_size: Some(16_384),
request_queue: Some(8),
};
let parsed = PeerWireExtensionHandshakeModel::from_bencode_bytes(&handshake.to_bencode_bytes())
.expect("extended handshake should parse");
assert_eq!(parsed.extensions.get("ut_metadata"), Some(&0));
assert_eq!(parsed.ut_metadata_id(), None);
assert_eq!(parsed.metadata_piece_count(), Some(1));
}
#[test]
fn ut_metadata_data_messages_reject_out_of_range_piece_indexes() {
let invalid = b"d8:msg_typei1e5:piecei2e10:total_sizei16384eepayload".to_vec();
assert!(
PeerWireMetadataMessageModel::from_bencode_bytes(&invalid)
.expect_err("piece index beyond metadata size should fail")
.contains("piece")
);
}
#[test]
fn ut_metadata_data_messages_reject_payload_lengths_that_do_not_match_piece_geometry() {
let invalid = b"d8:msg_typei1e5:piecei0e10:total_sizei16385ee".to_vec();
assert!(
PeerWireMetadataMessageModel::from_bencode_bytes(&invalid)
.expect_err("empty payload for non-empty piece should fail")
.contains("payload")
);
}
#[test]
fn ut_metadata_request_and_reject_messages_reject_total_size_fields() {
let request_with_total_size = b"d8:msg_typei0e5:piecei0e10:total_sizei16384ee".to_vec();
assert!(
PeerWireMetadataMessageModel::from_bencode_bytes(&request_with_total_size)
.expect_err("request total_size should fail")
.contains("total_size")
);
let reject_with_total_size = b"d8:msg_typei2e5:piecei1e10:total_sizei16384ee".to_vec();
assert!(
PeerWireMetadataMessageModel::from_bencode_bytes(&reject_with_total_size)
.expect_err("reject total_size should fail")
.contains("total_size")
);
}
#[test]
fn peer_wire_parse_rejects_truncated_and_invalid_payload_shapes() {
let truncated = [0, 0, 0, 13, 6, 0, 0, 0, 1, 0, 0];
assert!(
TorrentMessageModel::parse_peer_wire_frame_exact(&truncated)
.expect_err("truncated request frame should fail")
.contains("truncated")
);
let invalid_have = [0, 0, 0, 4, 4, 0, 0, 0];
assert!(
TorrentMessageModel::parse_peer_wire_frame_exact(&invalid_have)
.expect_err("invalid have frame should fail")
.contains("have")
);
let invalid_extension = [0, 0, 0, 1, 20];
assert!(
TorrentMessageModel::parse_peer_wire_frame_exact(&invalid_extension)
.expect_err("extension frame missing ext id should fail")
.contains("extension")
);
}
@@ -0,0 +1,69 @@
use super::bencode::BencodeValue;
/// Decodes a 40-character hexadecimal string into a fixed-width 20-byte array.
pub(super) fn decode_hex_20_array(input: &str) -> Result<[u8; 20], String> {
if input.len() != 40 {
return Err(format!(
"expected 40 hex characters for 20-byte info-hash, got {}",
input.len()
));
}
let mut out = [0_u8; 20];
for (index, chunk) in input.as_bytes().chunks_exact(2).enumerate() {
let hi = decode_hex_nibble(chunk[0])
.ok_or_else(|| "info-hash contains non-hex characters".to_owned())?;
let lo = decode_hex_nibble(chunk[1])
.ok_or_else(|| "info-hash contains non-hex characters".to_owned())?;
out[index] = (hi << 4) | lo;
}
Ok(out)
}
/// Decodes one ASCII hexadecimal nibble.
const fn decode_hex_nibble(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
/// Decodes torrent byte strings into owned lossy UTF-8 text.
pub(super) fn bytes_to_string(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
/// Converts one torrent bencode value into human-readable string form.
pub(super) fn value_to_string(value: &BencodeValue) -> String {
match value {
BencodeValue::Bytes(bytes) => bytes_to_string(bytes),
BencodeValue::Int(value) => value.to_string(),
BencodeValue::List(values) => values
.iter()
.map(value_to_string)
.collect::<Vec<_>>()
.join(","),
BencodeValue::Dict(map) => map
.iter()
.map(|(key, value)| format!("{key}={}", value_to_string(value)))
.collect::<Vec<_>>()
.join("&"),
}
}
/// Hex-encodes raw torrent bytes using lowercase hexadecimal.
pub(super) fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &byte in bytes {
out.push(char::from(HEX[usize::from(byte >> 4)]));
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
out
}
/// Saturates an integer-like torrent length field into `u64`.
pub(super) fn i64_to_u64(value: i64) -> u64 {
u64::try_from(value.max(0)).unwrap_or(u64::MAX)
}
@@ -0,0 +1,47 @@
//! Tracker parsing, scrape models, and tracker transport implementations.
#![forbid(unsafe_code)]
pub(super) use std::{
collections::BTreeMap,
fmt::{Display, Formatter},
sync::atomic::{AtomicU32, Ordering},
};
pub(super) use reqwest::blocking::Client;
pub(super) use crate::{
torrent::{DhtMessageModel, TorrentPeerModel},
transport::{TransportError, TransportErrorKind},
};
/// Tracker parsing and validation error model.
mod error;
/// Bencode parsing plus tracker wire-format normalization helpers.
mod parsing;
/// Shared tracker and DHT request-response protocol models.
mod request_response;
/// Live reqwest-backed HTTP tracker transport.
mod reqwest_transport;
/// UDP tracker message types and transport helpers.
mod udp;
pub use self::error::TrackerParseError;
pub use self::request_response::{
DhtNodeModel, DhtTransport, TrackerPeerListModel, TrackerRequestModel, TrackerResponseModel,
TrackerScrapeFileModel, TrackerScrapeModel, TrackerTransport,
};
pub use self::reqwest_transport::ReqwestTrackerTransport;
pub use self::udp::{
UDP_TRACKER_PROTOCOL_ID, UdpTrackerAction, UdpTrackerAnnounceEvent, UdpTrackerAnnounceRequest,
UdpTrackerAnnounceResponse, UdpTrackerConnectRequest, UdpTrackerConnectResponse,
UdpTrackerResponseHeader, UdpTrackerScrapeRequest, UdpTrackerScrapeResponse,
UdpTrackerScrapeStats, UdpTrackerTransactionId,
};
#[cfg(test)]
fn hex_encode(bytes: &[u8]) -> String {
parsing::hex_encode(bytes)
}
#[cfg(test)]
mod tracker_tests;
@@ -0,0 +1,44 @@
use super::{Display, Formatter};
/// Errors raised while parsing tracker announce, scrape, or UDP payloads.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TrackerParseError {
/// The tracker bencode payload was malformed.
InvalidBencode(String),
/// A required field was missing from the payload.
MissingField(&'static str),
/// A hex-encoded value was malformed.
InvalidHex(String),
/// A peer entry was malformed.
InvalidPeer(String),
/// A UDP tracker packet was malformed.
InvalidUdpPacket(String),
/// A UDP tracker action code was unknown.
InvalidUdpAction(u32),
/// The response transaction identifier differed from the request identifier.
TransactionIdMismatch {
/// Transaction identifier the caller expected to receive.
expected: u32,
/// Transaction identifier that was actually received.
actual: u32,
},
}
impl Display for TrackerParseError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidBencode(reason) => write!(f, "invalid tracker bencode: {reason}"),
Self::MissingField(field) => write!(f, "missing tracker field: {field}"),
Self::InvalidHex(value) => write!(f, "invalid hex string: {value}"),
Self::InvalidPeer(reason) => write!(f, "invalid peer entry: {reason}"),
Self::InvalidUdpPacket(reason) => write!(f, "invalid udp tracker packet: {reason}"),
Self::InvalidUdpAction(action) => write!(f, "invalid udp tracker action id: {action}"),
Self::TransactionIdMismatch { expected, actual } => write!(
f,
"udp tracker transaction id mismatch: expected {expected}, got {actual}"
),
}
}
}
impl std::error::Error for TrackerParseError {}
@@ -0,0 +1,491 @@
use super::{BTreeMap, TorrentPeerModel};
use crate::tracker::{TrackerParseError, TrackerScrapeFileModel, TrackerScrapeModel};
#[derive(Clone, Debug, PartialEq, Eq)]
/// Internal bencode value representation used while parsing tracker payloads.
pub(super) enum BencodeValue {
/// Signed integer literal.
Int(i64),
/// Raw byte string payload.
Bytes(Vec<u8>),
/// Ordered list of nested bencode values.
List(Vec<Self>),
/// Dictionary keyed by raw tracker bytes.
Dict(BencodeDict),
}
/// Internal tracker bencode dictionary keyed by raw byte strings.
pub(super) type BencodeDict = BTreeMap<Vec<u8>, BencodeValue>;
/// Parses a tracker bencode payload whose root value must be a dictionary.
pub(super) fn parse_bencode(input: &[u8]) -> Result<BencodeDict, TrackerParseError> {
let (value, next) = parse_value(input, 0)?;
if next != input.len() {
return Err(TrackerParseError::InvalidBencode(
"trailing data after root value".to_owned(),
));
}
match value {
BencodeValue::Dict(map) => Ok(map),
_ => Err(TrackerParseError::InvalidBencode(
"tracker response root must be a dictionary".to_owned(),
)),
}
}
/// Parses the `peers` field from a tracker response dictionary.
pub(super) fn parse_peer_list(
root: &BencodeDict,
) -> Result<Vec<TorrentPeerModel>, TrackerParseError> {
match root.get(b"peers".as_slice()) {
Some(BencodeValue::Bytes(bytes)) => parse_compact_peers(bytes),
Some(BencodeValue::List(entries)) => entries
.iter()
.map(parse_peer_dict)
.collect::<Result<Vec<_>, _>>(),
Some(_) => Err(TrackerParseError::InvalidBencode(
"peers must be bytes or list".to_owned(),
)),
None => Ok(Vec::new()),
}
}
/// Parses a compact IPv4 peer blob into tracker peer models.
pub(super) fn parse_compact_peers_ipv4(
bytes: &[u8],
) -> Result<Vec<TorrentPeerModel>, TrackerParseError> {
parse_compact_peers_with_stride(bytes, 6, |chunk| {
let ip = format!("{}.{}.{}.{}", chunk[0], chunk[1], chunk[2], chunk[3]);
let port = u16::from_be_bytes([chunk[4], chunk[5]]);
TorrentPeerModel {
peer_id: None,
ip,
port,
client_name: None,
interested: false,
choked: false,
}
})
}
/// Extracts optional scrape metadata from an announce-style tracker dictionary.
pub(super) fn parse_scrape_section(root: &BencodeDict) -> Option<TrackerScrapeModel> {
parse_scrape_section_from_root(root)
}
/// Extracts scrape metadata from a `files` scrape dictionary when present.
pub(super) fn parse_scrape_section_from_root(root: &BencodeDict) -> Option<TrackerScrapeModel> {
let files = match root.get(b"files".as_slice()) {
Some(BencodeValue::Dict(files)) => files
.iter()
.filter_map(|(info_hash, value)| match value {
BencodeValue::Dict(stats) => Some(TrackerScrapeFileModel {
info_hash: hex_encode(info_hash),
complete: dict_get_int(stats, "complete").map(i64_to_u32),
downloaded: dict_get_int(stats, "downloaded").map(i64_to_u32),
incomplete: dict_get_int(stats, "incomplete").map(i64_to_u32),
}),
_ => None,
})
.collect::<Vec<_>>(),
_ => Vec::new(),
};
if files.is_empty() {
let complete = dict_get_int(root, "complete").map(i64_to_u32);
let downloaded = dict_get_int(root, "downloaded").map(i64_to_u32);
let incomplete = dict_get_int(root, "incomplete").map(i64_to_u32);
if complete.is_none() && downloaded.is_none() && incomplete.is_none() {
return None;
}
return Some(TrackerScrapeModel {
complete,
downloaded,
incomplete,
files,
});
}
Some(TrackerScrapeModel {
complete: dict_get_int(root, "complete").map(i64_to_u32),
downloaded: dict_get_int(root, "downloaded").map(i64_to_u32),
incomplete: dict_get_int(root, "incomplete").map(i64_to_u32),
files,
})
}
/// Looks up a raw byte-string field inside a tracker bencode dictionary.
pub(super) fn dict_get_bytes<'a>(dict: &'a BencodeDict, key: &str) -> Option<&'a [u8]> {
match dict.get(key.as_bytes()) {
Some(BencodeValue::Bytes(bytes)) => Some(bytes.as_slice()),
_ => None,
}
}
/// Looks up an integer field inside a tracker bencode dictionary.
pub(super) fn dict_get_int(dict: &BencodeDict, key: &str) -> Option<i64> {
match dict.get(key.as_bytes()) {
Some(BencodeValue::Int(value)) => Some(*value),
_ => None,
}
}
/// Decodes tracker bytes into owned lossy UTF-8 text.
pub(super) fn bytes_to_string(value: &[u8]) -> String {
String::from_utf8_lossy(value).into_owned()
}
/// Hex-encodes a raw tracker info-hash or peer-id byte slice.
pub(super) fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &byte in bytes {
out.push(char::from(HEX[usize::from(byte >> 4)]));
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
out
}
/// Derives the scrape URL from an announce URL using the conventional path rewrite.
pub(super) fn tracker_scrape_url(announce_url: &str) -> String {
if let Some(stripped) = announce_url.strip_suffix("announce.php") {
return format!("{stripped}scrape.php");
}
if let Some(stripped) = announce_url.strip_suffix("announce") {
return format!("{stripped}scrape");
}
announce_url.to_owned()
}
/// Parses a `host:port` or `[ipv6]:port` endpoint spec into its address and port components.
pub(super) fn parse_endpoint_spec(
raw: &str,
label: &'static str,
) -> Result<(String, u16), TrackerParseError> {
if let Some(rest) = raw.strip_prefix('[') {
let (address, port_raw) = rest.split_once("]:").ok_or_else(|| {
TrackerParseError::InvalidPeer(format!(
"{label} entry must use [ipv6]:port format when brackets are present"
))
})?;
let port = port_raw.parse::<u16>().map_err(|_| {
TrackerParseError::InvalidPeer(format!("{label} port must be a valid u16"))
})?;
return Ok((address.to_owned(), port));
}
let (address, port_raw) = raw.rsplit_once(':').ok_or_else(|| {
TrackerParseError::InvalidPeer(format!("{label} entry must use host:port format"))
})?;
if address.is_empty() {
return Err(TrackerParseError::InvalidPeer(format!(
"{label} address must not be empty"
)));
}
let port = port_raw
.parse::<u16>()
.map_err(|_| TrackerParseError::InvalidPeer(format!("{label} port must be a valid u16")))?;
Ok((address.to_owned(), port))
}
/// Formats an endpoint spec while preserving bracketed IPv6 output.
pub(super) fn format_endpoint_spec(address: &str, port: u16) -> String {
if address.contains(':') && !address.starts_with('[') && !address.ends_with(']') {
format!("[{address}]:{port}")
} else {
format!("{address}:{port}")
}
}
/// Builds the final HTTP tracker announce URL and query string.
pub(super) fn build_url(
base_url: &str,
query_pairs: &[(String, String)],
) -> Result<String, TrackerParseError> {
let mut out = String::from(base_url);
if !out.contains('?') {
out.push('?');
} else if !out.ends_with('?') && !out.ends_with('&') {
out.push('&');
}
for (index, (key, value)) in query_pairs.iter().enumerate() {
if index > 0 {
out.push('&');
}
out.push_str(key);
out.push('=');
out.push_str(&percent_encode_tracker_value(key, value)?);
}
Ok(out)
}
/// Decodes a 40-character hex string into a 20-byte tracker field.
pub(super) fn decode_hex_20(input: &str) -> Result<[u8; 20], TrackerParseError> {
let text = input.trim();
if text.len() != 40 {
return Err(TrackerParseError::InvalidHex(input.to_owned()));
}
let mut out = [0_u8; 20];
let bytes = text.as_bytes();
for (index, slot) in out.iter_mut().enumerate() {
let hi = hex_value(bytes[index * 2])
.ok_or_else(|| TrackerParseError::InvalidHex(input.to_owned()))?;
let lo = hex_value(bytes[index * 2 + 1])
.ok_or_else(|| TrackerParseError::InvalidHex(input.to_owned()))?;
*slot = (hi << 4) | lo;
}
Ok(out)
}
/// Copies a raw 20-byte tracker field into a fixed-size array.
pub(super) fn bytes_to_20(bytes: &[u8]) -> Result<[u8; 20], TrackerParseError> {
if bytes.len() != 20 {
return Err(TrackerParseError::InvalidPeer(
"peer id must be 20 bytes".to_owned(),
));
}
let mut out = [0_u8; 20];
out.copy_from_slice(bytes);
Ok(out)
}
/// Saturates an integer-like scrape field into `u32`.
pub(super) fn i64_to_u32(value: i64) -> u32 {
u32::try_from(value.max(0)).unwrap_or(u32::MAX)
}
/// Saturates an integer-like port field into `u16`.
pub(super) fn i64_to_u16(value: i64) -> u16 {
u16::try_from(value.max(0)).unwrap_or(u16::MAX)
}
/// Parses one bencode value and returns the decoded value plus the next byte index.
fn parse_value(input: &[u8], index: usize) -> Result<(BencodeValue, usize), TrackerParseError> {
match input.get(index).copied() {
Some(b'i') => parse_int(input, index),
Some(b'l') => parse_list(input, index),
Some(b'd') => parse_dict(input, index),
Some(b'0'..=b'9') => {
let (bytes, next) = parse_bytes(input, index)?;
Ok((BencodeValue::Bytes(bytes), next))
}
_ => Err(TrackerParseError::InvalidBencode(
"invalid bencode value".to_owned(),
)),
}
}
/// Parses one bencode integer starting at `index`.
fn parse_int(input: &[u8], index: usize) -> Result<(BencodeValue, usize), TrackerParseError> {
let mut cursor = index + 1;
let start = cursor;
while cursor < input.len() && input[cursor] != b'e' {
cursor += 1;
}
if cursor >= input.len() {
return Err(TrackerParseError::InvalidBencode(
"unterminated integer".to_owned(),
));
}
let number = std::str::from_utf8(&input[start..cursor])
.map_err(|_| TrackerParseError::InvalidBencode("invalid integer utf-8".to_owned()))?
.parse::<i64>()
.map_err(|_| TrackerParseError::InvalidBencode("invalid integer value".to_owned()))?;
Ok((BencodeValue::Int(number), cursor + 1))
}
/// Parses one bencode list starting at `index`.
fn parse_list(input: &[u8], index: usize) -> Result<(BencodeValue, usize), TrackerParseError> {
let mut cursor = index + 1;
let mut values = Vec::new();
while cursor < input.len() {
if input[cursor] == b'e' {
return Ok((BencodeValue::List(values), cursor + 1));
}
let (value, next) = parse_value(input, cursor)?;
values.push(value);
cursor = next;
}
Err(TrackerParseError::InvalidBencode(
"unterminated list".to_owned(),
))
}
/// Parses one bencode dictionary starting at `index`.
fn parse_dict(input: &[u8], index: usize) -> Result<(BencodeValue, usize), TrackerParseError> {
let mut cursor = index + 1;
let mut map = BTreeMap::new();
while cursor < input.len() {
if input[cursor] == b'e' {
return Ok((BencodeValue::Dict(map), cursor + 1));
}
let (key_bytes, next) = parse_bytes(input, cursor)?;
cursor = next;
let (value, next) = parse_value(input, cursor)?;
cursor = next;
map.insert(key_bytes, value);
}
Err(TrackerParseError::InvalidBencode(
"unterminated dictionary".to_owned(),
))
}
/// Parses one bencode byte string starting at `index`.
fn parse_bytes(input: &[u8], index: usize) -> Result<(Vec<u8>, usize), TrackerParseError> {
let mut cursor = index;
while cursor < input.len() && input[cursor].is_ascii_digit() {
cursor += 1;
}
if cursor == index || cursor >= input.len() || input[cursor] != b':' {
return Err(TrackerParseError::InvalidBencode(
"invalid byte string".to_owned(),
));
}
let len = std::str::from_utf8(&input[index..cursor])
.map_err(|_| TrackerParseError::InvalidBencode("invalid byte string length".to_owned()))?
.parse::<usize>()
.map_err(|_| TrackerParseError::InvalidBencode("invalid byte string length".to_owned()))?;
let start = cursor + 1;
let end = start.saturating_add(len);
if end > input.len() {
return Err(TrackerParseError::InvalidBencode(
"truncated byte string".to_owned(),
));
}
Ok((input[start..end].to_vec(), end))
}
/// Converts one tracker peer dictionary entry into the higher-level peer model.
fn parse_peer_dict(value: &BencodeValue) -> Result<TorrentPeerModel, TrackerParseError> {
let BencodeValue::Dict(dict) = value else {
return Err(TrackerParseError::InvalidPeer(
"peer entry must be a dictionary".to_owned(),
));
};
let ip = dict_get_bytes(dict, "ip")
.map(bytes_to_string)
.ok_or(TrackerParseError::MissingField("peer.ip"))?;
let port =
i64_to_u16(dict_get_int(dict, "port").ok_or(TrackerParseError::MissingField("peer.port"))?);
let peer_id = dict_get_bytes(dict, "peer id").and_then(|bytes| bytes_to_20(bytes).ok());
let client_name = dict_get_bytes(dict, "client").map(bytes_to_string);
let choked = dict_get_bool(dict, "choked").unwrap_or(false);
let interested = dict_get_bool(dict, "interested").unwrap_or(false);
Ok(TorrentPeerModel {
peer_id,
ip,
port,
client_name,
interested,
choked,
})
}
/// Parses a compact peer blob, auto-detecting IPv4 vs IPv6 stride.
fn parse_compact_peers(bytes: &[u8]) -> Result<Vec<TorrentPeerModel>, TrackerParseError> {
if bytes.len().is_multiple_of(6) {
parse_compact_peers_ipv4(bytes)
} else if bytes.len().is_multiple_of(18) {
parse_compact_peers_ipv6(bytes)
} else {
Err(TrackerParseError::InvalidPeer(
"compact peer list length must be divisible by 6 or 18".to_owned(),
))
}
}
/// Parses a compact IPv6 peer blob into tracker peer models.
fn parse_compact_peers_ipv6(bytes: &[u8]) -> Result<Vec<TorrentPeerModel>, TrackerParseError> {
parse_compact_peers_with_stride(bytes, 18, |chunk| {
let ip = format!(
"{:x}:{:x}:{:x}:{:x}:{:x}:{:x}:{:x}:{:x}",
u16::from_be_bytes([chunk[0], chunk[1]]),
u16::from_be_bytes([chunk[2], chunk[3]]),
u16::from_be_bytes([chunk[4], chunk[5]]),
u16::from_be_bytes([chunk[6], chunk[7]]),
u16::from_be_bytes([chunk[8], chunk[9]]),
u16::from_be_bytes([chunk[10], chunk[11]]),
u16::from_be_bytes([chunk[12], chunk[13]]),
u16::from_be_bytes([chunk[14], chunk[15]])
);
let port = u16::from_be_bytes([chunk[16], chunk[17]]);
TorrentPeerModel {
peer_id: None,
ip,
port,
client_name: None,
interested: false,
choked: false,
}
})
}
/// Parses a compact peer blob using the provided stride and address decoder.
fn parse_compact_peers_with_stride<F>(
bytes: &[u8],
stride: usize,
map_peer: F,
) -> Result<Vec<TorrentPeerModel>, TrackerParseError>
where
F: FnMut(&[u8]) -> TorrentPeerModel,
{
if bytes.is_empty() {
return Ok(Vec::new());
}
if !bytes.len().is_multiple_of(stride) {
return Err(TrackerParseError::InvalidPeer(format!(
"compact peer list length must be divisible by {stride}"
)));
}
Ok(bytes.chunks_exact(stride).map(map_peer).collect())
}
/// Looks up a boolean-like integer field inside a tracker bencode dictionary.
fn dict_get_bool(dict: &BencodeDict, key: &str) -> Option<bool> {
dict_get_int(dict, key).map(|value| value != 0)
}
/// Percent-encodes one tracker query value, special-casing 20-byte binary fields.
fn percent_encode_tracker_value(key: &str, value: &str) -> Result<String, TrackerParseError> {
if key == "info_hash" || key == "peer_id" {
let bytes = decode_hex_20(value)?;
return Ok(percent_encode_bytes(&bytes));
}
Ok(percent_encode(value))
}
/// Percent-encodes one string-valued tracker query component.
fn percent_encode(input: &str) -> String {
percent_encode_bytes(input.as_bytes())
}
/// Percent-encodes arbitrary tracker query bytes using uppercase hexadecimal.
fn percent_encode_bytes(input: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut output = String::with_capacity(input.len());
for &byte in input {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
output.push(char::from(byte));
}
_ => {
output.push('%');
output.push(char::from(HEX[usize::from(byte >> 4)]));
output.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
}
}
output
}
/// Decodes one ASCII hex digit into its numeric nibble.
fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
@@ -0,0 +1,303 @@
use super::{DhtMessageModel, TorrentPeerModel, TransportError};
use crate::tracker::{
TrackerParseError, UdpTrackerAnnounceEvent, UdpTrackerAnnounceRequest, UdpTrackerTransactionId,
};
/// Announce request fields sent to an HTTP or UDP tracker.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TrackerRequestModel {
/// Base announce URL.
pub announce_url: String,
/// Hex-encoded torrent info hash.
pub info_hash: String,
/// Hex-encoded local peer identifier.
pub peer_id: String,
/// Listening port exposed to peers.
pub port: u16,
/// Uploaded byte counter sent to the tracker.
pub uploaded: u64,
/// Downloaded byte counter sent to the tracker.
pub downloaded: u64,
/// Remaining byte counter sent to the tracker.
pub left: u64,
/// Optional tracker lifecycle event.
pub event: Option<String>,
/// Whether the tracker should prefer the compact peer format.
pub compact: bool,
/// Optional requested peer count.
pub numwant: Option<u32>,
}
/// Peer list returned by a tracker announce response.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TrackerPeerListModel {
/// Recommended announce interval in seconds.
pub interval_sec: u32,
/// Parsed peer entries.
pub peers: Vec<TorrentPeerModel>,
/// Optional minimum announce interval in seconds.
pub min_interval_sec: Option<u32>,
/// Optional tracker session identifier.
pub tracker_id: Option<String>,
}
/// DHT node coordinate returned by tracker or DHT metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtNodeModel {
/// Hex-encoded node identifier when available.
pub node_id: String,
/// Node IP address or hostname.
pub address: String,
/// Node UDP port.
pub port: u16,
}
impl DhtNodeModel {
/// Parses a DHT bootstrap node from a `host:port` or `[ipv6]:port` spec.
///
/// # Errors
///
/// Returns an error when the node spec is malformed or the port is invalid.
pub fn from_spec(raw: &str) -> Result<Self, TrackerParseError> {
let (address, port) = super::parsing::parse_endpoint_spec(raw, "dht node")?;
Ok(Self {
node_id: String::new(),
address,
port,
})
}
/// Formats the node as a stable `host:port` or `[ipv6]:port` spec.
#[must_use]
pub fn to_spec(&self) -> String {
super::parsing::format_endpoint_spec(&self.address, self.port)
}
}
/// Scrape statistics for a single info hash.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TrackerScrapeFileModel {
/// Hex-encoded info hash the entry describes.
pub info_hash: String,
/// Number of completed downloads.
pub complete: Option<u32>,
/// Number of times the torrent was downloaded.
pub downloaded: Option<u32>,
/// Number of incomplete peers.
pub incomplete: Option<u32>,
}
/// Scrape summary returned by a tracker.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TrackerScrapeModel {
/// Aggregate completed download count when present.
pub complete: Option<u32>,
/// Aggregate download count when present.
pub downloaded: Option<u32>,
/// Aggregate incomplete peer count when present.
pub incomplete: Option<u32>,
/// Per-info-hash scrape entries.
pub files: Vec<TrackerScrapeFileModel>,
}
/// Parsed tracker response containing announce peers and optional scrape data.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TrackerResponseModel {
/// Peer-list payload from the announce response.
pub peers: TrackerPeerListModel,
/// Optional scrape metadata synthesized from the response.
pub scrape: Option<TrackerScrapeModel>,
}
impl TrackerRequestModel {
/// Builds a tracker request from raw `BitTorrent` info-hash and peer-id bytes.
#[must_use]
pub fn from_bt_bytes(
announce_url: impl Into<String>,
info_hash: [u8; 20],
peer_id: [u8; 20],
port: u16,
uploaded: u64,
downloaded: u64,
left: u64,
) -> Self {
Self {
announce_url: announce_url.into(),
info_hash: super::parsing::hex_encode(&info_hash),
peer_id: super::parsing::hex_encode(&peer_id),
port,
uploaded,
downloaded,
left,
event: None,
compact: true,
numwant: Some(50),
}
}
/// Returns sorted query pairs suitable for announce and scrape URLs.
#[must_use]
pub fn query_pairs(&self) -> Vec<(String, String)> {
let mut pairs = vec![
("info_hash".to_owned(), self.info_hash.clone()),
("peer_id".to_owned(), self.peer_id.clone()),
("port".to_owned(), self.port.to_string()),
("uploaded".to_owned(), self.uploaded.to_string()),
("downloaded".to_owned(), self.downloaded.to_string()),
("left".to_owned(), self.left.to_string()),
("compact".to_owned(), u8::from(self.compact).to_string()),
];
if let Some(event) = &self.event {
pairs.push(("event".to_owned(), event.clone()));
}
if let Some(numwant) = self.numwant {
pairs.push(("numwant".to_owned(), numwant.to_string()));
}
pairs
}
/// Builds the full announce URL with query parameters.
///
/// # Errors
///
/// Returns an error when the announce URL is invalid.
pub fn announce_url(&self) -> Result<String, TrackerParseError> {
super::parsing::build_url(&self.announce_url, &self.query_pairs())
}
/// Builds the matching scrape URL with query parameters.
///
/// # Errors
///
/// Returns an error when the derived scrape URL is invalid.
pub fn scrape_url(&self) -> Result<String, TrackerParseError> {
let base = super::parsing::tracker_scrape_url(&self.announce_url);
super::parsing::build_url(&base, &self.query_pairs())
}
/// Decodes the hex-encoded info hash into its raw 20-byte form.
///
/// # Errors
///
/// Returns an error when the info hash is not a valid 20-byte hex string.
pub fn info_hash_bytes(&self) -> Result<[u8; 20], TrackerParseError> {
super::parsing::decode_hex_20(&self.info_hash)
}
/// Decodes the hex-encoded peer id into its raw 20-byte form.
///
/// # Errors
///
/// Returns an error when the peer id is not a valid 20-byte hex string.
pub fn peer_id_bytes(&self) -> Result<[u8; 20], TrackerParseError> {
super::parsing::decode_hex_20(&self.peer_id)
}
/// Converts the higher-level request into a UDP tracker announce request.
///
/// # Errors
///
/// Returns an error when the info hash, peer id, event, or numwant cannot be represented
/// in a UDP announce packet.
pub fn to_udp_announce_request(
&self,
connection_id: u64,
transaction_id: UdpTrackerTransactionId,
) -> Result<UdpTrackerAnnounceRequest, TrackerParseError> {
let event = match self.event.as_deref() {
None | Some("") => UdpTrackerAnnounceEvent::None,
Some("completed") => UdpTrackerAnnounceEvent::Completed,
Some("started") => UdpTrackerAnnounceEvent::Started,
Some("stopped") => UdpTrackerAnnounceEvent::Stopped,
Some(other) => {
return Err(TrackerParseError::InvalidUdpPacket(format!(
"unsupported udp tracker event: {other}"
)));
}
};
let numwant = match self.numwant {
Some(numwant) => i32::try_from(numwant).map_err(|_| {
TrackerParseError::InvalidUdpPacket(format!(
"udp tracker numwant exceeds i32 range: {numwant}"
))
})?,
None => -1,
};
Ok(UdpTrackerAnnounceRequest {
connection_id,
transaction_id,
info_hash: self.info_hash_bytes()?,
peer_id: self.peer_id_bytes()?,
downloaded: self.downloaded,
left: self.left,
uploaded: self.uploaded,
event,
ip_address: 0,
key: 0,
numwant,
port: self.port,
})
}
}
impl TrackerResponseModel {
/// Parses an HTTP tracker announce payload.
///
/// # Errors
///
/// Returns an error when the payload is malformed bencode or lacks required fields.
pub fn from_announce_bytes(input: &[u8]) -> Result<Self, TrackerParseError> {
let root = super::parsing::parse_bencode(input)?;
let interval_sec = super::parsing::i64_to_u32(
super::parsing::dict_get_int(&root, "interval").unwrap_or(1800),
);
let min_interval_sec =
super::parsing::dict_get_int(&root, "min interval").map(super::parsing::i64_to_u32);
let tracker_id = super::parsing::dict_get_bytes(&root, "tracker id")
.map(super::parsing::bytes_to_string);
let peers = super::parsing::parse_peer_list(&root)?;
let scrape = super::parsing::parse_scrape_section(&root);
Ok(Self {
peers: TrackerPeerListModel {
interval_sec,
peers,
min_interval_sec,
tracker_id,
},
scrape,
})
}
/// Parses an HTTP tracker scrape payload.
///
/// # Errors
///
/// Returns an error when the payload is malformed or lacks scrape metadata.
pub fn from_scrape_bytes(input: &[u8]) -> Result<TrackerScrapeModel, TrackerParseError> {
let root = super::parsing::parse_bencode(input)?;
super::parsing::parse_scrape_section_from_root(&root)
.ok_or(TrackerParseError::MissingField("files"))
}
}
/// Tracker transport contract for announce and scrape requests.
pub trait TrackerTransport {
/// Executes a tracker announce request.
fn announce(
&self,
request: &TrackerRequestModel,
) -> Result<TrackerResponseModel, TransportError>;
/// Executes a tracker scrape request for the given announce URL.
fn scrape(&self, announce_url: &str) -> Result<TrackerScrapeModel, TransportError>;
}
/// DHT transport contract for request/response messaging.
pub trait DhtTransport {
/// Sends a DHT message to the target node and returns the response.
fn send_message(
&self,
node: &DhtNodeModel,
message: &DhtMessageModel,
) -> Result<DhtMessageModel, TransportError>;
}
@@ -0,0 +1,107 @@
use super::{Client, TransportError, TransportErrorKind};
use crate::tracker::{
TrackerRequestModel, TrackerResponseModel, TrackerScrapeModel, TrackerTransport,
};
/// `reqwest`-backed HTTP tracker transport.
#[derive(Clone, Debug)]
pub struct ReqwestTrackerTransport {
/// Shared blocking HTTP client used for announce and scrape requests.
client: Client,
}
impl ReqwestTrackerTransport {
/// Creates a tracker transport backed by a default blocking `reqwest` client.
///
/// # Errors
///
/// Returns an error when the HTTP client cannot be constructed.
pub fn new() -> Result<Self, TransportError> {
let client = Client::builder()
.build()
.map_err(|error| tracker_transport_error(TransportErrorKind::Io, error.to_string()))?;
Ok(Self { client })
}
/// Fetches the raw response bytes for one tracker announce or scrape URL.
fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, TransportError> {
let response = self
.client
.get(url)
.send()
.map_err(map_reqwest_tracker_error)?;
let status = response.status();
if !status.is_success() {
return Err(tracker_transport_error(
TransportErrorKind::ProtocolViolation,
format!(
"tracker request failed with http status {}",
status.as_u16()
),
));
}
response
.bytes()
.map(Vec::from)
.map_err(map_reqwest_tracker_error)
}
}
impl Default for ReqwestTrackerTransport {
fn default() -> Self {
Self::new().expect("reqwest tracker transport should build")
}
}
impl TrackerTransport for ReqwestTrackerTransport {
fn announce(
&self,
request: &TrackerRequestModel,
) -> Result<TrackerResponseModel, TransportError> {
let url = request.announce_url().map_err(|error| {
tracker_transport_error(TransportErrorKind::ProtocolViolation, error.to_string())
})?;
let bytes = self.fetch_bytes(&url)?;
TrackerResponseModel::from_announce_bytes(&bytes).map_err(|error| {
tracker_transport_error(
TransportErrorKind::ProtocolViolation,
format!("invalid tracker announce payload: {error}"),
)
})
}
fn scrape(&self, announce_url: &str) -> Result<TrackerScrapeModel, TransportError> {
let url = super::parsing::tracker_scrape_url(announce_url);
let bytes = self.fetch_bytes(&url)?;
TrackerResponseModel::from_scrape_bytes(&bytes).map_err(|error| {
tracker_transport_error(
TransportErrorKind::ProtocolViolation,
format!("invalid tracker scrape payload: {error}"),
)
})
}
}
/// Builds a transport error with tracker-specific context already normalized.
fn tracker_transport_error(kind: TransportErrorKind, message: impl Into<String>) -> TransportError {
TransportError {
kind,
message: message.into(),
source: None,
context: None,
}
}
/// Maps a reqwest tracker fetch failure into the transport error model.
fn map_reqwest_tracker_error(error: reqwest::Error) -> TransportError {
let kind = if error.is_timeout() {
TransportErrorKind::Timeout
} else if error.is_connect() {
TransportErrorKind::NotConnected
} else if error.is_decode() {
TransportErrorKind::ProtocolViolation
} else {
TransportErrorKind::Io
};
tracker_transport_error(kind, error.to_string())
}
@@ -0,0 +1,567 @@
use std::{
io::{Read, Write},
net::TcpListener,
thread,
};
use super::*;
#[test]
fn builds_announce_and_scrape_urls() {
let request = TrackerRequestModel {
announce_url: "https://tracker.example.org/announce".to_owned(),
info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(),
peer_id: "89abcdef0123456789abcdef0123456789abcdef".to_owned(),
port: 6881,
uploaded: 1,
downloaded: 2,
left: 3,
event: Some("started".to_owned()),
compact: true,
numwant: Some(50),
};
let announce = request.announce_url().expect("announce url should build");
let scrape = request.scrape_url().expect("scrape url should build");
assert!(announce.starts_with("https://tracker.example.org/announce?"));
assert!(announce.contains("info_hash=%"));
assert!(announce.contains("peer_id=%"));
assert!(announce.contains("event=started"));
assert!(scrape.starts_with("https://tracker.example.org/scrape?"));
assert!(scrape.contains("info_hash=%"));
}
#[test]
fn parses_dict_announce_response_with_peer_metadata() {
let response = announce_bytes(
1800,
Some("tracker-01"),
vec![peer_dict(
"127.0.0.1",
6881,
Some("qBittorrent 4.6.5"),
true,
false,
Some([1_u8; 20]),
)],
None,
);
let parsed =
TrackerResponseModel::from_announce_bytes(&response).expect("should parse announce");
assert_eq!(parsed.peers.interval_sec, 1800);
assert_eq!(parsed.peers.tracker_id.as_deref(), Some("tracker-01"));
assert_eq!(parsed.peers.peers.len(), 1);
assert_eq!(parsed.peers.peers[0].ip, "127.0.0.1");
assert_eq!(parsed.peers.peers[0].port, 6881);
assert_eq!(
parsed.peers.peers[0].client_name.as_deref(),
Some("qBittorrent 4.6.5")
);
assert!(parsed.peers.peers[0].choked);
assert!(!parsed.peers.peers[0].interested);
assert_eq!(parsed.peers.peers[0].peer_id, Some([1_u8; 20]));
}
#[test]
fn parses_tracker_id_and_scrape_metadata_from_announce_response() {
let response = announce_bytes(
900,
Some("tracker-02"),
vec![peer_dict("127.0.0.2", 6882, None, false, true, None)],
Some((7, 3, 11)),
);
let parsed =
TrackerResponseModel::from_announce_bytes(&response).expect("should parse announce");
assert_eq!(parsed.peers.tracker_id.as_deref(), Some("tracker-02"));
assert_eq!(parsed.peers.interval_sec, 900);
let scrape = parsed.scrape.expect("scrape metadata should be present");
assert_eq!(scrape.complete, Some(7));
assert_eq!(scrape.incomplete, Some(3));
assert_eq!(scrape.downloaded, Some(11));
assert!(scrape.files.is_empty());
}
#[test]
fn parses_scrape_response_with_binary_info_hash_keys() {
let info_hash_a = [0x11_u8; 20];
let info_hash_b = [0x22_u8; 20];
let response = bencode_dict(vec![(
"files".to_owned(),
bencode_binary_key_dict(vec![
(
info_hash_a.to_vec(),
bencode_dict(vec![
("complete".to_owned(), bencode_int(7)),
("downloaded".to_owned(), bencode_int(9)),
("incomplete".to_owned(), bencode_int(3)),
]),
),
(
info_hash_b.to_vec(),
bencode_dict(vec![
("complete".to_owned(), bencode_int(4)),
("downloaded".to_owned(), bencode_int(5)),
("incomplete".to_owned(), bencode_int(6)),
]),
),
]),
)]);
let parsed = TrackerResponseModel::from_scrape_bytes(&response).expect("should parse scrape");
assert_eq!(parsed.files.len(), 2);
assert_eq!(parsed.files[0].info_hash, hex_encode(&info_hash_a));
assert_eq!(parsed.files[0].complete, Some(7));
assert_eq!(parsed.files[0].downloaded, Some(9));
assert_eq!(parsed.files[0].incomplete, Some(3));
assert_eq!(parsed.files[1].info_hash, hex_encode(&info_hash_b));
assert_eq!(parsed.files[1].complete, Some(4));
assert_eq!(parsed.files[1].downloaded, Some(5));
assert_eq!(parsed.files[1].incomplete, Some(6));
}
#[test]
fn udp_connect_request_serializes_expected_wire_format() {
let request = UdpTrackerConnectRequest {
transaction_id: UdpTrackerTransactionId::new(0x1020_3040),
};
assert_eq!(
request.encode(),
vec![
0x00, 0x00, 0x04, 0x17, 0x27, 0x10, 0x19, 0x80, 0x00, 0x00, 0x00, 0x00, 0x10, 0x20,
0x30, 0x40,
]
);
}
#[test]
fn udp_connect_response_parses_header_and_connection_id() {
let response = [
0x00, 0x00, 0x00, 0x00, 0xaa, 0xbb, 0xcc, 0xdd, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08,
];
let parsed =
UdpTrackerConnectResponse::decode(&response).expect("connect response should parse");
assert_eq!(
parsed.transaction_id,
UdpTrackerTransactionId::new(0xaabb_ccdd)
);
assert_eq!(parsed.connection_id, 0x0102_0304_0506_0708);
}
#[test]
fn udp_response_header_rejects_invalid_action_id() {
let error = UdpTrackerResponseHeader::decode(&[0x00, 0x00, 0x00, 0x09, 0xaa, 0xbb, 0xcc, 0xdd])
.expect_err("invalid action id should fail");
assert!(matches!(error, TrackerParseError::InvalidUdpAction(9)));
}
#[test]
fn udp_response_header_rejects_truncated_payload() {
let error = UdpTrackerResponseHeader::decode(&[0x00, 0x00, 0x00])
.expect_err("truncated header should fail");
assert!(matches!(error, TrackerParseError::InvalidUdpPacket(_)));
}
#[test]
fn udp_response_header_rejects_transaction_id_mismatch() {
let header = UdpTrackerResponseHeader {
action: UdpTrackerAction::Announce,
transaction_id: UdpTrackerTransactionId::new(7),
};
let error = header
.expect_transaction_id(UdpTrackerTransactionId::new(8))
.expect_err("mismatched transaction id should fail");
assert!(matches!(
error,
TrackerParseError::TransactionIdMismatch {
expected: 8,
actual: 7
}
));
}
#[test]
fn udp_announce_request_serializes_expected_wire_format() {
let request = UdpTrackerAnnounceRequest {
connection_id: 0x0102_0304_0506_0708,
transaction_id: UdpTrackerTransactionId::new(0x5566_7788),
info_hash: [0x11_u8; 20],
peer_id: [0x22_u8; 20],
downloaded: 0x1122_3344_5566_7788,
left: 0x8877_6655_4433_2211,
uploaded: 0x0101_0202_0303_0404,
event: UdpTrackerAnnounceEvent::Started,
ip_address: 0,
key: 0x1234_5678,
numwant: -1,
port: 6881,
};
let encoded = request.encode();
assert_eq!(encoded.len(), 98);
assert_eq!(
&encoded[0..8],
&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]
);
assert_eq!(&encoded[8..12], &[0x00, 0x00, 0x00, 0x01]);
assert_eq!(&encoded[12..16], &[0x55, 0x66, 0x77, 0x88]);
assert_eq!(&encoded[16..36], &[0x11_u8; 20]);
assert_eq!(&encoded[36..56], &[0x22_u8; 20]);
assert_eq!(&encoded[80..84], &[0x00, 0x00, 0x00, 0x02]);
assert_eq!(&encoded[88..92], &[0x12, 0x34, 0x56, 0x78]);
assert_eq!(&encoded[92..96], &[0xff, 0xff, 0xff, 0xff]);
assert_eq!(&encoded[96..98], &[0x1a, 0xe1]);
}
#[test]
fn udp_announce_response_parses_interval_counts_and_peers() {
let response = udp_announce_response_bytes(
UdpTrackerTransactionId::new(0x0102_0304),
1800,
4,
9,
&[(192, 168, 1, 10, 6881), (10, 0, 0, 2, 51413)],
);
let parsed =
UdpTrackerAnnounceResponse::decode(&response).expect("announce response should parse");
assert_eq!(
parsed.transaction_id,
UdpTrackerTransactionId::new(0x0102_0304)
);
assert_eq!(parsed.interval_sec, 1800);
assert_eq!(parsed.leechers, 4);
assert_eq!(parsed.seeders, 9);
assert_eq!(parsed.peers.len(), 2);
assert_eq!(parsed.peers[0].ip, "192.168.1.10");
assert_eq!(parsed.peers[0].port, 6881);
assert_eq!(parsed.peers[1].ip, "10.0.0.2");
assert_eq!(parsed.peers[1].port, 51413);
}
#[test]
fn udp_announce_response_rejects_malformed_compact_peer_blob() {
let mut response = vec![
0x00, 0x00, 0x00, 0x01, 0xde, 0xad, 0xbe, 0xef, 0x00, 0x00, 0x07, 0x08, 0x00, 0x00, 0x00,
0x03, 0x00, 0x00, 0x00, 0x06,
];
response.extend_from_slice(&[127, 0, 0, 1, 0x1a]);
let error =
UdpTrackerAnnounceResponse::decode(&response).expect_err("malformed peers should fail");
assert!(matches!(error, TrackerParseError::InvalidPeer(_)));
}
#[test]
fn udp_scrape_request_serializes_multiple_info_hashes() {
let request = UdpTrackerScrapeRequest {
connection_id: 0x1112_1314_1516_1718,
transaction_id: UdpTrackerTransactionId::new(0x99aa_bbcc),
info_hashes: vec![[0x44_u8; 20], [0x55_u8; 20]],
};
let encoded = request.encode().expect("scrape request should encode");
assert_eq!(encoded.len(), 56);
assert_eq!(
&encoded[0..8],
&[0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18]
);
assert_eq!(&encoded[8..12], &[0x00, 0x00, 0x00, 0x02]);
assert_eq!(&encoded[12..16], &[0x99, 0xaa, 0xbb, 0xcc]);
assert_eq!(&encoded[16..36], &[0x44_u8; 20]);
assert_eq!(&encoded[36..56], &[0x55_u8; 20]);
}
#[test]
fn udp_scrape_response_maps_multiple_entries_to_scrape_model() {
let response = udp_scrape_response_bytes(
UdpTrackerTransactionId::new(0x0bad_f00d),
&[(7, 9, 3), (4, 5, 6)],
);
let parsed = UdpTrackerScrapeResponse::decode(&response).expect("scrape response should parse");
let scrape = parsed
.to_scrape_model(&[[0x33_u8; 20], [0x44_u8; 20]])
.expect("scrape model should build");
assert_eq!(
parsed.transaction_id,
UdpTrackerTransactionId::new(0x0bad_f00d)
);
assert_eq!(scrape.files.len(), 2);
assert_eq!(scrape.files[0].info_hash, hex_encode(&[0x33_u8; 20]));
assert_eq!(scrape.files[0].complete, Some(7));
assert_eq!(scrape.files[0].downloaded, Some(9));
assert_eq!(scrape.files[0].incomplete, Some(3));
assert_eq!(scrape.files[1].info_hash, hex_encode(&[0x44_u8; 20]));
assert_eq!(scrape.files[1].complete, Some(4));
assert_eq!(scrape.files[1].downloaded, Some(5));
assert_eq!(scrape.files[1].incomplete, Some(6));
}
#[test]
fn udp_scrape_response_rejects_truncated_payload() {
let error = UdpTrackerScrapeResponse::decode(&[
0x00, 0x00, 0x00, 0x02, 0x12, 0x34, 0x56, 0x78, 0x00, 0x00, 0x00,
])
.expect_err("truncated scrape payload should fail");
assert!(matches!(error, TrackerParseError::InvalidUdpPacket(_)));
}
#[test]
fn reqwest_tracker_transport_executes_live_announce_request() {
let listener = TcpListener::bind("127.0.0.1:0").expect("local listener should bind");
let addr = listener.local_addr().expect("local addr should exist");
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("client should connect");
let mut request = [0_u8; 2048];
let read = stream.read(&mut request).expect("request should read");
let request_text = String::from_utf8_lossy(&request[..read]);
assert!(request_text.starts_with("GET /announce?"));
assert!(request_text.contains("info_hash="));
assert!(request_text.contains("peer_id="));
assert!(request_text.contains("compact=1"));
assert!(request_text.contains("event=started"));
let payload = announce_bytes(
1200,
Some("live-tracker"),
vec![peer_dict(
"127.0.0.1",
6881,
Some("local-peer"),
false,
true,
None,
)],
Some((5, 2, 9)),
);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n",
payload.len()
);
stream
.write_all(response.as_bytes())
.expect("headers should write");
stream.write_all(&payload).expect("payload should write");
});
let transport = ReqwestTrackerTransport::new().expect("tracker transport should build");
let response = transport
.announce(&TrackerRequestModel {
announce_url: format!("http://{addr}/announce"),
info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(),
peer_id: "89abcdef0123456789abcdef0123456789abcdef".to_owned(),
port: 6881,
uploaded: 0,
downloaded: 0,
left: 1024,
event: Some("started".to_owned()),
compact: true,
numwant: Some(25),
})
.expect("announce should succeed");
assert_eq!(response.peers.interval_sec, 1200);
assert_eq!(response.peers.tracker_id.as_deref(), Some("live-tracker"));
assert_eq!(response.peers.peers.len(), 1);
assert_eq!(response.peers.peers[0].ip, "127.0.0.1");
let scrape = response.scrape.expect("scrape stats should be present");
assert_eq!(scrape.complete, Some(5));
assert_eq!(scrape.incomplete, Some(2));
assert_eq!(scrape.downloaded, Some(9));
handle.join().expect("server thread should join");
}
#[test]
fn dht_node_model_parses_and_formats_ipv4_and_ipv6_specs() {
let ipv4 = DhtNodeModel::from_spec("198.51.100.9:51413").expect("ipv4 should parse");
assert_eq!(ipv4.address, "198.51.100.9");
assert_eq!(ipv4.port, 51413);
assert_eq!(ipv4.to_spec(), "198.51.100.9:51413");
let ipv6 = DhtNodeModel::from_spec("[2001:db8::9]:6881").expect("ipv6 should parse");
assert_eq!(ipv6.address, "2001:db8::9");
assert_eq!(ipv6.port, 6881);
assert_eq!(ipv6.to_spec(), "[2001:db8::9]:6881");
}
#[test]
fn tracker_request_converts_into_udp_announce_request() {
let request = TrackerRequestModel {
announce_url: "udp://tracker.example.org:6969".to_owned(),
info_hash: "00112233445566778899aabbccddeeff00112233".to_owned(),
peer_id: "89abcdef0123456789abcdef0123456789abcdef".to_owned(),
port: 51413,
uploaded: 11,
downloaded: 22,
left: 33,
event: Some("started".to_owned()),
compact: true,
numwant: Some(40),
};
let udp = request
.to_udp_announce_request(0x1122_3344_5566_7788, UdpTrackerTransactionId::new(77))
.expect("tracker request should convert");
assert_eq!(udp.connection_id, 0x1122_3344_5566_7788);
assert_eq!(udp.transaction_id, UdpTrackerTransactionId::new(77));
assert_eq!(udp.info_hash[0], 0x00);
assert_eq!(udp.info_hash[19], 0x33);
assert_eq!(udp.peer_id[0], 0x89);
assert_eq!(udp.peer_id[19], 0xef);
assert_eq!(udp.event, UdpTrackerAnnounceEvent::Started);
assert_eq!(udp.numwant, 40);
assert_eq!(udp.port, 51413);
}
fn announce_bytes(
interval: i64,
tracker_id: Option<&str>,
peers: Vec<Vec<u8>>,
scrape: Option<(i64, i64, i64)>,
) -> Vec<u8> {
let mut fields = Vec::new();
fields.push(("interval".to_owned(), bencode_int(interval)));
if let Some(tracker_id) = tracker_id {
fields.push((
"tracker id".to_owned(),
bencode_bytes(tracker_id.as_bytes()),
));
}
fields.push(("peers".to_owned(), bencode_list(peers)));
if let Some((complete, incomplete, downloaded)) = scrape {
fields.push(("complete".to_owned(), bencode_int(complete)));
fields.push(("incomplete".to_owned(), bencode_int(incomplete)));
fields.push(("downloaded".to_owned(), bencode_int(downloaded)));
}
bencode_dict(fields)
}
fn peer_dict(
ip: &str,
port: i64,
client: Option<&str>,
choked: bool,
interested: bool,
peer_id: Option<[u8; 20]>,
) -> Vec<u8> {
let mut fields = vec![
("ip".to_owned(), bencode_bytes(ip.as_bytes())),
("port".to_owned(), bencode_int(port)),
(
"choked".to_owned(),
bencode_int(i64::from(u8::from(choked))),
),
(
"interested".to_owned(),
bencode_int(i64::from(u8::from(interested))),
),
];
if let Some(client) = client {
fields.push(("client".to_owned(), bencode_bytes(client.as_bytes())));
}
if let Some(peer_id) = peer_id {
fields.push(("peer id".to_owned(), bencode_bytes(&peer_id)));
}
bencode_dict(fields)
}
fn bencode_dict(fields: Vec<(String, Vec<u8>)>) -> Vec<u8> {
let mut out = Vec::from(b"d".as_slice());
for (key, value) in fields {
out.extend_from_slice(key.len().to_string().as_bytes());
out.push(b':');
out.extend_from_slice(key.as_bytes());
out.extend_from_slice(&value);
}
out.push(b'e');
out
}
fn bencode_binary_key_dict(fields: Vec<(Vec<u8>, Vec<u8>)>) -> Vec<u8> {
let mut out = Vec::from(b"d".as_slice());
for (key, value) in fields {
out.extend_from_slice(key.len().to_string().as_bytes());
out.push(b':');
out.extend_from_slice(&key);
out.extend_from_slice(&value);
}
out.push(b'e');
out
}
fn bencode_list(values: Vec<Vec<u8>>) -> Vec<u8> {
let mut out = Vec::from(b"l".as_slice());
for value in values {
out.extend_from_slice(&value);
}
out.push(b'e');
out
}
fn bencode_int(value: i64) -> Vec<u8> {
format!("i{value}e").into_bytes()
}
fn bencode_bytes(value: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(value.len().to_string().as_bytes());
out.push(b':');
out.extend_from_slice(value);
out
}
fn udp_announce_response_bytes(
transaction_id: UdpTrackerTransactionId,
interval_sec: u32,
leechers: u32,
seeders: u32,
peers: &[(u8, u8, u8, u8, u16)],
) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&1_u32.to_be_bytes());
out.extend_from_slice(&transaction_id.get().to_be_bytes());
out.extend_from_slice(&interval_sec.to_be_bytes());
out.extend_from_slice(&leechers.to_be_bytes());
out.extend_from_slice(&seeders.to_be_bytes());
for (a, b, c, d, port) in peers {
out.extend_from_slice(&[*a, *b, *c, *d]);
out.extend_from_slice(&port.to_be_bytes());
}
out
}
fn udp_scrape_response_bytes(
transaction_id: UdpTrackerTransactionId,
entries: &[(u32, u32, u32)],
) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&2_u32.to_be_bytes());
out.extend_from_slice(&transaction_id.get().to_be_bytes());
for (complete, downloaded, incomplete) in entries {
out.extend_from_slice(&complete.to_be_bytes());
out.extend_from_slice(&downloaded.to_be_bytes());
out.extend_from_slice(&incomplete.to_be_bytes());
}
out
}
@@ -0,0 +1,490 @@
use super::{AtomicU32, Ordering, TorrentPeerModel};
use crate::tracker::{
TrackerParseError, TrackerPeerListModel, TrackerResponseModel, TrackerScrapeFileModel,
TrackerScrapeModel,
};
/// UDP tracker protocol identifier from BEP 15.
pub const UDP_TRACKER_PROTOCOL_ID: u64 = 0x0417_2710_1980;
/// Process-local counter used to allocate monotonic UDP tracker transaction ids.
static UDP_TRACKER_TRANSACTION_COUNTER: AtomicU32 = AtomicU32::new(0x6d69_0000);
/// Monotonic transaction identifier used for UDP tracker requests.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct UdpTrackerTransactionId(u32);
impl UdpTrackerTransactionId {
/// Creates a transaction identifier from a raw integer value.
#[must_use]
pub const fn new(value: u32) -> Self {
Self(value)
}
/// Allocates the next process-local UDP tracker transaction identifier.
#[must_use]
pub fn next() -> Self {
Self(UDP_TRACKER_TRANSACTION_COUNTER.fetch_add(1, Ordering::Relaxed))
}
/// Returns the raw integer value sent on the wire.
#[must_use]
pub const fn get(self) -> u32 {
self.0
}
}
/// UDP tracker actions defined by BEP 15.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum UdpTrackerAction {
/// Connection-id bootstrap request/response.
Connect,
/// Announce request/response.
Announce,
/// Scrape request/response.
Scrape,
/// Error response.
Error,
}
impl UdpTrackerAction {
/// Returns the wire value associated with the action.
#[must_use]
pub const fn wire_value(self) -> u32 {
match self {
Self::Connect => 0,
Self::Announce => 1,
Self::Scrape => 2,
Self::Error => 3,
}
}
/// Decodes a raw BEP 15 action code into the typed tracker action.
fn decode(value: u32) -> Result<Self, TrackerParseError> {
match value {
0 => Ok(Self::Connect),
1 => Ok(Self::Announce),
2 => Ok(Self::Scrape),
3 => Ok(Self::Error),
_ => Err(TrackerParseError::InvalidUdpAction(value)),
}
}
#[must_use]
/// Returns a short diagnostic label for the tracker action.
const fn label(self) -> &'static str {
match self {
Self::Connect => "connect",
Self::Announce => "announce",
Self::Scrape => "scrape",
Self::Error => "error",
}
}
}
/// Announce lifecycle values defined by the UDP tracker protocol.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum UdpTrackerAnnounceEvent {
/// No explicit lifecycle event.
None,
/// Download completed.
Completed,
/// Download started.
Started,
/// Download stopped.
Stopped,
}
impl UdpTrackerAnnounceEvent {
/// Returns the wire value associated with the announce event.
#[must_use]
pub const fn wire_value(self) -> u32 {
match self {
Self::None => 0,
Self::Completed => 1,
Self::Started => 2,
Self::Stopped => 3,
}
}
}
/// Header shared by all UDP tracker responses.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct UdpTrackerResponseHeader {
/// Action encoded by the response.
pub action: UdpTrackerAction,
/// Transaction identifier associated with the request/response pair.
pub transaction_id: UdpTrackerTransactionId,
}
impl UdpTrackerResponseHeader {
/// Decodes a UDP tracker response header from raw bytes.
///
/// # Errors
///
/// Returns an error when the payload is truncated or the action code is invalid.
pub fn decode(input: &[u8]) -> Result<Self, TrackerParseError> {
ensure_udp_payload_len(input, 8, "tracker response header")?;
Ok(Self {
action: UdpTrackerAction::decode(read_u32_be(input, 0, "tracker action id")?)?,
transaction_id: UdpTrackerTransactionId::new(read_u32_be(
input,
4,
"tracker transaction id",
)?),
})
}
/// Verifies that the header action matches the expected value.
///
/// # Errors
///
/// Returns an error when the action differs from `expected`.
pub fn expect_action(self, expected: UdpTrackerAction) -> Result<Self, TrackerParseError> {
if self.action == expected {
Ok(self)
} else {
Err(TrackerParseError::InvalidUdpPacket(format!(
"expected tracker action {} but got {}",
expected.label(),
self.action.label()
)))
}
}
/// Verifies that the header transaction identifier matches the expected value.
///
/// # Errors
///
/// Returns an error when the transaction identifier differs from `expected`.
pub fn expect_transaction_id(
self,
expected: UdpTrackerTransactionId,
) -> Result<Self, TrackerParseError> {
if self.transaction_id == expected {
Ok(self)
} else {
Err(TrackerParseError::TransactionIdMismatch {
expected: expected.get(),
actual: self.transaction_id.get(),
})
}
}
}
/// UDP tracker connect request payload.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct UdpTrackerConnectRequest {
/// Transaction identifier to match in the response.
pub transaction_id: UdpTrackerTransactionId,
}
impl UdpTrackerConnectRequest {
/// Encodes the request into BEP 15 wire bytes.
#[must_use]
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(16);
out.extend_from_slice(&UDP_TRACKER_PROTOCOL_ID.to_be_bytes());
out.extend_from_slice(&UdpTrackerAction::Connect.wire_value().to_be_bytes());
out.extend_from_slice(&self.transaction_id.get().to_be_bytes());
out
}
}
/// UDP tracker connect response payload.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct UdpTrackerConnectResponse {
/// Transaction identifier echoed by the tracker.
pub transaction_id: UdpTrackerTransactionId,
/// Connection identifier used by later requests.
pub connection_id: u64,
}
impl UdpTrackerConnectResponse {
/// Decodes a connect response from BEP 15 wire bytes.
///
/// # Errors
///
/// Returns an error when the payload is truncated or malformed.
pub fn decode(input: &[u8]) -> Result<Self, TrackerParseError> {
ensure_udp_payload_len(input, 16, "connect response")?;
let header =
UdpTrackerResponseHeader::decode(input)?.expect_action(UdpTrackerAction::Connect)?;
Ok(Self {
transaction_id: header.transaction_id,
connection_id: read_u64_be(input, 8, "tracker connection id")?,
})
}
}
/// UDP tracker announce request payload.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct UdpTrackerAnnounceRequest {
/// Connection identifier previously returned by the tracker.
pub connection_id: u64,
/// Transaction identifier to match in the response.
pub transaction_id: UdpTrackerTransactionId,
/// Raw 20-byte torrent info hash.
pub info_hash: [u8; 20],
/// Raw 20-byte local peer identifier.
pub peer_id: [u8; 20],
/// Uploaded byte count.
pub downloaded: u64,
/// Remaining byte count.
pub left: u64,
/// Uploaded byte count.
pub uploaded: u64,
/// Announce lifecycle event.
pub event: UdpTrackerAnnounceEvent,
/// Optional explicit IPv4 address encoded as a `u32`.
pub ip_address: u32,
/// Opaque tracker key.
pub key: u32,
/// Desired peer count or `-1` for tracker default.
pub numwant: i32,
/// Listening port exposed to peers.
pub port: u16,
}
impl UdpTrackerAnnounceRequest {
/// Encodes the announce request into BEP 15 wire bytes.
#[must_use]
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(98);
out.extend_from_slice(&self.connection_id.to_be_bytes());
out.extend_from_slice(&UdpTrackerAction::Announce.wire_value().to_be_bytes());
out.extend_from_slice(&self.transaction_id.get().to_be_bytes());
out.extend_from_slice(&self.info_hash);
out.extend_from_slice(&self.peer_id);
out.extend_from_slice(&self.downloaded.to_be_bytes());
out.extend_from_slice(&self.left.to_be_bytes());
out.extend_from_slice(&self.uploaded.to_be_bytes());
out.extend_from_slice(&self.event.wire_value().to_be_bytes());
out.extend_from_slice(&self.ip_address.to_be_bytes());
out.extend_from_slice(&self.key.to_be_bytes());
out.extend_from_slice(&self.numwant.to_be_bytes());
out.extend_from_slice(&self.port.to_be_bytes());
out
}
}
/// UDP tracker announce response payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UdpTrackerAnnounceResponse {
/// Transaction identifier echoed by the tracker.
pub transaction_id: UdpTrackerTransactionId,
/// Recommended announce interval in seconds.
pub interval_sec: u32,
/// Number of incomplete peers.
pub leechers: u32,
/// Number of complete peers.
pub seeders: u32,
/// Parsed compact IPv4 peer list.
pub peers: Vec<TorrentPeerModel>,
}
impl UdpTrackerAnnounceResponse {
/// Decodes an announce response from BEP 15 wire bytes.
///
/// # Errors
///
/// Returns an error when the payload is truncated or malformed.
pub fn decode(input: &[u8]) -> Result<Self, TrackerParseError> {
ensure_udp_payload_len(input, 20, "announce response")?;
let header =
UdpTrackerResponseHeader::decode(input)?.expect_action(UdpTrackerAction::Announce)?;
Ok(Self {
transaction_id: header.transaction_id,
interval_sec: read_u32_be(input, 8, "announce interval")?,
leechers: read_u32_be(input, 12, "announce leechers")?,
seeders: read_u32_be(input, 16, "announce seeders")?,
peers: super::parsing::parse_compact_peers_ipv4(&input[20..])?,
})
}
/// Converts the announce response into the higher-level tracker response model.
#[must_use]
pub fn into_tracker_response(self) -> TrackerResponseModel {
TrackerResponseModel {
peers: TrackerPeerListModel {
interval_sec: self.interval_sec,
peers: self.peers,
min_interval_sec: None,
tracker_id: None,
},
scrape: None,
}
}
}
/// UDP tracker scrape request payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UdpTrackerScrapeRequest {
/// Connection identifier previously returned by the tracker.
pub connection_id: u64,
/// Transaction identifier to match in the response.
pub transaction_id: UdpTrackerTransactionId,
/// Raw 20-byte info hashes to scrape.
pub info_hashes: Vec<[u8; 20]>,
}
impl UdpTrackerScrapeRequest {
/// Encodes the scrape request into BEP 15 wire bytes.
///
/// # Errors
///
/// Returns an error when no info hashes were supplied.
pub fn encode(&self) -> Result<Vec<u8>, TrackerParseError> {
if self.info_hashes.is_empty() {
return Err(TrackerParseError::InvalidUdpPacket(
"udp tracker scrape request requires at least one info hash".to_owned(),
));
}
let mut out = Vec::with_capacity(16 + self.info_hashes.len() * 20);
out.extend_from_slice(&self.connection_id.to_be_bytes());
out.extend_from_slice(&UdpTrackerAction::Scrape.wire_value().to_be_bytes());
out.extend_from_slice(&self.transaction_id.get().to_be_bytes());
for info_hash in &self.info_hashes {
out.extend_from_slice(info_hash);
}
Ok(out)
}
}
/// Per-torrent counters returned by a UDP tracker scrape response.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct UdpTrackerScrapeStats {
/// Number of completed downloads.
pub complete: u32,
/// Number of completed client downloads.
pub downloaded: u32,
/// Number of incomplete peers.
pub incomplete: u32,
}
/// UDP tracker scrape response payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UdpTrackerScrapeResponse {
/// Transaction identifier echoed by the tracker.
pub transaction_id: UdpTrackerTransactionId,
/// Per-info-hash scrape statistics.
pub files: Vec<UdpTrackerScrapeStats>,
}
impl UdpTrackerScrapeResponse {
/// Decodes a scrape response from BEP 15 wire bytes.
///
/// # Errors
///
/// Returns an error when the payload is truncated or malformed.
pub fn decode(input: &[u8]) -> Result<Self, TrackerParseError> {
ensure_udp_payload_len(input, 8, "scrape response header")?;
let header =
UdpTrackerResponseHeader::decode(input)?.expect_action(UdpTrackerAction::Scrape)?;
let payload = &input[8..];
if !payload.len().is_multiple_of(12) {
return Err(TrackerParseError::InvalidUdpPacket(
"scrape response payload length must be divisible by 12".to_owned(),
));
}
let mut files = Vec::with_capacity(payload.len() / 12);
for offset in (0..payload.len()).step_by(12) {
files.push(UdpTrackerScrapeStats {
complete: read_u32_be(payload, offset, "scrape complete count")?,
downloaded: read_u32_be(payload, offset + 4, "scrape downloaded count")?,
incomplete: read_u32_be(payload, offset + 8, "scrape incomplete count")?,
});
}
Ok(Self {
transaction_id: header.transaction_id,
files,
})
}
/// Converts the response into the higher-level scrape model.
///
/// # Errors
///
/// Returns an error when the info-hash list does not match the response entry count.
pub fn to_scrape_model(
&self,
info_hashes: &[[u8; 20]],
) -> Result<TrackerScrapeModel, TrackerParseError> {
if info_hashes.len() != self.files.len() {
return Err(TrackerParseError::InvalidUdpPacket(format!(
"scrape response entry count {} does not match info-hash count {}",
self.files.len(),
info_hashes.len()
)));
}
let files = info_hashes
.iter()
.zip(self.files.iter())
.map(|(info_hash, stats)| TrackerScrapeFileModel {
info_hash: super::parsing::hex_encode(info_hash),
complete: Some(stats.complete),
downloaded: Some(stats.downloaded),
incomplete: Some(stats.incomplete),
})
.collect::<Vec<_>>();
let (complete, downloaded, incomplete) = if self.files.len() == 1 {
let stats = self.files[0];
(
Some(stats.complete),
Some(stats.downloaded),
Some(stats.incomplete),
)
} else {
(None, None, None)
};
Ok(TrackerScrapeModel {
complete,
downloaded,
incomplete,
files,
})
}
}
/// Verifies that a UDP tracker payload is at least `min_len` bytes long.
fn ensure_udp_payload_len(
input: &[u8],
min_len: usize,
label: &str,
) -> Result<(), TrackerParseError> {
if input.len() < min_len {
return Err(TrackerParseError::InvalidUdpPacket(format!(
"{label} truncated: expected at least {min_len} bytes but got {}",
input.len()
)));
}
Ok(())
}
/// Reads one big-endian `u32` from a UDP tracker payload.
fn read_u32_be(input: &[u8], offset: usize, field: &str) -> Result<u32, TrackerParseError> {
let end = offset.saturating_add(4);
let bytes = input
.get(offset..end)
.ok_or_else(|| TrackerParseError::InvalidUdpPacket(format!("{field} truncated")))?;
let mut out = [0_u8; 4];
out.copy_from_slice(bytes);
Ok(u32::from_be_bytes(out))
}
/// Reads one big-endian `u64` from a UDP tracker payload.
fn read_u64_be(input: &[u8], offset: usize, field: &str) -> Result<u64, TrackerParseError> {
let end = offset.saturating_add(8);
let bytes = input
.get(offset..end)
.ok_or_else(|| TrackerParseError::InvalidUdpPacket(format!("{field} truncated")))?;
let mut out = [0_u8; 8];
out.copy_from_slice(bytes);
Ok(u64::from_be_bytes(out))
}
@@ -0,0 +1,21 @@
//! Generic transport request and response models plus connector traits.
#![forbid(unsafe_code)]
/// Generic transport-layer request, response, and error models.
mod model;
/// Standard library backed connector implementations.
mod std_connectors;
#[cfg(test)]
mod tests;
pub use self::model::{
HttpTransportConnector, PeerWireTransportConnector, PeerWireTransportRequest,
PeerWireTransportResponse, TransportBody, TransportConnector, TransportEndpoint,
TransportError, TransportErrorContext, TransportErrorKind, TransportRequest, TransportResponse,
TransportResult, TransportScheme, TransportStream, UdpTransportConnector, UdpTransportRequest,
UdpTransportResponse,
};
pub use self::std_connectors::{
StdDhtTransport, StdTcpPeerWireTransportConnector, StdUdpTransportConnector,
};
@@ -0,0 +1,246 @@
use std::{
error::Error,
fmt::{Display, Formatter},
};
use crate::http::{HttpHeader, HttpRequestModel, HttpResponseModel};
/// Transport schemes recognized by the protocol layer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TransportScheme {
/// Plain HTTP.
Http,
/// HTTP over TLS.
Https,
/// FTP control/data channels.
Ftp,
/// SFTP over SSH.
Sftp,
/// Metalink document fetches.
Metalink,
/// `BitTorrent` peer or metadata exchanges.
BitTorrent,
/// Magnet URI bootstrap requests.
Magnet,
/// Local file operations.
File,
}
/// Resolved network or file endpoint targeted by a transport request.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportEndpoint {
/// Scheme used to interpret the address.
pub scheme: TransportScheme,
/// Opaque address string such as a URL, socket address, or path.
pub address: String,
}
/// Generic transport body payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TransportBody {
/// No payload.
Empty,
/// In-memory payload bytes.
Inline(Vec<u8>),
/// Streamed payload with an optional expected length.
Stream {
/// Declared payload length when known in advance.
expected_len: Option<u64>,
},
}
/// Generic transport request shared across protocol adapters.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportRequest {
/// Destination endpoint.
pub endpoint: TransportEndpoint,
/// Transport headers represented with the HTTP header model.
pub headers: Vec<HttpHeader>,
/// Request body payload.
pub body: TransportBody,
}
/// Generic transport response shared across protocol adapters.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportResponse {
/// Optional response status code when the transport exposes one.
pub status: Option<u16>,
/// Response headers represented with the HTTP header model.
pub headers: Vec<HttpHeader>,
/// Response body payload.
pub body: TransportBody,
}
/// Classified transport failure kinds.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TransportErrorKind {
/// The requested scheme is not supported by the connector.
UnsupportedScheme,
/// The connector has not established a usable session.
NotConnected,
/// The operation timed out.
Timeout,
/// The operation would block and should be retried later.
WouldBlock,
/// The remote side reset the connection.
ConnectionReset,
/// The peer returned malformed data or violated the protocol contract.
ProtocolViolation,
/// DNS resolution failed.
DnsFailed,
/// TLS negotiation or validation failed.
TlsFailed,
/// Authentication failed.
AuthenticationFailed,
/// Proxy negotiation failed.
ProxyFailed,
/// Checksum verification failed.
ChecksumMismatch,
/// A generic I/O error occurred.
Io,
}
/// Optional context attached to a transport error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportErrorContext {
/// Endpoint involved in the failure.
pub endpoint: Option<TransportEndpoint>,
/// Request payload involved in the failure.
pub request: Option<TransportRequest>,
/// Partial response captured before the failure, if any.
pub response: Option<TransportResponse>,
}
/// Error value returned by transport connectors.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportError {
/// Coarse failure classification.
pub kind: TransportErrorKind,
/// Human-readable error message.
pub message: String,
/// Optional stringified source error.
pub source: Option<String>,
/// Optional structured request/response context.
pub context: Option<TransportErrorContext>,
}
impl Display for TransportError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl Error for TransportError {}
/// Result wrapper used by some protocol adapters that carry value-or-error explicitly.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportResult<T> {
/// Successful value when the operation completed.
pub value: Option<T>,
/// Failure when the operation did not complete successfully.
pub error: Option<TransportError>,
}
impl<T> TransportResult<T> {
/// Builds a successful transport result.
#[must_use]
pub const fn ok(value: T) -> Self {
Self {
value: Some(value),
error: None,
}
}
/// Builds an error transport result with no additional context.
#[must_use]
pub fn err(kind: TransportErrorKind, message: impl Into<String>) -> Self {
Self {
value: None,
error: Some(TransportError {
kind,
message: message.into(),
source: None,
context: None,
}),
}
}
}
/// Minimal lifecycle contract for persistent transport streams.
pub trait TransportStream {
/// Returns whether the stream is currently open.
fn is_open(&self) -> bool;
/// Closes the stream and releases any associated resources.
fn close(&self) -> Result<(), TransportError>;
}
/// Generic connector contract for request/response transports.
pub trait TransportConnector {
/// Sends a transport request and returns a transport response.
fn connect(&self, request: &TransportRequest) -> Result<TransportResponse, TransportError>;
}
/// Specialized connector contract for HTTP request execution.
pub trait HttpTransportConnector {
/// Executes an HTTP request and returns the protocol-layer response model.
fn connect_http(&self, request: &HttpRequestModel)
-> Result<HttpResponseModel, TransportError>;
}
/// Datagram request wrapper for tracker and DHT exchanges.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UdpTransportRequest {
/// Remote endpoint to send to.
pub endpoint: TransportEndpoint,
/// Datagram payload bytes.
pub payload: Vec<u8>,
}
/// Datagram response wrapper for tracker and DHT exchanges.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UdpTransportResponse {
/// Endpoint that produced the payload.
pub endpoint: TransportEndpoint,
/// Datagram payload bytes.
pub payload: Vec<u8>,
}
/// Connector contract for UDP datagram transports.
pub trait UdpTransportConnector {
/// Sends a UDP datagram and returns the response payload.
fn send_udp(
&self,
request: &UdpTransportRequest,
) -> Result<UdpTransportResponse, TransportError>;
}
/// Request wrapper for `BitTorrent` peer-wire exchanges.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerWireTransportRequest {
/// Remote peer endpoint.
pub endpoint: TransportEndpoint,
/// Info hash associated with the peer session.
pub info_hash: Vec<u8>,
/// Local peer identifier.
pub peer_id: Vec<u8>,
/// Peer-wire payload bytes.
pub payload: Vec<u8>,
}
/// Response wrapper for `BitTorrent` peer-wire exchanges.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerWireTransportResponse {
/// Endpoint that produced the payload.
pub endpoint: TransportEndpoint,
/// Peer-wire payload bytes.
pub payload: Vec<u8>,
}
/// Connector contract for `BitTorrent` peer-wire transports.
pub trait PeerWireTransportConnector {
/// Executes a peer-wire request and returns the peer response payload.
fn connect_peer_wire(
&self,
request: &PeerWireTransportRequest,
) -> Result<PeerWireTransportResponse, TransportError>;
}
@@ -0,0 +1,518 @@
use std::{
io::{self, Read, Write},
net::{SocketAddr, TcpStream, ToSocketAddrs, UdpSocket},
time::Duration,
};
use crate::{
torrent::DhtMessageModel,
tracker::{DhtNodeModel, DhtTransport},
};
use super::model::{
PeerWireTransportConnector, PeerWireTransportRequest, PeerWireTransportResponse,
TransportEndpoint, TransportError, TransportErrorContext, TransportErrorKind, TransportScheme,
UdpTransportConnector, UdpTransportRequest, UdpTransportResponse,
};
/// Default blocking socket timeout used by protocol transports.
const DEFAULT_SOCKET_TIMEOUT: Duration = Duration::from_secs(5);
/// Default maximum UDP datagram size accepted by the loopback connector.
const DEFAULT_MAX_DATAGRAM_SIZE: usize = 65_535;
/// Default maximum peer-wire response budget accepted from one exchange.
const DEFAULT_MAX_PEER_WIRE_RESPONSE_BYTES: usize = 1_048_576;
/// Blocking stdlib UDP connector for tracker- and DHT-style datagram exchanges.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct StdUdpTransportConnector {
/// Blocking read/write timeout applied to the underlying UDP socket.
timeout: Duration,
/// Maximum response payload size accepted from one datagram exchange.
max_datagram_size: usize,
}
impl StdUdpTransportConnector {
/// Creates a UDP connector with conservative blocking timeouts.
#[must_use]
pub const fn new() -> Self {
Self {
timeout: DEFAULT_SOCKET_TIMEOUT,
max_datagram_size: DEFAULT_MAX_DATAGRAM_SIZE,
}
}
/// Creates a UDP connector with the provided read/write timeout.
#[must_use]
pub const fn with_timeout(timeout: Duration) -> Self {
Self {
timeout,
max_datagram_size: DEFAULT_MAX_DATAGRAM_SIZE,
}
}
/// Creates a UDP connector with explicit timeout and receive buffer sizing.
#[must_use]
pub const fn with_config(timeout: Duration, max_datagram_size: usize) -> Self {
Self {
timeout,
max_datagram_size: if max_datagram_size == 0 {
1
} else {
max_datagram_size
},
}
}
}
impl Default for StdUdpTransportConnector {
fn default() -> Self {
Self::new()
}
}
impl UdpTransportConnector for StdUdpTransportConnector {
fn send_udp(
&self,
request: &UdpTransportRequest,
) -> Result<UdpTransportResponse, TransportError> {
let remote =
resolve_first_socket_addr(&request.endpoint.address, request.endpoint.clone())?;
let socket = bind_udp_socket(&remote, request.endpoint.clone())?;
socket
.set_read_timeout(Some(self.timeout))
.map_err(|error| {
io_transport_error(
"failed to set udp read timeout",
error,
Some(request.endpoint.clone()),
)
})?;
socket
.set_write_timeout(Some(self.timeout))
.map_err(|error| {
io_transport_error(
"failed to set udp write timeout",
error,
Some(request.endpoint.clone()),
)
})?;
socket.connect(remote).map_err(|error| {
io_transport_error(
"failed to connect udp socket",
error,
Some(request.endpoint.clone()),
)
})?;
socket.send(&request.payload).map_err(|error| {
io_transport_error(
"failed to send udp datagram",
error,
Some(request.endpoint.clone()),
)
})?;
let mut buffer = vec![0_u8; self.max_datagram_size];
let read = socket.recv(&mut buffer).map_err(|error| {
io_transport_error(
"failed to receive udp datagram",
error,
Some(request.endpoint.clone()),
)
})?;
buffer.truncate(read);
Ok(UdpTransportResponse {
endpoint: TransportEndpoint {
scheme: request.endpoint.scheme,
address: remote.to_string(),
},
payload: buffer,
})
}
}
/// Blocking DHT transport backed by one-shot UDP sockets.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct StdDhtTransport {
/// UDP transport used to execute one-shot DHT datagram exchanges.
udp: StdUdpTransportConnector,
}
impl StdDhtTransport {
/// Creates a DHT transport with conservative blocking timeouts.
#[must_use]
pub const fn new() -> Self {
Self {
udp: StdUdpTransportConnector::new(),
}
}
/// Creates a DHT transport with the provided request timeout.
#[must_use]
pub const fn with_timeout(timeout: Duration) -> Self {
Self {
udp: StdUdpTransportConnector::with_timeout(timeout),
}
}
/// Wraps an explicit UDP connector for DHT request/response exchanges.
#[must_use]
pub const fn with_udp_connector(udp: StdUdpTransportConnector) -> Self {
Self { udp }
}
}
impl Default for StdDhtTransport {
fn default() -> Self {
Self::new()
}
}
impl DhtTransport for StdDhtTransport {
fn send_message(
&self,
node: &DhtNodeModel,
message: &DhtMessageModel,
) -> Result<DhtMessageModel, TransportError> {
let endpoint = TransportEndpoint {
scheme: TransportScheme::BitTorrent,
address: format_socket_endpoint(&node.address, node.port),
};
let response = self.udp.send_udp(&UdpTransportRequest {
endpoint: endpoint.clone(),
payload: message.to_bencode_bytes(),
})?;
DhtMessageModel::from_bencode_bytes(&response.payload).map_err(|error| TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!("failed to parse dht response: {error}"),
source: Some(error),
context: Some(TransportErrorContext {
endpoint: Some(endpoint),
request: None,
response: None,
}),
})
}
}
/// Blocking peer-wire connector backed by stdlib TCP streams.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct StdTcpPeerWireTransportConnector {
/// Timeout budget for establishing the TCP connection.
connect_timeout: Duration,
/// Blocking read/write timeout applied after the connection opens.
io_timeout: Duration,
/// Maximum response size accepted from one peer-wire exchange.
max_response_bytes: usize,
}
impl StdTcpPeerWireTransportConnector {
/// Creates a peer-wire connector with conservative blocking timeouts.
#[must_use]
pub const fn new() -> Self {
Self {
connect_timeout: DEFAULT_SOCKET_TIMEOUT,
io_timeout: DEFAULT_SOCKET_TIMEOUT,
max_response_bytes: DEFAULT_MAX_PEER_WIRE_RESPONSE_BYTES,
}
}
/// Creates a peer-wire connector with explicit connect and I/O timeouts.
#[must_use]
pub const fn with_timeouts(connect_timeout: Duration, io_timeout: Duration) -> Self {
Self {
connect_timeout,
io_timeout,
max_response_bytes: DEFAULT_MAX_PEER_WIRE_RESPONSE_BYTES,
}
}
/// Creates a peer-wire connector with fully explicit limits.
#[must_use]
pub const fn with_config(
connect_timeout: Duration,
io_timeout: Duration,
max_response_bytes: usize,
) -> Self {
Self {
connect_timeout,
io_timeout,
max_response_bytes: if max_response_bytes == 0 {
1
} else {
max_response_bytes
},
}
}
}
impl Default for StdTcpPeerWireTransportConnector {
fn default() -> Self {
Self::new()
}
}
impl PeerWireTransportConnector for StdTcpPeerWireTransportConnector {
fn connect_peer_wire(
&self,
request: &PeerWireTransportRequest,
) -> Result<PeerWireTransportResponse, TransportError> {
if request.endpoint.scheme != TransportScheme::BitTorrent {
return Err(TransportError {
kind: TransportErrorKind::UnsupportedScheme,
message: format!(
"peer-wire tcp connector only supports bittorrent endpoints, got {:?}",
request.endpoint.scheme
),
source: None,
context: Some(TransportErrorContext {
endpoint: Some(request.endpoint.clone()),
request: None,
response: None,
}),
});
}
let (mut stream, remote) =
connect_tcp_stream(&request.endpoint, self.connect_timeout, self.io_timeout)?;
stream.write_all(&request.payload).map_err(|error| {
io_transport_error(
"failed to write peer-wire payload",
error,
Some(request.endpoint.clone()),
)
})?;
stream.flush().map_err(|error| {
io_transport_error(
"failed to flush peer-wire payload",
error,
Some(request.endpoint.clone()),
)
})?;
let mut response = Vec::new();
let mut buffer = [0_u8; 8192];
loop {
match stream.read(&mut buffer) {
Ok(0) => break,
Ok(read) => {
response.extend_from_slice(&buffer[..read]);
if response.len() > self.max_response_bytes {
return Err(TransportError {
kind: TransportErrorKind::ProtocolViolation,
message: format!(
"peer-wire response exceeded {} bytes",
self.max_response_bytes
),
source: None,
context: Some(TransportErrorContext {
endpoint: Some(request.endpoint.clone()),
request: None,
response: None,
}),
});
}
}
Err(error)
if matches!(
error.kind(),
io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
) =>
{
if response.is_empty() {
return Err(io_transport_error(
"timed out waiting for peer-wire response",
error,
Some(request.endpoint.clone()),
));
}
break;
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => {
return Err(io_transport_error(
"failed to read peer-wire response",
error,
Some(request.endpoint.clone()),
));
}
}
}
if response.is_empty() {
return Err(TransportError {
kind: TransportErrorKind::ConnectionReset,
message: "peer-wire peer closed connection without a response".to_owned(),
source: None,
context: Some(TransportErrorContext {
endpoint: Some(request.endpoint.clone()),
request: None,
response: None,
}),
});
}
Ok(PeerWireTransportResponse {
endpoint: TransportEndpoint {
scheme: request.endpoint.scheme,
address: remote.to_string(),
},
payload: response,
})
}
}
/// Binds a wildcard UDP socket that matches the remote address family.
fn bind_udp_socket(
remote: &SocketAddr,
endpoint: TransportEndpoint,
) -> Result<UdpSocket, TransportError> {
let bind_addr = if remote.is_ipv4() {
"0.0.0.0:0"
} else {
"[::]:0"
};
UdpSocket::bind(bind_addr)
.map_err(|error| io_transport_error("failed to bind udp socket", error, Some(endpoint)))
}
/// Resolves and connects a TCP stream to the first reachable peer endpoint.
fn connect_tcp_stream(
endpoint: &TransportEndpoint,
connect_timeout: Duration,
io_timeout: Duration,
) -> Result<(TcpStream, SocketAddr), TransportError> {
let resolved = resolve_socket_addrs(&endpoint.address, endpoint.clone())?;
let mut last_error = None;
for address in resolved {
match TcpStream::connect_timeout(&address, connect_timeout) {
Ok(stream) => {
stream.set_nodelay(true).map_err(|error| {
io_transport_error(
"failed to enable tcp nodelay for peer-wire stream",
error,
Some(endpoint.clone()),
)
})?;
stream.set_read_timeout(Some(io_timeout)).map_err(|error| {
io_transport_error(
"failed to set peer-wire read timeout",
error,
Some(endpoint.clone()),
)
})?;
stream
.set_write_timeout(Some(io_timeout))
.map_err(|error| {
io_transport_error(
"failed to set peer-wire write timeout",
error,
Some(endpoint.clone()),
)
})?;
return Ok((stream, address));
}
Err(error) => last_error = Some(error),
}
}
Err(last_error.map_or_else(
|| TransportError {
kind: TransportErrorKind::DnsFailed,
message: format!("no socket addresses resolved for {}", endpoint.address),
source: None,
context: Some(TransportErrorContext {
endpoint: Some(endpoint.clone()),
request: None,
response: None,
}),
},
|error| {
io_transport_error(
"failed to connect peer-wire tcp stream",
error,
Some(endpoint.clone()),
)
},
))
}
/// Resolves one socket address and returns the first candidate.
fn resolve_first_socket_addr(
address: &str,
endpoint: TransportEndpoint,
) -> Result<SocketAddr, TransportError> {
resolve_socket_addrs(address, endpoint)?
.into_iter()
.next()
.ok_or_else(|| TransportError {
kind: TransportErrorKind::DnsFailed,
message: format!("no socket addresses resolved for {address}"),
source: None,
context: None,
})
}
/// Resolves all socket-address candidates for a host and port string.
fn resolve_socket_addrs(
address: &str,
endpoint: TransportEndpoint,
) -> Result<Vec<SocketAddr>, TransportError> {
let iter = address.to_socket_addrs().map_err(|error| TransportError {
kind: TransportErrorKind::DnsFailed,
message: format!("failed to resolve socket address {address}: {error}"),
source: Some(error.to_string()),
context: Some(TransportErrorContext {
endpoint: Some(endpoint),
request: None,
response: None,
}),
})?;
Ok(iter.collect())
}
/// Wraps a low-level I/O error into the protocol-layer transport error model.
fn io_transport_error(
message: &str,
error: io::Error,
endpoint: Option<TransportEndpoint>,
) -> TransportError {
TransportError {
kind: map_io_error_kind(&error),
message: format!("{message}: {error}"),
source: Some(error.to_string()),
context: Some(TransportErrorContext {
endpoint,
request: None,
response: None,
}),
}
}
/// Maps stdlib I/O error kinds into protocol transport categories.
fn map_io_error_kind(error: &io::Error) -> TransportErrorKind {
match error.kind() {
io::ErrorKind::TimedOut => TransportErrorKind::Timeout,
io::ErrorKind::WouldBlock => TransportErrorKind::WouldBlock,
io::ErrorKind::ConnectionRefused
| io::ErrorKind::NotConnected
| io::ErrorKind::AddrNotAvailable
| io::ErrorKind::HostUnreachable
| io::ErrorKind::NetworkUnreachable => TransportErrorKind::NotConnected,
io::ErrorKind::ConnectionReset
| io::ErrorKind::ConnectionAborted
| io::ErrorKind::BrokenPipe
| io::ErrorKind::UnexpectedEof => TransportErrorKind::ConnectionReset,
io::ErrorKind::InvalidData => TransportErrorKind::ProtocolViolation,
_ => TransportErrorKind::Io,
}
}
/// Formats a socket endpoint as `host:port` or `[ipv6]:port`.
fn format_socket_endpoint(host: &str, port: u16) -> String {
if host.contains(':') && !host.starts_with('[') {
format!("[{host}]:{port}")
} else {
format!("{host}:{port}")
}
}
@@ -0,0 +1,192 @@
use std::{
io::{Read, Write},
net::{TcpListener, UdpSocket},
thread,
time::Duration,
};
use crate::{
torrent::{DhtMessageBody, DhtMessageModel, DhtResponseModel, PeerWireHandshakeModel},
tracker::{DhtNodeModel, DhtTransport},
};
use super::*;
struct LoopbackUdpConnector;
impl UdpTransportConnector for LoopbackUdpConnector {
fn send_udp(
&self,
request: &UdpTransportRequest,
) -> Result<UdpTransportResponse, TransportError> {
Ok(UdpTransportResponse {
endpoint: request.endpoint.clone(),
payload: request.payload.clone(),
})
}
}
struct LoopbackPeerWireConnector;
impl PeerWireTransportConnector for LoopbackPeerWireConnector {
fn connect_peer_wire(
&self,
request: &PeerWireTransportRequest,
) -> Result<PeerWireTransportResponse, TransportError> {
let mut echoed = request.info_hash.clone();
echoed.extend_from_slice(&request.peer_id);
echoed.extend_from_slice(&request.payload);
Ok(PeerWireTransportResponse {
endpoint: request.endpoint.clone(),
payload: echoed,
})
}
}
#[test]
fn transport_result_error_builder_populates_error_shape() {
let result = TransportResult::<()>::err(TransportErrorKind::Timeout, "timed out");
assert!(result.value.is_none());
assert_eq!(
result.error,
Some(TransportError {
kind: TransportErrorKind::Timeout,
message: "timed out".to_owned(),
source: None,
context: None,
})
);
}
#[test]
fn loopback_udp_connector_round_trips_payload() {
let connector = LoopbackUdpConnector;
let request = UdpTransportRequest {
endpoint: TransportEndpoint {
scheme: TransportScheme::BitTorrent,
address: "127.0.0.1:6969".to_owned(),
},
payload: vec![0, 1, 2, 3, 4],
};
let response = connector
.send_udp(&request)
.expect("udp transport should echo");
assert_eq!(response.endpoint, request.endpoint);
assert_eq!(response.payload, request.payload);
}
#[test]
fn loopback_peer_wire_connector_round_trips_handshake_material() {
let connector = LoopbackPeerWireConnector;
let request = PeerWireTransportRequest {
endpoint: TransportEndpoint {
scheme: TransportScheme::BitTorrent,
address: "192.0.2.10:51413".to_owned(),
},
info_hash: vec![0x11; 20],
peer_id: vec![0x22; 20],
payload: vec![0x13, b'B', b'i', b't'],
};
let response = connector
.connect_peer_wire(&request)
.expect("peer-wire transport should echo");
assert_eq!(response.endpoint, request.endpoint);
assert_eq!(response.payload.len(), 44);
assert!(response.payload.starts_with(&request.info_hash));
}
#[test]
fn std_dht_transport_sends_live_udp_bencoded_messages() {
let socket = UdpSocket::bind("127.0.0.1:0").expect("udp listener should bind");
let addr = socket
.local_addr()
.expect("udp listener should expose addr");
let handle = thread::spawn(move || {
let mut buffer = [0_u8; 2048];
let (read, peer) = socket
.recv_from(&mut buffer)
.expect("udp request should arrive");
let request = DhtMessageModel::from_bencode_bytes(&buffer[..read])
.expect("incoming dht message should parse");
assert_eq!(request.method(), Some("ping"));
let response = DhtMessageModel::ping_response(request.transaction_id, vec![0x44; 20])
.to_bencode_bytes();
socket
.send_to(&response, peer)
.expect("udp response should write");
});
let transport = StdDhtTransport::with_timeout(Duration::from_secs(1));
let response = transport
.send_message(
&DhtNodeModel {
node_id: String::new(),
address: "127.0.0.1".to_owned(),
port: addr.port(),
},
&DhtMessageModel::ping_query(b"pi".to_vec(), vec![0x11; 20]),
)
.expect("live dht transport should round-trip");
assert_eq!(response.transaction_id, b"pi".to_vec());
assert!(matches!(
response.body,
DhtMessageBody::Response(DhtResponseModel::Ping(_))
));
handle.join().expect("udp server thread should join");
}
#[test]
fn std_tcp_peer_wire_connector_executes_live_exchange() {
let listener = TcpListener::bind("127.0.0.1:0").expect("tcp listener should bind");
let addr = listener
.local_addr()
.expect("tcp listener should expose addr");
let info_hash = [0x11; 20];
let local_peer_id = [0x22; 20];
let remote_peer_id = [0x33; 20];
let request_payload = PeerWireHandshakeModel::new(info_hash, local_peer_id).serialize();
let request_len = request_payload.len();
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("client should connect");
let mut received = vec![0_u8; request_len];
stream
.read_exact(&mut received)
.expect("peer-wire request should read");
let (handshake, consumed) = PeerWireHandshakeModel::parse_prefix(&received)
.expect("request handshake should parse");
assert_eq!(consumed, request_len);
assert_eq!(handshake.info_hash, info_hash);
assert_eq!(handshake.peer_id, local_peer_id);
let response = PeerWireHandshakeModel::new(info_hash, remote_peer_id).serialize();
stream
.write_all(&response)
.expect("peer-wire response should write");
});
let connector = StdTcpPeerWireTransportConnector::with_timeouts(
Duration::from_secs(1),
Duration::from_secs(1),
);
let response = connector
.connect_peer_wire(&PeerWireTransportRequest {
endpoint: TransportEndpoint {
scheme: TransportScheme::BitTorrent,
address: addr.to_string(),
},
info_hash: info_hash.to_vec(),
peer_id: local_peer_id.to_vec(),
payload: request_payload,
})
.expect("live peer-wire connector should exchange handshake");
let handshake =
PeerWireHandshakeModel::parse(&response.payload).expect("response handshake should parse");
assert_eq!(handshake.info_hash, info_hash);
assert_eq!(handshake.peer_id, remote_peer_id);
handle.join().expect("tcp server thread should join");
}