xml-sec 0.2.0

Pure Rust XML Security: XMLDSig, XMLEnc, C14N. Drop-in replacement for libxmlsec1.
Documentation
use super::lexical::{is_ncname_char, is_ncname_start, is_xml_whitespace, trim_xml_whitespace};
use super::{
    Result,
    budget::{Meter, reserve_temporary_vec_slot},
};

pub(crate) struct FunctionCall {
    pub start: usize,
    pub end: usize,
    pub arguments: Vec<String>,
    pub namespace: String,
    pub local: String,
    pub display_name: String,
}

pub(crate) fn innermost_namespaced_call(
    source: &str,
    namespaces: &[(String, String)],
    accepts: impl Fn(&str, &str) -> bool + Copy,
) -> Option<FunctionCall> {
    struct PendingCall {
        start: usize,
        arguments_start: usize,
        namespace: usize,
        local_start: usize,
        local_end: usize,
        display_end: usize,
    }

    let mut parentheses = Vec::new();
    let mut quote = None;
    let mut cursor = 0;
    while cursor < source.len() {
        let character = source[cursor..].chars().next()?;
        if let Some(active) = quote {
            if character == active {
                quote = None;
            }
            cursor += character.len_utf8();
            continue;
        }
        if matches!(character, '\'' | '"') {
            quote = Some(character);
            cursor += character.len_utf8();
            continue;
        }
        if character == '(' {
            parentheses.push(None);
            cursor += 1;
            continue;
        }
        if character == ')' {
            cursor += 1;
            let Some(pending) = parentheses.pop() else {
                continue;
            };
            if let Some(PendingCall {
                start,
                arguments_start,
                namespace,
                local_start,
                local_end,
                display_end,
            }) = pending
            {
                return Some(FunctionCall {
                    start,
                    end: cursor,
                    arguments: split_function_arguments(&source[arguments_start..cursor - 1]),
                    namespace: namespaces[namespace].1.clone(),
                    local: source[local_start..local_end].to_owned(),
                    display_name: source[start..display_end].to_owned(),
                });
            }
            continue;
        }
        if !is_ncname_start(character) {
            cursor += character.len_utf8();
            continue;
        }
        let start = cursor;
        let (end, qualified) = lexical_name_end(source, start)?;
        cursor = end;
        if !qualified {
            continue;
        }
        let lexical = &source[start..cursor];
        let Some((prefix, local)) = lexical.split_once(':') else {
            continue;
        };
        if local.contains(':') {
            continue;
        }
        let Some(namespace) = namespaces
            .iter()
            .position(|(candidate, _)| candidate == prefix)
        else {
            continue;
        };
        if !accepts(&namespaces[namespace].1, local) {
            continue;
        }
        let mut open = cursor;
        while open < source.len() && source[open..].chars().next().is_some_and(is_xml_whitespace) {
            open += source[open..].chars().next()?.len_utf8();
        }
        if !source[open..].starts_with('(') {
            continue;
        }
        parentheses.push(Some(PendingCall {
            start,
            arguments_start: open + 1,
            namespace,
            local_start: start + prefix.len() + 1,
            local_end: cursor,
            display_end: cursor,
        }));
        cursor = open + 1;
    }
    None
}

pub(crate) fn metered_unprefixed_call_arguments<'a>(
    source: &'a str,
    name: &str,
    meter: &mut Meter,
) -> Result<(Vec<(usize, &'a str)>, usize)> {
    let mut parentheses = Vec::<Option<(usize, usize)>>::new();
    let mut parentheses_bytes = 0;
    let mut arguments = Vec::<(usize, &str)>::new();
    let mut arguments_bytes = 0;
    let result = (|| {
        let mut quote = None;
        let mut cursor = 0;
        while cursor < source.len() {
            let character = source[cursor..].chars().next().expect("character boundary");
            if let Some(active) = quote {
                if character == active {
                    quote = None;
                }
                cursor += character.len_utf8();
                continue;
            }
            if matches!(character, '\'' | '"') {
                quote = Some(character);
                cursor += character.len_utf8();
                continue;
            }
            if character == '(' {
                reserve_temporary_vec_slot(&mut parentheses, meter, &mut parentheses_bytes)?;
                parentheses.push(None);
                cursor += 1;
                continue;
            }
            if character == ')' {
                if let Some(Some((start, open))) = parentheses.pop() {
                    reserve_temporary_vec_slot(&mut arguments, meter, &mut arguments_bytes)?;
                    arguments.push((start, &source[open + 1..cursor]));
                }
                cursor += 1;
                continue;
            }
            if !is_ncname_start(character) {
                cursor += character.len_utf8();
                continue;
            }
            let start = cursor;
            let Some((end, qualified)) = lexical_name_end(source, start) else {
                break;
            };
            cursor = end;
            if qualified || &source[start..end] != name {
                continue;
            }
            let mut open = cursor;
            while open < source.len()
                && source[open..].chars().next().is_some_and(is_xml_whitespace)
            {
                open += source[open..]
                    .chars()
                    .next()
                    .expect("character boundary")
                    .len_utf8();
            }
            if source[open..].starts_with('(') {
                reserve_temporary_vec_slot(&mut parentheses, meter, &mut parentheses_bytes)?;
                parentheses.push(Some((start, open)));
                cursor = open + 1;
            }
        }
        arguments.sort_unstable_by_key(|(start, _)| *start);
        Ok(())
    })();
    drop(parentheses);
    meter.release_owned_bytes(parentheses_bytes);
    if let Err(error) = result {
        meter.release_owned_bytes(arguments_bytes);
        return Err(error);
    }
    Ok((arguments, arguments_bytes))
}

pub(crate) fn has_unprefixed_function_call(source: &str, name: &str) -> bool {
    let mut quote = None;
    let mut depth = 0usize;
    let mut candidate_depth = None;
    let mut cursor = 0;
    while cursor < source.len() {
        let Some(character) = source[cursor..].chars().next() else {
            break;
        };
        if let Some(active) = quote {
            if character == active {
                quote = None;
            }
            cursor += character.len_utf8();
            continue;
        }
        if matches!(character, '\'' | '"') {
            quote = Some(character);
            cursor += character.len_utf8();
            continue;
        }
        if character == '(' {
            depth += 1;
            cursor += 1;
            continue;
        }
        if character == ')' {
            if candidate_depth == Some(depth) {
                return true;
            }
            depth = depth.saturating_sub(1);
            cursor += 1;
            continue;
        }
        if !is_ncname_start(character) {
            cursor += character.len_utf8();
            continue;
        }
        let start = cursor;
        let Some((end, qualified)) = lexical_name_end(source, start) else {
            break;
        };
        cursor = end;
        if qualified || &source[start..cursor] != name {
            continue;
        }
        while cursor < source.len()
            && source[cursor..]
                .chars()
                .next()
                .is_some_and(is_xml_whitespace)
        {
            cursor += source[cursor..]
                .chars()
                .next()
                .expect("cursor is inside source")
                .len_utf8();
        }
        if source[cursor..].starts_with('(') {
            depth += 1;
            candidate_depth = Some(depth);
            cursor += 1;
        }
    }
    false
}

fn lexical_name_end(source: &str, start: usize) -> Option<(usize, bool)> {
    let first = source[start..].chars().next()?;
    if !is_ncname_start(first) {
        return None;
    }
    let mut cursor = start + first.len_utf8();
    while cursor < source.len() {
        let next = source[cursor..].chars().next()?;
        if !is_ncname_char(next) {
            break;
        }
        cursor += next.len_utf8();
    }

    let mut qualified = false;
    while source[cursor..].starts_with(':') {
        qualified = true;
        cursor += 1;
        let Some(local_start) = source[cursor..].chars().next() else {
            break;
        };
        if !is_ncname_start(local_start) {
            continue;
        }
        cursor += local_start.len_utf8();
        while cursor < source.len() {
            let next = source[cursor..].chars().next()?;
            if !is_ncname_char(next) {
                break;
            }
            cursor += next.len_utf8();
        }
    }
    Some((cursor, qualified))
}

fn split_function_arguments(source: &str) -> Vec<String> {
    if trim_xml_whitespace(source).is_empty() {
        return Vec::new();
    }
    let mut arguments = Vec::new();
    let mut start = 0;
    let mut depth = 0usize;
    let mut quote = None;
    for (offset, character) in source.char_indices() {
        if let Some(active) = quote {
            if character == active {
                quote = None;
            }
            continue;
        }
        match character {
            '\'' | '"' => quote = Some(character),
            '(' | '[' => depth += 1,
            ')' | ']' => depth = depth.saturating_sub(1),
            ',' if depth == 0 => {
                arguments.push(trim_xml_whitespace(&source[start..offset]).to_owned());
                start = offset + 1;
            }
            _ => {}
        }
    }
    arguments.push(trim_xml_whitespace(&source[start..]).to_owned());
    arguments
}

#[cfg(all(test, not(feature = "embedded")))]
mod tests;