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