From ec3dedf1133a3a135b3ece15d1488bd4898b765b Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 23 Oct 2026 12:55:35 +0000 Subject: [PATCH] Torrents download into a folder of their own (#318 #331) The recorded path was output_folder.join(handle.name()). The name is the publisher's: librqbit checks it for traversal only when it chooses the subfolder itself, and ipx passes output_folder, so a multi-file torrent named "/config" or ".." pointed Delete, the reaper and /media at that path. With output_folder set, librqbit also puts a multi-file torrent's files straight into it, not under the name, so the joined path was not where the files were even for an honest torrent. Each enclosure now gets torrent- inside its feed's folder, and that folder is the path: playable() already picks the largest file under a folder and remove() deletes one (#158). The same folder keeps overwrite: true from replacing another enclosure's files of the same name. A torrent half-downloaded before this restarts in its new folder. Co-Authored-By: Claude Opus 5.5 --- crates/ipx/CHANGELOG.md | 4 ++++ crates/ipx/src/main.rs | 2 +- crates/ipx/src/torrent.rs | 25 ++++++++++++++++++++++--- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index 5098cba..fb5e4b5 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -20,6 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A WordPress.com site that answers its feed with a browser check (HTTP 403, "Checking your browser...") is read from WordPress.com's API instead, so Pelgrane Press's feed no longer fails for hours at a time. The items are the ones the feed gave: same ids (#317). +### Security + +- An episode that comes as a torrent downloads into a folder of its own, `torrent-` in its podcast's folder. A torrent could name a path anywhere on the server for Delete, the cleanup and playback to act on, and could overwrite other episodes' files of the same name (#318, #331). + ## [1.4.0] - 2026-10-19 ### Added diff --git a/crates/ipx/src/main.rs b/crates/ipx/src/main.rs index e7d7979..c427153 100644 --- a/crates/ipx/src/main.rs +++ b/crates/ipx/src/main.rs @@ -2401,7 +2401,7 @@ async fn torrent_one( let cfg = ctx.cfg(); ctx.torrents() .await? - .fetch(&cfg, url, dest_dir, |done, total| { + .fetch(&cfg, url, &torrent::own_folder(dest_dir, enclosure), |done, total| { if let Some(pct) = (done * 100).checked_div(total).map(|p| p as i64) && pct > last_pct { diff --git a/crates/ipx/src/torrent.rs b/crates/ipx/src/torrent.rs index ec054e9..9a7f4e9 100644 --- a/crates/ipx/src/torrent.rs +++ b/crates/ipx/src/torrent.rs @@ -39,7 +39,8 @@ impl Torrents { } /// Downloads a torrent (a .torrent URL or a magnet link) straight into `dest_dir`, - /// seeds it up to the configured limit, then stops. + /// seeds it up to the configured limit, then stops. `dest_dir` is the torrent's own + /// folder (`own_folder`), and it is the path returned: a file of several or one. /// /// Downloading in place rather than into a staging dir is deliberate: seeding serves /// the files it downloaded, so moving them first would break it. @@ -103,8 +104,10 @@ impl Torrents { } } - let name = handle.name().unwrap_or_else(|| "torrent".to_owned()); - let path = handle.output_folder().join(&name); + // The torrent's own name is the publisher's to choose, and librqbit checks it only when it + // picks the folder itself: joined on, "/config" or ".." pointed Delete, the reaper and + // /media at anything the daemon can reach (#318). The folder is ours, so it is the path. + let path = dest_dir.to_owned(); let size = handle.stats().total_bytes; self.seed(cfg, &handle).await; @@ -134,6 +137,13 @@ impl Torrents { } } +/// The folder an enclosure's torrent downloads into, one per enclosure inside the feed's folder. +/// With `overwrite` on, a torrent written straight into the feed's folder could replace another +/// enclosure's files of the same name, another feed's too when they share a folder (#331). +pub fn own_folder(feed_dir: &Path, enclosure: i64) -> PathBuf { + feed_dir.join(format!("torrent-{enclosure}")) +} + /// Uploaded over total. A zero-byte torrent counts as fully seeded rather than dividing by zero. pub fn ratio(uploaded: u64, total: u64) -> f64 { if total == 0 { @@ -154,6 +164,15 @@ mod tests { assert!(ratio(0, 0).is_infinite(), "must not divide by zero or seed forever"); } + #[test] + fn own_folder_is_one_folder_inside_the_feeds() { + let feed = Path::new("/downloads/Show"); + let dir = own_folder(feed, 42); + assert_eq!(dir.parent(), Some(feed)); + assert!(matches!(dir.components().next_back(), Some(std::path::Component::Normal(_)))); + assert_ne!(own_folder(feed, 43), dir, "two enclosures never share a folder"); + } + #[test] fn stall_limit_is_never_zero() { let mut cfg = Config::default();