chore: initial sanitized public snapshot

This commit is contained in:
Aria2 Rust Pro Contributors
2026-07-18 16:01:12 +08:00
commit 7c6b6a3746
321 changed files with 76896 additions and 0 deletions
@@ -0,0 +1,362 @@
use super::{
BTreeMap, BtFileInfo, BtPeerInfo, BtRuntimeState, BtTrackerInfo, DhtNodeModel, Digest,
MagnetBootstrapModel, MagnetUriModel, PieceId, PieceMap, PieceState, RequestGroup, RpcError,
TorrentMetadataModel, hex_string, merge_bt_dht_nodes, parse_torrent_metadata,
};
/// Extracts an uppercase BitTorrent info hash from a magnet URI.
pub(in crate::dispatcher) fn rpc_bt_info_hash(uri: &str) -> Option<String> {
let lower = uri.to_ascii_lowercase();
let marker = "xt=urn:btih:";
let start = lower.find(marker)?;
let raw = &uri[start + marker.len()..];
let token = raw.split('&').next().unwrap_or(raw);
let normalized: String = token
.chars()
.filter(|ch| ch.is_ascii_alphanumeric())
.map(|ch| ch.to_ascii_uppercase())
.collect();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
/// Converts parsed torrent metadata into the core BitTorrent runtime model.
pub(in crate::dispatcher) fn build_bt_runtime_state(
metadata: &TorrentMetadataModel,
) -> BtRuntimeState {
let trackers: Vec<BtTrackerInfo> = metadata
.trackers
.iter()
.map(|tracker| BtTrackerInfo {
url: tracker.url.clone(),
tier: tracker.tier,
id: tracker.id.clone(),
seeders: tracker.seeders,
leechers: tracker.leechers,
})
.collect();
let files = metadata
.info
.files
.iter()
.map(|file| BtFileInfo {
path: file.path.clone(),
length: file.length,
piece_offset: file.piece_offset,
selected: file.selected,
})
.collect();
let peers = metadata
.peers
.iter()
.map(|peer| BtPeerInfo {
peer_id: peer.peer_id.map(|peer_id| hex_string(&peer_id)),
ip: peer.ip.clone(),
port: peer.port,
client_name: peer.client_name.clone(),
interested: peer.interested,
choked: peer.choked,
download_speed: 0,
upload_speed: 0,
seeder: false,
})
.collect();
let info_hash = metadata
.info
.hash
.as_ref()
.map(|hash| hash.info_hash_hex.to_ascii_uppercase())
.unwrap_or_default();
let magnet_uri = (!info_hash.is_empty()).then(|| {
MagnetUriModel {
info_hash: info_hash.clone(),
display_name: Some(metadata.info.name.clone()),
trackers: trackers.iter().map(|tracker| tracker.url.clone()).collect(),
web_seeds: Vec::new(),
exact_topic: None,
}
.to_uri()
});
BtRuntimeState {
info_hash,
name: Some(metadata.info.name.clone()),
magnet_uri,
metadata_only: false,
metadata_size: None,
metadata_extension_ids: BTreeMap::new(),
metadata_piece_payloads: BTreeMap::new(),
creation_date: metadata.creation_date.clone(),
comment: metadata.comment.clone(),
dht_nodes: initial_bt_dht_nodes(&metadata.dht_nodes),
files,
trackers,
peers,
}
}
/// Seeds BitTorrent runtime state from a magnet URI before metadata arrives.
pub(in crate::dispatcher) fn build_bt_runtime_state_from_magnet(
uri: &str,
magnet: &MagnetBootstrapModel,
) -> BtRuntimeState {
let magnet_uri = Some(uri.to_owned());
let trackers = magnet
.trackers
.iter()
.map(|tracker| BtTrackerInfo {
url: tracker.url.clone(),
tier: tracker.tier,
id: tracker.id.clone(),
seeders: tracker.seeders,
leechers: tracker.leechers,
})
.collect();
let peer_hints = magnet
.peer_hints
.iter()
.map(|peer| BtPeerInfo {
peer_id: peer
.peer_id
.map(|peer_id: [u8; 20]| hex_string(&peer_id[..])),
ip: peer.ip.clone(),
port: peer.port,
client_name: peer.client_name.clone(),
interested: peer.interested,
choked: peer.choked,
download_speed: 0,
upload_speed: 0,
seeder: false,
})
.collect();
let hinted_dht_nodes = magnet
.peer_hint_nodes
.iter()
.map(DhtNodeModel::to_spec)
.collect::<Vec<_>>();
BtRuntimeState {
info_hash: magnet.info_hash_hex.to_ascii_uppercase(),
name: magnet.uri.display_name.clone(),
magnet_uri,
metadata_only: true,
metadata_size: None,
metadata_extension_ids: BTreeMap::new(),
metadata_piece_payloads: BTreeMap::new(),
creation_date: None,
comment: None,
dht_nodes: initial_bt_dht_nodes(&hinted_dht_nodes),
files: Vec::new(),
trackers,
peers: peer_hints,
}
}
/// Returns the built-in fallback DHT router list.
pub(in crate::dispatcher) fn default_bt_dht_nodes() -> Vec<String> {
vec![
"router.bittorrent.com:6881".to_owned(),
"dht.transmissionbt.com:6881".to_owned(),
"router.utorrent.com:6881".to_owned(),
]
}
/// Chooses explicit DHT nodes when present and otherwise falls back to router defaults.
pub(in crate::dispatcher) fn initial_bt_dht_nodes(explicit_nodes: &[String]) -> Vec<String> {
if explicit_nodes.is_empty() {
default_bt_dht_nodes()
} else {
explicit_nodes.to_vec()
}
}
/// Returns the BEP 9 metadata piece size used by aria2-compatible peers.
pub(in crate::dispatcher) const BT_METADATA_PIECE_LENGTH: u64 = 16 * 1024;
/// Returns the number of metadata pieces needed for one BEP 9 payload size.
pub(in crate::dispatcher) fn bt_metadata_piece_count(metadata_size: u32) -> u32 {
if metadata_size == 0 {
return 0;
}
metadata_size.div_ceil(BT_METADATA_PIECE_LENGTH as u32)
}
/// Returns the byte length of one metadata piece within the BEP 9 payload.
pub(in crate::dispatcher) fn bt_metadata_piece_span(metadata_size: u32, piece_index: u32) -> usize {
let piece_start = u64::from(piece_index).saturating_mul(BT_METADATA_PIECE_LENGTH);
let metadata_size = u64::from(metadata_size);
if piece_start >= metadata_size {
return 0;
}
let remaining = metadata_size.saturating_sub(piece_start);
usize::try_from(remaining.min(BT_METADATA_PIECE_LENGTH)).unwrap_or(usize::MAX)
}
/// Returns a stable runtime key for one BT peer endpoint.
pub(in crate::dispatcher) fn bt_peer_metadata_key(peer: &BtPeerInfo) -> String {
format!("{}:{}", peer.ip, peer.port)
}
/// Merges BT tracker snapshots by URL while preserving any existing runtime rows.
pub(in crate::dispatcher) fn merge_bt_trackers(
existing: &mut Vec<BtTrackerInfo>,
incoming: Vec<BtTrackerInfo>,
) {
for tracker in incoming {
if existing.iter().any(|current| current.url == tracker.url) {
continue;
}
existing.push(tracker);
}
}
/// Merges BT peer snapshots while preserving existing runtime rows.
pub(in crate::dispatcher) fn merge_bt_peers(
existing: &mut Vec<BtPeerInfo>,
incoming: Vec<BtPeerInfo>,
) {
for peer in incoming {
if let Some(current) = existing.iter_mut().find(|candidate| {
candidate.peer_id.as_deref() == peer.peer_id.as_deref()
|| (candidate.ip == peer.ip && candidate.port == peer.port)
}) {
*current = peer;
} else {
existing.push(peer);
}
}
}
/// Promotes a metadata-only magnet runtime into a full torrent-backed BT session when ready.
pub(in crate::dispatcher) fn try_promote_bt_metadata(
group: &mut RequestGroup,
) -> Result<bool, RpcError> {
let Some(bt) = group.bt().cloned() else {
return Err(RpcError::unsupported(
"metadata promotion requires bt runtime state",
));
};
if !bt.metadata_only {
return Ok(false);
}
let Some(metadata_size) = bt.metadata_size.filter(|size| *size > 0) else {
return Ok(false);
};
let piece_count = bt_metadata_piece_count(metadata_size);
if piece_count == 0 {
return Ok(false);
}
let mut metadata_bytes =
Vec::with_capacity(usize::try_from(metadata_size).unwrap_or(usize::MAX));
for piece in 0..piece_count {
let Some(payload) = bt.metadata_piece_payloads.get(&piece) else {
return Ok(false);
};
let expected_len = bt_metadata_piece_span(metadata_size, piece);
if payload.len() < expected_len {
return Ok(false);
}
metadata_bytes.extend_from_slice(&payload[..expected_len]);
}
metadata_bytes.truncate(usize::try_from(metadata_size).unwrap_or(usize::MAX));
let metadata = parse_torrent_metadata(&metadata_bytes).map_err(|error| {
RpcError::unsupported(&format!("invalid magnet metadata payload: {error}"))
})?;
let parsed_info_hash = metadata
.info
.hash
.as_ref()
.map(|hash| hash.info_hash_hex.to_ascii_uppercase())
.unwrap_or_default();
if parsed_info_hash.is_empty() {
return Err(RpcError::unsupported(
"promoted torrent metadata did not expose an info hash",
));
}
if !bt.info_hash.is_empty() && !parsed_info_hash.eq_ignore_ascii_case(&bt.info_hash) {
return Err(RpcError::unsupported(&format!(
"magnet metadata info hash mismatch: expected {}, got {parsed_info_hash}",
bt.info_hash
)));
}
let mut promoted = build_bt_runtime_state(&metadata);
promoted.magnet_uri = bt.magnet_uri.clone();
promoted.metadata_size = Some(metadata_size);
promoted.metadata_extension_ids = bt.metadata_extension_ids.clone();
promoted.metadata_piece_payloads = bt.metadata_piece_payloads.clone();
promoted.dht_nodes = bt.dht_nodes.clone();
merge_bt_dht_nodes(&mut promoted.dht_nodes, metadata.dht_nodes.iter().cloned());
merge_bt_trackers(&mut promoted.trackers, bt.trackers.clone());
let metadata_peers = std::mem::take(&mut promoted.peers);
promoted.peers = bt.peers.clone();
merge_bt_peers(&mut promoted.peers, metadata_peers);
group.set_bt(promoted);
group.set_total_length(metadata.total_length());
group.set_piece_length(metadata.info.piece_length.max(1));
group.set_completed_length(0);
group.clear_piece_availability();
group.clear_segment_assignments();
*group.piece_map_mut() = PieceMap::new();
for piece in &metadata.pieces {
group.set_piece_state(PieceId(piece.index), PieceState::Pending);
}
Ok(true)
}
/// Computes the effective total length visible to BitTorrent runtime reporting.
pub(in crate::dispatcher) fn bt_runtime_total_length(group: &RequestGroup) -> u64 {
group.total_length().max(
group
.bt()
.map(BtRuntimeState::selected_or_all_total_length)
.unwrap_or_default(),
)
}
/// Computes the number of pieces required to cover a torrent payload.
pub(in crate::dispatcher) fn bt_piece_count(total_length: u64, piece_length: u64) -> usize {
if total_length == 0 {
0
} else {
total_length.div_ceil(piece_length.max(1)) as usize
}
}
/// Computes the byte span represented by a single BitTorrent piece.
pub(in crate::dispatcher) fn bt_piece_span_bytes(
piece: PieceId,
piece_length: u64,
total_length: u64,
) -> u64 {
let piece_length = piece_length.max(1);
if total_length == 0 {
return piece_length;
}
let start = u64::from(piece.0).saturating_mul(piece_length);
total_length.saturating_sub(start).min(piece_length)
}
/// Sums the verified length implied by the request group's piece map.
pub(in crate::dispatcher) fn bt_verified_length(
group: &RequestGroup,
piece_length: u64,
total_length: u64,
) -> u64 {
group
.piece_map()
.iter()
.filter(|(_, state)| **state == PieceState::Verified)
.map(|(piece, _)| bt_piece_span_bytes(*piece, piece_length, total_length))
.sum()
}
/// Returns whether a BT peer row is usable for an outbound peer-wire exchange.
pub(in crate::dispatcher) fn bt_peer_is_connectable(peer: &BtPeerInfo) -> bool {
!peer.ip.trim().is_empty() && peer.port != 0
}
@@ -0,0 +1,240 @@
use super::{
BtPeerInfo, BtRuntimeState, DhtMessageModel, DhtNodeModel, Digest, DownloadId, RequestGroup,
RpcError, rpc_bt_info_hash,
};
/// Builds the outbound DHT `get_peers` message and selected target node.
pub(in crate::dispatcher) fn build_dht_get_peers_request(
group: &RequestGroup,
) -> Result<(DhtNodeModel, DhtMessageModel), RpcError> {
let bt = group
.bt()
.ok_or_else(|| RpcError::unsupported("dht get_peers requires bt runtime state"))?;
let node = pick_bt_dht_node(bt.dht_nodes())?;
let info_hash = resolve_bt_info_hash(group, bt)?;
Ok((
node,
DhtMessageModel::get_peers_query(
b"gp".to_vec(),
rpc_bt_local_node_id(group.gid()),
info_hash,
),
))
}
/// Builds the outbound DHT `ping` message and selected target node.
pub(in crate::dispatcher) fn build_dht_ping_request(
group: &RequestGroup,
) -> Result<(DhtNodeModel, DhtMessageModel), RpcError> {
let bt = group
.bt()
.ok_or_else(|| RpcError::unsupported("dht ping requires bt runtime state"))?;
let node = pick_bt_dht_node(bt.dht_nodes())?;
Ok((
node,
DhtMessageModel::ping_query(b"pi".to_vec(), rpc_bt_local_node_id(group.gid())),
))
}
/// Builds the outbound DHT `find_node` message and selected target node.
pub(in crate::dispatcher) fn build_dht_find_node_request(
group: &RequestGroup,
) -> Result<(DhtNodeModel, DhtMessageModel), RpcError> {
let bt = group
.bt()
.ok_or_else(|| RpcError::unsupported("dht find_node requires bt runtime state"))?;
let node = pick_bt_dht_node(bt.dht_nodes())?;
let target = resolve_bt_info_hash(group, bt)?;
Ok((
node,
DhtMessageModel::find_node_query(b"fn".to_vec(), rpc_bt_local_node_id(group.gid()), target),
))
}
/// Builds the outbound DHT `announce_peer` message and selected target node.
pub(in crate::dispatcher) fn build_dht_announce_peer_request(
group: &RequestGroup,
) -> Result<(DhtNodeModel, DhtMessageModel), RpcError> {
let bt = group
.bt()
.ok_or_else(|| RpcError::unsupported("dht announce_peer requires bt runtime state"))?;
let node = pick_bt_dht_node(bt.dht_nodes())?;
let token = group
.dht_token()
.map(|token| token.to_vec())
.ok_or_else(|| {
RpcError::unsupported("dht announce_peer requires token from prior get_peers")
})?;
let info_hash = resolve_bt_info_hash(group, bt)?;
Ok((
node,
DhtMessageModel::announce_peer_query(
b"ap".to_vec(),
rpc_bt_local_node_id(group.gid()),
info_hash,
6881,
token,
false,
),
))
}
/// Chooses the first parseable DHT node entry from runtime state.
pub(in crate::dispatcher) fn pick_bt_dht_node(nodes: &[String]) -> Result<DhtNodeModel, RpcError> {
if nodes.is_empty() {
return Err(RpcError::unsupported(
"dht get_peers requires at least one dht node",
));
}
let mut last_error = None;
for node in nodes {
match parse_dht_node_spec(node) {
Ok(parsed) => return Ok(parsed),
Err(error) => last_error = Some(error.message),
}
}
Err(RpcError::unsupported(&format!(
"dht get_peers found no valid dht nodes in runtime state{}",
last_error
.map(|message| format!(": {message}"))
.unwrap_or_default()
)))
}
/// Resolves the BitTorrent info hash bytes required by DHT and peer-wire requests.
pub(in crate::dispatcher) fn resolve_bt_info_hash(
group: &RequestGroup,
bt: &BtRuntimeState,
) -> Result<Vec<u8>, RpcError> {
let info_hash = if !bt.info_hash.is_empty() {
bt.info_hash.clone()
} else {
rpc_bt_info_hash(group.uri()).unwrap_or_default()
};
decode_hex_string_exact(&info_hash, 20, "dht get_peers info hash")
.map_err(|error| RpcError::unsupported(&error))
}
/// Parses a `host:port` DHT node spec into a transport model.
pub(in crate::dispatcher) fn parse_dht_node_spec(raw: &str) -> Result<DhtNodeModel, RpcError> {
let (address, port_raw) = raw
.rsplit_once(':')
.ok_or_else(|| RpcError::unsupported("dht node entry must use host:port format"))?;
let port = port_raw
.parse::<u16>()
.map_err(|_| RpcError::unsupported("dht node port must be a valid u16"))?;
Ok(DhtNodeModel {
node_id: String::new(),
address: address.to_owned(),
port,
})
}
/// Derives a deterministic local DHT node ID from a download GID.
pub(in crate::dispatcher) fn rpc_bt_local_node_id(gid: DownloadId) -> Vec<u8> {
let gid_hex = format!("{:040x}", gid.as_u64());
decode_hex_string_exact(&gid_hex, 20, "local dht node id").unwrap_or_else(|_| vec![0_u8; 20])
}
/// Decodes a fixed-width hexadecimal string into raw bytes.
pub(in crate::dispatcher) fn decode_hex_string_exact(
raw: &str,
expected_len: usize,
label: &str,
) -> Result<Vec<u8>, String> {
if raw.len() != expected_len * 2 {
return Err(format!(
"{label} must be {} hex characters",
expected_len * 2
));
}
let mut bytes = Vec::with_capacity(expected_len);
for pair in raw.as_bytes().chunks_exact(2) {
let hi = decode_hex_nibble(pair[0])
.ok_or_else(|| format!("{label} contains non-hex characters"))?;
let lo = decode_hex_nibble(pair[1])
.ok_or_else(|| format!("{label} contains non-hex characters"))?;
bytes.push((hi << 4) | lo);
}
Ok(bytes)
}
/// Decodes a single ASCII hex nibble.
pub(in crate::dispatcher) 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,
}
}
/// Parses compact peer payloads returned by DHT `get_peers`.
pub(in crate::dispatcher) fn parse_dht_compact_peers(
values: &[Vec<u8>],
) -> Result<Vec<BtPeerInfo>, 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(BtPeerInfo {
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,
download_speed: 0,
upload_speed: 0,
seeder: false,
});
}
}
Ok(peers)
}
/// Parses compact DHT node payload bytes into `host:port` strings.
pub(in crate::dispatcher) fn parse_dht_compact_nodes(
raw: Option<&[u8]>,
) -> Result<Vec<String>, String> {
let Some(raw) = raw else {
return Ok(Vec::new());
};
if raw.len() % 26 != 0 {
return Err("compact dht node list length must be a multiple of 26".to_owned());
}
let mut nodes = Vec::new();
for chunk in raw.chunks_exact(26) {
let ip = format!("{}.{}.{}.{}", chunk[20], chunk[21], chunk[22], chunk[23]);
let port = u16::from_be_bytes([chunk[24], chunk[25]]);
nodes.push(format!("{ip}:{port}"));
}
Ok(nodes)
}
/// Appends newly discovered DHT nodes while preserving existing order.
pub(in crate::dispatcher) fn merge_bt_dht_nodes<I>(existing: &mut Vec<String>, discovered: I)
where
I: IntoIterator<Item = String>,
{
for node in discovered {
if !existing.iter().any(|current| current == &node) {
existing.push(node);
}
}
}
/// Moves a successfully used DHT node to the front of the runtime node list.
pub(in crate::dispatcher) fn promote_bt_dht_node(existing: &mut Vec<String>, node: &DhtNodeModel) {
let entry = format!("{}:{}", node.address, node.port);
if let Some(index) = existing.iter().position(|current| current == &entry) {
if index > 0 {
let value = existing.remove(index);
existing.insert(0, value);
}
} else {
existing.insert(0, entry);
}
}
@@ -0,0 +1,360 @@
use super::{
BTreeMap, BTreeSet, BtPeerInfo, Digest, PeerWireBlockRequestModel,
PeerWireExtensionHandshakeModel, PeerWireHandshakeModel, PeerWireMessageKind,
PeerWireMetadataMessageModel, PeerWirePieceBlockModel, PeerWireTransportRequest,
PeerWireTransportResponse, PieceState, RequestGroup, RpcError, TorrentMessageModel,
TransportEndpoint, TransportScheme, bt_metadata_piece_count, bt_peer_metadata_key,
bt_piece_span_bytes, bt_runtime_total_length, hex_string, resolve_bt_info_hash,
rpc_bt_local_node_id,
};
/// Identifies the peer selected for a peer-wire compatibility exchange.
#[derive(Clone, Debug)]
pub(in crate::dispatcher) struct PeerWirePeerTarget {
/// Original peer index inside the runtime peer list.
pub(in crate::dispatcher) index: usize,
/// Peer runtime snapshot used to build the outbound request.
pub(in crate::dispatcher) peer: BtPeerInfo,
}
/// Holds the outbound peer-wire request and bookkeeping for a compatibility probe.
#[derive(Clone, Debug)]
pub(in crate::dispatcher) struct PeerWireExchangePlan {
/// Peer list index that should receive the parsed response data.
pub(in crate::dispatcher) peer_index: usize,
/// Expected info hash validated against the peer handshake.
pub(in crate::dispatcher) info_hash: [u8; 20],
/// Transport payload sent to the peer.
pub(in crate::dispatcher) request: PeerWireTransportRequest,
/// Optional block request emitted after the handshake.
pub(in crate::dispatcher) block_request: Option<PeerWireBlockRequestModel>,
/// Optional known `ut_metadata` extension id for the selected peer.
pub(in crate::dispatcher) metadata_extension_id: Option<u8>,
}
/// Captures peer-wire handshake and frame state recovered from a peer response.
#[derive(Clone, Debug, Default)]
pub(in crate::dispatcher) struct PeerWireExchangeResponseModel {
/// Remote peer ID emitted by the handshake when present.
pub(in crate::dispatcher) peer_id: Option<String>,
/// Parsed remote extended handshake, when observed.
pub(in crate::dispatcher) extension_handshake: Option<PeerWireExtensionHandshakeModel>,
/// Latest observed choke state.
pub(in crate::dispatcher) peer_choked: Option<bool>,
/// Latest observed interest state.
pub(in crate::dispatcher) peer_interested: Option<bool>,
/// Piece set explicitly advertised by a bitfield frame.
pub(in crate::dispatcher) bitfield_pieces: Option<BTreeSet<u32>>,
/// Aggregate set of pieces implied by bitfield, have, and piece frames.
pub(in crate::dispatcher) available_pieces: BTreeSet<u32>,
/// Piece payload frames recovered from the response.
pub(in crate::dispatcher) pieces: Vec<PeerWirePieceBlockModel>,
/// Metadata payloads recovered from BEP 9 messages.
pub(in crate::dispatcher) metadata_messages: Vec<PeerWireMetadataMessageModel>,
}
/// Builds the outbound peer-wire request for the current BitTorrent runtime state.
pub(in crate::dispatcher) fn build_peer_wire_exchange_plan(
group: &RequestGroup,
) -> Result<PeerWireExchangePlan, RpcError> {
let bt = group
.bt()
.ok_or_else(|| RpcError::unsupported("peer-wire exchange requires bt runtime state"))?;
let target = pick_bt_peer_target(&bt.peers)?;
let info_hash_vec = resolve_bt_info_hash(group, bt)?;
let info_hash: [u8; 20] = info_hash_vec
.as_slice()
.try_into()
.map_err(|_| RpcError::unsupported("peer-wire exchange info hash must be 20 bytes"))?;
let peer_id_vec = rpc_bt_local_node_id(group.gid());
let peer_id: [u8; 20] = peer_id_vec
.clone()
.try_into()
.map_err(|_| RpcError::unsupported("peer-wire exchange peer id must be 20 bytes"))?;
let peer_key = bt_peer_metadata_key(&target.peer);
let metadata_extension_id = bt.metadata_extension_ids.get(&peer_key).copied();
let metadata_request_piece = if bt.metadata_only {
match (metadata_extension_id, bt.metadata_size) {
(Some(_), Some(metadata_size)) if metadata_size > 0 => {
let piece_count = bt_metadata_piece_count(metadata_size);
(0..piece_count).find(|piece| !bt.metadata_piece_payloads.contains_key(piece))
}
_ => None,
}
} else {
None
};
let mut handshake = PeerWireHandshakeModel::new(info_hash, peer_id);
handshake.reserved[5] |= 0x10;
let mut payload = handshake.serialize();
let mut block_request = None;
if bt.metadata_only {
let extension_handshake = PeerWireExtensionHandshakeModel {
extensions: BTreeMap::from([("ut_metadata".to_owned(), 1_u8)]),
client_name: Some("aria2-rust-pro".to_owned()),
metadata_size: None,
request_queue: Some(16),
};
payload.extend_from_slice(
&TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Extension(
extension_handshake.to_peer_wire_message(),
))
.serialize_peer_wire_frame()
.map_err(|error| {
RpcError::unsupported(&format!(
"peer-wire extension handshake serialization failed: {error}"
))
})?,
);
if let (Some(extension_message_id), Some(piece)) =
(metadata_extension_id, metadata_request_piece)
{
payload.extend_from_slice(
&TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Extension(
PeerWireMetadataMessageModel::request(piece)
.to_peer_wire_message(extension_message_id),
))
.serialize_peer_wire_frame()
.map_err(|error| {
RpcError::unsupported(&format!(
"peer-wire metadata request serialization failed: {error}"
))
})?,
);
}
} else {
let piece_length = group.piece_length().max(1);
let total_length = bt_runtime_total_length(group);
let requestable = group.bt_requestable_piece_ids(false, 8);
let availability = group.piece_availability();
let selected_piece = requestable
.iter()
.copied()
.find(|piece| availability.get(piece).copied().unwrap_or(0) > 0)
.or_else(|| requestable.first().copied());
block_request = selected_piece.map(|piece| PeerWireBlockRequestModel {
piece_index: piece.0,
block_offset: 0,
block_length: bt_piece_span_bytes(piece, piece_length, total_length)
.min(16_u64 * 1024)
.max(1) as u32,
});
if block_request.is_some() {
payload.extend_from_slice(
&TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Interested)
.serialize_peer_wire_frame()
.map_err(|error| {
RpcError::unsupported(&format!(
"peer-wire interested frame serialization failed: {error}"
))
})?,
);
}
if let Some(block_request) = &block_request {
payload.extend_from_slice(
&TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Request(
block_request.clone(),
))
.serialize_peer_wire_frame()
.map_err(|error| {
RpcError::unsupported(&format!(
"peer-wire request frame serialization failed: {error}"
))
})?,
);
}
}
Ok(PeerWireExchangePlan {
peer_index: target.index,
info_hash,
request: PeerWireTransportRequest {
endpoint: TransportEndpoint {
scheme: TransportScheme::BitTorrent,
address: format!("{}:{}", target.peer.ip, target.peer.port),
},
info_hash: info_hash_vec,
peer_id: peer_id_vec,
payload,
},
block_request,
metadata_extension_id,
})
}
/// Chooses the best available peer target for a peer-wire exchange.
pub(super) fn pick_bt_peer_target(peers: &[BtPeerInfo]) -> Result<PeerWirePeerTarget, RpcError> {
if peers.is_empty() {
return Err(RpcError::unsupported(
"peer-wire exchange requires at least one bt peer",
));
}
let mut last_error = None;
let mut fallback = None;
for (index, peer) in peers.iter().enumerate() {
if peer.ip.trim().is_empty() {
last_error = Some("peer ip must not be empty".to_owned());
continue;
}
if peer.port == 0 {
last_error = Some("peer port must be non-zero".to_owned());
continue;
}
let target = PeerWirePeerTarget {
index,
peer: peer.clone(),
};
if !peer.choked {
return Ok(target);
}
if fallback.is_none() {
fallback = Some(target);
}
}
if let Some(target) = fallback {
return Ok(target);
}
Err(RpcError::unsupported(&format!(
"peer-wire exchange found no valid bt peers in runtime state{}",
last_error
.map(|message| format!(": {message}"))
.unwrap_or_default()
)))
}
/// Parses a peer-wire transport payload into normalized runtime update data.
pub(in crate::dispatcher) fn parse_peer_wire_exchange_response(
response: &PeerWireTransportResponse,
expected_info_hash: &[u8; 20],
known_metadata_extension_id: Option<u8>,
) -> Result<PeerWireExchangeResponseModel, RpcError> {
let mut parsed = PeerWireExchangeResponseModel::default();
let mut cursor = 0;
let mut negotiated_metadata_extension_id = known_metadata_extension_id;
if response.payload.first().copied() == Some(19) {
let (handshake, consumed) = PeerWireHandshakeModel::parse_prefix(&response.payload)
.map_err(|error| {
RpcError::unsupported(&format!("invalid peer-wire handshake: {error}"))
})?;
if handshake.info_hash != *expected_info_hash {
return Err(RpcError::unsupported(
"peer-wire handshake info hash did not match download runtime state",
));
}
parsed.peer_id = Some(hex_string(&handshake.peer_id).to_ascii_lowercase());
cursor = consumed;
}
while cursor < response.payload.len() {
let (frame, consumed) = TorrentMessageModel::parse_peer_wire_frame(
&response.payload[cursor..],
)
.map_err(|error| RpcError::unsupported(&format!("invalid peer-wire frame: {error}")))?;
cursor = cursor.saturating_add(consumed);
match frame.peer_wire_kind().map_err(|error| {
RpcError::unsupported(&format!("invalid peer-wire message: {error}"))
})? {
PeerWireMessageKind::Choke => parsed.peer_choked = Some(true),
PeerWireMessageKind::Unchoke => parsed.peer_choked = Some(false),
PeerWireMessageKind::Interested => parsed.peer_interested = Some(true),
PeerWireMessageKind::NotInterested => parsed.peer_interested = Some(false),
PeerWireMessageKind::Have(piece) => {
parsed.available_pieces.insert(piece);
}
PeerWireMessageKind::Bitfield(bitfield) => {
let available = bitfield
.to_piece_flags(bitfield.piece_capacity())
.into_iter()
.enumerate()
.filter_map(|(piece, has_piece)| has_piece.then_some(piece as u32))
.collect::<BTreeSet<_>>();
parsed.available_pieces.extend(available.iter().copied());
parsed.bitfield_pieces = Some(available);
}
PeerWireMessageKind::Piece(piece) => {
parsed.available_pieces.insert(piece.piece_index);
parsed.pieces.push(piece);
}
PeerWireMessageKind::Extension(message) => {
if message.extension_message_id == 0 {
let handshake = PeerWireExtensionHandshakeModel::from_peer_wire_message(
&message,
)
.map_err(|error| {
RpcError::unsupported(&format!(
"invalid peer-wire extended handshake: {error}"
))
})?;
negotiated_metadata_extension_id = handshake
.ut_metadata_id()
.or(negotiated_metadata_extension_id);
parsed.extension_handshake = Some(handshake);
} else if negotiated_metadata_extension_id
.is_some_and(|extension_id| extension_id == message.extension_message_id)
{
let metadata_message = PeerWireMetadataMessageModel::from_peer_wire_message(
&message,
negotiated_metadata_extension_id.expect("checked is_some above"),
)
.map_err(|error| {
RpcError::unsupported(&format!(
"invalid peer-wire ut_metadata payload: {error}"
))
})?;
parsed.metadata_messages.push(metadata_message);
}
}
PeerWireMessageKind::KeepAlive
| PeerWireMessageKind::Request(_)
| PeerWireMessageKind::Cancel(_)
| PeerWireMessageKind::Port(_)
| PeerWireMessageKind::Unknown(_) => {}
}
}
Ok(parsed)
}
/// Computes how many verified bytes overlap a requested byte range.
pub(in crate::dispatcher) fn verified_length_for_range(
group: &RequestGroup,
range_start: u64,
range_length: u64,
piece_length: u64,
total_length: u64,
) -> u64 {
if range_length == 0 || piece_length == 0 || total_length == 0 {
return 0;
}
let range_end = range_start.saturating_add(range_length).min(total_length);
if range_end <= range_start {
return 0;
}
group
.piece_map()
.iter()
.filter(|(_, state)| **state == PieceState::Verified)
.map(|(piece, _)| {
let piece_start = u64::from(piece.0).saturating_mul(piece_length);
let piece_end = piece_start
.saturating_add(bt_piece_span_bytes(*piece, piece_length, total_length))
.min(total_length);
let overlap_start = piece_start.max(range_start);
let overlap_end = piece_end.min(range_end);
overlap_end.saturating_sub(overlap_start)
})
.sum()
}
/// Returns whether a peer-wire bitfield covers every expected piece index.
pub(in crate::dispatcher) fn peer_wire_bitfield_is_complete(
pieces: &BTreeSet<u32>,
expected_piece_count: usize,
) -> bool {
expected_piece_count > 0
&& pieces.len() >= expected_piece_count
&& (0..expected_piece_count as u32).all(|piece| pieces.contains(&piece))
}
@@ -0,0 +1,91 @@
use super::{
AtomicU64, BtRuntimeCoordinatorAction, BtRuntimeCoordinatorStepReport,
BtRuntimeCoordinatorStepStatus, Digest, Ordering, RpcError, Sha1, SystemTime, UNIX_EPOCH,
};
/// Converts a successful or failed coordinator action result into a stable report row.
pub(in crate::dispatcher) fn push_bt_runtime_coordinator_result(
steps: &mut Vec<BtRuntimeCoordinatorStepReport>,
action: BtRuntimeCoordinatorAction,
result: Result<(), RpcError>,
) {
match result {
Ok(()) => steps.push(BtRuntimeCoordinatorStepReport {
action,
status: BtRuntimeCoordinatorStepStatus::Executed,
detail: None,
}),
Err(error) => steps.push(BtRuntimeCoordinatorStepReport {
action,
status: BtRuntimeCoordinatorStepStatus::Failed,
detail: Some(error.message),
}),
}
}
/// Builds a skipped coordinator action report row with a concise reason.
pub(in crate::dispatcher) fn skipped_bt_runtime_coordinator_step(
action: BtRuntimeCoordinatorAction,
detail: &str,
) -> BtRuntimeCoordinatorStepReport {
BtRuntimeCoordinatorStepReport {
action,
status: BtRuntimeCoordinatorStepStatus::Skipped,
detail: Some(detail.to_owned()),
}
}
/// Monotonic nonce mixed into generated session IDs.
static NEXT_SESSION_ID_NONCE: AtomicU64 = AtomicU64::new(1);
/// Generates a stable hex session identifier for RPC clients.
pub(in crate::dispatcher) fn generate_session_id() -> String {
let now_nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos();
let nonce = NEXT_SESSION_ID_NONCE.fetch_add(1, Ordering::Relaxed);
let mut sha1 = Sha1::new();
sha1.update(now_nanos.to_le_bytes());
sha1.update(std::process::id().to_le_bytes());
sha1.update(nonce.to_le_bytes());
let digest = sha1.finalize();
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
/// Returns the upstream-style enabled feature list reported by `getVersion`.
pub(in crate::dispatcher) fn rpc_enabled_features() -> &'static [&'static str] {
&[
"Async DNS",
"BitTorrent",
"GZip",
"HTTPS",
"Message Digest",
"Metalink",
"XML-RPC",
"SFTP",
]
}
/// Encodes bytes as an uppercase hexadecimal string.
pub(in crate::dispatcher) fn hex_string(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02X}"));
}
out
}
/// Formats the share ratio text expected by aria2 RPC payloads.
pub(in crate::dispatcher) fn rpc_share_ratio_text(share_ratio_milli: Option<u64>) -> String {
share_ratio_milli
.map(|milli| format!("{:.3}", milli as f64 / 1000.0))
.unwrap_or_else(|| "0.000".to_owned())
}
/// Formats the share time text expected by aria2 RPC payloads.
pub(in crate::dispatcher) fn rpc_share_time_text(
snapshot: &aria2_rust_pro_core::ProgressSnapshot,
) -> String {
snapshot.share_time_secs.unwrap_or(0).to_string()
}
@@ -0,0 +1,74 @@
use super::{BTreeSet, Digest, RequestGroup};
/// Applies a `select-file` option value to BitTorrent file selection state.
pub(in crate::dispatcher) fn apply_bt_select_file_option(
group: &mut RequestGroup,
select_file: &str,
) -> Result<(), String> {
let Some(mut bt_state) = group.bt().cloned() else {
return Ok(());
};
if bt_state.files.is_empty() {
return Ok(());
}
let selected_indexes = parse_bt_select_file_indexes(select_file, bt_state.files.len())?;
for (index, file) in bt_state.files.iter_mut().enumerate() {
file.selected = selected_indexes.contains(&(index + 1));
}
group.set_bt(bt_state);
Ok(())
}
/// Parses aria2-style `select-file` syntax into a set of selected file indexes.
pub(in crate::dispatcher) fn parse_bt_select_file_indexes(
select_file: &str,
file_count: usize,
) -> Result<BTreeSet<usize>, String> {
let mut selected = BTreeSet::new();
let trimmed = select_file.trim();
if trimmed.is_empty() {
return Err("empty value".to_owned());
}
for token in trimmed
.split(',')
.map(str::trim)
.filter(|token| !token.is_empty())
{
if let Some((start_raw, end_raw)) = token.split_once('-') {
let start = start_raw
.trim()
.parse::<usize>()
.map_err(|_| format!("invalid start index `{start_raw}`"))?;
let end = end_raw
.trim()
.parse::<usize>()
.map_err(|_| format!("invalid end index `{end_raw}`"))?;
if start == 0 || end == 0 {
return Err("indexes are 1-based".to_owned());
}
let (lo, hi) = if start <= end {
(start, end)
} else {
(end, start)
};
if hi > file_count {
return Err(format!("index {hi} out of range 1..={file_count}"));
}
for index in lo..=hi {
selected.insert(index);
}
continue;
}
let index = token
.parse::<usize>()
.map_err(|_| format!("invalid index `{token}`"))?;
if index == 0 {
return Err("indexes are 1-based".to_owned());
}
if index > file_count {
return Err(format!("index {index} out of range 1..={file_count}"));
}
selected.insert(index);
}
Ok(selected)
}
@@ -0,0 +1,40 @@
use super::{RequestGroup, RpcError, TrackerRequestModel, rpc_bt_info_hash};
/// Builds a tracker announce request from the current BitTorrent runtime snapshot.
pub(in crate::dispatcher) fn build_tracker_request(
group: &RequestGroup,
) -> Result<TrackerRequestModel, RpcError> {
let bt = group
.bt()
.ok_or_else(|| RpcError::unsupported("tracker announce requires bt runtime state"))?;
let announce_url = bt
.trackers
.first()
.map(|tracker| tracker.url.clone())
.ok_or_else(|| RpcError::unsupported("tracker announce requires at least one tracker"))?;
let info_hash = if !bt.info_hash.is_empty() {
bt.info_hash.clone()
} else {
rpc_bt_info_hash(group.uri()).unwrap_or_default()
};
if info_hash.len() != 40 || !info_hash.chars().all(|ch| ch.is_ascii_hexdigit()) {
return Err(RpcError::unsupported(
"tracker announce requires a 40-character hex info hash",
));
}
let selected_total = group
.bt_selected_total_length()
.unwrap_or_else(|| group.total_length());
Ok(TrackerRequestModel {
announce_url,
info_hash,
peer_id: format!("{:040x}", group.gid().as_u64()),
port: 6881,
uploaded: group.upload_length(),
downloaded: group.completed_length(),
left: selected_total.saturating_sub(group.completed_length()),
event: Some("started".to_owned()),
compact: true,
numwant: Some(50),
})
}