feat(torrents): support tracker exclusion

This commit is contained in:
NimBold
2026-08-02 01:19:18 +03:30
parent ab9f0507d0
commit a46a64994d
20 changed files with 295 additions and 32 deletions
+63 -15
View File
@@ -952,6 +952,7 @@ fn remove_persisted_transfer_secrets(value: &mut Value) {
}
sanitize_portable_torrent_trackers(object);
sanitize_portable_torrent_exclude_trackers(object);
if let Some(url) = object.get("url").and_then(Value::as_str) {
if let Ok(mut parsed) = url::Url::parse(url) {
@@ -1010,22 +1011,26 @@ fn remove_persisted_transfer_secrets(value: &mut Value) {
}
}
fn sanitize_portable_torrent_trackers(object: &mut serde_json::Map<String, Value>) {
let Some(raw_value) = object.get("torrentTrackers").cloned() else {
fn sanitize_portable_torrent_tracker_field(
object: &mut serde_json::Map<String, Value>,
key: &str,
normalize: fn(Option<&str>) -> Result<Option<String>, String>,
) {
let Some(raw_value) = object.get(key).cloned() else {
return;
};
let Some(raw) = raw_value.as_str().map(str::to_string) else {
object.remove("torrentTrackers");
object.remove(key);
mark_portable_download_unresumable(object);
return;
};
let raw = raw.trim();
if raw.is_empty() {
object.remove("torrentTrackers");
object.remove(key);
return;
}
let Some(normalized) = crate::queue::normalize_torrent_trackers(Some(raw)).ok().flatten() else {
object.remove("torrentTrackers");
let Some(normalized) = normalize(Some(raw)).ok().flatten() else {
object.remove(key);
mark_portable_download_unresumable(object);
return;
};
@@ -1033,8 +1038,12 @@ fn sanitize_portable_torrent_trackers(object: &mut serde_json::Map<String, Value
let mut sanitized = Vec::new();
let mut removed_context = false;
for token in normalized.split(',') {
if token == "*" {
sanitized.push(token.to_string());
continue;
}
let Ok(mut parsed) = url::Url::parse(token) else {
object.remove("torrentTrackers");
object.remove(key);
mark_portable_download_unresumable(object);
return;
};
@@ -1053,18 +1062,31 @@ fn sanitize_portable_torrent_trackers(object: &mut serde_json::Map<String, Value
}
if sanitized.is_empty() {
object.remove("torrentTrackers");
object.remove(key);
} else {
object.insert(
"torrentTrackers".to_string(),
Value::String(sanitized.join(",")),
);
object.insert(key.to_string(), Value::String(sanitized.join(",")));
}
if removed_context {
mark_portable_download_unresumable(object);
}
}
fn sanitize_portable_torrent_trackers(object: &mut serde_json::Map<String, Value>) {
sanitize_portable_torrent_tracker_field(
object,
"torrentTrackers",
crate::queue::normalize_torrent_trackers,
);
}
fn sanitize_portable_torrent_exclude_trackers(object: &mut serde_json::Map<String, Value>) {
sanitize_portable_torrent_tracker_field(
object,
"torrentExcludeTrackers",
crate::queue::normalize_torrent_exclude_trackers,
);
}
fn value_is_empty(value: &Value) -> bool {
value.as_str().is_some_and(str::is_empty)
|| value.as_array().is_some_and(Vec::is_empty)
@@ -2041,7 +2063,8 @@ mod tests {
"headers": "Authorization: Bearer secret",
"mirrors": "https://user:secret@example.com/mirror",
"proxy": "http://user:secret@example.com:8080",
"torrentTrackers": "https://tracker.example/announce?passkey=secret"
"torrentTrackers": "https://tracker.example/announce?passkey=secret",
"torrentExcludeTrackers": "https://tracker.example/exclude?passkey=secret"
}])
.to_string();
@@ -2056,6 +2079,7 @@ mod tests {
assert!(saved.get(key).is_none(), "portable data retained {key}");
}
assert_eq!(saved["torrentTrackers"], "https://tracker.example/announce");
assert_eq!(saved["torrentExcludeTrackers"], "https://tracker.example/exclude");
}
#[test]
@@ -2068,7 +2092,8 @@ mod tests {
"status": "queued",
"queueId": "main",
"url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
"torrentTrackers": { "token": "secret" }
"torrentTrackers": { "token": "secret" },
"torrentExcludeTrackers": { "token": "secret" }
}])
.to_string();
@@ -2076,11 +2101,32 @@ mod tests {
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert!(saved.get("torrentTrackers").is_none());
assert!(saved.get("torrentExcludeTrackers").is_none());
assert_eq!(saved["status"], "failed");
assert_eq!(saved["resumable"], false);
assert!(!saved.to_string().contains("secret"));
}
#[test]
fn portable_download_persistence_keeps_wildcard_tracker_exclusion() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let mut connection = state.lock().unwrap();
let data = json!([{
"id": "download-wildcard-exclusion",
"status": "queued",
"queueId": "main",
"url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
"torrentExcludeTrackers": "*"
}])
.to_string();
replace_downloads(&mut connection, &data, true).unwrap();
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert_eq!(saved["torrentExcludeTrackers"], "*");
}
#[test]
fn portable_download_persistence_drops_invalid_tracker_urls() {
let temp = TempDir::new().unwrap();
@@ -2091,7 +2137,8 @@ mod tests {
"status": "queued",
"queueId": "main",
"url": "https://example.com/file.bin",
"torrentTrackers": "ftp://tracker.example/announce"
"torrentTrackers": "ftp://tracker.example/announce",
"torrentExcludeTrackers": "ftp://tracker.example/announce"
}])
.to_string();
@@ -2099,6 +2146,7 @@ mod tests {
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert!(saved.get("torrentTrackers").is_none());
assert!(saved.get("torrentExcludeTrackers").is_none());
assert_eq!(saved["status"], "failed");
assert_eq!(saved["resumable"], false);
}
+3
View File
@@ -199,6 +199,9 @@ pub struct DownloadItem {
pub torrent_trackers: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_exclude_trackers: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_stop_timeout: Option<u32>,
}
+2
View File
@@ -5800,6 +5800,8 @@ async fn validate_torrent_enqueue(
return Err("torrent transfer cannot be a media download".to_string());
}
item.torrent_trackers = queue::normalize_torrent_trackers(item.torrent_trackers.as_deref())?;
item.torrent_exclude_trackers =
queue::normalize_torrent_exclude_trackers(item.torrent_exclude_trackers.as_deref())?;
item.torrent_stop_timeout = queue::normalize_torrent_stop_timeout(item.torrent_stop_timeout)?;
validate_enqueue_uris("", item.mirrors.as_deref()).await?;
if let Some(path) = item.torrent_path.as_deref() {
+93 -3
View File
@@ -216,6 +216,7 @@ pub struct SpawnPayload {
pub torrent_peer_speed_limit: Option<String>,
pub torrent_check_integrity: bool,
pub torrent_trackers: Option<String>,
pub torrent_exclude_trackers: Option<String>,
pub torrent_stop_timeout: Option<u32>,
}
@@ -3262,7 +3263,10 @@ pub(crate) fn parse_torrent_peer_diagnostics(
})
}
pub(crate) fn normalize_torrent_trackers(value: Option<&str>) -> Result<Option<String>, String> {
fn normalize_torrent_tracker_list(
value: Option<&str>,
allow_wildcard: bool,
) -> Result<Option<String>, String> {
let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
@@ -3273,6 +3277,7 @@ pub(crate) fn normalize_torrent_trackers(value: Option<&str>) -> Result<Option<S
}
let mut trackers = Vec::new();
let mut wildcard = false;
let mut serialized_bytes = 0usize;
for line in raw.split(['\r', '\n']) {
let line = line.trim();
@@ -3287,6 +3292,22 @@ pub(crate) fn normalize_torrent_trackers(value: Option<&str>) -> Result<Option<S
if token.chars().any(char::is_control) {
return Err("torrent tracker URI contains a control character".to_string());
}
if allow_wildcard && token == "*" {
if !trackers.is_empty() {
return Err(
"torrent tracker exclusion wildcard cannot be combined with tracker URLs"
.to_string(),
);
}
wildcard = true;
continue;
}
if wildcard {
return Err(
"torrent tracker exclusion wildcard cannot be combined with tracker URLs"
.to_string(),
);
}
let parsed = url::Url::parse(token)
.map_err(|_| "torrent tracker URI is invalid".to_string())?;
if !matches!(parsed.scheme(), "http" | "https" | "udp") {
@@ -3324,12 +3345,25 @@ pub(crate) fn normalize_torrent_trackers(value: Option<&str>) -> Result<Option<S
}
}
if wildcard {
return Ok(Some("*".to_string()));
}
if trackers.is_empty() {
return Ok(None);
}
Ok(Some(trackers.join(",")))
}
pub(crate) fn normalize_torrent_trackers(value: Option<&str>) -> Result<Option<String>, String> {
normalize_torrent_tracker_list(value, false)
}
pub(crate) fn normalize_torrent_exclude_trackers(
value: Option<&str>,
) -> Result<Option<String>, String> {
normalize_torrent_tracker_list(value, true)
}
fn apply_aria2_torrent_options(
options: &mut serde_json::Map<String, serde_json::Value>,
payload: &SpawnPayload,
@@ -3391,6 +3425,11 @@ fn apply_aria2_torrent_options(
if let Some(trackers) = normalize_torrent_trackers(payload.torrent_trackers.as_deref())? {
options.insert("bt-tracker".to_string(), serde_json::json!(trackers));
}
if let Some(trackers) =
normalize_torrent_exclude_trackers(payload.torrent_exclude_trackers.as_deref())?
{
options.insert("bt-exclude-tracker".to_string(), serde_json::json!(trackers));
}
if let Some(stop_timeout) = normalize_torrent_stop_timeout(payload.torrent_stop_timeout)? {
options.insert(
"bt-stop-timeout".to_string(),
@@ -4008,6 +4047,9 @@ pub struct EnqueueItem {
pub torrent_trackers: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_exclude_trackers: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_stop_timeout: Option<u32>,
#[serde(default)]
#[ts(optional)]
@@ -4060,6 +4102,7 @@ impl EnqueueItem {
torrent_peer_speed_limit: self.torrent_peer_speed_limit,
torrent_check_integrity: self.torrent_check_integrity.unwrap_or(false),
torrent_trackers: self.torrent_trackers,
torrent_exclude_trackers: self.torrent_exclude_trackers,
torrent_stop_timeout: self.torrent_stop_timeout,
},
}
@@ -4284,6 +4327,33 @@ mod tests {
assert!(normalize_torrent_trackers(Some(&too_many)).is_err());
}
#[test]
fn torrent_exclude_trackers_support_wildcard_and_normalized_uris() {
assert_eq!(normalize_torrent_exclude_trackers(Some("*")).unwrap(), Some("*".to_string()));
assert_eq!(
normalize_torrent_exclude_trackers(Some(
" https://tracker.example/announce\nudp://tracker.example:6969/announce "
))
.unwrap(),
Some("https://tracker.example/announce,udp://tracker.example:6969/announce".to_string())
);
assert_eq!(normalize_torrent_exclude_trackers(Some(" \n\n ")).unwrap(), None);
}
#[test]
fn torrent_exclude_trackers_reject_unsafe_or_ambiguous_values() {
for value in [
"ftp://tracker.example/announce",
"https://user:pass@tracker.example/announce",
"https://tracker.example/announce#fragment",
"https://tracker.example/announce,*",
"*,https://tracker.example/announce",
"https://tracker.example/announce,",
] {
assert!(normalize_torrent_exclude_trackers(Some(value)).is_err(), "{value}");
}
}
#[test]
fn torrent_trackers_are_emitted_as_the_aria2_tracker_option() {
let mut options = serde_json::Map::new();
@@ -4301,6 +4371,23 @@ mod tests {
);
}
#[test]
fn torrent_exclude_trackers_are_emitted_as_the_aria2_option() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_exclude_trackers: Some("*".to_string()),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(
options.get("bt-exclude-tracker"),
Some(&serde_json::json!("*"))
);
}
#[test]
fn torrent_stop_timeout_is_normalized_and_emitted() {
assert_eq!(normalize_torrent_stop_timeout(None).unwrap(), None);
@@ -4410,14 +4497,17 @@ mod tests {
"filename": "payload",
"is_media": false,
"is_torrent": true,
"torrent_trackers": "https://tracker.example/announce"
"torrent_trackers": "https://tracker.example/announce",
"torrent_exclude_trackers": "*"
}))
.expect("frontend enqueue payload should deserialize");
let payload = item.into_task().payload;
assert_eq!(
item.into_task().payload.torrent_trackers.as_deref(),
payload.torrent_trackers.as_deref(),
Some("https://tracker.example/announce")
);
assert_eq!(payload.torrent_exclude_trackers.as_deref(), Some("*"));
}
#[test]