mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-07 09:53:17 +00:00
feat(torrents): harden info-hash metadata reuse
This commit is contained in:
+18
-8
@@ -11,7 +11,8 @@ Reference: [Aria2 1.37.0 manual](https://aria2.github.io/manual/en/html/aria2c.h
|
||||
|
||||
## Audit basis
|
||||
|
||||
- Audited on 2026-08-02 at Firelink `b2c86a2` (`main`), with the cumulative
|
||||
- Audited on 2026-08-03 at Firelink `cba485e` (`main`) plus the current working
|
||||
tree, with the cumulative
|
||||
Torrent work reviewed from `edc76a7`.
|
||||
- Source of truth: `src-tauri/src/torrent.rs`, `torrent_probe.rs`, `queue.rs`,
|
||||
`lib.rs`, `settings.rs`, `download_ownership.rs`, `db.rs`, the IPC bindings,
|
||||
@@ -39,6 +40,19 @@ Reference: [Aria2 1.37.0 manual](https://aria2.github.io/manual/en/html/aria2c.h
|
||||
- Torrent metadata probing uses Aria2 `bt-metadata-only` and `bt-save-metadata`
|
||||
internally, validates the returned hash, and conservatively cleans probe
|
||||
directories. It is not exposed as a separate metadata-only download mode.
|
||||
- Validated metadata is also stored under a canonical lowercase hexadecimal
|
||||
info-hash key. Plain magnets containing only `xt` and optional `dn` reuse
|
||||
that cache before probing when the cached file has no tracker, web-seed, or
|
||||
other source-specific outer metadata; tracker, web-seed, source, and unknown
|
||||
query parameters conservatively force a fresh probe. Cache hits are
|
||||
revalidated against bencode and the exact hash, copied into the current
|
||||
draft ID, and therefore remain compatible with Add-window rekeying.
|
||||
- Canonical metadata writes use a same-directory temporary file and rename;
|
||||
invalid entries and abandoned canonical temporary files are removed safely.
|
||||
Canonical files use a separate `.info-<hash>.torrent` namespace from
|
||||
draft/final IDs, and reads are bounded before parsing. Startup retention
|
||||
keeps canonical files referenced by persisted Torrent records'
|
||||
`torrentInfoHash`, as well as draft/final ID-keyed files.
|
||||
- `addTorrent` passes validated web-seed/mirror URIs when supplied through the
|
||||
existing download input. There is no separate Torrent web-seed manager.
|
||||
|
||||
@@ -124,7 +138,7 @@ Reference: [Aria2 1.37.0 manual](https://aria2.github.io/manual/en/html/aria2c.h
|
||||
|
||||
| Aria2 capability | Firelink status | Reason / next step |
|
||||
| --- | --- | --- |
|
||||
| `bt-load-saved-metadata` | Not exposed | Firelink has managed metadata files, but a new magnet currently probes metadata instead of reusing an info-hash-keyed cache. Add hash-keyed reuse with validation and stale-cache invalidation. |
|
||||
| `bt-load-saved-metadata` | App-equivalent implemented | Firelink owns a validated, atomic, info-hash-keyed metadata cache for plain magnets, limited to metadata without source-specific outer tracker/web-seed fields, while preserving the current draft-ID/rekey contract. Source-specific magnet parameters intentionally bypass reuse; Aria2's daemon option is not exposed directly. |
|
||||
| `dht-message-timeout` | Not exposed | Global DHT/UDP timeout tuning is not yet represented in settings. Add only with bounded validation and a runtime/startup contract. |
|
||||
| `dht-file-path`, `dht-file-path6` | Not explicitly controlled | Aria2 can persist DHT routing tables, but Firelink does not choose app-managed paths or report their health. Decide whether portable-mode and privacy behavior justify exposing this. |
|
||||
| `bt-detach-seed-only` | Not used | Aria2's concurrent-download accounting does not replace Firelink's permit ownership. Enabling it blindly would create two competing concurrency models. Revisit only with an explicit seed-slot policy. |
|
||||
@@ -161,15 +175,11 @@ Before any new Torrent feature is promoted, keep these gates mandatory:
|
||||
|
||||
### Tier 1 — high-value user behavior
|
||||
|
||||
1. **Info-hash-keyed magnet metadata reuse.** Reuse a previously validated
|
||||
managed `.torrent` by info hash before probing DHT/trackers. Revalidate the
|
||||
bencode and exact hash, bind the result to the current draft/download
|
||||
identity, and delete only invalid or unretained cache entries.
|
||||
2. **Unselected-file removal crash/restart audit.** Add post-crash tests around
|
||||
1. **Unselected-file removal crash/restart audit.** Add post-crash tests around
|
||||
the persisted removal reservation, Aria2 completion cleanup, path reuse, and
|
||||
case-insensitive path equality. Do not change cleanup ordering until the
|
||||
ownership postconditions are proven.
|
||||
3. **DHT routing-table persistence policy.** Decide and implement app-managed
|
||||
2. **DHT routing-table persistence policy.** Decide and implement app-managed
|
||||
`dht-file-path`/`dht-file-path6` behavior, especially for portable mode,
|
||||
permissions, reset, and privacy. This should be opt-in if it expands data
|
||||
retention beyond the current download metadata contract.
|
||||
|
||||
+111
-23
@@ -5976,6 +5976,23 @@ async fn resolve_magnet_metadata(
|
||||
.map(crate::queue::aria2_all_proxy_value)
|
||||
.transpose()?
|
||||
.flatten();
|
||||
if cache && crate::torrent::magnet_allows_cached_metadata(source) {
|
||||
match crate::torrent::read_cached_torrent_by_info_hash(app_handle, &expected.info_hash)
|
||||
.await
|
||||
{
|
||||
Ok(Some(bytes)) => {
|
||||
let parsed = crate::torrent::parse_torrent_bytes(&bytes)?;
|
||||
crate::torrent::validate_info_hash(Some(&expected.info_hash), &parsed.info_hash)?;
|
||||
let torrent_path =
|
||||
crate::torrent::cache_torrent_bytes(app_handle, id, &bytes).await?;
|
||||
return Ok(crate::torrent::to_metadata(parsed, Some(torrent_path)));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
log::warn!("could not inspect canonical torrent metadata cache: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let storage_root = managed_path
|
||||
.parent()
|
||||
.ok_or_else(|| "torrent storage has no parent directory".to_string())?;
|
||||
@@ -6045,7 +6062,11 @@ async fn resolve_magnet_metadata(
|
||||
let parsed = crate::torrent::parse_torrent_bytes(&bytes)?;
|
||||
crate::torrent::validate_info_hash(Some(&expected.info_hash), &parsed.info_hash)?;
|
||||
let torrent_path = if cache {
|
||||
Some(crate::torrent::cache_torrent_bytes(app_handle, id, &bytes).await?)
|
||||
let torrent_path = crate::torrent::cache_torrent_bytes(app_handle, id, &bytes).await?;
|
||||
if let Err(error) = crate::torrent::cache_torrent_info_hash(app_handle, &bytes).await {
|
||||
log::warn!("could not cache canonical torrent metadata: {error}");
|
||||
}
|
||||
Some(torrent_path)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -6080,11 +6101,15 @@ async fn inspect_torrent(
|
||||
.map_err(AppError::Internal)?;
|
||||
let parsed = crate::torrent::parse_torrent_bytes(&bytes).map_err(AppError::Internal)?;
|
||||
let torrent_path = if cache != Some(false) {
|
||||
Some(
|
||||
crate::torrent::cache_torrent_bytes(&app_handle, &id, &bytes)
|
||||
.await
|
||||
.map_err(AppError::Internal)?,
|
||||
)
|
||||
let torrent_path = crate::torrent::cache_torrent_bytes(&app_handle, &id, &bytes)
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
if let Err(error) =
|
||||
crate::torrent::cache_torrent_info_hash(&app_handle, &bytes).await
|
||||
{
|
||||
log::warn!("could not cache canonical torrent metadata: {error}");
|
||||
}
|
||||
Some(torrent_path)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -6124,6 +6149,9 @@ async fn rekey_torrent_metadata(
|
||||
let target = crate::torrent::cache_torrent_bytes(&app_handle, &target_id, &bytes)
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
if let Err(error) = crate::torrent::cache_torrent_info_hash(&app_handle, &bytes).await {
|
||||
log::warn!("could not cache canonical torrent metadata during rekey: {error}");
|
||||
}
|
||||
let target = crate::torrent::validate_managed_torrent_path(
|
||||
&app_handle,
|
||||
&target_id,
|
||||
@@ -7402,6 +7430,20 @@ fn retained_torrent_id_from_persisted_record(record: &str) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn retained_torrent_info_hash_from_persisted_record(record: &str) -> Option<String> {
|
||||
let value = serde_json::from_str::<serde_json::Value>(record).ok()?;
|
||||
let object = value.as_object()?;
|
||||
if object.get("isTorrent").and_then(serde_json::Value::as_bool) != Some(true)
|
||||
|| object.get("torrentPath").is_none_or(serde_json::Value::is_null)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
object
|
||||
.get("torrentInfoHash")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(crate::torrent::canonical_info_hash)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn db_replace_downloads(
|
||||
state: tauri::State<'_, crate::db::DbState>,
|
||||
@@ -7851,6 +7893,7 @@ mod tests {
|
||||
normalize_media_connections,
|
||||
validate_enqueue_url, validate_enqueue_uris, validate_keychain_grant_request_id,
|
||||
retained_torrent_id_from_persisted_record,
|
||||
retained_torrent_info_hash_from_persisted_record,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::should_apply_dock_badge_update;
|
||||
@@ -8026,6 +8069,41 @@ mod tests {
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_torrent_info_hash_is_canonicalized_only_for_live_torrents() {
|
||||
let record = json!({
|
||||
"id": "retained-torrent",
|
||||
"isTorrent": true,
|
||||
"torrentPath": "/tmp/retained-torrent.torrent",
|
||||
"torrentInfoHash": "AERUKZ4JVPG66AJDIVTYTK6N54ASGRLH"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
assert_eq!(
|
||||
retained_torrent_info_hash_from_persisted_record(&record).as_deref(),
|
||||
Some("0123456789abcdef0123456789abcdef01234567")
|
||||
);
|
||||
assert!(retained_torrent_info_hash_from_persisted_record(
|
||||
&json!({
|
||||
"id": "not-live",
|
||||
"isTorrent": true,
|
||||
"torrentInfoHash": "0123456789abcdef0123456789abcdef01234567"
|
||||
})
|
||||
.to_string()
|
||||
)
|
||||
.is_none());
|
||||
assert!(retained_torrent_info_hash_from_persisted_record(
|
||||
&json!({
|
||||
"id": "invalid-hash",
|
||||
"isTorrent": true,
|
||||
"torrentPath": "/tmp/invalid-hash.torrent",
|
||||
"torrentInfoHash": "not-a-hash"
|
||||
})
|
||||
.to_string()
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_active_connection_count_uses_only_nonnegative_daemon_values() {
|
||||
assert_eq!(
|
||||
@@ -10485,30 +10563,40 @@ pub fn run() {
|
||||
if let Err(error) = crate::torrent::remove_orphaned_probe_dirs(app.handle()) {
|
||||
log::warn!("could not remove orphaned torrent probes: {error}");
|
||||
}
|
||||
let retained_torrent_ids = database
|
||||
let retained_torrent_metadata = database
|
||||
.lock()
|
||||
.and_then(|connection| crate::db::load_downloads(&connection))
|
||||
.map(|records| {
|
||||
records
|
||||
.into_iter()
|
||||
.filter_map(|record| {
|
||||
let retained = retained_torrent_id_from_persisted_record(&record);
|
||||
if retained.is_none() {
|
||||
if serde_json::from_str::<crate::ipc::DownloadItem>(&record).is_err() {
|
||||
log::warn!(
|
||||
"skipping malformed persisted download during torrent metadata retention"
|
||||
);
|
||||
}
|
||||
}
|
||||
retained
|
||||
})
|
||||
.collect::<HashSet<_>>()
|
||||
let mut retained_ids = HashSet::new();
|
||||
let mut retained_info_hashes = HashSet::new();
|
||||
for record in records {
|
||||
let retained_id = retained_torrent_id_from_persisted_record(&record);
|
||||
let retained_info_hash =
|
||||
retained_torrent_info_hash_from_persisted_record(&record);
|
||||
let has_retained_metadata =
|
||||
retained_id.is_some() || retained_info_hash.is_some();
|
||||
if let Some(id) = retained_id {
|
||||
retained_ids.insert(id);
|
||||
}
|
||||
if let Some(info_hash) = retained_info_hash {
|
||||
retained_info_hashes.insert(info_hash);
|
||||
}
|
||||
if !has_retained_metadata
|
||||
&& serde_json::from_str::<crate::ipc::DownloadItem>(&record).is_err()
|
||||
{
|
||||
log::warn!(
|
||||
"skipping malformed persisted download during torrent metadata retention"
|
||||
);
|
||||
}
|
||||
}
|
||||
(retained_ids, retained_info_hashes)
|
||||
});
|
||||
match retained_torrent_ids {
|
||||
Ok(retained_torrent_ids) => {
|
||||
match retained_torrent_metadata {
|
||||
Ok((retained_torrent_ids, retained_torrent_info_hashes)) => {
|
||||
if let Err(error) = crate::torrent::remove_orphaned_cached_torrents(
|
||||
app.handle(),
|
||||
&retained_torrent_ids,
|
||||
&retained_torrent_info_hashes,
|
||||
) {
|
||||
log::warn!("could not remove orphaned torrent metadata: {error}");
|
||||
}
|
||||
|
||||
+428
-7
@@ -3,6 +3,7 @@ use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tauri::Manager;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use crate::ipc::{TorrentFile, TorrentMetadata};
|
||||
|
||||
@@ -329,6 +330,10 @@ fn canonical_btih(value: &str) -> Option<String> {
|
||||
Some(decoded.iter().map(|byte| format!("{byte:02x}")).collect())
|
||||
}
|
||||
|
||||
pub fn canonical_info_hash(value: &str) -> Option<String> {
|
||||
canonical_btih(value)
|
||||
}
|
||||
|
||||
pub fn validate_info_hash(expected: Option<&str>, actual: &str) -> Result<(), String> {
|
||||
let Some(expected) = expected else {
|
||||
return Ok(());
|
||||
@@ -356,6 +361,39 @@ pub fn parse_torrent_bytes(bytes: &[u8]) -> Result<ParsedTorrent, String> {
|
||||
parse_info(info)
|
||||
}
|
||||
|
||||
pub fn torrent_metadata_is_safe_for_plain_magnet_reuse(bytes: &[u8]) -> Result<bool, String> {
|
||||
if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES {
|
||||
return Err(format!(
|
||||
"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)?;
|
||||
|
||||
Ok(root.keys().all(|key| {
|
||||
matches!(
|
||||
key.as_slice(),
|
||||
b"info"
|
||||
| b"comment"
|
||||
| b"comment.utf-8"
|
||||
| b"created by"
|
||||
| b"created by.utf-8"
|
||||
| b"creation date"
|
||||
| b"encoding"
|
||||
| b"publisher"
|
||||
| b"publisher-url"
|
||||
| b"publisher-url.utf-8"
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
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" {
|
||||
@@ -380,6 +418,33 @@ fn magnet_metadata(source: &str) -> Result<ParsedTorrent, String> {
|
||||
Ok(ParsedTorrent { name, total_bytes: 0, files: Vec::new(), info_hash })
|
||||
}
|
||||
|
||||
pub fn magnet_allows_cached_metadata(source: &str) -> bool {
|
||||
let Ok(parsed) = url::Url::parse(source.trim()) else {
|
||||
return false;
|
||||
};
|
||||
if parsed.scheme() != "magnet" {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut has_info_hash = false;
|
||||
for (key, value) in parsed.query_pairs() {
|
||||
match key.as_ref() {
|
||||
"xt" => {
|
||||
let Some(info_hash) = value.strip_prefix("urn:btih:") else {
|
||||
return false;
|
||||
};
|
||||
if canonical_btih(info_hash).is_none() {
|
||||
return false;
|
||||
}
|
||||
has_info_hash = true;
|
||||
}
|
||||
"dn" => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
has_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
|
||||
@@ -468,13 +533,27 @@ 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'_') {
|
||||
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 = managed_torrent_storage_root(app_handle)?;
|
||||
Ok(root.join(format!("{id}.torrent")))
|
||||
}
|
||||
|
||||
pub fn managed_torrent_info_hash_path<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
info_hash: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
let info_hash = canonical_btih(info_hash)
|
||||
.ok_or_else(|| "invalid torrent info hash cache key".to_string())?;
|
||||
let root = managed_torrent_storage_root(app_handle)?;
|
||||
Ok(root.join(format!(".info-{info_hash}.torrent")))
|
||||
}
|
||||
|
||||
pub fn managed_torrent_storage_root<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
) -> Result<PathBuf, String> {
|
||||
@@ -525,14 +604,16 @@ fn remove_orphaned_probe_dirs_at(root: &Path) -> Result<usize, String> {
|
||||
pub fn remove_orphaned_cached_torrents<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
retained_ids: &HashSet<String>,
|
||||
retained_info_hashes: &HashSet<String>,
|
||||
) -> Result<usize, String> {
|
||||
let root = managed_torrent_storage_root(app_handle)?;
|
||||
remove_orphaned_cached_torrents_at(&root, retained_ids)
|
||||
remove_orphaned_cached_torrents_at(&root, retained_ids, retained_info_hashes)
|
||||
}
|
||||
|
||||
fn remove_orphaned_cached_torrents_at(
|
||||
root: &Path,
|
||||
retained_ids: &HashSet<String>,
|
||||
retained_info_hashes: &HashSet<String>,
|
||||
) -> Result<usize, String> {
|
||||
let entries = match std::fs::read_dir(&root) {
|
||||
Ok(entries) => entries,
|
||||
@@ -541,11 +622,50 @@ fn remove_orphaned_cached_torrents_at(
|
||||
};
|
||||
let mut removed = 0;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|error| format!("could not inspect torrent metadata storage: {error}"))?;
|
||||
let entry = entry
|
||||
.map_err(|error| format!("could not inspect torrent metadata storage: {error}"))?;
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|error| format!("could not inspect torrent metadata entry: {error}"))?;
|
||||
if !file_type.is_file() || entry.path().extension().and_then(|ext| ext.to_str()) != Some("torrent") {
|
||||
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
if file_type.is_file() && is_canonical_torrent_temp_file(&name) {
|
||||
match std::fs::remove_file(entry.path()) {
|
||||
Ok(()) => removed += 1,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"could not remove orphaned torrent metadata temporary file: {error}"
|
||||
));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if file_type.is_file() && name.starts_with(".info-") && name.ends_with(".torrent") {
|
||||
let retained = name
|
||||
.strip_prefix(".info-")
|
||||
.and_then(|name| name.strip_suffix(".torrent"))
|
||||
.and_then(canonical_btih)
|
||||
.is_some_and(|info_hash| {
|
||||
name == format!(".info-{info_hash}.torrent")
|
||||
&& retained_info_hashes.contains(&info_hash)
|
||||
});
|
||||
if retained {
|
||||
continue;
|
||||
}
|
||||
match std::fs::remove_file(entry.path()) {
|
||||
Ok(()) => removed += 1,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(format!("could not remove orphaned torrent metadata: {error}"));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !file_type.is_file()
|
||||
|| entry.path().extension().and_then(|ext| ext.to_str()) != Some("torrent")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
@@ -596,11 +716,14 @@ pub async fn prepare_local_torrent<R: tauri::Runtime>(
|
||||
"torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes"
|
||||
));
|
||||
}
|
||||
let bytes = tokio::fs::read(&source_path)
|
||||
let bytes = read_bounded_torrent_bytes(&source_path)
|
||||
.await
|
||||
.map_err(|error| format!("could not read torrent file: {error}"))?;
|
||||
let parsed = parse_torrent_bytes(&bytes)?;
|
||||
let destination = cache_torrent_bytes(app_handle, id, &bytes).await?;
|
||||
if let Err(error) = cache_torrent_info_hash(app_handle, &bytes).await {
|
||||
log::warn!("could not cache canonical torrent metadata: {error}");
|
||||
}
|
||||
Ok((parsed, destination))
|
||||
}
|
||||
|
||||
@@ -626,19 +749,182 @@ pub async fn cache_torrent_bytes<R: tauri::Runtime>(
|
||||
Ok(destination.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn is_canonical_torrent_temp_file(name: &str) -> bool {
|
||||
let Some(rest) = name.strip_prefix(".cache-") else {
|
||||
return false;
|
||||
};
|
||||
let Some((info_hash, temporary_id)) = rest.split_once(".torrent.") else {
|
||||
return false;
|
||||
};
|
||||
let Some(temporary_id) = temporary_id.strip_suffix(".tmp") else {
|
||||
return false;
|
||||
};
|
||||
canonical_btih(info_hash).as_deref() == Some(info_hash)
|
||||
&& temporary_id.len() == 32
|
||||
&& temporary_id.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
async fn read_bounded_torrent_bytes(path: &Path) -> std::io::Result<Vec<u8>> {
|
||||
let file = tokio::fs::File::open(path).await?;
|
||||
let mut bytes = Vec::with_capacity(std::cmp::min(MAX_TORRENT_BYTES, 64 * 1024));
|
||||
file.take((MAX_TORRENT_BYTES + 1) as u64)
|
||||
.read_to_end(&mut bytes)
|
||||
.await?;
|
||||
if bytes.len() > MAX_TORRENT_BYTES {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("torrent metadata exceeds {MAX_TORRENT_BYTES} bytes"),
|
||||
));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
static CANONICAL_TORRENT_CACHE_LOCK: std::sync::OnceLock<tokio::sync::Mutex<()>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
fn canonical_torrent_cache_lock() -> &'static tokio::sync::Mutex<()> {
|
||||
CANONICAL_TORRENT_CACHE_LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
|
||||
}
|
||||
|
||||
async fn read_cached_torrent_by_info_hash_unlocked<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
info_hash: &str,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let info_hash = canonical_btih(info_hash)
|
||||
.ok_or_else(|| "invalid torrent info hash cache key".to_string())?;
|
||||
let path = managed_torrent_info_hash_path(app_handle, &info_hash)?;
|
||||
let metadata = match tokio::fs::symlink_metadata(&path).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(format!("could not inspect cached torrent metadata: {error}")),
|
||||
};
|
||||
if !metadata.file_type().is_file() && !metadata.file_type().is_symlink() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let validated_path = match validate_managed_torrent_info_hash_path(
|
||||
app_handle,
|
||||
&info_hash,
|
||||
&path.to_string_lossy(),
|
||||
) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
log::warn!("discarding invalid cached torrent metadata: {error}");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let bytes = match read_bounded_torrent_bytes(&validated_path).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::InvalidData => {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
return Ok(None);
|
||||
}
|
||||
Err(error) => return Err(format!("could not read cached torrent metadata: {error}")),
|
||||
};
|
||||
let reusable = torrent_metadata_is_safe_for_plain_magnet_reuse(&bytes).is_ok_and(|safe| safe);
|
||||
match parse_torrent_bytes(&bytes) {
|
||||
Ok(parsed) if reusable && parsed.info_hash == info_hash => {}
|
||||
Ok(_) | Err(_) => {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
pub async fn read_cached_torrent_by_info_hash<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
info_hash: &str,
|
||||
) -> Result<Option<Vec<u8>>, String> {
|
||||
let _guard = canonical_torrent_cache_lock().lock().await;
|
||||
read_cached_torrent_by_info_hash_unlocked(app_handle, info_hash).await
|
||||
}
|
||||
|
||||
pub async fn cache_torrent_info_hash<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
bytes: &[u8],
|
||||
) -> Result<Option<String>, String> {
|
||||
let _guard = canonical_torrent_cache_lock().lock().await;
|
||||
let parsed = parse_torrent_bytes(bytes)?;
|
||||
if !torrent_metadata_is_safe_for_plain_magnet_reuse(bytes)? {
|
||||
return Ok(None);
|
||||
}
|
||||
let info_hash = parsed.info_hash;
|
||||
let destination = managed_torrent_info_hash_path(app_handle, &info_hash)?;
|
||||
if read_cached_torrent_by_info_hash_unlocked(app_handle, &info_hash)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(Some(destination.to_string_lossy().to_string()));
|
||||
}
|
||||
|
||||
let parent = destination
|
||||
.parent()
|
||||
.ok_or_else(|| "torrent storage has no parent directory".to_string())?;
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|error| format!("could not create torrent storage: {error}"))?;
|
||||
let temporary = parent.join(format!(
|
||||
".cache-{info_hash}.torrent.{}.tmp",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
if let Err(error) = tokio::fs::write(&temporary, bytes).await {
|
||||
let _ = tokio::fs::remove_file(&temporary).await;
|
||||
return Err(format!("could not stage canonical torrent metadata: {error}"));
|
||||
}
|
||||
match tokio::fs::rename(&temporary, &destination).await {
|
||||
Ok(()) => Ok(Some(destination.to_string_lossy().to_string())),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
let _ = tokio::fs::remove_file(&temporary).await;
|
||||
if read_cached_torrent_by_info_hash_unlocked(app_handle, &info_hash)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
Ok(Some(destination.to_string_lossy().to_string()))
|
||||
} else {
|
||||
Err("canonical torrent metadata already exists but is invalid".to_string())
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = tokio::fs::remove_file(&temporary).await;
|
||||
Err(format!("could not commit canonical torrent metadata: {error}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)?;
|
||||
validate_managed_torrent_path_against_expected(&expected, path)
|
||||
}
|
||||
|
||||
pub fn validate_managed_torrent_info_hash_path<R: tauri::Runtime>(
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
info_hash: &str,
|
||||
path: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
let expected = managed_torrent_info_hash_path(app_handle, info_hash)?;
|
||||
validate_managed_torrent_path_against_expected(&expected, path)
|
||||
}
|
||||
|
||||
fn validate_managed_torrent_path_against_expected(
|
||||
expected: &Path,
|
||||
path: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
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() {
|
||||
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)
|
||||
@@ -696,6 +982,81 @@ mod tests {
|
||||
assert!(parsed.files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_magnet_reuse_rejects_tracker_and_web_seed_metadata() {
|
||||
assert!(torrent_metadata_is_safe_for_plain_magnet_reuse(
|
||||
b"d4:infod6:lengthi5e4:name4:testee"
|
||||
)
|
||||
.expect("plain torrent metadata should parse"));
|
||||
assert!(!torrent_metadata_is_safe_for_plain_magnet_reuse(
|
||||
b"d8:announce1:x4:infod6:lengthi5e4:name4:testee"
|
||||
)
|
||||
.expect("tracker-bearing torrent metadata should parse"));
|
||||
assert!(!torrent_metadata_is_safe_for_plain_magnet_reuse(
|
||||
b"d4:infod6:lengthi5e4:name4:teste8:url-list1:xe"
|
||||
)
|
||||
.expect("web-seed-bearing torrent metadata should parse"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_cache_temporary_names_are_strictly_recognized() {
|
||||
assert!(is_canonical_torrent_temp_file(
|
||||
".cache-0123456789abcdef0123456789abcdef01234567.torrent.0123456789abcdef0123456789abcdef.tmp"
|
||||
));
|
||||
assert!(!is_canonical_torrent_temp_file(
|
||||
".cache-orphan.torrent.temporary.tmp"
|
||||
));
|
||||
assert!(!is_canonical_torrent_temp_file(
|
||||
".cache-0123456789abcdef0123456789abcdef01234567.torrent.tmp"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn canonical_cache_round_trip_rejects_invalid_bytes_and_source_metadata() {
|
||||
let app = tauri::test::mock_builder()
|
||||
.build(tauri::test::mock_context(tauri::test::noop_assets()))
|
||||
.expect("mock app");
|
||||
let bytes = b"d4:infod6:lengthi5e4:name4:testee";
|
||||
let parsed = parse_torrent_bytes(bytes).expect("test torrent should parse");
|
||||
let path = managed_torrent_info_hash_path(app.handle(), &parsed.info_hash)
|
||||
.expect("canonical cache path should resolve");
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
|
||||
assert!(
|
||||
cache_torrent_info_hash(app.handle(), bytes)
|
||||
.await
|
||||
.expect("canonical cache write should succeed")
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
read_cached_torrent_by_info_hash(app.handle(), &parsed.info_hash)
|
||||
.await
|
||||
.expect("canonical cache read should succeed"),
|
||||
Some(bytes.to_vec())
|
||||
);
|
||||
|
||||
tokio::fs::write(&path, b"not a torrent")
|
||||
.await
|
||||
.expect("invalid cache fixture should be writable");
|
||||
assert!(
|
||||
read_cached_torrent_by_info_hash(app.handle(), &parsed.info_hash)
|
||||
.await
|
||||
.expect("invalid cache should be handled")
|
||||
.is_none()
|
||||
);
|
||||
assert!(!path.exists());
|
||||
|
||||
assert!(
|
||||
cache_torrent_info_hash(
|
||||
app.handle(),
|
||||
b"d8:announce1:x4:infod6:lengthi5e4:name4:testee"
|
||||
)
|
||||
.await
|
||||
.expect("source-specific metadata should be handled")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_base32_magnet_hashes_to_hex() {
|
||||
let parsed = inspect_source(
|
||||
@@ -718,6 +1079,35 @@ mod tests {
|
||||
)
|
||||
.is_err());
|
||||
validate_info_hash(None, "not-used").expect("missing legacy identity should remain compatible");
|
||||
assert_eq!(
|
||||
canonical_info_hash("AERUKZ4JVPG66AJDIVTYTK6N54ASGRLH").as_deref(),
|
||||
Some("0123456789abcdef0123456789abcdef01234567")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_plain_magnets_can_reuse_hash_keyed_metadata() {
|
||||
assert!(magnet_allows_cached_metadata(
|
||||
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567"
|
||||
));
|
||||
assert!(magnet_allows_cached_metadata(
|
||||
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example%20Torrent"
|
||||
));
|
||||
assert!(!magnet_allows_cached_metadata(
|
||||
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&tr=https%3A%2F%2Ftracker.invalid%2Fannounce"
|
||||
));
|
||||
assert!(!magnet_allows_cached_metadata(
|
||||
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&ws=https%3A%2F%2Fexample.invalid%2Ffile"
|
||||
));
|
||||
assert!(!magnet_allows_cached_metadata(
|
||||
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&as=https%3A%2F%2Fexample.invalid%2Ffile"
|
||||
));
|
||||
assert!(!magnet_allows_cached_metadata(
|
||||
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&unknown=value"
|
||||
));
|
||||
assert!(!magnet_allows_cached_metadata(
|
||||
"magnet:?xt=urn:btih:not-a-valid-hash"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -793,17 +1183,48 @@ mod tests {
|
||||
fn removes_unretained_torrent_files_but_preserves_retained_and_unrelated_entries() {
|
||||
let temporary = tempfile::tempdir().expect("temporary torrent storage should exist");
|
||||
let root = temporary.path();
|
||||
let retained_hash = "0123456789abcdef0123456789abcdef01234567";
|
||||
std::fs::write(root.join("keep-id.torrent"), b"retained")
|
||||
.expect("retained metadata should exist");
|
||||
std::fs::write(root.join("orphan-id.torrent"), b"orphan")
|
||||
.expect("orphan metadata should exist");
|
||||
std::fs::write(root.join(format!(".info-{retained_hash}.torrent")), b"retained hash")
|
||||
.expect("retained hash metadata should exist");
|
||||
std::fs::write(root.join(format!("{retained_hash}.torrent")), b"legacy hash")
|
||||
.expect("legacy hash metadata should exist");
|
||||
std::fs::write(
|
||||
root.join(".info-fedcba9876543210fedcba9876543210fedcba98.torrent"),
|
||||
b"orphan hash",
|
||||
)
|
||||
.expect("orphan hash metadata should exist");
|
||||
std::fs::write(
|
||||
root.join(format!(
|
||||
".cache-{retained_hash}.torrent.0123456789abcdef0123456789abcdef.tmp"
|
||||
)),
|
||||
b"orphan temporary",
|
||||
)
|
||||
.expect("orphan temporary metadata should exist");
|
||||
std::fs::write(root.join("notes.txt"), b"unrelated")
|
||||
.expect("unrelated file should exist");
|
||||
let retained = HashSet::from(["keep-id".to_string()]);
|
||||
let retained_hashes = HashSet::from([retained_hash.to_string()]);
|
||||
|
||||
assert_eq!(remove_orphaned_cached_torrents_at(root, &retained).unwrap(), 1);
|
||||
assert_eq!(
|
||||
remove_orphaned_cached_torrents_at(root, &retained, &retained_hashes).unwrap(),
|
||||
4
|
||||
);
|
||||
assert!(root.join("keep-id.torrent").is_file());
|
||||
assert!(!root.join("orphan-id.torrent").exists());
|
||||
assert!(root.join(format!(".info-{retained_hash}.torrent")).is_file());
|
||||
assert!(!root.join(format!("{retained_hash}.torrent")).exists());
|
||||
assert!(!root
|
||||
.join(".info-fedcba9876543210fedcba9876543210fedcba98.torrent")
|
||||
.exists());
|
||||
assert!(!root
|
||||
.join(format!(
|
||||
".cache-{retained_hash}.torrent.0123456789abcdef0123456789abcdef.tmp"
|
||||
))
|
||||
.exists());
|
||||
assert!(root.join("notes.txt").is_file());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user