inkly-core 0.1.0

Core PII detection engine for Inkly SecureText
Documentation
use regex_lite::Regex;
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;

// Initialize panic hook for better error messages
#[wasm_bindgen(start)]
pub fn start() {
    console_error_panic_hook::set_once();
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Span {
    pub start: usize,
    pub end: usize,
    pub type_: String,
}

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(typescript_type = "Rule[]")]
    pub type RuleArray;
}

#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "rule_type", rename_all = "camelCase")]
pub enum Rule {
    #[serde(rename = "pii.email")]
    Email,
    #[serde(rename = "pii.ssn")]
    Ssn,
    #[serde(rename = "pii.credit_card")]
    CreditCard,
    #[serde(rename = "pii.phone")]
    Phone,
    #[serde(rename = "pii.id.somali_nid")]
    SomaliNid,
    #[serde(rename_all = "camelCase")]
    Custom { type_: String, regex: String },
}

impl Rule {
    fn get_type(&self) -> String {
        match self {
            Rule::Email => "pii.email".to_string(),
            Rule::Ssn => "pii.ssn".to_string(),
            Rule::CreditCard => "pii.credit_card".to_string(),
            Rule::Phone => "pii.phone".to_string(),
            Rule::SomaliNid => "pii.id.somali_nid".to_string(),
            Rule::Custom { type_, .. } => type_.clone(),
        }
    }

    fn get_regex(&self) -> Regex {
        match self {
            Rule::Email => {
                Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}").unwrap()
            }
            Rule::Ssn => Regex::new(r"\b\d{3}[-]?\d{2}[-]?\d{4}\b").unwrap(),
            Rule::CreditCard => {
                Regex::new(r"\b(?:\d[ -]*?){13,16}\b").unwrap() // Simplified for WASM size
            }
            Rule::Phone => Regex::new(r"\b(?:\+\d{1,3}[-\s]?)?\(?\d{3}\)?[-\s]?\d{3}[-\s]?\d{4}\b").unwrap(),
            Rule::SomaliNid => Regex::new(r"\b[A-Z]{2}\d{7}\b").unwrap(), // Format: XX1234567
            Rule::Custom { regex, .. } => Regex::new(regex).unwrap_or_else(|_| Regex::new(r"a^").unwrap()), // Never matches if invalid
        }
    }
}

#[wasm_bindgen]
pub fn find_pii(text: &str, rules_js: RuleArray) -> Result<JsValue, JsValue> {
    let rules: Vec<Rule> = serde_wasm_bindgen::from_value(rules_js.into())?;
    let mut spans = Vec::new();

    for rule in rules {
        let rule_type = rule.get_type();
        let regex = rule.get_regex();

        for mat in regex.find_iter(text) {
            spans.push(Span {
                start: mat.start(),
                end: mat.end(),
                type_: rule_type.clone(),
            });
        }
    }

    // Sort spans by start position
    spans.sort_by_key(|span| span.start);

    // Convert to JS
    Ok(serde_wasm_bindgen::to_value(&spans)?)
}

// Helper function to check if a credit card number passes Luhn algorithm
// Not exposed to JS to keep WASM size small
fn is_valid_credit_card(number: &str) -> bool {
    let digits: Vec<u32> = number
        .chars()
        .filter(|c| c.is_digit(10))
        .map(|c| c.to_digit(10).unwrap())
        .collect();

    if digits.len() < 13 || digits.len() > 19 {
        return false;
    }

    let mut sum = 0;
    let mut double = false;

    for &digit in digits.iter().rev() {
        let mut value = digit;
        if double {
            value *= 2;
            if value > 9 {
                value -= 9;
            }
        }
        sum += value;
        double = !double;
    }

    sum % 10 == 0
}

// Include tests module
#[cfg(test)]
mod tests;