mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-08 10:23:29 +00:00
feat(torrents): harden lifecycle and web-seed management
- Enforce generation-safe seed admission and budget tracking. - Make web-seed RPC, persistence, rollback, and startup attachment lifecycle-safe. - Keep Torrent progress, DHT, seed-capacity, and web-seed validation covered. - Ignore local TORRENT_FEATURES.md roadmap notes.
This commit is contained in:
@@ -34,6 +34,18 @@ fn default_torrent_max_open_files() -> u32 {
|
||||
crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES
|
||||
}
|
||||
|
||||
fn default_torrent_dht_message_timeout() -> u32 {
|
||||
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
}
|
||||
|
||||
fn default_torrent_separate_seed_slots() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_torrent_max_concurrent_seeds() -> u32 {
|
||||
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -49,6 +61,10 @@ pub enum DownloadStatus {
|
||||
/// A BitTorrent download has all selected data and is still seeding.
|
||||
/// The Aria2 GID and queue permit remain live until seeding ends.
|
||||
Seeding,
|
||||
/// A BitTorrent download is complete but paused while waiting for a
|
||||
/// Firelink-owned seeding slot.
|
||||
#[serde(rename = "waitingToSeed")]
|
||||
WaitingToSeed,
|
||||
Paused,
|
||||
Completed,
|
||||
Failed,
|
||||
@@ -66,6 +82,7 @@ impl DownloadStatus {
|
||||
Self::Downloading => "downloading",
|
||||
Self::Processing => "processing",
|
||||
Self::Seeding => "seeding",
|
||||
Self::WaitingToSeed => "waitingToSeed",
|
||||
Self::Paused => "paused",
|
||||
Self::Completed => "completed",
|
||||
Self::Failed => "failed",
|
||||
@@ -188,6 +205,12 @@ pub struct DownloadItem {
|
||||
pub torrent_seed_ratio: Option<f64>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_seed_remaining: Option<f64>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_web_seeds: Option<Vec<TorrentWebSeed>>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_upload_limit: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
@@ -252,6 +275,48 @@ pub struct TorrentPeerDiagnostics {
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentFileProgress {
|
||||
pub index: u32,
|
||||
pub relative_path: String,
|
||||
#[ts(type = "number")]
|
||||
pub length: u64,
|
||||
#[ts(type = "number")]
|
||||
pub completed_length: u64,
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentFileProgressSnapshot {
|
||||
pub files: Vec<TorrentFileProgress>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentPieceProgressSnapshot {
|
||||
#[ts(type = "number")]
|
||||
pub piece_length: u64,
|
||||
#[ts(type = "number")]
|
||||
pub num_pieces: u64,
|
||||
#[ts(type = "number")]
|
||||
pub completed_pieces: u64,
|
||||
pub buckets: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentWebSeed {
|
||||
#[ts(type = "number")]
|
||||
pub file_index: u32,
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
@@ -501,6 +566,12 @@ pub struct PersistedSettings {
|
||||
pub torrent_enable_lpd: bool,
|
||||
#[serde(default = "default_torrent_max_open_files")]
|
||||
pub torrent_max_open_files: u32,
|
||||
#[serde(default = "default_torrent_dht_message_timeout")]
|
||||
pub torrent_dht_message_timeout: u32,
|
||||
#[serde(default = "default_torrent_separate_seed_slots")]
|
||||
pub torrent_separate_seed_slots: bool,
|
||||
#[serde(default = "default_torrent_max_concurrent_seeds")]
|
||||
pub torrent_max_concurrent_seeds: u32,
|
||||
#[serde(default)]
|
||||
pub torrent_listen_port: String,
|
||||
#[serde(default)]
|
||||
@@ -559,6 +630,8 @@ pub struct DownloadStateEvent {
|
||||
pub error: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub file_name: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub torrent_seed_remaining: Option<f64>,
|
||||
}
|
||||
|
||||
impl DownloadStateEvent {
|
||||
@@ -568,6 +641,7 @@ impl DownloadStateEvent {
|
||||
status: status.as_str().to_string(),
|
||||
error: None,
|
||||
file_name: None,
|
||||
torrent_seed_remaining: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,6 +651,7 @@ impl DownloadStateEvent {
|
||||
status: DownloadStatus::Failed.as_str().to_string(),
|
||||
error: Some(error.into()),
|
||||
file_name: None,
|
||||
torrent_seed_remaining: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,6 +661,17 @@ impl DownloadStateEvent {
|
||||
status: DownloadStatus::Paused.as_str().to_string(),
|
||||
error: Some(error.into()),
|
||||
file_name: None,
|
||||
torrent_seed_remaining: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paused_with_seed_remaining(id: impl Into<String>, remaining: Option<f64>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::Paused.as_str().to_string(),
|
||||
error: None,
|
||||
file_name: None,
|
||||
torrent_seed_remaining: remaining,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,6 +681,7 @@ impl DownloadStateEvent {
|
||||
status: DownloadStatus::Completed.as_str().to_string(),
|
||||
error: None,
|
||||
file_name: Some(file_name.into()),
|
||||
torrent_seed_remaining: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,6 +693,17 @@ impl DownloadStateEvent {
|
||||
status: DownloadStatus::Retrying.as_str().to_string(),
|
||||
error: Some(reason.into()),
|
||||
file_name: None,
|
||||
torrent_seed_remaining: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn waiting_to_seed(id: impl Into<String>, remaining: Option<f64>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
status: DownloadStatus::WaitingToSeed.as_str().to_string(),
|
||||
error: None,
|
||||
file_name: None,
|
||||
torrent_seed_remaining: remaining,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+405
-4
@@ -4633,11 +4633,17 @@ async fn pause_download(
|
||||
}
|
||||
}
|
||||
|
||||
let seed_remaining = if state.queue_manager.is_seed_owner(&id) {
|
||||
state.queue_manager.capture_seed_remaining(&id).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
state.queue_manager.release_seed_tracking(&id);
|
||||
state.queue_manager.release_permit(&id).await;
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Paused),
|
||||
crate::ipc::DownloadStateEvent::paused_with_seed_remaining(id, seed_remaining),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -4677,6 +4683,12 @@ async fn pause_download(
|
||||
.await;
|
||||
}
|
||||
|
||||
let seed_remaining = if state.queue_manager.is_seed_owner(&id) {
|
||||
state.queue_manager.capture_seed_remaining(&id).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
state.queue_manager.release_seed_tracking(&id);
|
||||
state.queue_manager.release_permit(&id).await;
|
||||
if registered_lifecycle_generation.is_some()
|
||||
|| removed_pending
|
||||
@@ -4694,7 +4706,7 @@ async fn pause_download(
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Paused),
|
||||
crate::ipc::DownloadStateEvent::paused_with_seed_remaining(id, seed_remaining),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -4723,6 +4735,14 @@ async fn resume_download(
|
||||
state.queue_manager.release_registered_id(&id).await;
|
||||
return Ok(false);
|
||||
};
|
||||
if state.queue_manager.is_waiting_to_seed(&id) {
|
||||
// WaitingToSeed is an intentional paused GID with no download
|
||||
// permit. A resume request only wakes the Firelink seed scheduler;
|
||||
// it must not bypass the seed-slot admission gate.
|
||||
state.queue_manager.wake_seed_waiters();
|
||||
drop(control_guard);
|
||||
return Ok(true);
|
||||
}
|
||||
let status = aria2_download_status(
|
||||
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||
&state.aria2_secret,
|
||||
@@ -6489,6 +6509,297 @@ async fn get_torrent_peers(
|
||||
state.queue_manager.get_aria2_torrent_peers(&id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_torrent_file_progress(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<crate::ipc::TorrentFileProgressSnapshot, String> {
|
||||
state
|
||||
.queue_manager
|
||||
.get_aria2_torrent_file_progress(&id)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_torrent_piece_progress(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<crate::ipc::TorrentPieceProgressSnapshot, String> {
|
||||
state
|
||||
.queue_manager
|
||||
.get_aria2_torrent_piece_progress(&id)
|
||||
.await
|
||||
}
|
||||
|
||||
fn replace_persisted_torrent_web_seeds(
|
||||
database: &crate::db::DbState,
|
||||
id: &str,
|
||||
seeds: &[crate::ipc::TorrentWebSeed],
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
let mut connection = database.lock()?;
|
||||
let records = crate::db::load_downloads(&connection)?;
|
||||
let next_seeds = serde_json::to_value(seeds)
|
||||
.map_err(|error| format!("failed to encode Torrent web seeds: {error}"))?;
|
||||
let mut previous_seeds = None;
|
||||
let mut changed = false;
|
||||
let mut next = Vec::with_capacity(records.len());
|
||||
for record in records {
|
||||
let mut value: serde_json::Value = match serde_json::from_str(&record) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
// Preserve unrelated legacy/corrupt rows byte-for-byte. A
|
||||
// web-seed update must not fail its own transaction merely
|
||||
// because another download cannot be decoded.
|
||||
next.push(record);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if value
|
||||
.get("id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(id)
|
||||
{
|
||||
let object = value
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "persisted download is not an object".to_string())?;
|
||||
previous_seeds = object.get("torrentWebSeeds").cloned();
|
||||
object.insert("torrentWebSeeds".to_string(), next_seeds.clone());
|
||||
changed = true;
|
||||
}
|
||||
next.push(
|
||||
serde_json::to_string(&value)
|
||||
.map_err(|error| format!("failed to encode persisted download: {error}"))?,
|
||||
);
|
||||
}
|
||||
if !changed {
|
||||
return Err("download is not persisted".to_string());
|
||||
}
|
||||
let next_data = serde_json::to_string(&next)
|
||||
.map_err(|error| format!("failed to encode persisted downloads: {error}"))?;
|
||||
crate::db::replace_downloads(&mut connection, &next_data, database.is_portable())?;
|
||||
Ok(previous_seeds)
|
||||
}
|
||||
|
||||
fn restore_persisted_torrent_web_seeds(
|
||||
database: &crate::db::DbState,
|
||||
id: &str,
|
||||
expected_seeds: &[crate::ipc::TorrentWebSeed],
|
||||
previous_seeds: Option<serde_json::Value>,
|
||||
) -> Result<(), String> {
|
||||
let mut connection = database.lock()?;
|
||||
let records = crate::db::load_downloads(&connection)?;
|
||||
let expected_value = serde_json::to_value(expected_seeds)
|
||||
.map_err(|error| format!("failed to encode expected Torrent web seeds: {error}"))?;
|
||||
let mut found = false;
|
||||
let mut changed = false;
|
||||
let mut next = Vec::with_capacity(records.len());
|
||||
for record in records {
|
||||
let mut value: serde_json::Value = match serde_json::from_str(&record) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
next.push(record);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if value
|
||||
.get("id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(id)
|
||||
{
|
||||
found = true;
|
||||
let object = value
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "persisted download is not an object".to_string())?;
|
||||
if object.get("torrentWebSeeds") == Some(&expected_value) {
|
||||
match previous_seeds.clone() {
|
||||
Some(previous) => {
|
||||
object.insert("torrentWebSeeds".to_string(), previous);
|
||||
}
|
||||
None => {
|
||||
object.remove("torrentWebSeeds");
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
} else {
|
||||
log::warn!(
|
||||
"Torrent web-seed rollback [{}] skipped because persisted state changed concurrently",
|
||||
id
|
||||
);
|
||||
}
|
||||
}
|
||||
next.push(
|
||||
serde_json::to_string(&value)
|
||||
.map_err(|error| format!("failed to encode persisted download: {error}"))?,
|
||||
);
|
||||
}
|
||||
if !found {
|
||||
return Err("download is no longer persisted".to_string());
|
||||
}
|
||||
if changed {
|
||||
let next_data = serde_json::to_string(&next)
|
||||
.map_err(|error| format!("failed to encode persisted downloads: {error}"))?;
|
||||
crate::db::replace_downloads(&mut connection, &next_data, database.is_portable())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn normalize_persisted_torrent_web_seeds(
|
||||
database: &crate::db::DbState,
|
||||
app_handle: &tauri::AppHandle,
|
||||
id: &str,
|
||||
seeds: &[crate::ipc::TorrentWebSeed],
|
||||
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
|
||||
let record = {
|
||||
let connection = database.lock()?;
|
||||
crate::db::load_downloads(&connection)?
|
||||
.into_iter()
|
||||
.find_map(|record| {
|
||||
serde_json::from_str::<crate::ipc::DownloadItem>(&record)
|
||||
.ok()
|
||||
.filter(|item| item.id == id)
|
||||
})
|
||||
.ok_or_else(|| "download is not persisted".to_string())?
|
||||
};
|
||||
let path = record
|
||||
.torrent_path
|
||||
.as_deref()
|
||||
.ok_or_else(|| "Torrent metadata is unavailable for web-seed management".to_string())?;
|
||||
let path = crate::torrent::validate_managed_torrent_path(app_handle, id, path)?;
|
||||
let bytes = tokio::fs::read(path)
|
||||
.await
|
||||
.map_err(|error| format!("could not read cached Torrent metadata: {error}"))?;
|
||||
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
|
||||
crate::queue::normalize_torrent_web_seeds(Some(seeds), &metadata.files)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_torrent_web_seeds(
|
||||
database: tauri::State<'_, crate::db::DbState>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
|
||||
if state.queue_manager.is_registered(&id).await
|
||||
&& matches!(state.queue_manager.active_kind(&id).await, Some(crate::queue::TaskKind::Aria2))
|
||||
{
|
||||
return state.queue_manager.get_aria2_torrent_web_seeds(&id).await;
|
||||
}
|
||||
let persisted_seeds = {
|
||||
let connection = database.lock()?;
|
||||
crate::db::load_downloads(&connection)?
|
||||
.into_iter()
|
||||
.find_map(|record| {
|
||||
serde_json::from_str::<crate::ipc::DownloadItem>(&record)
|
||||
.ok()
|
||||
.filter(|item| item.id == id)
|
||||
.map(|item| item.torrent_web_seeds)
|
||||
})
|
||||
.ok_or_else(|| "download is not persisted".to_string())?
|
||||
};
|
||||
let Some(seeds) = persisted_seeds else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if seeds.is_empty() {
|
||||
return Ok(seeds);
|
||||
}
|
||||
normalize_persisted_torrent_web_seeds(
|
||||
database.inner(),
|
||||
&state.queue_manager.app_handle(),
|
||||
&id,
|
||||
&seeds,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_torrent_web_seeds(
|
||||
database: tauri::State<'_, crate::db::DbState>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
seeds: Vec<crate::ipc::TorrentWebSeed>,
|
||||
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
|
||||
let active = state.queue_manager.is_registered(&id).await
|
||||
&& matches!(state.queue_manager.active_kind(&id).await, Some(crate::queue::TaskKind::Aria2));
|
||||
let normalized = if active {
|
||||
state
|
||||
.queue_manager
|
||||
.normalize_aria2_torrent_web_seeds(&id, &seeds)
|
||||
.await?
|
||||
} else {
|
||||
normalize_persisted_torrent_web_seeds(
|
||||
database.inner(),
|
||||
&state.queue_manager.app_handle(),
|
||||
&id,
|
||||
&seeds,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
let previous_seeds =
|
||||
replace_persisted_torrent_web_seeds(database.inner(), &id, &normalized)?;
|
||||
// The download can cross the queued/active boundary while metadata is
|
||||
// being normalized and persistence is updated. Recheck before returning
|
||||
// so a newly active Torrent receives the live Aria2 change instead of
|
||||
// waiting for a restart to apply its persisted value.
|
||||
let active_now = state.queue_manager.is_registered(&id).await
|
||||
&& matches!(state.queue_manager.active_kind(&id).await, Some(crate::queue::TaskKind::Aria2));
|
||||
if !active_now {
|
||||
return Ok(normalized);
|
||||
}
|
||||
match state
|
||||
.queue_manager
|
||||
.set_aria2_torrent_web_seeds(&id, normalized.clone())
|
||||
.await
|
||||
{
|
||||
Ok((result, previous_live_seeds)) => {
|
||||
if let Err(persist_error) =
|
||||
replace_persisted_torrent_web_seeds(database.inner(), &id, &result)
|
||||
{
|
||||
// The live operation succeeded, but the durable value must
|
||||
// remain transactional. Try to restore the exact value that
|
||||
// was persisted before this command before reporting the
|
||||
// persistence failure to the caller.
|
||||
if let Err(rollback_error) = state
|
||||
.queue_manager
|
||||
.set_aria2_torrent_web_seeds(&id, previous_live_seeds)
|
||||
.await
|
||||
{
|
||||
log::error!(
|
||||
"Torrent web-seed live rollback [{}] failed after persistence error: {}",
|
||||
id,
|
||||
rollback_error
|
||||
);
|
||||
}
|
||||
if let Err(restore_error) = restore_persisted_torrent_web_seeds(
|
||||
database.inner(),
|
||||
&id,
|
||||
&normalized,
|
||||
previous_seeds.clone(),
|
||||
) {
|
||||
log::error!(
|
||||
"Torrent web-seed persistence rollback [{}] failed: {}",
|
||||
id,
|
||||
restore_error
|
||||
);
|
||||
}
|
||||
return Err(format!(
|
||||
"Torrent web seeds changed live but could not be persisted: {persist_error}"
|
||||
));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
Err(error) => {
|
||||
if let Err(restore_error) = restore_persisted_torrent_web_seeds(
|
||||
database.inner(),
|
||||
&id,
|
||||
&normalized,
|
||||
previous_seeds,
|
||||
) {
|
||||
log::error!("Torrent web-seed rollback [{}] failed: {}", id, restore_error);
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_speed_limit_for_aria2(limit: &str) -> Option<String> {
|
||||
let trimmed = limit.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -6568,6 +6879,25 @@ fn apply_aria2_torrent_network_options(
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_aria2_torrent_dht_paths(
|
||||
command: &mut std::process::Command,
|
||||
dht_path: &std::path::Path,
|
||||
dht6_path: &std::path::Path,
|
||||
) {
|
||||
command
|
||||
.arg(format!("--dht-file-path={}", dht_path.display()))
|
||||
.arg(format!("--dht-file-path6={}", dht6_path.display()));
|
||||
}
|
||||
|
||||
fn apply_aria2_torrent_dht_options(
|
||||
command: &mut std::process::Command,
|
||||
message_timeout: u32,
|
||||
) {
|
||||
let timeout = queue::normalize_torrent_dht_message_timeout(message_timeout)
|
||||
.unwrap_or(queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT);
|
||||
command.arg(format!("--dht-message-timeout={timeout}"));
|
||||
}
|
||||
|
||||
fn apply_aria2_torrent_peer_identity_options(
|
||||
command: &mut std::process::Command,
|
||||
peer_id_prefix: &str,
|
||||
@@ -7255,8 +7585,12 @@ fn db_save_settings(
|
||||
let prevent_system_sleep = decoded.prevents_sleep_while_downloading;
|
||||
let prevent_display_sleep = decoded.prevents_display_sleep_while_downloading;
|
||||
if let Ok(mut cached) = app_state.scheduler_settings.write() {
|
||||
*cached = Some(decoded);
|
||||
*cached = Some(decoded.clone());
|
||||
}
|
||||
app_state.queue_manager.configure_seed_capacity(
|
||||
decoded.torrent_separate_seed_slots,
|
||||
decoded.torrent_max_concurrent_seeds,
|
||||
);
|
||||
if let Err(error) = app_state
|
||||
.power_manager
|
||||
.set_preferences(prevent_system_sleep, prevent_display_sleep)
|
||||
@@ -7782,6 +8116,8 @@ mod tests {
|
||||
apply_aria2_torrent_network_options,
|
||||
apply_aria2_torrent_peer_identity_options,
|
||||
apply_aria2_torrent_peer_discovery_options,
|
||||
apply_aria2_torrent_dht_paths,
|
||||
apply_aria2_torrent_dht_options,
|
||||
aria2_rpc_port_is_occupied,
|
||||
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
|
||||
redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value,
|
||||
@@ -7834,6 +8170,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_torrent_dht_paths_are_explicit_and_owned_by_firelink() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dht_path = root.path().join("aria2/dht.dat");
|
||||
let dht6_path = root.path().join("aria2/dht6.dat");
|
||||
let mut command = std::process::Command::new("aria2c");
|
||||
|
||||
apply_aria2_torrent_dht_paths(&mut command, &dht_path, &dht6_path);
|
||||
|
||||
assert_eq!(
|
||||
command
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
format!("--dht-file-path={}", dht_path.display()),
|
||||
format!("--dht-file-path6={}", dht6_path.display()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_torrent_dht_message_timeout_is_bounded_and_launch_scoped() {
|
||||
let mut command = std::process::Command::new("aria2c");
|
||||
apply_aria2_torrent_dht_options(&mut command, 42);
|
||||
assert_eq!(
|
||||
command
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["--dht-message-timeout=42"]
|
||||
);
|
||||
|
||||
let mut command = std::process::Command::new("aria2c");
|
||||
apply_aria2_torrent_dht_options(&mut command, 0);
|
||||
assert_eq!(
|
||||
command
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["--dht-message-timeout=10"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_torrent_global_options_are_bounded_and_explicit() {
|
||||
let mut command = std::process::Command::new("aria2c");
|
||||
@@ -10466,6 +10846,12 @@ pub fn run() {
|
||||
|
||||
let database = crate::db::init(&storage_layout)
|
||||
.map_err(|error| format!("failed to initialize persistence: {error}"))?;
|
||||
// Establish Firelink-owned Aria2 routing-table paths after the
|
||||
// existing data-root initializer has created the selected storage
|
||||
// directory, but before the daemon launcher is scheduled. A
|
||||
// conflict must fail startup; silently allowing Aria2 to fall back
|
||||
// to a user-global dht.dat would escape the storage boundary.
|
||||
let aria2_dht_paths = storage_layout.prepare_aria2_dht_paths()?;
|
||||
if let Err(error) = crate::torrent::remove_orphaned_probe_dirs(app.handle()) {
|
||||
log::warn!("could not remove orphaned torrent probes: {error}");
|
||||
}
|
||||
@@ -10564,6 +10950,12 @@ pub fn run() {
|
||||
let scheduler_settings = Arc::new(RwLock::new(persisted_settings.clone()));
|
||||
|
||||
let queue_manager = Arc::new(queue::QueueManager::new(app.handle().clone(), max_concurrent));
|
||||
if let Some(settings) = persisted_settings.as_ref() {
|
||||
queue_manager.configure_seed_capacity(
|
||||
settings.torrent_separate_seed_slots,
|
||||
settings.torrent_max_concurrent_seeds,
|
||||
);
|
||||
}
|
||||
let power_manager = queue_manager.power_manager();
|
||||
if let Some(settings) = persisted_settings.as_ref() {
|
||||
let _ = power_manager.set_preferences(
|
||||
@@ -10723,6 +11115,15 @@ pub fn run() {
|
||||
torrent_max_open_files,
|
||||
Some(&torrent_overall_upload_limit),
|
||||
);
|
||||
apply_aria2_torrent_dht_paths(
|
||||
&mut cmd,
|
||||
&aria2_dht_paths.0,
|
||||
&aria2_dht_paths.1,
|
||||
);
|
||||
apply_aria2_torrent_dht_options(
|
||||
&mut cmd,
|
||||
torrent_startup_settings.dht_message_timeout,
|
||||
);
|
||||
|
||||
apply_aria2_torrent_peer_discovery_options(
|
||||
&mut cmd,
|
||||
@@ -11332,7 +11733,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_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, get_torrent_peers, 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_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_file_progress, get_torrent_piece_progress, 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,
|
||||
|
||||
+1840
-10
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ pub struct TorrentStartupSettings {
|
||||
pub lpd_interface: String,
|
||||
pub peer_id_prefix: String,
|
||||
pub peer_agent: String,
|
||||
pub dht_message_timeout: u32,
|
||||
}
|
||||
|
||||
fn normalize_torrent_startup_value(
|
||||
@@ -85,6 +86,10 @@ pub fn torrent_startup_settings(settings: Option<&PersistedSettings>) -> Torrent
|
||||
&settings.torrent_peer_agent,
|
||||
crate::queue::normalize_torrent_peer_agent,
|
||||
),
|
||||
dht_message_timeout: crate::queue::normalize_torrent_dht_message_timeout(
|
||||
settings.torrent_dht_message_timeout,
|
||||
)
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +150,32 @@ 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);
|
||||
let dht_message_timeout = state
|
||||
.get("torrentDhtMessageTimeout")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.and_then(|value| crate::queue::normalize_torrent_dht_message_timeout(value).ok())
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT);
|
||||
state.insert(
|
||||
"torrentDhtMessageTimeout".to_string(),
|
||||
Value::Number(serde_json::Number::from(dht_message_timeout)),
|
||||
);
|
||||
let max_concurrent_seeds = state
|
||||
.get("torrentMaxConcurrentSeeds")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.and_then(|value| crate::queue::normalize_torrent_max_concurrent_seeds(value).ok())
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS);
|
||||
state.insert(
|
||||
"torrentMaxConcurrentSeeds".to_string(),
|
||||
Value::Number(serde_json::Number::from(max_concurrent_seeds)),
|
||||
);
|
||||
if !state
|
||||
.get("torrentSeparateSeedSlots")
|
||||
.is_some_and(Value::is_boolean)
|
||||
{
|
||||
state.insert("torrentSeparateSeedSlots".to_string(), Value::Bool(false));
|
||||
}
|
||||
serde_json::to_string(&document)
|
||||
.map_err(|error| format!("failed to encode canonical settings: {error}"))
|
||||
}
|
||||
@@ -314,11 +345,26 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
|
||||
.contains(&value)
|
||||
})
|
||||
});
|
||||
sanitize_integer_setting(state, "torrentDhtMessageTimeout", |value| {
|
||||
value.as_u64().and_then(|value| u32::try_from(value).ok()).is_some_and(|value| {
|
||||
(crate::queue::MIN_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
..=crate::queue::MAX_TORRENT_DHT_MESSAGE_TIMEOUT)
|
||||
.contains(&value)
|
||||
})
|
||||
});
|
||||
sanitize_integer_setting(state, "torrentMaxConcurrentSeeds", |value| {
|
||||
value.as_u64().and_then(|value| u32::try_from(value).ok()).is_some_and(|value| {
|
||||
(crate::queue::MIN_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
..=crate::queue::MAX_TORRENT_MAX_CONCURRENT_SEEDS)
|
||||
.contains(&value)
|
||||
})
|
||||
});
|
||||
for key in [
|
||||
"torrentEnableDht",
|
||||
"torrentEnableDht6",
|
||||
"torrentEnablePex",
|
||||
"torrentEnableLpd",
|
||||
"torrentSeparateSeedSlots",
|
||||
] {
|
||||
sanitize_boolean_setting(state, key);
|
||||
}
|
||||
@@ -483,6 +529,14 @@ fn validate_settings(settings: &mut PersistedSettings) {
|
||||
settings.torrent_max_open_files,
|
||||
)
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES);
|
||||
settings.torrent_dht_message_timeout = crate::queue::normalize_torrent_dht_message_timeout(
|
||||
settings.torrent_dht_message_timeout,
|
||||
)
|
||||
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT);
|
||||
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);
|
||||
settings.torrent_listen_port = crate::queue::normalize_torrent_port_spec(
|
||||
Some(&settings.torrent_listen_port),
|
||||
"TCP listen ports",
|
||||
@@ -735,6 +789,9 @@ fn default_settings() -> PersistedSettings {
|
||||
torrent_enable_pex: true,
|
||||
torrent_enable_lpd: false,
|
||||
torrent_max_open_files: crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES,
|
||||
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_listen_port: String::new(),
|
||||
torrent_dht_listen_port: String::new(),
|
||||
torrent_external_ip: String::new(),
|
||||
@@ -1085,6 +1142,10 @@ mod tests {
|
||||
settings.torrent_max_open_files,
|
||||
crate::queue::DEFAULT_TORRENT_MAX_OPEN_FILES
|
||||
);
|
||||
assert_eq!(
|
||||
settings.torrent_dht_message_timeout,
|
||||
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
);
|
||||
assert!(settings.torrent_listen_port.is_empty());
|
||||
assert!(settings.torrent_dht_listen_port.is_empty());
|
||||
assert!(settings.torrent_external_ip.is_empty());
|
||||
@@ -1139,7 +1200,10 @@ mod tests {
|
||||
"torrentListenPort": " 6881-6999 ",
|
||||
"torrentExternalIp": "not-an-ip",
|
||||
"torrentPeerIdPrefix": "123456789012345678901",
|
||||
"torrentPeerAgent": " Firelink/1.3.1 "
|
||||
"torrentPeerAgent": " Firelink/1.3.1 ",
|
||||
"torrentDhtMessageTimeout": 601,
|
||||
"torrentMaxConcurrentSeeds": 65,
|
||||
"torrentSeparateSeedSlots": "yes"
|
||||
},
|
||||
"version": 6
|
||||
});
|
||||
@@ -1150,6 +1214,15 @@ mod tests {
|
||||
assert_eq!(canonical["state"]["torrentExternalIp"], "");
|
||||
assert_eq!(canonical["state"]["torrentPeerIdPrefix"], "");
|
||||
assert_eq!(canonical["state"]["torrentPeerAgent"], "Firelink/1.3.1");
|
||||
assert_eq!(
|
||||
canonical["state"]["torrentDhtMessageTimeout"],
|
||||
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
);
|
||||
assert_eq!(
|
||||
canonical["state"]["torrentMaxConcurrentSeeds"],
|
||||
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
);
|
||||
assert_eq!(canonical["state"]["torrentSeparateSeedSlots"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1166,12 +1239,21 @@ mod tests {
|
||||
assert!(startup.listen_port.is_empty());
|
||||
assert!(startup.peer_id_prefix.is_empty());
|
||||
assert_eq!(startup.peer_agent, "Firelink/1.3.1");
|
||||
assert_eq!(
|
||||
startup.dht_message_timeout,
|
||||
crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opt_in_defaults_match_the_frontend_defaults() {
|
||||
assert!(!default_settings().play_completion_sound);
|
||||
assert!(!default_settings().auto_add_clipboard_links);
|
||||
assert!(!default_settings().torrent_separate_seed_slots);
|
||||
assert_eq!(
|
||||
default_settings().torrent_max_concurrent_seeds,
|
||||
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+113
-1
@@ -5,6 +5,9 @@ pub const PORTABLE_MARKER: &str = "portable.flag";
|
||||
const PORTABLE_DATA_DIR: &str = "data";
|
||||
const PORTABLE_LOG_DIR: &str = "logs";
|
||||
const PORTABLE_WEBVIEW_DIR: &str = "webview";
|
||||
const ARIA2_DATA_DIR: &str = "aria2";
|
||||
const ARIA2_DHT_FILE: &str = "dht.dat";
|
||||
const ARIA2_DHT6_FILE: &str = "dht6.dat";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StorageMode {
|
||||
@@ -104,6 +107,59 @@ impl StorageLayout {
|
||||
pub fn webview_dir(&self) -> &Path {
|
||||
&self.webview_dir
|
||||
}
|
||||
|
||||
pub fn aria2_dht_paths(&self) -> (PathBuf, PathBuf) {
|
||||
let directory = self.data_dir.join(ARIA2_DATA_DIR);
|
||||
(
|
||||
directory.join(ARIA2_DHT_FILE),
|
||||
directory.join(ARIA2_DHT6_FILE),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create and validate only Firelink's Aria2 state directory. Aria2 owns
|
||||
/// the table contents; Firelink owns this exact location and must never
|
||||
/// fall back to a user-global default when it cannot establish it.
|
||||
pub fn prepare_aria2_dht_paths(&self) -> Result<(PathBuf, PathBuf), String> {
|
||||
let directory = self.data_dir.join(ARIA2_DATA_DIR);
|
||||
if crate::path_has_symlink_component(&directory) {
|
||||
return Err(format!(
|
||||
"Aria2 state directory contains a symlink: '{}'",
|
||||
directory.display()
|
||||
));
|
||||
}
|
||||
|
||||
match std::fs::symlink_metadata(&directory) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err(format!(
|
||||
"Aria2 state directory is a symlink: '{}'",
|
||||
directory.display()
|
||||
));
|
||||
}
|
||||
Ok(metadata) if !metadata.is_dir() => {
|
||||
return Err(format!(
|
||||
"Aria2 state path is not a directory: '{}'",
|
||||
directory.display()
|
||||
));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
std::fs::create_dir(&directory).map_err(|error| {
|
||||
format!(
|
||||
"failed to create Aria2 state directory '{}': {error}",
|
||||
directory.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"failed to inspect Aria2 state directory '{}': {error}",
|
||||
directory.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(self.aria2_dht_paths())
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
|
||||
@@ -154,7 +210,7 @@ fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{canonicalize_storage_path, StorageMode, PORTABLE_MARKER};
|
||||
use super::{canonicalize_storage_path, StorageLayout, StorageMode, PORTABLE_MARKER};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
@@ -182,6 +238,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn test_layout(data_dir: &Path) -> StorageLayout {
|
||||
let data_dir = fs::canonicalize(data_dir).unwrap();
|
||||
StorageLayout {
|
||||
mode: StorageMode::Standard,
|
||||
data_dir: data_dir.clone(),
|
||||
log_dir: data_dir.join("logs"),
|
||||
webview_dir: data_dir.join("webview"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_dht_paths_are_owned_by_the_selected_data_directory() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let layout = test_layout(root.path());
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
layout.aria2_dht_paths(),
|
||||
(
|
||||
root_path.join("aria2/dht.dat"),
|
||||
root_path.join("aria2/dht6.dat")
|
||||
)
|
||||
);
|
||||
let prepared = layout.prepare_aria2_dht_paths().unwrap();
|
||||
assert_eq!(prepared, layout.aria2_dht_paths());
|
||||
assert!(root_path.join("aria2").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_dht_preparation_rejects_a_file_at_the_directory_boundary() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
fs::write(root_path.join("aria2"), b"not a directory").unwrap();
|
||||
|
||||
let error = test_layout(root.path())
|
||||
.prepare_aria2_dht_paths()
|
||||
.unwrap_err();
|
||||
assert!(error.contains("not a directory"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn aria2_dht_preparation_rejects_a_symlinked_directory() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
let target = TempDir::new().unwrap();
|
||||
let root_path = fs::canonicalize(root.path()).unwrap();
|
||||
symlink(target.path(), root_path.join("aria2")).unwrap();
|
||||
|
||||
let error = test_layout(root.path())
|
||||
.prepare_aria2_dht_paths()
|
||||
.unwrap_err();
|
||||
assert!(error.contains("symlink"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_symlinked_storage_directories() {
|
||||
|
||||
Reference in New Issue
Block a user