xrust-net 0.3.0

Support for Xrust
Documentation
//! # xrust-net::resolver
//!
//! Resolve a URL

use qualname::{NamespaceUri, NcName, QName};
use std::fs;
use std::path::Path;
use std::rc::Rc;
use std::sync::LazyLock;
use tokio::runtime::Runtime;
use url::Url;
use xrust::item::{Item, Node};
use xrust::security::{Policy, SecurityResult};
use xrust::transform::Transform;
use xrust::transform::callable::ActualParameters;
use xrust::value::Value;
use xrust::xdmerror::{Error, ErrorKind};

/// Resolve a URL to the resource. Currently supports http and file schemes.
/// This function operates asynchronously, so that security features can control the incoming data.
/// However, it blocks until the resource is fully loaded (or an error occurs).
/// ## Security
/// Security features are provided. These may be set by a security policy, see [χrust Security](https://gitlab.gnome.org/balls/xrust-sec)
/// If no security policy is provided then the resolution is failed, i.e. secure by default.
/// ### Access
/// Access to a URL may be restricted. The URL to be accessed is passed as a parameter to the feature.
///
/// * Feature: Q{http://gitlab.gnome.org/World/Rust/markup-rs/xrust-net/security}access
/// * Security Result:
///   * NotPermitted - resolution fails.
///   * Permitted - resolution proceeds.
/// * Default: NotPermitted - access to external resources is not permitted.
/// ### Resource Size
/// The size of the resource may be limited. The URL of the resource and its intended size are passed as parameters to the feature.
///
/// * Feature: Q{http://gitlab.gnome.org/World/Rust/markup-rs/xrust-net/security}maximum-size
/// * Security Result:
///   * NotPermitted - resolution fails.
///   * Permitted (None) - no limit to resource size.
///   * Permitted (Value) - actual resource size must be less than or equal to value.
/// * Default: NotPermitted - resolution fails.
pub fn resolve<N: Node>(url: &Url, policy: &Rc<Policy<N>>) -> Result<String, Error> {
    let rt = Runtime::new().unwrap();
    rt.block_on(resolve_internal(url, policy))
}
async fn resolve_internal<N: Node>(url: &Url, policy: &Rc<Policy<N>>) -> Result<String, Error> {
    // First, is access allowed?
    if policy.get(
        &*ACCESS_QNAME,
        ActualParameters::Named(vec![
            (
                PARAM_URL.clone(),
                Transform::Literal(Item::Value(Rc::new(Value::from(url.to_string())))),
            ), // TODO: base URI
        ]),
    )? == SecurityResult::NotPermitted
    {
        return Err(Error::new(
            ErrorKind::NotPermitted,
            "resolving URL not permitted by security policy",
        ));
    }

    // Is there a limit on the body length?
    let limit = match policy.get(
        &*MAXSIZE_QNAME,
        ActualParameters::Named(vec![
            (
                PARAM_URL.clone(),
                Transform::Literal(Item::Value(Rc::new(Value::from(url.to_string())))),
            ), // TODO: base URI
        ]),
    )? {
        SecurityResult::NotPermitted => {
            return Err(Error::new(
                ErrorKind::NotPermitted,
                "access to URL not permitted by security policy",
            ));
        }
        SecurityResult::Permitted(None) => None,
        SecurityResult::Permitted(Some(v)) => {
            if let Ok(i) = v.parse::<usize>() {
                Some(i)
            } else {
                return Err(Error::new(
                    ErrorKind::NotPermitted,
                    "security policy permits invalid value",
                ));
            }
        }
    };

    // The easy way is to use a blocking request.
    // However, we want to be able to terminate the request if it exceeds the permitted value.
    // Also, we could add a progress meter.
    match url.scheme() {
        "http" => {
            let mut body = String::new();
            let mut res = reqwest::get(url.to_string())
                .await
                .map_err(|e| Error::new(ErrorKind::Unknown, e.to_string()))?;
            while let Some(chunk) = res
                .chunk()
                .await
                .map_err(|e| Error::new(ErrorKind::Unknown, e.to_string()))?
            {
                if limit.is_some_and(|v| body.len() + chunk.len() > v) {
                    return Err(Error::new(
                        ErrorKind::NotPermitted,
                        format!("resource exceeds allowed length \"{}\"", limit.unwrap()),
                    ));
                } else {
                    let frag = chunk.utf8_chunks().try_fold(String::new(), |mut acc, c| {
                        acc.push_str(c.valid());
                        if !c.invalid().is_empty() {
                            Err(Error::new(ErrorKind::ParseError, "invalid UTF-8 character"))
                        } else {
                            Ok(acc)
                        }
                    })?;
                    body.push_str(frag.as_str())
                }
            }
            Ok(body)
        }
        "file" => {
            let body = fs::read_to_string(Path::new(url.path()))
                .map_err(|er| Error::new(ErrorKind::Unknown, er.to_string()))?;
            if limit.is_some_and(|v| body.len() > v) {
                Err(Error::new(
                    ErrorKind::NotPermitted,
                    format!("resource exceeds allowed length \"{}\"", limit.unwrap()),
                ))
            } else {
                Ok(body)
            }
        }
        _ => Err(Error::new(
            ErrorKind::Unknown,
            format!("unable to fetch URL \"{}\"", url.to_string()),
        )),
    }
}

/// Qualified Name (QName) for security features
static SECURITYNS: LazyLock<Option<NamespaceUri>> = LazyLock::new(|| {
    Some(
        NamespaceUri::try_from("http://gitlab.gnome.org/World/Rust/markup-rs/xrust-net/security")
            .unwrap(),
    )
});
static ACCESS_QNAME: LazyLock<QName> = LazyLock::new(|| {
    QName::new_from_parts(NcName::try_from("access").unwrap(), SECURITYNS.clone())
});
static MAXSIZE_QNAME: LazyLock<QName> = LazyLock::new(|| {
    QName::new_from_parts(
        NcName::try_from("maximum-size").unwrap(),
        SECURITYNS.clone(),
    )
});
static PARAM_URL: LazyLock<QName> =
    LazyLock::new(|| QName::new_from_parts(NcName::try_from("url").unwrap(), None));

#[cfg(test)]
mod tests {
    use super::*;

    use xrust::item::{Item, Node, SequenceTrait};
    use xrust::parser::ParseError;
    use xrust::parser::xml::parse as xmlparse;
    use xrust::security::{Feature, Policy, SecurityPolicy};
    use xrust::transform::callable::FormalParameters;
    use xrust::transform::context::StaticContextBuilder;
    use xrust::trees::smite::RNode;
    use xrust::xslt::from_document;

    fn make_from_str(s: &str) -> Result<RNode, Error> {
        let d = RNode::new_document();
        let _ = xmlparse(d.clone(), s, Some(|_: &_| Err(ParseError::Notimplemented)))?;
        Ok(d)
    }

    #[test]
    fn include() {
        // This security policy places no limits on resource access and size
        let mut policy: Policy<RNode> = Policy::new(QName::from_local_name(
            NcName::try_from("test_policy").unwrap(),
        ));
        policy.add(
            QName::new_from_parts(
                NcName::try_from("access").unwrap(),
                Some(
                    NamespaceUri::try_from(
                        "http://gitlab.gnome.org/World/Rust/markup-rs/xrust-net/security",
                    )
                    .unwrap(),
                ),
            ),
            Feature::new(
                Transform::LiteralElement(
                    QName::new_from_parts(
                        NcName::try_from("permitted").unwrap(),
                        Some(
                            NamespaceUri::try_from(
                                "http://gitlab.gnome.org/World/Rust/markup-rs/Security",
                            )
                            .unwrap(),
                        ),
                    ),
                    Box::new(Transform::<RNode>::Empty),
                ),
                FormalParameters::Named(vec![(PARAM_URL.clone(), None)]),
            ),
        );
        policy.add(
            QName::new_from_parts(
                NcName::try_from("maximum-size").unwrap(),
                Some(
                    NamespaceUri::try_from(
                        "http://gitlab.gnome.org/World/Rust/markup-rs/xrust-net/security",
                    )
                    .unwrap(),
                ),
            ),
            Feature::new(
                Transform::LiteralElement(
                    QName::new_from_parts(
                        NcName::try_from("permitted").unwrap(),
                        Some(
                            NamespaceUri::try_from(
                                "http://gitlab.gnome.org/World/Rust/markup-rs/Security",
                            )
                            .unwrap(),
                        ),
                    ),
                    Box::new(Transform::<RNode>::Empty),
                ),
                FormalParameters::Named(vec![(PARAM_URL.clone(), None)]),
            ),
        );
        let rpolicy = Rc::new(policy);
        let mut sc = StaticContextBuilder::new()
            .message(|_| Ok(()))
            .fetcher(|u| resolve(u, &rpolicy))
            .parser(|s| make_from_str(s))
            .build();

        let src = Item::Node(
            make_from_str("<Test>one<Level1/>two<Level2/>three<Level3/>four<Level4/></Test>")
                .expect("unable to parse source document"),
        );

        let style = make_from_str(
            "<xsl:stylesheet xmlns:xsl='http://www.w3.org/1999/XSL/Transform'>
  <xsl:include href='included.xsl'/>
  <xsl:template match='child::Test'><xsl:apply-templates/></xsl:template>
  <xsl:template match='child::Level1'>found Level1 element</xsl:template>
  <xsl:template match='child::text()'><xsl:sequence select='.'/></xsl:template>
</xsl:stylesheet>",
        )
        .expect("unable to parse stylesheet");

        // Setup dynamic context with result document
        let pwd = std::env::current_dir().expect("unable to get current directory");
        let pwds = pwd
            .into_os_string()
            .into_string()
            .expect("unable to convert pwd");
        let mut ctxt = from_document(
            style,
            Some(
                Url::parse(format!("file://{}/tests/xsl/including.xsl", pwds.as_str()).as_str())
                    .expect("unable to parse URL"),
            ),
            |s| make_from_str(s),
            |url| resolve(url, &rpolicy),
        )
        .expect("failed to compile stylesheet");

        let rd = RNode::new_document();

        ctxt.context(vec![src], 0);
        ctxt.result_document(rd.clone());

        let seq = ctxt.evaluate(&mut sc).expect("evaluation failed");

        assert_eq!(
            seq.to_xml(),
            "onefound Level1 elementtwofound Level2 elementthreefound Level3 elementfour"
        )
    }

    // Check a security policy that restricts access to only the local filesystem
    #[test]
    fn local_only() {
        let poldoc = make_from_str(r##"<sec:policy name='localonly' xmlns:sec='http://gitlab.gnome.org/World/Rust/markup-rs/Security'
            xmlns:net='http://gitlab.gnome.org/World/Rust/markup-rs/xrust-net/security'
                xmlns:xsl='http://www.w3.org/1999/XSL/Transform'>
          <sec:feature name='net:access'>
            <xsl:param name='url'/>
            <xsl:choose>
              <xsl:when test='starts-with($url, "file://")'>
                <sec:permitted/>
              </xsl:when>
              <xsl:otherwise>
                <sec:not-permitted/>
              </xsl:otherwise>
            </xsl:choose>
          </sec:feature>
          <sec:feature name='net:maximum-size'>
            <sec:permitted/>
          </sec:feature>
        </sec:policy>"##).expect("unable to parse security policy");
        let policy = poldoc
            .to_policy()
            .expect("unable to compile security policy");
        let rpolicy = Rc::new(policy);

        let mut sc = StaticContextBuilder::new()
            .message(|_| Ok(()))
            .fetcher(|u| resolve(u, &rpolicy))
            .parser(|s| make_from_str(s))
            .build();

        let src = Item::Node(
            make_from_str("<Test>one<Level1/>two<Level2/>three<Level3/>four<Level4/></Test>")
                .expect("unable to parse source document"),
        );

        let style1 = make_from_str(
            "<xsl:stylesheet xmlns:xsl='http://www.w3.org/1999/XSL/Transform'>
  <xsl:include href='included.xsl'/>
  <xsl:template match='child::Test'><xsl:apply-templates/></xsl:template>
  <xsl:template match='child::Level1'>found Level1 element</xsl:template>
  <xsl:template match='child::text()'><xsl:sequence select='.'/></xsl:template>
</xsl:stylesheet>",
        )
        .expect("unable to parse stylesheet");

        // Setup dynamic context with result document
        let pwd = std::env::current_dir().expect("unable to get current directory");
        let pwds = pwd
            .into_os_string()
            .into_string()
            .expect("unable to convert pwd");
        let mut ctxt1 = from_document(
            style1,
            Some(
                Url::parse(format!("file://{}/tests/xsl/including.xsl", pwds.as_str()).as_str())
                    .expect("unable to parse URL"),
            ),
            |s| make_from_str(s),
            |url| resolve(url, &rpolicy),
        )
        .expect("failed to compile stylesheet");

        let rd1 = RNode::new_document();

        ctxt1.context(vec![src.clone()], 0);
        ctxt1.result_document(rd1.clone());

        let seq1 = ctxt1.evaluate(&mut sc).expect("evaluation failed");

        assert_eq!(
            seq1.to_xml(),
            "onefound Level1 elementtwofound Level2 elementthreefound Level3 elementfour"
        );

        let style2 = make_from_str(
            "<xsl:stylesheet xmlns:xsl='http://www.w3.org/1999/XSL/Transform'>
  <xsl:include href='http://example.org/xslt/included.xsl'/>
  <xsl:template match='child::Test'><xsl:apply-templates/></xsl:template>
  <xsl:template match='child::Level1'>found Level1 element</xsl:template>
  <xsl:template match='child::text()'><xsl:sequence select='.'/></xsl:template>
</xsl:stylesheet>",
        )
        .expect("unable to parse stylesheet");

        // This should fail since it is trying to load a remote URL
        let result = from_document(
            style2,
            Some(
                Url::parse("http://example.org/tests/xsl/including.xsl")
                    .expect("unable to parse URL"),
            ),
            |s| make_from_str(s),
            |url| resolve(url, &rpolicy),
        );
        assert!(result.is_err())
    }
}