392 lines
14 KiB
Rust
392 lines
14 KiB
Rust
//! 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}"
|
|
);
|
|
}
|
|
}
|