use std::{
collections::{BTreeMap, BTreeSet},
fmt,
io::{ErrorKind, Read, Write},
};
use anyhow::{Context, Result, bail};
use io_http::rfc9112::send::Http11SendError;
use io_webdav::{
client::{WebdavClientStd, WebdavClientStdError},
rfc4791::calendar::CaldavCalendar,
rfc4918::{WebdavAuth, follow_redirects::WebdavFollowRedirectsError, send::WebdavSendError},
rfc6352::addressbook::CarddavAddressbook,
rfc6578::sync_collection::{
SYNC_COLLECTION, WebdavSyncChange, WebdavSyncCollectionError, WebdavSyncCollectionOptions,
WebdavSyncDelta,
},
};
use log::{debug, warn};
use pimalaya_stream::tls::Tls;
use url::Url;
use crate::{
client::{EnumEntry, Enumeration, WrittenItem},
item::{collection::Collection, flag::Flag, flag::FlagOp},
kind::{Kind, LinkId},
};
const MAX_SYNC_ROUNDS: usize = 32;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DavKind {
Card,
Cal,
}
impl DavKind {
pub fn media_type(self) -> &'static str {
match self {
Self::Card => "text/vcard",
Self::Cal => "text/calendar",
}
}
fn item_kind(self) -> Kind {
match self {
Self::Card => Kind::Vcard,
Self::Cal => Kind::Ical,
}
}
pub fn protocol(self) -> &'static str {
match self {
Self::Card => "carddav",
Self::Cal => "caldav",
}
}
fn extension(self) -> &'static str {
match self {
Self::Card => "vcf",
Self::Cal => "ics",
}
}
}
impl fmt::Display for DavKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Card => write!(f, "CardDAV"),
Self::Cal => write!(f, "CalDAV"),
}
}
}
pub struct DavClient {
kind: DavKind,
inner: WebdavClientStd,
server: Url,
tls: Tls,
auth: WebdavAuth,
}
impl DavClient {
pub fn connect(kind: DavKind, server: &Url, tls: &Tls, auth: WebdavAuth) -> Result<Self> {
let inner = WebdavClientStd::connect(server, tls, auth.clone())
.with_context(|| format!("Cannot connect to the {kind} server"))?;
let mut client = Self {
kind,
inner,
server: server.clone(),
tls: tls.clone(),
auth,
};
let home = match kind {
DavKind::Card => client.op(WebdavClientStd::addressbook_home_set),
DavKind::Cal => client.op(WebdavClientStd::calendar_home_set),
}
.with_context(|| format!("Cannot discover the {kind} home set"))?;
debug!("{kind} home set: {home}");
Ok(client)
}
pub fn media_type(&self) -> &'static str {
self.kind.media_type()
}
fn op<T>(
&mut self,
mut run: impl FnMut(&mut WebdavClientStd) -> Result<T, WebdavClientStdError>,
) -> Result<T, WebdavClientStdError> {
match run(&mut self.inner) {
Err(err) if is_connection_closed(&err) => {
debug!("{} connection closed by the server, reopening", self.kind);
self.reconnect()?;
run(&mut self.inner)
}
out => out,
}
}
fn reconnect(&mut self) -> Result<(), WebdavClientStdError> {
let mut inner = WebdavClientStd::connect(&self.server, &self.tls, self.auth.clone())?;
inner.principal_url = self.inner.principal_url.clone();
inner.addressbook_home_set = self.inner.addressbook_home_set.clone();
inner.addressbook_reports = self.inner.addressbook_reports.clone();
inner.calendar_home_set = self.inner.calendar_home_set.clone();
inner.calendar_reports = self.inner.calendar_reports.clone();
self.inner = inner;
Ok(())
}
pub fn list_collections(&mut self, _with_counts: bool) -> Result<Vec<Collection>> {
let kind = self.kind;
let collections: BTreeMap<String, Option<String>> = match kind {
DavKind::Card => self.op(WebdavClientStd::list_addressbooks).map(|books| {
books
.into_iter()
.map(|book| (book.id, book.display_name))
.collect()
}),
DavKind::Cal => self.op(WebdavClientStd::list_calendars).map(|calendars| {
calendars
.into_iter()
.map(|calendar| (calendar.id, calendar.display_name))
.collect()
}),
}
.with_context(|| format!("Cannot list the {kind} collections"))?;
Ok(collections
.into_iter()
.map(|(id, display)| Collection {
name: display
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| id.clone()),
id,
total: None,
unread: None,
})
.collect())
}
pub fn create_collection(&mut self, collection: &str) -> Result<()> {
let kind = self.kind;
let name = Some(collection.to_owned());
match kind {
DavKind::Card => {
let book = CarddavAddressbook {
id: collection.to_owned(),
display_name: name,
..Default::default()
};
self.op(|dav| dav.create_addressbook(&book))
}
DavKind::Cal => {
let calendar = CaldavCalendar {
id: collection.to_owned(),
display_name: name,
..Default::default()
};
self.op(|dav| dav.create_calendar(&calendar))
}
}
.with_context(|| format!("Cannot create the {kind} collection {collection}"))
}
pub fn delete_collection(&mut self, collection: &str) -> Result<()> {
let kind = self.kind;
match kind {
DavKind::Card => self.op(|dav| dav.delete_addressbook(collection)),
DavKind::Cal => self.op(|dav| dav.delete_calendar(collection)),
}
.with_context(|| format!("Cannot delete the {kind} collection {collection}"))
}
pub fn enumerate(&mut self, collection: &str, cursor: Option<&[u8]>) -> Result<Enumeration> {
if !has_sync_collection(self.reports(collection)) {
debug!(
"{} server advertises no `sync-collection` for {collection}, listing it",
self.kind
);
return self
.list(collection)
.with_context(|| format!("Cannot list {collection}"));
}
let token = cursor
.filter(|cursor| !cursor.is_empty())
.map(String::from_utf8_lossy)
.map(String::from);
match self.sync(collection, token.as_deref()) {
Ok(enumeration) => Ok(enumeration),
Err(err) if is_invalid_sync_token(&err) => {
warn!(
"{} sync token rejected for {collection}, enumerating in full",
self.kind
);
self.sync(collection, None)
.with_context(|| format!("Cannot enumerate {collection} in full"))
}
Err(err) if err.is_unsupported_report() => {
debug!(
"{} server has no `sync-collection`, listing {collection} instead",
self.kind
);
self.list(collection)
.with_context(|| format!("Cannot list {collection}"))
}
Err(err) => {
Err(anyhow::Error::new(err).context(format!("Cannot enumerate {collection}")))
}
}
}
fn reports(&self, collection: &str) -> Option<&BTreeSet<String>> {
match self.kind {
DavKind::Card => self.inner.addressbook_reports.get(collection),
DavKind::Cal => self.inner.calendar_reports.get(collection),
}
}
fn sync_collection(
&mut self,
collection: &str,
token: Option<&str>,
opts: WebdavSyncCollectionOptions,
) -> Result<WebdavSyncDelta, WebdavClientStdError> {
match self.kind {
DavKind::Card => self.op(|dav| dav.sync_cards(collection, token, opts)),
DavKind::Cal => self.op(|dav| dav.sync_items(collection, token, opts)),
}
}
fn list(&mut self, collection: &str) -> Result<Enumeration, WebdavClientStdError> {
let opts = WebdavSyncCollectionOptions { fallback: true };
let delta = self.sync_collection(collection, None, opts)?;
if delta.truncated {
warn!(
"{} server truncated the listing of {collection}, reconciling as a delta",
self.kind
);
}
Ok(Enumeration {
items: delta.changed.into_iter().map(entry).collect(),
vanished: Vec::new(),
complete: !delta.truncated,
checkpoint: Vec::new(),
})
}
fn sync(
&mut self,
collection: &str,
token: Option<&str>,
) -> Result<Enumeration, WebdavClientStdError> {
let complete = token.is_none();
let mut items = Vec::new();
let mut vanished = Vec::new();
let mut token = token.map(String::from);
for round in 0..MAX_SYNC_ROUNDS {
let delta = self.sync_collection(collection, token.as_deref(), Default::default())?;
items.extend(delta.changed.into_iter().map(entry));
vanished.extend(delta.vanished.iter().map(|href| href_id(href)));
token = delta.sync_token;
if !delta.truncated {
break;
}
if round + 1 == MAX_SYNC_ROUNDS {
warn!(
"{} server kept truncating {collection} after {MAX_SYNC_ROUNDS} rounds, \
continuing with what it returned",
self.kind
);
}
}
Ok(Enumeration {
items,
vanished,
complete,
checkpoint: token.unwrap_or_default().into_bytes(),
})
}
pub fn fetch_bodies<S: Write>(
&mut self,
collection: &str,
ids: &[&str],
mut open: impl FnMut(&str) -> std::io::Result<S>,
mut done: impl FnMut(&str, Option<&str>, S) -> std::io::Result<()>,
) -> Result<()> {
if ids.is_empty() {
return Ok(());
}
let kind = self.kind;
let objects: Vec<(String, Option<String>, Vec<u8>)> = match kind {
DavKind::Card => self
.op(|dav| dav.multiget_cards(collection, ids))
.map(|cards| {
cards
.into_iter()
.map(|card| (card.id, card.etag, card.data))
.collect()
}),
DavKind::Cal => self
.op(|dav| dav.multiget_items(collection, ids))
.map(|items| {
items
.into_iter()
.map(|item| (item.id, item.etag, item.data))
.collect()
}),
}
.with_context(|| format!("Cannot fetch {kind} bodies from {collection}"))?;
for (id, etag, data) in objects {
let mut sink = open(&id)?;
sink.write_all(&data)?;
done(&id, etag.as_deref(), sink)?;
}
Ok(())
}
pub fn get_item_stream(
&mut self,
collection: &str,
id: &str,
mut sink: impl Write,
) -> Result<Option<String>> {
let (data, etag) = self
.read(collection, id)
.with_context(|| format!("Cannot read {id} in {collection}"))?;
sink.write_all(&data)?;
Ok(etag)
}
pub fn add_item_stream(
&mut self,
collection: &str,
mut source: impl Read,
link: LinkId<'_>,
) -> Result<WrittenItem> {
let mut body = Vec::new();
source.read_to_end(&mut body)?;
let id = resource_id(self.kind, link, &body);
self.create(collection, &id, body)
.with_context(|| format!("Cannot create {id} in {collection}"))
}
pub fn update_item_stream(
&mut self,
collection: &str,
id: &str,
mut source: impl Read,
if_match: Option<&str>,
) -> Result<Option<String>> {
let mut body = Vec::new();
source.read_to_end(&mut body)?;
let updated = match self.kind {
DavKind::Card => self
.op(|dav| dav.update_card(collection, id, body.clone(), if_match))
.map(|updated| updated.etag),
DavKind::Cal => self
.op(|dav| dav.update_item(collection, id, body.clone(), if_match))
.map(|updated| updated.etag),
};
updated.with_context(|| format!("Cannot update {id} in {collection}"))
}
pub fn delete_item(
&mut self,
collection: &str,
id: &str,
if_match: Option<&str>,
) -> Result<()> {
self.delete(collection, id, if_match)
.with_context(|| format!("Cannot delete {id} from {collection}"))
}
pub fn move_items(&mut self, from: &str, to: &str, ids: &[&str]) -> Result<()> {
for id in ids {
let (data, etag) = self
.read(from, id)
.with_context(|| format!("Cannot read {id} for a move"))?;
self.create(to, id, data)
.with_context(|| format!("Cannot create {id} in {to}"))?;
self.delete(from, id, etag.as_deref())
.with_context(|| format!("Cannot delete the moved {id} from {from}"))?;
}
Ok(())
}
pub fn store_flags(&mut self, _ids: &[&str], _flags: &[Flag], _op: FlagOp) -> Result<()> {
bail!("{} has no flags (store not supported)", self.kind)
}
fn read(
&mut self,
collection: &str,
id: &str,
) -> Result<(Vec<u8>, Option<String>), WebdavClientStdError> {
match self.kind {
DavKind::Card => self
.op(|dav| dav.read_card(collection, id))
.map(|card| (card.data, card.etag)),
DavKind::Cal => self
.op(|dav| dav.read_item(collection, id))
.map(|item| (item.data, item.etag)),
}
}
fn create(
&mut self,
collection: &str,
id: &str,
body: Vec<u8>,
) -> Result<WrittenItem, WebdavClientStdError> {
match self.kind {
DavKind::Card => self
.op(|dav| dav.create_card(collection, id, body.clone()))
.map(|created| WrittenItem {
id: created.id,
revision: created.etag,
}),
DavKind::Cal => self
.op(|dav| dav.create_item(collection, id, body.clone()))
.map(|created| WrittenItem {
id: created.id,
revision: created.etag,
}),
}
}
fn delete(
&mut self,
collection: &str,
id: &str,
if_match: Option<&str>,
) -> Result<(), WebdavClientStdError> {
match self.kind {
DavKind::Card => self.op(|dav| dav.delete_card(collection, id, if_match)),
DavKind::Cal => self.op(|dav| dav.delete_item(collection, id, if_match)),
}
}
}
fn entry(change: WebdavSyncChange) -> EnumEntry {
EnumEntry {
id: href_id(&change.href),
flags: BTreeSet::new(),
revision: change.etag,
}
}
fn href_id(href: &str) -> String {
href.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or(href)
.to_owned()
}
fn resource_id(kind: DavKind, link: LinkId<'_>, body: &[u8]) -> String {
let extension = kind.extension();
let hint = link.hint.map(str::trim).filter(|hint| !hint.is_empty());
let name = match (hint, link.mint) {
(Some(uid), None) => sanitize(uid),
(Some(uid), Some(mint)) => format!("{}-{}", sanitize(uid), sanitize(mint)),
(None, Some(mint)) => sanitize(mint),
(None, None) => {
let link = kind.item_kind().parse_body(body, body.len() as u64).link_id;
sanitize(link.0.trim_start_matches("hash:"))
}
};
format!("{name}.{extension}")
}
fn sanitize(uid: &str) -> String {
uid.chars()
.map(|char| match char {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | ':' => char,
_ => '-',
})
.collect()
}
fn is_connection_closed(err: &WebdavClientStdError) -> bool {
match err {
WebdavClientStdError::Send(WebdavSendError::Send(Http11SendError::Eof))
| WebdavClientStdError::WebdavFollowRedirects(WebdavFollowRedirectsError::Send(
Http11SendError::Eof,
))
| WebdavClientStdError::WebdavSyncCollection(WebdavSyncCollectionError::Send(
WebdavSendError::Send(Http11SendError::Eof),
)) => true,
WebdavClientStdError::Io(err) => matches!(
err.kind(),
ErrorKind::BrokenPipe | ErrorKind::ConnectionAborted | ErrorKind::ConnectionReset
),
_ => false,
}
}
pub fn is_duplicate_uid(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause
.downcast_ref::<WebdavClientStdError>()
.is_some_and(WebdavClientStdError::is_duplicate_uid)
})
}
fn has_sync_collection(reports: Option<&BTreeSet<String>>) -> bool {
match reports {
Some(reports) => reports.contains(SYNC_COLLECTION.local),
None => true,
}
}
fn is_invalid_sync_token(err: &WebdavClientStdError) -> bool {
matches!(
err,
WebdavClientStdError::WebdavSyncCollection(WebdavSyncCollectionError::InvalidSyncToken)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_member_href_addresses_by_its_last_segment() {
assert_eq!(href_id("/dav/books/default/card-1.vcf"), "card-1.vcf");
assert_eq!(
href_id("https://dav.example.org/books/default/card-1.vcf"),
"card-1.vcf"
);
assert_eq!(href_id("/dav/books/default/"), "default");
assert_eq!(href_id("card-1.vcf"), "card-1.vcf");
}
fn stated(uid: &str) -> LinkId<'_> {
LinkId {
hint: Some(uid),
mint: None,
}
}
#[test]
fn a_new_resource_is_addressed_by_its_uid_under_its_kinds_extension() {
assert_eq!(
resource_id(DavKind::Card, stated("card-1"), b""),
"card-1.vcf"
);
assert_eq!(
resource_id(DavKind::Cal, stated("event-1"), b""),
"event-1.ics"
);
assert_eq!(
resource_id(DavKind::Card, stated("urn:uuid:4fbe8971-0bc3"), b""),
"urn:uuid:4fbe8971-0bc3.vcf"
);
assert_eq!(
resource_id(DavKind::Card, stated("a b/c"), b""),
"a-b-c.vcf"
);
}
#[test]
fn a_minted_copy_is_addressed_beside_its_twin_rather_than_over_it() {
let twin = stated("event-1@google.com");
let copy = LinkId {
hint: Some("event-1@google.com"),
mint: Some("event-1%2540google.com.ics"),
};
let twin = resource_id(DavKind::Cal, twin, b"");
let copy = resource_id(DavKind::Cal, copy, b"");
assert_eq!(twin, "event-1-google.com.ics");
assert_eq!(copy, "event-1-google.com-event-1-2540google.com.ics.ics");
assert_ne!(twin, copy);
}
#[test]
fn a_minted_copy_without_a_uid_is_never_named_after_its_body() {
let card = b"BEGIN:VCARD\r\nVERSION:4.0\r\nFN:No Uid\r\nEND:VCARD\r\n";
let copy = LinkId {
hint: None,
mint: Some("card-2.vcf"),
};
let twin = resource_id(DavKind::Card, LinkId::default(), card);
let copy = resource_id(DavKind::Card, copy, card);
assert_eq!(copy, "card-2.vcf.vcf");
assert_ne!(twin, copy);
}
#[test]
fn a_resource_without_a_uid_is_addressed_by_its_body_digest() {
let card = b"BEGIN:VCARD\r\nVERSION:4.0\r\nFN:No Uid\r\nEND:VCARD\r\n";
let event =
b"BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:No Uid\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
for (kind, body, extension) in [
(DavKind::Card, card.as_slice(), ".vcf"),
(DavKind::Cal, event.as_slice(), ".ics"),
] {
let id = resource_id(kind, LinkId::default(), body);
assert!(id.ends_with(extension), "got {id}");
assert!(!id.starts_with("hash:"), "the prefix is not a path segment");
assert_eq!(id, resource_id(kind, LinkId::default(), body));
}
}
#[test]
fn a_collection_without_the_report_is_listed_instead() {
let advertised = |reports: &[&str]| {
reports
.iter()
.map(|report| String::from(*report))
.collect::<BTreeSet<String>>()
};
assert!(!has_sync_collection(Some(&advertised(&[
"addressbook-multiget",
"addressbook-query",
]))));
assert!(!has_sync_collection(Some(&advertised(&[
"calendar-multiget",
"calendar-query",
]))));
assert!(has_sync_collection(Some(&advertised(&[
"calendar-query",
"sync-collection",
]))));
assert!(
has_sync_collection(None),
"a collection nobody listed is enumerated by the report, whose refusal names itself",
);
}
}