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-<id> 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 12:55:35 +00:00
parent 511246acb2
commit ec3dedf113
3 changed files with 27 additions and 4 deletions

View File

@@ -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-<id>` 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

View File

@@ -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
{

View File

@@ -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();