mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-09 18:59:36 +00:00
feat(torrent): complete lifecycle controls
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
+384
-5
@@ -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()
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user