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:
Dylan M. Taylor
2026-11-11 20:51:12 -05:00
committed by GitHub
co-authored by Martin Ball
parent b85549a5b4
commit d197b1e72f
7 changed files with 284 additions and 0 deletions
Generated
+8
View File
@@ -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"
+1
View File
@@ -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" }
+3
View File
@@ -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).
+17
View File
@@ -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 }
+48
View File
@@ -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`.
+114
View File
@@ -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;
+93
View File
@@ -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&amp;b&#x2f;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>&quot;v1&quot;</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:'>&#0;</multistatus>",
"<multistatus xmlns='DAV:' name='&#0;'/>",
"",
] {
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());
}
}
}
}