Add the CalDAV client and a CLI to drive it

Discovery is the three PROPFINDs RFC 4791 describes rather than a walk
through likely URLs. Queries use time-range, which v1 never did -- it
fetched whole calendars and filtered in the browser on every view change.
Every write states a precondition, so a stale ETag produces a Conflict a
caller can act on instead of silently destroying somebody's edit.

XML goes through quick-xml with namespace resolution. v1 matched prefixes
with six regexes tried in sequence and recompiled inside the loop; there
is a fixture here that is the same document under different prefixes, and
it parses identically.

Protocol parsing is split from transport so it can be tested against
responses recorded from a real Baikal -- including the second propstat
carrying 404s, which is what makes "this calendar has no colour" different
from "this calendar has an empty colour".

Live tests run against a real server, never a mock. tests/baikal/run.sh
starts a container, walks Baikal's install wizard, and runs them; each
test builds and destroys its own collection, so pointing it at a real
server touches nothing that was already there. They cover discovery,
round-trip, stale-ETag conflict, duplicate create, delete, time-range
filtering, a series returned whole with its override, and writing every
synthetic golden fixture to the server and reading it back.

libdav was evaluated first, as planned. Not adopted: its HttpClient trait
is defined over hyper::body::Incoming, so using it means replacing reqwest
everywhere, plus a DNS resolver for service discovery we do not do and a
second XML parser. Its precondition design is where Precondition's shape
comes from. Reasons are recorded in the crate docs.
This commit is contained in:
2026-08-26 15:32:08 -04:00
parent 689b78154b
commit f8e4a497fa
20 changed files with 2893 additions and 3 deletions
+2
View File
@@ -16,6 +16,8 @@ tracing = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
pretty_assertions = { workspace = true }
runway-core = { workspace = true, features = ["ical", "recurrence"] }
[lints]
workspace = true
+217
View File
@@ -0,0 +1,217 @@
//! The HTTP layer: authentication, WebDAV methods, and status handling.
use crate::error::CalDavError;
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
use reqwest::{Method, Response, StatusCode, Url};
use std::time::Duration;
/// A username and the password that goes with it.
///
/// `Debug` is implemented by hand. A derived one put the password in every
/// trace and error report that ever touched a client — the previous iteration
/// went further and logged `Password length: {}` from its login handler.
#[derive(Clone)]
pub struct Credentials {
pub username: String,
pub password: String,
}
impl Credentials {
pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
Self {
username: username.into(),
password: password.into(),
}
}
}
impl std::fmt::Debug for Credentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Credentials")
.field("username", &self.username)
.field("password", &"<redacted>")
.finish()
}
}
/// What must be true of a resource for a write to be allowed.
///
/// CalDAV's answer to two clients editing at once. Every write goes through one
/// of these; there is deliberately no way to write without saying which.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Precondition {
/// The resource must not exist yet — `If-None-Match: *`.
New,
/// The resource must still carry this `ETag` — `If-Match`.
Unchanged(String),
/// No condition. Last write wins, and the loser is never told.
Force,
}
/// A CalDAV client bound to one server and one set of credentials.
#[derive(Debug, Clone)]
pub struct CalDavClient {
http: reqwest::Client,
base: Url,
credentials: Credentials,
}
impl CalDavClient {
/// Builds a client for a server root, such as
/// `https://example.com/dav.php/`.
pub fn new(base: &str, credentials: Credentials) -> Result<Self, CalDavError> {
let base = Url::parse(base).map_err(|e| CalDavError::InvalidUrl(e.to_string()))?;
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.user_agent(concat!("Runway/", env!("CARGO_PKG_VERSION")))
.build()?;
Ok(Self {
http,
base,
credentials,
})
}
/// The server root this client was built for.
pub fn base(&self) -> &Url {
&self.base
}
/// Resolves an href from a response against the server root.
///
/// Servers return paths, not absolute URLs, so every href has to be joined
/// back onto the origin before it can be requested.
pub fn resolve(&self, href: &str) -> Result<Url, CalDavError> {
self.base
.join(href)
.map_err(|e| CalDavError::InvalidUrl(format!("{href}: {e}")))
}
/// Issues a WebDAV request and checks the status.
pub(crate) async fn send(
&self,
method: &'static str,
href: &str,
headers: HeaderMap,
body: Option<String>,
) -> Result<Response, CalDavError> {
let url = self.resolve(href)?;
let method = Method::from_bytes(method.as_bytes())
.map_err(|e| CalDavError::InvalidUrl(e.to_string()))?;
let mut request = self
.http
.request(method.clone(), url)
.basic_auth(&self.credentials.username, Some(&self.credentials.password))
.headers(headers);
if let Some(body) = body {
request = request.body(body);
}
let response = request.send().await?;
check(method_name(&method), href, response).await
}
/// A `PROPFIND` with a body, at a given depth.
pub(crate) async fn propfind(
&self,
href: &str,
depth: &str,
body: String,
) -> Result<Vec<crate::xml::DavResponse>, CalDavError> {
let mut headers = xml_headers();
headers.insert(
"Depth",
HeaderValue::from_str(depth).unwrap_or(HeaderValue::from_static("0")),
);
let response = self.send("PROPFIND", href, headers, Some(body)).await?;
let text = response.text().await?;
Ok(crate::xml::parse_multistatus(&text)?)
}
/// A `REPORT` with a body, at a given depth.
pub(crate) async fn report(
&self,
href: &str,
depth: &str,
body: String,
) -> Result<Vec<crate::xml::DavResponse>, CalDavError> {
let mut headers = xml_headers();
headers.insert(
"Depth",
HeaderValue::from_str(depth).unwrap_or(HeaderValue::from_static("1")),
);
let response = self.send("REPORT", href, headers, Some(body)).await?;
let text = response.text().await?;
Ok(crate::xml::parse_multistatus(&text)?)
}
}
pub(crate) fn xml_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
CONTENT_TYPE,
HeaderValue::from_static("application/xml; charset=utf-8"),
);
headers
}
/// Applies a precondition to a request's headers.
pub(crate) fn apply_precondition(headers: &mut HeaderMap, precondition: &Precondition) {
match precondition {
Precondition::New => {
headers.insert("If-None-Match", HeaderValue::from_static("*"));
}
Precondition::Unchanged(etag) => {
if let Ok(value) = HeaderValue::from_str(etag) {
headers.insert("If-Match", value);
}
}
Precondition::Force => {}
}
}
/// Turns a non-success status into the most specific error that fits.
async fn check(
method: &'static str,
href: &str,
response: Response,
) -> Result<Response, CalDavError> {
let status = response.status();
if status.is_success() || status == StatusCode::MULTI_STATUS {
return Ok(response);
}
Err(match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => CalDavError::Unauthorized,
StatusCode::NOT_FOUND => CalDavError::NotFound {
href: href.to_owned(),
},
// 412 is a failed If-Match: somebody else wrote first. 409 and 507 are
// different problems and stay generic on purpose.
StatusCode::PRECONDITION_FAILED => CalDavError::Conflict {
href: href.to_owned(),
},
_ => CalDavError::Status {
method,
href: href.to_owned(),
status: status.as_u16(),
body: response.text().await.unwrap_or_default(),
},
})
}
/// The static name for a method, so errors can carry one without allocating.
fn method_name(method: &Method) -> &'static str {
match method.as_str() {
"PROPFIND" => "PROPFIND",
"REPORT" => "REPORT",
"MKCALENDAR" => "MKCALENDAR",
"PROPPATCH" => "PROPPATCH",
"PUT" => "PUT",
"GET" => "GET",
"DELETE" => "DELETE",
"OPTIONS" => "OPTIONS",
_ => "request",
}
}
+191
View File
@@ -0,0 +1,191 @@
//! Finding out what calendars a server holds.
//!
//! Three steps, each a `PROPFIND`, exactly as RFC 4791 §6.2 and RFC 5397
//! describe: the server names the current user's principal, the principal names
//! its calendar home, and the home lists the collections. Nothing here guesses
//! at a URL layout — the previous iteration walked a list of likely paths and
//! tried each one, which works until it meets a server that arranges things
//! differently.
use crate::client::CalDavClient;
use crate::error::CalDavError;
use crate::xml::{APPLE_ICAL, CALDAV, CALENDARSERVER, DAV, DavResponse};
/// A calendar collection on the server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Calendar {
/// Path of the collection, as the server gave it.
pub href: String,
/// `displayname`, when set.
pub display_name: Option<String>,
/// `calendar-description`.
pub description: Option<String>,
/// Apple's `calendar-color`, an `#RRGGBB` or `#RRGGBBAA` string.
///
/// Read rather than invented: the previous iteration hashed the path to
/// pick a colour and ignored what the server already knew, so a calendar
/// looked different here than in every other client.
pub color: Option<String>,
/// `getctag` — changes whenever anything in the collection does, which
/// makes it a cheap way to skip a re-sync.
pub ctag: Option<String>,
/// The component types the collection accepts. Empty means the server did
/// not say, which RFC 4791 defines as "all of them".
pub supported_components: Vec<String>,
}
impl Calendar {
/// Whether this collection holds events.
pub fn supports_events(&self) -> bool {
self.supported_components.is_empty()
|| self
.supported_components
.iter()
.any(|c| c.eq_ignore_ascii_case("VEVENT"))
}
/// The name to show, falling back to the last path segment.
pub fn name(&self) -> &str {
if let Some(name) = self.display_name.as_deref().filter(|n| !n.is_empty()) {
return name;
}
self.href
.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or(&self.href)
}
}
const PRINCIPAL_BODY: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>"#;
const HOME_BODY: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><c:calendar-home-set/></d:prop></d:propfind>"#;
const CALENDARS_BODY: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"
xmlns:cs="http://calendarserver.org/ns/"
xmlns:ical="http://apple.com/ns/ical/">
<d:prop>
<d:resourcetype/><d:displayname/>
<c:calendar-description/><c:supported-calendar-component-set/>
<ical:calendar-color/><cs:getctag/>
</d:prop></d:propfind>"#;
/// Reads a principal URL out of a `PROPFIND` response.
///
/// Separated from the request so it can be tested against recorded responses
/// from a real server rather than only against a live one.
pub fn principal_from(responses: &[DavResponse]) -> Result<String, CalDavError> {
responses
.iter()
.find_map(|r| {
r.prop(DAV, "current-user-principal")?
.child_text(DAV, "href")
.map(str::to_owned)
})
.ok_or_else(|| {
CalDavError::Discovery(
"the server did not report a current-user-principal; check the URL points at \
the DAV root and that the credentials are accepted"
.to_owned(),
)
})
}
/// Reads a calendar home out of a `PROPFIND` response.
pub fn home_from(responses: &[DavResponse], principal: &str) -> Result<String, CalDavError> {
responses
.iter()
.find_map(|r| {
r.prop(CALDAV, "calendar-home-set")?
.child_text(DAV, "href")
.map(str::to_owned)
})
.ok_or_else(|| {
CalDavError::Discovery(format!(
"{principal} does not advertise a calendar-home-set"
))
})
}
/// Reads the calendar collections out of a `PROPFIND` response.
///
/// Ordered by display name, so a sidebar built from this does not reshuffle
/// itself between requests.
pub fn calendars_from(responses: &[DavResponse]) -> Vec<Calendar> {
let mut calendars: Vec<Calendar> = responses.iter().filter_map(as_calendar).collect();
calendars.sort_by_key(|c| c.name().to_lowercase());
calendars
}
impl CalDavClient {
/// The principal URL for the authenticated user.
pub async fn current_user_principal(&self) -> Result<String, CalDavError> {
let responses = self
.propfind(self.base().as_str(), "0", PRINCIPAL_BODY.to_owned())
.await?;
principal_from(&responses)
}
/// The collection holding a principal's calendars.
pub async fn calendar_home(&self, principal: &str) -> Result<String, CalDavError> {
let responses = self.propfind(principal, "0", HOME_BODY.to_owned()).await?;
home_from(&responses, principal)
}
/// The calendar collections inside a home.
///
/// Scheduling inboxes and outboxes are collections too and are filtered
/// out here; they are not calendars a person browses.
pub async fn calendars_in(&self, home: &str) -> Result<Vec<Calendar>, CalDavError> {
let responses = self.propfind(home, "1", CALENDARS_BODY.to_owned()).await?;
Ok(calendars_from(&responses))
}
/// Discovery end to end: principal, home, then the calendars in it.
pub async fn discover(&self) -> Result<Vec<Calendar>, CalDavError> {
let principal = self.current_user_principal().await?;
let home = self.calendar_home(&principal).await?;
self.calendars_in(&home).await
}
}
fn as_calendar(response: &DavResponse) -> Option<Calendar> {
let resourcetype = response.prop(DAV, "resourcetype")?;
if !resourcetype.has_child(CALDAV, "calendar") {
return None;
}
// A scheduling inbox or outbox also carries the calendar resourcetype on
// some servers; neither belongs in a calendar list.
if resourcetype.has_child(CALDAV, "schedule-inbox")
|| resourcetype.has_child(CALDAV, "schedule-outbox")
{
return None;
}
Some(Calendar {
href: response.href.clone(),
display_name: response.prop_text(DAV, "displayname").map(str::to_owned),
description: response
.prop_text(CALDAV, "calendar-description")
.map(str::to_owned),
color: response
.prop_text(APPLE_ICAL, "calendar-color")
.map(str::to_owned),
ctag: response
.prop_text(CALENDARSERVER, "getctag")
.map(str::to_owned),
supported_components: response
.prop(CALDAV, "supported-calendar-component-set")
.map(|set| {
set.children(CALDAV, "comp")
.filter_map(|c| c.attribute("name"))
.map(str::to_owned)
.collect()
})
.unwrap_or_default(),
})
}
+81
View File
@@ -0,0 +1,81 @@
//! Errors from talking to a CalDAV server.
//!
//! Typed, so a caller can tell a stale `ETag` from a bad password from a
//! malformed response. The previous iteration returned `Result<T, String>` at
//! every boundary, which made a 401 and a JSON parse failure indistinguishable
//! and left the UI with nothing to do but show the text.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CalDavError {
/// The request never reached the server, or the connection failed.
#[error("could not reach the CalDAV server: {0}")]
Transport(String),
/// The credentials were rejected.
#[error("the CalDAV server rejected these credentials")]
Unauthorized,
/// The resource is not there.
#[error("no such resource on the server: {href}")]
NotFound { href: String },
/// The resource changed since it was read.
///
/// This is the case worth having a name for: it means somebody else edited
/// the same event, and the right response is to re-read and merge rather
/// than to retry blindly.
#[error("{href} was modified by someone else since it was read")]
Conflict { href: String },
/// The server refused for some other reason.
#[error("{method} {href} failed: HTTP {status}{}", detail(.body))]
Status {
method: &'static str,
href: String,
status: u16,
body: String,
},
/// The response was not the XML it should have been.
#[error("{0}")]
Xml(#[from] crate::xml::XmlError),
/// The calendar data in a response could not be read.
#[error("{href} contains unreadable calendar data: {source}")]
Ical {
href: String,
#[source]
source: runway_core::ical::IcalError,
},
/// The server did not advertise what the protocol requires.
#[error("CalDAV discovery failed: {0}")]
Discovery(String),
/// A URL could not be built or resolved.
#[error("invalid URL: {0}")]
InvalidUrl(String),
}
fn detail(body: &str) -> String {
let trimmed = body.trim();
if trimmed.is_empty() {
String::new()
} else {
// Server error bodies can be whole HTML pages; enough to identify the
// problem is enough.
let mut summary: String = trimmed.chars().take(300).collect();
if trimmed.chars().count() > 300 {
summary.push('…');
}
format!("{summary}")
}
}
impl From<reqwest::Error> for CalDavError {
fn from(error: reqwest::Error) -> Self {
Self::Transport(error.to_string())
}
}
+33 -1
View File
@@ -1 +1,33 @@
//! CalDAV client: discovery, time-ranged queries and ETag-aware CRUD.
//! A CalDAV client: discovery, time-ranged queries, and ETag-aware writes.
//!
//! Built on `reqwest` for HTTP and `quick-xml` for the protocol. Namespaces are
//! resolved properly rather than matched by prefix, and every write states a
//! precondition, so two clients editing the same event produce a conflict a
//! caller can act on instead of a silent overwrite.
//!
//! # Why not `libdav`
//!
//! [`libdav`](https://git.sr.ht/~whynothugo/libdav) covers this ground well and
//! was evaluated before any of this was written — its ETag precondition design
//! is where the shape of [`Precondition`] comes from. It was not adopted for
//! one structural reason: its `HttpClient` trait is defined in terms of
//! `hyper::body::Incoming`, so using it means running `hyper` directly and
//! replacing `reqwest` everywhere, including the parts of Runway that only ever
//! wanted a plain HTTPS GET. It also brings a full DNS resolver for the SRV and
//! TXT service discovery this application does not do — the server URL is typed
//! in by the person logging in — and a second XML parser alongside `quick-xml`.
//!
//! Set against that, what it would save is protocol glue that has to be typed
//! to this application's own errors and model anyway. Worth revisiting if it
//! grows an HTTP abstraction that is not hyper-shaped.
mod client;
mod discovery;
mod error;
mod objects;
pub mod xml;
pub use client::{CalDavClient, Credentials, Precondition};
pub use discovery::{Calendar, calendars_from, home_from, principal_from};
pub use error::CalDavError;
pub use objects::{href_for, objects_from};
+255
View File
@@ -0,0 +1,255 @@
//! Reading and writing the resources inside a calendar collection.
use crate::client::{CalDavClient, Precondition, apply_precondition, xml_headers};
use crate::error::CalDavError;
use crate::xml::{CALDAV, DAV, DavResponse};
use chrono::{DateTime, SecondsFormat, Utc};
use reqwest::header::{CONTENT_TYPE, ETAG, HeaderMap, HeaderValue};
use runway_core::ical;
use runway_core::model::{CalendarObject, VCalendar};
impl CalDavClient {
/// The objects in a collection that touch a time range.
///
/// The range is the point. RFC 4791 §9.9 has had `time-range` all along and
/// the previous iteration never used it: every view change re-fetched the
/// entire calendar and filtered in the browser. Here the server sends back
/// the week that is being looked at.
///
/// Recurring series are returned whole — master plus overrides — because
/// the filter asks whether the *series* touches the range. Expanding them
/// is [`runway_core::recurrence`]'s job, not the server's.
pub async fn events_in_range(
&self,
calendar_href: &str,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> Result<Vec<CalendarObject>, CalDavError> {
let body = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:getetag/><c:calendar-data/></d:prop>
<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT">
<c:time-range start="{}" end="{}"/>
</c:comp-filter></c:comp-filter></c:filter>
</c:calendar-query>"#,
ical_instant(from),
ical_instant(to),
);
let responses = self.report(calendar_href, "1", body).await?;
objects_from(calendar_href, responses)
}
/// Every object in a collection, unfiltered.
///
/// Useful for a first sync and for the CLI; a view should be asking for a
/// range instead.
pub async fn all_events(
&self,
calendar_href: &str,
) -> Result<Vec<CalendarObject>, CalDavError> {
let body = r#"<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:getetag/><c:calendar-data/></d:prop>
<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"/></c:comp-filter></c:filter>
</c:calendar-query>"#;
let responses = self.report(calendar_href, "1", body.to_owned()).await?;
objects_from(calendar_href, responses)
}
/// The `ETag` of every object in a collection, without their bodies.
///
/// The cheap half of a sync: compare these against what is already held and
/// fetch only what changed.
pub async fn etags(&self, calendar_href: &str) -> Result<Vec<(String, String)>, CalDavError> {
let body = r#"<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>"#;
let responses = self.propfind(calendar_href, "1", body.to_owned()).await?;
Ok(responses
.into_iter()
.filter(|r| r.href.trim_end_matches('/') != calendar_href.trim_end_matches('/'))
.filter_map(|r| {
let etag = r.prop_text(DAV, "getetag")?.to_owned();
Some((r.href, etag))
})
.collect())
}
/// Fetches specific objects by href in one round trip.
pub async fn multiget(
&self,
calendar_href: &str,
hrefs: &[String],
) -> Result<Vec<CalendarObject>, CalDavError> {
if hrefs.is_empty() {
return Ok(Vec::new());
}
let hrefs_xml: String = hrefs
.iter()
.map(|h| format!("<d:href>{}</d:href>", escape_xml(h)))
.collect();
let body = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:getetag/><c:calendar-data/></d:prop>{hrefs_xml}</c:calendar-multiget>"#,
);
let responses = self.report(calendar_href, "1", body).await?;
objects_from(calendar_href, responses)
}
/// Fetches one object with a plain `GET`.
pub async fn get_object(
&self,
calendar_href: &str,
href: &str,
) -> Result<CalendarObject, CalDavError> {
let response = self.send("GET", href, HeaderMap::new(), None).await?;
let etag = etag_of(response.headers());
let body = response.text().await?;
ical::parse_object(href, calendar_href, etag, &body).map_err(|source| CalDavError::Ical {
href: href.to_owned(),
source,
})
}
/// Writes an object, subject to a precondition.
///
/// Returns the new `ETag` when the server supplies one. Servers are not
/// obliged to, and a caller that gets `None` has to re-read to find out
/// where it stands rather than assuming.
pub async fn put_object(
&self,
href: &str,
calendar: &VCalendar,
precondition: &Precondition,
) -> Result<Option<String>, CalDavError> {
let mut headers = HeaderMap::new();
headers.insert(
CONTENT_TYPE,
HeaderValue::from_static("text/calendar; charset=utf-8"),
);
apply_precondition(&mut headers, precondition);
let response = self
.send("PUT", href, headers, Some(ical::write(calendar)))
.await?;
Ok(etag_of(response.headers()))
}
/// Deletes an object, subject to a precondition.
pub async fn delete_object(
&self,
href: &str,
precondition: &Precondition,
) -> Result<(), CalDavError> {
let mut headers = HeaderMap::new();
apply_precondition(&mut headers, precondition);
self.send("DELETE", href, headers, None).await?;
Ok(())
}
/// Creates a calendar collection.
pub async fn create_calendar(
&self,
href: &str,
display_name: &str,
color: Option<&str>,
) -> Result<(), CalDavError> {
let color_xml = color
.map(|c| {
format!(
"<ical:calendar-color>{}</ical:calendar-color>",
escape_xml(c)
)
})
.unwrap_or_default();
let body = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"
xmlns:ical="http://apple.com/ns/ical/">
<d:set><d:prop>
<d:displayname>{}</d:displayname>{color_xml}
<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>
</d:prop></d:set></c:mkcalendar>"#,
escape_xml(display_name),
);
self.send("MKCALENDAR", href, xml_headers(), Some(body))
.await?;
Ok(())
}
/// Deletes a calendar collection and everything in it.
pub async fn delete_calendar(&self, href: &str) -> Result<(), CalDavError> {
self.send("DELETE", href, HeaderMap::new(), None).await?;
Ok(())
}
}
/// The href a new object should be written to.
///
/// One resource per `UID`, which is what makes a series and its overrides share
/// a file. The `UID` is percent-encoded because real ones contain `@` and `/`
/// and a path segment cannot.
pub fn href_for(calendar_href: &str, uid: &str) -> String {
let mut out = String::with_capacity(calendar_href.len() + uid.len() + 8);
out.push_str(calendar_href.trim_end_matches('/'));
out.push('/');
for byte in uid.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
other => out.push_str(&format!("%{other:02X}")),
}
}
out.push_str(".ics");
out
}
/// Reads calendar objects out of a `REPORT` response.
///
/// Responses carrying no `calendar-data` are skipped rather than treated as
/// empty calendars: a `propstat` with a 404 lists what the resource does not
/// have, and folding those in would invent objects that are not there.
pub fn objects_from(
calendar_href: &str,
responses: Vec<DavResponse>,
) -> Result<Vec<CalendarObject>, CalDavError> {
let mut out = Vec::new();
for response in responses {
let Some(data) = response.prop_text(CALDAV, "calendar-data") else {
continue;
};
let etag = response.prop_text(DAV, "getetag").map(str::to_owned);
out.push(
ical::parse_object(&response.href, calendar_href, etag, data).map_err(|source| {
CalDavError::Ical {
href: response.href.clone(),
source,
}
})?,
);
}
Ok(out)
}
fn etag_of(headers: &HeaderMap) -> Option<String> {
headers
.get(ETAG)
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
}
/// The `YYYYMMDDTHHMMSSZ` form a `time-range` filter takes.
fn ical_instant(at: DateTime<Utc>) -> String {
at.to_rfc3339_opts(SecondsFormat::Secs, true)
.replace(['-', ':'], "")
}
fn escape_xml(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
+251
View File
@@ -0,0 +1,251 @@
//! Reading WebDAV `multistatus` documents.
//!
//! Namespaces are resolved by the parser, which is the whole point. The
//! previous iteration extracted values with regular expressions and tried
//! **six** patterns in sequence to cope with the fact that one server writes
//! `<d:href>`, another `<D:href>` and a third `<href xmlns="DAV:">` — then
//! recompiled every one of those `Regex` objects inside the loop. A prefix is
//! not a namespace; `quick-xml`'s `NsReader` knows the difference and this
//! module never sees a prefix at all.
//!
//! Documents are read into a small generic element tree rather than parsed
//! directly into typed responses. WebDAV properties nest arbitrarily —
//! `<resourcetype><calendar/></resourcetype>`,
//! `<supported-calendar-component-set><comp name="VEVENT"/></...>` — and a tree
//! handles all of it without a special case per property.
use quick_xml::events::Event;
use quick_xml::name::ResolveResult;
use quick_xml::reader::NsReader;
pub const DAV: &str = "DAV:";
pub const CALDAV: &str = "urn:ietf:params:xml:ns:caldav";
pub const CALENDARSERVER: &str = "http://calendarserver.org/ns/";
pub const APPLE_ICAL: &str = "http://apple.com/ns/ical/";
/// One element of a parsed document.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Element {
/// The resolved namespace URI, empty when the element has none.
pub ns: String,
/// The local name, with any prefix already stripped.
pub name: String,
/// Character data directly inside this element, trimmed.
pub text: String,
pub attributes: Vec<(String, String)>,
pub children: Vec<Element>,
}
impl Element {
/// Whether this element is the named one.
pub fn is(&self, ns: &str, name: &str) -> bool {
self.ns == ns && self.name == name
}
/// Direct children with a given name.
pub fn children(&self, ns: &str, name: &str) -> impl Iterator<Item = &Element> {
self.children.iter().filter(move |c| c.is(ns, name))
}
/// The first direct child with a given name.
pub fn child(&self, ns: &str, name: &str) -> Option<&Element> {
self.children(ns, name).next()
}
/// The text of the first direct child with a given name, if it is not empty.
pub fn child_text(&self, ns: &str, name: &str) -> Option<&str> {
self.child(ns, name)
.map(|c| c.text.as_str())
.filter(|t| !t.is_empty())
}
/// An attribute value by name.
pub fn attribute(&self, name: &str) -> Option<&str> {
self.attributes
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
}
/// Whether a child element exists — how WebDAV states a boolean, as in
/// `<resourcetype><calendar/></resourcetype>`.
pub fn has_child(&self, ns: &str, name: &str) -> bool {
self.child(ns, name).is_some()
}
}
/// Failure to read a document.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("malformed XML response: {0}")]
pub struct XmlError(pub String);
/// Parses a document into its root element.
pub fn parse(xml: &str) -> Result<Element, XmlError> {
let mut reader = NsReader::from_str(xml);
let mut stack: Vec<Element> = Vec::new();
let mut root: Option<Element> = None;
loop {
let (ns, event) = reader
.read_resolved_event()
.map_err(|e| XmlError(e.to_string()))?;
match event {
Event::Start(start) => stack.push(element_from(&ns, &start)?),
Event::Empty(empty) => {
let element = element_from(&ns, &empty)?;
push(&mut stack, &mut root, element);
}
Event::Text(text) => {
if let Some(current) = stack.last_mut() {
current.text.push_str(&text.xml10_content());
}
}
// SabreDAV wraps some property values in CDATA.
Event::CData(data) => {
if let Some(current) = stack.last_mut() {
current.text.push_str(&data.xml10_content());
}
}
// Entity references arrive as their own event rather than inline,
// so `&amp;` in a display name has to be put back by hand. The
// library's own resolver is reused for it, numeric references
// included.
Event::GeneralRef(reference) => {
if let Some(current) = stack.last_mut() {
let name = reference.into_inner();
let resolved = quick_xml::escape::unescape(&format!("&{name};"))
.map_err(|e| XmlError(format!("unknown entity &{name};: {e}")))?
.into_owned();
current.text.push_str(&resolved);
}
}
Event::End(_) => {
let Some(mut finished) = stack.pop() else {
return Err(XmlError("closing tag with nothing open".to_owned()));
};
// Trimmed once, at the end. Trimming each text event instead
// would eat the spaces around an entity reference and turn
// "Bed &amp; Breakfast" into "Bed&Breakfast".
let trimmed = finished.text.trim();
if trimmed.len() != finished.text.len() {
finished.text = trimmed.to_owned();
}
push(&mut stack, &mut root, finished);
}
Event::Eof => break,
_ => {}
}
}
root.ok_or_else(|| XmlError("no elements in document".to_owned()))
}
fn push(stack: &mut [Element], root: &mut Option<Element>, element: Element) {
match stack.last_mut() {
Some(parent) => parent.children.push(element),
None => *root = Some(element),
}
}
fn element_from(
ns: &ResolveResult<'_>,
tag: &quick_xml::events::BytesStart<'_>,
) -> Result<Element, XmlError> {
let namespace = match ns {
ResolveResult::Bound(bound) => bound.as_ref().to_owned(),
_ => String::new(),
};
let mut attributes = Vec::new();
for attribute in tag.attributes() {
let attribute = attribute.map_err(|e| XmlError(e.to_string()))?;
let key = attribute.key.local_name().as_ref().to_owned();
let value = attribute
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.map_err(|e| XmlError(e.to_string()))?
.into_owned();
attributes.push((key, value));
}
Ok(Element {
ns: namespace,
name: tag.name().local_name().as_ref().to_owned(),
text: String::new(),
attributes,
children: Vec::new(),
})
}
/// One `<response>` from a `multistatus`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DavResponse {
pub href: String,
/// Properties that the server reported as found, flattened across every
/// `propstat` whose status was 2xx.
///
/// A `propstat` carrying 404 lists the properties the resource does *not*
/// have; folding those in as though they were present is how a missing
/// display name becomes an empty one.
pub found: Vec<Element>,
/// A `<status>` on the response itself, present on error responses.
pub status: Option<u16>,
}
impl DavResponse {
/// A found property by name.
pub fn prop(&self, ns: &str, name: &str) -> Option<&Element> {
self.found.iter().find(|p| p.is(ns, name))
}
/// The text of a found property.
pub fn prop_text(&self, ns: &str, name: &str) -> Option<&str> {
self.prop(ns, name)
.map(|p| p.text.as_str())
.filter(|t| !t.is_empty())
}
}
/// Reads a `multistatus` document into its responses.
pub fn parse_multistatus(xml: &str) -> Result<Vec<DavResponse>, XmlError> {
let root = parse(xml)?;
if !root.is(DAV, "multistatus") {
return Err(XmlError(format!(
"expected a DAV:multistatus document, got {{{}}}{}",
root.ns, root.name,
)));
}
let mut out = Vec::new();
for response in root.children(DAV, "response") {
let href = response
.child_text(DAV, "href")
.ok_or_else(|| XmlError("a response has no href".to_owned()))?
.to_owned();
let mut found = Vec::new();
for propstat in response.children(DAV, "propstat") {
let ok = propstat
.child_text(DAV, "status")
.and_then(status_code)
.is_some_and(|code| (200..300).contains(&code));
if !ok {
continue;
}
if let Some(prop) = propstat.child(DAV, "prop") {
found.extend(prop.children.iter().cloned());
}
}
out.push(DavResponse {
href,
found,
status: response.child_text(DAV, "status").and_then(status_code),
});
}
Ok(out)
}
/// Pulls the numeric code out of a `HTTP/1.1 200 OK` status line.
pub fn status_code(line: &str) -> Option<u16> {
line.split_whitespace().nth(1)?.parse().ok()
}
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Run the CalDAV integration tests against a throwaway Baikal.
#
# Starts a container, walks it through the install wizard, runs the live test
# suite against it, and tears it down again. Nothing touches a real calendar.
#
# crates/runway-caldav/tests/baikal/run.sh # start, test, stop
# KEEP=1 crates/runway-caldav/tests/baikal/run.sh # leave it running
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NAME="${NAME:-runway-baikal}"
PORT="${PORT:-8800}"
IMAGE="${IMAGE:-docker.io/ckulka/baikal:nginx}"
USERNAME="testuser"
PASSWORD="testpassword"
runtime() {
if command -v podman >/dev/null 2>&1; then echo podman
elif command -v docker >/dev/null 2>&1; then echo docker
else echo "need podman or docker" >&2; exit 1
fi
}
RUNTIME="$(runtime)"
cleanup() {
if [ "${KEEP:-0}" != "1" ]; then
"$RUNTIME" rm -f "$NAME" >/dev/null 2>&1 || true
else
echo "container $NAME left running on port $PORT"
fi
}
trap cleanup EXIT
"$RUNTIME" rm -f "$NAME" >/dev/null 2>&1 || true
"$RUNTIME" run -d --rm --name "$NAME" -p "$PORT:80" "$IMAGE" >/dev/null
echo "started $NAME ($IMAGE) on port $PORT"
# Baikal needs a moment before PHP answers.
for _ in $(seq 1 60); do
if [ "$(curl -sS -o /dev/null -w '%{http_code}' -L "http://localhost:$PORT/" 2>/dev/null)" = "200" ]; then
break
fi
sleep 1
done
python3 "$HERE/setup.py" "http://localhost:$PORT" "$USERNAME" "$PASSWORD"
export RUNWAY_CALDAV_URL="http://localhost:$PORT/dav.php/"
export RUNWAY_CALDAV_USER="$USERNAME"
export RUNWAY_CALDAV_PASSWORD="$PASSWORD"
cargo test -p runway-caldav --test live -- --test-threads=1 "$@"
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Drive a fresh Baikal container through its install wizard.
Baikal has no unattended-install path, so the integration tests would otherwise
need a hand-prepared image. This walks the same web forms a person would,
leaving a server with Basic authentication, one user, and one calendar.
Usage:
podman run -d --rm --name runway-baikal -p 8800:80 docker.io/ckulka/baikal:nginx
python3 setup.py http://localhost:8800 testuser testpassword
"""
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
import http.cookiejar
ADMIN_PASSWORD = "runway-integration-admin"
def make_opener():
jar = http.cookiejar.CookieJar()
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def get(opener, url):
with opener.open(url, timeout=30) as response:
return response.read().decode("utf-8", "replace")
def post(opener, url, fields):
body = urllib.parse.urlencode(fields).encode()
request = urllib.request.Request(
url, data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}
)
with opener.open(request, timeout=30) as response:
return response.read().decode("utf-8", "replace")
def csrf(html):
"""The page's CSRF token. Not every form carries one -- the admin login
does not -- so callers that may see either use `maybe_csrf`."""
token = maybe_csrf(html)
if token is None:
raise SystemExit("no CSRF token in page; Baikal's forms have changed")
return token
def maybe_csrf(html):
match = re.search(r'name="CSRF_TOKEN"\s+value="([^"]+)"', html)
return match.group(1) if match else None
def install(base, username, password):
"""Walk the wizard until it stops offering forms.
The wizard serves every step from the same URL and decides which form to
show from its own stored state, so this is driven by what comes back rather
than by a fixed sequence of URLs -- which is also what keeps it working
across Baikal versions that add or reorder a step.
"""
opener = make_opener()
for _ in range(6):
page = get(opener, f"{base}/admin/install/")
if "Baikal_Model_Config_Standard::submitted" in page:
post(opener, f"{base}/admin/install/", system_fields(csrf(page)))
elif "Baikal_Model_Config_Database::submitted" in page:
post(opener, f"{base}/admin/install/", database_fields(csrf(page)))
else:
break
else:
raise SystemExit("the install wizard did not finish")
# Log in to the admin interface. This form has no CSRF token of its own.
page = get(opener, f"{base}/admin/")
if 'name="auth"' in page:
fields = {"auth": "1", "login": "admin", "password": ADMIN_PASSWORD}
token = maybe_csrf(page)
if token:
fields["CSRF_TOKEN"] = token
post(opener, f"{base}/admin/", fields)
# Create the test user. Baikal gives every new user a default calendar.
page = get(opener, f"{base}/admin/?/users/new/1/")
post(
opener,
f"{base}/admin/?/users/new/1/",
{
"Baikal_Model_User::submitted": "1",
"refreshed": "0",
"CSRF_TOKEN": csrf(page),
"data[username]": username,
"witness[username]": "1",
"data[displayname]": "Integration Test",
"witness[displayname]": "1",
"data[email]": f"{username}@example.org",
"witness[email]": "1",
"data[password]": password,
"witness[password]": "1",
"data[passwordconfirm]": password,
"witness[passwordconfirm]": "1",
},
)
users = get(opener, f"{base}/admin/?/users/")
if username not in users:
raise SystemExit(f"user {username} was not created")
print(f"ready: {base}/dav.php/ as {username}")
def system_fields(token):
"""Step one. Basic authentication, because that is what the client sends;
Baikal defaults to Digest."""
return {
"Baikal_Model_Config_Standard::submitted": "1",
"refreshed": "0",
"CSRF_TOKEN": token,
"data[timezone]": "UTC",
"witness[timezone]": "1",
"data[card_enabled]": "1",
"witness[card_enabled]": "1",
"data[cal_enabled]": "1",
"witness[cal_enabled]": "1",
"data[invite_from]": "noreply@example.org",
"witness[invite_from]": "1",
"data[dav_auth_type]": "Basic",
"witness[dav_auth_type]": "1",
"data[admin_passwordhash]": ADMIN_PASSWORD,
"witness[admin_passwordhash]": "1",
"data[admin_passwordhash_confirm]": ADMIN_PASSWORD,
"witness[admin_passwordhash_confirm]": "1",
}
def database_fields(token):
"""Step two. SQLite, at the path the form arrives pre-filled with."""
return {
"Baikal_Model_Config_Database::submitted": "1",
"refreshed": "0",
"CSRF_TOKEN": token,
"data[backend]": "sqlite",
"witness[backend]": "1",
"data[sqlite_file]": "/var/www/baikal/Specific/db/db.sqlite",
"witness[sqlite_file]": "1",
}
if __name__ == "__main__":
if len(sys.argv) != 4:
raise SystemExit(__doc__)
install(sys.argv[1].rstrip("/"), sys.argv[2], sys.argv[3])
@@ -0,0 +1,200 @@
<?xml version="1.0" ?>
<multistatus xmlns="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/" xmlns:card="urn:ietf:params:xml:ns:carddav">
<response>
<href>/dav.php/calendars/alex/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
</resourcetype>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<displayname/>
<CAL:calendar-description/>
<CAL:supported-calendar-component-set/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
<cs:getctag/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/household-chores/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
</resourcetype>
<displayname>Household Chores</displayname>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
</CAL:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#DC2626</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/4</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<CAL:calendar-description/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/partner-chores/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
</resourcetype>
<displayname>Partner Chores</displayname>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
</CAL:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#7C3AED</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/6</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<CAL:calendar-description/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/trips/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
</resourcetype>
<displayname>Trips</displayname>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
</CAL:supported-calendar-component-set>
<cs:getctag>http://sabre.io/ns/sync/23</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<CAL:calendar-description/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/workouts/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
</resourcetype>
<displayname>Workouts</displayname>
<CAL:calendar-description>Calendar for logging workouts</CAL:calendar-description>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
</CAL:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#3B82F6</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/64</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/birthdays/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
<cs:shared-owner/>
</resourcetype>
<displayname>Birthdays</displayname>
<CAL:calendar-description/>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
</CAL:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#DD403A</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/36</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/personal/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:calendar/>
</resourcetype>
<displayname>Personal</displayname>
<CAL:calendar-description/>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
<CAL:comp name="VTODO"/>
<CAL:comp name="VJOURNAL"/>
</CAL:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#0CCE6B</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/930</cs:getctag>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/inbox/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:schedule-inbox/>
</resourcetype>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<displayname/>
<CAL:calendar-description/>
<CAL:supported-calendar-component-set/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
<cs:getctag/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
<response>
<href>/dav.php/calendars/alex/outbox/</href>
<propstat>
<prop>
<resourcetype>
<collection/>
<CAL:schedule-outbox/>
</resourcetype>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
<propstat>
<prop>
<displayname/>
<CAL:calendar-description/>
<CAL:supported-calendar-component-set/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
<cs:getctag/>
</prop>
<status>HTTP/1.1 404 Not Found</status>
</propstat>
</response>
</multistatus>
@@ -0,0 +1,200 @@
<?xml version="1.0" ?>
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:response>
<d:href>/dav.php/calendars/alex/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
</d:resourcetype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<d:displayname/>
<cal:calendar-description/>
<cal:supported-calendar-component-set/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
<cs:getctag/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/household-chores/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<cal:calendar/>
</d:resourcetype>
<d:displayname>Household Chores</d:displayname>
<cal:supported-calendar-component-set>
<cal:comp name="VEVENT"/>
</cal:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#DC2626</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/4</cs:getctag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<cal:calendar-description/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/partner-chores/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<cal:calendar/>
</d:resourcetype>
<d:displayname>Partner Chores</d:displayname>
<cal:supported-calendar-component-set>
<cal:comp name="VEVENT"/>
</cal:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#7C3AED</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/6</cs:getctag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<cal:calendar-description/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/trips/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<cal:calendar/>
</d:resourcetype>
<d:displayname>Trips</d:displayname>
<cal:supported-calendar-component-set>
<cal:comp name="VEVENT"/>
</cal:supported-calendar-component-set>
<cs:getctag>http://sabre.io/ns/sync/23</cs:getctag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<cal:calendar-description/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/workouts/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<cal:calendar/>
</d:resourcetype>
<d:displayname>Workouts</d:displayname>
<cal:calendar-description>Calendar for logging workouts</cal:calendar-description>
<cal:supported-calendar-component-set>
<cal:comp name="VEVENT"/>
</cal:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#3B82F6</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/64</cs:getctag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/birthdays/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<cal:calendar/>
<cs:shared-owner/>
</d:resourcetype>
<d:displayname>Birthdays</d:displayname>
<cal:calendar-description/>
<cal:supported-calendar-component-set>
<cal:comp name="VEVENT"/>
</cal:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#DD403A</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/36</cs:getctag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/personal/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<cal:calendar/>
</d:resourcetype>
<d:displayname>Personal</d:displayname>
<cal:calendar-description/>
<cal:supported-calendar-component-set>
<cal:comp name="VEVENT"/>
<cal:comp name="VTODO"/>
<cal:comp name="VJOURNAL"/>
</cal:supported-calendar-component-set>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#0CCE6B</x1:calendar-color>
<cs:getctag>http://sabre.io/ns/sync/930</cs:getctag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/inbox/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<cal:schedule-inbox/>
</d:resourcetype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<d:displayname/>
<cal:calendar-description/>
<cal:supported-calendar-component-set/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
<cs:getctag/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/outbox/</d:href>
<d:propstat>
<d:prop>
<d:resourcetype>
<d:collection/>
<cal:schedule-outbox/>
</d:resourcetype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<d:displayname/>
<cal:calendar-description/>
<cal:supported-calendar-component-set/>
<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/"/>
<cs:getctag/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
@@ -0,0 +1,20 @@
<?xml version="1.0" ?>
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:response>
<d:href>/dav.php/</d:href>
<d:propstat>
<d:prop>
<d:current-user-principal>
<d:href>/dav.php/principals/alex/</d:href>
</d:current-user-principal>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
<d:propstat>
<d:prop>
<d:displayname/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:cal="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/dav.php/calendars/alex/personal/allday.ics</d:href>
<d:propstat>
<d:prop>
<d:getetag>&quot;7c9e8a1d2f3b4c5d6e7f8a9b0c1d2e3f&quot;</d:getetag>
<cal:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
PRODID:DAVx5/4.4.8-gplay ical4j/3.2.19 (com.digibites.calendar)
BEGIN:VEVENT
UID:eee51914-187b-40d5-342c-dc80c118438a
STATUS:CONFIRMED
SUMMARY:Check-in
CLASS:PUBLIC
TRANSP:OPAQUE
DTSTART;VALUE=DATE:20250331
DTEND;VALUE=DATE:20250401
RRULE:FREQ=WEEKLY;BYDAY=MO
CREATED:20250902T164854Z
DTSTAMP:20250902T164854Z
LAST-MODIFIED:20250902T164854Z
SEQUENCE:2
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Retrospective demo retro
TRIGGER:-PT4H
END:VALARM
END:VEVENT
END:VCALENDAR
</cal:calendar-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/personal/zoned.ics</d:href>
<d:propstat>
<d:prop>
<d:getetag>&quot;1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d&quot;</d:getetag>
<cal:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
PRODID:DAVx5/4.5.19-gplay ical4j/4.3.0
BEGIN:VEVENT
DTSTAMP:20260811T142404Z
UID:3a21fd46-26c4-85b5-eee3-6d2d2256f8ef
SUMMARY:Briefing
DTSTART;TZID=America/New_York:20260818T083000
DTEND;TZID=America/New_York:20260818T093000
RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU
STATUS:CONFIRMED
BEGIN:VALARM
TRIGGER:-PT1H
ACTION:DISPLAY
DESCRIPTION:Design
END:VALARM
BEGIN:VALARM
TRIGGER:-PT12H
ACTION:DISPLAY
DESCRIPTION:Design
END:VALARM
END:VEVENT
BEGIN:VTIMEZONE
TZID:America/New_York
BEGIN:STANDARD
TZNAME:EST
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
DTSTART:20071104T020000
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:EDT
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
DTSTART:20070311T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
END:DAYLIGHT
END:VTIMEZONE
END:VCALENDAR
</cal:calendar-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav.php/calendars/alex/personal/gone.ics</d:href>
<d:propstat>
<d:prop>
<d:getetag/>
<cal:calendar-data/>
</d:prop>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:propstat>
</d:response>
</d:multistatus>
+474
View File
@@ -0,0 +1,474 @@
//! End-to-end tests against a real CalDAV server.
//!
//! Not mocks. A mock encodes what we already believe the protocol does, which
//! is precisely the belief worth checking — the previous iteration's
//! integration suite duplicated the router instead of importing it, and rotted
//! until it no longer compiled.
//!
//! These are skipped unless a server is configured, so `cargo test` works
//! offline. To run them:
//!
//! ```sh
//! crates/runway-caldav/tests/baikal/run.sh
//! ```
//!
//! which starts a throwaway Baikal in a container, installs it, and sets the
//! three variables below. Point them at any RFC-compliant server to test
//! against that instead. **Every test creates its own calendar collection and
//! deletes it afterwards**, so nothing touches data that was already there.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use chrono::{TimeZone, Utc};
use runway_caldav::{CalDavClient, CalDavError, Credentials, Precondition, href_for};
use runway_core::ical;
use runway_core::model::{CalendarDateTime, TzId, VCalendar, VEvent};
/// A client, or `None` when no server is configured.
fn client() -> Option<(CalDavClient, String)> {
let url = std::env::var("RUNWAY_CALDAV_URL").ok()?;
let user = std::env::var("RUNWAY_CALDAV_USER").ok()?;
let password = std::env::var("RUNWAY_CALDAV_PASSWORD").ok()?;
let client = CalDavClient::new(&url, Credentials::new(&user, password))
.expect("the configured CalDAV URL is not valid");
Some((client, user))
}
/// Runs a test body against a scratch calendar, removing it afterwards.
///
/// The calendar is created and destroyed per test so the tests cannot see each
/// other's leftovers, and so a failure never leaves rubbish behind on a real
/// server.
async fn with_calendar<F, Fut>(name: &str, body: F)
where
F: FnOnce(CalDavClient, String) -> Fut,
Fut: Future<Output = ()>,
{
let Some((client, user)) = client() else {
eprintln!(
"SKIPPED: no CalDAV server configured. Run \
crates/runway-caldav/tests/baikal/run.sh to run these against a container."
);
return;
};
let href = format!("/dav.php/calendars/{user}/{name}/");
// A previous run that died mid-test would leave this behind.
let _ = client.delete_calendar(&href).await;
client
.create_calendar(&href, &format!("Runway test {name}"), Some("#336699"))
.await
.expect("could not create the scratch calendar");
body(client.clone(), href.clone()).await;
client
.delete_calendar(&href)
.await
.expect("could not remove the scratch calendar");
}
fn event(uid: &str, summary: &str, hour: u32) -> VEvent {
let start = CalendarDateTime::Zoned {
local: chrono::NaiveDate::from_ymd_opt(2026, 3, 10)
.unwrap()
.and_hms_opt(hour, 0, 0)
.unwrap(),
tzid: TzId::new("America/Denver").unwrap(),
};
VEvent::with_uid(uid, start)
.titled(summary)
.lasting(runway_core::model::IcalDuration::hours(1).unwrap())
}
fn calendar_of(event: VEvent) -> VCalendar {
VCalendar::with_events(vec![event])
}
// --------------------------------------------------------------- discovery --
#[tokio::test]
async fn discovery_finds_the_scratch_calendar() {
with_calendar("discovery", |client, href| async move {
let principal = client.current_user_principal().await.unwrap();
assert!(principal.contains("principals"), "got {principal}");
let home = client.calendar_home(&principal).await.unwrap();
let calendars = client.calendars_in(&home).await.unwrap();
let found = calendars
.iter()
.find(|c| c.href.trim_end_matches('/') == href.trim_end_matches('/'))
.expect("the calendar just created was not listed");
assert_eq!(found.display_name.as_deref(), Some("Runway test discovery"));
assert!(found.supports_events());
assert!(
!calendars.iter().any(|c| c.href.contains("outbox")),
"scheduling collections must not appear as calendars",
);
})
.await;
}
// ------------------------------------------------------------------- writes --
#[tokio::test]
async fn an_event_survives_a_round_trip_through_the_server() {
with_calendar("roundtrip", |client, calendar| async move {
let uid = "runway-roundtrip@test";
let href = href_for(&calendar, uid);
let mut original = event(uid, "Round trip", 9);
original.description = Some("Two lines\nand a comma, kept".to_owned());
original.categories = vec!["Work".to_owned(), "Personal".to_owned()];
client
.put_object(&href, &calendar_of(original.clone()), &Precondition::New)
.await
.unwrap();
let fetched = client.get_object(&calendar, &href).await.unwrap();
let stored = &fetched.events()[0];
assert_eq!(stored.uid, original.uid);
assert_eq!(stored.summary, original.summary);
assert_eq!(stored.description, original.description);
assert_eq!(
stored.categories,
vec!["Work", "Personal"],
"the separator must survive the server, not come back as one \
category called \"Work,Personal\"",
);
assert_eq!(
stored.dtstart.tzid().map(TzId::as_str),
Some("America/Denver"),
"the zone has to make it to the server -- this is what the phone \
reads when it decides when to ring",
);
})
.await;
}
#[tokio::test]
async fn a_stale_etag_is_refused_rather_than_overwriting() {
with_calendar("conflict", |client, calendar| async move {
let uid = "runway-conflict@test";
let href = href_for(&calendar, uid);
client
.put_object(
&href,
&calendar_of(event(uid, "First", 9)),
&Precondition::New,
)
.await
.unwrap();
// Read it, then let somebody else write.
let first = client.get_object(&calendar, &href).await.unwrap();
let stale = first.etag.clone().expect("Baikal returns an ETag");
client
.put_object(
&href,
&calendar_of(event(uid, "Someone else's edit", 10)),
&Precondition::Force,
)
.await
.unwrap();
// Now try to save the edit built on the stale read.
let result = client
.put_object(
&href,
&calendar_of(event(uid, "My edit", 11)),
&Precondition::Unchanged(stale),
)
.await;
assert!(
matches!(result, Err(CalDavError::Conflict { .. })),
"expected a conflict, got {result:?} -- without this the second \
person to hit save silently destroys the first person's change",
);
let current = client.get_object(&calendar, &href).await.unwrap();
assert_eq!(
current.events()[0].summary.as_deref(),
Some("Someone else's edit"),
"and the refused write must not have landed",
);
})
.await;
}
#[tokio::test]
async fn creating_the_same_resource_twice_is_refused() {
with_calendar("create-twice", |client, calendar| async move {
let uid = "runway-exists@test";
let href = href_for(&calendar, uid);
client
.put_object(
&href,
&calendar_of(event(uid, "First", 9)),
&Precondition::New,
)
.await
.unwrap();
let result = client
.put_object(
&href,
&calendar_of(event(uid, "Second", 9)),
&Precondition::New,
)
.await;
assert!(
result.is_err(),
"If-None-Match: * must stop a create from clobbering an existing \
resource, got {result:?}",
);
})
.await;
}
#[tokio::test]
async fn an_event_can_be_deleted() {
with_calendar("delete", |client, calendar| async move {
let uid = "runway-delete@test";
let href = href_for(&calendar, uid);
client
.put_object(
&href,
&calendar_of(event(uid, "Doomed", 9)),
&Precondition::New,
)
.await
.unwrap();
client
.delete_object(&href, &Precondition::Force)
.await
.unwrap();
assert!(
matches!(
client.get_object(&calendar, &href).await,
Err(CalDavError::NotFound { .. })
),
"a deleted resource should be reported as gone, not as an error \
with no name",
);
})
.await;
}
// -------------------------------------------------------------- time ranges --
#[tokio::test]
async fn a_time_range_query_returns_only_what_it_should() {
with_calendar("time-range", |client, calendar| async move {
for (uid, summary, day) in [
("in-window@test", "Inside", 10),
("out-of-window@test", "Outside", 25),
] {
let start = CalendarDateTime::Zoned {
local: chrono::NaiveDate::from_ymd_opt(2026, 3, day)
.unwrap()
.and_hms_opt(9, 0, 0)
.unwrap(),
tzid: TzId::new("America/Denver").unwrap(),
};
let event = VEvent::with_uid(uid, start)
.titled(summary)
.lasting(runway_core::model::IcalDuration::hours(1).unwrap());
client
.put_object(
&href_for(&calendar, uid),
&calendar_of(event),
&Precondition::New,
)
.await
.unwrap();
}
let found = client
.events_in_range(
&calendar,
Utc.with_ymd_and_hms(2026, 3, 9, 0, 0, 0).unwrap(),
Utc.with_ymd_and_hms(2026, 3, 12, 0, 0, 0).unwrap(),
)
.await
.unwrap();
let summaries: Vec<&str> = found
.iter()
.filter_map(|o| o.events().first())
.filter_map(|e| e.summary.as_deref())
.collect();
assert_eq!(
summaries,
vec!["Inside"],
"the server filters by time-range; the last iteration fetched every \
event in the calendar on every view change and filtered in the \
browser",
);
assert_eq!(
client.all_events(&calendar).await.unwrap().len(),
2,
"and both are really there",
);
})
.await;
}
#[tokio::test]
async fn a_recurring_series_is_returned_whole() {
with_calendar("series", |client, calendar| async move {
let uid = "runway-series@test";
let mut master = event(uid, "Weekly", 9);
master.rrule = Some("FREQ=WEEKLY;BYDAY=TU;COUNT=6".to_owned());
let mut moved = event(uid, "Weekly (moved)", 14);
moved.dtstart = CalendarDateTime::Zoned {
local: chrono::NaiveDate::from_ymd_opt(2026, 3, 17)
.unwrap()
.and_hms_opt(14, 0, 0)
.unwrap(),
tzid: TzId::new("America/Denver").unwrap(),
};
moved.recurrence_id = Some(CalendarDateTime::Zoned {
local: chrono::NaiveDate::from_ymd_opt(2026, 3, 17)
.unwrap()
.and_hms_opt(9, 0, 0)
.unwrap(),
tzid: TzId::new("America/Denver").unwrap(),
});
let mut resource = VCalendar::with_events(vec![master, moved]);
resource.timezones.clear();
client
.put_object(&href_for(&calendar, uid), &resource, &Precondition::New)
.await
.unwrap();
let found = client
.events_in_range(
&calendar,
Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(),
Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap(),
)
.await
.unwrap();
assert_eq!(
found.len(),
1,
"one UID is one resource, however many VEVENTs"
);
let object = &found[0];
assert_eq!(
object.events().len(),
2,
"the master and its override come back together; reading them as \
two unrelated events is what made a correct calendar look like it \
was full of duplicates",
);
assert!(object.master().is_some());
assert_eq!(object.overrides().count(), 1);
assert!(object.has_consistent_uid());
})
.await;
}
// -------------------------------------------------------------------- sync --
#[tokio::test]
async fn etags_can_be_listed_without_the_bodies() {
with_calendar("etags", |client, calendar| async move {
for uid in ["one@test", "two@test"] {
client
.put_object(
&href_for(&calendar, uid),
&calendar_of(event(uid, "Something", 9)),
&Precondition::New,
)
.await
.unwrap();
}
let etags = client.etags(&calendar).await.unwrap();
assert_eq!(etags.len(), 2, "the collection itself must not be listed");
assert!(etags.iter().all(|(_, etag)| !etag.is_empty()));
let hrefs: Vec<String> = etags.iter().map(|(href, _)| href.clone()).collect();
let fetched = client.multiget(&calendar, &hrefs).await.unwrap();
assert_eq!(fetched.len(), 2, "and a multiget brings back exactly those");
})
.await;
}
// --------------------------------------------------------------- rejections --
#[tokio::test]
async fn bad_credentials_are_reported_as_such() {
let Some((_, user)) = client() else {
return;
};
let url = std::env::var("RUNWAY_CALDAV_URL").unwrap();
let wrong = CalDavClient::new(&url, Credentials::new(&user, "not-the-password")).unwrap();
assert!(
matches!(
wrong.current_user_principal().await,
Err(CalDavError::Unauthorized)
),
"a rejected password has to be distinguishable from a server being down",
);
}
#[tokio::test]
async fn the_golden_corpus_can_be_written_to_a_real_server() {
// The strongest statement available about the iCalendar writer: what it
// produces is accepted by a real CalDAV server, not merely by our own
// parser. Every fixture goes up and comes back.
with_calendar("corpus", |client, calendar| async move {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../runway-core/tests/golden/synthetic");
let mut checked = 0;
for entry in std::fs::read_dir(&dir).unwrap().filter_map(Result::ok) {
let path = entry.path();
if path.extension().is_none_or(|e| e != "ics") {
continue;
}
let source = ical::parse(&std::fs::read_to_string(&path).unwrap()).unwrap();
let Some(uid) = source.events.first().map(|e| e.uid.clone()) else {
continue;
};
let href = href_for(&calendar, &uid);
client
.put_object(&href, &source, &Precondition::New)
.await
.unwrap_or_else(|e| panic!("{} was rejected by the server: {e}", path.display()));
let returned = client.get_object(&calendar, &href).await.unwrap();
assert_eq!(
returned.calendar.events.len(),
source.events.len(),
"{} lost events on the server",
path.display(),
);
checked += 1;
}
assert!(checked >= 6, "only {checked} fixtures were exercised");
})
.await;
}
+286
View File
@@ -0,0 +1,286 @@
//! The CalDAV protocol layer, tested against responses recorded from a real
//! server.
//!
//! No mocks and no hand-invented XML for the main cases: the fixtures are what
//! Baikal actually sent, scrubbed of names. That matters because the awkward
//! parts of WebDAV are not in the specification's examples — they are the
//! second `propstat` carrying a 404 for properties the resource does not have,
//! the scheduling collections that look like calendars, and the fact that a
//! prefix is not a namespace.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use pretty_assertions::assert_eq;
use runway_caldav::xml::{self, CALDAV, DAV, DavResponse};
use runway_caldav::{calendars_from, href_for, objects_from, principal_from};
use std::path::Path;
fn fixture(name: &str) -> String {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name);
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}
fn responses(name: &str) -> Vec<DavResponse> {
xml::parse_multistatus(&fixture(name)).unwrap_or_else(|e| panic!("{name}: {e}"))
}
// ---------------------------------------------------------------- discovery --
#[test]
fn the_principal_is_read_from_a_real_response() {
assert_eq!(
principal_from(&responses("propfind-principal.xml")).unwrap(),
"/dav.php/principals/alex/",
);
}
#[test]
fn properties_the_server_reported_as_missing_are_not_treated_as_present() {
// The same response carries a second propstat with 404 listing displayname.
// Folding both propstats together would turn "this resource has no display
// name" into "this resource has an empty display name".
let responses = responses("propfind-principal.xml");
assert!(responses[0].prop(DAV, "current-user-principal").is_some());
assert!(
responses[0].prop(DAV, "displayname").is_none(),
"a property inside a 404 propstat was treated as found",
);
}
#[test]
fn the_calendar_list_matches_the_server() {
let calendars = calendars_from(&responses("propfind-calendars.xml"));
assert_eq!(
calendars.iter().map(|c| c.name()).collect::<Vec<_>>(),
vec![
"Birthdays",
"Household Chores",
"Partner Chores",
"Personal",
"Trips",
"Workouts",
],
);
}
#[test]
fn scheduling_collections_are_not_calendars() {
let calendars = calendars_from(&responses("propfind-calendars.xml"));
assert!(
!calendars
.iter()
.any(|c| c.href.contains("inbox") || c.href.contains("outbox")),
"a scheduling inbox is a collection, not something to show in a sidebar",
);
assert!(
!calendars
.iter()
.any(|c| c.href == "/dav.php/calendars/alex/"),
"the home collection itself is not a calendar",
);
}
#[test]
fn calendar_metadata_is_read_rather_than_invented() {
let calendars = calendars_from(&responses("propfind-calendars.xml"));
let personal = calendars.iter().find(|c| c.name() == "Personal").unwrap();
assert_eq!(personal.color.as_deref(), Some("#0CCE6B"));
assert!(
personal.ctag.is_some(),
"a ctag makes a cheap sync check possible"
);
assert_eq!(
personal.supported_components,
vec!["VEVENT", "VTODO", "VJOURNAL"],
"this collection really does accept all three",
);
assert!(personal.supports_events());
}
#[test]
fn a_calendar_with_no_colour_simply_has_none() {
let calendars = calendars_from(&responses("propfind-calendars.xml"));
let trips = calendars.iter().find(|c| c.name() == "Trips").unwrap();
assert_eq!(
trips.color, None,
"the server returned a 404 propstat for its colour; hashing the path to \
invent one is what made the last iteration disagree with every other \
client about what colour a calendar was",
);
assert!(trips.ctag.is_some());
}
#[test]
fn namespace_prefixes_do_not_matter() {
// The same document with DAV: as the default namespace and CalDAV under a
// differently-cased prefix. The previous backend tried six regular
// expressions in sequence to cope with this, recompiling each one inside
// the loop; a namespace-aware parser makes the question disappear.
let normal = calendars_from(&responses("propfind-calendars.xml"));
let rewritten = calendars_from(&responses("propfind-calendars-other-prefixes.xml"));
assert_eq!(normal, rewritten);
assert!(!normal.is_empty());
}
// ------------------------------------------------------------------ objects --
#[test]
fn a_calendar_query_yields_objects_with_their_etags() {
let objects = objects_from(
"/dav.php/calendars/alex/personal/",
responses("report-calendar-query.xml"),
)
.unwrap();
assert_eq!(objects.len(), 2);
assert_eq!(
objects[0].href,
"/dav.php/calendars/alex/personal/allday.ics"
);
assert_eq!(
objects[0].etag.as_deref(),
Some("\"7c9e8a1d2f3b4c5d6e7f8a9b0c1d2e3f\""),
"the ETag is what makes a conditional write possible; losing it means \
every save silently overwrites whatever arrived in the meantime",
);
assert_eq!(
objects[0].calendar_path,
"/dav.php/calendars/alex/personal/"
);
}
#[test]
fn a_response_with_no_calendar_data_is_skipped_not_invented() {
let all = responses("report-calendar-query.xml");
assert_eq!(all.len(), 3, "the fixture includes a 404 response");
let objects = objects_from("/dav.php/calendars/alex/personal/", all).unwrap();
assert_eq!(
objects.len(),
2,
"a resource the server reported as gone must not become an empty event",
);
}
#[test]
fn calendar_data_survives_the_trip_through_xml() {
// XML normalises CRLF to LF, so the iCalendar arriving here has different
// line endings from the bytes on the wire. Unfolding has to cope.
let objects = objects_from(
"/dav.php/calendars/alex/personal/",
responses("report-calendar-query.xml"),
)
.unwrap();
let zoned = &objects[1];
assert_eq!(zoned.events().len(), 1);
let event = &zoned.events()[0];
assert_eq!(event.alarms.len(), 2, "both alarms survived");
assert_eq!(
event.dtstart.tzid().map(runway_core::model::TzId::as_str),
Some("America/New_York"),
);
assert_eq!(
zoned.calendar.timezones.len(),
1,
"the VTIMEZONE came through with it",
);
}
// ---------------------------------------------------------------------- xml --
#[test]
fn an_entity_reference_is_resolved_with_its_surrounding_spaces() {
let doc = r#"<d:multistatus xmlns:d="DAV:"><d:response>
<d:href>/c/</d:href>
<d:propstat><d:prop><d:displayname>Bed &amp; Breakfast</d:displayname></d:prop>
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
</d:response></d:multistatus>"#;
let parsed = xml::parse_multistatus(doc).unwrap();
assert_eq!(
parsed[0].prop_text(DAV, "displayname"),
Some("Bed & Breakfast"),
"trimming each text fragment instead of the whole value would give \
\"Bed&Breakfast\"",
);
}
#[test]
fn a_numeric_character_reference_is_resolved() {
let doc = r#"<d:multistatus xmlns:d="DAV:"><d:response>
<d:href>/c/</d:href>
<d:propstat><d:prop><d:displayname>caf&#233;</d:displayname></d:prop>
<d:status>HTTP/1.1 200 OK</d:status></d:propstat>
</d:response></d:multistatus>"#;
let parsed = xml::parse_multistatus(doc).unwrap();
assert_eq!(parsed[0].prop_text(DAV, "displayname"), Some("café"));
}
#[test]
fn nested_property_values_are_navigable() {
let doc = r#"<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:response><d:href>/c/</d:href><d:propstat><d:prop>
<c:supported-calendar-component-set>
<c:comp name="VEVENT"/><c:comp name="VTODO"/>
</c:supported-calendar-component-set>
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#;
let parsed = xml::parse_multistatus(doc).unwrap();
let names: Vec<&str> = parsed[0]
.prop(CALDAV, "supported-calendar-component-set")
.unwrap()
.children(CALDAV, "comp")
.filter_map(|c| c.attribute("name"))
.collect();
assert_eq!(names, vec!["VEVENT", "VTODO"]);
}
#[test]
fn a_document_that_is_not_a_multistatus_is_rejected() {
let html = "<html><body>502 Bad Gateway</body></html>";
assert!(
xml::parse_multistatus(html).is_err(),
"a proxy error page must not parse as an empty calendar list",
);
}
#[test]
fn status_lines_are_read_for_their_code() {
assert_eq!(xml::status_code("HTTP/1.1 200 OK"), Some(200));
assert_eq!(xml::status_code("HTTP/1.1 404 Not Found"), Some(404));
assert_eq!(xml::status_code("nonsense"), None);
}
// ----------------------------------------------------------------- href_for --
#[test]
fn an_object_href_is_one_resource_per_uid() {
assert_eq!(
href_for("/dav.php/calendars/alex/personal/", "abc-123"),
"/dav.php/calendars/alex/personal/abc-123.ics",
);
}
#[test]
fn a_uid_that_is_not_url_safe_is_encoded() {
// Real UIDs from Google and Exchange contain characters a path segment
// cannot carry unescaped.
assert_eq!(
href_for("/c/", "26u614553d18@google.com"),
"/c/26u614553d18%40google.com.ics",
);
assert_eq!(href_for("/c/", "a/b c"), "/c/a%2Fb%20c.ics");
}