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
|
|
@ -90,15 +90,46 @@ static SXX_EPISODE_RANGE_RE: LazyLock<Regex> =
|
|||
// elsewhere in the title with a smaller trailing number.
|
||||
static BARE_EPISODE_RANGE_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\b(\d{1,3})\s*[-~]\s*(\d{1,3})\b").unwrap());
|
||||
// A `YYYY-MM-DD` date ("2024-01-15"): the year's 4 digits are too many for
|
||||
// `BARE_EPISODE_RANGE_RE`'s/`DASH_EPISODE_RE`'s `\d{1,3}` to match as a
|
||||
// whole, so those regexes' *first real match* on a date-named release ends
|
||||
// up starting at the month ("01-15", or "01" alone) instead — a bare
|
||||
// month/day pair, not a real episode range or episode number. Matched as a
|
||||
// whole date span (not just a "year-" prefix check) so both the month *and*
|
||||
// the day segment are covered — checking only the text immediately before a
|
||||
// candidate match would still let "15" in "2024-01-15" slip through as a
|
||||
// false "episode 15" once "01" alone was correctly rejected.
|
||||
static DATE_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\b(?:19|20)\d{2}-\d{1,2}-\d{1,2}\b").unwrap());
|
||||
|
||||
fn overlaps_a_date(s: &str, start: usize, end: usize) -> bool {
|
||||
DATE_RE.find_iter(s).any(|d| d.start() <= start && end <= d.end())
|
||||
}
|
||||
|
||||
pub(super) fn looks_like_episode_range(s: &str) -> bool {
|
||||
if BATCH_WORD_RE.is_match(s) || SXX_EPISODE_RANGE_RE.is_match(s) {
|
||||
return true;
|
||||
}
|
||||
BARE_EPISODE_RANGE_RE.captures(s).is_some_and(|c| {
|
||||
let a: u32 = c[1].parse().unwrap_or(0);
|
||||
let b: u32 = c[2].parse().unwrap_or(0);
|
||||
b > a
|
||||
BARE_EPISODE_RANGE_RE.captures_iter(s).any(|c| {
|
||||
let first = c.get(1).unwrap();
|
||||
let second = c.get(2).unwrap();
|
||||
let a: u32 = first.as_str().parse().unwrap_or(0);
|
||||
let b: u32 = second.as_str().parse().unwrap_or(0);
|
||||
if b <= a {
|
||||
return false;
|
||||
}
|
||||
if overlaps_a_date(s, first.start(), second.end()) {
|
||||
return false;
|
||||
}
|
||||
// "Season 2 - 25": the range's first number is really a season
|
||||
// marker's own number (checked by comparing spans, not just text,
|
||||
// so this only fires when the two genuinely overlap), not a range
|
||||
// start — "2 - 25" isn't a real episode range, it's "season 2,
|
||||
// episode 25", resolved separately in `extract_episode_info`.
|
||||
let is_season_marker_number = SEASON_PACK_RE.captures(s).is_some_and(|sc| {
|
||||
sc.get(1).unwrap().range() == first.range()
|
||||
});
|
||||
!is_season_marker_number
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -165,6 +196,23 @@ pub(super) fn extract_year(s: &str) -> Option<u32> {
|
|||
s[m.start()..m.end()].parse().ok()
|
||||
}
|
||||
|
||||
/// `DASH_EPISODE_RE`'s first match that isn't actually the month of a
|
||||
/// `YYYY-MM-DD` date. A bare `-\s*\d{1,3}\b` alone can't tell "Show - 25
|
||||
/// [1080p]" (a real episode number) apart from "...2024-01-15..." (the "01"
|
||||
/// is just a month, matched for the same reason `BARE_EPISODE_RANGE_RE`
|
||||
/// does in `looks_like_episode_range` — the 4-digit year is too many digits
|
||||
/// to match as a whole, so the regex's first real match starts one segment
|
||||
/// later). Reused by every `extract_episode_info` branch that falls back to
|
||||
/// `DASH_EPISODE_RE`, not just the range-detection path, since the date
|
||||
/// misread happens independently of whether `looks_like_episode_range`
|
||||
/// fires.
|
||||
fn find_real_dash_episode(s: &str) -> Option<regex::Captures<'_>> {
|
||||
DASH_EPISODE_RE.captures_iter(s).find(|c| {
|
||||
let m = c.get(0).unwrap();
|
||||
!overlaps_a_date(s, m.start(), m.end())
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns (season, episode, absolute_episode, title_span_end) — the last
|
||||
/// element is the byte offset in `s` where the episode/season token (or,
|
||||
/// failing that, the first quality marker) begins, used to slice out the
|
||||
|
|
@ -194,9 +242,19 @@ pub(super) fn extract_episode_info(s: &str) -> (Option<u32>, Option<u32>, Option
|
|||
}
|
||||
if let Some(c) = SEASON_PACK_RE.captures(s) {
|
||||
let season = c[1].parse().ok();
|
||||
// A season marker immediately followed elsewhere by a dash-number
|
||||
// ("Season 2 - 25") names one episode within that season, not a
|
||||
// season-only pack — checked here rather than reordering the checks
|
||||
// above `SXX_DASH_EP_RE`/`SXXEXX_RE` still get first crack at more
|
||||
// specific shapes, and a genuine season-only pack (no trailing
|
||||
// dash-number anywhere) is unaffected.
|
||||
if let Some(ep) = find_real_dash_episode(s) {
|
||||
let episode: Option<u32> = ep[1].parse().ok();
|
||||
return (season, episode, None, c.get(0).unwrap().start());
|
||||
}
|
||||
return (season, None, None, c.get(0).unwrap().start());
|
||||
}
|
||||
if let Some(c) = DASH_EPISODE_RE.captures(s) {
|
||||
if let Some(c) = find_real_dash_episode(s) {
|
||||
let episode: Option<u32> = c[1].parse().ok();
|
||||
return (None, episode, episode, c.get(0).unwrap().start());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue