Skip to main content

aster_forge_webdav/
xml.rs

1//! WebDAV XML grammar and representation boundary.
2//!
3//! The concrete XML implementation is intentionally private to this module. Products consume
4//! WebDAV-specific request models and [`DavXmlElement`] instead of depending on an XML crate.
5
6use std::collections::BTreeMap;
7use std::io::Read;
8
9use aster_forge_xml::{
10    BorrowedDocument, ElementRef, Error as ForgeXmlError, NodeRef, OwnedDocument, ParseOptions,
11    XmlSafetyError, XmlSafetyPolicy, XmlStreamWriter, XmlWriteAttribute,
12};
13
14const DAV_NAMESPACE: &str = "DAV:";
15
16/// XML failure returned by the WebDAV grammar boundary.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
18pub enum DavXmlError {
19    /// The document declares a DTD or entity.
20    #[error("XML external entity declarations are not allowed")]
21    ExternalEntity,
22    /// The document exceeds the configured nesting depth.
23    #[error("XML nesting depth exceeds the configured limit")]
24    TooDeep,
25    /// The request XML exceeds an input or decoded-text size limit.
26    #[error("XML input exceeds the configured size limit")]
27    TooLarge,
28    /// The document is malformed or is not a single-root document.
29    #[error("malformed XML input")]
30    Malformed,
31    /// The document is well-formed XML but violates the method grammar.
32    #[error("invalid WebDAV XML grammar")]
33    InvalidGrammar,
34}
35
36impl From<XmlSafetyError> for DavXmlError {
37    fn from(error: XmlSafetyError) -> Self {
38        match error {
39            XmlSafetyError::ExternalEntity => Self::ExternalEntity,
40            XmlSafetyError::TooDeep => Self::TooDeep,
41            XmlSafetyError::InputTooLarge | XmlSafetyError::TextTooLarge => Self::TooLarge,
42            XmlSafetyError::InvalidPolicy
43            | XmlSafetyError::OutputTooLarge
44            | XmlSafetyError::TooManyElements
45            | XmlSafetyError::TooManyAttributes
46            | XmlSafetyError::TooManyEvents
47            | XmlSafetyError::InvalidEncoding
48            | XmlSafetyError::Malformed => Self::Malformed,
49        }
50    }
51}
52
53/// XML content owned by the WebDAV boundary.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum DavXmlNode {
56    /// Child element.
57    Element(DavXmlElement),
58    /// Escaped character data.
59    Text(String),
60    /// CDATA content.
61    CData(String),
62    /// Comment content.
63    Comment(String),
64    /// Processing instruction.
65    ProcessingInstruction(String, Option<String>),
66}
67
68/// Owned DAV element used for persisted subtrees and response composition.
69///
70/// Known request grammars traverse the source-backed `aster_forge_xml` arena directly and only
71/// materialize the owner or property subtrees that must cross the backend boundary.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct DavXmlElement {
74    /// Local element name.
75    pub name: String,
76    /// Lexical prefix, when present.
77    pub prefix: Option<String>,
78    /// Resolved namespace URI, when present.
79    pub namespace: Option<String>,
80    /// In-scope namespace declarations keyed by prefix; an empty key is the default namespace.
81    pub namespaces: BTreeMap<String, String>,
82    /// Element attributes in their lexical form.
83    pub attributes: BTreeMap<String, String>,
84    /// Ordered child content.
85    pub children: Vec<DavXmlNode>,
86}
87
88impl DavXmlElement {
89    /// Creates an element from a lexical QName such as `D:href`.
90    #[must_use]
91    pub fn new(name: &str) -> Self {
92        let (prefix, local_name) = name
93            .split_once(':')
94            .map_or((None, name), |(prefix, local)| {
95                (Some(prefix.to_owned()), local)
96            });
97        Self {
98            name: local_name.to_owned(),
99            prefix,
100            namespace: None,
101            namespaces: BTreeMap::new(),
102            attributes: BTreeMap::new(),
103            children: Vec::new(),
104        }
105    }
106
107    /// Creates a `DAV:` element using the conventional `D` prefix.
108    #[must_use]
109    pub fn dav(local_name: &str) -> Self {
110        let mut element = Self::new(&format!("D:{local_name}"));
111        element.namespace = Some(DAV_NAMESPACE.to_owned());
112        element
113    }
114
115    /// Parses one bounded XML element.
116    pub fn parse(bytes: &[u8]) -> Result<Self, DavXmlError> {
117        parse_element(bytes)
118    }
119
120    /// Parses one bounded XML element from a reader.
121    pub fn parse_reader(reader: impl Read) -> Result<Self, DavXmlError> {
122        let options = webdav_parse_options();
123        let document = OwnedDocument::from_reader_with_options(reader, &options)
124            .map_err(map_forge_xml_error)?;
125        Ok(element_from_forge(document.root()))
126    }
127
128    /// Serializes the element as UTF-8 XML bytes.
129    pub fn to_bytes(&self) -> Result<Vec<u8>, DavXmlError> {
130        let mut writer = XmlStreamWriter::new(Vec::new()).map_err(map_forge_xml_error)?;
131        write_element(&mut writer, self, &BTreeMap::new())?;
132        writer.finish().map_err(map_forge_xml_error)
133    }
134
135    /// Iterates over direct child elements while ignoring text, comments, and CDATA.
136    pub fn child_elements(&self) -> impl Iterator<Item = &Self> {
137        self.children.iter().filter_map(|child| match child {
138            DavXmlNode::Element(element) => Some(element),
139            DavXmlNode::Text(_)
140            | DavXmlNode::CData(_)
141            | DavXmlNode::Comment(_)
142            | DavXmlNode::ProcessingInstruction(_, _) => None,
143        })
144    }
145
146    /// Returns concatenated direct text and CDATA content.
147    #[must_use]
148    pub fn text(&self) -> Option<String> {
149        let text = self
150            .children
151            .iter()
152            .filter_map(|child| match child {
153                DavXmlNode::Text(text) | DavXmlNode::CData(text) => Some(text.as_str()),
154                _ => None,
155            })
156            .collect::<String>();
157        (!text.is_empty()).then_some(text)
158    }
159}
160
161/// Property name selected by PROPFIND.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct DavRequestedProperty {
164    /// Local property name.
165    pub name: String,
166    /// Resolved namespace URI.
167    pub namespace: Option<String>,
168    /// Client-supplied lexical prefix.
169    pub prefix: Option<String>,
170}
171
172/// Parsed PROPFIND request selector.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub enum DavPropfindRequest {
175    /// All live/dead properties plus optional explicit properties.
176    AllProp {
177        /// Additional requested properties.
178        include: Vec<DavRequestedProperty>,
179    },
180    /// Property names without values.
181    PropName,
182    /// Explicit property selection.
183    Prop(Vec<DavRequestedProperty>),
184}
185
186/// One ordered PROPPATCH operation.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct DavPropertyPatchRequest {
189    /// Whether the operation sets rather than removes the property.
190    pub set: bool,
191    /// Property value/name.
192    pub property: DavPropertyPatchValue,
193}
194
195/// Validated property element carried by PROPPATCH.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct DavPropertyPatchValue {
198    /// Local property name.
199    pub name: String,
200    /// Resolved namespace URI.
201    pub namespace: Option<String>,
202    /// Lexical prefix.
203    pub prefix: Option<String>,
204    /// Standalone validated element, including inherited `xml:lang` when needed.
205    pub element: DavXmlElement,
206}
207
208/// Parsed LOCK creation body.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct DavLockRequestBody {
211    /// Whether the requested lock scope is shared.
212    pub shared: bool,
213    /// Optional owner element, preserved for discovery and persistence.
214    pub owner: Option<DavXmlElement>,
215}
216
217/// Parses a PROPFIND body. An absent body selects `allprop`.
218pub fn parse_propfind_request(body: &[u8]) -> Result<DavPropfindRequest, DavXmlError> {
219    if body.is_empty() {
220        return Ok(DavPropfindRequest::AllProp {
221            include: Vec::new(),
222        });
223    }
224    let document = parse_document(body)?;
225    let root = document.root();
226    if !is_dav_element(root, "propfind") {
227        return Err(DavXmlError::InvalidGrammar);
228    }
229
230    let mut kind = None;
231    let mut include = Vec::new();
232    let mut include_seen = false;
233    for child in root.child_elements() {
234        if is_dav_element(child, "propname") {
235            if kind.is_some() {
236                return Err(DavXmlError::InvalidGrammar);
237            }
238            kind = Some(DavPropfindRequest::PropName);
239        } else if is_dav_element(child, "allprop") {
240            if kind.is_some() {
241                return Err(DavXmlError::InvalidGrammar);
242            }
243            kind = Some(DavPropfindRequest::AllProp {
244                include: Vec::new(),
245            });
246        } else if is_dav_element(child, "include") {
247            if include_seen {
248                return Err(DavXmlError::InvalidGrammar);
249            }
250            include_seen = true;
251            include.extend(child.child_elements().map(requested_property));
252        } else if is_dav_element(child, "prop") {
253            if kind.is_some() {
254                return Err(DavXmlError::InvalidGrammar);
255            }
256            kind = Some(DavPropfindRequest::Prop(
257                child.child_elements().map(requested_property).collect(),
258            ));
259        }
260    }
261
262    match kind {
263        Some(DavPropfindRequest::AllProp { .. }) => Ok(DavPropfindRequest::AllProp { include }),
264        Some(kind) if !include_seen => Ok(kind),
265        _ => Err(DavXmlError::InvalidGrammar),
266    }
267}
268
269/// Parses an ordered PROPPATCH request.
270pub fn parse_proppatch_request(body: &[u8]) -> Result<Vec<DavPropertyPatchRequest>, DavXmlError> {
271    let document = parse_document(body)?;
272    let root = document.root();
273    if !is_dav_element(root, "propertyupdate") {
274        return Err(DavXmlError::InvalidGrammar);
275    }
276    let root_lang = xml_lang_value(root).map(str::to_owned);
277    let mut patches = Vec::new();
278    for action in root.child_elements() {
279        let set = if is_dav_element(action, "set") {
280            true
281        } else if is_dav_element(action, "remove") {
282            false
283        } else {
284            // RFC 4918 section 17: unknown extension elements are ignored with their subtree.
285            continue;
286        };
287        let action_lang = xml_lang_value(action).or(root_lang.as_deref());
288        let dav_children = action
289            .child_elements()
290            .filter(|child| child.namespace() == Some(DAV_NAMESPACE))
291            .collect::<Vec<_>>();
292        if !matches!(dav_children.as_slice(), [child] if is_dav_element(*child, "prop")) {
293            return Err(DavXmlError::InvalidGrammar);
294        }
295        let prop_container = dav_children[0];
296        let container_lang = xml_lang_value(prop_container).or(action_lang);
297        for property in prop_container.child_elements() {
298            let mut element = element_from_forge(property);
299            let inherited_lang = xml_lang_value(property).or(container_lang);
300            if let Some(lang) = inherited_lang.filter(|lang| !lang.is_empty()) {
301                element
302                    .attributes
303                    .entry("xml:lang".to_owned())
304                    .or_insert_with(|| lang.to_owned());
305            }
306            patches.push(DavPropertyPatchRequest {
307                set,
308                property: DavPropertyPatchValue {
309                    name: element.name.clone(),
310                    namespace: element.namespace.clone(),
311                    prefix: element.prefix.clone(),
312                    element,
313                },
314            });
315        }
316    }
317    if patches.is_empty() {
318        return Err(DavXmlError::InvalidGrammar);
319    }
320    Ok(patches)
321}
322
323/// Parses a LOCK creation body.
324pub fn parse_lock_request(body: &[u8]) -> Result<DavLockRequestBody, DavXmlError> {
325    let document = parse_document(body)?;
326    let root = document.root();
327    if !is_dav_element(root, "lockinfo") {
328        return Err(DavXmlError::InvalidGrammar);
329    }
330    let mut shared = None;
331    let mut write_lock = false;
332    let mut owner = None;
333    for child in root.child_elements() {
334        if is_dav_element(child, "lockscope") {
335            if shared.is_some() {
336                return Err(DavXmlError::InvalidGrammar);
337            }
338            let children = child
339                .child_elements()
340                .filter(|scope| scope.namespace() == Some(DAV_NAMESPACE))
341                .collect::<Vec<_>>();
342            shared = match children.as_slice() {
343                [scope] if is_dav_element(*scope, "exclusive") => Some(false),
344                [scope] if is_dav_element(*scope, "shared") => Some(true),
345                _ => return Err(DavXmlError::InvalidGrammar),
346            };
347        } else if is_dav_element(child, "locktype") {
348            if write_lock {
349                return Err(DavXmlError::InvalidGrammar);
350            }
351            let children = child
352                .child_elements()
353                .filter(|kind| kind.namespace() == Some(DAV_NAMESPACE))
354                .collect::<Vec<_>>();
355            if !matches!(children.as_slice(), [kind] if is_dav_element(*kind, "write")) {
356                return Err(DavXmlError::InvalidGrammar);
357            }
358            write_lock = true;
359        } else if is_dav_element(child, "owner") {
360            if owner.is_some() {
361                return Err(DavXmlError::InvalidGrammar);
362            }
363            owner = Some(element_from_forge(child));
364        }
365    }
366    match (shared, write_lock) {
367        (Some(shared), true) => Ok(DavLockRequestBody { shared, owner }),
368        _ => Err(DavXmlError::InvalidGrammar),
369    }
370}
371
372/// Returns the QName of a bounded REPORT root.
373pub fn parse_report_root(body: &[u8]) -> Result<DavRequestedProperty, DavXmlError> {
374    let document = parse_document(body)?;
375    Ok(requested_property(document.root()))
376}
377
378fn parse_element(bytes: &[u8]) -> Result<DavXmlElement, DavXmlError> {
379    let document = parse_document(bytes)?;
380    Ok(element_from_forge(document.root()))
381}
382
383fn parse_document(bytes: &[u8]) -> Result<BorrowedDocument<'_>, DavXmlError> {
384    // The Forge parser applies the WebDAV safety policy while building its source-backed arena.
385    // A separate validator pass here would scan every request twice.
386    BorrowedDocument::parse_with_options(bytes, &webdav_parse_options())
387        .map_err(map_forge_xml_error)
388}
389
390fn is_dav_element<S: AsRef<[u8]>>(element: ElementRef<'_, S>, local_name: &str) -> bool {
391    element.name() == local_name && element.namespace() == Some(DAV_NAMESPACE)
392}
393
394fn requested_property<S: AsRef<[u8]>>(element: ElementRef<'_, S>) -> DavRequestedProperty {
395    DavRequestedProperty {
396        name: element.name().to_owned(),
397        namespace: element.namespace().map(str::to_owned),
398        prefix: element.prefix().map(str::to_owned),
399    }
400}
401
402fn xml_lang_value<'document, S: AsRef<[u8]>>(
403    element: ElementRef<'document, S>,
404) -> Option<&'document str> {
405    element.attribute("xml:lang")
406}
407
408fn webdav_parse_options() -> ParseOptions {
409    // Preserve the established WebDAV XML boundary: formatting whitespace is ignored and retained
410    // text is trimmed before WebDAV grammar evaluation or dead-property persistence.
411    ParseOptions::new()
412        .safety_policy(XmlSafetyPolicy::untrusted())
413        .trim_whitespace(true)
414}
415
416fn map_forge_xml_error(error: ForgeXmlError) -> DavXmlError {
417    match error {
418        ForgeXmlError::Safety(error) => error.into(),
419        ForgeXmlError::InvalidXml(_) | ForgeXmlError::InvalidData(_) | ForgeXmlError::Io(_) => {
420            DavXmlError::Malformed
421        }
422    }
423}
424
425fn element_from_forge<S: AsRef<[u8]>>(element: ElementRef<'_, S>) -> DavXmlElement {
426    let mut namespaces = BTreeMap::new();
427    match (element.prefix(), element.namespace()) {
428        (Some(prefix), Some(namespace)) if prefix != "xml" => {
429            namespaces.insert(prefix.to_owned(), namespace.to_owned());
430        }
431        (None, Some(namespace)) => {
432            namespaces.insert(String::new(), namespace.to_owned());
433        }
434        // This owned subtree may later be embedded under a default namespace. Declaring the
435        // empty namespace keeps an originally unqualified element unqualified.
436        (None, None) => {
437            namespaces.insert(String::new(), String::new());
438        }
439        _ => {}
440    }
441
442    let mut attributes = BTreeMap::new();
443    for attribute in element.attributes() {
444        if let (Some(prefix), Some(namespace)) = (attribute.prefix(), attribute.namespace())
445            && prefix != "xml"
446        {
447            namespaces
448                .entry(prefix.to_owned())
449                .or_insert_with(|| namespace.to_owned());
450        }
451        attributes.insert(
452            attribute.qualified_name().to_owned(),
453            attribute.value().to_owned(),
454        );
455    }
456
457    DavXmlElement {
458        name: element.name().to_owned(),
459        prefix: element.prefix().map(str::to_owned),
460        namespace: element.namespace().map(str::to_owned),
461        namespaces,
462        attributes,
463        children: element
464            .children()
465            .map(|child| match child {
466                NodeRef::Element(element) => DavXmlNode::Element(element_from_forge(element)),
467                NodeRef::Text(text) => DavXmlNode::Text(text.to_owned()),
468                NodeRef::CData(text) => DavXmlNode::CData(text.to_owned()),
469                NodeRef::Comment(text) => DavXmlNode::Comment(text.to_owned()),
470                NodeRef::ProcessingInstruction(instruction) => DavXmlNode::ProcessingInstruction(
471                    instruction.target.to_owned(),
472                    instruction.content.map(str::to_owned),
473                ),
474            })
475            .collect(),
476    }
477}
478
479fn write_element(
480    writer: &mut XmlStreamWriter<Vec<u8>>,
481    element: &DavXmlElement,
482    inherited_namespaces: &BTreeMap<String, String>,
483) -> Result<(), DavXmlError> {
484    let qualified_name = element.prefix.as_ref().map_or_else(
485        || element.name.clone(),
486        |prefix| format!("{prefix}:{}", element.name),
487    );
488    let mut namespaces = inherited_namespaces.clone();
489    let mut attributes = BTreeMap::new();
490    for (prefix, namespace) in &element.namespaces {
491        if namespaces.get(prefix) != Some(namespace) {
492            let name = if prefix.is_empty() {
493                "xmlns".to_owned()
494            } else {
495                format!("xmlns:{prefix}")
496            };
497            attributes.insert(name, namespace.clone());
498        }
499        namespaces.insert(prefix.clone(), namespace.clone());
500    }
501    attributes.extend(element.attributes.clone());
502    for (name, namespace) in &attributes {
503        if let Some(prefix) = namespace_declaration_prefix(name) {
504            namespaces.insert(prefix.to_owned(), namespace.clone());
505        }
506    }
507
508    if let Some(namespace) = &element.namespace {
509        let prefix = element.prefix.as_deref().unwrap_or("");
510        if namespaces.get(prefix).map(String::as_str) != Some(namespace) {
511            let binding_name = if prefix.is_empty() {
512                "xmlns".to_owned()
513            } else {
514                format!("xmlns:{prefix}")
515            };
516            match attributes.get(&binding_name) {
517                Some(binding) if binding != namespace => return Err(DavXmlError::Malformed),
518                Some(_) => {}
519                None => {
520                    attributes.insert(binding_name, namespace.clone());
521                }
522            }
523            namespaces.insert(prefix.to_owned(), namespace.clone());
524        }
525    } else if element.prefix.is_none()
526        && namespaces
527            .get("")
528            .is_some_and(|namespace| !namespace.is_empty())
529    {
530        match attributes.get("xmlns") {
531            Some(namespace) if !namespace.is_empty() => return Err(DavXmlError::Malformed),
532            Some(_) => {}
533            None => {
534                attributes.insert("xmlns".to_owned(), String::new());
535            }
536        }
537        namespaces.insert(String::new(), String::new());
538    }
539
540    let write_attributes = attributes
541        .iter()
542        .map(|(name, value)| XmlWriteAttribute::new(name, value));
543    if element.children.is_empty() {
544        writer
545            .empty_element(&qualified_name, write_attributes)
546            .map_err(map_forge_xml_error)?;
547        return Ok(());
548    }
549    writer
550        .start_element(&qualified_name, write_attributes)
551        .map_err(map_forge_xml_error)?;
552    for child in &element.children {
553        match child {
554            DavXmlNode::Element(element) => write_element(writer, element, &namespaces)?,
555            DavXmlNode::Text(text) => writer.text(text).map_err(map_forge_xml_error)?,
556            DavXmlNode::CData(text) => writer.cdata(text).map_err(map_forge_xml_error)?,
557            DavXmlNode::Comment(text) => writer.comment(text).map_err(map_forge_xml_error)?,
558            DavXmlNode::ProcessingInstruction(target, content) => {
559                writer
560                    .processing_instruction(target, content.as_deref())
561                    .map_err(map_forge_xml_error)?;
562            }
563        }
564    }
565    writer.end_element().map_err(map_forge_xml_error)
566}
567
568fn namespace_declaration_prefix(name: &str) -> Option<&str> {
569    if name == "xmlns" {
570        Some("")
571    } else {
572        name.strip_prefix("xmlns:")
573    }
574}