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:
@@ -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).
|
- 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
|
## [1.4.0] - 2026-10-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -2401,7 +2401,7 @@ async fn torrent_one(
|
|||||||
let cfg = ctx.cfg();
|
let cfg = ctx.cfg();
|
||||||
ctx.torrents()
|
ctx.torrents()
|
||||||
.await?
|
.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)
|
if let Some(pct) = (done * 100).checked_div(total).map(|p| p as i64)
|
||||||
&& pct > last_pct
|
&& pct > last_pct
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ impl Torrents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads a torrent (a .torrent URL or a magnet link) straight into `dest_dir`,
|
/// 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
|
/// Downloading in place rather than into a staging dir is deliberate: seeding serves
|
||||||
/// the files it downloaded, so moving them first would break it.
|
/// 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());
|
// The torrent's own name is the publisher's to choose, and librqbit checks it only when it
|
||||||
let path = handle.output_folder().join(&name);
|
// 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;
|
let size = handle.stats().total_bytes;
|
||||||
|
|
||||||
self.seed(cfg, &handle).await;
|
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.
|
/// Uploaded over total. A zero-byte torrent counts as fully seeded rather than dividing by zero.
|
||||||
pub fn ratio(uploaded: u64, total: u64) -> f64 {
|
pub fn ratio(uploaded: u64, total: u64) -> f64 {
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
@@ -154,6 +164,15 @@ mod tests {
|
|||||||
assert!(ratio(0, 0).is_infinite(), "must not divide by zero or seed forever");
|
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]
|
#[test]
|
||||||
fn stall_limit_is_never_zero() {
|
fn stall_limit_is_never_zero() {
|
||||||
let mut cfg = Config::default();
|
let mut cfg = Config::default();
|
||||||
|
|||||||
Reference in New Issue
Block a user