Files
usenet-indexer/tools/edge-bench-rs/src/names.rs
T
Daan MeijerandClaude Opus 5.5 b1c2db5984 edge-bench-rs: upload tools, release-name checks and common Date headers by hand
Each with its regex/chrono version as the reference in tests; check.ts
still finds all three captures identical. Boneless 2026 ~500k headers/s,
ath 2013 ~650k.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:31:56 +02:00

740 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! lib/release (identity, random), lib/files/volumes and lib/rules/upload_tools.
//! Regexes run on bytes without Unicode (`(?-u)`): JS regexes without the `u`
//! flag match ASCII classes, `\d` is [0-9] and `\b` is an ASCII boundary. JS
//! `\s` (which includes Unicode spaces) is spelled out as SP.
use crate::js::{self, len16, sha1_hex};
use md5::{Digest, Md5};
use regex::bytes::Regex;
use std::sync::LazyLock;
/// JS `\s` as a byte pattern.
const SP: &str = r"(?:[\t\n\x0B\x0C\r ]|\xC2\xA0|\xE1\x9A\x80|\xE2\x80[\x80-\x8A\xA8\xA9\xAF]|\xE2\x81\x9F|\xE3\x80\x80|\xEF\xBB\xBF)";
fn re(pattern: &str) -> Regex {
Regex::new(&format!("(?-u){}", pattern.replace("SP", SP))).unwrap()
}
/// `s.replace(regex, with)` (first match) or `s.replace(/…/g, with)` (all).
fn replace(re: &Regex, s: &str, with: &str, all: bool) -> String {
let out = if all { re.replace_all(s.as_bytes(), with.as_bytes()) } else { re.replacen(s.as_bytes(), 1, with.as_bytes()) };
// The patterns match whole characters (ASCII, or delimited by ASCII), so this stays UTF-8.
String::from_utf8(out.into_owned()).unwrap()
}
fn test(re: &Regex, s: &str) -> bool {
re.is_match(s.as_bytes())
}
static SUFFIXES: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
vec![
(re(r"(?i)\.vol[0-9]+[+-][0-9]+\.par2$"), ""),
(re(r"(?i)\.par2$"), ""),
(re(r"(?i)\.part[0-9]+\.rar$"), ""),
(re(r"(?i)\.r[0-9]{2,3}$"), ""),
(re(r"(?i)\.rar$"), ""),
(re(r"(?i)\.7z(\.[0-9]{3})?$"), ""),
(re(r"(?i)\.zip(\.[0-9]{3})?$"), ""),
// JS: (?<=\.(7z|…))\.\d{3}$ — no lookbehind here, so the extension is matched and put back.
(re(r"(?i)(\.(?:7z|zip|rar|tar|mkv|mp4|avi|m4v|ts|m2ts|iso|img|bin|vob))\.[0-9]{3}$"), "$1"),
(re(r"(?i)\.(nfo|sfv|nzb|srr|srs|txt|jpg|png|md5|sha1|srt|sub|idx|ass|ssa|m3u|m3u8|cue|log)$"), ""),
(re(r"(?i)\.tar$"), ""),
(re(r"(?i)\.(zst|gz|xz|bz2)$"), ""),
(re(r"(?i)\.(mkv|mp4|avi|m4v|ts|iso|img|bin|flac|mp3|epub|pdf)$"), ""),
]
});
/// fileStem with the regexes, as in the TypeScript (the reference for file_stem's tests).
#[allow(dead_code)]
pub fn file_stem_re(name: &str) -> String {
let mut stem = js::trim(name).to_string();
let mut changed = true;
while changed {
changed = false;
for (suffix, with) in SUFFIXES.iter() {
if !suffix.is_match(stem.as_bytes()) {
continue;
}
let next = replace(suffix, &stem, with, false);
if next != stem && !next.is_empty() {
stem = next;
changed = true;
}
}
}
stem
}
static QUOTED: LazyLock<Regex> = LazyLock::new(|| re(r#""[^"]*""#));
static COUNTERS: LazyLock<Regex> = LazyLock::new(|| re(r"\[[0-9]+/[0-9]+\]|\([0-9]+/[0-9]+\)"));
static YENC_END: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\byEnc\b(SP+[0-9]+)?SP*$"));
static YENC: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\byEnc\b"));
static SEPARATORS: LazyLock<Regex> = LazyLock::new(|| re(r"(?:SP|[-.:|]|\xE2\x80\x93)+"));
/// subjectRemainder with the regexes, as in the TypeScript (the reference for subject_remainder's tests).
#[allow(dead_code)]
pub fn subject_remainder_re(subject: &str) -> String {
let s = replace(&QUOTED, subject, " ", true);
let s = replace(&COUNTERS, &s, " ", true);
let s = replace(&YENC_END, &s, " ", false);
let s = replace(&YENC, &s, " ", true);
let s = replace(&SEPARATORS, &s, " ", true);
js::trim(&s).to_string()
}
fn eq_ci(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.to_ascii_lowercase() == *y)
}
/// Ends in `suffix` (lowercase), ASCII case-insensitively.
fn ends_ci(s: &[u8], suffix: &[u8]) -> bool {
s.len() >= suffix.len() && eq_ci(&s[s.len() - suffix.len()..], suffix)
}
/// ASCII digits ending at `end`.
fn digits_back(s: &[u8], end: usize) -> usize {
s[..end].iter().rev().take_while(|c| c.is_ascii_digit()).count()
}
/// `.` + one of `exts` at the end: the length without it.
fn strip_ext(s: &[u8], exts: &[&[u8]]) -> Option<usize> {
for ext in exts {
let n = ext.len() + 1;
if s.len() >= n && s[s.len() - n] == b'.' && ends_ci(s, ext) {
return Some(s.len() - n);
}
}
None
}
/// `\.\d{3}$`: the length without it.
fn strip_three_digits(s: &[u8]) -> Option<usize> {
let n = s.len();
(n >= 4 && s[n - 4] == b'.' && s[n - 3..].iter().all(u8::is_ascii_digit)).then(|| n - 4)
}
const SPLIT_EXTS: [&[u8]; 14] = [b"7z", b"zip", b"rar", b"tar", b"mkv", b"mp4", b"avi", b"m4v", b"ts", b"m2ts", b"iso", b"img", b"bin", b"vob"];
const SIDE_EXTS: [&[u8]; 19] = [b"nfo", b"sfv", b"nzb", b"srr", b"srs", b"txt", b"jpg", b"png", b"md5", b"sha1", b"srt", b"sub", b"idx", b"ass", b"ssa", b"m3u", b"m3u8", b"cue", b"log"];
const PACK_EXTS: [&[u8]; 4] = [b"zst", b"gz", b"xz", b"bz2"];
const MEDIA_EXTS: [&[u8]; 12] = [b"mkv", b"mp4", b"avi", b"m4v", b"ts", b"iso", b"img", b"bin", b"flac", b"mp3", b"epub", b"pdf"];
/// SUFFIXES[rule] once: the stem's new length when it matches.
fn strip_suffix_rule(s: &[u8], rule: usize) -> Option<usize> {
let n = s.len();
match rule {
// \.vol\d+[+-]\d+\.par2$
0 => {
if !ends_ci(s, b".par2") {
return None;
}
let d2 = digits_back(s, n - 5);
let sign = (n - 5).checked_sub(d2 + 1).filter(|_| d2 > 0)?;
if !matches!(s[sign], b'+' | b'-') {
return None;
}
let d1 = digits_back(s, sign);
let v = sign.checked_sub(d1 + 4).filter(|_| d1 > 0)?;
eq_ci(&s[v..v + 4], b".vol").then_some(v)
}
1 => ends_ci(s, b".par2").then(|| n - 5),
// \.part\d+\.rar$
2 => {
if !ends_ci(s, b".rar") {
return None;
}
let d = digits_back(s, n - 4);
let p = (n - 4).checked_sub(d + 5).filter(|_| d > 0)?;
eq_ci(&s[p..p + 5], b".part").then_some(p)
}
// \.r\d{2,3}$
3 => {
let d = digits_back(s, n);
let p = n.checked_sub(d + 2).filter(|_| (2..=3).contains(&d))?;
eq_ci(&s[p..p + 2], b".r").then_some(p)
}
4 => ends_ci(s, b".rar").then(|| n - 4),
// \.7z(\.\d{3})?$ and \.zip(\.\d{3})?$
5 | 6 => {
let ext: &[u8] = if rule == 5 { b".7z" } else { b".zip" };
if let Some(m) = strip_three_digits(s) {
if ends_ci(&s[..m], ext) {
return Some(m - ext.len());
}
}
ends_ci(s, ext).then(|| n - ext.len())
}
// (?<=\.(7z|…))\.\d{3}$: only the volume number goes.
7 => {
let m = strip_three_digits(s)?;
strip_ext(&s[..m], &SPLIT_EXTS).map(|_| m)
}
8 => strip_ext(s, &SIDE_EXTS),
9 => strip_ext(s, &[b"tar"]),
10 => strip_ext(s, &PACK_EXTS),
11 => strip_ext(s, &MEDIA_EXTS),
_ => None,
}
}
/// fileStem: per-file suffixes stripped, repeatedly. The SUFFIXES regexes by hand: with them, a
/// file's description was most of the indexing time.
pub fn file_stem(name: &str) -> String {
let stem = js::trim(name);
let b = stem.as_bytes();
let mut len = b.len();
let mut changed = true;
while changed {
changed = false;
for rule in 0..12 {
if let Some(next) = strip_suffix_rule(&b[..len], rule) {
if next > 0 && next < len {
len = next;
changed = true;
}
}
}
}
// The suffixes are ASCII: `len` is a character boundary.
stem[..len].to_string()
}
fn is_word(c: u8) -> bool {
c.is_ascii_alphanumeric() || c == b'_'
}
/// "yEnc" (any case) at `i` with a word boundary (ASCII, as in JS) on both sides.
fn yenc_at(b: &[u8], i: usize) -> bool {
i + 4 <= b.len() && eq_ci(&b[i..i + 4], b"yenc") && (i == 0 || !is_word(b[i - 1])) && (i + 4 == b.len() || !is_word(b[i + 4]))
}
/// `(\s+\d+)?\s*$`
fn yenc_tail(rest: &str) -> bool {
let after_spaces = rest.trim_start_matches(js::is_space);
if after_spaces.chars().all(js::is_space) {
return true;
}
if after_spaces.len() == rest.len() {
return false;
}
let after_digits = after_spaces.trim_start_matches(|c: char| c.is_ascii_digit());
after_digits.len() < after_spaces.len() && after_digits.chars().all(js::is_space)
}
/// subjectRemainder by hand: the same five replacements and trim.
pub fn subject_remainder(subject: &str) -> String {
let b = subject.as_bytes();
// "[^"]*" → ' '
let mut s1: Vec<u8> = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'"' {
if let Some(j) = b[i + 1..].iter().position(|&c| c == b'"') {
s1.push(b' ');
i += j + 2;
continue;
}
}
s1.push(b[i]);
i += 1;
}
// \[\d+/\d+\] and \(\d+/\d+\) → ' '
let mut s2: Vec<u8> = Vec::with_capacity(s1.len());
let mut i = 0;
while i < s1.len() {
let close = match s1[i] {
b'[' => Some(b']'),
b'(' => Some(b')'),
_ => None,
};
if let Some(close) = close {
let a = s1[i + 1..].iter().take_while(|c| c.is_ascii_digit()).count();
let slash = i + 1 + a;
if a > 0 && s1.get(slash) == Some(&b'/') {
let d = s1[slash + 1..].iter().take_while(|c| c.is_ascii_digit()).count();
if d > 0 && s1.get(slash + 1 + d) == Some(&close) {
s2.push(b' ');
i = slash + 2 + d;
continue;
}
}
}
s2.push(s1[i]);
i += 1;
}
// Only ASCII was replaced, between whole characters.
let mut s3 = String::from_utf8(s2).unwrap();
// \byEnc\b(\s+\d+)?\s*$ → ' ' (the first place where it fits)
let b3 = s3.as_bytes();
if let Some(at) = (0..b3.len()).find(|&i| yenc_at(b3, i) && yenc_tail(&s3[i + 4..])) {
s3.truncate(at);
s3.push(' ');
}
// \byEnc\b → ' ', then runs of \s, -, –, ., :, | → ' '
let b3 = s3.as_bytes();
let mut s4 = String::with_capacity(s3.len());
let mut separator = false;
let mut i = 0;
while i < b3.len() {
if yenc_at(b3, i) {
// Its " " starts or joins a separator run.
if !separator {
s4.push(' ');
separator = true;
}
i += 4;
continue;
}
let c = s3[i..].chars().next().unwrap();
if js::is_space(c) || matches!(c, '-' | '.' | ':' | '|' | '\u{2013}') {
if !separator {
s4.push(' ');
separator = true;
}
} else {
s4.push(c);
separator = false;
}
i += c.len_utf8();
}
js::trim(&s4).to_string()
}
fn is_random_token(token: &str) -> bool {
let t = token.strip_prefix('[').unwrap_or(token);
let t = t.strip_suffix(']').unwrap_or(t);
if len16(t) < 6 {
return false;
}
let digit = t.bytes().any(|b| b.is_ascii_digit());
if digit && t.bytes().all(|b| b.is_ascii_hexdigit()) {
return true;
}
digit && t.bytes().any(|b| b.is_ascii_lowercase()) && t.bytes().any(|b| b.is_ascii_uppercase()) && !t.bytes().any(|b| matches!(b, b'.' | b'_' | b'-'))
}
pub fn is_post_token(remainder: &str) -> bool {
let compact: String = remainder.chars().filter(|&c| !js::is_space(c) && !matches!(c, '[' | ']' | '(' | ')' | '_' | '-')).collect();
let n = len16(&compact);
(6..=40).contains(&n) && compact.bytes().any(|b| b.is_ascii_digit()) && compact.bytes().all(|b| b.is_ascii_alphanumeric())
}
#[allow(dead_code)]
static NAME_LIKE: LazyLock<Regex> = LazyLock::new(|| re(r"[A-Za-z]{2,}[._ -]+[A-Za-z0-9]{2,}"));
pub fn names_release(remainder: &str) -> bool {
let words: Vec<&str> = js::split_spaces(remainder).into_iter().filter(|w| !w.is_empty() && !is_random_token(w)).collect();
name_like(words.join(" ").as_bytes())
}
#[derive(Debug, Clone)]
pub struct Release {
pub key: String,
pub name: String,
pub source: &'static str,
/// The [i/M] total as parsed (0 is falsy in the TypeScript).
pub file_total: Option<i64>,
}
impl Release {
pub fn file_total_truthy(&self) -> Option<i64> {
self.file_total.filter(|&n| n != 0)
}
}
#[allow(dead_code)]
static FILE_COUNTER: LazyLock<Regex> = LazyLock::new(|| re(r"\[([0-9]+)/([0-9]+)\]"));
fn hash(parts: &[&str]) -> String {
sha1_hex(&parts.join("\n"))
}
/// releaseIdentity for a named (not weak) file: never null.
pub fn release_identity(poster: &str, subject: &str, name: &str) -> Release {
let file_total = file_counter_total(subject);
let stem = file_stem(name);
let remainder = subject_remainder(subject);
if names_release(&remainder) {
return Release { key: hash(&["subject", poster, &remainder]), name: remainder, source: "subject", file_total };
}
if let Some(total) = file_total.filter(|&n| n != 0) {
if is_post_token(&remainder) {
return Release { key: hash(&["post", poster, &remainder, &total.to_string()]), name: remainder, source: "post", file_total };
}
}
Release { key: hash(&["stem", poster, &stem]), name: stem, source: "stem", file_total }
}
static GENERIC: LazyLock<Regex> = LazyLock::new(|| {
re(r"(?i)^(video_ts|audio_ts|bdmv|certificate|hvdvd_ts|samples?|subs?|subtitles?|proof|cover|covers|extras?|featurettes?|(cd|dvd|disc|disk)[ ._-]?[0-9]{1,2})$")
});
fn is_generic_name(name: &str) -> bool {
test(&GENERIC, js::trim(name))
}
/// looksRandom.
pub fn looks_random(name: &str) -> bool {
// name.replace(/\.[A-Za-z0-9]{1,4}$/, '')
let name = match name.rfind('.') {
Some(i) if (1..=4).contains(&(name.len() - i - 1)) && name[i + 1..].bytes().all(|b| b.is_ascii_alphanumeric()) => &name[..i],
_ => name,
};
if is_generic_name(name) {
return true;
}
let b = name.as_bytes();
let hex = |s: &[u8]| s.iter().all(|c| c.is_ascii_hexdigit());
let digits = |s: &[u8]| s.iter().all(|c| c.is_ascii_digit());
if b.len() >= 16 && hex(b) {
return true;
}
if let Some(dot) = name.find('.') {
let (h, d) = (&b[..dot], &b[dot + 1..]);
if h.len() >= 16 && hex(h) && d.len() >= 9 && digits(d) {
return true;
}
}
if b.len() == 36 && [8, 13, 18, 23].iter().all(|&i| b[i] == b'-') && b.iter().enumerate().all(|(i, c)| [8, 13, 18, 23].contains(&i) || c.is_ascii_hexdigit()) {
return true;
}
{
let core = name.strip_suffix(" n").unwrap_or(name).as_bytes();
if core.len() >= 6 && digits(core) {
return true;
}
}
let count = |f: fn(&u8) -> bool| b.iter().filter(|c| f(c)).count();
let n_digit = count(u8::is_ascii_digit);
let n_lower = count(u8::is_ascii_lowercase);
let n_upper = count(u8::is_ascii_uppercase);
let alnum = b.iter().all(|c| c.is_ascii_alphanumeric());
if b.len() >= 12 && b.iter().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) && n_digit >= 2 && n_lower >= 4 {
return true;
}
if (8..=15).contains(&b.len()) && alnum && n_digit > 0 && n_lower > 0 && b[1..].iter().filter(|c| c.is_ascii_uppercase()).count() >= 3 {
return true;
}
if !(b.len() >= 16 && alnum) || n_lower == 0 || n_upper == 0 {
return false;
}
n_digit > 0 || n_upper >= 6
}
pub fn is_random_file(name: &str) -> bool {
looks_random(&file_stem(name))
}
static SEVEN_SPLIT: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.7z\.([0-9]{1,4})$"));
static SEVEN: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.7z$"));
static PART1: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.part0*1\.rar$"));
static RAR: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.rar$"));
static PART: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.part([0-9]+)\.rar$"));
static BIN: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.bin$"));
static OLD_RAR: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.r[0-9]{2,3}$"));
static PAR2_VOL: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.vol[0-9]+[+-][0-9]+\.par2$"));
pub static PAR2: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.par2$"));
pub fn is_par2(name: &str) -> bool {
test(&PAR2, name)
}
pub fn seven_zip_volume(name: &str) -> Option<i64> {
if let Some(c) = SEVEN_SPLIT.captures(name.as_bytes()) {
return js::parse_int(std::str::from_utf8(&c[1]).unwrap());
}
if test(&SEVEN, name) {
Some(0)
} else {
None
}
}
pub fn is_archive_start(name: &str) -> bool {
if test(&BIN, name) {
return true;
}
if matches!(seven_zip_volume(name), Some(0) | Some(1)) {
return true;
}
test(&PART1, name) || (test(&RAR, name) && !test(&PART, name))
}
pub fn is_later_volume(name: &str) -> bool {
if let Some(c) = PART.captures(name.as_bytes()) {
return js::parse_int(std::str::from_utf8(&c[1]).unwrap()).unwrap_or(0) > 1;
}
test(&OLD_RAR, name) || test(&PAR2_VOL, name)
}
static TOOLS: LazyLock<Vec<(&'static str, Regex)>> = LazyLock::new(|| {
vec![
("nyuu", re(r"^(?:[A-Z][a-z]){12}-[0-9]{13}@nyuu$")),
("nyuu-host", re(r"^(?:[A-Z][a-z]){12}-[0-9]{13}@[^\n\r]+$")),
("ngPost", re(r"^[0-9a-f]{32}@ngPost$")),
("yuun", re(r"^[0-9a-f]{32}@[0-9a-f]{32}\.yuun$")),
("hex32", re(r"^[0-9a-f]{32}@[A-Za-z0-9.-]+$")),
("JBinUp", re(r"@JBin(?:Up|Down)\.local$")),
("partNofM", re(r"(?i)^part[0-9]+of[0-9]+\.")),
("ts-dollar", re(r"^[0-9]{13}\$[0-9]+\$[0-9]+@localhost$")),
("random", re(r"^[A-Za-z0-9]{10,40}@[A-Za-z0-9]{3,12}\.[A-Za-z0-9]{2,6}$")),
]
});
/// uploadTool with the regexes, as in the TypeScript (the reference for upload_tool's test).
#[allow(dead_code)]
pub fn upload_tool_re(message_id: &str) -> &'static str {
if message_id.is_empty() {
return "other";
}
for (tool, pattern) in TOOLS.iter() {
if test(pattern, message_id) {
return tool;
}
}
"other"
}
fn all(s: &[u8], f: impl Fn(u8) -> bool) -> bool {
s.iter().all(|&c| f(c))
}
fn lower_hex(c: u8) -> bool {
c.is_ascii_digit() || (b'a'..=b'f').contains(&c)
}
/// `(?:[A-Z][a-z]){12}-\d{13}@`: nyuu's prefix (38 bytes, then '@').
fn nyuu_prefix(b: &[u8]) -> bool {
b.len() > 38 && (0..24).step_by(2).all(|i| b[i].is_ascii_uppercase() && b[i + 1].is_ascii_lowercase()) && b[24] == b'-' && all(&b[25..38], |c| c.is_ascii_digit()) && b[38] == b'@'
}
/// The posting tool of a Message-ID (TOOLS, in order, by hand: they ran for every per-article file).
pub fn upload_tool(message_id: &str) -> &'static str {
let b = message_id.as_bytes();
if b.is_empty() {
return "other";
}
if nyuu_prefix(b) && b.len() > 39 {
// nyuu: "@nyuu" exactly; nyuu-host: any host (`.+`: no line terminators).
if &b[39..] == b"nyuu" {
return "nyuu";
}
if !b[39..].iter().any(|&c| c == b'\n' || c == b'\r') && !message_id[39..].contains(['\u{2028}', '\u{2029}']) {
return "nyuu-host";
}
}
let hex32 = b.len() > 33 && all(&b[..32], lower_hex) && b[32] == b'@';
if hex32 {
let host = &b[33..];
if host == b"ngPost" {
return "ngPost";
}
if host.len() == 37 && all(&host[..32], lower_hex) && &host[32..] == b".yuun" {
return "yuun";
}
if all(host, |c| c.is_ascii_alphanumeric() || c == b'.' || c == b'-') {
return "hex32";
}
}
if message_id.ends_with("@JBinUp.local") || message_id.ends_with("@JBinDown.local") {
return "JBinUp";
}
// (?i)^part\d+of\d+\.
if b.len() > 4 && eq_ci(&b[..4], b"part") {
let d1 = b[4..].iter().take_while(|c| c.is_ascii_digit()).count();
let of = 4 + d1;
if d1 > 0 && b.len() > of + 2 && eq_ci(&b[of..of + 2], b"of") {
let d2 = b[of + 2..].iter().take_while(|c| c.is_ascii_digit()).count();
if d2 > 0 && b.get(of + 2 + d2) == Some(&b'.') {
return "partNofM";
}
}
}
// ^\d{13}\$\d+\$\d+@localhost$
if let Some(rest) = message_id.strip_suffix("@localhost") {
let r = rest.as_bytes();
let parts: Vec<&[u8]> = r.split(|&c| c == b'$').collect();
if parts.len() == 3 && parts[0].len() == 13 && parts.iter().all(|p| !p.is_empty() && all(p, |c| c.is_ascii_digit())) {
return "ts-dollar";
}
}
// ^[A-Za-z0-9]{10,40}@[A-Za-z0-9]{3,12}\.[A-Za-z0-9]{2,6}$
if let Some(at) = b.iter().position(|&c| c == b'@') {
let (local, host) = (&b[..at], &b[at + 1..]);
if (10..=40).contains(&local.len()) && all(local, |c| c.is_ascii_alphanumeric()) {
if let Some(dot) = host.iter().position(|&c| c == b'.') {
let (name, tld) = (&host[..dot], &host[dot + 1..]);
if (3..=12).contains(&name.len()) && (2..=6).contains(&tld.len()) && all(name, |c| c.is_ascii_alphanumeric()) && all(tld, |c| c.is_ascii_alphanumeric()) {
return "random";
}
}
}
}
"other"
}
/// [A-Za-z]{2,}[._ -]+[A-Za-z0-9]{2,} anywhere.
fn name_like(b: &[u8]) -> bool {
let sep = |c: u8| matches!(c, b'.' | b'_' | b' ' | b'-');
(2..b.len()).any(|k| {
if !(sep(b[k]) && b[k - 1].is_ascii_alphabetic() && b[k - 2].is_ascii_alphabetic()) {
return false;
}
let m = k + b[k..].iter().take_while(|&&c| sep(c)).count();
m + 2 <= b.len() && b[m].is_ascii_alphanumeric() && b[m + 1].is_ascii_alphanumeric()
})
}
/// The second number of the first \[(\d+)/(\d+)\].
fn file_counter_total(s: &str) -> Option<i64> {
let b = s.as_bytes();
for (i, &c) in b.iter().enumerate() {
if c != b'[' {
continue;
}
let a = b[i + 1..].iter().take_while(|c| c.is_ascii_digit()).count();
let slash = i + 1 + a;
if a == 0 || b.get(slash) != Some(&b'/') {
continue;
}
let d = b[slash + 1..].iter().take_while(|c| c.is_ascii_digit()).count();
if d > 0 && b.get(slash + 1 + d) == Some(&b']') {
return js::parse_int(&s[slash + 1..slash + 1 + d]);
}
}
None
}
/// explored: a release in the sampled share of a rule's drops.
pub fn explored(key: &str, percent: f64) -> bool {
if percent <= 0.0 {
return false;
}
let d = Md5::digest(key.as_bytes());
let n = u32::from_be_bytes([d[0], d[1], d[2], d[3]]);
((n % 10_000) as f64) < percent * 100.0
}
static SIDECAR: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.(par2|nfo|sfv|txt|jpe?g|png|nzb)$"));
static EXECUTABLE: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\.(exe|scr|com|pif|bat|cmd|vbs|lnk)SP*$"));
pub struct JudgedFile<'a> {
pub name: &'a str,
pub total_parts: u32,
pub groups: &'a [String],
pub first_message_id: &'a str,
}
pub enum Verdict {
Keep,
Drop { rule: &'static str, key: String, sample: bool },
}
/// RuleSet.judge with DEFAULT_RULES (no dropped signatures).
pub fn judge(release_key: &str, files: &[JudgedFile]) -> Verdict {
const DROPPED_TOOLS: [&str; 5] = ["ngPost", "hex32", "yuun", "JBinUp", "random"];
let sample = explored(release_key, 1.0);
let content: Vec<&JudgedFile> = files.iter().filter(|f| !test(&SIDECAR, f.name)).collect();
if !content.is_empty() && content.iter().all(|f| test(&EXECUTABLE, f.name)) {
let c = EXECUTABLE.captures(content[0].name.as_bytes()).unwrap();
let key = std::str::from_utf8(&c[1]).unwrap().to_lowercase();
return Verdict::Drop { rule: "executable", key, sample };
}
if files.iter().any(|f| !is_random_file(f.name)) {
return Verdict::Keep;
}
let first = &files[0];
let tool = upload_tool(first.first_message_id);
let group = first.groups.first().map(String::as_str).unwrap_or("");
if DROPPED_TOOLS.contains(&tool) {
return Verdict::Drop { rule: "garbage", key: format!("{tool} {group}"), sample };
}
if files.iter().all(|f| f.total_parts <= 3) {
return Verdict::Drop { rule: "per-article", key: tool.to_string(), sample };
}
Verdict::Keep
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stems() {
assert_eq!(file_stem("x.mkv.vol03+04.par2"), "x");
assert_eq!(file_stem("x.part07.rar"), "x");
assert_eq!(file_stem("Movie.H.264.mkv.001"), "Movie.H.264");
assert_eq!(file_stem("a.7z.002"), "a");
}
#[test]
fn remainders() {
assert_eq!(subject_remainder(r#"Some.Release [01/45] - "x.rar" yEnc (1/50) 7168"#), "Some Release");
assert!(names_release("Some Release"));
assert!(!names_release("elys de81bcabef5faf6a"));
}
#[test]
fn by_hand_as_the_regexes() {
let names = [
"x.mkv.vol03+04.par2", "x.part07.rar", "Movie.H.264.mkv.001", "a.7z.002", "a.7z", "b.zip.001", "B.ZIP", "c.r00", "c.r1234",
"d.vol1+2.par2", "d.vol+2.par2", ".par2", "e.tar.gz", "f.mkv", "g.m3u8", "h.bin.001", " spaced.rar ", "i.PART01.RAR", "j.part.rar",
"k.nfo.txt", "noext", "ünï.cödé.mkv.001", "l.ts.123", "m.r12.par2", "n.vol12-34.PAR2", "o.7z.1234", "p.123", "q.r1", ".rar.rar",
];
for n in names {
assert_eq!(file_stem(n), file_stem_re(n), "{n}");
}
let subjects = [
r#"Some.Release [01/45] - "x.rar" yEnc (1/50) 7168"#,
r#"[1/3] - "a.par2" yEnc (1/1) 123 "#,
r#"yEnc "x" yenc yEncx (1/2)"#,
r#"Show – S01E01 | 1080p : x "a" "b"#,
r#"name (12/3x) [4/] "q" YENC 12 34"#,
" lead\u{a0}nbsp yEnc\u{3000}99 ",
r#"elys-de81bcabef5faf6a - [Nyh5Q07Y] - "x.mkv" yEnc"#,
"a_yEnc b yEnc_c yEnc",
"yEnc yEnc 5",
"x yEnc 12 yEnc",
"",
];
for s in subjects {
assert_eq!(subject_remainder(s), subject_remainder_re(s), "{s}");
}
}
#[test]
fn tools_by_hand_as_the_regexes() {
let ids = [
"FmFwIaTpGgUiSfYcMwFlMeRo-1790637260478@nyuu", "FmFwIaTpGgUiSfYcMwFlMeRo-1790637260478@", "FmFwIaTpGgUiSfYcMwFlMeRo-1790637260478@news.example",
"b94268bc702040f7b811d5318f727e4f@ngPost", "b94268bc702040f7b811d5318f727e4f@b94268bc702040f7b811d5318f727e4f.yuun", "b94268bc702040f7b811d5318f727e4f@host-1.example",
"B94268bc702040f7b811d5318f727e4f@ngPost", "x@JBinUp.local", "y@JBinDown.local", "part1of69.lOlWzvSHabJCBTVrs0ts@AdultPoster.local", "PART12OF3.x",
"1790923856877$1729905757859$1@localhost", "179092385687$1$1@localhost", "t3Y4mV92YCp0McxDc5@zLZlsw.RYqes", "t3Y4mV92YC@zL.RYqes", "t3Y4mV92YCp0McxDc5@zLZlsw.RY.es",
"", "a@b", "b94268bc702040f7b811d5318f727e4f@", "b94268bc702040f7b811d5318f727e4f@host_x",
];
for id in ids {
assert_eq!(upload_tool(id), upload_tool_re(id), "{id}");
}
for t in ["Some Release", "a b", "ab-cd", "ab - c1", "ab.c", "x ab_12", "12 ab", "ab "] {
assert_eq!(name_like(t.as_bytes()), test(&NAME_LIKE, t), "{t}");
}
for t in ["[01/45] x", "x [1/] [2/30]", "[a/1] [3/4]", "no counter"] {
let re = FILE_COUNTER.captures(t.as_bytes()).and_then(|c| js::parse_int(std::str::from_utf8(&c[2]).unwrap()));
assert_eq!(file_counter_total(t), re, "{t}");
}
}
#[test]
fn random_names() {
assert!(looks_random("prvjr7hapd0i1ny14pz4"));
assert!(looks_random("RmISakytFwXSCEzjVEpaKk"));
assert!(!looks_random("Some.Show.S01E01.1080p"));
assert!(looks_random("VIDEO_TS"));
}
#[test]
fn tools() {
assert_eq!(upload_tool("FmFwIaTpGgUiSfYcMwFlMeRo-1790637260478@nyuu"), "nyuu");
assert_eq!(upload_tool("b94268bc702040f7b811d5318f727e4f@ngPost"), "ngPost");
assert_eq!(upload_tool("x@y"), "other");
}
}