fix(torrent): harden metadata reuse and live peer telemetry

- reuse validated tracker-bearing metainfo without restoring direct web seeds
- preserve Torrent file selection while making long paths scrollable and copyable
- add lifecycle-fenced peer and seeder summaries to Properties telemetry
- validate cache tracker metadata and cover malformed, stale, and path-copy cases
This commit is contained in:
NimBold
2026-08-14 13:13:06 +03:30
parent 314f4e2e00
commit de41dd55d6
23 changed files with 536 additions and 81 deletions
+10
View File
@@ -346,6 +346,16 @@ pub struct TorrentPeerDiagnostics {
pub truncated: bool,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentPeerSummary {
#[ts(type = "number")]
pub total_peers: u32,
#[ts(type = "number")]
pub total_seeders: u32,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
+93 -2
View File
@@ -6783,12 +6783,57 @@ async fn remove_magnet_metadata_probe_dir(path: &std::path::Path) -> Result<(),
}
}
struct MagnetMetadataProbeTelemetry {
info_hash: String,
started_at: Instant,
outcome: Option<&'static str>,
}
impl MagnetMetadataProbeTelemetry {
fn new(info_hash: &str, tracker_bearing: bool, proxy_configured: bool) -> Self {
log::debug!(
"magnet metadata probe started: info_hash={info_hash}, tracker_bearing={tracker_bearing}, proxy_configured={proxy_configured}"
);
Self {
info_hash: info_hash.to_string(),
started_at: Instant::now(),
outcome: None,
}
}
fn finish(&mut self, outcome: &'static str) {
if self.outcome.is_some() {
return;
}
self.outcome = Some(outcome);
log::debug!(
"magnet metadata probe finished: info_hash={}, outcome={outcome}, elapsed_ms={}",
self.info_hash,
self.started_at.elapsed().as_millis()
);
}
}
impl Drop for MagnetMetadataProbeTelemetry {
fn drop(&mut self) {
if self.outcome.is_none() {
log::debug!(
"magnet metadata probe finished: info_hash={}, outcome=canceled, elapsed_ms={}",
self.info_hash,
self.started_at.elapsed().as_millis()
);
}
}
}
async fn resolve_magnet_metadata(
app_handle: &tauri::AppHandle,
state: &AppState,
source: &str,
id: &str,
proxy: Option<&str>,
headers: Option<&str>,
cookies: Option<&str>,
cache: bool,
) -> Result<crate::ipc::TorrentMetadata, String> {
let expected = crate::torrent::inspect_source(source)?;
@@ -6798,6 +6843,7 @@ async fn resolve_magnet_metadata(
.transpose()?
.flatten();
if cache && crate::torrent::magnet_allows_cached_metadata(source) {
let cache_started_at = Instant::now();
match crate::torrent::read_cached_torrent_by_info_hash(app_handle, &expected.info_hash)
.await
{
@@ -6806,6 +6852,11 @@ async fn resolve_magnet_metadata(
crate::torrent::validate_info_hash(Some(&expected.info_hash), &parsed.info_hash)?;
let torrent_path =
crate::torrent::cache_torrent_bytes(app_handle, id, &bytes).await?;
log::debug!(
"magnet metadata cache hit: info_hash={}, elapsed_ms={}",
expected.info_hash,
cache_started_at.elapsed().as_millis()
);
return Ok(crate::torrent::to_metadata(parsed, Some(torrent_path)));
}
Ok(None) => {}
@@ -6839,12 +6890,33 @@ async fn resolve_magnet_metadata(
options.insert("connect-timeout".to_string(), serde_json::json!("20"));
options.insert("timeout".to_string(), serde_json::json!("60"));
options.insert("auto-file-renaming".to_string(), serde_json::json!("false"));
if let Some(proxy) = proxy_value {
if let Some(proxy) = proxy_value.as_deref() {
options.insert("all-proxy".to_string(), serde_json::json!(proxy));
}
let mut header_list = Vec::new();
if let Some(cookies) = cookies.map(str::trim).filter(|value| !value.is_empty()) {
header_list.push(format!("Cookie: {cookies}"));
}
if let Some(headers) = headers {
header_list.extend(
headers
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string),
);
}
if !header_list.is_empty() {
options.insert("header".to_string(), serde_json::json!(header_list));
}
let client = std::sync::Arc::new(Aria2RpcClient { port, secret });
let sanitized_source = crate::torrent::sanitize_magnet_uri_for_aria2(source)?;
let mut telemetry = MagnetMetadataProbeTelemetry::new(
&expected.info_hash,
sanitized_source.contains("tr="),
proxy_value.is_some(),
);
let metadata_result = crate::torrent_probe::run_metadata_probe(
client,
&sanitized_source,
@@ -6855,6 +6927,12 @@ async fn resolve_magnet_metadata(
)
.await;
match &metadata_result {
Ok(_) => telemetry.finish("probe-success"),
Err(crate::torrent_probe::ProbeFailure::Metadata(_)) => telemetry.finish("metadata-failure"),
Err(crate::torrent_probe::ProbeFailure::Cleanup(_)) => telemetry.finish("cleanup-failure"),
}
let bytes = match metadata_result {
Ok(bytes) => bytes,
Err(crate::torrent_probe::ProbeFailure::Metadata(error)) => {
@@ -6917,6 +6995,8 @@ async fn inspect_torrent(
&source,
&id,
proxy.as_deref(),
headers.as_deref(),
cookies.as_deref(),
cache == Some(true),
)
.await
@@ -7390,6 +7470,17 @@ async fn get_torrent_peers(
state.queue_manager.get_aria2_torrent_peers(&id).await
}
#[tauri::command]
async fn get_torrent_peer_summary(
caller: tauri::WebviewWindow,
properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>,
state: tauri::State<'_, AppState>,
id: String,
) -> Result<crate::ipc::TorrentPeerSummary, String> {
properties_window::ensure_properties_or_main(&caller, &properties, &id)?;
state.queue_manager.get_aria2_torrent_peer_summary(&id).await
}
#[tauri::command]
async fn get_torrent_availability(
caller: tauri::WebviewWindow,
@@ -15467,7 +15558,7 @@ pub fn run() {
authorize_keychain_access,
acknowledge_pairing_token_change,
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
get_extension_server_port, set_extension_frontend_ready, ack_frontend_exit, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, get_torrent_peers, get_torrent_availability, get_torrent_file_progress, get_torrent_piece_progress, get_torrent_file_selection, set_torrent_file_selection, get_torrent_details, get_torrent_magnet_link, export_torrent_metadata, move_torrent_data, cancel_torrent_move_data, verify_torrent_data, get_torrent_web_seeds, set_torrent_web_seeds, set_torrent_max_open_files, set_torrent_overall_upload_limit, set_global_speed_limit, remove_download, get_download_primary_path,
get_extension_server_port, set_extension_frontend_ready, ack_frontend_exit, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, get_torrent_peers, get_torrent_peer_summary, get_torrent_availability, get_torrent_file_progress, get_torrent_piece_progress, get_torrent_file_selection, set_torrent_file_selection, get_torrent_details, get_torrent_magnet_link, export_torrent_metadata, move_torrent_data, cancel_torrent_move_data, verify_torrent_data, get_torrent_web_seeds, set_torrent_web_seeds, set_torrent_max_open_files, set_torrent_overall_upload_limit, set_global_speed_limit, remove_download, get_download_primary_path,
detach_download_for_reconfigure,
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
commands::reveal_in_file_manager, commands::open_downloaded_file,
+90 -34
View File
@@ -2661,14 +2661,11 @@ impl<R: tauri::Runtime> QueueManager<R> {
Ok(())
}
/// Return bounded peer diagnostics for the current Torrent GID. Endpoint
/// and peer-id fields live only in this response; they are not persisted.
/// The control lock and post-RPC mapping check prevent a late response from
/// being attributed to a replaced or terminal lifecycle.
pub async fn get_aria2_torrent_peers(
async fn get_aria2_torrent_peer_result(
&self,
id: &str,
) -> Result<crate::ipc::TorrentPeerDiagnostics, String> {
result_kind: &str,
) -> Result<serde_json::Value, String> {
let _control_guard = self.acquire_aria2_control(id).await;
if !self.is_registered(id).await {
return Err("Torrent peer diagnostics are unavailable for this lifecycle".to_string());
@@ -2710,8 +2707,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
crate::redact_sensitive_text(&error)
)
})?;
let diagnostics = parse_torrent_peer_diagnostics(result)?;
let still_current = self.is_registered(id).await
&& self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
@@ -2719,10 +2714,37 @@ impl<R: tauri::Runtime> QueueManager<R> {
&& self.is_current_aria2_gid_mapping(&gid, &expected_mapping)
&& self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str());
if !still_current {
return Err("Torrent lifecycle changed while reading peer diagnostics".to_string());
return Err(format!("Torrent lifecycle changed while reading {result_kind}"));
}
Ok(diagnostics)
Ok(result)
}
/// Return bounded peer diagnostics for the current Torrent GID. Endpoint
/// and peer-id fields live only in this response; they are not persisted.
/// The control lock and post-RPC mapping check prevent a late response from
/// being attributed to a replaced or terminal lifecycle.
pub async fn get_aria2_torrent_peers(
&self,
id: &str,
) -> Result<crate::ipc::TorrentPeerDiagnostics, String> {
let result = self
.get_aria2_torrent_peer_result(id, "peer diagnostics")
.await?;
parse_torrent_peer_diagnostics(result)
}
/// Return only aggregate peer/seeder counts for the current Torrent GID.
/// No peer addresses, IDs, bitfields, or transfer rates cross the IPC
/// boundary.
pub async fn get_aria2_torrent_peer_summary(
&self,
id: &str,
) -> Result<crate::ipc::TorrentPeerSummary, String> {
let result = self
.get_aria2_torrent_peer_result(id, "peer summary")
.await?;
parse_torrent_peer_summary(result)
}
/// Compute bounded, anonymized swarm availability for the current
@@ -5933,20 +5955,46 @@ pub(crate) fn parse_torrent_availability(
})
}
pub(crate) fn parse_torrent_peer_diagnostics(
result: serde_json::Value,
) -> Result<crate::ipc::TorrentPeerDiagnostics, String> {
let peers = result
.as_array()
.ok_or_else(|| "aria2.getPeers returned a non-array result".to_string())?;
fn torrent_peer_summary_from_array(
peers: &[serde_json::Value],
) -> Result<crate::ipc::TorrentPeerSummary, String> {
if peers.len() > MAX_TORRENT_PEER_RESPONSE {
return Err("aria2.getPeers returned too many peers".to_string());
}
if peers.iter().any(|peer| !peer.is_object()) {
return Err("aria2.getPeers returned malformed peer data".to_string());
}
let total_peers = u32::try_from(peers.len()).unwrap_or(u32::MAX);
let mut total_seeders = 0u32;
for peer in peers {
let peer = peer
.as_object()
.ok_or_else(|| "aria2.getPeers returned malformed peer data".to_string())?;
if aria2_peer_bool(peer.get("seeder")) {
total_seeders = total_seeders.saturating_add(1);
}
}
Ok(crate::ipc::TorrentPeerSummary {
total_peers: u32::try_from(peers.len()).unwrap_or(u32::MAX),
total_seeders,
})
}
pub(crate) fn parse_torrent_peer_summary(
result: serde_json::Value,
) -> Result<crate::ipc::TorrentPeerSummary, String> {
let peers = result
.as_array()
.ok_or_else(|| "aria2.getPeers returned a non-array result".to_string())?;
torrent_peer_summary_from_array(peers)
}
pub(crate) fn parse_torrent_peer_diagnostics(
result: serde_json::Value,
) -> Result<crate::ipc::TorrentPeerDiagnostics, String> {
let peers = result
.as_array()
.ok_or_else(|| "aria2.getPeers returned a non-array result".to_string())?;
let summary = torrent_peer_summary_from_array(peers)?;
let mut sanitized = Vec::with_capacity(peers.len().min(MAX_TORRENT_PEER_DIAGNOSTICS));
for peer in peers.iter().take(MAX_TORRENT_PEER_DIAGNOSTICS) {
@@ -5954,9 +6002,6 @@ pub(crate) fn parse_torrent_peer_diagnostics(
.as_object()
.ok_or_else(|| "aria2.getPeers returned malformed peer data".to_string())?;
let seeder = aria2_peer_bool(peer.get("seeder"));
if seeder {
total_seeders = total_seeders.saturating_add(1);
}
sanitized.push(crate::ipc::TorrentPeer {
ip: aria2_peer_ip(peer.get("ip")),
port: aria2_peer_port(peer.get("port")),
@@ -5968,21 +6013,9 @@ pub(crate) fn parse_torrent_peer_diagnostics(
});
}
// Count seeders beyond the display cap without retaining any identifying
// peer data. The response is bounded by Aria2's per-Torrent peer limit,
// while the UI receives at most MAX_TORRENT_PEER_DIAGNOSTICS rows.
for peer in peers.iter().skip(MAX_TORRENT_PEER_DIAGNOSTICS) {
let peer = peer
.as_object()
.ok_or_else(|| "aria2.getPeers returned malformed peer data".to_string())?;
if aria2_peer_bool(peer.get("seeder")) {
total_seeders = total_seeders.saturating_add(1);
}
}
Ok(crate::ipc::TorrentPeerDiagnostics {
total_peers,
total_seeders,
total_peers: summary.total_peers,
total_seeders: summary.total_seeders,
peers: sanitized,
truncated: peers.len() > MAX_TORRENT_PEER_DIAGNOSTICS,
})
@@ -8311,10 +8344,28 @@ mod tests {
assert!(!serialized.contains("bitfield"));
}
#[test]
fn torrent_peer_summary_counts_all_seeders_without_returning_peer_data() {
let result = serde_json::json!([
{"ip": "192.0.2.10", "seeder": "true", "bitfield": "secret"},
{"ip": "192.0.2.11", "seeder": false},
{"ip": "192.0.2.12", "seeder": true}
]);
let summary = parse_torrent_peer_summary(result).unwrap();
assert_eq!(summary.total_peers, 3);
assert_eq!(summary.total_seeders, 2);
let serialized = serde_json::to_string(&summary).unwrap();
assert!(!serialized.contains("192.0.2."));
assert!(!serialized.contains("bitfield"));
}
#[test]
fn torrent_peer_diagnostics_reject_non_array_results() {
let error = parse_torrent_peer_diagnostics(serde_json::json!({"peers": []})).unwrap_err();
assert!(error.contains("non-array"));
let summary_error = parse_torrent_peer_summary(serde_json::json!({"peers": []})).unwrap_err();
assert!(summary_error.contains("non-array"));
}
#[test]
@@ -8324,6 +8375,11 @@ mod tests {
}, "not-a-peer"]))
.unwrap_err();
assert!(error.contains("malformed"));
let summary_error = parse_torrent_peer_summary(serde_json::json!([{
"seeder": true
}, "not-a-peer"]))
.unwrap_err();
assert!(summary_error.contains("malformed"));
}
fn test_torrent_progress_metadata() -> Vec<crate::ipc::TorrentFile> {
+99 -13
View File
@@ -443,6 +443,48 @@ fn collect_torrent_uris(value: Option<&BencodeValue>, schemes: &[&str]) -> Vec<S
values
}
fn torrent_tracker_uri_is_safe(value: &BencodeValue) -> bool {
let BencodeValue::Bytes(bytes) = value else {
return false;
};
let Ok(value) = String::from_utf8(bytes.clone()) else {
return false;
};
bounded_uri(value.trim(), &["http", "https", "udp"]).is_some()
}
fn torrent_tracker_metadata_is_safe(root: &BTreeMap<Vec<u8>, BencodeValue>) -> bool {
let mut tracker_count = 0usize;
let mut count_tracker = |value: &BencodeValue| {
tracker_count = tracker_count.saturating_add(1);
tracker_count <= 256 && torrent_tracker_uri_is_safe(value)
};
if let Some(announce) = root.get(b"announce".as_slice()) {
if !count_tracker(announce) {
return false;
}
}
let Some(announce_list) = root.get(b"announce-list".as_slice()) else {
return true;
};
let BencodeValue::List(tiers) = announce_list else {
return false;
};
for tier in tiers {
let BencodeValue::List(trackers) = tier else {
return false;
};
for tracker in trackers {
if !count_tracker(tracker) {
return false;
}
}
}
true
}
fn parse_torrent_web_seeds(value: Option<&BencodeValue>) -> Result<Vec<String>, String> {
let Some(value) = value else {
return Ok(Vec::new());
@@ -546,7 +588,11 @@ pub fn torrent_details_from_bytes(bytes: &[u8]) -> Result<crate::ipc::TorrentDet
})
}
pub fn torrent_metadata_is_safe_for_plain_magnet_reuse(bytes: &[u8]) -> Result<bool, String> {
/// Return whether validated metainfo may be reused for a Magnet identified by
/// its info hash. Tracker lists are safe to retain because they are peer
/// discovery metadata, while direct web-seed/source fields are deliberately
/// excluded so a later Magnet cannot inherit arbitrary HTTP resources.
pub fn torrent_metadata_is_safe_for_magnet_reuse(bytes: &[u8]) -> Result<bool, String> {
if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES {
return Err(format!(
"torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes"
@@ -561,11 +607,16 @@ pub fn torrent_metadata_is_safe_for_plain_magnet_reuse(bytes: &[u8]) -> Result<b
.get(b"info".as_slice())
.ok_or_else(|| "torrent metadata is missing info".to_string())?;
parse_info(info)?;
if !torrent_tracker_metadata_is_safe(&root) {
return Ok(false);
}
Ok(root.keys().all(|key| {
matches!(
key.as_slice(),
b"info"
| b"announce"
| b"announce-list"
| b"comment"
| b"comment.utf-8"
| b"created by"
@@ -665,7 +716,7 @@ pub fn magnet_allows_cached_metadata(source: &str) -> bool {
}
has_info_hash = true;
}
"dn" => {}
"dn" | "tr" => {}
_ => return false,
}
}
@@ -1076,7 +1127,7 @@ async fn read_cached_torrent_by_info_hash_unlocked<R: tauri::Runtime>(
}
Err(error) => return Err(format!("could not read cached torrent metadata: {error}")),
};
let reusable = torrent_metadata_is_safe_for_plain_magnet_reuse(&bytes).is_ok_and(|safe| safe);
let reusable = torrent_metadata_is_safe_for_magnet_reuse(&bytes).is_ok_and(|safe| safe);
match parse_torrent_bytes(&bytes) {
Ok(parsed) if reusable && parsed.info_hash == info_hash => {}
Ok(_) | Err(_) => {
@@ -1101,7 +1152,7 @@ pub async fn cache_torrent_info_hash<R: tauri::Runtime>(
) -> Result<Option<String>, String> {
let _guard = canonical_torrent_cache_lock().lock().await;
let parsed = parse_torrent_bytes(bytes)?;
if !torrent_metadata_is_safe_for_plain_magnet_reuse(bytes)? {
if !torrent_metadata_is_safe_for_magnet_reuse(bytes)? {
return Ok(None);
}
let info_hash = parsed.info_hash;
@@ -1302,16 +1353,32 @@ mod tests {
}
#[test]
fn plain_magnet_reuse_rejects_tracker_and_web_seed_metadata() {
assert!(torrent_metadata_is_safe_for_plain_magnet_reuse(
fn magnet_reuse_rejects_web_seed_metadata_but_retains_tracker_metadata() {
assert!(torrent_metadata_is_safe_for_magnet_reuse(
b"d4:infod6:lengthi5e4:name4:testee"
)
.expect("plain torrent metadata should parse"));
assert!(!torrent_metadata_is_safe_for_plain_magnet_reuse(
b"d8:announce1:x4:infod6:lengthi5e4:name4:testee"
assert!(torrent_metadata_is_safe_for_magnet_reuse(
b"d8:announce32:https://tracker.example/announce4:infod6:lengthi5e4:name4:testee"
)
.expect("tracker-bearing torrent metadata should parse"));
assert!(!torrent_metadata_is_safe_for_plain_magnet_reuse(
assert!(torrent_metadata_is_safe_for_magnet_reuse(
b"d13:announce-listll32:https://tracker.example/announceee4:infod6:lengthi5e4:name4:testee"
)
.expect("tracker-list-bearing torrent metadata should parse"));
assert!(!torrent_metadata_is_safe_for_magnet_reuse(
b"d8:announce30:ftp://tracker.example/announce4:infod6:lengthi5e4:name4:testee"
)
.expect("unsupported tracker metadata should parse"));
assert!(!torrent_metadata_is_safe_for_magnet_reuse(
b"d8:announce42:https://user:pass@tracker.example/announce4:infod6:lengthi5e4:name4:testee"
)
.expect("credential-bearing tracker metadata should parse"));
assert!(!torrent_metadata_is_safe_for_magnet_reuse(
b"d13:announce-listl1:xe4:infod6:lengthi5e4:name4:testee"
)
.expect("malformed tracker-list metadata should parse"));
assert!(!torrent_metadata_is_safe_for_magnet_reuse(
b"d4:infod6:lengthi5e4:name4:teste8:url-list1:xe"
)
.expect("web-seed-bearing torrent metadata should parse"));
@@ -1365,13 +1432,29 @@ mod tests {
);
assert!(!path.exists());
let tracker_bytes = b"d8:announce32:https://tracker.example/announce4:infod6:lengthi5e4:name4:testee";
let tracker_hash = parse_torrent_bytes(tracker_bytes)
.expect("tracker-bearing torrent should parse")
.info_hash;
assert!(
cache_torrent_info_hash(app.handle(), tracker_bytes)
.await
.expect("tracker metadata should be reusable")
.is_some()
);
assert_eq!(
read_cached_torrent_by_info_hash(app.handle(), &tracker_hash)
.await
.expect("tracker cache should be readable"),
Some(tracker_bytes.to_vec())
);
assert!(
cache_torrent_info_hash(
app.handle(),
b"d8:announce1:x4:infod6:lengthi5e4:name4:testee"
b"d4:infod6:lengthi5e4:name4:teste8:url-list22:https://example.test/ae"
)
.await
.expect("source-specific metadata should be handled")
.expect("web-seed metadata should be handled")
.is_none()
);
}
@@ -1405,16 +1488,19 @@ mod tests {
}
#[test]
fn only_plain_magnets_can_reuse_hash_keyed_metadata() {
fn magnets_with_safe_parameters_can_reuse_hash_keyed_metadata() {
assert!(magnet_allows_cached_metadata(
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567"
));
assert!(magnet_allows_cached_metadata(
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example%20Torrent"
));
assert!(!magnet_allows_cached_metadata(
assert!(magnet_allows_cached_metadata(
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&tr=https%3A%2F%2Ftracker.invalid%2Fannounce"
));
assert!(magnet_allows_cached_metadata(
"magnet:?tr=udp%3A%2F%2Ftracker.invalid%3A1337%2Fannounce&xt=urn:btih:0123456789abcdef0123456789abcdef01234567"
));
assert!(!magnet_allows_cached_metadata(
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&ws=https%3A%2F%2Fexample.invalid%2Ffile"
));