xss-filters 0.2.0

Context-aware, just-sufficient output encoding to prevent XSS in HTML, attribute, URI, and CSS contexts
Documentation
//! Percent-encoding that matches JavaScript `encodeURI` and `encodeURIComponent`.
//!
//! Both functions encode each non-reserved character's UTF-8 bytes as uppercase
//! `%XX`. They differ only in which characters stay literal. `encode_uri` keeps
//! the RFC 3986 reserved set unescaped. `encode_uri_component` escapes that set.
//!
//! JavaScript strings are UTF-16, so the builtins throw a `URIError` on a lone
//! surrogate. Rust `&str` is always well-formed UTF-8, so the `&str` entry points
//! never hit that case. [`encode_uri_units`] models the throw for callers that
//! supply raw UTF-16 code units.

use alloc::string::String;

/// Characters left unescaped by both `encodeURI` and `encodeURIComponent`.
///
/// Alphanumerics plus the unreserved marks `- _ . ! ~ * ' ( )`.
fn is_unescaped_component(b: u8) -> bool {
    b.is_ascii_alphanumeric()
        || matches!(
            b,
            b'-' | b'_' | b'.' | b'!' | b'~' | b'*' | b'\'' | b'(' | b')'
        )
}

/// Characters `encodeURI` keeps literal on top of the component-unescaped set.
///
/// The RFC 3986 reserved set: `; / ? : @ & = + $ , #`.
fn is_uri_reserved(b: u8) -> bool {
    matches!(
        b,
        b';' | b'/' | b'?' | b':' | b'@' | b'&' | b'=' | b'+' | b'$' | b',' | b'#'
    )
}

/// Append the uppercase `%XX` escape for one byte.
fn push_percent(out: &mut String, b: u8) {
    const HEX: &[u8; 16] = b"0123456789ABCDEF";
    out.push('%');
    out.push(HEX[(b >> 4) as usize] as char);
    out.push(HEX[(b & 0x0F) as usize] as char);
}

/// Equivalent of JavaScript `encodeURI(s)` for well-formed UTF-8 input.
///
/// Encodes every byte except alphanumerics, the unreserved marks, and the RFC
/// 3986 reserved set. So `<` becomes `%3C` while `:` and `/` stay literal.
pub fn encode_uri(s: &str) -> String {
    encode_with(s, |b| is_unescaped_component(b) || is_uri_reserved(b))
}

/// Equivalent of JavaScript `encodeURIComponent(s)` for well-formed UTF-8 input.
///
/// Encodes every byte except alphanumerics and the unreserved marks. So `:`
/// becomes `%3A`, which is why component filters defeat `javascript:` without a
/// separate protocol guard.
pub fn encode_uri_component(s: &str) -> String {
    encode_with(s, is_unescaped_component)
}

/// Percent-encode every byte of `s` for which `keep` returns false.
fn encode_with(s: &str, keep: impl Fn(u8) -> bool) -> String {
    let mut out = String::with_capacity(s.len());
    for &b in s.as_bytes() {
        if keep(b) {
            out.push(b as char);
        } else {
            push_percent(&mut out, b);
        }
    }
    out
}

/// Error returned when UTF-16 input contains a lone surrogate.
///
/// Mirrors the `URIError` thrown by `encodeURI`/`encodeURIComponent` in
/// JavaScript. The message matches one of the spellings the conformance suite
/// accepts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct UriMalformed;

impl core::fmt::Display for UriMalformed {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.write_str("URI malformed")
    }
}

#[cfg(feature = "std")]
impl std::error::Error for UriMalformed {}

/// Which reserved set [`encode_uri_units`] escapes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UriEncoding {
    /// `encodeURI` semantics. Keeps the RFC 3986 reserved set literal, so `:`
    /// and `/` survive.
    Full,
    /// `encodeURIComponent` semantics. Escapes the reserved set too, so `:`
    /// becomes `%3A`.
    Component,
}

/// Encode raw UTF-16 code units the way JavaScript does, including the throw on
/// a lone surrogate.
///
/// `encoding` picks `encodeURI` or `encodeURIComponent` semantics. Returns
/// [`UriMalformed`] if `units` holds an unpaired surrogate. This exists so
/// callers and tests can reproduce the JavaScript `URIError` contract that a
/// `&str` API cannot reach.
pub fn encode_uri_units(units: &[u16], encoding: UriEncoding) -> Result<String, UriMalformed> {
    let s = utf16_to_string(units)?;
    Ok(match encoding {
        UriEncoding::Component => encode_uri_component(&s),
        UriEncoding::Full => encode_uri(&s),
    })
}

/// Decode UTF-16 code units to a `String`, erroring on any lone surrogate.
fn utf16_to_string(units: &[u16]) -> Result<String, UriMalformed> {
    let mut out = String::with_capacity(units.len());
    for decoded in core::char::decode_utf16(units.iter().copied()) {
        out.push(decoded.map_err(|_| UriMalformed)?);
    }
    Ok(out)
}