diff --git a/tools/edge-bench-rs/src/js.rs b/tools/edge-bench-rs/src/js.rs index 8c31083..7a56d2a 100644 --- a/tools/edge-bench-rs/src/js.rs +++ b/tools/edge-bench-rs/src/js.rs @@ -93,9 +93,85 @@ pub fn parse_int(s: &str) -> Option { } } +/// Days since 1970-01-01 of a proleptic Gregorian date (month 1..=12; a day past the month's end runs on, as in JS). +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = y.div_euclid(400); + let yoe = y - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +/// The usual Date headers: [Day, ]D[D] Mon YY[YY] HH:MM:SS (GMT|UTC|UT|±hhmm). None for anything else. +fn fast_date(s: &[u8]) -> Option { + let mut i = 0; + if s.len() > 4 && s[3] == b',' && s[..3].iter().all(u8::is_ascii_alphabetic) { + i = 4; + while s.get(i) == Some(&b' ') { + i += 1; + } + } + let num = |i: &mut usize, min: usize, max: usize| -> Option { + let start = *i; + while *i < s.len() && s[*i].is_ascii_digit() && *i - start < max { + *i += 1; + } + if *i - start < min || s.get(*i).is_some_and(u8::is_ascii_digit) { + return None; + } + Some(std::str::from_utf8(&s[start..*i]).ok()?.parse().ok()?) + }; + let expect = |i: &mut usize, c: u8| -> Option<()> { + (s.get(*i) == Some(&c)).then(|| *i += 1) + }; + let day = num(&mut i, 1, 2)?; + expect(&mut i, b' ')?; + const MONTHS: [&[u8]; 12] = [b"jan", b"feb", b"mar", b"apr", b"may", b"jun", b"jul", b"aug", b"sep", b"oct", b"nov", b"dec"]; + let mon = s.get(i..i + 3)?; + let month = MONTHS.iter().position(|m| mon.iter().zip(m.iter()).all(|(a, b)| a.to_ascii_lowercase() == *b))? as i64 + 1; + i += 3; + expect(&mut i, b' ')?; + let year_start = i; + let mut year = num(&mut i, 2, 4)?; + match i - year_start { + 2 => year += if year < 50 { 2000 } else { 1900 }, + 4 => {} + _ => return None, + } + expect(&mut i, b' ')?; + let hour = num(&mut i, 2, 2)?; + expect(&mut i, b':')?; + let minute = num(&mut i, 2, 2)?; + expect(&mut i, b':')?; + let second = num(&mut i, 2, 2)?; + expect(&mut i, b' ')?; + if day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59 { + return None; + } + let offset_minutes = match &s[i..] { + b"GMT" | b"UTC" | b"UT" => 0, + [sign @ (b'+' | b'-'), h1, h2, m1, m2] if [h1, h2, m1, m2].iter().all(|c| c.is_ascii_digit()) => { + let minutes = ((h1 - b'0') as i64 * 10 + (h2 - b'0') as i64) * 60 + (m1 - b'0') as i64 * 10 + (m2 - b'0') as i64; + if *sign == b'+' { minutes } else { -minutes } + } + _ => return None, + }; + let days = days_from_civil(year, month, day); + Some((((days * 24 + hour) * 60 + minute - offset_minutes) * 60 + second) * 1000) +} + /// `Date.parse` for the Date header (RFC 5322 and the obsolete forms); None for NaN. pub fn parse_date(s: &str) -> Option { let s = trim(s); + if let Some(t) = fast_date(s.as_bytes()) { + return Some(t); + } + parse_date_chrono(s) +} + +fn parse_date_chrono(s: &str) -> Option { // "UTC" isn't an RFC 5322 zone (UT and GMT are), but Date.parse takes it (~a quarter of a.b.boneless). let owned; let s = match s.strip_suffix(" UTC") { @@ -170,5 +246,12 @@ mod tests { assert_eq!(parse_date("Mon, 05 Oct 2026 10:00:00 +0000"), Some(1_791_194_400_000)); assert_eq!(parse_date("5 Oct 2026 10:00:00 GMT"), Some(1_791_194_400_000)); assert_eq!(parse_date("Mon, 05 Oct 26 10:00:00 UTC"), Some(1_791_194_400_000)); + for d in [ + "Mon, 05 Oct 2026 10:00:00 +0000", "Mon, 5 Oct 2026 12:00:00 +0200", "05 Oct 2026 10:00:00 GMT", "Thu, 01 Jan 1970 00:00:00 GMT", + "Sat, 29 Feb 2020 23:59:59 -0130", "Fri, 31 Dec 99 00:00:00 UTC", "Mon, 05 Oct 2026 10:00:00 UT", + ] { + assert_eq!(fast_date(d.as_bytes()), parse_date_chrono(d), "{d}"); + } + assert_eq!(fast_date(b"Mon, 05 Oct 2026 10:00:00 EST"), None); } } diff --git a/tools/edge-bench-rs/src/names.rs b/tools/edge-bench-rs/src/names.rs index afbd3c9..5be17ea 100644 --- a/tools/edge-bench-rs/src/names.rs +++ b/tools/edge-bench-rs/src/names.rs @@ -318,11 +318,12 @@ pub fn is_post_token(remainder: &str) -> bool { (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 = 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(); - test(&NAME_LIKE, &words.join(" ")) + name_like(words.join(" ").as_bytes()) } #[derive(Debug, Clone)] @@ -340,6 +341,7 @@ impl Release { } } +#[allow(dead_code)] static FILE_COUNTER: LazyLock = LazyLock::new(|| re(r"\[([0-9]+)/([0-9]+)\]")); fn hash(parts: &[&str]) -> String { @@ -348,9 +350,7 @@ fn hash(parts: &[&str]) -> String { /// 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 - .captures(subject.as_bytes()) - .and_then(|c| js::parse_int(std::str::from_utf8(&c[2]).unwrap())); + let file_total = file_counter_total(subject); let stem = file_stem(name); let remainder = subject_remainder(subject); if names_release(&remainder) { @@ -480,7 +480,9 @@ static TOOLS: LazyLock> = LazyLock::new(|| { ] }); -pub fn upload_tool(message_id: &str) -> &'static str { +/// 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"; } @@ -492,6 +494,116 @@ pub fn upload_tool(message_id: &str) -> &'static str { "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 { + 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 { @@ -589,6 +701,27 @@ mod tests { } } + #[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"));