Skip to main content

xrust_net/
resolver.rs

1//! # xrust-net::resolver
2//!
3//! Resolve a URL
4
5use qualname::{NamespaceUri, NcName, QName};
6use std::fs;
7use std::path::Path;
8use std::rc::Rc;
9use std::sync::LazyLock;
10use tokio::runtime::Runtime;
11use url::Url;
12use xrust::item::{Item, Node};
13use xrust::security::{Policy, SecurityResult};
14use xrust::transform::Transform;
15use xrust::transform::callable::ActualParameters;
16use xrust::value::Value;
17use xrust::xdmerror::{Error, ErrorKind};
18
19/// Resolve a URL to the resource. Currently supports http and file schemes.
20/// This function operates asynchronously, so that security features can control the incoming data.
21/// However, it blocks until the resource is fully loaded (or an error occurs).
22/// ## Security
23/// Security features are provided. These may be set by a security policy, see [χrust Security](https://gitlab.gnome.org/balls/xrust-sec)
24/// If no security policy is provided then the resolution is failed, i.e. secure by default.
25/// ### Access
26/// Access to a URL may be restricted. The URL to be accessed is passed as a parameter to the feature.
27///
28/// * Feature: Q{http://gitlab.gnome.org/World/Rust/markup-rs/xrust-net/security}access
29/// * Security Result:
30///   * NotPermitted - resolution fails.
31///   * Permitted - resolution proceeds.
32/// * Default: NotPermitted - access to external resources is not permitted.
33/// ### Resource Size
34/// The size of the resource may be limited. The URL of the resource and its intended size are passed as parameters to the feature.
35///
36/// * Feature: Q{http://gitlab.gnome.org/World/Rust/markup-rs/xrust-net/security}maximum-size
37/// * Security Result:
38///   * NotPermitted - resolution fails.
39///   * Permitted (None) - no limit to resource size.
40///   * Permitted (Value) - actual resource size must be less than or equal to value.
41/// * Default: NotPermitted - resolution fails.
42pub fn resolve<N: Node>(url: &Url, policy: &Rc<Policy<N>>) -> Result<String, Error> {
43    let rt = Runtime::new().unwrap();
44    rt.block_on(resolve_internal(url, policy))
45}
46async fn resolve_internal<N: Node>(url: &Url, policy: &Rc<Policy<N>>) -> Result<String, Error> {
47    // First, is access allowed?
48    if policy.get(
49        &*ACCESS_QNAME,
50        ActualParameters::Named(vec![
51            (
52                PARAM_URL.clone(),
53                Transform::Literal(Item::Value(Rc::new(Value::from(url.to_string())))),
54            ), // TODO: base URI
55        ]),
56    )? == SecurityResult::NotPermitted
57    {
58        return Err(Error::new(
59            ErrorKind::NotPermitted,
60            "resolving URL not permitted by security policy",
61        ));
62    }
63
64    // Is there a limit on the body length?
65    let limit = match policy.get(
66        &*MAXSIZE_QNAME,
67        ActualParameters::Named(vec![
68            (
69                PARAM_URL.clone(),
70                Transform::Literal(Item::Value(Rc::new(Value::from(url.to_string())))),
71            ), // TODO: base URI
72        ]),
73    )? {
74        SecurityResult::NotPermitted => {
75            return Err(Error::new(
76                ErrorKind::NotPermitted,
77                "access to URL not permitted by security policy",
78            ));
79        }
80        SecurityResult::Permitted(None) => None,
81        SecurityResult::Permitted(Some(v)) => {
82            if let Ok(i) = v.parse::<usize>() {
83                Some(i)
84            } else {
85                return Err(Error::new(
86                    ErrorKind::NotPermitted,
87                    "security policy permits invalid value",
88                ));
89            }
90        }
91    };
92
93    // The easy way is to use a blocking request.
94    // However, we want to be able to terminate the request if it exceeds the permitted value.
95    // Also, we could add a progress meter.
96    match url.scheme() {
97        "http" => {
98            let mut body = String::new();
99            let mut res = reqwest::get(url.to_string())
100                .await
101                .map_err(|e| Error::new(ErrorKind::Unknown, e.to_string()))?;
102            while let Some(chunk) = res
103                .chunk()
104                .await
105                .map_err(|e| Error::new(ErrorKind::Unknown, e.to_string()))?
106            {
107                if limit.is_some_and(|v| body.len() + chunk.len() > v) {
108                    return Err(Error::new(
109                        ErrorKind::NotPermitted,
110                        format!("resource exceeds allowed length \"{}\"", limit.unwrap()),
111                    ));
112                } else {
113                    let frag = chunk.utf8_chunks().try_fold(String::new(), |mut acc, c| {
114                        acc.push_str(c.valid());
115                        if !c.invalid().is_empty() {
116                            Err(Error::new(ErrorKind::ParseError, "invalid UTF-8 character"))
117                        } else {
118                            Ok(acc)
119                        }
120                    })?;
121                    body.push_str(frag.as_str())
122                }
123            }
124            Ok(body)
125        }
126        "file" => {
127            let body = fs::read_to_string(Path::new(url.path()))
128                .map_err(|er| Error::new(ErrorKind::Unknown, er.to_string()))?;
129            if limit.is_some_and(|v| body.len() > v) {
130                Err(Error::new(
131                    ErrorKind::NotPermitted,
132                    format!("resource exceeds allowed length \"{}\"", limit.unwrap()),
133                ))
134            } else {
135                Ok(body)
136            }
137        }
138        _ => Err(Error::new(
139            ErrorKind::Unknown,
140            format!("unable to fetch URL \"{}\"", url.to_string()),
141        )),
142    }
143}
144
145/// Qualified Name (QName) for security features
146static SECURITYNS: LazyLock<Option<NamespaceUri>> = LazyLock::new(|| {
147    Some(
148        NamespaceUri::try_from("http://gitlab.gnome.org/World/Rust/markup-rs/xrust-net/security")
149            .unwrap(),
150    )
151});
152static ACCESS_QNAME: LazyLock<QName> = LazyLock::new(|| {
153    QName::new_from_parts(NcName::try_from("access").unwrap(), SECURITYNS.clone())
154});
155static MAXSIZE_QNAME: LazyLock<QName> = LazyLock::new(|| {
156    QName::new_from_parts(
157        NcName::try_from("maximum-size").unwrap(),
158        SECURITYNS.clone(),
159    )
160});
161static PARAM_URL: LazyLock<QName> =
162    LazyLock::new(|| QName::new_from_parts(NcName::try_from("url").unwrap(), None));
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    use xrust::item::{Item, Node, SequenceTrait};
169    use xrust::parser::ParseError;
170    use xrust::parser::xml::parse as xmlparse;
171    use xrust::security::{Feature, Policy, SecurityPolicy};
172    use xrust::transform::callable::FormalParameters;
173    use xrust::transform::context::StaticContextBuilder;
174    use xrust::trees::smite::RNode;
175    use xrust::xslt::from_document;
176
177    fn make_from_str(s: &str) -> Result<RNode, Error> {
178        let d = RNode::new_document();
179        let _ = xmlparse(d.clone(), s, Some(|_: &_| Err(ParseError::Notimplemented)))?;
180        Ok(d)
181    }
182
183    #[test]
184    fn include() {
185        // This security policy places no limits on resource access and size
186        let mut policy: Policy<RNode> = Policy::new(QName::from_local_name(
187            NcName::try_from("test_policy").unwrap(),
188        ));
189        policy.add(
190            QName::new_from_parts(
191                NcName::try_from("access").unwrap(),
192                Some(
193                    NamespaceUri::try_from(
194                        "http://gitlab.gnome.org/World/Rust/markup-rs/xrust-net/security",
195                    )
196                    .unwrap(),
197                ),
198            ),
199            Feature::new(
200                Transform::LiteralElement(
201                    QName::new_from_parts(
202                        NcName::try_from("permitted").unwrap(),
203                        Some(
204                            NamespaceUri::try_from(
205                                "http://gitlab.gnome.org/World/Rust/markup-rs/Security",
206                            )
207                            .unwrap(),
208                        ),
209                    ),
210                    Box::new(Transform::<RNode>::Empty),
211                ),
212                FormalParameters::Named(vec![(PARAM_URL.clone(), None)]),
213            ),
214        );
215        policy.add(
216            QName::new_from_parts(
217                NcName::try_from("maximum-size").unwrap(),
218                Some(
219                    NamespaceUri::try_from(
220                        "http://gitlab.gnome.org/World/Rust/markup-rs/xrust-net/security",
221                    )
222                    .unwrap(),
223                ),
224            ),
225            Feature::new(
226                Transform::LiteralElement(
227                    QName::new_from_parts(
228                        NcName::try_from("permitted").unwrap(),
229                        Some(
230                            NamespaceUri::try_from(
231                                "http://gitlab.gnome.org/World/Rust/markup-rs/Security",
232                            )
233                            .unwrap(),
234                        ),
235                    ),
236                    Box::new(Transform::<RNode>::Empty),
237                ),
238                FormalParameters::Named(vec![(PARAM_URL.clone(), None)]),
239            ),
240        );
241        let rpolicy = Rc::new(policy);
242        let mut sc = StaticContextBuilder::new()
243            .message(|_| Ok(()))
244            .fetcher(|u| resolve(u, &rpolicy))
245            .parser(|s| make_from_str(s))
246            .build();
247
248        let src = Item::Node(
249            make_from_str("<Test>one<Level1/>two<Level2/>three<Level3/>four<Level4/></Test>")
250                .expect("unable to parse source document"),
251        );
252
253        let style = make_from_str(
254            "<xsl:stylesheet xmlns:xsl='http://www.w3.org/1999/XSL/Transform'>
255  <xsl:include href='included.xsl'/>
256  <xsl:template match='child::Test'><xsl:apply-templates/></xsl:template>
257  <xsl:template match='child::Level1'>found Level1 element</xsl:template>
258  <xsl:template match='child::text()'><xsl:sequence select='.'/></xsl:template>
259</xsl:stylesheet>",
260        )
261        .expect("unable to parse stylesheet");
262
263        // Setup dynamic context with result document
264        let pwd = std::env::current_dir().expect("unable to get current directory");
265        let pwds = pwd
266            .into_os_string()
267            .into_string()
268            .expect("unable to convert pwd");
269        let mut ctxt = from_document(
270            style,
271            Some(
272                Url::parse(format!("file://{}/tests/xsl/including.xsl", pwds.as_str()).as_str())
273                    .expect("unable to parse URL"),
274            ),
275            |s| make_from_str(s),
276            |url| resolve(url, &rpolicy),
277        )
278        .expect("failed to compile stylesheet");
279
280        let rd = RNode::new_document();
281
282        ctxt.context(vec![src], 0);
283        ctxt.result_document(rd.clone());
284
285        let seq = ctxt.evaluate(&mut sc).expect("evaluation failed");
286
287        assert_eq!(
288            seq.to_xml(),
289            "onefound Level1 elementtwofound Level2 elementthreefound Level3 elementfour"
290        )
291    }
292
293    // Check a security policy that restricts access to only the local filesystem
294    #[test]
295    fn local_only() {
296        let poldoc = make_from_str(r##"<sec:policy name='localonly' xmlns:sec='http://gitlab.gnome.org/World/Rust/markup-rs/Security'
297            xmlns:net='http://gitlab.gnome.org/World/Rust/markup-rs/xrust-net/security'
298                xmlns:xsl='http://www.w3.org/1999/XSL/Transform'>
299          <sec:feature name='net:access'>
300            <xsl:param name='url'/>
301            <xsl:choose>
302              <xsl:when test='starts-with($url, "file://")'>
303                <sec:permitted/>
304              </xsl:when>
305              <xsl:otherwise>
306                <sec:not-permitted/>
307              </xsl:otherwise>
308            </xsl:choose>
309          </sec:feature>
310          <sec:feature name='net:maximum-size'>
311            <sec:permitted/>
312          </sec:feature>
313        </sec:policy>"##).expect("unable to parse security policy");
314        let policy = poldoc
315            .to_policy()
316            .expect("unable to compile security policy");
317        let rpolicy = Rc::new(policy);
318
319        let mut sc = StaticContextBuilder::new()
320            .message(|_| Ok(()))
321            .fetcher(|u| resolve(u, &rpolicy))
322            .parser(|s| make_from_str(s))
323            .build();
324
325        let src = Item::Node(
326            make_from_str("<Test>one<Level1/>two<Level2/>three<Level3/>four<Level4/></Test>")
327                .expect("unable to parse source document"),
328        );
329
330        let style1 = make_from_str(
331            "<xsl:stylesheet xmlns:xsl='http://www.w3.org/1999/XSL/Transform'>
332  <xsl:include href='included.xsl'/>
333  <xsl:template match='child::Test'><xsl:apply-templates/></xsl:template>
334  <xsl:template match='child::Level1'>found Level1 element</xsl:template>
335  <xsl:template match='child::text()'><xsl:sequence select='.'/></xsl:template>
336</xsl:stylesheet>",
337        )
338        .expect("unable to parse stylesheet");
339
340        // Setup dynamic context with result document
341        let pwd = std::env::current_dir().expect("unable to get current directory");
342        let pwds = pwd
343            .into_os_string()
344            .into_string()
345            .expect("unable to convert pwd");
346        let mut ctxt1 = from_document(
347            style1,
348            Some(
349                Url::parse(format!("file://{}/tests/xsl/including.xsl", pwds.as_str()).as_str())
350                    .expect("unable to parse URL"),
351            ),
352            |s| make_from_str(s),
353            |url| resolve(url, &rpolicy),
354        )
355        .expect("failed to compile stylesheet");
356
357        let rd1 = RNode::new_document();
358
359        ctxt1.context(vec![src.clone()], 0);
360        ctxt1.result_document(rd1.clone());
361
362        let seq1 = ctxt1.evaluate(&mut sc).expect("evaluation failed");
363
364        assert_eq!(
365            seq1.to_xml(),
366            "onefound Level1 elementtwofound Level2 elementthreefound Level3 elementfour"
367        );
368
369        let style2 = make_from_str(
370            "<xsl:stylesheet xmlns:xsl='http://www.w3.org/1999/XSL/Transform'>
371  <xsl:include href='http://example.org/xslt/included.xsl'/>
372  <xsl:template match='child::Test'><xsl:apply-templates/></xsl:template>
373  <xsl:template match='child::Level1'>found Level1 element</xsl:template>
374  <xsl:template match='child::text()'><xsl:sequence select='.'/></xsl:template>
375</xsl:stylesheet>",
376        )
377        .expect("unable to parse stylesheet");
378
379        // This should fail since it is trying to load a remote URL
380        let result = from_document(
381            style2,
382            Some(
383                Url::parse("http://example.org/tests/xsl/including.xsl")
384                    .expect("unable to parse URL"),
385            ),
386            |s| make_from_str(s),
387            |url| resolve(url, &rpolicy),
388        );
389        assert!(result.is_err())
390    }
391}