io_webdav/rfc6578/
sync_collection.rs1use alloc::{
59 format,
60 string::{String, ToString},
61 vec,
62 vec::Vec,
63};
64
65use log::trace;
66use thiserror::Error;
67use url::Url;
68
69use crate::{
70 coroutine::*,
71 rfc4918::{
72 DAV, GETETAG, Multistatus, Property, WebdavAuth, XML_DECL, escape_text, prop_block,
73 report::Report, send::SendError, xmlns_decls,
74 },
75};
76
77#[derive(Clone, Debug, Default)]
79pub struct SyncDelta {
80 pub changed: Vec<SyncChange>,
82
83 pub vanished: Vec<String>,
86
87 pub sync_token: Option<String>,
89
90 pub truncated: bool,
94}
95
96#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
98pub struct SyncChange {
99 pub href: String,
101
102 pub etag: Option<String>,
104}
105
106#[derive(Debug, Error)]
108pub enum SyncCollectionError {
109 #[error("WebDAV server rejected the sync token; run a full enumeration")]
111 InvalidSyncToken,
112
113 #[error(transparent)]
115 Send(#[from] SendError),
116}
117
118#[derive(Debug)]
121pub struct SyncCollection {
122 state: State,
123 collection: String,
126}
127
128impl SyncCollection {
129 pub fn new(
135 base_url: &Url,
136 auth: &WebdavAuth,
137 user_agent: &str,
138 path: &str,
139 sync_token: Option<&str>,
140 props: &[Property],
141 ) -> Self {
142 let body = sync_collection_body(sync_token, props);
143 let report = Report::new(base_url, auth, user_agent, path, 0, body);
144 Self {
145 state: State::Report(report),
146 collection: path.trim_end_matches('/').to_string(),
147 }
148 }
149}
150
151impl WebdavCoroutine for SyncCollection {
152 type Yield = WebdavYield;
153 type Return = Result<SyncDelta, SyncCollectionError>;
154
155 fn resume(&mut self, arg: Option<&[u8]>) -> WebdavCoroutineState<Self::Yield, Self::Return> {
156 trace!("sending request");
157 match &mut self.state {
158 State::Report(report) => {
159 let multistatus = match report.resume(arg) {
160 WebdavCoroutineState::Yielded(yielded) => {
161 return WebdavCoroutineState::Yielded(yielded);
162 }
163 WebdavCoroutineState::Complete(Err(SendError::HttpStatus(403, body)))
164 if body.contains("valid-sync-token") =>
165 {
166 let err = SyncCollectionError::InvalidSyncToken;
167 return WebdavCoroutineState::Complete(Err(err));
168 }
169 WebdavCoroutineState::Complete(Err(err)) => {
170 return WebdavCoroutineState::Complete(Err(err.into()));
171 }
172 WebdavCoroutineState::Complete(Ok(multistatus)) => multistatus,
173 };
174
175 let delta = from_multistatus(multistatus, &self.collection);
176 WebdavCoroutineState::Complete(Ok(delta))
177 }
178 }
179 }
180}
181
182pub fn sync_collection_body(sync_token: Option<&str>, props: &[Property]) -> Vec<u8> {
186 let mut nss = vec![DAV];
187 nss.extend(props.iter().map(|prop| prop.ns));
188 let decls = xmlns_decls(&nss);
189
190 let token = match sync_token {
191 Some(token) => format!("<D:sync-token>{}</D:sync-token>", escape_text(token)),
192 None => String::from("<D:sync-token/>"),
193 };
194
195 let mut body =
196 format!("{XML_DECL}<D:sync-collection{decls}>{token}<D:sync-level>1</D:sync-level>");
197 body.push_str(&prop_block(props));
198 body.push_str("</D:sync-collection>");
199 body.into_bytes()
200}
201
202fn from_multistatus(multistatus: Multistatus, collection: &str) -> SyncDelta {
208 let mut delta = SyncDelta {
209 sync_token: multistatus.sync_token,
210 ..Default::default()
211 };
212
213 for entry in multistatus.responses {
214 match entry.status {
215 Some(404) => delta.vanished.push(entry.href),
216 Some(507) => delta.truncated = true,
217 Some(status) if status / 100 != 2 => {
218 trace!(
219 "skip sync-collection row {} with status {status}",
220 entry.href
221 );
222 }
223 _ if entry.href.trim_end_matches('/') == collection.trim_end_matches('/') => {
229 trace!("skip sync-collection self-entry {}", entry.href);
230 }
231 _ => {
232 let etag = entry
233 .text(GETETAG)
234 .map(|raw| raw.trim_matches('"').to_string());
235 delta.changed.push(SyncChange {
236 href: entry.href,
237 etag,
238 });
239 }
240 }
241 }
242
243 delta
244}
245
246#[derive(Debug)]
247enum State {
248 Report(Report),
249}
250
251#[cfg(test)]
252mod tests {
253 use crate::rfc4918::parse_multistatus;
254
255 use super::*;
256
257 #[test]
258 fn body_carries_empty_token_on_initial_sync() {
259 let body = sync_collection_body(None, &[GETETAG]);
260 let xml = core::str::from_utf8(&body).unwrap();
261 assert!(xml.contains("<D:sync-collection xmlns:D=\"DAV:\">"));
262 assert!(xml.contains("<D:sync-token/><D:sync-level>1</D:sync-level>"));
263 assert!(xml.contains("<D:prop><D:getetag/></D:prop>"));
264 assert!(xml.ends_with("</D:sync-collection>"));
265 }
266
267 #[test]
268 fn body_carries_the_given_token() {
269 let body = sync_collection_body(Some("http://example.com/ns/sync/1234"), &[GETETAG]);
270 let xml = core::str::from_utf8(&body).unwrap();
271 assert!(xml.contains("<D:sync-token>http://example.com/ns/sync/1234</D:sync-token>"));
272 }
273
274 #[test]
275 fn delta_sorts_changed_vanished_and_truncated_rows() {
276 let xml = r#"<?xml version="1.0"?>
277 <d:multistatus xmlns:d="DAV:">
278 <d:response>
279 <d:href>/dav/addressbooks/contacts/changed.vcf</d:href>
280 <d:propstat>
281 <d:prop><d:getetag>"etag-1"</d:getetag></d:prop>
282 <d:status>HTTP/1.1 200 OK</d:status>
283 </d:propstat>
284 </d:response>
285 <d:response>
286 <d:href>/dav/addressbooks/contacts/removed.vcf</d:href>
287 <d:status>HTTP/1.1 404 Not Found</d:status>
288 </d:response>
289 <d:response>
290 <d:href>/dav/addressbooks/contacts/</d:href>
291 <d:status>HTTP/1.1 507 Insufficient Storage</d:status>
292 </d:response>
293 <d:sync-token>http://example.com/ns/sync/1234</d:sync-token>
294 </d:multistatus>"#;
295
296 let delta = from_multistatus(parse_multistatus(xml), "/dav/addressbooks/contacts");
297
298 assert_eq!(delta.changed.len(), 1);
299 assert_eq!(
300 delta.changed[0].href,
301 "/dav/addressbooks/contacts/changed.vcf"
302 );
303 assert_eq!(delta.changed[0].etag.as_deref(), Some("etag-1"));
304 assert_eq!(delta.vanished, ["/dav/addressbooks/contacts/removed.vcf"]);
305 assert_eq!(
306 delta.sync_token.as_deref(),
307 Some("http://example.com/ns/sync/1234")
308 );
309 assert!(delta.truncated);
310 }
311
312 #[test]
313 fn delta_skips_the_collection_self_entry() {
314 let xml = r#"<?xml version="1.0"?>
318 <d:multistatus xmlns:d="DAV:">
319 <d:response>
320 <d:href>/17170244959/carddavhome/card</d:href>
321 <d:propstat>
322 <d:prop><d:getetag>"coll-etag"</d:getetag></d:prop>
323 <d:status>HTTP/1.1 200 OK</d:status>
324 </d:propstat>
325 </d:response>
326 <d:response>
327 <d:href>/17170244959/carddavhome/card/5d18175a.vcf</d:href>
328 <d:propstat>
329 <d:prop><d:getetag>"etag-1"</d:getetag></d:prop>
330 <d:status>HTTP/1.1 200 OK</d:status>
331 </d:propstat>
332 </d:response>
333 </d:multistatus>"#;
334
335 let delta = from_multistatus(parse_multistatus(xml), "/17170244959/carddavhome/card/");
336
337 assert_eq!(delta.changed.len(), 1);
338 assert_eq!(
339 delta.changed[0].href,
340 "/17170244959/carddavhome/card/5d18175a.vcf"
341 );
342 }
343}