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:
NimBold
2026-08-03 16:56:01 +03:30
parent 79c0e48c43
commit c4d3a2be51
31 changed files with 3398 additions and 258 deletions
+98
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+83 -1
View File
@@ -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
View File
@@ -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() {