mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 07:46:08 +00:00
Add craft-dav: WebDAV multistatus reader from MailCraft (#975)
MailCraft crates/dav/src/xml.rs at a44a450, re-based onto craft-xml: a multistatus read fail-closed into a bounded tree with fixed prefixes for the DAV, CalDAV, CardDAV and related namespaces, and the properties of a response's successful propstats. Its tests come along. Co-authored-by: Martin Ball <98639962+benandez@users.noreply.github.com>
This commit is contained in:
co-authored by
Martin Ball
parent
b85549a5b4
commit
d197b1e72f
Generated
+8
@@ -1018,6 +1018,14 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-dav"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-xml",
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-dialogs"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -35,6 +35,7 @@ craft-cms = { path = "crates/cms" }
|
||||
craft-crs = { path = "crates/crs" }
|
||||
craft-crw = { path = "crates/crw" }
|
||||
craft-csv = { path = "crates/csv" }
|
||||
craft-dav = { path = "crates/dav" }
|
||||
craft-dialogs = { path = "crates/dialogs" }
|
||||
craft-docx = { path = "crates/docx" }
|
||||
craft-dxf = { path = "crates/dxf" }
|
||||
|
||||
@@ -46,6 +46,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
- [`craft-cfb`](crates/cfb/README.md): clean-room Compound File Binary ([MS-CFB], OLE structured
|
||||
storage) reader and writer: the container of legacy Office files, Outlook messages, encrypted
|
||||
OOXML packages and AAF. Bounded on hostile input; what the streams hold stays with each format.
|
||||
- [`craft-dav`](crates/dav/README.md): WebDAV multistatus documents (RFC 4918) with the CalDAV and
|
||||
CardDAV namespaces, from MailCraft: read fail-closed into a bounded `craft-xml` tree with fixed
|
||||
prefixes, and the successful properties of each response. No I/O.
|
||||
- [`craft-cms`](crates/cms/README.md): pure-Rust ICC v2/v4 colour management: profile parsing
|
||||
and writing, transforms, rendering intents, black point compensation, soft proofing, gamut
|
||||
checks, 3D LUTs and built-in profiles (sRGB, Display P3, synthetic coated CMYK and others).
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "craft-dav"
|
||||
description = "WebDAV multistatus (RFC 4918) documents, with the CalDAV and CardDAV namespaces, read from hostile bytes into a bounded tree."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-xml = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,48 @@
|
||||
# craft-dav
|
||||
|
||||
WebDAV multistatus documents (RFC 4918 §13): what a server answers a `PROPFIND`, or a CalDAV
|
||||
(RFC 4791) or CardDAV (RFC 6352) `REPORT`, with. No I/O.
|
||||
|
||||
```rust
|
||||
let root = craft_dav::parse_multistatus(body)?;
|
||||
for response in craft_dav::responses(&root) {
|
||||
let href = craft_dav::href(response);
|
||||
for property in craft_dav::properties(response) {
|
||||
match property.name.as_str() {
|
||||
"d:getetag" => { /* property.text() */ }
|
||||
"c:calendar-data" | "card:address-data" => { /* the iCalendar or vCard text */ }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Names carry fixed prefixes whatever the document used: `d:` (`DAV:`), `c:` (CalDAV), `card:`
|
||||
(CardDAV), `cs:` (CalendarServer), `oc:` (ownCloud), `nc:` (Nextcloud). Other namespaces start
|
||||
with `?:`. `properties` gives only what a `200` propstat returned.
|
||||
|
||||
## Hostile input
|
||||
|
||||
The reader is built on `craft-xml` and fails closed: a document type declaration, an entity that
|
||||
is not predefined, a character XML cannot carry, text that is not UTF-8, malformed or unbalanced
|
||||
markup, a second root, text outside the root, or a root other than `DAV:multistatus` makes the
|
||||
whole document an error.
|
||||
|
||||
| Limit | Value |
|
||||
|---|---|
|
||||
| `MAX_BYTES` | 16 MiB |
|
||||
| `MAX_NODES` | 100,000 |
|
||||
| `MAX_DEPTH` | 32 |
|
||||
| `MAX_ATTRS` per element / `MAX_ATTRS_TOTAL` | 64 / 200,000 |
|
||||
|
||||
## What stays in the apps
|
||||
|
||||
Requests, credentials and HTTP; what a collection or resource means; reading the iCalendar and
|
||||
vCard text (`craft-ical`, `craft-vcard`).
|
||||
|
||||
## Origin
|
||||
|
||||
MailCraft `crates/dav/src/xml.rs` at
|
||||
[a44a450](https://github.com/UCS-Care-Group/mailcraft/commit/a44a45013d1c6396229c40ab60172d1882b368d5),
|
||||
by Martin Ball and the MailCraft contributors (MIT OR Apache-2.0), and its tests, re-based from
|
||||
`quick-xml` onto `craft-xml`.
|
||||
@@ -0,0 +1,114 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! WebDAV multistatus documents (RFC 4918 §13), what a server answers a `PROPFIND` or a CalDAV or
|
||||
//! CardDAV `REPORT` with.
|
||||
//!
|
||||
//! [`parse_multistatus`] reads the bytes into a [`craft_xml::El`] tree whose names carry fixed
|
||||
//! prefixes whatever the document used: `d:` for `DAV:` ([`DAV`]), `c:` for CalDAV ([`CALDAV`]),
|
||||
//! `card:` for CardDAV ([`CARDDAV`]), `cs:` for CalendarServer ([`CALENDARSERVER`]), `oc:` and
|
||||
//! `nc:` for ownCloud and Nextcloud ([`OWNCLOUD`], [`NEXTCLOUD`]); a name in any other namespace
|
||||
//! starts with `?:` and matches none of them. [`responses`], [`href`] and [`properties`] walk it.
|
||||
//!
|
||||
//! Nothing here does I/O. The reader fails closed: a document type declaration, an entity that
|
||||
//! is not predefined, a character XML cannot carry, malformed or unbalanced markup, a second root,
|
||||
//! text outside the root, a root that is not `d:multistatus` and anything past the limits make
|
||||
//! the whole document an error; a partial tree is never returned.
|
||||
|
||||
use craft_xml::{Canonical, Doctype, El, Limits, Options};
|
||||
|
||||
/// The WebDAV namespace.
|
||||
pub const DAV: &str = "DAV:";
|
||||
/// RFC 4791.
|
||||
pub const CALDAV: &str = "urn:ietf:params:xml:ns:caldav";
|
||||
/// RFC 6352.
|
||||
pub const CARDDAV: &str = "urn:ietf:params:xml:ns:carddav";
|
||||
/// Apple's calendar server extensions (`getctag`).
|
||||
pub const CALENDARSERVER: &str = "http://calendarserver.org/ns/";
|
||||
pub const OWNCLOUD: &str = "http://owncloud.org/ns";
|
||||
pub const NEXTCLOUD: &str = "http://nextcloud.org/ns";
|
||||
|
||||
/// Most bytes of a document.
|
||||
pub const MAX_BYTES: usize = 16 * 1024 * 1024;
|
||||
/// Most nodes (elements and stretches of text) in a document.
|
||||
pub const MAX_NODES: usize = 100_000;
|
||||
/// Deepest nesting of elements, the root at 1.
|
||||
pub const MAX_DEPTH: usize = 32;
|
||||
/// Most attributes on one element, and in the whole document.
|
||||
pub const MAX_ATTRS: usize = 64;
|
||||
pub const MAX_ATTRS_TOTAL: usize = 200_000;
|
||||
|
||||
const NAMESPACES: [(&str, &str); 6] = [("d", DAV), ("c", CALDAV), ("card", CARDDAV), ("cs", CALENDARSERVER), ("oc", OWNCLOUD), ("nc", NEXTCLOUD)];
|
||||
const CANONICAL: Canonical<'static> = Canonical::new(&NAMESPACES);
|
||||
|
||||
/// Why a document was not read.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// Not well-formed XML within the limits, or refused (see the crate's documentation).
|
||||
Xml(craft_xml::Error),
|
||||
/// Text that is not UTF-8, or a character XML cannot carry.
|
||||
Characters,
|
||||
/// Well-formed, but its root is not `DAV:multistatus`.
|
||||
NotMultistatus,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::Xml(e) => write!(f, "invalid or oversized DAV XML: {e}"),
|
||||
Error::Characters => f.write_str("DAV XML with text that is not UTF-8 XML characters"),
|
||||
Error::NotMultistatus => f.write_str("DAV XML that is not a multistatus"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
/// Read one UTF-8 multistatus document (see the crate's documentation). The root is named
|
||||
/// `d:multistatus`; namespace declarations are not kept as attributes.
|
||||
pub fn parse_multistatus(bytes: &[u8]) -> Result<El, Error> {
|
||||
if bytes.len() > MAX_BYTES {
|
||||
return Err(Error::Xml(craft_xml::Error::TooLarge(MAX_BYTES)));
|
||||
}
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| Error::Characters)?;
|
||||
if !text.chars().all(craft_xml::is_xml_char) {
|
||||
return Err(Error::Characters);
|
||||
}
|
||||
let limits = Limits { max_input: MAX_BYTES, max_depth: MAX_DEPTH, max_nodes: MAX_NODES, max_attrs: MAX_ATTRS, max_attrs_total: MAX_ATTRS_TOTAL };
|
||||
let options = Options { doctype: Doctype::Reject, ..Options::canonical(&CANONICAL).strict().with_limits(limits) };
|
||||
let root = options.parse(bytes).map_err(Error::Xml)?;
|
||||
if root.name != "d:multistatus" {
|
||||
return Err(Error::NotMultistatus);
|
||||
}
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
/// The `d:response` elements of a multistatus.
|
||||
pub fn responses(multistatus: &El) -> impl Iterator<Item = &El> {
|
||||
multistatus.children("d:response")
|
||||
}
|
||||
|
||||
/// The `d:href` of a response, trimmed (still percent-encoded, as the server wrote it).
|
||||
pub fn href(response: &El) -> Option<String> {
|
||||
response.child_text("d:href").map(|h| h.trim().to_string()).filter(|h| !h.is_empty())
|
||||
}
|
||||
|
||||
/// Whether a `d:status` line (`HTTP/1.1 200 OK`) says 200.
|
||||
pub fn is_ok_status(status: &str) -> bool {
|
||||
let mut words = status.split_whitespace();
|
||||
words.next().is_some_and(|v| v.starts_with("HTTP/")) && words.next() == Some("200")
|
||||
}
|
||||
|
||||
/// The properties a response returned in a successful (`200`) propstat, in document order. A
|
||||
/// property in a failed propstat (404, 403) is not one: a property missing here says nothing
|
||||
/// about whether the resource still exists.
|
||||
pub fn properties(response: &El) -> impl Iterator<Item = &El> {
|
||||
response
|
||||
.children("d:propstat")
|
||||
.filter(|p| p.child_text("d:status").is_some_and(|s| is_ok_status(&s)))
|
||||
.filter_map(|p| p.child("d:prop"))
|
||||
.flat_map(El::elements)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,93 @@
|
||||
use proptest::prelude::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn namespaces_entities_cdata_and_failed_properties_stay_distinct() {
|
||||
let root = parse_multistatus(br#"<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:e="evil"><d:response><d:href>/a&b/c</d:href><d:propstat><d:prop><e:getetag>wrong</e:getetag></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat><d:propstat><d:prop><d:getetag>"v1"</d:getetag><c:calendar-data><![CDATA[BEGIN:VCALENDAR]]></c:calendar-data><c:comp name="VEVENT"/></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#).unwrap();
|
||||
let row = responses(&root).next().unwrap();
|
||||
assert_eq!(href(row).as_deref(), Some("/a&b/c"));
|
||||
let props: Vec<_> = properties(row).collect();
|
||||
assert_eq!(props.len(), 3);
|
||||
assert_eq!(props[0].name, "d:getetag");
|
||||
assert_eq!(props[0].text(), "\"v1\"");
|
||||
assert_eq!((props[1].name.as_str(), props[1].text().as_ref()), ("c:calendar-data", "BEGIN:VCALENDAR"));
|
||||
assert_eq!(props[2].attr("name"), Some("VEVENT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_prefix_and_the_default_namespace_read_the_same() {
|
||||
let a = parse_multistatus(b"<multistatus xmlns='DAV:' xmlns:C='urn:ietf:params:xml:ns:carddav'><response><href>/x</href><propstat><prop><C:address-data>v</C:address-data></prop><status>HTTP/1.1 200 OK</status></propstat></response></multistatus>").unwrap();
|
||||
let b = parse_multistatus(b"<D:multistatus xmlns:D='DAV:'><D:response><D:href>/x</D:href><D:propstat><D:prop><address-data xmlns='urn:ietf:params:xml:ns:carddav'>v</address-data></D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response></D:multistatus>").unwrap();
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(properties(responses(&a).next().unwrap()).next().map(|p| p.name.as_str()), Some("card:address-data"));
|
||||
// Other namespaces never pass for these.
|
||||
let other = parse_multistatus(b"<multistatus xmlns='DAV:'><response><x:href xmlns:x='urn:other'>/x</x:href></response></multistatus>").unwrap();
|
||||
assert_eq!(href(responses(&other).next().unwrap()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statuses_are_read_strictly() {
|
||||
assert!(is_ok_status("HTTP/1.1 200 OK") && is_ok_status(" HTTP/2 200"));
|
||||
for bad in ["", "200", "HTTP/1.1 2000", "HTTP/1.1 404 Not Found", "OK 200", "http/1.1 200"] {
|
||||
assert!(!is_ok_status(bad), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dtd_unknown_entities_namespaces_and_incomplete_trees_fail_closed() {
|
||||
for text in [
|
||||
"<!DOCTYPE x [<!ENTITY a SYSTEM 'file:///not-read'>]><multistatus xmlns='DAV:'/>",
|
||||
"<!DOCTYPE multistatus><multistatus xmlns='DAV:'/>",
|
||||
"<multistatus xmlns='DAV:'>&unknown;</multistatus>",
|
||||
"<d:multistatus/>",
|
||||
"<multistatus xmlns='DAV:'><response>",
|
||||
"<multistatus xmlns='DAV:'><response></multistatus>",
|
||||
"<multistatus xmlns='DAV:'/><multistatus xmlns='DAV:'/>",
|
||||
"not XML<multistatus xmlns='DAV:'/>",
|
||||
"\u{a0}<multistatus xmlns='DAV:'/>",
|
||||
"<multistatus xmlns='wrong'/>",
|
||||
"<multistatus/>",
|
||||
"<multistatus xmlns='DAV:'>\0</multistatus>",
|
||||
"<multistatus xmlns='DAV:'>�</multistatus>",
|
||||
"<multistatus xmlns='DAV:' name='�'/>",
|
||||
"",
|
||||
] {
|
||||
assert!(parse_multistatus(text.as_bytes()).is_err(), "{text}");
|
||||
}
|
||||
assert_eq!(parse_multistatus(&[0xff]), Err(Error::Characters));
|
||||
assert_eq!(parse_multistatus(b"<propfind xmlns='DAV:'/>"), Err(Error::NotMultistatus));
|
||||
assert!(parse_multistatus(&vec![b' '; MAX_BYTES + 1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_node_and_attribute_counts_are_bounded() {
|
||||
let deep = |levels: usize| format!("<multistatus xmlns='DAV:'>{}text{}</multistatus>", "<x>".repeat(levels), "</x>".repeat(levels));
|
||||
assert!(parse_multistatus(deep(MAX_DEPTH - 1).as_bytes()).is_ok());
|
||||
assert!(parse_multistatus(deep(MAX_DEPTH).as_bytes()).is_err());
|
||||
let many = format!("<multistatus xmlns='DAV:'>{}</multistatus>", "<response/>".repeat(MAX_NODES));
|
||||
assert!(parse_multistatus(many.as_bytes()).is_err());
|
||||
let attrs: String = (0..=MAX_ATTRS).map(|i| format!(" a{i}='x'")).collect();
|
||||
assert!(parse_multistatus(format!("<multistatus xmlns='DAV:'><response{attrs}/></multistatus>").as_bytes()).is_err());
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn arbitrary_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..4096)) {
|
||||
let _ = parse_multistatus(&bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_documents_never_panic(cut in 0usize..400, at in 0usize..400, byte in any::<u8>()) {
|
||||
let mut doc = br#"<d:multistatus xmlns:d="DAV:"><d:response><d:href>/a</d:href><d:propstat><d:prop><d:getetag>"1"</d:getetag></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#.to_vec();
|
||||
if let Some(b) = doc.get_mut(at) {
|
||||
*b = byte;
|
||||
}
|
||||
doc.truncate(cut.max(1));
|
||||
if let Ok(root) = parse_multistatus(&doc) {
|
||||
for r in responses(&root) {
|
||||
let _ = (href(r), properties(r).count());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user