feat(torrent): complete lifecycle controls

This commit is contained in:
NimBold
2026-08-03 21:47:02 +03:30
parent 819a48bd4b
commit 55a905df14
32 changed files with 2390 additions and 136 deletions
+70
View File
@@ -46,6 +46,14 @@ fn default_torrent_max_concurrent_seeds() -> u32 {
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
}
fn default_torrent_ipv6_enabled() -> bool {
true
}
fn default_aria2_disk_cache() -> String {
crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string()
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
@@ -72,6 +80,9 @@ pub enum DownloadStatus {
/// Transient state: a connection-aware retry is in progress with
/// exponential backoff. The download slot/permit is still held.
Retrying,
/// Aria2 is verifying already-present Torrent data before transfer or
/// after an explicit integrity check.
Verifying,
}
impl DownloadStatus {
@@ -88,6 +99,7 @@ impl DownloadStatus {
Self::Failed => "failed",
Self::Queued => "queued",
Self::Retrying => "retrying",
Self::Verifying => "verifying",
}
}
}
@@ -248,6 +260,15 @@ pub struct DownloadItem {
#[serde(default)]
#[ts(optional)]
pub torrent_encryption_policy: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_file_allocation: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_verify_only: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub torrent_verify_restore_status: Option<String>,
}
#[derive(Clone, Debug, Serialize, TS)]
@@ -308,6 +329,49 @@ pub struct TorrentPieceProgressSnapshot {
pub buckets: Vec<u8>,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentFileSelectionEntry {
pub index: u32,
pub relative_path: String,
#[ts(type = "number")]
pub length: u64,
pub selected: bool,
#[ts(type = "number")]
#[ts(optional)]
pub completed_length: Option<u64>,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentFileSelectionSnapshot {
pub files: Vec<TorrentFileSelectionEntry>,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentDetails {
pub info_hash: String,
pub display_name: String,
#[ts(type = "number")]
pub total_bytes: u64,
#[ts(type = "number")]
pub file_count: u32,
#[ts(type = "number")]
pub piece_length: u64,
#[ts(type = "number")]
pub piece_count: u64,
pub private: bool,
pub creation_date: Option<String>,
pub creator: Option<String>,
pub comment: Option<String>,
pub trackers: Vec<String>,
pub web_seeds: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
@@ -572,6 +636,8 @@ pub struct PersistedSettings {
pub torrent_separate_seed_slots: bool,
#[serde(default = "default_torrent_max_concurrent_seeds")]
pub torrent_max_concurrent_seeds: u32,
#[serde(default = "default_torrent_ipv6_enabled")]
pub torrent_ipv6_enabled: bool,
#[serde(default)]
pub torrent_listen_port: String,
#[serde(default)]
@@ -590,6 +656,10 @@ pub struct PersistedSettings {
pub torrent_peer_id_prefix: String,
#[serde(default)]
pub torrent_peer_agent: String,
#[serde(default)]
pub torrent_bind_address: String,
#[serde(default = "default_aria2_disk_cache")]
pub aria2_disk_cache: String,
pub custom_user_agent: String,
pub ask_where_to_save_each_file: bool,
pub remember_last_used_download_directory: bool,
+953 -84
View File
File diff suppressed because it is too large Load Diff
+384 -5
View File
@@ -41,6 +41,59 @@ pub const MAX_TORRENT_WEB_SEEDS: usize = 256;
pub const MAX_TORRENT_WEB_SEED_URI_LENGTH: usize = 2_048;
pub const MIN_TORRENT_LISTEN_PORT: u16 = 1024;
pub const DEFAULT_TORRENT_LISTEN_PORT_SPEC: &str = "6881-6999";
pub const DEFAULT_ARIA2_DISK_CACHE: &str = "16M";
pub const MAX_ARIA2_DISK_CACHE_MIB: u64 = 1024;
pub fn normalize_torrent_bind_address(value: Option<&str>) -> Result<Option<String>, String> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if value.len() > MAX_TORRENT_NETWORK_VALUE_LENGTH
|| value.chars().any(char::is_control)
{
return Err("Torrent bind address is too long or contains control characters".to_string());
}
let address = value
.parse::<std::net::IpAddr>()
.map_err(|_| "Torrent bind address must be a valid IPv4 or IPv6 address".to_string())?;
Ok(Some(address.to_string()))
}
pub fn normalize_aria2_disk_cache(value: Option<&str>) -> Result<String, String> {
let value = value.map(str::trim).filter(|value| !value.is_empty()).unwrap_or(DEFAULT_ARIA2_DISK_CACHE);
if value == "0" {
return Ok("0".to_string());
}
let (digits, multiplier, suffix) = match value.as_bytes().last().copied() {
Some(b'k' | b'K') => (&value[..value.len() - 1], 1_u64, "K"),
Some(b'm' | b'M') => (&value[..value.len() - 1], 1024_u64, "M"),
_ => return Err("Aria2 disk cache must be 0 or a positive value ending in K or M".to_string()),
};
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err("Aria2 disk cache must be 0 or a positive value ending in K or M".to_string());
}
let amount = digits
.parse::<u64>()
.map_err(|_| "Aria2 disk cache is too large".to_string())?;
let kib = amount
.checked_mul(multiplier)
.ok_or_else(|| "Aria2 disk cache is too large".to_string())?;
if kib == 0 || kib > MAX_ARIA2_DISK_CACHE_MIB * 1024 {
return Err(format!(
"Aria2 disk cache must be between 1K and {MAX_ARIA2_DISK_CACHE_MIB}M"
));
}
Ok(format!("{amount}{suffix}"))
}
pub fn normalize_torrent_file_allocation(value: Option<&str>) -> Result<String, String> {
match value.map(str::trim).filter(|value| !value.is_empty()) {
None => Ok("prealloc".to_string()),
Some("prealloc") => Ok("prealloc".to_string()),
Some("none") => Ok("none".to_string()),
Some(_) => Err("Torrent file allocation must be prealloc or none".to_string()),
}
}
pub fn clamp_download_connections(connections: i32) -> i32 {
connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX)
@@ -596,6 +649,10 @@ pub struct SpawnPayload {
pub torrent_prioritize_piece: Option<String>,
pub torrent_remove_unselected_file: bool,
pub torrent_encryption_policy: Option<String>,
pub torrent_file_allocation: Option<String>,
pub torrent_verify_only: bool,
pub torrent_verify_restore_status: Option<String>,
pub torrent_verified_length: Option<u64>,
}
/// A sidecar spawner. In production this calls the real aria2/yt-dlp
@@ -1251,6 +1308,27 @@ impl<R: tauri::Runtime> QueueManager<R> {
.collect()
}
/// Temporarily remove one not-yet-admitted task while a caller performs a
/// lifecycle-safe reconfiguration. The admission gate prevents the
/// dispatcher from popping the same task between the caller's validation
/// and mutation of its payload.
pub async fn take_pending_task(&self, id: &str) -> Option<(usize, QueuedTask)> {
let _admission_gate = self.admission_gate.lock().await;
let mut pending = self.pending.lock().await;
let index = pending.iter().position(|task| task.id == id)?;
pending.remove(index).map(|task| (index, task))
}
/// Restore a task removed by `take_pending_task`, preserving its queue
/// position even when another queue's work was admitted meanwhile.
pub async fn restore_pending_task(&self, index: usize, task: QueuedTask) {
let _admission_gate = self.admission_gate.lock().await;
let mut pending = self.pending.lock().await;
let insert_at = index.min(pending.len());
pending.insert(insert_at, task);
self.notify.notify_one();
}
/// Explicitly release a backend registry id (e.g. on un-resumable false paths, removals, or detach).
pub async fn release_registered_id(&self, id: &str) {
self.registered_ids.lock().await.remove(id);
@@ -1525,6 +1603,104 @@ impl<R: tauri::Runtime> QueueManager<R> {
.is_some_and(torrent_seeding_requested)
}
pub async fn aria2_is_torrent(&self, id: &str) -> bool {
self.aria2_payloads
.lock()
.await
.get(id)
.is_some_and(|payload| payload.is_torrent)
}
pub async fn aria2_is_torrent_verification(&self, id: &str) -> bool {
self.aria2_payloads
.lock()
.await
.get(id)
.is_some_and(|payload| payload.torrent_verify_only)
}
async fn capture_torrent_verification_evidence(&self, id: &str) {
if !self.aria2_is_torrent_verification(id).await {
return;
}
let Some(gid) = self.aria2_gid_for_download(id) else {
return;
};
let Some(mapping) = self.aria2_gid_mapping(&gid) else {
return;
};
let Some(state) = self.app_handle.try_state::<crate::AppState>() else {
return;
};
let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed);
let secret = state.aria2_secret.clone();
drop(state);
let Ok(status) = crate::rpc_call(
port,
&secret,
"aria2.tellStatus",
serde_json::json!([gid, ["status", "totalLength", "verifiedLength", "verifyIntegrityPending"]]),
)
.await else {
return;
};
if !self.is_current_aria2_gid_mapping(&gid, &mapping)
|| !self
.is_aria2_control_epoch_current(id, mapping.epoch)
.await
{
return;
}
let status_name = status.get("status").and_then(|value| value.as_str());
let total = status
.get("totalLength")
.and_then(|value| value.as_str().and_then(|value| value.parse::<u64>().ok()).or_else(|| value.as_u64()));
let verified = status
.get("verifiedLength")
.and_then(|value| value.as_str().and_then(|value| value.parse::<u64>().ok()).or_else(|| value.as_u64()));
let pending = status
.get("verifyIntegrityPending")
.is_some_and(|value| value.as_bool() == Some(true) || value.as_str() == Some("true"));
if let Some(total) = Self::complete_torrent_verification_length(
status_name,
pending,
total,
verified,
) {
self.record_torrent_verified_length(id, mapping.epoch, total)
.await;
}
}
fn complete_torrent_verification_length(
status: Option<&str>,
verify_pending: bool,
total: Option<u64>,
verified: Option<u64>,
) -> Option<u64> {
if matches!(status, Some("complete" | "active" | "waiting"))
&& !verify_pending
{
return verified
.zip(total)
.filter(|(verified, total)| verified >= total)
.map(|(_, total)| total);
}
None
}
pub async fn record_torrent_verified_length(&self, id: &str, epoch: u64, length: u64) {
if !self.is_aria2_control_epoch_current(id, epoch).await {
return;
}
if let Some(payload) = self.aria2_payloads.lock().await.get_mut(id) {
if payload.torrent_verify_only {
let current = payload.torrent_verified_length.unwrap_or(0);
payload.torrent_verified_length = Some(current.max(length));
}
}
}
async fn torrent_files_for_payload(
&self,
id: &str,
@@ -3005,6 +3181,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
let buffered_outcome = self.remember_gid(id.clone(), gid.clone()).await;
let install_web_seeds = buffered_outcome.is_none()
&& task.payload.is_torrent
&& !task.payload.torrent_verify_only
&& task.payload.torrent_web_seeds.is_some();
self.finish_aria2_dispatch(&id, lifecycle_epoch).await;
drop(control_guard);
@@ -3150,6 +3327,14 @@ impl<R: tauri::Runtime> QueueManager<R> {
.emit("download-state", DownloadStateEvent::failed(id, error));
}
fn emit_paused_with_error(&self, id: &str, error: String) {
use tauri::Emitter;
let _ = self.app_handle.emit(
"download-state",
DownloadStateEvent::paused_with_error(id, error),
);
}
/// Store gid -> id and return any buffered terminal event for the caller
/// to reconcile against the correct event path. In particular, buffered
/// errors must still pass through transient retry classification.
@@ -3224,7 +3409,30 @@ impl<R: tauri::Runtime> QueueManager<R> {
/// and lets commands reconcile an Aria2 terminal status without releasing
/// the lock first.
pub(crate) async fn apply_completion_locked(&self, id: &str, outcome: PendingOutcome) {
if matches!(&outcome, PendingOutcome::Complete) {
self.capture_torrent_verification_evidence(id).await;
}
let (verification_restore_status, verification_only, verification_observed) = {
let payloads = self.aria2_payloads.lock().await;
payloads
.get(id)
.filter(|payload| payload.torrent_verify_only)
.map(|payload| {
(
payload.torrent_verify_restore_status.clone(),
true,
payload.torrent_verified_length.is_some(),
)
})
.unwrap_or((None, false, false))
};
let outcome = match outcome {
PendingOutcome::Complete if verification_only && !verification_observed => {
PendingOutcome::Error(
"Torrent integrity verification did not produce a complete hash-check result"
.to_string(),
)
}
PendingOutcome::Seeding if self.aria2_torrent_seeding_requested(id).await => {
if !self.seed_capacity_enabled() {
// Keep a budget record even when the legacy single-pool
@@ -3308,10 +3516,26 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
self.release_registered_id(id).await;
self.release_permit(id).await;
self.emit_state(id, DownloadStatus::Completed);
let restored_status = if verification_only {
if verification_restore_status.as_deref() == Some("completed") {
DownloadStatus::Completed
} else {
DownloadStatus::Paused
}
} else {
match verification_restore_status.as_deref() {
Some("paused") => DownloadStatus::Paused,
Some("failed") => DownloadStatus::Failed,
Some("ready") => DownloadStatus::Ready,
Some("staged") => DownloadStatus::Staged,
Some("completed") => DownloadStatus::Completed,
_ => DownloadStatus::Completed,
}
};
self.emit_state(id, restored_status);
}
PendingOutcome::Error(error) => {
if error.to_ascii_lowercase().contains("checksum") {
if !verification_only && error.to_ascii_lowercase().contains("checksum") {
log::warn!("Checksum error detected for {}, cleaning up assets", id);
if let Ok(paths) =
crate::download_ownership::owned_paths_for_id(&self.app_handle, id)
@@ -3322,7 +3546,13 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
}
log::error!("aria2 download {} failed: {}", id, error);
let error = if verification_only {
format!(
"Torrent integrity verification failed; resume the Torrent to repair it: {error}"
)
} else {
error
};
self.clear_aria2_retry_state(id).await;
self.forget_aria2_gid(id).await;
@@ -3345,7 +3575,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
self.release_registered_id(id).await;
self.release_permit(id).await;
self.emit_failed(id, error);
if verification_only {
self.emit_paused_with_error(id, error);
} else {
log::error!("aria2 download {} failed: {}", id, error);
self.emit_failed(id, error);
}
}
PendingOutcome::Seeding => unreachable!("seeding outcomes are normalized before terminal cleanup"),
}
@@ -5143,6 +5378,16 @@ fn apply_aria2_torrent_options(
return Ok(());
}
if payload.torrent_verify_only {
options.insert("check-integrity".to_string(), serde_json::json!("true"));
options.insert("hash-check-only".to_string(), serde_json::json!("true"));
options.insert("seed-time".to_string(), serde_json::json!("0"));
options.insert("seed-ratio".to_string(), serde_json::json!("0"));
options.insert("bt-hash-check-seed".to_string(), serde_json::json!("false"));
options.insert("bt-seed-unverified".to_string(), serde_json::json!("false"));
return Ok(());
}
let encryption_policy =
normalize_torrent_encryption_policy(payload.torrent_encryption_policy.as_deref())?;
let (force_encryption, require_crypto, min_crypto_level) =
@@ -5276,6 +5521,8 @@ fn apply_aria2_torrent_options(
serde_json::json!("true"),
);
}
let allocation = normalize_torrent_file_allocation(payload.torrent_file_allocation.as_deref())?;
options.insert("file-allocation".to_string(), serde_json::json!(allocation));
if payload.torrent_check_integrity {
options.insert(
"check-integrity".to_string(),
@@ -5379,6 +5626,9 @@ impl SidecarSpawner for ProductionSpawner {
if !crate::is_safe_path(&resolved_dest, &self.app_handle) {
return Err("Path traversal blocked".to_string());
}
if payload.is_torrent {
crate::torrent::validate_output_name(&payload.filename)?;
}
let proxy_value = payload
.proxy
.as_deref()
@@ -5461,7 +5711,7 @@ impl SidecarSpawner for ProductionSpawner {
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
options.insert(
"index-out".to_string(),
serde_json::json!(crate::torrent::aria2_index_outputs(&metadata)),
serde_json::json!(crate::torrent::aria2_index_outputs(&metadata, &payload.filename)),
);
let selected = crate::torrent::validate_selected_indices(
payload.torrent_file_indices.as_deref(),
@@ -6017,6 +6267,15 @@ pub struct EnqueueItem {
pub torrent_encryption_policy: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_file_allocation: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_verify_only: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub torrent_verify_restore_status: Option<String>,
#[serde(default)]
#[ts(optional)]
pub lifecycle_generation: Option<String>,
}
@@ -6078,6 +6337,10 @@ impl EnqueueItem {
.torrent_remove_unselected_file
.unwrap_or(false),
torrent_encryption_policy: self.torrent_encryption_policy,
torrent_file_allocation: self.torrent_file_allocation,
torrent_verify_only: self.torrent_verify_only.unwrap_or(false),
torrent_verify_restore_status: self.torrent_verify_restore_status,
torrent_verified_length: None,
},
}
}
@@ -6160,6 +6423,59 @@ mod tests {
);
}
#[test]
fn torrent_network_and_storage_settings_are_normalized_at_the_boundary() {
assert_eq!(
normalize_torrent_bind_address(Some(" 2001:db8::1 ")).unwrap(),
Some("2001:db8::1".to_string())
);
assert_eq!(normalize_torrent_bind_address(Some(" ")).unwrap(), None);
assert!(normalize_torrent_bind_address(Some("localhost")).is_err());
assert!(normalize_torrent_bind_address(Some("127.0.0.1\n--bad")).is_err());
assert_eq!(normalize_aria2_disk_cache(None).unwrap(), "16M");
assert_eq!(normalize_aria2_disk_cache(Some(" 256m ")).unwrap(), "256M");
assert_eq!(normalize_aria2_disk_cache(Some("1024K")).unwrap(), "1024K");
assert_eq!(normalize_aria2_disk_cache(Some("0")).unwrap(), "0");
assert!(normalize_aria2_disk_cache(Some("1025M")).is_err());
assert!(normalize_aria2_disk_cache(Some("16")).is_err());
assert_eq!(normalize_torrent_file_allocation(None).unwrap(), "prealloc");
assert_eq!(normalize_torrent_file_allocation(Some(" none ")).unwrap(), "none");
assert!(normalize_torrent_file_allocation(Some("truncate")).is_err());
}
#[test]
fn torrent_verification_evidence_requires_matching_lengths_but_accepts_empty_data() {
assert_eq!(
QueueManager::<tauri::Wry>::complete_torrent_verification_length(
Some("complete"),
false,
Some(0),
Some(0),
),
Some(0)
);
assert_eq!(
QueueManager::<tauri::Wry>::complete_torrent_verification_length(
Some("complete"),
false,
Some(100),
Some(99),
),
None
);
assert_eq!(
QueueManager::<tauri::Wry>::complete_torrent_verification_length(
Some("complete"),
true,
Some(100),
Some(100),
),
None
);
}
#[test]
fn torrent_options_disable_seeding_when_no_policy_is_saved() {
let mut options = serde_json::Map::new();
@@ -6354,6 +6670,28 @@ mod tests {
);
}
#[test]
fn torrent_verification_uses_hash_only_options_and_ignores_transfer_policy() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_verify_only: true,
torrent_trackers: Some("https://tracker.example/announce".to_string()),
torrent_seed_ratio: Some(1.0),
torrent_file_allocation: Some("none".to_string()),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(options.get("check-integrity"), Some(&serde_json::json!("true")));
assert_eq!(options.get("hash-check-only"), Some(&serde_json::json!("true")));
assert_eq!(options.get("seed-time"), Some(&serde_json::json!("0")));
assert_eq!(options.get("seed-ratio"), Some(&serde_json::json!("0")));
assert!(!options.contains_key("bt-tracker"));
assert!(!options.contains_key("file-allocation"));
}
#[test]
fn torrent_integrity_check_preserves_an_explicit_seeding_policy() {
let mut options = serde_json::Map::new();
@@ -7279,6 +7617,47 @@ mod tests {
.is_some());
}
#[tokio::test]
async fn pending_torrent_reconfiguration_restores_position_and_payload() {
let app = tauri::test::mock_builder()
.build(tauri::test::mock_context(tauri::test::noop_assets()))
.expect("mock app");
let manager = QueueManager::test_new(app.handle().clone(), 1, Arc::new(TestSpawner));
for id in ["first", "target", "last"] {
manager
.push(QueuedTask {
id: id.to_string(),
queue_id: "queue".to_string(),
kind: TaskKind::Aria2,
lifecycle_generation: 0,
payload: SpawnPayload {
is_torrent: true,
..Default::default()
},
})
.await
.unwrap();
}
let (index, mut task) = manager
.take_pending_task("target")
.await
.expect("target remains pending");
assert_eq!(manager.pending_order(None).await, ["first", "last"]);
task.payload.torrent_file_indices = Some(vec![2]);
manager.restore_pending_task(index, task).await;
assert_eq!(
manager.pending_order(None).await,
["first", "target", "last"]
);
let (_, restored) = manager
.take_pending_task("target")
.await
.expect("target was restored");
assert_eq!(restored.payload.torrent_file_indices, Some(vec![2]));
}
#[tokio::test]
async fn enabling_separate_seed_capacity_counts_existing_seeders() {
let app = tauri::test::mock_builder()
+95 -1
View File
@@ -19,6 +19,9 @@ pub struct TorrentStartupSettings {
pub peer_id_prefix: String,
pub peer_agent: String,
pub dht_message_timeout: u32,
pub ipv6_enabled: bool,
pub bind_address: String,
pub disk_cache: String,
}
fn normalize_torrent_startup_value(
@@ -40,6 +43,22 @@ pub fn torrent_startup_settings(settings: Option<&PersistedSettings>) -> Torrent
let Some(settings) = settings else {
return TorrentStartupSettings::default();
};
let bind_address = normalize_torrent_startup_value(
"Torrent bind address",
&settings.torrent_bind_address,
crate::queue::normalize_torrent_bind_address,
);
let bind_address = if !settings.torrent_ipv6_enabled
&& bind_address
.parse::<std::net::IpAddr>()
.is_ok_and(|address| address.is_ipv6())
{
log::error!("IPv6 Torrent bind address ignored while IPv6 transport is disabled");
String::new()
} else {
bind_address
};
TorrentStartupSettings {
listen_port: normalize_torrent_startup_value(
"TCP listen ports",
@@ -90,6 +109,13 @@ pub fn torrent_startup_settings(settings: Option<&PersistedSettings>) -> Torrent
settings.torrent_dht_message_timeout,
)
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT),
ipv6_enabled: settings.torrent_ipv6_enabled,
bind_address,
disk_cache: crate::queue::normalize_aria2_disk_cache(Some(&settings.aria2_disk_cache))
.unwrap_or_else(|error| {
log::error!("invalid persisted Aria2 disk cache; using default: {error}");
crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string()
}),
}
}
@@ -150,6 +176,13 @@ pub fn canonicalize_torrent_network_settings(stored: &str) -> Result<String, Str
canonicalize_torrent_network_value(state, "torrentLpdInterface", crate::queue::normalize_torrent_lpd_interface);
canonicalize_torrent_network_value(state, "torrentPeerIdPrefix", crate::queue::normalize_torrent_peer_id_prefix);
canonicalize_torrent_network_value(state, "torrentPeerAgent", crate::queue::normalize_torrent_peer_agent);
canonicalize_torrent_network_value(state, "torrentBindAddress", crate::queue::normalize_torrent_bind_address);
let disk_cache = state
.get("aria2DiskCache")
.and_then(Value::as_str)
.and_then(|value| crate::queue::normalize_aria2_disk_cache(Some(value)).ok())
.unwrap_or_else(|| crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string());
state.insert("aria2DiskCache".to_string(), Value::String(disk_cache));
let dht_message_timeout = state
.get("torrentDhtMessageTimeout")
.and_then(Value::as_u64)
@@ -176,6 +209,26 @@ pub fn canonicalize_torrent_network_settings(stored: &str) -> Result<String, Str
{
state.insert("torrentSeparateSeedSlots".to_string(), Value::Bool(false));
}
if !state
.get("torrentIpv6Enabled")
.is_some_and(Value::is_boolean)
{
state.insert("torrentIpv6Enabled".to_string(), Value::Bool(true));
}
if state
.get("torrentIpv6Enabled")
.and_then(Value::as_bool)
== Some(false)
&& state
.get("torrentBindAddress")
.and_then(Value::as_str)
.and_then(|value| value.parse::<std::net::IpAddr>().ok())
.is_some_and(|address| address.is_ipv6())
{
return Err(
"IPv6 Torrent bind address requires IPv6 transport to remain enabled".to_string(),
);
}
serde_json::to_string(&document)
.map_err(|error| format!("failed to encode canonical settings: {error}"))
}
@@ -365,6 +418,7 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
"torrentEnablePex",
"torrentEnableLpd",
"torrentSeparateSeedSlots",
"torrentIpv6Enabled",
] {
sanitize_boolean_setting(state, key);
}
@@ -395,6 +449,12 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
sanitize_torrent_network_string(state, "torrentPeerAgent", |value| {
crate::queue::normalize_torrent_peer_agent(Some(value)).is_ok()
});
sanitize_torrent_network_string(state, "torrentBindAddress", |value| {
crate::queue::normalize_torrent_bind_address(Some(value)).is_ok()
});
sanitize_torrent_network_string(state, "aria2DiskCache", |value| {
crate::queue::normalize_aria2_disk_cache(Some(value)).is_ok()
});
sanitize_allowed_string(
state,
"theme",
@@ -536,7 +596,24 @@ fn validate_settings(settings: &mut PersistedSettings) {
settings.torrent_max_concurrent_seeds = crate::queue::normalize_torrent_max_concurrent_seeds(
settings.torrent_max_concurrent_seeds,
)
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS);
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS);
settings.torrent_bind_address = crate::queue::normalize_torrent_bind_address(
Some(&settings.torrent_bind_address),
)
.ok()
.flatten()
.unwrap_or_default();
if !settings.torrent_ipv6_enabled
&& settings
.torrent_bind_address
.parse::<std::net::IpAddr>()
.is_ok_and(|address| address.is_ipv6())
{
log::warn!("clearing IPv6 Torrent bind address while IPv6 transport is disabled");
settings.torrent_bind_address.clear();
}
settings.aria2_disk_cache = crate::queue::normalize_aria2_disk_cache(Some(&settings.aria2_disk_cache))
.unwrap_or_else(|_| crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string());
settings.torrent_listen_port = crate::queue::normalize_torrent_port_spec(
Some(&settings.torrent_listen_port),
"TCP listen ports",
@@ -792,6 +869,7 @@ fn default_settings() -> PersistedSettings {
torrent_dht_message_timeout: crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
torrent_separate_seed_slots: false,
torrent_max_concurrent_seeds: crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
torrent_ipv6_enabled: true,
torrent_listen_port: String::new(),
torrent_dht_listen_port: String::new(),
torrent_external_ip: String::new(),
@@ -801,6 +879,8 @@ fn default_settings() -> PersistedSettings {
torrent_lpd_interface: String::new(),
torrent_peer_id_prefix: String::new(),
torrent_peer_agent: String::new(),
torrent_bind_address: String::new(),
aria2_disk_cache: crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string(),
custom_user_agent: String::new(),
ask_where_to_save_each_file: false,
remember_last_used_download_directory: false,
@@ -1225,6 +1305,20 @@ mod tests {
assert_eq!(canonical["state"]["torrentSeparateSeedSlots"], false);
}
#[test]
fn rejects_ipv6_bind_address_when_transport_is_disabled() {
let stored = json!({
"state": {
"torrentIpv6Enabled": false,
"torrentBindAddress": "2001:db8::10"
}
});
let error = canonicalize_torrent_network_settings(&stored.to_string())
.expect_err("IPv6 bind must not be accepted with IPv6 transport disabled");
assert!(error.contains("IPv6 Torrent bind address"));
}
#[test]
fn startup_settings_revalidate_values_at_the_aria2_boundary() {
let stored = json!({
+191 -9
View File
@@ -361,6 +361,133 @@ pub fn parse_torrent_bytes(bytes: &[u8]) -> Result<ParsedTorrent, String> {
parse_info(info)
}
fn bounded_optional_text(value: Option<&BencodeValue>, limit: usize) -> Option<String> {
let BencodeValue::Bytes(bytes) = value? else {
return None;
};
if bytes.len() > limit {
return None;
}
String::from_utf8(bytes.clone())
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn bounded_uri(value: &str, schemes: &[&str]) -> Option<String> {
if value.len() > 2_048 || value.chars().any(char::is_control) {
return None;
}
let parsed = url::Url::parse(value).ok()?;
if !schemes.contains(&parsed.scheme())
|| parsed.host_str().is_none_or(str::is_empty)
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.fragment().is_some()
{
return None;
}
Some(parsed.to_string())
}
fn collect_torrent_uris(value: Option<&BencodeValue>, schemes: &[&str]) -> Vec<String> {
let mut values = Vec::new();
let mut append = |value: &BencodeValue| {
if let BencodeValue::Bytes(bytes) = value {
if let Ok(value) = String::from_utf8(bytes.clone()) {
if let Some(uri) = bounded_uri(value.trim(), schemes) {
if !values.contains(&uri) && values.len() < 256 {
values.push(uri);
}
}
}
}
};
match value {
Some(BencodeValue::Bytes(_)) => append(value.unwrap()),
Some(BencodeValue::List(entries)) => {
for entry in entries {
append(entry);
}
}
_ => {}
}
values
}
pub fn torrent_details_from_bytes(bytes: &[u8]) -> Result<crate::ipc::TorrentDetails, 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"
));
}
let root = match Parser::new(bytes).parse()? {
BencodeValue::Dict(value) => value,
_ => return Err("torrent root is not a dictionary".to_string()),
};
let info = root
.get(b"info".as_slice())
.ok_or_else(|| "torrent metadata is missing info".to_string())?;
let parsed = parse_info(info)?;
let info_dict = match info {
BencodeValue::Dict(value) => value,
_ => return Err("torrent info dictionary is invalid".to_string()),
};
let piece_length = positive_length(info_dict.get(b"piece length".as_slice()), "piece length")?;
let piece_count = match info_dict.get(b"pieces".as_slice()) {
Some(BencodeValue::Bytes(pieces)) if pieces.len() % 20 == 0 => (pieces.len() / 20) as u64,
_ => return Err("torrent pieces field is invalid".to_string()),
};
let creation_date = root
.get(b"creation date".as_slice())
.and_then(|value| match value {
BencodeValue::Integer(value) if *value >= 0 => chrono::DateTime::<chrono::Utc>::from_timestamp(*value, 0)
.map(|date| date.to_rfc3339()),
_ => None,
});
let trackers = collect_torrent_uris(root.get(b"announce".as_slice()), &["http", "https", "udp"])
.into_iter()
.chain(root.get(b"announce-list".as_slice()).into_iter().flat_map(|value| {
let mut trackers = Vec::new();
if let BencodeValue::List(tiers) = value {
for tier in tiers {
if let BencodeValue::List(entries) = tier {
for entry in entries {
trackers.extend(collect_torrent_uris(Some(entry), &["http", "https", "udp"]));
}
}
}
}
trackers
}))
.fold(Vec::new(), |mut result, tracker| {
if !result.contains(&tracker) && result.len() < 256 {
result.push(tracker);
}
result
});
Ok(crate::ipc::TorrentDetails {
info_hash: parsed.info_hash,
display_name: parsed.name,
total_bytes: parsed.total_bytes,
file_count: parsed.files.len() as u32,
piece_length,
piece_count,
private: matches!(info_dict.get(b"private".as_slice()), Some(BencodeValue::Integer(1))),
creation_date,
creator: bounded_optional_text(
root.get(b"created by.utf-8".as_slice()).or_else(|| root.get(b"created by".as_slice())),
256,
),
comment: bounded_optional_text(
root.get(b"comment.utf-8".as_slice()).or_else(|| root.get(b"comment".as_slice())),
4_096,
),
trackers,
web_seeds: collect_torrent_uris(root.get(b"url-list".as_slice()), &["http", "https"]),
})
}
pub fn torrent_metadata_is_safe_for_plain_magnet_reuse(bytes: &[u8]) -> Result<bool, String> {
if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES {
return Err(format!(
@@ -499,31 +626,53 @@ pub fn to_metadata(parsed: ParsedTorrent, torrent_path: Option<String>) -> Torre
/// Aria2's BitTorrent output is controlled by `index-out`, not `out`. Keep
/// these values derived from the validated, canonical paths so the daemon's
/// actual files stay aligned with Firelink's ownership registry.
pub fn aria2_index_outputs(parsed: &ParsedTorrent) -> Vec<String> {
pub fn validate_output_name(name: &str) -> Result<(), String> {
if name.is_empty()
|| name != name.trim()
|| name == "."
|| name == ".."
|| name.ends_with(['.', ' '])
|| name.chars().any(|character| {
character.is_control()
|| matches!(character, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*')
})
|| crate::platform::is_windows_reserved_filename(name)
|| crate::download_ownership::canonical_download_filename(name) != name
{
return Err("Torrent output name is not a safe single path component".to_string());
}
Ok(())
}
pub fn aria2_index_outputs(parsed: &ParsedTorrent, output_name: &str) -> Vec<String> {
parsed
.files
.iter()
.map(|file| {
let output = if parsed.files.len() == 1 {
file.path.clone()
output_name.to_string()
} else {
format!("{}/{}", parsed.name, file.path)
format!("{output_name}/{}", file.path)
};
format!("{}={output}", file.index)
})
.collect()
}
pub fn aria2_output_paths(parsed: &ParsedTorrent, selected: Option<&[u32]>) -> Vec<String> {
pub fn aria2_output_paths(
parsed: &ParsedTorrent,
selected: Option<&[u32]>,
output_name: &str,
) -> Vec<String> {
parsed
.files
.iter()
.filter(|file| selected.is_none_or(|indices| indices.contains(&file.index)))
.map(|file| {
if parsed.files.len() == 1 {
file.path.clone()
output_name.to_string()
} else {
format!("{}/{}", parsed.name, file.path)
format!("{output_name}/{}", file.path)
}
})
.collect()
@@ -699,7 +848,11 @@ pub fn validate_selected_indices(
let mut normalized = selected.to_vec();
normalized.sort_unstable();
normalized.dedup();
Ok(Some(normalized))
if normalized.len() == file_count {
Ok(None)
} else {
Ok(Some(normalized))
}
}
pub async fn prepare_local_torrent<R: tauri::Runtime>(
@@ -954,6 +1107,32 @@ mod tests {
assert_eq!(parsed.info_hash.len(), 40);
}
#[test]
fn validates_torrent_output_names_as_single_safe_components() {
for name in ["test", "My Torrent (1)", "archive.tar"] {
validate_output_name(name).expect("ordinary output names should be accepted");
}
for name in ["", " test", "test ", ".", "..", "a/b", "a\\b", "CON", "a?.bin"] {
assert!(validate_output_name(name).is_err(), "{name:?}");
}
}
#[test]
fn exposes_bounded_torrent_details_from_metadata() {
let mut bytes = b"d4:infod6:lengthi5e4:name4:test12:piece lengthi2e6:pieces20:".to_vec();
bytes.extend([0_u8; 20]);
bytes.extend_from_slice(b"ee");
let details = torrent_details_from_bytes(&bytes).expect("details should parse");
assert_eq!(details.display_name, "test");
assert_eq!(details.total_bytes, 5);
assert_eq!(details.file_count, 1);
assert_eq!(details.piece_length, 2);
assert_eq!(details.piece_count, 1);
assert!(!details.private);
}
#[test]
fn parses_multi_file_torrent_and_rejects_traversal() {
let parsed = parse_torrent_bytes(
@@ -1142,8 +1321,11 @@ mod tests {
)
.expect("multi-file torrent should parse");
assert_eq!(
aria2_index_outputs(&parsed),
vec!["1=root/root/a.txt".to_string(), "2=root/root/b.bin".to_string()]
aria2_index_outputs(&parsed, "custom-name"),
vec![
"1=custom-name/root/a.txt".to_string(),
"2=custom-name/root/b.bin".to_string(),
]
);
}