Skip to main content

io_webdav/rfc6578/
sync_collection.rs

1//! `sync-collection` REPORT coroutine (RFC 6578 §3.2): incremental
2//! enumeration of a collection against a sync token.
3//!
4//! An initial sync (no token) returns every member; a subsequent sync
5//! returns only the members changed or removed since the given token,
6//! plus the next token to checkpoint. A rejected token surfaces as
7//! [`SyncCollectionError::InvalidSyncToken`] so the consumer can fall
8//! back to a full enumeration.
9//!
10//! # Example
11//!
12//! ```rust,no_run
13//! use std::{
14//!     io::{Read, Write},
15//!     net::TcpStream,
16//! };
17//!
18//! use io_webdav::{
19//!     coroutine::{WebdavCoroutine, WebdavCoroutineState, WebdavYield},
20//!     rfc4918::{GETETAG, WebdavAuth},
21//!     rfc6578::sync_collection::SyncCollection,
22//! };
23//! use url::Url;
24//!
25//! // Ready stream needed (TCP-connected, TLS-negociated)
26//! let mut stream = TcpStream::connect("dav.example.org:443").unwrap();
27//! let mut buf = [0u8; 4096];
28//!
29//! let base_url: Url = "https://dav.example.org/".parse().unwrap();
30//! let auth = WebdavAuth::None;
31//! let mut coroutine = SyncCollection::new(
32//!     &base_url,
33//!     &auth,
34//!     "io-webdav",
35//!     "/dav/addressbooks/contacts/",
36//!     None,
37//!     &[GETETAG],
38//! );
39//! let mut arg = None;
40//!
41//! let delta = loop {
42//!     match coroutine.resume(arg.take()) {
43//!         WebdavCoroutineState::Yielded(WebdavYield::WantsWrite(bytes)) => {
44//!             stream.write_all(&bytes).unwrap();
45//!         }
46//!         WebdavCoroutineState::Yielded(WebdavYield::WantsRead) => {
47//!             let n = stream.read(&mut buf).unwrap();
48//!             arg = Some(&buf[..n]);
49//!         }
50//!         WebdavCoroutineState::Complete(Ok(delta)) => break delta,
51//!         WebdavCoroutineState::Complete(Err(err)) => panic!("{err}"),
52//!     }
53//! };
54//!
55//! println!("{} changed, {} vanished", delta.changed.len(), delta.vanished.len());
56//! ```
57
58use 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/// Delta returned by a `sync-collection` REPORT.
78#[derive(Clone, Debug, Default)]
79pub struct SyncDelta {
80    /// Members created or updated since the request token.
81    pub changed: Vec<SyncChange>,
82
83    /// Hrefs of the members removed since the request token (404
84    /// response-level status, RFC 6578 §3.4).
85    pub vanished: Vec<String>,
86
87    /// The next checkpoint token, fed back to the following sync.
88    pub sync_token: Option<String>,
89
90    /// Whether the server truncated the result set (a 507 row was
91    /// present, RFC 6578 §3.6); the consumer must run the report again
92    /// from [`sync_token`](Self::sync_token) to drain the rest.
93    pub truncated: bool,
94}
95
96/// A changed member reported by a `sync-collection` REPORT.
97#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
98pub struct SyncChange {
99    /// The member `<href>`, as returned by the server.
100    pub href: String,
101
102    /// Entity tag (RFC 9110 §8.8.3), without surrounding quotes.
103    pub etag: Option<String>,
104}
105
106/// Failure causes during a `sync-collection` REPORT.
107#[derive(Debug, Error)]
108pub enum SyncCollectionError {
109    /// The server rejected the sync token; a full enumeration is needed.
110    #[error("WebDAV server rejected the sync token; run a full enumeration")]
111    InvalidSyncToken,
112
113    /// The underlying WebDAV send failed.
114    #[error(transparent)]
115    Send(#[from] SendError),
116}
117
118/// Coroutine that runs a `sync-collection` REPORT (RFC 6578 §3.2) and
119/// returns the parsed [`SyncDelta`].
120#[derive(Debug)]
121pub struct SyncCollection {
122    state: State,
123    /// The collection path, without a trailing slash, so its own
124    /// self-entry can be told apart from member resources.
125    collection: String,
126}
127
128impl SyncCollection {
129    /// Builds a new `sync-collection` coroutine against the collection
130    /// at `path`, requesting `props` on each changed member. Pass
131    /// [`None`] as `sync_token` for an initial sync. The `Depth` header
132    /// is pinned to 0 as required by RFC 6578 §3.3; the scope is
133    /// carried by the sync-level element instead.
134    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
182/// Builds a `sync-collection` REPORT body (RFC 6578 §6.1): the request
183/// token (an empty element for an initial sync), sync-level 1 and the
184/// requested `props`, in DTD order.
185pub 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
202/// Sorts the multistatus rows into a [`SyncDelta`]: 404 rows are
203/// removals, a 507 row flags truncation, everything else is a change.
204/// `collection` is the request-target path (trailing slash trimmed), so
205/// the collection's own self-entry can be dropped rather than mistaken
206/// for a member resource.
207fn 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            // Skip the collection self-entry: some servers (iCloud) echo
224            // the collection itself in the sync report, as its own path
225            // (with or without a trailing slash). It is not a member
226            // resource and would otherwise enter the spine as a bogus
227            // card named after the collection.
228            _ 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        // iCloud echoes the addressbook collection itself in the initial
315        // sync report, as its own path with no trailing slash; it must
316        // not enter the spine as a bogus card named after the collection.
317        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}