use alloc::{
format,
string::{String, ToString},
vec,
vec::Vec,
};
use log::trace;
use thiserror::Error;
use url::Url;
use crate::{
coroutine::*,
rfc4918::{
DAV, GETETAG, Multistatus, Property, WebdavAuth, XML_DECL, escape_text, prop_block,
report::Report, send::SendError, xmlns_decls,
},
};
#[derive(Clone, Debug, Default)]
pub struct SyncDelta {
pub changed: Vec<SyncChange>,
pub vanished: Vec<String>,
pub sync_token: Option<String>,
pub truncated: bool,
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SyncChange {
pub href: String,
pub etag: Option<String>,
}
#[derive(Debug, Error)]
pub enum SyncCollectionError {
#[error("WebDAV server rejected the sync token; run a full enumeration")]
InvalidSyncToken,
#[error(transparent)]
Send(#[from] SendError),
}
#[derive(Debug)]
pub struct SyncCollection {
state: State,
collection: String,
}
impl SyncCollection {
pub fn new(
base_url: &Url,
auth: &WebdavAuth,
user_agent: &str,
path: &str,
sync_token: Option<&str>,
props: &[Property],
) -> Self {
let body = sync_collection_body(sync_token, props);
let report = Report::new(base_url, auth, user_agent, path, 0, body);
Self {
state: State::Report(report),
collection: path.trim_end_matches('/').to_string(),
}
}
}
impl WebdavCoroutine for SyncCollection {
type Yield = WebdavYield;
type Return = Result<SyncDelta, SyncCollectionError>;
fn resume(&mut self, arg: Option<&[u8]>) -> WebdavCoroutineState<Self::Yield, Self::Return> {
trace!("sending request");
match &mut self.state {
State::Report(report) => {
let multistatus = match report.resume(arg) {
WebdavCoroutineState::Yielded(yielded) => {
return WebdavCoroutineState::Yielded(yielded);
}
WebdavCoroutineState::Complete(Err(SendError::HttpStatus(403, body)))
if body.contains("valid-sync-token") =>
{
let err = SyncCollectionError::InvalidSyncToken;
return WebdavCoroutineState::Complete(Err(err));
}
WebdavCoroutineState::Complete(Err(err)) => {
return WebdavCoroutineState::Complete(Err(err.into()));
}
WebdavCoroutineState::Complete(Ok(multistatus)) => multistatus,
};
let delta = from_multistatus(multistatus, &self.collection);
WebdavCoroutineState::Complete(Ok(delta))
}
}
}
}
pub fn sync_collection_body(sync_token: Option<&str>, props: &[Property]) -> Vec<u8> {
let mut nss = vec![DAV];
nss.extend(props.iter().map(|prop| prop.ns));
let decls = xmlns_decls(&nss);
let token = match sync_token {
Some(token) => format!("<D:sync-token>{}</D:sync-token>", escape_text(token)),
None => String::from("<D:sync-token/>"),
};
let mut body =
format!("{XML_DECL}<D:sync-collection{decls}>{token}<D:sync-level>1</D:sync-level>");
body.push_str(&prop_block(props));
body.push_str("</D:sync-collection>");
body.into_bytes()
}
fn from_multistatus(multistatus: Multistatus, collection: &str) -> SyncDelta {
let mut delta = SyncDelta {
sync_token: multistatus.sync_token,
..Default::default()
};
for entry in multistatus.responses {
match entry.status {
Some(404) => delta.vanished.push(entry.href),
Some(507) => delta.truncated = true,
Some(status) if status / 100 != 2 => {
trace!(
"skip sync-collection row {} with status {status}",
entry.href
);
}
_ if entry.href.trim_end_matches('/') == collection.trim_end_matches('/') => {
trace!("skip sync-collection self-entry {}", entry.href);
}
_ => {
let etag = entry
.text(GETETAG)
.map(|raw| raw.trim_matches('"').to_string());
delta.changed.push(SyncChange {
href: entry.href,
etag,
});
}
}
}
delta
}
#[derive(Debug)]
enum State {
Report(Report),
}
#[cfg(test)]
mod tests {
use crate::rfc4918::parse_multistatus;
use super::*;
#[test]
fn body_carries_empty_token_on_initial_sync() {
let body = sync_collection_body(None, &[GETETAG]);
let xml = core::str::from_utf8(&body).unwrap();
assert!(xml.contains("<D:sync-collection xmlns:D=\"DAV:\">"));
assert!(xml.contains("<D:sync-token/><D:sync-level>1</D:sync-level>"));
assert!(xml.contains("<D:prop><D:getetag/></D:prop>"));
assert!(xml.ends_with("</D:sync-collection>"));
}
#[test]
fn body_carries_the_given_token() {
let body = sync_collection_body(Some("http://example.com/ns/sync/1234"), &[GETETAG]);
let xml = core::str::from_utf8(&body).unwrap();
assert!(xml.contains("<D:sync-token>http://example.com/ns/sync/1234</D:sync-token>"));
}
#[test]
fn delta_sorts_changed_vanished_and_truncated_rows() {
let xml = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/dav/addressbooks/contacts/changed.vcf</d:href>
<d:propstat>
<d:prop><d:getetag>"etag-1"</d:getetag></d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav/addressbooks/contacts/removed.vcf</d:href>
<d:status>HTTP/1.1 404 Not Found</d:status>
</d:response>
<d:response>
<d:href>/dav/addressbooks/contacts/</d:href>
<d:status>HTTP/1.1 507 Insufficient Storage</d:status>
</d:response>
<d:sync-token>http://example.com/ns/sync/1234</d:sync-token>
</d:multistatus>"#;
let delta = from_multistatus(parse_multistatus(xml), "/dav/addressbooks/contacts");
assert_eq!(delta.changed.len(), 1);
assert_eq!(
delta.changed[0].href,
"/dav/addressbooks/contacts/changed.vcf"
);
assert_eq!(delta.changed[0].etag.as_deref(), Some("etag-1"));
assert_eq!(delta.vanished, ["/dav/addressbooks/contacts/removed.vcf"]);
assert_eq!(
delta.sync_token.as_deref(),
Some("http://example.com/ns/sync/1234")
);
assert!(delta.truncated);
}
#[test]
fn delta_skips_the_collection_self_entry() {
let xml = r#"<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/17170244959/carddavhome/card</d:href>
<d:propstat>
<d:prop><d:getetag>"coll-etag"</d:getetag></d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/17170244959/carddavhome/card/5d18175a.vcf</d:href>
<d:propstat>
<d:prop><d:getetag>"etag-1"</d:getetag></d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"#;
let delta = from_multistatus(parse_multistatus(xml), "/17170244959/carddavhome/card/");
assert_eq!(delta.changed.len(), 1);
assert_eq!(
delta.changed[0].href,
"/17170244959/carddavhome/card/5d18175a.vcf"
);
}
}