feat(torrents): harden Aria2 torrent downloads

This commit is contained in:
NimBold
2026-07-30 22:51:46 +03:30
parent 31fbc5495b
commit 292447d417
29 changed files with 1656 additions and 112 deletions
+24 -4
View File
@@ -7,7 +7,7 @@ pub async fn reveal_in_file_manager(
app_handle: tauri::AppHandle,
path: String,
) -> Result<(), String> {
let primary = authorize_download_path(&app_handle, &path)?;
let primary = authorize_reveal_path(&app_handle, &path)?;
let path = existing_download_asset(&primary).ok_or_else(|| {
format!(
"Downloaded file or partial file is missing: {}",
@@ -56,18 +56,37 @@ fn authorize_download_path(
authorize_exact_path(Path::new(requested), &known_download_paths(app_handle)?)
}
fn authorize_reveal_path(
app_handle: &tauri::AppHandle,
requested: &str,
) -> Result<PathBuf, String> {
authorize_exact_path_with_directory(
Path::new(requested),
&known_download_paths(app_handle)?,
true,
)
}
fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
crate::download_ownership::known_primary_paths(app_handle)
}
fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<PathBuf, String> {
authorize_exact_path_with_directory(requested, allowed_paths, false)
}
fn authorize_exact_path_with_directory(
requested: &Path,
allowed_paths: &[PathBuf],
allow_directory: bool,
) -> Result<PathBuf, String> {
if crate::path_has_symlink_component(requested) {
return Err("Download path may not contain symlink components".to_string());
}
let requested = canonicalize_with_missing_leaf(requested)?;
if let Ok(metadata) = std::fs::metadata(&requested) {
if !metadata.is_file() {
if !metadata.is_file() && !(allow_directory && metadata.is_dir()) {
return Err("Download path is not a file".to_string());
}
}
@@ -140,8 +159,9 @@ fn existing_download_asset(primary: &Path) -> Option<PathBuf> {
]
.into_iter()
.find(|candidate| {
std::fs::symlink_metadata(candidate)
.is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
std::fs::symlink_metadata(candidate).is_ok_and(|metadata| {
(metadata.is_file() || metadata.is_dir()) && !metadata.file_type().is_symlink()
})
})
}
+191 -32
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 = 1;
const CURRENT_SCHEMA_VERSION: i64 = 2;
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
@@ -127,6 +127,10 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(),
id TEXT PRIMARY KEY,
primary_path TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS download_owned_paths (
id TEXT PRIMARY KEY,
paths TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS migration_events (
key TEXT PRIMARY KEY,
consumed INTEGER NOT NULL DEFAULT 0
@@ -176,6 +180,19 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(),
}
}
if from_version < 2 {
transaction
.execute_batch(
"
CREATE TABLE IF NOT EXISTS download_owned_paths (
id TEXT PRIMARY KEY,
paths TEXT NOT NULL
);
",
)
.map_err(|error| format!("failed to migrate download ownership paths: {error}"))?;
}
transaction
.pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION)
.map_err(|error| format!("failed to update database schema version: {error}"))?;
@@ -937,20 +954,44 @@ fn remove_persisted_transfer_secrets(value: &mut Value) {
if let Some(url) = object.get("url").and_then(Value::as_str) {
if let Ok(mut parsed) = url::Url::parse(url) {
let had_userinfo = !parsed.username().is_empty() || parsed.password().is_some();
let had_query_or_fragment = parsed.query().is_some() || parsed.fragment().is_some();
if had_userinfo || had_query_or_fragment {
if parsed.scheme() == "magnet" {
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
let mut retained_info_hash = false;
let mut removed_query_context = false;
for (key, value) in parsed.query_pairs() {
if key == "xt" || key == "dn" {
retained_info_hash |= key == "xt";
serializer.append_pair(&key, &value);
} else {
removed_query_context = true;
}
}
let removed_fragment = parsed.fragment().is_some();
let safe_query = serializer.finish();
let _ = parsed.set_username("");
let _ = parsed.set_password(None);
parsed.set_query(None);
parsed.set_query((!safe_query.is_empty()).then_some(safe_query.as_str()));
parsed.set_fragment(None);
object.insert("url".to_string(), Value::String(parsed.to_string()));
// A queued transfer whose URL depended on query/fragment
// credentials must not silently auto-resume with a truncated
// URL after a portable restart.
if had_userinfo || had_query_or_fragment {
if had_userinfo || removed_query_context || removed_fragment || !retained_info_hash {
mark_portable_download_unresumable(object);
}
} else {
let had_query_or_fragment = parsed.query().is_some() || parsed.fragment().is_some();
if had_userinfo || had_query_or_fragment {
let _ = parsed.set_username("");
let _ = parsed.set_password(None);
parsed.set_query(None);
parsed.set_fragment(None);
object.insert("url".to_string(), Value::String(parsed.to_string()));
// A queued transfer whose URL depended on query/fragment
// credentials must not silently auto-resume with a truncated
// URL after a portable restart.
if had_userinfo || had_query_or_fragment {
mark_portable_download_unresumable(object);
}
}
}
} else {
object.insert("url".to_string(), Value::String(String::new()));
@@ -1117,43 +1158,91 @@ fn required_string<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> {
.ok_or_else(|| format!("persisted item is missing '{key}'"))
}
pub fn load_ownership(connection: &Connection) -> Result<Vec<(String, String)>, String> {
pub fn load_ownership(connection: &Connection) -> Result<Vec<(String, String, Vec<String>)>, String> {
let mut statement = connection
.prepare("SELECT id, primary_path FROM download_ownership")
.prepare(
"SELECT ownership.id, ownership.primary_path, paths.paths
FROM download_ownership AS ownership
LEFT JOIN download_owned_paths AS paths ON paths.id = ownership.id",
)
.map_err(|error| format!("failed to prepare ownership query: {error}"))?;
let rows = statement
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.query_map([], |row| {
let primary_path: String = row.get(1)?;
let owned_paths = row
.get::<_, Option<String>>(2)?
.and_then(|paths| serde_json::from_str::<Vec<String>>(&paths).ok())
.filter(|paths| !paths.is_empty())
.unwrap_or_else(|| vec![primary_path.clone()]);
Ok((row.get(0)?, primary_path, owned_paths))
})
.map_err(|error| format!("failed to query ownership data: {error}"))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("failed to read ownership data: {error}"))
}
pub fn set_ownership(connection: &Connection, id: &str, path: &str) -> Result<(), String> {
let existing_owner = connection
.query_row(
"SELECT id FROM download_ownership
WHERE primary_path = ?1 AND id <> ?2
LIMIT 1",
params![path, id],
|row| row.get::<_, String>(0),
pub fn set_ownership_paths(
connection: &Connection,
id: &str,
primary_path: &str,
paths: &[String],
) -> Result<(), String> {
let mut statement = connection
.prepare(
"SELECT ownership.id, ownership.primary_path, paths.paths
FROM download_ownership AS ownership
LEFT JOIN download_owned_paths AS paths ON paths.id = ownership.id
WHERE ownership.id <> ?1",
)
.optional()
.map_err(|error| format!("failed to check download ownership path: {error}"))?;
if existing_owner.is_some() {
return Err("Download destination is already owned by another Firelink download".to_string());
.map_err(|error| format!("failed to prepare download ownership check: {error}"))?;
let existing = statement
.query_map(params![id], |row| {
let primary: String = row.get(1)?;
let owned = row
.get::<_, Option<String>>(2)?
.and_then(|value| serde_json::from_str::<Vec<String>>(&value).ok())
.unwrap_or_else(|| vec![primary.clone()]);
Ok((primary, owned))
})
.map_err(|error| format!("failed to check download ownership paths: {error}"))?;
for row in existing {
let (existing_primary, owned) =
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 existing_paths = std::iter::once(existing_primary.as_str())
.chain(owned.iter().map(String::as_str));
if new_paths.clone().any(|new_path| {
existing_paths
.clone()
.any(|existing_path| crate::platform::paths_equal(Path::new(new_path), Path::new(existing_path)))
}) {
return Err("Download destination is already owned by another Firelink download".to_string());
}
}
connection
.execute(
"INSERT INTO download_ownership (id, primary_path) VALUES (?1, ?2)
ON CONFLICT(id) DO UPDATE SET primary_path = excluded.primary_path",
params![id, path],
params![id, primary_path],
)
.map_err(|error| format!("failed to save ownership data: {error}"))?;
let encoded_paths = serde_json::to_string(paths)
.map_err(|error| format!("failed to encode download ownership paths: {error}"))?;
connection
.execute(
"INSERT INTO download_owned_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 download ownership path list: {error}"))?;
Ok(())
}
pub fn remove_ownership(connection: &Connection, id: &str) -> Result<(), String> {
connection
.execute("DELETE FROM download_owned_paths WHERE id = ?1", params![id])
.map_err(|error| format!("failed to delete download ownership paths: {error}"))?;
connection
.execute("DELETE FROM download_ownership WHERE id = ?1", params![id])
.map_err(|error| format!("failed to delete ownership data: {error}"))?;
@@ -1937,6 +2026,29 @@ mod tests {
assert!(saved.get("headers").is_none());
}
#[test]
fn portable_magnet_persistence_keeps_identity_but_does_not_resume_after_tracker_removal() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let mut connection = state.lock().unwrap();
let data = json!([{
"id": "magnet-download",
"status": "queued",
"queueId": "main",
"url": "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example%20Torrent&tr=https%3A%2F%2Ftracker.invalid%2Fsecret"
}])
.to_string();
replace_downloads(&mut connection, &data, true).unwrap();
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert_eq!(saved["url"], "magnet:?xt=urn%3Abtih%3A0123456789abcdef0123456789abcdef01234567&dn=Example+Torrent");
assert_eq!(saved["status"], "failed");
assert_eq!(saved["resumable"], false);
assert!(!saved.to_string().contains("tracker.invalid"));
assert!(!saved.to_string().contains("secret"));
}
#[test]
fn portable_download_persistence_redacts_error_secrets_but_preserves_safe_errors_and_standard_details() {
let temp = TempDir::new().unwrap();
@@ -2142,15 +2254,62 @@ mod tests {
let state = init_at_path(temp.path()).unwrap();
let connection = state.lock().unwrap();
set_ownership(&connection, "first", "/downloads/file.bin").unwrap();
let error = set_ownership(&connection, "second", "/downloads/file.bin")
set_ownership_paths(
&connection,
"first",
"/downloads/file.bin",
&["/downloads/file.bin".to_string()],
)
.unwrap();
let error = set_ownership_paths(
&connection,
"second",
"/downloads/file.bin",
&["/downloads/file.bin".to_string()],
)
.expect_err("a primary path must have one live owner");
assert!(error.contains("already owned"));
assert_eq!(load_ownership(&connection).unwrap(), vec![(
"first".to_string(),
"/downloads/file.bin".to_string()
)]);
set_ownership(&connection, "first", "/downloads/renamed.bin").unwrap();
assert_eq!(
load_ownership(&connection).unwrap(),
vec![(
"first".to_string(),
"/downloads/file.bin".to_string(),
vec!["/downloads/file.bin".to_string()]
)]
);
set_ownership_paths(
&connection,
"first",
"/downloads/renamed.bin",
&["/downloads/renamed.bin".to_string()],
)
.unwrap();
}
#[test]
fn rejects_overlap_with_any_owned_torrent_path() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let connection = state.lock().unwrap();
set_ownership_paths(
&connection,
"first",
"/downloads/root",
&[
"/downloads/root/a.bin".to_string(),
"/downloads/root/b.bin".to_string(),
],
)
.unwrap();
let error = set_ownership_paths(
&connection,
"second",
"/downloads/root",
&["/downloads/root/c.bin".to_string()],
)
.expect_err("a torrent root must not be reused");
assert!(error.contains("already owned"));
}
}
+83 -6
View File
@@ -7,6 +7,7 @@ use tauri::Manager;
struct DownloadOwnershipRecord {
id: String,
primary_path: String,
owned_paths: Vec<String>,
}
pub fn canonical_download_filename(filename: &str) -> String {
@@ -124,6 +125,63 @@ pub fn set_primary_path(
id: &str,
path: &Path,
) -> Result<(), String> {
set_owned_paths(app_handle, id, &[path.to_path_buf()])
}
pub fn set_owned_paths<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
paths: &[PathBuf],
) -> Result<(), String> {
let primary = paths
.first()
.ok_or_else(|| "Download ownership requires at least one path".to_string())?;
set_owned_paths_with_primary(app_handle, id, primary, paths)
}
pub fn set_owned_paths_with_primary<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
primary: &Path,
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 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);
}
}
let path_strings = canonical_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_paths(
&connection,
id,
&canonical_primary.to_string_lossy(),
&path_strings,
)
}
fn canonical_owned_path<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
path: &Path,
) -> Result<PathBuf, String> {
if !path.is_absolute() {
return Err("Download ownership path must be absolute".to_string());
}
@@ -140,10 +198,10 @@ pub fn set_primary_path(
}
let canonical_path = crate::canonicalize_with_missing_components(path)
.ok_or_else(|| "Download ownership path could not be canonicalized".to_string())?;
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
crate::db::set_ownership(&connection, id, &canonical_path.to_string_lossy())
if !crate::is_safe_path(&canonical_path, app_handle) {
return Err("Download ownership path is outside an allowed download location".to_string());
}
Ok(canonical_path)
}
pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> {
@@ -162,10 +220,25 @@ pub fn primary_path_for_id<R: tauri::Runtime>(
.map(|record| PathBuf::from(record.primary_path)))
}
pub fn owned_paths_for_id<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
) -> Result<Vec<PathBuf>, String> {
Ok(load_records(app_handle)?
.into_iter()
.find(|record| record.id == id)
.map(|record| record.owned_paths.into_iter().map(PathBuf::from).collect())
.unwrap_or_default())
}
pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
let mut paths: Vec<PathBuf> = load_records(app_handle)?
.into_iter()
.map(|record| PathBuf::from(record.primary_path))
.flat_map(|record| {
std::iter::once(PathBuf::from(record.primary_path)).chain(
record.owned_paths.into_iter().map(PathBuf::from),
)
})
.collect();
// One-time compatibility for downloads created before the backend-owned
@@ -185,7 +258,11 @@ fn load_records<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) -> Result<V
crate::db::load_ownership(&connection).map(|records| {
records
.into_iter()
.map(|(id, primary_path)| DownloadOwnershipRecord { id, primary_path })
.map(|(id, primary_path, owned_paths)| DownloadOwnershipRecord {
id,
primary_path,
owned_paths,
})
.collect()
})
}
+36
View File
@@ -144,6 +144,42 @@ pub struct DownloadItem {
pub last_error: Option<String>,
#[ts(optional)]
pub last_try: Option<String>,
#[serde(default)]
#[ts(optional)]
pub is_torrent: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub torrent_path: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_file_indices: Option<Vec<u32>>,
#[serde(default)]
#[ts(optional)]
pub torrent_info_hash: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentFile {
pub index: u32,
pub path: String,
#[ts(type = "number")]
pub length: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentMetadata {
pub name: String,
#[ts(type = "number")]
pub total_bytes: u64,
pub files: Vec<TorrentFile>,
pub info_hash: String,
#[serde(default)]
#[ts(optional)]
pub torrent_path: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
+246 -12
View File
@@ -2918,6 +2918,7 @@ mod platform;
pub mod queue;
pub mod process;
pub mod retry;
pub mod torrent;
mod settings;
mod storage;
pub use error::AppError;
@@ -3156,7 +3157,7 @@ fn parse_firelink_deep_link(deep_link: &url::Url) -> FirelinkDeepLink {
let Ok(url) = url::Url::parse(raw_url) else {
continue;
};
if !matches!(url.scheme(), "http" | "https" | "ftp" | "sftp") {
if !matches!(url.scheme(), "http" | "https" | "ftp" | "sftp" | "magnet") {
continue;
}
let url = url.to_string();
@@ -4883,7 +4884,7 @@ async fn remove_download(
) -> Result<(), String> {
log::info!("remove_download called for id: {}", id);
let preserve_resumable = preserve_resumable.unwrap_or(false);
let primary_path = crate::download_ownership::primary_path_for_id(&app_handle, &id)?;
let mut owned_paths = crate::download_ownership::owned_paths_for_id(&app_handle, &id)?;
let _control_guard = state.queue_manager.acquire_aria2_control(&id).await;
let active_kind = state.queue_manager.active_kind(&id).await;
@@ -4896,6 +4897,19 @@ async fn remove_download(
let gid = state.queue_manager.aria2_gid_for_download(&id);
if let Some(gid) = gid.as_deref() {
if let Err(error) = state
.queue_manager
.reconcile_aria2_torrent_ownership(&id, gid)
.await
{
log::debug!(
"aria2 torrent ownership [{}]: could not resolve files before removal of gid {}: {}",
id,
gid,
error
);
}
owned_paths = crate::download_ownership::owned_paths_for_id(&app_handle, &id)?;
// Keep the current epoch and mapping alive until daemon removal is
// confirmed. If removal fails, terminal events from this lifecycle
// must still be accepted so the permit and mapping can be cleaned up.
@@ -4959,16 +4973,15 @@ async fn remove_download(
);
let preserve_assets = preserve_resumable
&& primary_path
.as_deref()
.is_some_and(has_resumable_download_assets);
&& owned_paths.iter().any(|path| has_resumable_download_assets(path));
let cleanup_result = async {
if delete_assets && !preserve_assets {
if let Some(path) = primary_path.as_deref() {
for path in &owned_paths {
remove_download_assets(path, &app_handle).await?;
}
}
crate::torrent::remove_managed_torrent(&app_handle, &id).await;
crate::download_ownership::remove(&app_handle, &id)?;
Ok::<(), String>(())
}
@@ -4978,6 +4991,15 @@ async fn remove_download(
cleanup_result
}
#[tauri::command]
fn get_download_primary_path(
app_handle: tauri::AppHandle,
id: String,
) -> Result<Option<String>, String> {
crate::download_ownership::primary_path_for_id(&app_handle, &id)
.map(|path| path.map(|path| path.to_string_lossy().to_string()))
}
fn has_resumable_download_assets(primary: &std::path::Path) -> bool {
[".aria2", ".part", ".ytdl"].iter().any(|suffix| {
let mut candidate = primary.as_os_str().to_os_string();
@@ -5510,15 +5532,122 @@ async fn validate_enqueue_uris(url: &str, mirrors: Option<&str>) -> Result<(), S
Ok(())
}
async fn validate_torrent_enqueue(
app_handle: &tauri::AppHandle,
item: &queue::EnqueueItem,
) -> Result<(), String> {
if item.is_media.unwrap_or(false) {
return Err("torrent transfer cannot be a media download".to_string());
}
validate_enqueue_uris("", item.mirrors.as_deref()).await?;
if let Some(path) = item.torrent_path.as_deref() {
let path = crate::torrent::validate_managed_torrent_path(app_handle, &item.id, path)?;
let bytes = std::fs::read(path)
.map_err(|error| format!("could not read cached torrent metadata: {error}"))?;
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
crate::torrent::validate_selected_indices(
item.torrent_file_indices.as_deref(),
metadata.files.len(),
)?;
return Ok(());
}
let parsed = url::Url::parse(&item.url).map_err(|_| "invalid magnet URI".to_string())?;
if parsed.scheme() != "magnet" {
return Err("torrent transfer has no magnet URI or cached metadata".to_string());
}
if item.torrent_file_indices.is_some() {
return Err("magnet file selection requires resolved torrent metadata".to_string());
}
crate::torrent::inspect_source(&item.url).map(|_| ())
}
fn expected_torrent_output_paths(
app_handle: &tauri::AppHandle,
item: &queue::EnqueueItem,
) -> Result<Option<Vec<std::path::PathBuf>>, String> {
if !item.is_torrent.unwrap_or(false) {
return Ok(None);
}
let Some(torrent_path) = item.torrent_path.as_deref() else {
return Ok(None);
};
let torrent_path = crate::torrent::validate_managed_torrent_path(app_handle, &item.id, torrent_path)?;
let bytes = std::fs::read(torrent_path)
.map_err(|error| format!("could not read cached torrent metadata: {error}"))?;
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
let selected = crate::torrent::validate_selected_indices(
item.torrent_file_indices.as_deref(),
metadata.files.len(),
)?;
let destination = crate::resolve_path(&item.destination, app_handle);
if !crate::is_safe_path(&destination, app_handle) {
return Err("Path traversal blocked".to_string());
}
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 relative = std::path::PathBuf::from(relative);
if relative.is_absolute()
|| relative.components().any(|component| {
matches!(
component,
std::path::Component::ParentDir | std::path::Component::CurDir
)
})
{
return Err("torrent output path is unsafe".to_string());
}
let path = destination.join(relative);
let canonical_path = crate::canonicalize_with_missing_components(&path)
.ok_or_else(|| "torrent output path could not be canonicalized".to_string())?;
if !crate::platform::path_is_within(&canonical_path, &canonical_destination) {
return Err("torrent output path is outside its destination".to_string());
}
paths.push(canonical_path);
}
Ok(Some(paths))
}
#[tauri::command]
async fn inspect_torrent(
app_handle: tauri::AppHandle,
source: String,
id: String,
cache: Option<bool>,
) -> Result<crate::ipc::TorrentMetadata, AppError> {
if source.trim_start().to_ascii_lowercase().starts_with("magnet:") {
return crate::torrent::inspect_source(&source)
.map(|parsed| crate::torrent::to_metadata(parsed, None))
.map_err(AppError::Internal);
}
if cache == Some(false) {
return crate::torrent::inspect_source(&source)
.map(|parsed| crate::torrent::to_metadata(parsed, None))
.map_err(AppError::Internal);
}
let (parsed, path) = crate::torrent::prepare_local_torrent(&app_handle, &source, &id)
.await
.map_err(AppError::Internal)?;
Ok(crate::torrent::to_metadata(parsed, Some(path)))
}
#[tauri::command]
async fn enqueue_download(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
mut item: queue::EnqueueItem,
) -> Result<crate::ipc::EnqueueAccepted, AppError> {
validate_enqueue_uris(&item.url, item.mirrors.as_deref())
.await
.map_err(AppError::Internal)?;
if item.is_torrent.unwrap_or(false) {
validate_torrent_enqueue(&app_handle, &item)
.await
.map_err(AppError::Internal)?;
} else {
validate_enqueue_uris(&item.url, item.mirrors.as_deref())
.await
.map_err(AppError::Internal)?;
}
let id = item.id.clone();
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
let accepted_filename = item.filename.clone();
@@ -5540,6 +5669,47 @@ async fn enqueue_download(
.await;
return Err(AppError::Internal(error));
}
match expected_torrent_output_paths(&app_handle, &item) {
Ok(Some(paths)) => {
let primary = match crate::download_ownership::expected_primary_path(
&app_handle,
&item.destination,
&item.filename,
) {
Ok(primary) => primary,
Err(error) => {
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) = crate::download_ownership::set_owned_paths_with_primary(
&app_handle,
&item.id,
&primary,
&paths,
) {
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));
}
}
Ok(None) => {}
Err(error) => {
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)
@@ -5583,7 +5753,12 @@ async fn enqueue_many(
let mut results = Vec::with_capacity(items.len());
for mut item in items {
let id = item.id.clone();
if let Err(error) = validate_enqueue_uris(&item.url, item.mirrors.as_deref()).await {
let validation = if item.is_torrent.unwrap_or(false) {
validate_torrent_enqueue(&app_handle, &item).await
} else {
validate_enqueue_uris(&item.url, item.mirrors.as_deref()).await
};
if let Err(error) = validation {
results.push(crate::ipc::EnqueueResult {
id,
success: false,
@@ -5640,6 +5815,65 @@ async fn enqueue_many(
});
continue;
}
match expected_torrent_output_paths(&app_handle, &item) {
Ok(Some(paths)) => {
let primary = match crate::download_ownership::expected_primary_path(
&app_handle,
&item.destination,
&item.filename,
) {
Ok(primary) => primary,
Err(error) => {
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) = crate::download_ownership::set_owned_paths_with_primary(
&app_handle,
&item.id,
&primary,
&paths,
) {
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;
}
}
Ok(None) => {}
Err(error) => {
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)
@@ -9979,7 +10213,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status,
get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno,
pause_download, resume_download, fetch_metadata, fetch_media_metadata, fetch_media_playlist_metadata,
pause_download, resume_download, fetch_metadata, inspect_torrent, fetch_media_metadata, fetch_media_playlist_metadata,
begin_dock_badge_session, update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, set_power_preferences, get_free_space, perform_system_action,
ack_schedule_trigger,
check_automation_permission, request_automation_permission, open_automation_settings,
@@ -9990,7 +10224,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_global_speed_limit, remove_download,
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_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,
+2 -2
View File
@@ -123,12 +123,12 @@ pub fn path_is_within(path: &Path, root: &Path) -> bool {
}
pub fn paths_equal(left: &Path, right: &Path) -> bool {
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "macos"))]
{
left.to_string_lossy()
.eq_ignore_ascii_case(&right.to_string_lossy())
}
#[cfg(not(target_os = "windows"))]
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
left == right
}
+182 -14
View File
@@ -1,3 +1,4 @@
use base64::Engine as _;
use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection};
use crate::power::PowerManager;
use crate::retry::{backoff_and_emit, is_transient_network_error, BackoffOutcome, MAX_RETRIES};
@@ -201,6 +202,9 @@ pub struct SpawnPayload {
pub format_selector: Option<String>,
pub cookie_source: Option<String>,
pub is_media: bool,
pub is_torrent: bool,
pub torrent_path: Option<String>,
pub torrent_file_indices: Option<Vec<u32>>,
}
/// A sidecar spawner. In production this calls the real aria2/yt-dlp
@@ -1594,6 +1598,16 @@ 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 let Some(gid) = self.aria2_gid_for_download(id) {
if let Err(error) = self.reconcile_aria2_torrent_ownership(id, &gid).await {
log::debug!(
"aria2 torrent ownership [{}]: could not resolve files for gid {}: {}",
id,
gid,
error
);
}
}
// A terminal event invalidates every delayed retry or control worker
// from the previous lifecycle before releasing its permit.
self.next_aria2_control_epoch(id).await;
@@ -1609,11 +1623,11 @@ impl<R: tauri::Runtime> QueueManager<R> {
PendingOutcome::Error(error) => {
if error.to_ascii_lowercase().contains("checksum") {
log::warn!("Checksum error detected for {}, cleaning up assets", id);
if let Ok(primary_path) =
crate::download_ownership::primary_path_for_id(&self.app_handle, id)
if let Ok(paths) =
crate::download_ownership::owned_paths_for_id(&self.app_handle, id)
{
if let Some(path) = primary_path.as_deref() {
let _ = crate::remove_download_assets(path, &self.app_handle).await;
for path in paths {
let _ = crate::remove_download_assets(&path, &self.app_handle).await;
}
}
}
@@ -1741,6 +1755,92 @@ impl<R: tauri::Runtime> QueueManager<R> {
.find_map(|(gid, mapping)| (mapping.id == id).then(|| gid.clone()))
}
/// Refresh ownership from Aria2's resolved file list before a torrent
/// lifecycle is forgotten. Magnet metadata is not available when the row
/// is enqueued, so the user-provided display name is not a safe ownership
/// path until Aria2 reports the actual files.
pub async fn reconcile_aria2_torrent_ownership(
&self,
id: &str,
gid: &str,
) -> Result<(), String> {
let payload = self.aria2_payloads.lock().await.get(id).cloned();
let Some(payload) = payload.filter(|payload| payload.is_torrent) else {
return Ok(());
};
let mapping = self
.aria2_gid_mapping(gid)
.filter(|mapping| mapping.id == id)
.ok_or_else(|| "aria2 torrent ownership has no current gid mapping".to_string())?;
let state = self.app_handle.state::<crate::AppState>();
let result = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.getFiles",
serde_json::json!([gid]),
)
.await?;
if !self.is_current_aria2_gid_mapping(gid, &mapping)
|| !self
.is_aria2_control_epoch_current(id, mapping.epoch)
.await
{
return Err("aria2 torrent lifecycle changed while resolving output paths".to_string());
}
let paths = result
.as_array()
.into_iter()
.flatten()
.filter(|file| file.get("selected").and_then(|value| value.as_str()) != Some("false"))
.filter_map(|file| file.get("path").and_then(|value| value.as_str()))
.map(std::path::PathBuf::from)
.collect::<Vec<_>>();
if paths.is_empty() {
return Err("aria2 returned no selected torrent output paths".to_string());
}
let destination = crate::resolve_path(&payload.destination, &self.app_handle);
let canonical_destination = crate::canonicalize_with_missing_components(&destination)
.ok_or_else(|| "torrent destination could not be canonicalized".to_string())?;
if paths.iter().any(|path| {
crate::canonicalize_with_missing_components(path)
.is_none_or(|path| !crate::platform::path_is_within(&path, &canonical_destination))
}) {
return Err("aria2 returned a torrent output path outside its destination".to_string());
}
let primary = if paths.len() == 1 {
paths[0].clone()
} else {
let canonical_paths = paths
.iter()
.filter_map(|path| crate::canonicalize_with_missing_components(path))
.collect::<Vec<_>>();
let mut common = canonical_paths
.first()
.and_then(|path| path.parent())
.map(std::path::Path::to_path_buf)
.ok_or_else(|| "torrent output path has no parent".to_string())?;
for path in canonical_paths.iter().skip(1) {
while !crate::platform::path_is_within(path, &common) {
let Some(parent) = common.parent() else {
return Err("torrent output paths have no common directory".to_string());
};
if parent == common {
return Err("torrent output paths have no common directory".to_string());
}
common = parent.to_path_buf();
}
}
common
};
crate::download_ownership::set_owned_paths_with_primary(
&self.app_handle,
id,
&primary,
&paths,
)
}
/// Capture the GID ownership token used by the poller. The mapping's
/// epoch must still be current when an asynchronous status snapshot is
/// emitted; otherwise a late response from an older GID can be attributed
@@ -2666,9 +2766,10 @@ impl ProductionSpawner {
Self { app_handle }
}
async fn add_uri_rpc(
async fn add_transfer_rpc(
&self,
state: &crate::AppState,
method: &str,
params: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
@@ -2676,7 +2777,7 @@ impl ProductionSpawner {
match crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.addUri",
method,
params.clone(),
)
.await
@@ -2714,7 +2815,9 @@ impl SidecarSpawner for ProductionSpawner {
);
let safe_filename =
crate::download_ownership::canonical_download_filename(&payload.filename);
options.insert("out".to_string(), serde_json::json!(safe_filename));
if !payload.is_torrent {
options.insert("out".to_string(), serde_json::json!(safe_filename));
}
let conn = effective_aria2_connections(id, payload).await;
apply_aria2_connection_options(&mut options, conn);
let mt = aria2_attempt_limit(payload.max_tries);
@@ -2766,23 +2869,73 @@ impl SidecarSpawner for ProductionSpawner {
if let Some(prox) = proxy_value {
options.insert("all-proxy".to_string(), serde_json::json!(prox));
}
let uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref());
let params = serde_json::json!([uris, options]);
match self.add_uri_rpc(&state, &params).await {
let (method, params) = if payload.is_torrent {
if let Some(path) = payload.torrent_path.as_deref() {
let path = crate::torrent::validate_managed_torrent_path(
&self.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)?;
options.insert(
"index-out".to_string(),
serde_json::json!(crate::torrent::aria2_index_outputs(&metadata)),
);
let selected = crate::torrent::validate_selected_indices(
payload.torrent_file_indices.as_deref(),
metadata.files.len(),
)?;
if let Some(indices) = selected {
options.insert(
"select-file".to_string(),
serde_json::json!(indices.iter().map(u32::to_string).collect::<Vec<_>>().join(",")),
);
}
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
let uris = payload
.mirrors
.as_deref()
.map(|mirrors| crate::collect_download_uris("", Some(mirrors)))
.unwrap_or_default();
("aria2.addTorrent", serde_json::json!([encoded, uris, options]))
} else {
let parsed = url::Url::parse(&payload.url)
.map_err(|_| "invalid magnet URI".to_string())?;
if parsed.scheme() != "magnet" {
return Err("torrent transfer has no magnet URI or cached metadata".to_string());
}
let selected = crate::torrent::validate_selected_indices(
payload.torrent_file_indices.as_deref(),
usize::MAX,
)?;
if selected.is_some() {
return Err("magnet file selection requires resolved torrent metadata".to_string());
}
("aria2.addUri", serde_json::json!([[payload.url.clone()], options]))
}
} else {
let uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref());
("aria2.addUri", serde_json::json!([uris, options]))
};
match self.add_transfer_rpc(&state, method, &params).await {
Ok(result) => {
let gid = result.as_str().unwrap_or("").to_string();
if gid.is_empty() {
Err("aria2.addUri returned an empty gid".to_string())
Err(format!("{method} returned an empty gid"))
} else {
log::info!("aria2 addUri [{}]: created gid {}", id, gid);
log::info!("aria2 {} [{}]: created gid {}", method, id, gid);
Ok(gid)
}
}
Err(e) => {
let safe_error = crate::redact_sensitive_text(&e);
log::error!("aria2 addUri [{}] failed: {}", id, safe_error);
Err(format!("aria2 addUri failed: {safe_error}"))
log::error!("aria2 {} [{}] failed: {}", method, id, safe_error);
Err(format!("aria2 {method} failed: {safe_error}"))
}
}
}
@@ -3108,6 +3261,18 @@ pub struct EnqueueItem {
pub is_media: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub is_torrent: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub torrent_path: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_file_indices: Option<Vec<u32>>,
#[serde(default)]
#[ts(optional)]
pub torrent_info_hash: Option<String>,
#[serde(default)]
#[ts(optional)]
pub lifecycle_generation: Option<String>,
}
@@ -3147,6 +3312,9 @@ impl EnqueueItem {
format_selector: self.format_selector,
cookie_source: self.cookie_source,
is_media: media,
is_torrent: self.is_torrent.unwrap_or(false),
torrent_path: self.torrent_path,
torrent_file_indices: self.torrent_file_indices,
},
}
}
+581
View File
@@ -0,0 +1,581 @@
use sha1::{Digest, Sha1};
use std::collections::{BTreeMap, HashSet};
use std::path::PathBuf;
use tauri::Manager;
use crate::ipc::{TorrentFile, TorrentMetadata};
pub const MAX_TORRENT_BYTES: usize = 16 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct ParsedTorrent {
pub name: String,
pub total_bytes: u64,
pub files: Vec<TorrentFile>,
pub info_hash: String,
}
#[derive(Debug, Clone)]
enum BencodeValue {
Integer(i64),
Bytes(Vec<u8>),
List(Vec<BencodeValue>),
Dict(BTreeMap<Vec<u8>, BencodeValue>),
}
struct Parser<'a> {
input: &'a [u8],
position: usize,
depth: usize,
}
impl<'a> Parser<'a> {
fn new(input: &'a [u8]) -> Self {
Self { input, position: 0, depth: 0 }
}
fn parse(mut self) -> Result<BencodeValue, String> {
let value = self.parse_value()?;
if self.position != self.input.len() {
return Err("torrent metadata has trailing data".to_string());
}
Ok(value)
}
fn parse_value(&mut self) -> Result<BencodeValue, String> {
if self.depth >= 128 {
return Err("torrent metadata nesting is too deep".to_string());
}
let byte = *self
.input
.get(self.position)
.ok_or_else(|| "torrent metadata is truncated".to_string())?;
match byte {
b'i' => self.parse_integer(),
b'l' => self.parse_list(),
b'd' => self.parse_dict(),
b'0'..=b'9' => self.parse_bytes(),
_ => Err("torrent metadata contains an invalid bencode value".to_string()),
}
}
fn parse_integer(&mut self) -> Result<BencodeValue, String> {
self.position += 1;
let start = self.position;
while let Some(byte) = self.input.get(self.position) {
if *byte == b'e' {
break;
}
self.position += 1;
}
if self.input.get(self.position) != Some(&b'e') {
return Err("torrent integer is truncated".to_string());
}
let raw = std::str::from_utf8(&self.input[start..self.position])
.map_err(|_| "torrent integer is not valid UTF-8".to_string())?;
if raw.is_empty() || (raw.starts_with('0') && raw.len() > 1) || raw == "-0" {
return Err("torrent integer has invalid encoding".to_string());
}
let value = raw
.parse::<i64>()
.map_err(|_| "torrent integer is outside the supported range".to_string())?;
self.position += 1;
Ok(BencodeValue::Integer(value))
}
fn parse_bytes(&mut self) -> Result<BencodeValue, String> {
let start = self.position;
while let Some(byte) = self.input.get(self.position) {
if *byte == b':' {
break;
}
if !byte.is_ascii_digit() {
return Err("torrent byte string has an invalid length".to_string());
}
self.position += 1;
}
if self.input.get(self.position) != Some(&b':') {
return Err("torrent byte string is truncated".to_string());
}
let length = std::str::from_utf8(&self.input[start..self.position])
.map_err(|_| "torrent byte string length is invalid".to_string())?
.to_owned();
if (length.starts_with('0') && length.len() > 1) || length.is_empty() {
return Err("torrent byte string has invalid length encoding".to_string());
}
let length = length
.parse::<usize>()
.map_err(|_| "torrent byte string length is too large".to_string())?;
self.position += 1;
let end = self
.position
.checked_add(length)
.ok_or_else(|| "torrent byte string length overflowed".to_string())?;
if end > self.input.len() {
return Err("torrent byte string is truncated".to_string());
}
let value = self.input[self.position..end].to_vec();
self.position = end;
Ok(BencodeValue::Bytes(value))
}
fn parse_list(&mut self) -> Result<BencodeValue, String> {
self.position += 1;
self.depth += 1;
let mut values = Vec::new();
while self.input.get(self.position) != Some(&b'e') {
if self.input.get(self.position).is_none() {
self.depth -= 1;
return Err("torrent list is truncated".to_string());
}
values.push(self.parse_value()?);
}
self.position += 1;
self.depth -= 1;
Ok(BencodeValue::List(values))
}
fn parse_dict(&mut self) -> Result<BencodeValue, String> {
self.position += 1;
self.depth += 1;
let mut values = BTreeMap::new();
let mut previous_key: Option<Vec<u8>> = None;
while self.input.get(self.position) != Some(&b'e') {
if self.input.get(self.position).is_none() {
self.depth -= 1;
return Err("torrent dictionary is truncated".to_string());
}
let key = match self.parse_bytes()? {
BencodeValue::Bytes(key) => key,
_ => unreachable!(),
};
if previous_key.as_ref().is_some_and(|previous| previous >= &key) {
self.depth -= 1;
return Err("torrent dictionary keys are not sorted".to_string());
}
previous_key = Some(key.clone());
values.insert(key, self.parse_value()?);
}
self.position += 1;
self.depth -= 1;
Ok(BencodeValue::Dict(values))
}
}
fn encode(value: &BencodeValue, output: &mut Vec<u8>) {
match value {
BencodeValue::Integer(value) => output.extend_from_slice(format!("i{value}e").as_bytes()),
BencodeValue::Bytes(value) => {
output.extend_from_slice(value.len().to_string().as_bytes());
output.push(b':');
output.extend_from_slice(value);
}
BencodeValue::List(values) => {
output.push(b'l');
for value in values {
encode(value, output);
}
output.push(b'e');
}
BencodeValue::Dict(values) => {
output.push(b'd');
for (key, value) in values {
encode(&BencodeValue::Bytes(key.clone()), output);
encode(value, output);
}
output.push(b'e');
}
}
}
fn bytes_text(value: Option<&BencodeValue>, field: &str) -> Result<String, String> {
let bytes = match value {
Some(BencodeValue::Bytes(bytes)) => bytes,
_ => return Err(format!("torrent metadata is missing {field}")),
};
String::from_utf8(bytes.clone()).map_err(|_| format!("torrent {field} is not valid UTF-8"))
}
fn positive_length(value: Option<&BencodeValue>, field: &str) -> Result<u64, String> {
match value {
Some(BencodeValue::Integer(value)) if *value >= 0 => Ok(*value as u64),
_ => Err(format!("torrent {field} is invalid")),
}
}
fn safe_path_component(value: &str, field: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty() || value == "." || value == ".." || value.contains(['/', '\\', '\0']) {
return Err(format!("torrent {field} contains an unsafe path"));
}
Ok(crate::download_ownership::canonical_download_filename(value))
}
fn parse_info(info: &BencodeValue) -> Result<ParsedTorrent, String> {
let info_dict = match info {
BencodeValue::Dict(value) => value,
_ => return Err("torrent info dictionary is invalid".to_string()),
};
let name = info_dict
.get(b"name.utf-8".as_slice())
.or_else(|| info_dict.get(b"name".as_slice()))
.map(|value| bytes_text(Some(value), "name"))
.transpose()?
.ok_or_else(|| "torrent metadata is missing name".to_string())?;
let name = crate::download_ownership::canonical_download_filename(&safe_path_component(&name, "name")?);
let mut files = Vec::new();
let total_bytes = if let Some(files_value) = info_dict.get(b"files".as_slice()) {
let entries = match files_value {
BencodeValue::List(entries) => entries,
_ => return Err("torrent files field is invalid".to_string()),
};
let mut total = 0u64;
let mut paths = HashSet::new();
for (position, entry) in entries.iter().enumerate() {
let entry = match entry {
BencodeValue::Dict(value) => value,
_ => return Err(format!("torrent file entry {} is invalid", position + 1)),
};
let path_value = entry
.get(b"path.utf-8".as_slice())
.or_else(|| entry.get(b"path".as_slice()))
.ok_or_else(|| format!("torrent file entry {} has no path", position + 1))?;
let path_parts = match path_value {
BencodeValue::List(parts) => parts
.iter()
.map(|part| bytes_text(Some(part), "file path"))
.collect::<Result<Vec<_>, _>>()?,
_ => return Err(format!("torrent file entry {} path is invalid", position + 1)),
};
if path_parts.is_empty() {
return Err(format!("torrent file entry {} path is empty", position + 1));
}
let path = path_parts
.iter()
.map(|part| safe_path_component(part, "file path"))
.collect::<Result<Vec<_>, _>>()?
.join("/");
if !paths.insert(path.clone()) {
return Err("torrent contains duplicate output paths".to_string());
}
let length = positive_length(entry.get(b"length".as_slice()), "file length")?;
total = total
.checked_add(length)
.ok_or_else(|| "torrent total size is too large".to_string())?;
files.push(TorrentFile {
index: (position + 1) as u32,
path,
length,
});
}
if files.is_empty() {
return Err("torrent has no files".to_string());
}
total
} else {
let length = positive_length(info_dict.get(b"length".as_slice()), "length")?;
files.push(TorrentFile {
index: 1,
path: name.clone(),
length,
});
length
};
let mut encoded = Vec::new();
encode(info, &mut encoded);
let digest = Sha1::digest(encoded);
let info_hash = digest.iter().map(|byte| format!("{byte:02x}")).collect();
Ok(ParsedTorrent { name, total_bytes, files, info_hash })
}
pub fn parse_torrent_bytes(bytes: &[u8]) -> Result<ParsedTorrent, 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 = Parser::new(bytes).parse()?;
let root = match root {
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())?;
parse_info(info)
}
fn magnet_metadata(source: &str) -> Result<ParsedTorrent, String> {
let parsed = url::Url::parse(source).map_err(|_| "invalid magnet URI".to_string())?;
if parsed.scheme() != "magnet" {
return Err("unsupported torrent source".to_string());
}
let info_hash = parsed
.query_pairs()
.find_map(|(key, value)| {
if key != "xt" {
return None;
}
let value = value.strip_prefix("urn:btih:")?;
let normalized = value.trim().to_ascii_lowercase();
if (normalized.len() == 40 && normalized.bytes().all(|byte| byte.is_ascii_hexdigit()))
|| (normalized.len() == 32
&& normalized
.bytes()
.all(|byte| byte.is_ascii_lowercase() || matches!(byte, b'2'..=b'7')))
{
Some(normalized)
} else {
None
}
})
.ok_or_else(|| "magnet URI has no valid BitTorrent info hash".to_string())?;
let name = parsed
.query_pairs()
.find_map(|(key, value)| (key == "dn").then_some(value.into_owned()))
.filter(|value| !value.trim().is_empty())
.map(|value| crate::download_ownership::canonical_download_filename(&value))
.unwrap_or_else(|| format!("torrent-{info_hash}"));
Ok(ParsedTorrent { name, total_bytes: 0, files: Vec::new(), info_hash })
}
fn local_torrent_path(source: &str) -> Result<PathBuf, String> {
let path = match url::Url::parse(source) {
Ok(parsed) if parsed.scheme() == "file" => parsed
.to_file_path()
.map_err(|_| "invalid local torrent path".to_string())?,
_ => PathBuf::from(source),
};
if !path.is_absolute() {
return Err("torrent source is not an absolute local file".to_string());
}
let path = std::fs::canonicalize(&path)
.map_err(|error| format!("could not resolve torrent file: {error}"))?;
if path.extension().and_then(|extension| extension.to_str()).map(|extension| extension.eq_ignore_ascii_case("torrent")) != Some(true) {
return Err("torrent files must use the .torrent extension".to_string());
}
Ok(path)
}
pub fn inspect_source(source: &str) -> Result<ParsedTorrent, String> {
if source.trim_start().to_ascii_lowercase().starts_with("magnet:") {
return magnet_metadata(source.trim());
}
let path = local_torrent_path(source)?;
let bytes = std::fs::read(&path).map_err(|error| format!("could not read torrent file: {error}"))?;
parse_torrent_bytes(&bytes)
}
pub fn to_metadata(parsed: ParsedTorrent, torrent_path: Option<String>) -> TorrentMetadata {
TorrentMetadata {
name: parsed.name,
total_bytes: parsed.total_bytes,
files: parsed.files,
info_hash: parsed.info_hash,
torrent_path,
}
}
/// 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> {
parsed
.files
.iter()
.map(|file| {
let output = if parsed.files.len() == 1 {
file.path.clone()
} else {
format!("{}/{}", parsed.name, file.path)
};
format!("{}={output}", file.index)
})
.collect()
}
pub fn aria2_output_paths(parsed: &ParsedTorrent, selected: Option<&[u32]>) -> 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()
} else {
format!("{}/{}", parsed.name, file.path)
}
})
.collect()
}
pub fn managed_torrent_path<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
) -> Result<PathBuf, String> {
if id.is_empty() || !id.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') {
return Err("invalid torrent download id".to_string());
}
let root = app_handle
.path()
.app_data_dir()
.map_err(|error| format!("could not resolve torrent storage: {error}"))?
.join("torrents");
Ok(root.join(format!("{id}.torrent")))
}
pub fn validate_selected_indices(
selected: Option<&[u32]>,
file_count: usize,
) -> Result<Option<Vec<u32>>, String> {
let Some(selected) = selected else {
return Ok(None);
};
if selected.is_empty() || selected.iter().any(|index| *index == 0 || *index as usize > file_count) {
return Err("torrent file selection is invalid".to_string());
}
let mut normalized = selected.to_vec();
normalized.sort_unstable();
normalized.dedup();
Ok(Some(normalized))
}
pub async fn prepare_local_torrent<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
source: &str,
id: &str,
) -> Result<(ParsedTorrent, String), String> {
let source_path = local_torrent_path(source)?;
let file_metadata = tokio::fs::metadata(&source_path)
.await
.map_err(|error| format!("could not inspect torrent file: {error}"))?;
if file_metadata.len() == 0 || file_metadata.len() > MAX_TORRENT_BYTES as u64 {
return Err(format!(
"torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes"
));
}
let bytes = tokio::fs::read(&source_path)
.await
.map_err(|error| format!("could not read torrent file: {error}"))?;
let parsed = parse_torrent_bytes(&bytes)?;
let destination = managed_torrent_path(app_handle, id)?;
if let Some(parent) = destination.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|error| format!("could not create torrent storage: {error}"))?;
}
tokio::fs::write(&destination, &bytes)
.await
.map_err(|error| format!("could not cache torrent metadata: {error}"))?;
Ok((parsed, destination.to_string_lossy().to_string()))
}
pub fn validate_managed_torrent_path<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
path: &str,
) -> Result<PathBuf, String> {
let expected = managed_torrent_path(app_handle, id)?;
let candidate = std::fs::canonicalize(path)
.map_err(|error| format!("could not access cached torrent metadata: {error}"))?;
let expected_parent = expected
.parent()
.and_then(|parent| std::fs::canonicalize(parent).ok())
.ok_or_else(|| "cached torrent storage is unavailable".to_string())?;
if candidate.parent() != Some(expected_parent.as_path()) || candidate.file_name() != expected.file_name() {
return Err("cached torrent metadata path is invalid".to_string());
}
Ok(candidate)
}
pub async fn remove_managed_torrent<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
) {
if let Ok(path) = managed_torrent_path(app_handle, id) {
let _ = tokio::fs::remove_file(path).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_single_file_torrent_and_hashes_info_dictionary() {
let parsed = parse_torrent_bytes(b"d4:infod6:lengthi5e4:name4:testee")
.expect("single-file torrent should parse");
assert_eq!(parsed.name, "test");
assert_eq!(parsed.total_bytes, 5);
assert_eq!(parsed.files[0].index, 1);
assert_eq!(parsed.files[0].path, "test");
assert_eq!(parsed.info_hash.len(), 40);
}
#[test]
fn parses_multi_file_torrent_and_rejects_traversal() {
let parsed = parse_torrent_bytes(
b"d4:infod5:filesld6:lengthi2e4:pathl4:root5:a.txteed6:lengthi3e4:pathl4:root5:b.bineee4:name4:rootee",
)
.expect("multi-file torrent should parse");
assert_eq!(parsed.total_bytes, 5);
assert_eq!(parsed.files.len(), 2);
assert_eq!(parsed.files[1].path, "root/b.bin");
let error = parse_torrent_bytes(
b"d4:infod5:filesld6:lengthi1e4:pathl2:..4:evileee4:name4:rootee",
)
.expect_err("path traversal must be rejected");
assert!(error.contains("unsafe path"));
}
#[test]
fn parses_magnet_identity_without_logging_or_persisting_tracker_data() {
let parsed = inspect_source(
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example%20Torrent&tr=https%3A%2F%2Ftracker.invalid%2Fsecret",
)
.expect("magnet should parse");
assert_eq!(parsed.name, "Example Torrent");
assert_eq!(parsed.info_hash, "0123456789abcdef0123456789abcdef01234567");
assert!(parsed.files.is_empty());
}
#[test]
fn rejects_malformed_base32_magnet_hashes() {
let error = inspect_source(
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcde!&dn=Invalid",
)
.expect_err("a 32-character hash with non-base32 characters must be rejected");
assert!(error.contains("valid BitTorrent info hash"));
}
#[test]
fn normalizes_and_bounds_selected_file_indices() {
assert_eq!(
validate_selected_indices(Some(&[3, 1, 3]), 3).unwrap(),
Some(vec![1, 3])
);
assert!(validate_selected_indices(Some(&[0]), 3).is_err());
assert!(validate_selected_indices(Some(&[4]), 3).is_err());
}
#[test]
fn maps_validated_torrent_files_to_aria2_index_outputs() {
let parsed = parse_torrent_bytes(
b"d4:infod5:filesld6:lengthi2e4:pathl4:root5:a.txteed6:lengthi3e4:pathl4:root5:b.bineee4:name4:rootee",
)
.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()]
);
}
#[test]
fn rejects_noncanonical_lengths_and_invalid_files_field() {
assert!(parse_torrent_bytes(b"d4:infod6:lengthi5e4:name04:testee").is_err());
assert!(parse_torrent_bytes(b"d4:infod5:filesi1e6:lengthi5e4:name4:testee").is_err());
}
}