linkshieldai 0.2.0

Rust SDK for the LinkShieldAI URL safety API.
Documentation
use serde_json::Value;

use crate::types::{
    BasicCheckResult, ChimeraResult, DetailedCheckResult, NsfwCheckResult, RawJson,
};

pub(crate) fn ensure_payload(value: Value) -> crate::Result<RawJson> {
    let payload = match value {
        Value::Object(map) => map,
        other => {
            return Err(crate::LinkShieldAIError::Response {
                message: "LinkShieldAI API returned a non-object JSON response.".to_string(),
                payload: Some(other),
            });
        }
    };

    if payload.get("Error").is_some() || payload.get("error").is_some() {
        return Err(crate::LinkShieldAIError::Response {
            message: error_message_from_payload(&payload, "LinkShieldAI API request failed."),
            payload: Some(Value::Object(payload)),
        });
    }

    Ok(payload)
}

pub(crate) fn parse_basic(payload: RawJson) -> BasicCheckResult {
    let result = string_value(payload.get("result"));
    BasicCheckResult {
        is_malicious: is_malicious_text(result.as_deref()),
        is_safe: is_safe_text(result.as_deref()),
        result,
        raw: payload,
    }
}

pub(crate) fn parse_detailed(payload: RawJson) -> DetailedCheckResult {
    let result = string_value(payload.get("result"));
    let screenshot_url = first_string(
        &payload,
        &["screenshot url", "screenshot_url", "screenshotUrl"],
    );
    let tag = string_value(payload.get("tag"));
    DetailedCheckResult {
        is_malicious: is_malicious_text(result.as_deref()),
        result,
        screenshot_url,
        tag,
        raw: payload,
    }
}

pub(crate) fn parse_nsfw(payload: RawJson) -> NsfwCheckResult {
    let result = string_value(payload.get("result"));
    NsfwCheckResult {
        is_nsfw: result
            .as_deref()
            .unwrap_or_default()
            .trim()
            .eq_ignore_ascii_case("true"),
        result,
        raw: payload,
    }
}

pub(crate) fn parse_chimera(payload: RawJson) -> ChimeraResult {
    let result = string_value(payload.get("result"));
    let probability = number_value(payload.get("probability"));
    let detection_method = first_string(&payload, &["detection_method", "detectionMethod"]);
    let matched_signatures = integer_value(first_value(
        &payload,
        &["matched_signatures", "matchedSignatures"],
    ));
    let url = string_value(payload.get("url"));
    ChimeraResult {
        is_malicious: is_malicious_text(result.as_deref()),
        result,
        probability,
        detection_method,
        matched_signatures,
        url,
        raw: payload,
    }
}

pub(crate) fn error_message_from_payload(payload: &RawJson, fallback: &str) -> String {
    if let Some(message) = payload
        .get("error")
        .and_then(Value::as_object)
        .and_then(|error| error.get("message"))
        .and_then(Value::as_str)
    {
        return message.to_string();
    }
    for key in ["Error", "error", "message", "detail"] {
        if let Some(value) = payload.get(key) {
            if let Some(text) = value.as_str() {
                if !text.is_empty() {
                    return text.to_string();
                }
            } else if !value.is_null() {
                return value.to_string();
            }
        }
    }
    fallback.to_string()
}

fn first_value<'a>(payload: &'a RawJson, keys: &[&str]) -> Option<&'a Value> {
    keys.iter().find_map(|key| payload.get(*key))
}

fn first_string(payload: &RawJson, keys: &[&str]) -> Option<String> {
    string_value(first_value(payload, keys))
}

fn string_value(value: Option<&Value>) -> Option<String> {
    match value {
        Some(Value::String(text)) => Some(text.clone()),
        Some(Value::Number(number)) => Some(number.to_string()),
        Some(Value::Bool(boolean)) => Some(boolean.to_string()),
        _ => None,
    }
}

fn number_value(value: Option<&Value>) -> Option<f64> {
    match value {
        Some(Value::Number(number)) => number.as_f64(),
        Some(Value::String(text)) => text.parse().ok(),
        _ => None,
    }
}

fn integer_value(value: Option<&Value>) -> Option<i64> {
    match value {
        Some(Value::Number(number)) => number
            .as_i64()
            .or_else(|| number.as_u64().map(|value| value as i64)),
        Some(Value::String(text)) => text.parse().ok(),
        _ => None,
    }
}

fn is_malicious_text(result: Option<&str>) -> bool {
    let value = result.unwrap_or_default().trim().to_lowercase();
    for phrase in [
        "didn't detect",
        "did not detect",
        "no malicious",
        "not malicious",
    ] {
        if value.contains(phrase) {
            return false;
        }
    }
    matches!(value.as_str(), "might be malicious" | "malicious" | "true")
        || value.contains("malicious")
}

fn is_safe_text(result: Option<&str>) -> bool {
    let value = result.unwrap_or_default().trim().to_lowercase();
    matches!(
        value.as_str(),
        "likely safe" | "the system didn't detect anything malicious." | "false" | "benign"
    ) || value.contains("safe")
        || value.contains("benign")
}