use napi_derive::napi; use unicode_normalization::UnicodeNormalization; use unicode_skeleton::UnicodeSkeleton; const VERSION: u32 = 1; #[napi(object)] pub struct ContentPolicyScanInput { pub value: String, pub checks: Option>, } #[napi(object)] pub struct ContentPolicyMatchSpan { pub start: u32, pub end: u32, } #[napi(object)] pub struct ContentPolicyMatch { #[napi(js_name = "type")] pub match_type: String, pub reason: String, pub value: Option, pub span: Option, } #[napi(object)] pub struct ContentPolicyScanResult { pub version: u32, pub original: String, pub normalized: String, pub skeleton: String, pub matched: bool, pub matches: Vec, pub flags: Vec, } #[napi] pub fn scan_content_policy_v1(input: ContentPolicyScanInput) -> ContentPolicyScanResult { let original = input.value; let normalized = normalize_content(&original); let skeleton = skeleton_content(&normalized); let mut flags = Vec::new(); if normalized != original { flags.push("unicode_normalized".to_string()); } if skeleton != normalized { flags.push("confusable_skeleton_changed".to_string()); } let mut matches = Vec::new(); if should_run_check(input.checks.as_deref(), "url_or_domain") { matches.extend(scan_url_or_domain(&skeleton)); } ContentPolicyScanResult { version: VERSION, original, normalized, skeleton, matched: !matches.is_empty(), matches, flags, } } fn should_run_check(checks: Option<&[String]>, check: &str) -> bool { checks .map(|checks| checks.iter().any(|configured| configured == check)) .unwrap_or(true) } fn normalize_content(value: &str) -> String { value.nfkc().collect::() } fn skeleton_content(value: &str) -> String { let mut output = String::with_capacity(value.len()); for ch in value.chars() { push_skeleton_char(&mut output, ch); } output } fn push_skeleton_char(output: &mut String, ch: char) { if is_default_ignorable(ch) { return; } if let Some(replacement) = url_punctuation_ascii(ch).or_else(|| enclosed_ascii(ch)) { output.push_str(replacement); return; } if ch.is_ascii() { output.extend(ch.to_lowercase()); return; } let source = ch.to_string(); let skeleton = source.skeleton_chars().collect::(); if skeleton == source { output.extend(ch.to_lowercase()); return; } for skeleton_ch in skeleton.chars() { push_skeleton_char(output, skeleton_ch); } } fn is_default_ignorable(ch: char) -> bool { matches!( ch, '\u{00ad}' | '\u{034f}' | '\u{061c}' | '\u{180e}' | '\u{200b}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2060}'..='\u{206f}' | '\u{fe00}'..='\u{fe0f}' | '\u{feff}' | '\u{e0100}'..='\u{e01ef}' ) } fn enclosed_ascii(ch: char) -> Option<&'static str> { let code = ch as u32; if (0x1f150..=0x1f169).contains(&code) { return ASCII_LOWERCASE.get((code - 0x1f150) as usize).copied(); } if ch == '⓿' || ch == '🄌' { return Some("0"); } for (start, digits) in [ (0x2776, ASCII_DIGITS_1_TO_9), (0x2780, ASCII_DIGITS_1_TO_9), (0x278a, ASCII_DIGITS_1_TO_9), ] { if (start..=start + 8).contains(&code) { return digits.get((code - start) as usize).copied(); } } None } const ASCII_LOWERCASE: [&str; 26] = [ "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", ]; const ASCII_DIGITS_1_TO_9: [&str; 9] = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]; fn url_punctuation_ascii(ch: char) -> Option<&'static str> { match ch { // URL punctuation variants that are not always useful in generic skeletons. '。' | '.' | '。' | '․' | '‧' | '∙' | '●' | '•' => Some("."), '⁄' | '∕' | '╱' | '/' => Some("/"), ':' | '꞉' | 'ː' | '։' => Some(":"), '‐' | '‑' | '‒' | '–' | '—' | '―' | '−' | '﹘' | '-' => Some("-"), '_' | '_' => Some("_"), _ => None, } } fn scan_url_or_domain(value: &str) -> Vec { let mut matches = Vec::new(); let chars = value.char_indices().collect::>(); let mut index = 0; while index < chars.len() { let start = chars[index].0; let remaining = &value[start..]; if remaining.starts_with("http://") || remaining.starts_with("https://") || remaining.starts_with("www.") { let end = consume_non_space(value, start); matches.push(match_result(value, start, end)); index = char_index_after(&chars, end); continue; } if is_domain_boundary_before(value, start) { let end = consume_domain(value, start); if end > start && is_domain_candidate(&value[start..end]) && is_domain_boundary_after(value, end) { matches.push(match_result(value, start, end)); index = char_index_after(&chars, end); continue; } } index += 1; } matches } fn match_result(value: &str, start: usize, end: usize) -> ContentPolicyMatch { ContentPolicyMatch { match_type: "url_or_domain".to_string(), reason: "contains_url_or_domain".to_string(), value: Some(value[start..end].to_string()), span: Some(ContentPolicyMatchSpan { start: start as u32, end: end as u32, }), } } fn consume_non_space(value: &str, start: usize) -> usize { value[start..] .char_indices() .find_map(|(offset, ch)| ch.is_whitespace().then_some(start + offset)) .unwrap_or(value.len()) } fn consume_domain(value: &str, start: usize) -> usize { value[start..] .char_indices() .find_map(|(offset, ch)| (!is_domain_char(ch)).then_some(start + offset)) .unwrap_or(value.len()) } fn is_domain_candidate(candidate: &str) -> bool { let labels = candidate.split('.').collect::>(); if labels.len() < 2 { return false; } let Some(tld) = labels.last() else { return false; }; if !(2..=63).contains(&tld.len()) || !tld.chars().all(|ch| ch.is_ascii_alphabetic()) { return false; } labels.iter().all(|label| { !label.is_empty() && label.len() <= 63 && !label.starts_with('-') && !label.ends_with('-') && label.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-') }) } fn is_domain_char(ch: char) -> bool { ch.is_ascii_alphanumeric() || ch == '-' || ch == '.' } fn is_domain_boundary_before(value: &str, start: usize) -> bool { if start == 0 { return true; } let Some(previous) = value[..start].chars().next_back() else { return true; }; !(previous.is_ascii_alphanumeric() || previous == '-' || previous == '_' || previous == '@') } fn is_domain_boundary_after(value: &str, end: usize) -> bool { if end >= value.len() { return true; } let Some(next) = value[end..].chars().next() else { return true; }; !(next.is_ascii_alphanumeric() || next == '-' || next == '_' || next == '.') } fn char_index_after(chars: &[(usize, char)], byte_index: usize) -> usize { chars .iter() .position(|(offset, _)| *offset >= byte_index) .unwrap_or(chars.len()) } #[cfg(test)] mod tests { use super::*; fn scan(value: &str) -> ContentPolicyScanResult { scan_content_policy_v1(ContentPolicyScanInput { value: value.to_string(), checks: None, }) } #[test] fn scans_url_or_domain_cases() { struct Case { input: &'static str, matched: bool, skeleton: &'static str, value: Option<&'static str>, } for case in [ Case { input: "https://example.com", matched: true, skeleton: "https://example.com", value: Some("https://example.com"), }, Case { input: "join 🅠⓿❶.example", matched: true, skeleton: "join q01.example", value: Some("q01.example"), }, Case { input: "exa\u{200b}mple.com", matched: true, skeleton: "example.com", value: Some("example.com"), }, Case { input: "hello workspace", matched: false, skeleton: "hello workspace", value: None, }, Case { input: "user@example.com", matched: false, skeleton: "user@example.com", value: None, }, ] { let result = scan(case.input); assert_eq!(result.matched, case.matched, "{}", case.input); assert_eq!(result.skeleton, case.skeleton, "{}", case.input); assert_eq!( result.matches.first().and_then(|matched| matched.value.as_deref()), case.value, "{}", case.input ); } } }