From d197b1e72f2108df155133ce39e0736ec2d7d2e2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 20:51:12 -0500 Subject: [PATCH] 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> --- Cargo.lock | 8 +++ Cargo.toml | 1 + README.md | 3 ++ crates/dav/Cargo.toml | 17 ++++++ crates/dav/README.md | 48 +++++++++++++++++ crates/dav/src/lib.rs | 114 ++++++++++++++++++++++++++++++++++++++++ crates/dav/src/tests.rs | 93 ++++++++++++++++++++++++++++++++ 7 files changed, 284 insertions(+) create mode 100644 crates/dav/Cargo.toml create mode 100644 crates/dav/README.md create mode 100644 crates/dav/src/lib.rs create mode 100644 crates/dav/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index e7b0a242..fa5ca140 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index f5a0c2fb..ab3d8796 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/README.md b/README.md index be1fcaac..d638b5ff 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " { /* 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`. diff --git a/crates/dav/src/lib.rs b/crates/dav/src/lib.rs new file mode 100644 index 00000000..87040fea --- /dev/null +++ b/crates/dav/src/lib.rs @@ -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 { + 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 { + 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 { + 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 { + 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; diff --git a/crates/dav/src/tests.rs b/crates/dav/src/tests.rs new file mode 100644 index 00000000..a2a8361d --- /dev/null +++ b/crates/dav/src/tests.rs @@ -0,0 +1,93 @@ +use proptest::prelude::*; + +use super::*; + +#[test] +fn namespaces_entities_cdata_and_failed_properties_stay_distinct() { + let root = parse_multistatus(br#"/a&b/cwrongHTTP/1.1 404 Not Found"v1"HTTP/1.1 200 OK"#).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"/xvHTTP/1.1 200 OK").unwrap(); + let b = parse_multistatus(b"/xvHTTP/1.1 200 OK").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"/x").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 [ + "]>", + "", + "&unknown;", + "", + "", + "", + "", + "not XML", + "\u{a0}", + "", + "", + "\0", + "�", + "", + "", + ] { + assert!(parse_multistatus(text.as_bytes()).is_err(), "{text}"); + } + assert_eq!(parse_multistatus(&[0xff]), Err(Error::Characters)); + assert_eq!(parse_multistatus(b""), 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!("{}text{}", "".repeat(levels), "".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!("{}", "".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!("").as_bytes()).is_err()); +} + +proptest! { + #[test] + fn arbitrary_bytes_never_panic(bytes in proptest::collection::vec(any::(), 0..4096)) { + let _ = parse_multistatus(&bytes); + } + + #[test] + fn mutated_documents_never_panic(cut in 0usize..400, at in 0usize..400, byte in any::()) { + let mut doc = br#"/a"1"HTTP/1.1 200 OK"#.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()); + } + } + } +}