libdav 0.10.3

CalDAV and CardDAV client implementations.
Documentation
//! `SetProperty` request for setting or removing a property.

use http::Method;

use crate::{
    PropertyName, names,
    requests::{DavRequest, ParseResponseError, PreparedRequest, xml_content_type_header},
    xmlutils::{XmlNode, check_multistatus, validate_xml_response},
};

/// Request to set or remove a property on a resource.
///
/// Sends a PROPPATCH request to set or remove a single property.
/// Setting the value to `None` removes the property.
///
/// # Quirks
///
/// The namespace of values in the response from the server is ignored. This is a workaround
/// for an [issue in `cyrus-imapd`][cyrus-issue].
///
/// [cyrus-issue]: https://github.com/cyrusimap/cyrus-imapd/issues/4489
///
/// # See also
///
/// - [`crate::dav::GetProperty`] (contains a list of some included well-known properties).
/// - <https://www.rfc-editor.org/rfc/rfc4918#section-9.2>
pub struct SetProperty<'a> {
    href: &'a str,
    property: &'a PropertyName<'a, 'a>,
    value: Option<&'a str>,
}

impl<'a> SetProperty<'a> {
    /// Create a new request to set or remove a property.
    ///
    /// `href` should be a path relative to the server's base URL.
    /// Pass `None` for the value to remove the property.
    /// The provided value does not need to be XML-escaped.
    #[must_use]
    pub fn new(href: &'a str, property: &'a PropertyName<'a, 'a>, value: Option<&'a str>) -> Self {
        Self {
            href,
            property,
            value,
        }
    }
}

/// Response from a [`SetProperty`] request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetPropertyResponse {
    /// The property value as returned by the server.
    ///
    /// `None` if the property has no text content.
    pub value: Option<String>,
}

impl DavRequest for SetProperty<'_> {
    type Response = SetPropertyResponse;
    type ParseError = ParseResponseError;
    type Error<E> = crate::dav::WebDavError<E>;

    fn prepare_request(&self) -> Result<PreparedRequest, http::Error> {
        let mut property_node = XmlNode::new(self.property);
        if let Some(value) = self.value {
            property_node.characters = value;
        }

        let mut prop = XmlNode::new(&names::PROP);
        prop.children = vec![property_node];

        let action_name = if self.value.is_some() {
            &names::SET
        } else {
            &names::REMOVE
        };
        let mut action = XmlNode::new(action_name);
        action.children = vec![prop];

        let mut propertyupdate = XmlNode::new(&names::PROPERTYUPDATE);
        propertyupdate.children = vec![action];

        Ok(PreparedRequest {
            method: Method::from_bytes(b"PROPPATCH")?,
            path: self.href.to_string(),
            body: propertyupdate.render_node(),
            headers: vec![xml_content_type_header()],
        })
    }

    fn parse_response(
        &self,
        parts: &http::response::Parts,
        body: &[u8],
    ) -> Result<Self::Response, ParseResponseError> {
        let doc = validate_xml_response(parts, body)?;
        let root = doc.root_element();

        let prop = root
            .descendants()
            .find(|node| node.tag_name() == *self.property)
            // Hack to work around: https://github.com/cyrusimap/cyrus-imapd/issues/4489
            .or_else(|| {
                root.descendants()
                    .find(|node| node.tag_name().name() == self.property.name())
            });

        // Property element may still be present (just empty) but with an error status.
        check_multistatus(root)?;

        if let Some(prop) = prop {
            return Ok(SetPropertyResponse {
                value: prop.text().map(str::to_string),
            });
        }

        Err(ParseResponseError::InvalidResponse(
            "Property is missing from response, but response is non-error.".into(),
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::names;
    use http::StatusCode;

    #[test]
    fn test_prepare_request_set() {
        let req = SetProperty::new(
            "/calendars/personal/",
            &names::DISPLAY_NAME,
            Some("My Calendar"),
        );
        let prepared = req.prepare_request().unwrap();

        assert_eq!(prepared.method, Method::from_bytes(b"PROPPATCH").unwrap());
        assert_eq!(prepared.path, "/calendars/personal/");
        assert!(prepared.body.contains("D:set"));
        assert!(prepared.body.contains("displayname"));
        assert!(prepared.body.contains("My Calendar"));
        assert!(prepared.body.contains(r#"xmlns:D="DAV:""#));
    }

    #[test]
    fn test_prepare_request_remove() {
        let req = SetProperty::new("/calendars/personal/", &names::DISPLAY_NAME, None);
        let prepared = req.prepare_request().unwrap();

        assert_eq!(prepared.method, Method::from_bytes(b"PROPPATCH").unwrap());
        assert!(prepared.body.contains("D:remove"));
        assert!(prepared.body.contains("displayname"));
        assert!(prepared.body.contains("/>"));
    }

    #[test]
    fn test_prepare_request_escapes_value() {
        let req = SetProperty::new(
            "/calendars/personal/",
            &names::DISPLAY_NAME,
            Some("<Test & \"Calendar\">"),
        );
        let prepared = req.prepare_request().unwrap();

        assert!(
            prepared
                .body
                .contains("&lt;Test &amp; &quot;Calendar&quot;&gt;")
        );
    }

    #[test]
    fn test_parse_response_success() {
        let req = SetProperty::new(
            "/calendars/personal/",
            &names::DISPLAY_NAME,
            Some("My Calendar"),
        );
        let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:">
  <response>
    <href>/calendars/personal/</href>
    <propstat>
      <prop>
        <displayname>My Calendar</displayname>
      </prop>
      <status>HTTP/1.1 200 OK</status>
    </propstat>
  </response>
</multistatus>"#;

        let response = http::Response::builder()
            .status(StatusCode::MULTI_STATUS)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, body).unwrap();

        assert_eq!(result.value, Some("My Calendar".to_string()));
    }

    #[test]
    fn test_parse_response_empty_after_remove() {
        let req = SetProperty::new("/calendars/personal/", &names::DISPLAY_NAME, None);
        let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:">
  <response>
    <href>/calendars/personal/</href>
    <propstat>
      <prop>
        <displayname/>
      </prop>
      <status>HTTP/1.1 200 OK</status>
    </propstat>
  </response>
</multistatus>"#;

        let response = http::Response::builder()
            .status(StatusCode::MULTI_STATUS)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, body).unwrap();

        assert_eq!(result.value, None);
    }

    #[test]
    fn test_parse_response_forbidden() {
        let req = SetProperty::new(
            "/calendars/personal/",
            &names::DISPLAY_NAME,
            Some("My Calendar"),
        );
        let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:">
  <response>
    <href>/calendars/personal/</href>
    <propstat>
      <prop>
        <displayname/>
      </prop>
      <status>HTTP/1.1 403 Forbidden</status>
    </propstat>
  </response>
</multistatus>"#;

        let response = http::Response::builder()
            .status(StatusCode::MULTI_STATUS)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, body);

        assert!(matches!(
            result,
            Err(ParseResponseError::BadStatusCode(StatusCode::FORBIDDEN))
        ));
    }

    #[test]
    fn test_parse_response_bad_http_status() {
        let req = SetProperty::new(
            "/calendars/personal/",
            &names::DISPLAY_NAME,
            Some("My Calendar"),
        );
        let response = http::Response::builder()
            .status(StatusCode::UNAUTHORIZED)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, b"");

        assert!(matches!(
            result,
            Err(ParseResponseError::BadStatusCode(StatusCode::UNAUTHORIZED))
        ));
    }

    #[test]
    fn test_parse_response_cyrus_quirk() {
        // Cyrus returns properties in wrong namespace
        let req = SetProperty::new(
            "/calendars/personal/",
            &names::CALENDAR_COLOUR,
            Some("#ff0000"),
        );
        let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:" xmlns:X="http://wrong.namespace/">
  <response>
    <href>/calendars/personal/</href>
    <propstat>
      <prop>
        <X:calendar-color>#ff0000</X:calendar-color>
      </prop>
      <status>HTTP/1.1 200 OK</status>
    </propstat>
  </response>
</multistatus>"#;

        let response = http::Response::builder()
            .status(StatusCode::MULTI_STATUS)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, body).unwrap();

        assert_eq!(result.value, Some("#ff0000".to_string()));
    }
}