vector_core/community/policy/
matchers.rs1use super::document::{ExemptKind, ExemptPatterns};
10use super::normalize::is_token_char;
11use super::types::Span;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
15pub struct Hit {
16 pub start: usize,
17 pub end: usize,
18}
19
20impl Hit {
21 pub fn span(self) -> Span {
22 Span { start: self.start as u32, end: self.end as u32 }
23 }
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
30struct Keyword {
31 needle: String,
32 open_left: bool,
33 open_right: bool,
34}
35
36fn parse_keyword(pattern: &str) -> Keyword {
37 let mut s = pattern;
38 let mut open_left = false;
39 let mut open_right = false;
40 if let Some(rest) = s.strip_prefix('*') {
41 open_left = true;
42 s = rest;
43 }
44 if s.ends_with('*') && !s.ends_with("\\*") {
45 open_right = true;
46 s = &s[..s.len() - 1];
47 }
48 Keyword { needle: s.replace("\\*", "*"), open_left, open_right }
49}
50
51pub fn keyword_hits(text: &str, patterns: &[String]) -> Vec<Hit> {
55 let mut kws: Vec<Keyword> = patterns.iter().map(|p| parse_keyword(p)).filter(|k| !k.needle.is_empty()).collect();
56 kws.sort_by(|a, b| a.needle.cmp(&b.needle));
57 let bytes = text.as_bytes();
58 let mut hits: Vec<Hit> = Vec::new();
59 let mut cursor = 0usize;
60 while cursor < text.len() {
61 let mut best: Option<Hit> = None;
62 for k in &kws {
63 let mut from = cursor;
64 while let Some(rel) = text[from..].find(&k.needle) {
65 let start = from + rel;
66 let end = start + k.needle.len();
67 let left_ok = k.open_left || start == 0 || !is_token_char(prev_char(text, start));
68 let right_ok = k.open_right || end == text.len() || !is_token_char(next_char(text, end));
69 if left_ok && right_ok {
70 let candidate = Hit { start, end };
73 best = match best {
74 Some(b) if (b.start, std::cmp::Reverse(b.end)) <= (candidate.start, std::cmp::Reverse(candidate.end)) => Some(b),
75 _ => Some(candidate),
76 };
77 break;
78 }
79 let step = next_char_boundary(bytes, start);
80 if step <= from {
81 break;
82 }
83 from = step;
84 }
85 }
86 match best {
87 Some(h) => {
88 cursor = h.end.max(next_char_boundary(bytes, h.start));
89 hits.push(h);
90 }
91 None => break,
92 }
93 }
94 hits.sort();
95 hits
96}
97
98fn prev_char(text: &str, at: usize) -> char {
99 text[..at].chars().next_back().unwrap_or(' ')
100}
101
102fn next_char(text: &str, at: usize) -> char {
103 text[at..].chars().next().unwrap_or(' ')
104}
105
106fn next_char_boundary(bytes: &[u8], from: usize) -> usize {
107 let mut i = from + 1;
108 while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
109 i += 1;
110 }
111 i
112}
113
114pub fn cancel_exempt_hits(text: &str, hits: Vec<Hit>, exempts: &[&ExemptPatterns]) -> Vec<Hit> {
118 let mut covers: Vec<Hit> = Vec::new();
119 for e in exempts {
120 match e.kind {
121 Some(ExemptKind::Literal) | None => {
122 for v in &e.values {
123 let mut from = 0usize;
124 while let Some(rel) = text[from..].find(v.as_str()) {
125 let start = from + rel;
126 covers.push(Hit { start, end: start + v.len() });
127 from = start + v.len().max(1);
128 }
129 }
130 }
131 Some(ExemptKind::Wildcard) => {
132 for v in &e.values {
133 covers.extend(keyword_hits(text, std::slice::from_ref(v)));
134 }
135 }
136 Some(ExemptKind::Domain) => {}
139 }
140 }
141 hits.into_iter().filter(|h| !covers.iter().any(|c| c.start <= h.start && c.end >= h.end)).collect()
142}
143
144pub fn extract_domains(text: &str) -> Vec<String> {
151 let mut out: Vec<String> = Vec::new();
152 for raw in text.split(|c: char| c.is_whitespace() || c == '<' || c == '>' || c == '"' || c == '(' || c == ')') {
153 let token = raw.trim_end_matches(|c| ".,;:!?)]}'\"".contains(c));
154 if token.is_empty() {
155 continue;
156 }
157 let lowered = token.to_lowercase();
158 let after_scheme = match lowered.split_once("://") {
159 Some((scheme, rest)) => {
160 if scheme != "http" && scheme != "https" {
161 continue;
162 }
163 rest
164 }
165 None => lowered.as_str(),
166 };
167 let hostport = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
170 let host = hostport.rsplit_once('@').map(|(_, h)| h).unwrap_or(hostport);
171 let host = host.split(':').next().unwrap_or("").trim_end_matches('.');
172 if host.is_empty() || !host.contains('.') {
173 continue;
174 }
175 if !host.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
176 continue;
177 }
178 let d = registrable_domain(host);
179 if !d.is_empty() && !out.contains(&d) {
180 out.push(d);
181 }
182 }
183 out
184}
185
186fn registrable_domain(host: &str) -> String {
194 const MULTI: &[&str] = &[
195 "co.uk", "org.uk", "ac.uk", "gov.uk", "co.jp", "or.jp", "ne.jp", "com.au", "net.au", "org.au", "co.nz",
196 "com.br", "com.cn", "com.mx", "co.za", "co.in", "co.kr", "github.io", "gitlab.io", "pages.dev",
197 "workers.dev", "vercel.app", "netlify.app", "herokuapp.com", "s3.amazonaws.com",
198 ];
199 let labels: Vec<&str> = host.split('.').collect();
200 if labels.len() < 2 {
201 return host.to_string();
202 }
203 let last2 = labels[labels.len() - 2..].join(".");
204 if MULTI.contains(&last2.as_str()) && labels.len() >= 3 {
205 return labels[labels.len() - 3..].join(".");
206 }
207 last2
208}
209
210pub fn link_hits(text: &str, patterns: &[String], exempts: &[&ExemptPatterns]) -> Vec<String> {
212 let allow: Vec<String> = exempts
213 .iter()
214 .filter(|e| matches!(e.kind, Some(ExemptKind::Domain) | Some(ExemptKind::Literal) | None))
215 .flat_map(|e| e.values.iter().map(|v| registrable_domain(&v.to_lowercase())))
216 .collect();
217 extract_domains(text)
218 .into_iter()
219 .filter(|d| {
220 if allow.iter().any(|a| a == d) {
223 return false;
224 }
225 patterns.is_empty() || patterns.iter().any(|p| registrable_domain(&p.to_lowercase()) == *d)
226 })
227 .collect()
228}
229
230pub fn count_mentions(text: &str) -> u32 {
243 let mut seen: Vec<&str> = Vec::new();
244 for npub in crate::types::extract_mentions(text) {
245 if seen.contains(&npub) {
246 continue;
247 }
248 if text.match_indices(npub).any(|(at, _)| !is_linked(text, at)) {
251 seen.push(npub);
252 }
253 }
254 seen.len() as u32
255}
256
257fn is_linked(text: &str, at: usize) -> bool {
264 let bytes = text.as_bytes();
265 let mut lead = at;
266 if lead >= 1 && bytes[lead - 1] == b'@' {
267 lead -= 1;
268 } else if lead >= 6 && text.get(lead - 6..lead).is_some_and(|p| p.eq_ignore_ascii_case("nostr:")) {
269 lead -= 6;
270 }
271 if lead == 0 {
272 return false;
273 }
274 let prev = text[..lead].chars().next_back().unwrap_or(' ');
275 matches!(prev, '/' | '=' | '?' | '&' | '#' | '%' | '.' | '-' | '_')
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 fn pats(v: &[&str]) -> Vec<String> {
283 v.iter().map(|s| s.to_string()).collect()
284 }
285
286 #[test]
287 fn bare_patterns_are_token_anchored() {
288 assert_eq!(keyword_hits("classic assassin", &pats(&["ass"])).len(), 0);
290 assert_eq!(keyword_hits("what an ass", &pats(&["ass"])).len(), 1);
291 assert_eq!(keyword_hits("beta. beta_x", &pats(&["beta"])).len(), 2);
293 assert_eq!(keyword_hits("beta2", &pats(&["beta"])).len(), 0);
294 }
295
296 #[test]
297 fn wildcards_relax_the_edges() {
298 assert_eq!(keyword_hits("scammer", &pats(&["scam*"])).len(), 1);
299 assert_eq!(keyword_hits("scammer", &pats(&["*scam"])).len(), 0, "left-open still anchors the right");
300 assert_eq!(keyword_hits("descammer", &pats(&["*scam*"])).len(), 1);
301 assert_eq!(keyword_hits("buy * now", &pats(&["\\*"])).len(), 1);
303 }
304
305 #[test]
306 fn one_span_is_one_hit_regardless_of_pattern_count() {
307 let hits = keyword_hits("free airdrop free airdrop", &pats(&["free", "airdrop"]));
308 assert_eq!(hits.len(), 4, "four distinct spans");
309 let hits = keyword_hits("*scam*", &pats(&["*scam*", "*sca*", "*cam*"]));
311 assert_eq!(hits.len(), 1);
312 }
313
314 #[test]
315 fn declaration_order_never_decides_a_span() {
316 let a = keyword_hits("free airdrop", &pats(&["free", "airdrop"]));
317 let b = keyword_hits("free airdrop", &pats(&["airdrop", "free"]));
318 assert_eq!(a, b);
319 }
320
321 #[test]
322 fn exemptions_cancel_by_containment() {
323 let text = "join the free airdrop channel";
324 let hits = keyword_hits(text, &pats(&["airdrop"]));
325 assert_eq!(hits.len(), 1);
326 let ex = ExemptPatterns { kind: Some(ExemptKind::Literal), values: pats(&["free airdrop"]) };
327 assert!(cancel_exempt_hits(text, hits, &[&ex]).is_empty(), "an exempt phrase covers the hit inside it");
328 }
329
330 #[test]
331 fn domains_anchor_on_the_registrable_name() {
332 assert_eq!(extract_domains("see https://vectorapp.io/x"), vec!["vectorapp.io"]);
333 assert_eq!(extract_domains("https://vectorapp.io.evil.com/a"), vec!["evil.com"]);
335 assert_eq!(extract_domains("https://evil.com/vectorapp.io"), vec!["evil.com"]);
336 assert_eq!(extract_domains("https://vectorapp.io@evil.com/"), vec!["evil.com"]);
338 assert_eq!(extract_domains("go to bit.ly:8080, now"), vec!["bit.ly"]);
340 assert_eq!(extract_domains("https://foo.github.io/p"), vec!["foo.github.io"]);
342 assert_eq!(extract_domains("https://a.b.co.uk/p"), vec!["b.co.uk"]);
343 assert!(extract_domains("ftp://x.com nothing here").is_empty());
345 }
346
347 #[test]
348 fn link_allowlists_cancel_by_equality_only() {
349 let allow = ExemptPatterns { kind: Some(ExemptKind::Domain), values: pats(&["vectorapp.io"]) };
350 assert!(link_hits("https://vectorapp.io/a", &[], &[&allow]).is_empty());
351 let near = ExemptPatterns { kind: Some(ExemptKind::Domain), values: pats(&["app.io"]) };
353 assert_eq!(link_hits("https://myapp.io/a", &[], &[&near]), vec!["myapp.io"]);
354 let hits = link_hits("a bit.ly/x and bit.ly/y and tr.ee/z", &[], &[]);
356 assert_eq!(hits, vec!["bit.ly", "tr.ee"]);
357 }
358
359 #[test]
360 fn a_denylist_matches_only_its_own_domains() {
361 let deny = pats(&["bit.ly", "tr.ee"]);
362 assert_eq!(link_hits("see bit.ly/x", &deny, &[]), vec!["bit.ly"]);
363 assert!(link_hits("see github.com/x", &deny, &[]).is_empty());
364 }
365
366 fn npub_of(c: char) -> String {
369 format!("npub1{}", std::iter::repeat(c).take(58).collect::<String>())
370 }
371
372 #[test]
373 fn a_mention_is_an_npub_in_the_body_however_it_is_written() {
374 let a = npub_of('q');
375 assert_eq!(count_mentions(&format!("hey @{a} look")), 1);
376 assert_eq!(count_mentions(&format!("hey nostr:{a} look")), 1);
377 assert_eq!(count_mentions(&format!("hey {a} look")), 1);
378 }
379
380 #[test]
381 fn naming_one_person_twice_is_still_one_person() {
382 let a = npub_of('q');
383 let b = npub_of('z');
384 assert_eq!(count_mentions(&format!("@{a} and again @{a}")), 1);
385 assert_eq!(count_mentions(&format!("@{a} and @{b}")), 2);
386 }
387
388 #[test]
390 fn a_mass_ping_counts_everyone_it_named() {
391 let a = npub_of('q');
392 let b = npub_of('z');
393 assert_eq!(count_mentions(&format!("@{a} @{b} @{a} ping")), 2);
394 }
395
396 #[test]
399 fn an_npub_inside_a_url_or_a_word_is_not_a_mention() {
400 let a = npub_of('q');
401 assert_eq!(count_mentions(&format!("https://vectorapp.io/profile/{a}")), 0);
402 assert_eq!(count_mentions(&format!("x{a}")), 0);
403 assert_eq!(count_mentions(&format!("?p={a}")), 0);
404 }
405
406 #[test]
407 fn a_malformed_npub_is_not_a_mention() {
408 assert_eq!(count_mentions("npub1short"), 0);
409 assert_eq!(count_mentions(&format!("{}q", npub_of('q'))), 0);
411 }
412
413 #[test]
414 fn plain_text_names_nobody() {
415 assert_eq!(count_mentions("just a normal message @everyone"), 0);
416 }
417}