feat(torrents): safely remove unselected files

This commit is contained in:
NimBold
2026-08-02 09:49:57 +03:30
parent 67023d3f0d
commit b4da68655a
19 changed files with 695 additions and 49 deletions
+129 -6
View File
@@ -7,7 +7,7 @@ use std::sync::Mutex;
const DATABASE_NAME: &str = "firelink.sqlite";
const LEGACY_STORE_NAME: &str = "store.bin";
const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app";
const CURRENT_SCHEMA_VERSION: i64 = 2;
const CURRENT_SCHEMA_VERSION: i64 = 3;
pub(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed";
pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token";
// Development builds are a different executable identity from the packaged
@@ -193,6 +193,19 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(),
.map_err(|error| format!("failed to migrate download ownership paths: {error}"))?;
}
if from_version < 3 {
transaction
.execute_batch(
"
CREATE TABLE IF NOT EXISTS download_removal_paths (
id TEXT PRIMARY KEY,
paths TEXT NOT NULL
);
",
)
.map_err(|error| format!("failed to migrate torrent removal paths: {error}"))?;
}
transaction
.pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION)
.map_err(|error| format!("failed to update database schema version: {error}"))?;
@@ -1265,12 +1278,23 @@ pub fn set_ownership_paths(
id: &str,
primary_path: &str,
paths: &[String],
) -> Result<(), String> {
set_ownership_paths_checked(connection, id, primary_path, paths, &[])
}
fn set_ownership_paths_checked(
connection: &Connection,
id: &str,
primary_path: &str,
paths: &[String],
removal_paths: &[String],
) -> Result<(), String> {
let mut statement = connection
.prepare(
"SELECT ownership.id, ownership.primary_path, paths.paths
"SELECT ownership.id, ownership.primary_path, paths.paths, removal.paths
FROM download_ownership AS ownership
LEFT JOIN download_owned_paths AS paths ON paths.id = ownership.id
LEFT JOIN download_removal_paths AS removal ON removal.id = ownership.id
WHERE ownership.id <> ?1",
)
.map_err(|error| format!("failed to prepare download ownership check: {error}"))?;
@@ -1281,15 +1305,22 @@ pub fn set_ownership_paths(
.get::<_, Option<String>>(2)?
.and_then(|value| serde_json::from_str::<Vec<String>>(&value).ok())
.unwrap_or_else(|| vec![primary.clone()]);
Ok((primary, owned))
let removal = row
.get::<_, Option<String>>(3)?
.and_then(|value| serde_json::from_str::<Vec<String>>(&value).ok())
.unwrap_or_default();
Ok((primary, owned, removal))
})
.map_err(|error| format!("failed to check download ownership paths: {error}"))?;
for row in existing {
let (existing_primary, owned) =
let (existing_primary, owned, removal) =
row.map_err(|error| format!("failed to read download ownership paths: {error}"))?;
let new_paths = std::iter::once(primary_path).chain(paths.iter().map(String::as_str));
let new_paths = std::iter::once(primary_path)
.chain(paths.iter().map(String::as_str))
.chain(removal_paths.iter().map(String::as_str));
let existing_paths = std::iter::once(existing_primary.as_str())
.chain(owned.iter().map(String::as_str));
.chain(owned.iter().map(String::as_str))
.chain(removal.iter().map(String::as_str));
if new_paths.clone().any(|new_path| {
existing_paths
.clone()
@@ -1318,7 +1349,39 @@ pub fn set_ownership_paths(
Ok(())
}
pub fn set_ownership_and_removal_paths(
connection: &Connection,
id: &str,
primary_path: &str,
paths: &[String],
removal_paths: &[String],
) -> Result<(), String> {
set_ownership_paths_checked(connection, id, primary_path, paths, removal_paths)?;
if removal_paths.is_empty() {
connection
.execute(
"DELETE FROM download_removal_paths WHERE id = ?1",
params![id],
)
.map_err(|error| format!("failed to clear torrent removal paths: {error}"))?;
} else {
let encoded_paths = serde_json::to_string(removal_paths)
.map_err(|error| format!("failed to encode torrent removal paths: {error}"))?;
connection
.execute(
"INSERT INTO download_removal_paths (id, paths) VALUES (?1, ?2)
ON CONFLICT(id) DO UPDATE SET paths = excluded.paths",
params![id, encoded_paths],
)
.map_err(|error| format!("failed to save torrent removal paths: {error}"))?;
}
Ok(())
}
pub fn remove_ownership(connection: &Connection, id: &str) -> Result<(), String> {
connection
.execute("DELETE FROM download_removal_paths WHERE id = ?1", params![id])
.map_err(|error| format!("failed to delete torrent removal paths: {error}"))?;
connection
.execute("DELETE FROM download_owned_paths WHERE id = ?1", params![id])
.map_err(|error| format!("failed to delete download ownership paths: {error}"))?;
@@ -1328,6 +1391,33 @@ pub fn remove_ownership(connection: &Connection, id: &str) -> Result<(), String>
Ok(())
}
pub fn remove_torrent_removal_paths(connection: &Connection, id: &str) -> Result<(), String> {
connection
.execute("DELETE FROM download_removal_paths WHERE id = ?1", params![id])
.map_err(|error| format!("failed to clear torrent removal paths: {error}"))?;
Ok(())
}
pub fn load_torrent_removal_paths(
connection: &Connection,
id: &str,
) -> Result<Vec<String>, String> {
connection
.query_row(
"SELECT paths FROM download_removal_paths WHERE id = ?1",
params![id],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(|error| format!("failed to read torrent removal paths: {error}"))?
.map(|value| {
serde_json::from_str::<Vec<String>>(&value)
.map_err(|error| format!("failed to decode torrent removal paths: {error}"))
})
.transpose()
.map(|paths| paths.unwrap_or_default())
}
pub fn has_user_data(connection: &Connection) -> Result<bool, String> {
connection
.query_row(
@@ -2464,4 +2554,37 @@ mod tests {
.expect_err("a torrent root must not be reused");
assert!(error.contains("already owned"));
}
#[test]
fn removal_reservations_block_later_download_ownership_claims() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let connection = state.lock().unwrap();
set_ownership_and_removal_paths(
&connection,
"torrent",
"/downloads/selected.bin",
&["/downloads/selected.bin".to_string()],
&["/downloads/unselected.bin".to_string()],
)
.unwrap();
let error = set_ownership_paths(
&connection,
"later",
"/downloads/unselected.bin",
&["/downloads/unselected.bin".to_string()],
)
.expect_err("a planned Torrent deletion must reserve its path");
assert!(error.contains("already owned"));
remove_torrent_removal_paths(&connection, "torrent").unwrap();
set_ownership_paths(
&connection,
"later",
"/downloads/unselected.bin",
&["/downloads/unselected.bin".to_string()],
)
.unwrap();
}
}
+105 -9
View File
@@ -91,8 +91,8 @@ fn truncate_utf8_to_bytes(value: &str, max_bytes: usize) -> String {
value[..end].to_string()
}
pub fn expected_primary_path(
app_handle: &tauri::AppHandle,
pub fn expected_primary_path<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
destination: &str,
filename: &str,
) -> Result<PathBuf, String> {
@@ -110,8 +110,8 @@ pub fn expected_primary_path(
.ok_or_else(|| "Download path could not be canonicalized".to_string())
}
pub fn register_expected(
app_handle: &tauri::AppHandle,
pub fn register_expected<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
destination: &str,
filename: &str,
@@ -120,8 +120,8 @@ pub fn register_expected(
set_primary_path(app_handle, id, &path)
}
pub fn set_primary_path(
app_handle: &tauri::AppHandle,
pub fn set_primary_path<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
path: &Path,
) -> Result<(), String> {
@@ -178,6 +178,76 @@ pub fn set_owned_paths_with_primary<R: tauri::Runtime>(
)
}
pub fn set_owned_paths_with_primary_and_removal<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
primary: &Path,
paths: &[PathBuf],
removal_paths: &[PathBuf],
) -> Result<(), String> {
if paths.is_empty() {
return Err("Download ownership requires at least one path".to_string());
}
let canonical_primary = canonical_owned_path(app_handle, primary)?;
let canonical_paths = canonical_file_paths(app_handle, paths)?;
let canonical_removal_paths = canonical_file_paths(app_handle, removal_paths)?;
let mut current_paths = owned_paths_for_id(app_handle, id)?;
if let Some(primary) = primary_path_for_id(app_handle, id)? {
current_paths.push(primary);
}
let known_paths = known_primary_paths(app_handle)?;
if canonical_removal_paths.iter().any(|candidate| {
known_paths.iter().any(|known| {
crate::platform::paths_equal(candidate, known)
&& !current_paths
.iter()
.any(|current| crate::platform::paths_equal(candidate, current))
})
}) {
return Err(
"Torrent removal would delete a file owned by another Firelink download".to_string(),
);
}
let path_strings = canonical_paths
.iter()
.map(|path| path.to_string_lossy().to_string())
.collect::<Vec<_>>();
let removal_strings = canonical_removal_paths
.iter()
.map(|path| path.to_string_lossy().to_string())
.collect::<Vec<_>>();
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
crate::db::set_ownership_and_removal_paths(
&connection,
id,
&canonical_primary.to_string_lossy(),
&path_strings,
&removal_strings,
)
}
fn canonical_file_paths<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
paths: &[PathBuf],
) -> Result<Vec<PathBuf>, String> {
let mut canonical_paths = Vec::with_capacity(paths.len());
for path in paths {
if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) {
return Err("Download ownership file path is a directory".to_string());
}
let canonical_path = canonical_owned_path(app_handle, path)?;
if !canonical_paths
.iter()
.any(|existing: &PathBuf| crate::platform::paths_equal(existing, &canonical_path))
{
canonical_paths.push(canonical_path);
}
}
Ok(canonical_paths)
}
fn canonical_owned_path<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
path: &Path,
@@ -204,12 +274,34 @@ fn canonical_owned_path<R: tauri::Runtime>(
Ok(canonical_path)
}
pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> {
pub fn remove<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
) -> Result<(), String> {
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
crate::db::remove_ownership(&connection, id)
}
pub fn clear_torrent_removal_paths<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
) -> Result<(), String> {
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
crate::db::remove_torrent_removal_paths(&connection, id)
}
pub fn torrent_removal_paths_for_id<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
) -> Result<Vec<PathBuf>, String> {
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
crate::db::load_torrent_removal_paths(&connection, id)
.map(|paths| paths.into_iter().map(PathBuf::from).collect())
}
pub fn primary_path_for_id<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
@@ -231,7 +323,9 @@ pub fn owned_paths_for_id<R: tauri::Runtime>(
.unwrap_or_default())
}
pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
pub fn known_primary_paths<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
) -> Result<Vec<PathBuf>, String> {
let mut paths: Vec<PathBuf> = load_records(app_handle)?
.into_iter()
.flat_map(|record| {
@@ -267,7 +361,9 @@ fn load_records<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) -> Result<V
})
}
fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
fn legacy_download_queue_paths<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
) -> Result<Vec<PathBuf>, String> {
let settings = crate::settings::load_settings(app_handle).ok();
let downloads = {
+3
View File
@@ -206,6 +206,9 @@ pub struct DownloadItem {
#[serde(default)]
#[ts(optional)]
pub torrent_prioritize_piece: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_remove_unselected_file: Option<bool>,
}
#[derive(Clone, Debug, Serialize, TS)]
+128 -18
View File
@@ -5816,10 +5816,24 @@ async fn validate_torrent_enqueue(
item.torrent_info_hash.as_deref(),
&metadata.info_hash,
)?;
crate::torrent::validate_selected_indices(
let selected = crate::torrent::validate_selected_indices(
item.torrent_file_indices.as_deref(),
metadata.files.len(),
)?;
if item.torrent_remove_unselected_file.unwrap_or(false) {
let Some(selected) = selected else {
return Err(
"removing unselected Torrent files requires selecting a subset of files"
.to_string(),
);
};
if selected.len() >= metadata.files.len() {
return Err(
"removing unselected Torrent files requires at least one unselected file"
.to_string(),
);
}
}
return Ok(());
}
@@ -5830,14 +5844,24 @@ async fn validate_torrent_enqueue(
if item.torrent_file_indices.is_some() {
return Err("magnet file selection requires resolved torrent metadata".to_string());
}
if item.torrent_remove_unselected_file.unwrap_or(false) {
return Err(
"removing unselected Torrent files requires resolved torrent metadata".to_string(),
);
}
let metadata = crate::torrent::inspect_source(&item.url)?;
crate::torrent::validate_info_hash(item.torrent_info_hash.as_deref(), &metadata.info_hash)
}
struct ExpectedTorrentOutputPaths {
selected: Vec<std::path::PathBuf>,
unselected: Vec<std::path::PathBuf>,
}
fn expected_torrent_output_paths(
app_handle: &tauri::AppHandle,
item: &queue::EnqueueItem,
) -> Result<Option<Vec<std::path::PathBuf>>, String> {
) -> Result<Option<ExpectedTorrentOutputPaths>, String> {
if !item.is_torrent.unwrap_or(false) {
return Ok(None);
}
@@ -5858,8 +5882,10 @@ fn expected_torrent_output_paths(
}
let canonical_destination = crate::canonicalize_with_missing_components(&destination)
.ok_or_else(|| "torrent destination could not be canonicalized".to_string())?;
let mut paths = Vec::new();
for relative in crate::torrent::aria2_output_paths(&metadata, selected.as_deref()) {
let selected_relative = crate::torrent::aria2_output_paths(&metadata, selected.as_deref());
let resolve_paths = |relative_paths: Vec<String>| -> Result<Vec<std::path::PathBuf>, String> {
let mut paths = Vec::new();
for relative in relative_paths {
let relative = std::path::PathBuf::from(relative);
if relative.is_absolute()
|| relative.components().any(|component| {
@@ -5878,8 +5904,41 @@ fn expected_torrent_output_paths(
return Err("torrent output path is outside its destination".to_string());
}
paths.push(canonical_path);
}
Ok(Some(paths))
}
Ok(paths)
};
let selected_paths = resolve_paths(selected_relative)?;
let unselected_paths = if item.torrent_remove_unselected_file.unwrap_or(false) {
let selected_indices = selected
.as_deref()
.ok_or_else(|| "torrent file selection is required for unselected-file removal".to_string())?;
let selected_indices = selected_indices.iter().copied().collect::<std::collections::HashSet<_>>();
let unselected_relative = metadata
.files
.iter()
.filter(|file| !selected_indices.contains(&file.index))
.map(|file| {
if metadata.files.len() == 1 {
file.path.clone()
} else {
format!("{}/{}", metadata.name, file.path)
}
})
.collect::<Vec<_>>();
let paths = resolve_paths(unselected_relative)?;
if paths.iter().any(|path| {
std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir())
}) {
return Err("unselected Torrent output path is a directory".to_string());
}
paths
} else {
Vec::new()
};
Ok(Some(ExpectedTorrentOutputPaths {
selected: selected_paths,
unselected: unselected_paths,
}))
}
async fn remove_magnet_metadata_probe_dir(path: &std::path::Path) -> Result<(), String> {
@@ -6131,12 +6190,23 @@ async fn enqueue_download(
return Err(AppError::Internal(error));
}
};
if let Err(error) = crate::download_ownership::set_owned_paths_with_primary(
&app_handle,
&item.id,
&primary,
&paths,
) {
let ownership_result = if item.torrent_remove_unselected_file.unwrap_or(false) {
crate::download_ownership::set_owned_paths_with_primary_and_removal(
&app_handle,
&item.id,
&primary,
&paths.selected,
&paths.unselected,
)
} else {
crate::download_ownership::set_owned_paths_with_primary(
&app_handle,
&item.id,
&primary,
&paths.selected,
)
};
if let Err(error) = ownership_result {
let _ = crate::download_ownership::remove(&app_handle, &id);
state
.queue_manager
@@ -6155,6 +6225,17 @@ async fn enqueue_download(
return Err(AppError::Internal(error));
}
}
if !item.torrent_remove_unselected_file.unwrap_or(false) {
if let Err(error) = crate::download_ownership::clear_torrent_removal_paths(&app_handle, &id)
{
let _ = crate::download_ownership::remove(&app_handle, &id);
state
.queue_manager
.rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation)
.await;
return Err(AppError::Internal(error));
}
}
if let Err(error) = state
.queue_manager
.commit_reserved_enqueue(item.into_task(), lifecycle_generation)
@@ -6283,12 +6364,23 @@ async fn enqueue_many(
continue;
}
};
if let Err(error) = crate::download_ownership::set_owned_paths_with_primary(
&app_handle,
&item.id,
&primary,
&paths,
) {
let ownership_result = if item.torrent_remove_unselected_file.unwrap_or(false) {
crate::download_ownership::set_owned_paths_with_primary_and_removal(
&app_handle,
&item.id,
&primary,
&paths.selected,
&paths.unselected,
)
} else {
crate::download_ownership::set_owned_paths_with_primary(
&app_handle,
&item.id,
&primary,
&paths.selected,
)
};
if let Err(error) = ownership_result {
let _ = crate::download_ownership::remove(&app_handle, &id);
state
.queue_manager
@@ -6319,6 +6411,24 @@ async fn enqueue_many(
continue;
}
}
if !item.torrent_remove_unselected_file.unwrap_or(false) {
if let Err(error) =
crate::download_ownership::clear_torrent_removal_paths(&app_handle, &id)
{
let _ = crate::download_ownership::remove(&app_handle, &id);
state
.queue_manager
.rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation)
.await;
results.push(crate::ipc::EnqueueResult {
id,
success: false,
filename: None,
error: Some(error),
});
continue;
}
}
if let Err(error) = state
.queue_manager
.commit_reserved_enqueue(item.into_task(), lifecycle_generation)
+138
View File
@@ -220,6 +220,7 @@ pub struct SpawnPayload {
pub torrent_exclude_trackers: Option<String>,
pub torrent_stop_timeout: Option<u32>,
pub torrent_prioritize_piece: Option<String>,
pub torrent_remove_unselected_file: bool,
}
/// A sidecar spawner. In production this calls the real aria2/yt-dlp
@@ -1907,10 +1908,51 @@ impl<R: tauri::Runtime> QueueManager<R> {
// from the previous lifecycle before releasing its permit.
self.next_aria2_control_epoch(id).await;
self.cancel_aria2_retries(id).await;
let torrent_removal_requested = self
.aria2_payloads
.lock()
.await
.get(id)
.is_some_and(|payload| payload.is_torrent && payload.torrent_remove_unselected_file);
match outcome {
PendingOutcome::Complete => {
self.clear_aria2_retry_state(id).await;
self.forget_aria2_gid(id).await;
if torrent_removal_requested {
match crate::download_ownership::torrent_removal_paths_for_id(
&self.app_handle,
id,
) {
Ok(paths) if paths.iter().all(|path| !path.exists()) => {
if let Err(error) =
crate::download_ownership::clear_torrent_removal_paths(
&self.app_handle,
id,
)
{
log::warn!(
"aria2 torrent removal reservation [{}]: could not clear after completion: {}",
id,
error
);
}
}
Ok(paths) => {
log::warn!(
"aria2 torrent removal reservation [{}]: keeping {} path(s) reserved because Aria2 cleanup was not observed",
id,
paths.len()
);
}
Err(error) => {
log::warn!(
"aria2 torrent removal reservation [{}]: could not verify cleanup: {}",
id,
error
);
}
}
}
self.release_registered_id(id).await;
self.release_permit(id).await;
self.emit_state(id, DownloadStatus::Completed);
@@ -1931,6 +1973,17 @@ impl<R: tauri::Runtime> QueueManager<R> {
self.clear_aria2_retry_state(id).await;
self.forget_aria2_gid(id).await;
if torrent_removal_requested {
if let Err(clear_error) =
crate::download_ownership::clear_torrent_removal_paths(&self.app_handle, id)
{
log::warn!(
"aria2 torrent removal reservation [{}]: could not clear after terminal failure: {}",
id,
clear_error
);
}
}
self.release_registered_id(id).await;
self.release_permit(id).await;
self.emit_failed(id, error);
@@ -3533,6 +3586,21 @@ fn apply_aria2_torrent_options(
serde_json::json!(piece_priority),
);
}
if payload.torrent_remove_unselected_file {
let Some(indices) = payload.torrent_file_indices.as_deref() else {
return Err(
"removing unselected Torrent files requires selecting a subset of files"
.to_string(),
);
};
if indices.is_empty() {
return Err("torrent file selection is invalid".to_string());
}
options.insert(
"bt-remove-unselected-file".to_string(),
serde_json::json!("true"),
);
}
if payload.torrent_check_integrity {
options.insert(
"check-integrity".to_string(),
@@ -4153,6 +4221,9 @@ pub struct EnqueueItem {
pub torrent_prioritize_piece: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_remove_unselected_file: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub lifecycle_generation: Option<String>,
}
@@ -4205,6 +4276,9 @@ impl EnqueueItem {
torrent_exclude_trackers: self.torrent_exclude_trackers,
torrent_stop_timeout: self.torrent_stop_timeout,
torrent_prioritize_piece: self.torrent_prioritize_piece,
torrent_remove_unselected_file: self
.torrent_remove_unselected_file
.unwrap_or(false),
},
}
}
@@ -4596,6 +4670,52 @@ mod tests {
assert!(!options.contains_key("bt-prioritize-piece"));
}
#[test]
fn torrent_unselected_file_removal_requires_a_non_empty_file_selection() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_remove_unselected_file: true,
torrent_file_indices: Some(vec![]),
..Default::default()
};
let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err();
assert!(error.contains("file selection"));
assert!(!options.contains_key("bt-remove-unselected-file"));
}
#[test]
fn torrent_unselected_file_removal_is_emitted_only_for_selected_torrent_files() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_remove_unselected_file: true,
torrent_file_indices: Some(vec![1]),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(
options.get("bt-remove-unselected-file"),
Some(&serde_json::json!("true"))
);
}
#[test]
fn torrent_unselected_file_removal_is_not_applied_without_torrent_selection() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_remove_unselected_file: true,
..Default::default()
};
let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err();
assert!(error.contains("subset"));
}
#[test]
fn torrent_peer_diagnostics_are_redacted_and_bounded() {
let mut result = vec![serde_json::json!({
@@ -4710,6 +4830,24 @@ mod tests {
);
}
#[test]
fn enqueue_item_carries_torrent_unselected_file_removal_into_the_spawn_payload() {
let item: EnqueueItem = serde_json::from_value(serde_json::json!({
"id": "torrent-remove-unselected",
"queue_id": "main",
"url": "file:///tmp/payload.torrent",
"destination": "/tmp/downloads",
"filename": "payload",
"is_media": false,
"is_torrent": true,
"torrent_file_indices": [1],
"torrent_remove_unselected_file": true
}))
.expect("frontend enqueue payload should deserialize");
assert!(item.into_task().payload.torrent_remove_unselected_file);
}
#[test]
fn torrent_options_reject_invalid_seed_values() {
let mut options = serde_json::Map::new();