Files
AFFiNE/packages/backend/native/src/content_policy.rs
2026-07-07 08:38:16 +08:00

334 lines
8.5 KiB
Rust
Raw Permalink 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.
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<Vec<String>>,
}
#[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<String>,
pub span: Option<ContentPolicyMatchSpan>,
}
#[napi(object)]
pub struct ContentPolicyScanResult {
pub version: u32,
pub original: String,
pub normalized: String,
pub skeleton: String,
pub matched: bool,
pub matches: Vec<ContentPolicyMatch>,
pub flags: Vec<String>,
}
#[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::<String>()
}
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::<String>();
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<ContentPolicyMatch> {
let mut matches = Vec::new();
let chars = value.char_indices().collect::<Vec<_>>();
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::<Vec<_>>();
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
);
}
}
}