Fix bugs found by an Opus 5 audit: races, parsing, and CDATA gaps
- Atomic claim on review-queue approval, closing a double-approve race
that could grab the same release twice (scheduler.rs)
- Constant-time comparison for the daemon API token, closing a timing
side channel
- RSS items wrapped in CDATA (common for titles with '&') were
silently dropped - only Event::Text was ever handled
- Reject malformed apibay info_hash values before building a magnet
link that extract_btih can't parse back out
- Parse sizes with no space before the unit ("38.1GiB")
- Fix "Season N - NN" episode parsing and stop misreading a
YYYY-MM-DD date as a bare episode range
- Query embeddings are no longer cached, fixing unbounded cache growth
over the daemon's lifetime (only library-side candidates need caching)
This commit is contained in:
parent
66d323b7f7
commit
0f609aa4cc
9 changed files with 427 additions and 48 deletions
|
|
@ -2112,6 +2112,30 @@ pub fn prepare_review_approval(conn: &Connection, review_id: i64) -> Result<Appr
|
|||
let profile = load_quality_profile(conn, media_item.quality_profile_id, profile_kind)?;
|
||||
let score = scoring::score(&parsed, 0, false, &profile);
|
||||
|
||||
// Claimed atomically here, still under the caller's DB lock — a real
|
||||
// TOCTOU otherwise: the caller only checked `status == "pending"` above
|
||||
// (a plain read, not a claim), then drops the lock and does the actual
|
||||
// grab (~30s of network/qBittorrent I/O) before `finalize_review_approval`
|
||||
// ever writes anything. A double-click, or an HTTP client retrying after
|
||||
// an apparent timeout, lands two concurrent `approve()` calls that both
|
||||
// pass the read above, both grab the same release, and both write their
|
||||
// own `release`/`torrent_fetch` rows for it. Marking `approved` here
|
||||
// rather than waiting for `finalize_review_approval` closes that window;
|
||||
// `approved` at this point means "no longer available for a second
|
||||
// approval attempt," not "successfully grabbed" — same distinction
|
||||
// `release.status` already draws between `grabbed` and `failed`, and if
|
||||
// the grab itself then errors outright, `release_review_claim` reverts
|
||||
// this back to `pending` (see its own doc comment).
|
||||
let claimed = conn.execute(
|
||||
"UPDATE review_queue SET status = 'approved' WHERE id = ?1 AND status = 'pending'",
|
||||
params![review_id],
|
||||
)?;
|
||||
if claimed == 0 {
|
||||
// Lost the race to a concurrent approve() between the read above
|
||||
// and this claim.
|
||||
return Ok(ApprovalPrep::NotPending);
|
||||
}
|
||||
|
||||
Ok(ApprovalPrep::Ready(PreparedApproval {
|
||||
media_item_id: media_item.id,
|
||||
episode_id,
|
||||
|
|
@ -2123,6 +2147,30 @@ pub fn prepare_review_approval(conn: &Connection, review_id: i64) -> Result<Appr
|
|||
}))
|
||||
}
|
||||
|
||||
/// Releases a claim `prepare_review_approval` took when the grab itself
|
||||
/// then fails outright (a network/qBittorrent error — `Err` from
|
||||
/// `grab_prepared_approval`, not just a missing hash on an otherwise-ok add;
|
||||
/// see `finalize_review_approval`'s own handling for that case, which still
|
||||
/// runs to completion and records a `failed` release). Without this, an
|
||||
/// approval claimed just before a transient qBittorrent outage would be
|
||||
/// stuck `approved` forever with no `release`/`torrent_fetch` row to show
|
||||
/// for it — worse than the un-atomic version this replaced, which at least
|
||||
/// left the row `pending` and retryable. Guarded on `WHERE status =
|
||||
/// 'approved'` mainly to no-op if the row somehow moved on already (e.g. a
|
||||
/// racing `reject`), not because `approved` distinguishes "merely claimed"
|
||||
/// from "successfully finalized" — it doesn't, `review_queue.status` has no
|
||||
/// separate state for that. Safe in practice because a single request's
|
||||
/// grab either errors (this runs, nothing was ever finalized) or succeeds
|
||||
/// (`finalize_review_approval` runs instead, this never gets called) — the
|
||||
/// two are mutually exclusive within one `approve()` call.
|
||||
pub fn release_review_claim(conn: &Connection, review_id: i64) -> Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE review_queue SET status = 'pending' WHERE id = ?1 AND status = 'approved'",
|
||||
params![review_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The actual grab — network/qBittorrent I/O only, no `Connection` involved,
|
||||
/// safe to `.await` from anywhere.
|
||||
pub async fn grab_prepared_approval(
|
||||
|
|
@ -2133,11 +2181,12 @@ pub async fn grab_prepared_approval(
|
|||
grab_and_capture_hash(qbit, &prepared.link, qbit_category).await
|
||||
}
|
||||
|
||||
/// Sync-only: records the grab and marks the review approved. Call after
|
||||
/// [`grab_prepared_approval`] completes.
|
||||
/// Sync-only: records the grab. Call after [`grab_prepared_approval`]
|
||||
/// completes. Doesn't touch `review_queue.status` — `prepare_review_approval`
|
||||
/// already claimed it into `approved` before the grab ran (see its doc
|
||||
/// comment).
|
||||
pub fn finalize_review_approval(
|
||||
conn: &Connection,
|
||||
review_id: i64,
|
||||
prepared: &PreparedApproval,
|
||||
qbit_category: &str,
|
||||
torrent_hash: Option<&str>,
|
||||
|
|
@ -2168,10 +2217,6 @@ pub fn finalize_review_approval(
|
|||
torrent_hash,
|
||||
status,
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE review_queue SET status = 'approved' WHERE id = ?1",
|
||||
params![review_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -2545,6 +2590,47 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
// Regression test for a real gap found in review: `prepare_review_approval`
|
||||
// used to only *read* `status == "pending"`, never claim it — two
|
||||
// concurrent `approve()` calls (a double-click, or an HTTP client retry)
|
||||
// could both pass that check, both grab the same release, and both
|
||||
// write their own `release`/`torrent_fetch` rows. A second call for the
|
||||
// same review must now see it's already claimed.
|
||||
#[test]
|
||||
fn a_second_prepare_call_on_an_already_claimed_review_sees_not_pending() {
|
||||
let conn = seeded_movie_conn();
|
||||
let review_id = insert_pending_review(&conn, "Some Movie 2016 1080p BluRay x264");
|
||||
|
||||
assert!(matches!(
|
||||
prepare_review_approval(&conn, review_id).unwrap(),
|
||||
ApprovalPrep::Ready(_)
|
||||
));
|
||||
// Same review_id, called again before any grab or finalize ran —
|
||||
// simulates the double-click/retry race.
|
||||
assert!(matches!(
|
||||
prepare_review_approval(&conn, review_id).unwrap(),
|
||||
ApprovalPrep::NotPending
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_review_claim_reverts_a_claimed_row_back_to_pending() {
|
||||
let conn = seeded_movie_conn();
|
||||
let review_id = insert_pending_review(&conn, "Some Movie 2016 1080p BluRay x264");
|
||||
|
||||
assert!(matches!(
|
||||
prepare_review_approval(&conn, review_id).unwrap(),
|
||||
ApprovalPrep::Ready(_)
|
||||
));
|
||||
release_review_claim(&conn, review_id).unwrap();
|
||||
// The claim was released (e.g. because the grab itself then errored
|
||||
// outright) — a fresh approval attempt must be possible again.
|
||||
assert!(matches!(
|
||||
prepare_review_approval(&conn, review_id).unwrap(),
|
||||
ApprovalPrep::Ready(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_movie_review_whose_title_looks_like_an_episode() {
|
||||
let conn = seeded_movie_conn();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue