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