chore: initial sanitized public snapshot
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user