Skip to main content

sioul_sync/
dav.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Contacts and calendars on a server: CardDAV (RFC 6352) and CalDAV (RFC
5//! 4791), both WebDAV (RFC 4918), synced both ways with the folders on disk
6//! (`sioul_core::vdir`).
7//!
8//! - **Finding them**: the address you give, else `/.well-known/caldav` and
9//!   `/.well-known/carddav` on the domain (RFC 6764), else the known places of
10//!   some providers; then your principal (RFC 5397) and its home sets, and
11//!   the address books and calendars in them.
12//! - **What you changed here goes first**: a changed file is sent with
13//!   `If-Match` and its ETag, so nobody's change is overwritten silently; a new
14//!   one with `If-None-Match: *`; a deleted one is deleted there.
15//! - **Then what changed there**: with the sync token when the server keeps
16//!   one (RFC 6578), else by comparing ETags; changed items come in batches
17//!   (`calendar-multiget`, `addressbook-multiget`) of 50 at most, half as
18//!   many after a slow or cut answer, twice as many again after quick ones
19//!   (`pace`).
20//! - **A new item whose UID the server holds already** (an invitation
21//!   answered on two devices): the item there takes its place here, said
22//!   when its answer differs (`same_uid_there`, `adopt`).
23//! - **When both changed the same item**, the server's version wins and yours
24//!   is kept among this device's earlier versions (`history::ACCOUNTS`), which
25//!   the window lists and puts back, said in the report.
26//! - **Cut short** (the network lost, the app killed), a sync resumes where it
27//!   stopped: what was sent or brought is known at once, never sent twice.
28//!
29//! Only HTTPS, with the password in the system keyring and sent as Basic
30//! authentication over TLS; for Google, its OAuth access token instead
31//! (google.rs). A small client on `ureq`, written for this: the protocol
32//! needed is a handful of requests.
33
34use crate::SyncError;
35use crate::fetch::{Control, Parked};
36use sioul_core::config::{Account, state_dir};
37use sioul_core::vdir::{self, ItemState, Kind, State};
38use std::collections::{BTreeMap, BTreeSet};
39use std::path::{Path, PathBuf};
40use std::sync::Mutex;
41use std::time::{Duration, Instant};
42
43pub(crate) const DAV: &str = "DAV:";
44const CALDAV: &str = "urn:ietf:params:xml:ns:caldav";
45const CARDDAV: &str = "urn:ietf:params:xml:ns:carddav";
46const CALSERVER: &str = "http://calendarserver.org/ns/";
47const APPLE: &str = "http://apple.com/ns/ical/";
48
49/// Items asked for in one multiget, at most: the batch after quick answers.
50const BATCH: usize = 50;
51
52/// A multiget answered in this long or more is slow: the next batch of that
53/// collection is half as large (one item at least). A batch of 50 events with
54/// their attachments, or of 50 cards with their photos, on a weak signal, can
55/// take longer than a request may (`BUDGET`): asked again whole, it would fail
56/// again at every sync.
57const SLOW: Duration = if cfg!(test) { Duration::from_millis(600) } else { Duration::from_secs(20) };
58
59/// A multiget answered within this is quick: the next batch is twice as
60/// large, up to `BATCH`.
61const QUICK: Duration = if cfg!(test) { Duration::from_millis(300) } else { Duration::from_secs(5) };
62
63/// Each collection's batch now, by its address, in this process: halved after
64/// a slow answer or a cut one, doubled after a quick one (`pace`). Not kept
65/// on disk: a new start begins at `BATCH`, and a slow line slows it again at
66/// the first batch.
67static PACE: Mutex<BTreeMap<String, usize>> = Mutex::new(BTreeMap::new());
68
69/// The batch of the collection at `url` now.
70fn batch_of(url: &str) -> usize {
71    PACE.lock().ok().and_then(|pace| pace.get(&path_key(url)).copied()).unwrap_or(BATCH)
72}
73
74/// A multiget of that collection answered (`Some`: in that long) or cut by
75/// the network (`None`): its next batch follows.
76fn pace(url: &str, took: Option<Duration>) {
77    let Ok(mut pace) = PACE.lock() else { return };
78    let now = pace.get(&path_key(url)).copied().unwrap_or(BATCH);
79    let next = match took {
80        Some(took) if took <= QUICK => (now * 2).min(BATCH),
81        Some(took) if took < SLOW => now,
82        _ => (now / 2).max(1),
83    };
84    pace.insert(path_key(url), next);
85}
86
87/// A connection to one server, with its login.
88pub struct Client {
89    agent: ureq::Agent,
90    authorization: Mutex<String>,
91    /// A Google account's address: its access token ends after an hour, and a
92    /// refused one is asked again, once.
93    google: Option<String>,
94    /// The servers the login goes to, by origin (`origin`), once the
95    /// account's homes are known (`keep_to`): an address a server's answer
96    /// names on any other is refused, never asked with the login.
97    own: Mutex<Option<Vec<String>>>,
98}
99
100/// What a server answered.
101struct Answer {
102    status: u16,
103    etag: Option<String>,
104    location: Option<String>,
105    body: String,
106}
107
108/// How long a request may wait: to connect; for each read or write, so a big
109/// answer trickling in on a weak signal goes on as long as it moves, and one
110/// that stops is given up soon; and for the whole of it, a bound for the worst.
111pub(crate) struct Budget {
112    pub(crate) connect: Duration,
113    pub(crate) stall: Duration,
114    pub(crate) whole: Duration,
115}
116
117const BUDGET: Budget = Budget { connect: Duration::from_secs(20), stall: Duration::from_secs(60), whole: Duration::from_secs(15 * 60) };
118
119pub(crate) fn agent(budget: &Budget) -> ureq::Agent {
120    // Each read and write bounded by `stall`, as long as the answer moves:
121    // on ureq's transport, which follows no semver (`stalls`).
122    crate::stalls::agent(config(budget), budget.stall, false)
123}
124
125/// The sharing's server's one agent in this process (`remote::Server`): its
126/// connections and TLS sessions kept between the requests of a pull, of the
127/// sends after it and of the next pull, so that a phone's step opens one
128/// connection, not one per pull, per send and per file sent (on mobile data,
129/// each new one wakes the radio for a TLS handshake). Each request says its
130/// own budget (its whole and its connection's time, `configure_request`; its
131/// stall, `stalls::ask`); ureq keeps an idle connection fifteen seconds.
132pub(crate) fn shared_agent() -> ureq::Agent {
133    static SHARED: std::sync::OnceLock<ureq::Agent> = std::sync::OnceLock::new();
134    SHARED.get_or_init(|| crate::stalls::shared_agent(config(&BUDGET), BUDGET.stall)).clone()
135}
136
137fn config(budget: &Budget) -> ureq::config::Config {
138    ureq::Agent::config_builder()
139        .timeout_connect(Some(budget.connect))
140        .timeout_global(Some(budget.whole))
141        .http_status_as_error(false)
142        .max_redirects(0)
143        .allow_non_standard_methods(true)
144        .build()
145}
146
147impl Client {
148    pub fn new(login: &str, password: &str) -> Client {
149        Client {
150            agent: agent(&BUDGET),
151            authorization: Mutex::new(format!("Basic {}", sioul_core::lines::base64_encode(format!("{login}:{password}").as_bytes()))),
152            google: None,
153            own: Mutex::new(None),
154        }
155    }
156
157    /// A Google account, signed in with its access token.
158    pub fn google(address: &str) -> Result<Client, SyncError> {
159        let token = crate::google::access_token(address, false)?;
160        Ok(Client { agent: agent(&BUDGET), authorization: Mutex::new(format!("Bearer {token}")), google: Some(address.to_string()), own: Mutex::new(None) })
161    }
162
163    pub fn is_google(&self) -> bool {
164        self.google.is_some()
165    }
166
167    /// From now on, the login goes only to the servers of the account's
168    /// homes: their scheme, host and port. Every address of its address
169    /// books, calendars and items comes from a server's answer, which may
170    /// name any server; one on another is refused, and said
171    /// (`SyncError::Elsewhere`).
172    pub(crate) fn keep_to(&self, homes: &Homes) {
173        let own = [&homes.calendars, &homes.contacts].into_iter().flatten().filter_map(|home| origin(home)).collect();
174        *self.own.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(own);
175    }
176
177    /// From now on, the login goes to `server`'s origin only, even while the
178    /// homes are found (`discover_on`): a sign-in's app password is the
179    /// server's own, for it alone.
180    pub(crate) fn keep_to_origin(&self, server: &str) {
181        *self.own.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(origin(server).into_iter().collect());
182    }
183
184    /// Whether a request may go to `url` with the login: before the homes
185    /// are known, finding them (`discover`) keeps to the account's domain.
186    fn mine(&self, url: &str) -> Result<(), SyncError> {
187        match &*self.own.lock().unwrap_or_else(std::sync::PoisonError::into_inner) {
188            Some(own) if !origin(url).is_some_and(|o| own.contains(&o)) => Err(SyncError::Elsewhere(url.to_string())),
189            _ => Ok(()),
190        }
191    }
192
193    fn send(&self, method: &str, url: &str, headers: &[(&str, &str)], body: Option<&str>) -> Result<Answer, SyncError> {
194        match (self.send_once(method, url, headers, body), &self.google) {
195            // The access token ended early: a new one, once.
196            (Err(SyncError::Login(_)), Some(address)) => {
197                let token = crate::google::access_token(address, true)?;
198                if let Ok(mut authorization) = self.authorization.lock() {
199                    *authorization = format!("Bearer {token}");
200                }
201                self.send_once(method, url, headers, body)
202            }
203            (result, _) => result,
204        }
205    }
206
207    fn send_once(&self, method: &str, url: &str, headers: &[(&str, &str)], body: Option<&str>) -> Result<Answer, SyncError> {
208        allowed(url)?;
209        self.mine(url)?;
210        let authorization = self.authorization.lock().map(|a| a.clone()).unwrap_or_default();
211        let mut request = ureq::http::Request::builder().method(method).uri(url).header("Authorization", &authorization).header("User-Agent", "Sioul");
212        for (name, value) in headers {
213            request = request.header(*name, *value);
214        }
215        let request = request.body(body.unwrap_or("").to_string()).map_err(|e| SyncError::Server(e.to_string()))?;
216        let mut response = self.agent.run(request).map_err(|e| SyncError::Network(e.to_string()))?;
217        let header = |name: &str| response.headers().get(name).and_then(|v| v.to_str().ok()).map(str::to_string);
218        let (etag, location) = (header("ETag"), header("Location"));
219        let status = response.status().as_u16();
220        let body = response.body_mut().with_config().limit(64 * 1024 * 1024).read_to_string().unwrap_or_default();
221        if status == 401 {
222            return Err(SyncError::Login(format!("{method} {url}: 401")));
223        }
224        Ok(Answer { status, etag, location, body })
225    }
226
227    fn propfind(&self, url: &str, depth: &str, props: &str) -> Result<(u16, Vec<Response>), SyncError> {
228        let body = format!(
229            r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:" xmlns:c="{CALDAV}" xmlns:a="{CARDDAV}" xmlns:cs="{CALSERVER}" xmlns:ic="{APPLE}"><d:prop>{props}</d:prop></d:propfind>"#
230        );
231        let answer = self.send("PROPFIND", url, &[("Depth", depth), ("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
232        if answer.status != 207 {
233            return Ok((answer.status, Vec::new()));
234        }
235        Ok((answer.status, multistatus(&answer.body)?.0))
236    }
237}
238
239/// Plain HTTP only to this computer, and only in a test build.
240pub(crate) fn allowed(url: &str) -> Result<(), SyncError> {
241    if url.starts_with("https://") {
242        return Ok(());
243    }
244    #[cfg(feature = "insecure-test-tls")]
245    if std::env::var_os("SIOUL_TEST_INSECURE_TLS").is_some() && (url.starts_with("http://localhost") || url.starts_with("http://127.0.0.1")) {
246        return Ok(());
247    }
248    // This crate's own tests, against their stand-in (`stand_in`).
249    #[cfg(test)]
250    if url.starts_with("http://127.0.0.1:") {
251        return Ok(());
252    }
253    Err(SyncError::Tls(format!("{url}: not encrypted")))
254}
255
256/// One `response` of a multistatus: an address and what was found there.
257#[derive(Debug, Default, Clone)]
258pub(crate) struct Response {
259    pub(crate) href: String,
260    /// The status of the whole response (sync-collection says 404 for what went).
261    pub(crate) status: Option<u16>,
262    pub(crate) props: Vec<Prop>,
263}
264
265/// One property, found (200) or not.
266#[derive(Debug, Default, Clone)]
267pub(crate) struct Prop {
268    ns: String,
269    name: String,
270    status: u16,
271    text: String,
272    pub(crate) hrefs: Vec<String>,
273    /// Child elements, by namespace and name (resourcetype, privileges).
274    children: Vec<(String, String)>,
275    /// `name` attributes of `comp` children (supported-calendar-component-set).
276    comps: Vec<String>,
277}
278
279impl Response {
280    pub(crate) fn prop(&self, ns: &str, name: &str) -> Option<&Prop> {
281        self.props.iter().find(|p| p.ns == ns && p.name == name && p.status / 100 == 2)
282    }
283
284    pub(crate) fn text(&self, ns: &str, name: &str) -> Option<String> {
285        self.prop(ns, name).map(|p| p.text.trim().to_string()).filter(|t| !t.is_empty())
286    }
287
288    pub(crate) fn is(&self, ns: &str, kind: &str) -> bool {
289        self.prop(DAV, "resourcetype").is_some_and(|p| p.children.iter().any(|(n, k)| n == ns && k == kind))
290    }
291}
292
293fn status_code(text: &str) -> Option<u16> {
294    text.split_whitespace().nth(1).and_then(|c| c.parse().ok())
295}
296
297/// The responses of a 207 Multi-Status, and the sync token when one came.
298pub(crate) fn multistatus(xml: &str) -> Result<(Vec<Response>, Option<String>), SyncError> {
299    let doc = roxmltree::Document::parse(xml).map_err(|e| SyncError::Server(format!("XML: {e}")))?;
300    let mut responses = Vec::new();
301    let mut token = None;
302    for node in doc.root_element().children().filter(roxmltree::Node::is_element) {
303        if node.has_tag_name((DAV, "sync-token")) {
304            token = node.text().map(|t| t.trim().to_string());
305            continue;
306        }
307        if !node.has_tag_name((DAV, "response")) {
308            continue;
309        }
310        let mut response = Response::default();
311        for part in node.children().filter(roxmltree::Node::is_element) {
312            if part.has_tag_name((DAV, "href")) {
313                response.href = part.text().unwrap_or("").trim().to_string();
314            } else if part.has_tag_name((DAV, "status")) {
315                response.status = status_code(part.text().unwrap_or(""));
316            } else if part.has_tag_name((DAV, "propstat")) {
317                let status = part.children().find(|c| c.has_tag_name((DAV, "status"))).and_then(|s| status_code(s.text().unwrap_or(""))).unwrap_or(200);
318                for prop in part.children().filter(|c| c.has_tag_name((DAV, "prop"))).flat_map(|p| p.children().filter(roxmltree::Node::is_element)) {
319                    let tag = prop.tag_name();
320                    let elements: Vec<roxmltree::Node> = prop.descendants().filter(|d| d.is_element() && *d != prop).collect();
321                    response.props.push(Prop {
322                        ns: tag.namespace().unwrap_or("").to_string(),
323                        name: tag.name().to_string(),
324                        status,
325                        text: prop.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect::<String>(),
326                        hrefs: elements.iter().filter(|d| d.has_tag_name((DAV, "href"))).filter_map(|d| d.text()).map(|t| t.trim().to_string()).collect(),
327                        children: elements.iter().map(|d| (d.tag_name().namespace().unwrap_or("").to_string(), d.tag_name().name().to_string())).collect(),
328                        comps: elements.iter().filter(|d| d.tag_name().name() == "comp").filter_map(|d| d.attribute("name")).map(str::to_string).collect(),
329                    });
330                }
331            }
332        }
333        responses.push(response);
334    }
335    Ok((responses, token))
336}
337
338/// An address's origin, what a login is kept to: its scheme, host and port,
339/// in lowercase, the port written even when it is the scheme's own
340/// (`https://dav.example.org:443`). None without a scheme or a host.
341pub fn origin(url: &str) -> Option<String> {
342    let (scheme, rest) = url.split_once("://")?;
343    let scheme = scheme.to_ascii_lowercase();
344    let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
345    // What stands before an "@" is a login, not the host.
346    let at = authority.rsplit_once('@').map_or(authority, |(_, host)| host);
347    let (host, port) = match at.rsplit_once(':') {
348        // "[2001:db8::1]" holds colons of its own.
349        Some((host, port)) if !port.contains(']') => (host, port.parse::<u16>().ok()?),
350        _ => (at, if scheme == "https" { 443 } else if scheme == "http" { 80 } else { return None }),
351    };
352    (!host.is_empty()).then(|| format!("{scheme}://{}:{port}", host.to_ascii_lowercase()))
353}
354
355/// `href` made absolute against `base` ("https://host/a/b/" + "/c/" → "https://host/c/").
356/// An absolute `href` is kept as it is, whatever its server: the login goes
357/// only to the account's own (`Client::keep_to`).
358pub fn absolute(base: &str, href: &str) -> String {
359    if href.starts_with("http://") || href.starts_with("https://") {
360        return href.to_string();
361    }
362    let origin_end = base.find("://").map_or(0, |i| i + 3);
363    let origin = match base[origin_end..].find('/') {
364        Some(slash) => &base[..origin_end + slash],
365        None => base,
366    };
367    if href.starts_with('/') {
368        return format!("{origin}{href}");
369    }
370    let directory = base.rsplit_once('/').map_or(base, |(d, _)| d);
371    format!("{directory}/{href}")
372}
373
374/// The path of an address, percent-decoded: what to compare hrefs by, however a server writes them.
375pub(crate) fn path_key(url: &str) -> String {
376    let path = url.find("://").and_then(|i| url[i + 3..].find('/').map(|j| &url[i + 3 + j..])).unwrap_or(url);
377    let mut out = Vec::with_capacity(path.len());
378    let bytes = path.as_bytes();
379    let mut i = 0;
380    while i < bytes.len() {
381        if bytes[i] == b'%'
382            && let Some(byte) = path.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok())
383        {
384            out.push(byte);
385            i += 3;
386            continue;
387        }
388        out.push(bytes[i]);
389        i += 1;
390    }
391    let decoded = String::from_utf8_lossy(&out).to_string();
392    decoded.trim_end_matches('/').to_string()
393}
394
395/// Where to look for the server of `domain`: the address given, the
396/// well-known places, then the providers that put it elsewhere.
397fn candidates(kind: Kind, given: Option<&str>, address: &str, mail_host: Option<&str>) -> Vec<String> {
398    let mut out: Vec<String> = given.map(|g| vec![g.trim().to_string()]).unwrap_or_default();
399    let domain = address.rsplit_once('@').map(|(_, d)| d.to_ascii_lowercase()).unwrap_or_default();
400    let service = match kind {
401        Kind::Calendars => "caldav",
402        Kind::Contacts => "carddav",
403    };
404    if !domain.is_empty() {
405        out.push(format!("https://{domain}/.well-known/{service}"));
406        out.push(format!("https://{service}.{domain}/.well-known/{service}"));
407    }
408    let known: &[(&str, &str, &str)] = &[
409        ("murena.io", "https://murena.io/remote.php/dav/", "https://murena.io/remote.php/dav/"),
410        ("e.email", "https://murena.io/remote.php/dav/", "https://murena.io/remote.php/dav/"),
411        ("fastmail.com", "https://caldav.fastmail.com/dav/", "https://carddav.fastmail.com/dav/"),
412        ("icloud.com", "https://caldav.icloud.com/", "https://contacts.icloud.com/"),
413        ("me.com", "https://caldav.icloud.com/", "https://contacts.icloud.com/"),
414        ("posteo.de", "https://posteo.de:8443/", "https://posteo.de:8843/"),
415        ("mailbox.org", "https://dav.mailbox.org/", "https://dav.mailbox.org/"),
416    ];
417    if let Some((_, calendars, contacts)) = known.iter().find(|(d, _, _)| *d == domain) {
418        out.push(match kind {
419            Kind::Calendars => calendars.to_string(),
420            Kind::Contacts => contacts.to_string(),
421        });
422    }
423    // cPanel hosts serve both on port 2080 of the mail server.
424    if let Some(host) = mail_host {
425        out.push(format!("https://{host}:2080/.well-known/{service}"));
426        out.push(format!("https://{host}/.well-known/{service}"));
427    }
428    out.dedup();
429    out
430}
431
432/// Where the account's contacts or calendars live, as found.
433#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
434pub struct Homes {
435    #[serde(default)]
436    pub calendars: Option<String>,
437    #[serde(default)]
438    pub contacts: Option<String>,
439}
440
441fn homes_path(account: &str) -> PathBuf {
442    state_dir().join("dav").join(account).join("homes.toml")
443}
444
445impl Homes {
446    pub fn load(account: &str) -> Homes {
447        std::fs::read_to_string(homes_path(account)).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
448    }
449
450    pub fn save(&self, account: &str) -> Result<(), SyncError> {
451        let path = homes_path(account);
452        let fail = |e: std::io::Error| SyncError::Disk(format!("{}: {e}", path.display()));
453        if let Some(parent) = path.parent() {
454            std::fs::create_dir_all(parent).map_err(fail)?;
455        }
456        std::fs::write(&path, toml::to_string(self).map_err(|e| SyncError::Disk(e.to_string()))?).map_err(fail)
457    }
458
459    pub fn is_empty(&self) -> bool {
460        self.calendars.is_none() && self.contacts.is_none()
461    }
462}
463
464/// Finds an account's calendar home and address book home: where the
465/// calendars and address books are. `given` is a server address you typed, if any.
466pub fn discover(client: &Client, address: &str, given: Option<&str>, mail_host: Option<&str>) -> Result<Homes, SyncError> {
467    if client.is_google() {
468        // Google's calendars from the address's principal, its contacts from its well-known address.
469        let homes = Homes { calendars: home(client, Kind::Calendars, &crate::google::caldav_start(address))?, contacts: home(client, Kind::Contacts, crate::google::CARDDAV_START)? };
470        return if homes.is_empty() { Err(SyncError::NotFound("google.com".into())) } else { Ok(homes) };
471    }
472    let mut homes = Homes::default();
473    let mut last_error = None;
474    for kind in [Kind::Calendars, Kind::Contacts] {
475        for start in candidates(kind, given, address, mail_host) {
476            match home(client, kind, &start) {
477                Ok(Some(found)) => {
478                    match kind {
479                        Kind::Calendars => homes.calendars = Some(found),
480                        Kind::Contacts => homes.contacts = Some(found),
481                    }
482                    break;
483                }
484                Ok(None) => {}
485                // A refused password is said at once: retrying elsewhere could lock the account.
486                Err(e @ SyncError::Login(_)) => return Err(e),
487                Err(e) => last_error = Some(e),
488            }
489        }
490    }
491    if homes.is_empty() {
492        let domain = address.rsplit_once('@').map_or(address, |(_, d)| d).to_string();
493        return Err(last_error.unwrap_or(SyncError::NotFound(domain)));
494    }
495    Ok(homes)
496}
497
498/// Finds the homes on one Nextcloud only, `server` as its sign-in named it
499/// (`https://murena.io`, `https://cloud.example.org/nextcloud`): its own
500/// `remote.php/dav/`, then its well-known addresses, every request (and every
501/// redirect) on that server's origin alone, whatever its answers name
502/// (`Client::keep_to_origin`): an app password from its sign-in
503/// (`loginflow`) never goes anywhere else, the domain of your login neither.
504pub(crate) fn discover_on(client: &Client, server: &str) -> Result<Homes, SyncError> {
505    let server = server.trim_end_matches('/');
506    let Some(origin) = origin(server) else { return Err(SyncError::NotFound(server.to_string())) };
507    client.keep_to_origin(server);
508    let root = server.split_once("://").and_then(|(scheme, rest)| rest.split('/').next().map(|host| format!("{scheme}://{host}"))).unwrap_or_else(|| server.to_string());
509    let mut homes = Homes::default();
510    let mut last_error = None;
511    for kind in [Kind::Calendars, Kind::Contacts] {
512        let service = match kind {
513            Kind::Calendars => "caldav",
514            Kind::Contacts => "carddav",
515        };
516        for start in [format!("{server}/remote.php/dav/"), format!("{root}/.well-known/{service}")] {
517            match home(client, kind, &start) {
518                Ok(Some(found)) if self::origin(&found).as_deref() == Some(origin.as_str()) => {
519                    match kind {
520                        Kind::Calendars => homes.calendars = Some(found),
521                        Kind::Contacts => homes.contacts = Some(found),
522                    }
523                    break;
524                }
525                Ok(_) => {}
526                Err(e @ SyncError::Login(_)) => return Err(e),
527                Err(e) => last_error = Some(e),
528            }
529        }
530    }
531    if homes.is_empty() {
532        return Err(last_error.unwrap_or_else(|| SyncError::NotFound(crate::remote::host_of(server))));
533    }
534    Ok(homes)
535}
536
537/// The domain an address belongs to ("caldav.example.org" → "example.org").
538fn domain(url: &str) -> String {
539    sioul_core::sites::domain_of(&sioul_core::sites::host_of(url))
540}
541
542/// From a starting address: follows redirects, finds the principal, then its home for `kind`.
543fn home(client: &Client, kind: Kind, start: &str) -> Result<Option<String>, SyncError> {
544    let mut url = start.to_string();
545    // Redirects are followed by hand: the well-known addresses answer with one (RFC 6764 §5).
546    // Each request carries the password, and the addresses tried are guesses
547    // from your domain: a redirect to another domain (a parked domain's
548    // advertiser, a web host's) is not followed, so it never goes there; the
549    // server's own address can be given instead. Google's are its own, fixed.
550    for _ in 0..5 {
551        let answer = client.send("PROPFIND", &url, &[("Depth", "0"), ("Content-Type", "application/xml; charset=utf-8")], Some(PRINCIPAL))?;
552        match answer.status {
553            301 | 302 | 303 | 307 | 308 => match answer.location.map(|location| absolute(&url, &location)) {
554                Some(next) if client.is_google() || domain(&next) == domain(start) => url = next,
555                _ => return Ok(None),
556            },
557            207 => {
558                let (responses, _) = multistatus(&answer.body)?;
559                // A principal that does not name itself is the one asked.
560                let principal = responses
561                    .iter()
562                    .find_map(|r| r.prop(DAV, "current-user-principal").and_then(|p| p.hrefs.first().cloned()))
563                    .map_or_else(|| url.clone(), |href| absolute(&url, &href));
564                // The principal and the home are asked with the login too: on
565                // the domain started from only, as redirects (iCloud's homes
566                // are on another of its hosts).
567                let ours = |next: &str| if client.is_google() || domain(next) == domain(start) { Ok(()) } else { Err(SyncError::Elsewhere(next.to_string())) };
568                ours(&principal)?;
569                let (ns, name) = match kind {
570                    Kind::Calendars => (CALDAV, "calendar-home-set"),
571                    Kind::Contacts => (CARDDAV, "addressbook-home-set"),
572                };
573                let prop = match kind {
574                    Kind::Calendars => "<c:calendar-home-set/>",
575                    Kind::Contacts => "<a:addressbook-home-set/>",
576                };
577                let (_, responses) = client.propfind(&principal, "0", prop)?;
578                let home = responses.iter().find_map(|r| r.prop(ns, name).and_then(|p| p.hrefs.first().cloned())).map(|href| absolute(&principal, &href));
579                if let Some(home) = &home {
580                    ours(home)?;
581                }
582                return Ok(home);
583            }
584            _ => return Ok(None),
585        }
586    }
587    Ok(None)
588}
589
590const PRINCIPAL: &str = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>"#;
591
592/// An address book or calendar the server lists.
593#[derive(Debug, Clone, PartialEq, Eq)]
594pub struct Listed {
595    pub kind: Kind,
596    pub url: String,
597    pub id: String,
598    pub name: String,
599    pub color: Option<String>,
600    pub read_only: bool,
601    pub ctag: Option<String>,
602    pub sync_token: Option<String>,
603    /// For calendars: what it holds ("VEVENT", "VTODO"); empty when the server does not say.
604    pub components: Vec<String>,
605}
606
607/// The collections in a home.
608pub fn collections(client: &Client, kind: Kind, home: &str) -> Result<Vec<Listed>, SyncError> {
609    let props = "<d:resourcetype/><d:displayname/><cs:getctag/><d:sync-token/><d:current-user-privilege-set/><ic:calendar-color/><c:supported-calendar-component-set/>";
610    let (status, responses) = client.propfind(home, "1", props)?;
611    if status != 207 {
612        return Err(SyncError::Server(format!("PROPFIND {home}: {status}")));
613    }
614    let (ns, wanted) = match kind {
615        Kind::Calendars => (CALDAV, "calendar"),
616        Kind::Contacts => (CARDDAV, "addressbook"),
617    };
618    let mut found: Vec<Listed> = Vec::new();
619    for response in responses.iter().filter(|r| r.is(ns, wanted)) {
620        let url = absolute(home, &response.href);
621        let privileges = response.prop(DAV, "current-user-privilege-set");
622        let writable = privileges.is_none_or(|p| p.children.iter().any(|(n, k)| n == DAV && matches!(k.as_str(), "write" | "write-content" | "all" | "bind")));
623        let mut id = vdir::folder_id(&response.href);
624        // Two collections may end alike on different paths.
625        while found.iter().any(|l| l.id == id) {
626            id.push('_');
627        }
628        found.push(Listed {
629            kind,
630            name: response.text(DAV, "displayname").unwrap_or_else(|| id.clone()),
631            color: response.text(APPLE, "calendar-color").map(|c| c.chars().take(7).collect()),
632            read_only: !writable,
633            ctag: response.text(CALSERVER, "getctag"),
634            sync_token: response.text(DAV, "sync-token"),
635            components: response.prop(CALDAV, "supported-calendar-component-set").map(|p| p.comps.clone()).unwrap_or_default(),
636            url,
637            id,
638        });
639    }
640    Ok(found)
641}
642
643/// What a sync of one account did.
644#[derive(Debug, Clone, Default, PartialEq, Eq)]
645pub struct Report {
646    /// Items sent to the server: changed, new or deleted here.
647    pub sent: usize,
648    /// Items brought from the server.
649    pub received: usize,
650    pub removed: usize,
651    /// Changed on both sides: the server's kept, yours set aside.
652    pub conflicts: Vec<PathBuf>,
653    /// Collections synced whole this time (calendars and address books).
654    pub collections: usize,
655    /// Collections the server listed, each made here at once, empty until pulled.
656    pub listed: usize,
657    /// Of them, the calendars: listed, and synced whole (the phone's log).
658    pub calendars_listed: usize,
659    pub calendars_synced: usize,
660    /// Of the calendars listed, those read only, and those that keep no event
661    /// (tasks only): neither is offered for a new event (the phone's log).
662    pub calendars_read_only: usize,
663    pub calendars_without_events: usize,
664    /// Collections made here this time, or whose name or colour changed: the
665    /// pages read them again, even with no item come yet.
666    pub registered: usize,
667    /// What went wrong with some of them (a collection refused, cut, timed
668    /// out), the first first: the others synced all the same. An error of the
669    /// whole account (its homes refused) is the sync's own `Err`.
670    pub problems: Vec<SyncError>,
671    /// Invitations answered on another device first, and otherwise: each
672    /// one's title, and the answer the server holds and this device now keeps
673    /// ("ACCEPTED", "TENTATIVE", "DECLINED"), yours replaced by it (`adopt`).
674    pub answered: Vec<(String, String)>,
675}
676
677/// An account's client: Google's access token, or its login and the password in the keyring.
678pub fn client_for(account: &Account) -> Result<Client, SyncError> {
679    let login = account.login().ok_or(SyncError::NoServer)?;
680    if account.auth.as_deref() == Some("google") {
681        return Client::google(account.address.as_deref().unwrap_or(login));
682    }
683    Ok(Client::new(login, &crate::secret::password(account)?))
684}
685
686/// Syncs every calendar and address book of a contacts-and-calendars account
687/// (the calendars whole first: they never wait for contacts). Every collection the
688/// server lists is made here at once, its folder, name, colour and state,
689/// before any is pulled, so that each is listed, can be written to and is
690/// reached even before its first pull ends (the phone's calendars of 21
691/// October 2026). Then they are pulled in turns, a slice of time each
692/// (`SLICE`), until all are whole: no big first calendar keeps the others
693/// waiting. An error in one collection, a lasting one too (a calendar shared
694/// with you refusing your login), ends that one alone, said in the report;
695/// only the homes refused end the account (`Err`).
696pub fn sync(account: &Account) -> Result<Report, SyncError> {
697    sync_with(&client_for(account)?, account)
698}
699
700/// The time one collection is pulled in a turn before the others have theirs.
701const SLICE: Duration = if cfg!(test) { Duration::from_millis(150) } else { Duration::from_secs(20) };
702
703fn sync_with(client: &Client, account: &Account) -> Result<Report, SyncError> {
704    let login = account.login().ok_or(SyncError::NoServer)?;
705    // One sync of an account at a time, across processes too (the window's
706    // watcher, `sioul task add`): both would send the same new item, and the
707    // last to keep its state would forget what the other did.
708    let _lock = crate::fetch::lock(&state_dir().join("dav").join(format!("{}.lock", account.id)))?;
709    let mut homes = Homes::load(&account.id);
710    if homes.is_empty() {
711        homes = discover(client, account.address.as_deref().unwrap_or(login), account.url.as_deref(), None)?;
712        homes.save(&account.id)?;
713    }
714    client.keep_to(&homes);
715    // An item's move to another calendar cut short (a crash): finished before anything is sent.
716    vdir::finish_moves();
717    let mut report = Report::default();
718    // A home refused for good (its login, its certificate): the account's error, said at the end.
719    let mut account_error: Option<SyncError> = None;
720    // Calendars whole first, then contacts: an address book, however big or broken, never holds a calendar back.
721    for (kind, home) in [(Kind::Calendars, &homes.calendars), (Kind::Contacts, &homes.contacts)] {
722        let Some(home) = home else { continue };
723        // Google makes, renames and deletes no calendar or address book from here.
724        if !client.is_google() {
725            if let Err(e) = send_changes(client, &account.id, kind) {
726                report.problems.push(e);
727            }
728            if let Err(e) = create_pending(client, &account.id, kind, home) {
729                report.problems.push(e);
730            }
731        }
732        let mut listed = match collections(client, kind, home) {
733            Ok(listed) => listed,
734            Err(e) if e.is_lasting() => {
735                account_error.get_or_insert(e);
736                continue;
737            }
738            Err(e) => {
739                report.problems.push(e);
740                continue;
741            }
742        };
743        // Google's calendars hold events only, whether or not they say it.
744        if client.is_google() {
745            listed.iter_mut().filter(|l| l.kind == Kind::Calendars && l.components.is_empty()).for_each(|l| l.components = vec!["VEVENT".into()]);
746        }
747        forget_gone(&account.id, kind, &listed);
748        match register(&account.id, &listed) {
749            Ok(made) => report.registered += made,
750            Err(e) => report.problems.push(e),
751        }
752        report.listed += listed.len();
753        let synced_before = report.collections;
754        // In turns, a slice of time each, until each is whole or failed.
755        let mut pending: Vec<&Listed> = listed.iter().collect();
756        let mut first = true;
757        while !pending.is_empty() {
758            let mut again = Vec::new();
759            for collection in pending {
760                match sync_part(client, &account.id, collection, &mut report, first, Some(Instant::now() + SLICE)) {
761                    Ok(true) => report.collections += 1,
762                    Ok(false) => again.push(collection),
763                    Err(e) => report.problems.push(e),
764                }
765            }
766            pending = again;
767            first = false;
768        }
769        if kind == Kind::Calendars {
770            report.calendars_listed = listed.len();
771            report.calendars_synced = report.collections - synced_before;
772            report.calendars_read_only = listed.iter().filter(|l| l.read_only).count();
773            report.calendars_without_events = listed.iter().filter(|l| !l.components.is_empty() && !l.components.iter().any(|c| c.eq_ignore_ascii_case("VEVENT"))).count();
774        }
775    }
776    // Google's task lists, over Google Tasks.
777    if client.is_google() {
778        match crate::google_tasks::sync(account) {
779            Ok(tasks) => {
780                report.sent += tasks.sent;
781                report.received += tasks.received;
782                report.removed += tasks.removed;
783                report.collections += tasks.collections;
784                report.listed += tasks.collections;
785            }
786            Err(e) if e.is_lasting() && report.listed == 0 => {
787                account_error.get_or_insert(e);
788            }
789            Err(e) => report.problems.push(e),
790        }
791    }
792    account_error.map_or(Ok(report), Err)
793}
794
795/// Every listed collection made here, at once and in one pass: its folder,
796/// its name and colour (`vdir::prepare`), its state (address, read-only,
797/// components), empty until pulled. The first one that cannot be written is
798/// returned, the others made all the same; else how many are new here, or
799/// renamed or recoloured (the pages read them again).
800fn register(account: &str, listed: &[Listed]) -> Result<usize, SyncError> {
801    let mut failed = None;
802    let mut changed = 0;
803    for collection in listed {
804        let dir = collection.kind.root().join(account).join(&collection.id);
805        let read = |name: &str| std::fs::read_to_string(dir.join(name)).ok().map(|t| t.trim().to_string());
806        if read("displayname").as_deref() != Some(collection.name.trim()) || read("color") != collection.color.as_ref().map(|c| c.trim().to_string()) {
807            changed += 1;
808        }
809        let made = vdir::prepare(collection.kind, account, &collection.id, &collection.name, collection.color.as_deref()).map_err(|e| SyncError::Disk(e.to_string())).and_then(|_| {
810            let path = vdir::state_path(account, collection.kind, &collection.id);
811            let mut state = State::load(&path);
812            state.url = collection.url.clone();
813            state.read_only = collection.read_only;
814            state.components = collection.components.clone();
815            state.save(&path).map_err(SyncError::Disk)
816        });
817        if let Err(e) = made {
818            failed.get_or_insert(e);
819        }
820    }
821    failed.map_or(Ok(changed), Err)
822}
823
824/// Syncs an account now, then every `every` until stopped (see
825/// [`Control::pause`]: counted on the wall clock; while quiet, only when
826/// nudged), each sync reported. After a lasting error (a refused password…)
827/// it waits for a nudge instead of ending, then tries again (see
828/// `fetch::Parked`: a refused password, unchanged, not before an hour unless
829/// [`Control::retry`]).
830pub fn watch(account: &Account, control: &Control, every: Duration, report: impl FnMut(Result<Report, SyncError>)) {
831    watch_with(control, every, || sync(account), || crate::secret::password(account).ok(), report);
832}
833
834/// `watch`, syncing with `sync`; `password` reads the one kept now.
835fn watch_with(control: &Control, every: Duration, mut sync: impl FnMut() -> Result<Report, SyncError>, password: impl Fn() -> Option<String>, mut report: impl FnMut(Result<Report, SyncError>)) {
836    while !control.stopped() {
837        let result = sync();
838        let parked = result.as_ref().err().filter(|e| e.is_lasting()).map(|e| Parked::after(e, if e.wants_password() { password() } else { None }));
839        report(result);
840        let Some(parked) = parked else {
841            control.pause(every);
842            continue;
843        };
844        // A "Sync now" asked before this error is not an answer to it.
845        control.retried();
846        loop {
847            if !control.park() {
848                return;
849            }
850            if !parked.refused() || parked.again(password().as_deref()) || control.retried() {
851                break;
852            }
853        }
854    }
855}
856
857/// Goes on after an error of one collection: the first is kept, to say at
858/// the end. A lasting one (a refused password, a certificate) stops there:
859/// the others would meet it too, and retries can lock an account.
860fn go_on(first: &mut Option<SyncError>, result: Result<(), SyncError>) -> Result<(), SyncError> {
861    match result {
862        Err(e) if !e.is_lasting() => {
863            first.get_or_insert(e);
864            Ok(())
865        }
866        other => other,
867    }
868}
869
870/// Collections deleted on the server go here too: they were copies. One made
871/// here and not created there yet stays, and so does one holding items never
872/// sent, new or changed since (as Google Tasks' lists do).
873fn forget_gone(account: &str, kind: Kind, listed: &[Listed]) {
874    let kept: BTreeSet<&str> = listed.iter().map(|l| l.id.as_str()).collect();
875    for collection in vdir::collections(kind).into_iter().filter(|c| c.account == account && !kept.contains(c.id.as_str())) {
876        let state = State::load(&collection.state_path());
877        let unsent = has_local_changes(&collection.dir, kind, &state);
878        // Google's task lists are not on its CalDAV: Google Tasks says when they go.
879        if state.pending || unsent || crate::google_tasks::is_tasks_list(&state.url) {
880            continue;
881        }
882        let _ = std::fs::remove_dir_all(&collection.dir);
883        let _ = std::fs::remove_file(collection.state_path());
884    }
885}
886
887/// Tells the server the collections renamed here (PROPPATCH, RFC 4918 §9.2),
888/// and deletes those deleted here (RFC 4918 §9.6), only when the server
889/// holds nothing in them either: one filled elsewhere meanwhile comes back.
890/// One refused, the others are still told; the first refusal is returned.
891fn send_changes(client: &Client, account: &str, kind: Kind) -> Result<(), SyncError> {
892    let mut refused = None;
893    for collection in vdir::every_collection(kind).into_iter().filter(|c| c.account == account) {
894        let path = collection.state_path();
895        let mut state = State::load(&path);
896        if state.url.is_empty() {
897            continue;
898        }
899        if state.renamed {
900            let body = format!(r#"<?xml version="1.0" encoding="utf-8"?><d:propertyupdate xmlns:d="DAV:"><d:set><d:prop><d:displayname>{}</d:displayname></d:prop></d:set></d:propertyupdate>"#, xml_escape(&collection.name));
901            let answer = client.send("PROPPATCH", &state.url, &[("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
902            if !(200..300).contains(&answer.status) {
903                refused.get_or_insert(SyncError::Server(format!("PROPPATCH {}: {}", state.url, answer.status)));
904                continue;
905            }
906            state.renamed = false;
907            go_on(&mut refused, state.save(&path).map_err(SyncError::Disk))?;
908        }
909        if state.deleted {
910            let (_, found) = client.propfind(&state.url, "1", "<d:getetag/>")?;
911            let held = found.iter().filter(|r| path_key(&r.href) != path_key(&state.url)).count();
912            if held > 0 {
913                state.deleted = false;
914            } else {
915                let answer = client.send("DELETE", &state.url, &[], None)?;
916                if !(200..300).contains(&answer.status) && answer.status != 404 {
917                    refused.get_or_insert(SyncError::Server(format!("DELETE {}: {}", state.url, answer.status)));
918                    continue;
919                }
920                let _ = std::fs::remove_dir_all(&collection.dir);
921                let _ = std::fs::remove_file(&path);
922                continue;
923            }
924            go_on(&mut refused, state.save(&path).map_err(SyncError::Disk))?;
925        }
926    }
927    refused.map_or(Ok(()), Err)
928}
929
930/// Creates on the server the collections made here: a calendar or task list
931/// with MKCALENDAR (RFC 4791 §5.3.1), an address book with an extended MKCOL
932/// (RFC 5689), at `<home><id>/`, with its name, colour and, for a calendar,
933/// what it holds. Already there (405) counts as made. One refused, the others
934/// are still made; the first refusal is returned.
935fn create_pending(client: &Client, account: &str, kind: Kind, home: &str) -> Result<(), SyncError> {
936    let mut refused = None;
937    for collection in vdir::collections(kind).into_iter().filter(|c| c.account == account) {
938        let path = collection.state_path();
939        let mut state = State::load(&path);
940        if !state.pending {
941            continue;
942        }
943        let url = format!("{}/{}/", home.trim_end_matches('/'), url_segment(&collection.id));
944        let name = format!("<d:displayname>{}</d:displayname>", xml_escape(&collection.name));
945        let color = collection.color.as_deref().map(|c| format!("<ic:calendar-color>{}</ic:calendar-color>", xml_escape(c))).unwrap_or_default();
946        let (method, body) = match kind {
947            Kind::Calendars => {
948                let comps: String = state.components.iter().map(|c| format!(r#"<c:comp name="{}"/>"#, xml_escape(c))).collect();
949                let set = if comps.is_empty() { String::new() } else { format!("<c:supported-calendar-component-set>{comps}</c:supported-calendar-component-set>") };
950                (
951                    "MKCALENDAR",
952                    format!(r#"<?xml version="1.0" encoding="utf-8"?><c:mkcalendar xmlns:d="DAV:" xmlns:c="{CALDAV}" xmlns:ic="{APPLE}"><d:set><d:prop>{name}{color}{set}</d:prop></d:set></c:mkcalendar>"#),
953                )
954            }
955            Kind::Contacts => (
956                "MKCOL",
957                format!(r#"<?xml version="1.0" encoding="utf-8"?><d:mkcol xmlns:d="DAV:" xmlns:a="{CARDDAV}"><d:set><d:prop><d:resourcetype><d:collection/><a:addressbook/></d:resourcetype>{name}</d:prop></d:set></d:mkcol>"#),
958            ),
959        };
960        let answer = client.send(method, &url, &[("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
961        match answer.status {
962            200..=299 | 405 => {
963                state.pending = false;
964                state.url = url;
965                go_on(&mut refused, state.save(&path).map_err(SyncError::Disk))?;
966            }
967            status => {
968                refused.get_or_insert(SyncError::Server(format!("{method} {url}: {status}")));
969            }
970        }
971    }
972    refused.map_or(Ok(()), Err)
973}
974
975fn hash_of(path: &Path) -> Option<String> {
976    read_item(path).ok().map(|bytes| vdir::content_hash(&bytes))
977}
978
979/// An item's file, read whole.
980fn read_item(path: &Path) -> std::io::Result<Vec<u8>> {
981    #[cfg(test)]
982    tests::READS.with(|reads| reads.set(reads.get() + 1));
983    std::fs::read(path)
984}
985
986/// An item's file as text; what cannot be read is said with its path.
987fn read_text(path: &Path) -> Result<String, SyncError> {
988    let fail = |e: &dyn std::fmt::Display| SyncError::Disk(format!("{}: {e}", path.display()));
989    String::from_utf8(read_item(path).map_err(|e| fail(&e))?).map_err(|e| fail(&e))
990}
991
992/// A file name for an item, from its address: safe on every system, unique in its folder.
993fn file_name(href: &str, extension: &str, taken: &BTreeSet<String>) -> String {
994    let last = path_key(href).rsplit('/').next().unwrap_or("item").to_string();
995    let stem = last.strip_suffix(&format!(".{extension}")).unwrap_or(&last);
996    let stem: String = stem.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '@') { c } else { '_' }).take(120).collect();
997    // Not hidden (folders are read without their hidden files), and not one of
998    // the names Windows keeps for its devices, whatever follows them ("CON.ics").
999    let mut stem = stem.trim_start_matches('.').to_string();
1000    let device = stem.split('.').next().unwrap_or("").to_ascii_uppercase();
1001    if matches!(device.as_str(), "CON" | "PRN" | "AUX" | "NUL") || (device.len() == 4 && (device.starts_with("COM") || device.starts_with("LPT")) && device.ends_with(|c: char| c.is_ascii_digit())) {
1002        stem.insert(0, '_');
1003    }
1004    if stem.is_empty() {
1005        stem = vdir::new_name();
1006    }
1007    let mut name = format!("{stem}.{extension}");
1008    let mut n = 2;
1009    while taken.contains(&name) {
1010        name = format!("{stem}-{n}.{extension}");
1011        n += 1;
1012    }
1013    name
1014}
1015
1016/// One collection, whole: what changed here goes first, then what changed
1017/// there. Its state follows every answer and is kept whatever happens: what
1018/// was sent or brought before an error is known at the next sync, never sent twice.
1019#[cfg(test)]
1020fn sync_collection(client: &Client, account: &str, listed: &Listed, report: &mut Report) -> Result<(), SyncError> {
1021    sync_part(client, account, listed, report, true, None).map(|_| ())
1022}
1023
1024/// One collection's turn (`sync_with`): on its first (`first`), what changed
1025/// here is sent; then what changed there is brought until `until`, batch by
1026/// batch. Whether it is whole now: else the next turn goes on from there.
1027fn sync_part(client: &Client, account: &str, listed: &Listed, report: &mut Report, first: bool, until: Option<Instant>) -> Result<bool, SyncError> {
1028    let kind = listed.kind;
1029    let dir = vdir::prepare(kind, account, &listed.id, &listed.name, listed.color.as_deref()).map_err(|e| SyncError::Disk(e.to_string()))?;
1030    let state_path = vdir::state_path(account, kind, &listed.id);
1031    let mut state = State::load(&state_path);
1032    state.url = listed.url.clone();
1033    state.read_only = listed.read_only;
1034    state.components = listed.components.clone();
1035    let synced = both_ways(client, &dir, listed, &mut state, &state_path, report, first, until);
1036    state.save(&state_path).map_err(SyncError::Disk)?;
1037    synced
1038}
1039
1040/// What changed here, sent (on a collection's first turn); then, unless
1041/// nothing changed on either side, what changed there, brought until `until`.
1042/// The ctag is kept once both went through whole; what the server refused of
1043/// an item is said once the rest is done. Whether it is whole.
1044#[allow(clippy::too_many_arguments)]
1045fn both_ways(client: &Client, dir: &Path, listed: &Listed, state: &mut State, state_path: &Path, report: &mut Report, first: bool, until: Option<Instant>) -> Result<bool, SyncError> {
1046    let mut refetch: BTreeSet<String> = BTreeSet::new();
1047    let refused = if listed.read_only || !first { None } else { push(client, listed.kind, dir, listed, state, &mut refetch, report)? };
1048    let unchanged = state.ctag.is_some() && state.ctag == listed.ctag && refetch.is_empty() && refused.is_none();
1049    if !unchanged && !pull(client, dir, listed, state, state_path, &refetch, report, until)? {
1050        return Ok(false);
1051    }
1052    state.ctag = listed.ctag.clone();
1053    refused.map_or(Ok(true), Err)
1054}
1055
1056fn has_local_changes(dir: &Path, kind: Kind, state: &State) -> bool {
1057    let known: BTreeSet<&str> = state.items.iter().map(|i| i.file.as_str()).collect();
1058    let files = local_files(dir, kind);
1059    files.iter().any(|f| !known.contains(f.as_str())) || state.items.iter().any(|i| !unchanged(&dir.join(&i.file), i))
1060}
1061
1062/// Whether an item's file is as last synced: by its size and time when
1063/// noted, else by its hash.
1064fn unchanged(path: &Path, item: &ItemState) -> bool {
1065    std::fs::metadata(path).is_ok_and(|m| as_noted(&m, item)) || hash_of(path).is_some_and(|hash| hash == item.hash)
1066}
1067
1068/// A file's time is trusted to tell it unchanged once this old: a change made
1069/// within the same tick of the file system's clock (two seconds on FAT)
1070/// would keep it.
1071const SETTLED: Duration = Duration::from_secs(2);
1072
1073/// A file's size and modification time (nanoseconds since 1970), noted to
1074/// tell it unchanged later without reading it; the time 0 (unknown) while
1075/// too recent, or ahead of now.
1076fn stamp(metadata: &std::fs::Metadata) -> (u64, i64) {
1077    let settled = metadata.modified().ok().filter(|m| std::time::SystemTime::now().duration_since(*m).is_ok_and(|age| age >= SETTLED));
1078    let mtime = settled.and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).and_then(|d| i64::try_from(d.as_nanos()).ok()).unwrap_or(0);
1079    (metadata.len(), mtime)
1080}
1081
1082/// Whether a file is as noted for its item: the same size and the same settled time.
1083fn as_noted(metadata: &std::fs::Metadata, item: &ItemState) -> bool {
1084    item.mtime != 0 && stamp(metadata) == (item.size, item.mtime)
1085}
1086
1087fn local_files(dir: &Path, kind: Kind) -> BTreeSet<String> {
1088    std::fs::read_dir(dir)
1089        .into_iter()
1090        .flatten()
1091        .filter_map(Result::ok)
1092        .filter_map(|e| e.file_name().into_string().ok())
1093        .filter(|n| n.ends_with(&format!(".{}", kind.extension())) && !n.starts_with('.'))
1094        .collect()
1095}
1096
1097/// Sends what changed here: edits with If-Match, new items with If-None-Match,
1098/// deletions. The state follows each answer, so a push cut short keeps what
1099/// it did. What the server refuses of one item, or what cannot be read of it,
1100/// is returned, the others sent all the same; a lost connection stops it.
1101fn push(client: &Client, kind: Kind, dir: &Path, listed: &Listed, state: &mut State, refetch: &mut BTreeSet<String>, report: &mut Report) -> Result<Option<SyncError>, SyncError> {
1102    let mut refused = None;
1103    let mut i = 0;
1104    while i < state.items.len() {
1105        match send_known(client, kind, dir, listed, &state.items[i], refetch, report)? {
1106            Sent::Deleted => {
1107                state.items.remove(i);
1108                continue;
1109            }
1110            Sent::Agreed(item) => state.items[i] = item,
1111            Sent::Kept => {}
1112            Sent::Refused(e) => {
1113                refused.get_or_insert(e);
1114            }
1115        }
1116        i += 1;
1117    }
1118    let known: BTreeSet<String> = state.items.iter().map(|i| i.file.clone()).collect();
1119    for file in local_files(dir, kind).into_iter().filter(|f| !known.contains(f)) {
1120        let path = dir.join(&file);
1121        // Noted before it is read: a change made meanwhile is seen next time.
1122        let (size, mtime) = std::fs::metadata(&path).map(|m| stamp(&m)).unwrap_or_default();
1123        let text = match read_text(&path) {
1124            Ok(text) => text,
1125            Err(e) => {
1126                refused.get_or_insert(e);
1127                continue;
1128            }
1129        };
1130        let href = format!("{}/{}", listed.url.trim_end_matches('/'), url_segment(&file));
1131        if client.is_google() {
1132            match create_on_google(client, kind, listed, &file, &href, &text, refetch) {
1133                Ok(item) => {
1134                    state.items.push(ItemState { size, mtime, ..item });
1135                    report.sent += 1;
1136                    // Google may file it under its own name: the whole list is compared at the pull.
1137                    state.sync_token = None;
1138                }
1139                Err(e @ SyncError::Server(_)) => {
1140                    refused.get_or_insert(e);
1141                }
1142                Err(e) => return Err(e),
1143            }
1144            continue;
1145        }
1146        let answer = client.send("PUT", &href, &[("If-None-Match", "*"), ("Content-Type", kind.media_type())], Some(&text))?;
1147        // Refused because another item there holds its UID: an invitation
1148        // answered on another device first. That item takes this one's place.
1149        if matches!(answer.status, 400 | 403 | 409 | 412)
1150            && let Some(theirs) = same_uid_there(client, kind, listed, &href, &text, &answer.body)?
1151        {
1152            // An item moved or made another here is never replaced by its old
1153            // copy still there: said, and tried again (F6).
1154            if vdir::moved_here(&path) {
1155                refused.get_or_insert(SyncError::Server(format!("PUT {href}: {}: its old copy is still there", answer.status)));
1156                continue;
1157            }
1158            state.items.push(adopt(dir, listed, &file, &text, theirs, report)?);
1159            continue;
1160        }
1161        match answer.status {
1162            200..=299 => {
1163                report.sent += 1;
1164                let etag = answer.etag.unwrap_or_default();
1165                if etag.is_empty() {
1166                    refetch.insert(path_key(&href));
1167                }
1168                state.items.push(ItemState { href: path_key(&href), file, etag, hash: vdir::content_hash(text.as_bytes()), size, mtime });
1169            }
1170            // Already there: sent before and its answer lost, or another item
1171            // at that address. Known, never agreed (no ETag, no hash): what the
1172            // server holds comes at the pull, and yours is set aside then if it differs.
1173            412 => {
1174                refetch.insert(path_key(&href));
1175                state.items.push(ItemState { href: path_key(&href), file, ..ItemState::default() });
1176            }
1177            status => {
1178                refused.get_or_insert(SyncError::Server(format!("PUT {href}: {status}")));
1179            }
1180        }
1181    }
1182    Ok(refused)
1183}
1184
1185/// The item the server holds under the UID of `text`, a new item it refused
1186/// at `href` because another holds that UID (RFC 4791 §5.3.2.1 and RFC 6352
1187/// §6.3.2.1, `no-uid-conflict`, said with 403 or 409; Nextcloud says 400, and
1188/// a server may say 412): where the refusal names it, else found by a query
1189/// on the UID (`calendar-query`, `addressbook-query`). Its address, ETag and
1190/// text; None when no other item there holds that UID (a server that answers
1191/// no query included), and the refusal is what it was.
1192fn same_uid_there(client: &Client, kind: Kind, listed: &Listed, href: &str, text: &str, refusal: &str) -> Result<Option<(String, String, String)>, SyncError> {
1193    let Some(uid) = uid_of(text) else { return Ok(None) };
1194    let mut hrefs: Vec<String> = named_in_refusal(refusal).into_iter().collect();
1195    if hrefs.is_empty() {
1196        hrefs = holding_uid(client, kind, &listed.url, &uid, &component_of(text))?;
1197    }
1198    let mine = path_key(href);
1199    hrefs.retain(|h| path_key(&absolute(&listed.url, h)) != mine);
1200    if hrefs.is_empty() {
1201        return Ok(None);
1202    }
1203    Ok(fetch(client, kind, &listed.url, &hrefs)?.into_iter().find(|(_, _, data)| uid_of(data).as_deref() == Some(uid.as_str())))
1204}
1205
1206/// An item's UID, as its first `UID` line says.
1207fn uid_of(text: &str) -> Option<String> {
1208    sioul_core::lines::unfold(text).iter().find(|l| sioul_core::lines::name(l) == "UID").map(|l| sioul_core::lines::value(l).trim().to_string()).filter(|uid| !uid.is_empty())
1209}
1210
1211/// The component a calendar item holds ("VEVENT", "VTODO"…), for a query.
1212fn component_of(text: &str) -> String {
1213    sioul_core::lines::unfold(text)
1214        .iter()
1215        .filter(|l| sioul_core::lines::name(l) == "BEGIN")
1216        .map(|l| sioul_core::lines::value(l).trim().to_ascii_uppercase())
1217        .find(|c| !matches!(c.as_str(), "VCALENDAR" | "VTIMEZONE" | "STANDARD" | "DAYLIGHT" | "VALARM"))
1218        .unwrap_or_else(|| "VEVENT".into())
1219}
1220
1221/// The item a `no-uid-conflict` refusal names, when it names one.
1222fn named_in_refusal(body: &str) -> Option<String> {
1223    let doc = roxmltree::Document::parse(body).ok()?;
1224    let clash = doc.descendants().find(|n| n.tag_name().name() == "no-uid-conflict")?;
1225    clash.descendants().find(|n| n.has_tag_name((DAV, "href"))).and_then(|n| n.text()).map(|h| h.trim().to_string()).filter(|h| !h.is_empty())
1226}
1227
1228/// The items of a collection holding `uid`, by a query (RFC 4791 §7.8, RFC
1229/// 6352 §8.6); none from a server that answers no query.
1230fn holding_uid(client: &Client, kind: Kind, url: &str, uid: &str, component: &str) -> Result<Vec<String>, SyncError> {
1231    let (uid, component) = (xml_escape(uid), xml_escape(component));
1232    let body = match kind {
1233        Kind::Calendars => format!(
1234            r#"<?xml version="1.0" encoding="utf-8"?><c:calendar-query xmlns:d="DAV:" xmlns:c="{CALDAV}"><d:prop><d:getetag/></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="{component}"><c:prop-filter name="UID"><c:text-match>{uid}</c:text-match></c:prop-filter></c:comp-filter></c:comp-filter></c:filter></c:calendar-query>"#
1235        ),
1236        Kind::Contacts => format!(
1237            r#"<?xml version="1.0" encoding="utf-8"?><a:addressbook-query xmlns:d="DAV:" xmlns:a="{CARDDAV}"><d:prop><d:getetag/></d:prop><a:filter><a:prop-filter name="UID"><a:text-match match-type="equals">{uid}</a:text-match></a:prop-filter></a:filter></a:addressbook-query>"#
1238        ),
1239    };
1240    let answer = client.send("REPORT", url, &[("Depth", "1"), ("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
1241    if answer.status != 207 {
1242        return Ok(Vec::new());
1243    }
1244    let Ok((responses, _)) = multistatus(&answer.body) else { return Ok(Vec::new()) };
1245    let collection = path_key(url);
1246    Ok(responses.into_iter().filter(|r| r.status.is_none_or(|s| s / 100 == 2) && path_key(&absolute(url, &r.href)) != collection).map(|r| r.href).collect())
1247}
1248
1249/// A new item here (`file`, holding `mine`) whose UID the server holds
1250/// already under another address, `theirs`: that item takes its place, under
1251/// its file, never sent again. An invitation answered on two devices: the
1252/// same answers (`agenda::answers`), taken as it is; others, the server's
1253/// kept, and said (`Report::answered`): the organizer may have had both. An
1254/// item without guests (a contact, a task) that differs: yours set aside
1255/// first, as any item changed on both sides.
1256fn adopt(dir: &Path, listed: &Listed, file: &str, mine: &str, (href, etag, data): (String, String, String), report: &mut Report) -> Result<ItemState, SyncError> {
1257    let path = dir.join(file);
1258    let (ours, theirs) = (sioul_core::agenda::answers(mine), sioul_core::agenda::answers(&data));
1259    if ours != theirs {
1260        // The guest whose answer differs: you, answering on each device.
1261        let kept = theirs.iter().find(|(who, said)| ours.get(*who) != Some(*said)).map_or_else(String::new, |(_, said)| said.clone());
1262        report.answered.push((summary_of(&data), kept));
1263    } else if ours.is_empty() && !same_text(mine.as_bytes(), &data) {
1264        report.conflicts.push(set_aside(&path, mine.as_bytes())?);
1265    }
1266    let text = crlf(&data);
1267    vdir::write_item(&path, &text).map_err(SyncError::Disk)?;
1268    report.received += 1;
1269    // Written now, its time not trusted yet (`SETTLED`): read once at the next sync.
1270    Ok(ItemState { href: path_key(&absolute(&listed.url, &href)), file: file.to_string(), etag, hash: vdir::content_hash(text.as_bytes()), ..ItemState::default() })
1271}
1272
1273/// An event's title, as its first `SUMMARY` line says.
1274fn summary_of(text: &str) -> String {
1275    sioul_core::lines::unfold(text).iter().find(|l| sioul_core::lines::name(l) == "SUMMARY").map(|l| sioul_core::lines::unescape(sioul_core::lines::value(l).trim())).unwrap_or_default()
1276}
1277
1278/// What became of an item known to the server, at a push.
1279enum Sent {
1280    /// Deleted there as it was here: forgotten.
1281    Deleted,
1282    /// Its state now: sent (the server's new ETag), or found unchanged (its size and time noted).
1283    Agreed(ItemState),
1284    /// As it was: unchanged, or to read again at the pull.
1285    Kept,
1286    /// Refused by the server, or unreadable here.
1287    Refused(SyncError),
1288}
1289
1290/// One item known to the server, sent if it changed here, deleted there if
1291/// deleted here. A file whose size and time are as noted is not read.
1292fn send_known(client: &Client, kind: Kind, dir: &Path, listed: &Listed, item: &ItemState, refetch: &mut BTreeSet<String>, report: &mut Report) -> Result<Sent, SyncError> {
1293    let path = dir.join(&item.file);
1294    let url = absolute(&listed.url, &item.href);
1295    let unreadable = |e: std::io::Error| Sent::Refused(SyncError::Disk(format!("{}: {e}", path.display())));
1296    let metadata = match std::fs::metadata(&path) {
1297        Ok(metadata) => metadata,
1298        // Deleted here: deleted there, unless it changed there meanwhile.
1299        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1300            // Moved or made another here: its old copy goes only once the new
1301            // one is on its server (the review of 22 October 2026, F6).
1302            if vdir::deletion_waits_for(&path).is_some() {
1303                return Ok(Sent::Kept);
1304            }
1305            let answer = client.send("DELETE", &url, &[("If-Match", &item.etag)], None)?;
1306            return Ok(match answer.status {
1307                200..=299 | 404 => {
1308                    report.sent += 1;
1309                    vdir::deletion_done(&path);
1310                    Sent::Deleted
1311                }
1312                412 => {
1313                    refetch.insert(path_key(&url));
1314                    Sent::Kept
1315                }
1316                status => Sent::Refused(SyncError::Server(format!("DELETE {url}: {status}"))),
1317            });
1318        }
1319        Err(e) => return Ok(unreadable(e)),
1320    };
1321    if as_noted(&metadata, item) {
1322        return Ok(Sent::Kept);
1323    }
1324    // Noted before it is read: a change made meanwhile is seen next time.
1325    let (size, mtime) = stamp(&metadata);
1326    let bytes = match read_item(&path) {
1327        Ok(bytes) => bytes,
1328        Err(e) => return Ok(unreadable(e)),
1329    };
1330    let hash = vdir::content_hash(&bytes);
1331    if hash == item.hash {
1332        // Unchanged, its size and time noted: not read next time.
1333        return Ok(if (size, mtime) == (item.size, item.mtime) { Sent::Kept } else { Sent::Agreed(ItemState { size, mtime, ..item.clone() }) });
1334    }
1335    if item.hash.is_empty() {
1336        // Never agreed: found there when sent as new; compared at the pull.
1337        refetch.insert(path_key(&url));
1338        return Ok(Sent::Kept);
1339    }
1340    let text = match String::from_utf8(bytes) {
1341        Ok(text) => text,
1342        Err(e) => return Ok(Sent::Refused(SyncError::Disk(format!("{}: {e}", path.display())))),
1343    };
1344    let answer = client.send("PUT", &url, &[("If-Match", &item.etag), ("Content-Type", kind.media_type())], Some(&text))?;
1345    Ok(match answer.status {
1346        200..=299 => {
1347            report.sent += 1;
1348            // Without an ETag, the server changed what it stored: it comes back at the pull.
1349            let etag = answer.etag.unwrap_or_default();
1350            if etag.is_empty() {
1351                refetch.insert(path_key(&url));
1352            }
1353            Sent::Agreed(ItemState { etag, hash, size, mtime, ..item.clone() })
1354        }
1355        // Changed on both sides: theirs comes back at the pull, and yours is set aside then.
1356        412 => {
1357            refetch.insert(path_key(&url));
1358            Sent::Kept
1359        }
1360        status => Sent::Refused(SyncError::Server(format!("PUT {url}: {status}"))),
1361    })
1362}
1363
1364/// A new item on Google: no If-None-Match (it takes If-Match only), cards in
1365/// vCard 3.0, and a new contact by POST when its address book refuses the
1366/// PUT (RFC 5995). Where Google filed it (its Location) is where it is known;
1367/// what Google kept of it comes back at the pull.
1368fn create_on_google(client: &Client, kind: Kind, listed: &Listed, file: &str, href: &str, text: &str, refetch: &mut BTreeSet<String>) -> Result<ItemState, SyncError> {
1369    let sent = if kind == Kind::Contacts { sioul_core::contacts::as_vcard3(text) } else { text.to_string() };
1370    let mut answer = client.send("PUT", href, &[("Content-Type", kind.media_type())], Some(&sent))?;
1371    if kind == Kind::Contacts && matches!(answer.status, 403 | 405 | 409) {
1372        answer = client.send("POST", &listed.url, &[("Content-Type", kind.media_type())], Some(&sent))?;
1373    }
1374    if !(200..300).contains(&answer.status) {
1375        return Err(SyncError::Server(format!("PUT {href}: {}", answer.status)));
1376    }
1377    let at = answer.location.as_deref().map_or_else(|| path_key(href), |l| path_key(&absolute(&listed.url, l)));
1378    refetch.insert(at.clone());
1379    Ok(ItemState { href: at, file: file.to_string(), etag: answer.etag.unwrap_or_default(), hash: vdir::content_hash(text.as_bytes()), ..ItemState::default() })
1380}
1381
1382/// A file name as a URL path segment.
1383fn url_segment(name: &str) -> String {
1384    name.bytes()
1385        .map(|b| match b {
1386            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'@' => (b as char).to_string(),
1387            _ => format!("%{b:02X}"),
1388        })
1389        .collect()
1390}
1391
1392/// Your version of an item changed on both sides, kept where you can find it,
1393/// on a phone too: among this device's earlier versions (`history`, the part
1394/// `history::ACCOUNTS`, named by the item's place in the data folder), which
1395/// the window lists and puts back (Settings ▸ Your folder and sharing ▸ Show
1396/// earlier versions), sharing on or off. Until 10 October 2026 it went to
1397/// `$XDG_STATE_HOME/sioul/dav/conflicts`, out of reach on a phone. Not kept (a
1398/// full disk), the sync stops there: the server's version does not take its
1399/// place, and yours is never lost.
1400fn set_aside(path: &Path, mine: &[u8]) -> Result<PathBuf, SyncError> {
1401    let root = crate::history::root(&state_dir().join("share").join("memory.json"));
1402    let data = sioul_core::config::data_dir();
1403    let file = path.strip_prefix(&data).ok().map(|relative| format!("data/{}", relative.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/"))).unwrap_or_else(|| format!("data/{}", path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()));
1404    crate::history::keep_bytes(&root, crate::history::ACCOUNTS, &file, mine, jiff::Timestamp::now().as_millisecond()).map_err(SyncError::Disk)
1405}
1406
1407/// An item's file, when it changed here since the server last agreed on it (or never did).
1408fn changed_here(path: &Path, item: &ItemState) -> Option<Vec<u8>> {
1409    if std::fs::metadata(path).is_ok_and(|m| as_noted(&m, item)) {
1410        return None;
1411    }
1412    read_item(path).ok().filter(|bytes| item.hash.is_empty() || vdir::content_hash(bytes) != item.hash)
1413}
1414
1415/// Lines ended with CRLF, as items are written here (RFC 5545 §3.1, RFC 6350 §3.2).
1416fn crlf(text: &str) -> String {
1417    text.replace("\r\n", "\n").replace('\n', "\r\n")
1418}
1419
1420/// Whether two versions of an item say the same, however their lines end.
1421fn same_text(mine: &[u8], theirs: &str) -> bool {
1422    crlf(&String::from_utf8_lossy(mine)).trim_end() == crlf(theirs).trim_end()
1423}
1424
1425/// Brings what changed there: by sync token, else by comparing ETags. The
1426/// state is kept after each batch, so a pull cut short loses one batch at
1427/// most; the sync token moves once everything came.
1428/// Brought until `until` at most (a turn among the account's collections):
1429/// whether all came, else the next turn goes on from what was kept.
1430#[allow(clippy::too_many_arguments)]
1431fn pull(client: &Client, dir: &Path, listed: &Listed, state: &mut State, state_path: &Path, refetch: &BTreeSet<String>, report: &mut Report, until: Option<Instant>) -> Result<bool, SyncError> {
1432    let key = |href: &str| path_key(&absolute(&listed.url, href));
1433    let by_href: BTreeMap<String, usize> = state.items.iter().enumerate().map(|(i, item)| (key(&item.href), i)).collect();
1434    let (changed, gone, token) = match state.sync_token.clone().and_then(|token| changes_since(client, &listed.url, &token).ok().flatten()) {
1435        Some(found) => found,
1436        None => everything(client, &listed.url, state, &by_href)?,
1437    };
1438    // Gone there: gone here. Changed here meanwhile (an edit saved during the
1439    // sync, or one the server refused), yours is set aside first.
1440    let gone: BTreeSet<String> = gone.iter().map(|h| key(h)).collect();
1441    let mut i = 0;
1442    while i < state.items.len() {
1443        if !gone.contains(&key(&state.items[i].href)) {
1444            i += 1;
1445            continue;
1446        }
1447        let path = dir.join(&state.items[i].file);
1448        if let Some(mine) = changed_here(&path, &state.items[i]) {
1449            report.conflicts.push(set_aside(&path, &mine)?);
1450        }
1451        let _ = std::fs::remove_file(&path);
1452        state.items.remove(i);
1453        report.removed += 1;
1454    }
1455    // Changed there, or to read again after a send.
1456    let known: BTreeMap<String, usize> = state.items.iter().enumerate().map(|(i, item)| (key(&item.href), i)).collect();
1457    let mut wanted: BTreeMap<String, String> = changed
1458        .into_iter()
1459        .filter_map(|(href, etag)| {
1460            let at = key(&href);
1461            let stale = known.get(&at).is_none_or(|&i| etag.as_deref().is_none_or(|e| e != state.items[i].etag));
1462            (stale || refetch.contains(&at)).then_some((at, href))
1463        })
1464        .collect();
1465    for at in refetch {
1466        if let Some(&i) = known.get(at) {
1467            wanted.entry(at.clone()).or_insert_with(|| state.items[i].href.clone());
1468        }
1469    }
1470    let hrefs: Vec<String> = wanted.into_values().collect();
1471    let mut at = 0;
1472    while at < hrefs.len() {
1473        // As many as the last answers allow (`pace`): fewer on a slow line, 50 again once it is quick.
1474        let batch = &hrefs[at..(at + batch_of(&listed.url)).min(hrefs.len())];
1475        let started = Instant::now();
1476        let fetched = fetch(client, listed.kind, &listed.url, batch);
1477        match &fetched {
1478            Ok(_) => pace(&listed.url, Some(started.elapsed())),
1479            Err(SyncError::Network(_)) => pace(&listed.url, None),
1480            Err(_) => {}
1481        }
1482        for (href, etag, data) in fetched? {
1483            store(dir, listed.kind, state, &listed.url, (&href, &etag, &data), report)?;
1484        }
1485        // Kept as it goes: cut short, the next sync goes on from here.
1486        state.save(state_path).map_err(SyncError::Disk)?;
1487        at += batch.len();
1488        // Its turn over: the others have theirs, then it goes on (no sync token kept before all came).
1489        if at < hrefs.len() && until.is_some_and(|until| Instant::now() >= until) {
1490            return Ok(false);
1491        }
1492    }
1493    state.sync_token = token.or_else(|| listed.sync_token.clone());
1494    Ok(true)
1495}
1496
1497/// RFC 6578: what changed since `token`: (changed with their ETags, gone, the new token).
1498/// None when the server has forgotten the token or does not keep them.
1499#[allow(clippy::type_complexity)]
1500fn changes_since(client: &Client, url: &str, token: &str) -> Result<Option<(Vec<(String, Option<String>)>, Vec<String>, Option<String>)>, SyncError> {
1501    let body = format!(
1502        r#"<?xml version="1.0" encoding="utf-8"?><d:sync-collection xmlns:d="DAV:"><d:sync-token>{}</d:sync-token><d:sync-level>1</d:sync-level><d:prop><d:getetag/></d:prop></d:sync-collection>"#,
1503        xml_escape(token)
1504    );
1505    let answer = client.send("REPORT", url, &[("Depth", "0"), ("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
1506    if answer.status != 207 {
1507        return Ok(None);
1508    }
1509    let (responses, new_token) = multistatus(&answer.body)?;
1510    let collection = path_key(url);
1511    let mut changed = Vec::new();
1512    let mut gone = Vec::new();
1513    for response in responses.into_iter().filter(|r| path_key(&absolute(url, &r.href)) != collection) {
1514        if response.status == Some(404) {
1515            gone.push(response.href);
1516        } else {
1517            changed.push((response.href.clone(), response.text(DAV, "getetag")));
1518        }
1519    }
1520    Ok(Some((changed, gone, new_token)))
1521}
1522
1523/// Every item's ETag, compared with what is known: (changed, gone, no token).
1524#[allow(clippy::type_complexity)]
1525fn everything(client: &Client, url: &str, state: &State, known: &BTreeMap<String, usize>) -> Result<(Vec<(String, Option<String>)>, Vec<String>, Option<String>), SyncError> {
1526    let (status, responses) = client.propfind(url, "1", "<d:getetag/><d:resourcetype/>")?;
1527    if status != 207 {
1528        return Err(SyncError::Server(format!("PROPFIND {url}: {status}")));
1529    }
1530    let collection = path_key(url);
1531    let items: Vec<&Response> = responses.iter().filter(|r| path_key(&absolute(url, &r.href)) != collection && !r.is(DAV, "collection")).collect();
1532    let present: BTreeSet<String> = items.iter().map(|r| path_key(&absolute(url, &r.href))).collect();
1533    let changed = items.iter().map(|r| (r.href.clone(), r.text(DAV, "getetag"))).collect();
1534    let gone = known.keys().filter(|k| !present.contains(*k)).filter_map(|k| known.get(k).map(|&i| state.items[i].href.clone())).collect();
1535    Ok((changed, gone, None))
1536}
1537
1538/// Text made safe in XML, between tags and between quotes (a component's `name="…"`).
1539fn xml_escape(text: &str) -> String {
1540    text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
1541}
1542
1543/// Items by their addresses, with their ETags: a multiget, else one GET each.
1544fn fetch(client: &Client, kind: Kind, url: &str, hrefs: &[String]) -> Result<Vec<(String, String, String)>, SyncError> {
1545    if hrefs.is_empty() {
1546        return Ok(Vec::new());
1547    }
1548    let (root, ns, data) = match kind {
1549        Kind::Calendars => ("c:calendar-multiget", CALDAV, "calendar-data"),
1550        Kind::Contacts => ("a:addressbook-multiget", CARDDAV, "address-data"),
1551    };
1552    let prefix = if kind == Kind::Calendars { "c" } else { "a" };
1553    let list: String = hrefs.iter().map(|h| format!("<d:href>{}</d:href>", xml_escape(h))).collect();
1554    let body = format!(
1555        r#"<?xml version="1.0" encoding="utf-8"?><{root} xmlns:d="DAV:" xmlns:c="{CALDAV}" xmlns:a="{CARDDAV}"><d:prop><d:getetag/><{prefix}:{data}/></d:prop>{list}</{root}>"#
1556    );
1557    let answer = client.send("REPORT", url, &[("Depth", "1"), ("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
1558    if answer.status == 207 {
1559        let (responses, _) = multistatus(&answer.body)?;
1560        let found: Vec<(String, String, String)> = responses
1561            .iter()
1562            .filter_map(|r| Some((r.href.clone(), r.text(DAV, "getetag").unwrap_or_default(), r.prop(ns, data).map(|p| p.text.clone()).filter(|t| !t.trim().is_empty())?)))
1563            .collect();
1564        if !found.is_empty() || hrefs.is_empty() {
1565            return Ok(found);
1566        }
1567    }
1568    // Some servers answer multigets poorly: one GET each.
1569    let mut found = Vec::new();
1570    for href in hrefs {
1571        let answer = client.send("GET", &absolute(url, href), &[], None)?;
1572        if answer.status / 100 == 2 {
1573            found.push((href.clone(), answer.etag.unwrap_or_default(), answer.body));
1574        }
1575    }
1576    Ok(found)
1577}
1578
1579/// Writes an item brought from the server (href, ETag, text). Your version,
1580/// changed here since the server last agreed on it (or never agreed), is set
1581/// aside first, unless it is the same.
1582fn store(dir: &Path, kind: Kind, state: &mut State, url: &str, (href, etag, data): (&str, &str, &str), report: &mut Report) -> Result<(), SyncError> {
1583    let key = path_key(&absolute(url, href));
1584    let position = state.items.iter().position(|i| path_key(&absolute(url, &i.href)) == key);
1585    let file = match position {
1586        Some(i) => state.items[i].file.clone(),
1587        None => file_name(href, kind.extension(), &state.items.iter().map(|i| i.file.clone()).chain(local_files(dir, kind)).collect()),
1588    };
1589    let path = dir.join(&file);
1590    if let Some(i) = position
1591        && let Some(mine) = changed_here(&path, &state.items[i])
1592        && !same_text(&mine, data)
1593    {
1594        report.conflicts.push(set_aside(&path, &mine)?);
1595    }
1596    let text = crlf(data);
1597    vdir::write_item(&path, &text).map_err(SyncError::Disk)?;
1598    // Written now, its time not trusted yet (`SETTLED`): read once at the next sync.
1599    let item = ItemState { href: href.to_string(), file, etag: etag.to_string(), hash: vdir::content_hash(text.as_bytes()), ..ItemState::default() };
1600    match position {
1601        Some(i) => state.items[i] = item,
1602        None => state.items.push(item),
1603    }
1604    report.received += 1;
1605    Ok(())
1606}
1607
1608/// Tests the login and finds where the account's contacts and calendars are.
1609pub fn test(address: &str, login: &str, password: &str, given: Option<&str>, mail_host: Option<&str>) -> Result<Homes, SyncError> {
1610    discover(&Client::new(login, password), address, given, mail_host)
1611}
1612
1613/// Finds a Google account's calendars and contacts, once signed in.
1614pub fn test_google(address: &str) -> Result<Homes, SyncError> {
1615    discover(&Client::google(address)?, address, None, None)
1616}
1617
1618/// Servers on this computer, for this crate's tests: a small HTTP/1.1 server,
1619/// a CalDAV server in memory on it with faults to inject, and the home the
1620/// tests write in.
1621#[cfg(test)]
1622pub(crate) mod stand_in {
1623    use std::collections::{BTreeMap, BTreeSet};
1624    use std::io::{BufRead, BufReader, Read, Write};
1625    use std::net::{TcpListener, TcpStream};
1626    use std::path::{Path, PathBuf};
1627    use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
1628    use std::time::Duration;
1629
1630    /// The tests' home: the XDG folders point into it, set once, before any
1631    /// test reading them goes on (each asks for the home first, and waits here
1632    /// while it is made); the other tests of this crate read none. Each test
1633    /// syncs an account of its own, so they run side by side.
1634    pub(crate) fn home() -> &'static Path {
1635        static HOME: OnceLock<PathBuf> = OnceLock::new();
1636        HOME.get_or_init(|| {
1637            forget_old_homes();
1638            let root = std::env::temp_dir().join(format!("sioul-sync-tests-{}", std::process::id()));
1639            let _ = std::fs::remove_dir_all(&root);
1640            for (variable, folder) in [("XDG_CONFIG_HOME", "config"), ("XDG_DATA_HOME", "data"), ("XDG_STATE_HOME", "state"), ("XDG_CACHE_HOME", "cache")] {
1641                let dir = root.join(folder);
1642                std::fs::create_dir_all(&dir).unwrap();
1643                // SAFETY: set once, inside this initialiser, before any test
1644                // that reads these folders goes on (see above).
1645                unsafe { std::env::set_var(variable, &dir) };
1646            }
1647            root
1648        })
1649    }
1650
1651    /// The homes of earlier runs, gone: those whose run is over (no such
1652    /// process, where that can be told), or an hour old; a run going on beside
1653    /// this one keeps its own.
1654    fn forget_old_homes() {
1655        let hour_ago = std::time::SystemTime::now() - Duration::from_secs(3600);
1656        for entry in std::fs::read_dir(std::env::temp_dir()).into_iter().flatten().filter_map(Result::ok) {
1657            let name = entry.file_name().to_string_lossy().into_owned();
1658            let Some(process) = name.strip_prefix("sioul-sync-tests-") else { continue };
1659            let over = cfg!(target_os = "linux") && !Path::new("/proc").join(process).exists();
1660            let old = entry.metadata().and_then(|m| m.modified()).is_ok_and(|t| t < hour_ago);
1661            if over || old {
1662                let _ = std::fs::remove_dir_all(entry.path());
1663            }
1664        }
1665    }
1666
1667    /// A request, as read.
1668    pub(crate) struct Request {
1669        pub method: String,
1670        /// Its path, as sent.
1671        pub path: String,
1672        headers: Vec<(String, String)>,
1673        pub body: String,
1674    }
1675
1676    impl Request {
1677        pub fn header(&self, name: &str) -> Option<&str> {
1678            self.headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str())
1679        }
1680    }
1681
1682    pub(crate) struct Reply {
1683        pub status: u16,
1684        pub headers: Vec<(&'static str, String)>,
1685        pub body: String,
1686    }
1687
1688    impl Reply {
1689        pub fn new(status: u16, body: impl Into<String>) -> Reply {
1690            Reply { status, headers: Vec::new(), body: body.into() }
1691        }
1692    }
1693
1694    /// How a server misbehaves, for one request.
1695    #[derive(Debug, Clone, Copy)]
1696    pub(crate) enum Fault {
1697        /// Answers this status, doing nothing.
1698        Status(u16),
1699        /// Closes the connection, doing nothing: the network dropped.
1700        Drop,
1701        /// Does it, then closes without answering: the answer was lost.
1702        Lost,
1703        /// Waits this long, then does it.
1704        Late(Duration),
1705        /// Does it, and sends the answer's body in pieces over this long.
1706        Trickle(Duration),
1707    }
1708
1709    /// A stand-in: how it misbehaves for a request, if at all, and its answer, the request done.
1710    pub(crate) trait Server: Send + Sync + 'static {
1711        fn misbehaves(&self, request: &Request) -> Option<Fault>;
1712        fn answer(&self, request: &Request) -> Reply;
1713    }
1714
1715    /// Serves on 127.0.0.1 until the tests end, a thread and a request per
1716    /// connection. Its address: "http://127.0.0.1:port".
1717    pub(crate) fn serve(server: Arc<dyn Server>) -> String {
1718        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1719        let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
1720        std::thread::spawn(move || {
1721            for stream in listener.incoming().filter_map(Result::ok) {
1722                let server = Arc::clone(&server);
1723                std::thread::spawn(move || exchange(stream, server.as_ref()));
1724            }
1725        });
1726        base
1727    }
1728
1729    fn exchange(mut stream: TcpStream, server: &dyn Server) {
1730        let Some(request) = read(&stream) else { return };
1731        let fault = server.misbehaves(&request);
1732        let reply = match fault {
1733            Some(Fault::Status(status)) => Reply::new(status, ""),
1734            Some(Fault::Drop) => return,
1735            Some(Fault::Lost) => {
1736                server.answer(&request);
1737                return;
1738            }
1739            Some(Fault::Late(wait)) => {
1740                std::thread::sleep(wait);
1741                server.answer(&request)
1742            }
1743            Some(Fault::Trickle(_)) | None => server.answer(&request),
1744        };
1745        let headers: String = reply.headers.iter().map(|(name, value)| format!("{name}: {value}\r\n")).collect();
1746        let head = format!("HTTP/1.1 {} Stand-in\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n", reply.status, reply.body.len());
1747        if stream.write_all(head.as_bytes()).is_err() {
1748            return;
1749        }
1750        match fault {
1751            Some(Fault::Trickle(over)) => {
1752                let pieces = 30;
1753                for piece in reply.body.as_bytes().chunks(reply.body.len() / pieces + 1) {
1754                    std::thread::sleep(over / pieces as u32);
1755                    if stream.write_all(piece).and_then(|()| stream.flush()).is_err() {
1756                        return;
1757                    }
1758                }
1759            }
1760            _ => {
1761                let _ = stream.write_all(reply.body.as_bytes());
1762            }
1763        }
1764    }
1765
1766    fn read(stream: &TcpStream) -> Option<Request> {
1767        let mut reader = BufReader::new(stream);
1768        let mut line = String::new();
1769        reader.read_line(&mut line).ok()?;
1770        let mut words = line.split_whitespace();
1771        let (method, path) = (words.next()?.to_string(), words.next()?.to_string());
1772        let mut headers: Vec<(String, String)> = Vec::new();
1773        loop {
1774            let mut line = String::new();
1775            reader.read_line(&mut line).ok()?;
1776            let Some((name, value)) = line.trim_end().split_once(':') else { break };
1777            headers.push((name.trim().to_string(), value.trim().to_string()));
1778        }
1779        let length = headers.iter().find(|(n, _)| n.eq_ignore_ascii_case("content-length")).and_then(|(_, v)| v.parse().ok()).unwrap_or(0);
1780        let mut body = vec![0; length];
1781        reader.read_exact(&mut body).ok()?;
1782        Some(Request { method, path, headers, body: String::from_utf8_lossy(&body).into_owned() })
1783    }
1784
1785    /// A fault for the `nth` request (from 1) that `matches`, once.
1786    pub(crate) fn nth(n: usize, matches: impl Fn(&Request) -> bool + Send + 'static, fault: Fault) -> impl FnMut(&Request) -> Option<Fault> + Send + 'static {
1787        let mut count = 0;
1788        move |request| {
1789            if !matches(request) {
1790                return None;
1791            }
1792            count += 1;
1793            (count == n).then_some(fault)
1794        }
1795    }
1796
1797    type Faults = Vec<Box<dyn FnMut(&Request) -> Option<Fault> + Send>>;
1798
1799    /// A CalDAV server in memory: calendars under /cal/, every change numbered
1800    /// (ETags, ctags and sync tokens come from that), faults to inject, and
1801    /// every request seen.
1802    #[derive(Default)]
1803    pub(crate) struct Dav {
1804        held: Mutex<Held>,
1805        faults: Mutex<Faults>,
1806    }
1807
1808    #[derive(Default)]
1809    struct Held {
1810        /// Calendar ("a") → item ("x.ics") → (ETag, text).
1811        calendars: BTreeMap<String, BTreeMap<String, (String, String)>>,
1812        version: u64,
1813        /// Every change: (version, calendar, item).
1814        changes: Vec<(u64, String, String)>,
1815        /// "PUT /cal/a/x.ics 201", in order.
1816        seen: Vec<String>,
1817        /// A new item whose UID another item of its calendar holds is refused
1818        /// with this status, the other named in a `no-uid-conflict` or not.
1819        uid_clash: Option<(u16, bool)>,
1820        /// Another server's address (`http://127.0.0.1:port`), which this one
1821        /// names as a hostile one would: a calendar "theirs" there among its
1822        /// own, and an item there in each of its calendars (`name_elsewhere`).
1823        elsewhere: Option<String>,
1824    }
1825
1826    impl Dav {
1827        /// A server with these calendars, empty; and the address of their home.
1828        pub(crate) fn start(calendars: &[&str]) -> (Arc<Dav>, String) {
1829            let dav = Arc::new(Dav::default());
1830            for name in calendars {
1831                dav.held().calendars.insert(name.to_string(), BTreeMap::new());
1832            }
1833            let base = serve(dav.clone());
1834            (dav, format!("{base}/cal/"))
1835        }
1836
1837        fn held(&self) -> MutexGuard<'_, Held> {
1838            self.held.lock().unwrap()
1839        }
1840
1841        /// An item written there, as another device does.
1842        pub(crate) fn put(&self, calendar: &str, item: &str, text: &str) {
1843            self.held().store(calendar, item, text);
1844        }
1845
1846        /// An item deleted there, as another device does.
1847        pub(crate) fn delete(&self, calendar: &str, item: &str) {
1848            self.held().delete(calendar, item);
1849        }
1850
1851        /// A calendar's items: name → text.
1852        pub(crate) fn items(&self, calendar: &str) -> BTreeMap<String, String> {
1853            self.held().calendars[calendar].iter().map(|(name, (_, text))| (name.clone(), text.clone())).collect()
1854        }
1855
1856        /// The requests seen, in order: "PUT /cal/a/x.ics 201".
1857        pub(crate) fn seen(&self) -> Vec<String> {
1858            self.held().seen.clone()
1859        }
1860
1861        /// A fault, asked of each request until it says one.
1862        pub(crate) fn fault(&self, fault: impl FnMut(&Request) -> Option<Fault> + Send + 'static) {
1863            self.faults.lock().unwrap().push(Box::new(fault));
1864        }
1865
1866        /// From now on, one UID per calendar: a new item holding another's
1867        /// is refused with `status`, the other named when `named` (RFC 4791
1868        /// §5.3.2.1), or not (Nextcloud's 400).
1869        pub(crate) fn one_uid_each(&self, status: u16, named: bool) {
1870            self.held().uid_clash = Some((status, named));
1871        }
1872
1873        /// From now on, its answers name addresses on the server at `base` too.
1874        pub(crate) fn name_elsewhere(&self, base: &str) {
1875            self.held().elsewhere = Some(base.to_string());
1876        }
1877    }
1878
1879    /// An item's UID line, as the tests write them.
1880    fn uid_line(text: &str) -> Option<&str> {
1881        text.lines().find_map(|l| l.trim_end_matches('\r').strip_prefix("UID:"))
1882    }
1883
1884    impl Server for Dav {
1885        fn misbehaves(&self, request: &Request) -> Option<Fault> {
1886            let fault = self.faults.lock().unwrap().iter_mut().find_map(|f| f(request));
1887            // Late or slow, it is answered all the same, and seen then.
1888            if let Some(fault @ (Fault::Status(_) | Fault::Drop | Fault::Lost)) = fault {
1889                self.held().seen.push(format!("{} {} {fault:?}", request.method, request.path));
1890            }
1891            fault
1892        }
1893
1894        fn answer(&self, request: &Request) -> Reply {
1895            let mut held = self.held();
1896            let reply = held.answer(request);
1897            held.seen.push(format!("{} {} {}", request.method, request.path, reply.status));
1898            reply
1899        }
1900    }
1901
1902    fn found(href: &str, props: &str) -> String {
1903        format!("<d:response><d:href>{href}</d:href><d:propstat><d:prop>{props}</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>")
1904    }
1905
1906    fn missing(href: &str) -> String {
1907        format!("<d:response><d:href>{href}</d:href><d:status>HTTP/1.1 404 Not Found</d:status></d:response>")
1908    }
1909
1910    fn multistatus(responses: &str) -> Reply {
1911        Reply::new(207, format!(r#"<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">{responses}</d:multistatus>"#))
1912    }
1913
1914    fn etag_prop(etag: &str) -> String {
1915        format!("<d:getetag>{}</d:getetag>", super::xml_escape(etag))
1916    }
1917
1918    impl Held {
1919        fn store(&mut self, calendar: &str, item: &str, text: &str) -> String {
1920            self.version += 1;
1921            let etag = format!("\"e{}\"", self.version);
1922            self.calendars.get_mut(calendar).expect("a calendar of the stand-in").insert(item.to_string(), (etag.clone(), text.to_string()));
1923            self.changes.push((self.version, calendar.to_string(), item.to_string()));
1924            etag
1925        }
1926
1927        fn delete(&mut self, calendar: &str, item: &str) {
1928            if self.calendars.get_mut(calendar).and_then(|items| items.remove(item)).is_some() {
1929                self.version += 1;
1930                self.changes.push((self.version, calendar.to_string(), item.to_string()));
1931            }
1932        }
1933
1934        /// The last change of a calendar: its ctag and its sync token.
1935        fn last(&self, calendar: &str) -> u64 {
1936            self.changes.iter().filter(|(_, c, _)| c == calendar).map(|(v, _, _)| *v).max().unwrap_or(0)
1937        }
1938
1939        fn calendar_props(&self, calendar: &str) -> String {
1940            let last = self.last(calendar);
1941            format!("<d:resourcetype><d:collection/><c:calendar/></d:resourcetype><d:displayname>{calendar}</d:displayname><cs:getctag>ctag-{last}</cs:getctag><d:sync-token>token-{last}</d:sync-token>")
1942        }
1943
1944        fn answer(&mut self, request: &Request) -> Reply {
1945            let path = super::path_key(&request.path);
1946            let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
1947            match (request.method.as_str(), parts.as_slice()) {
1948                ("PROPFIND", ["cal"]) => {
1949                    let mut out = found("/cal/", "<d:resourcetype><d:collection/></d:resourcetype>");
1950                    for calendar in self.calendars.keys() {
1951                        out += &found(&format!("/cal/{calendar}/"), &self.calendar_props(calendar));
1952                    }
1953                    if let Some(base) = &self.elsewhere {
1954                        out += &found(&format!("{base}/cal/theirs/"), "<d:resourcetype><d:collection/><c:calendar/></d:resourcetype><d:displayname>theirs</d:displayname>");
1955                    }
1956                    multistatus(&out)
1957                }
1958                (_, ["cal", calendar, ..]) if !self.calendars.contains_key(*calendar) => Reply::new(404, ""),
1959                ("PROPFIND", ["cal", calendar]) => {
1960                    let mut out = found(&format!("/cal/{calendar}/"), &self.calendar_props(calendar));
1961                    for (item, (etag, _)) in &self.calendars[*calendar] {
1962                        out += &found(&format!("/cal/{calendar}/{item}"), &format!("{}<d:resourcetype/>", etag_prop(etag)));
1963                    }
1964                    if let Some(base) = &self.elsewhere {
1965                        out += &found(&format!("{base}/cal/{calendar}/there.ics"), &format!("{}<d:resourcetype/>", etag_prop("\"there\"")));
1966                    }
1967                    multistatus(&out)
1968                }
1969                ("REPORT", ["cal", calendar]) if request.body.contains("sync-collection") => self.changes_since(calendar, &request.body),
1970                ("REPORT", ["cal", calendar]) if request.body.contains("calendar-query") => self.query(calendar, &request.body),
1971                ("REPORT", ["cal", calendar]) => self.multiget(calendar, &request.body),
1972                ("PUT", ["cal", calendar, item]) => {
1973                    let current = self.calendars[*calendar].get(*item).map(|(etag, _)| etag.clone());
1974                    let refused = match (request.header("If-None-Match"), request.header("If-Match")) {
1975                        (Some("*"), _) => current.is_some(),
1976                        (_, Some(wanted)) => current.as_deref() != Some(wanted),
1977                        _ => false,
1978                    };
1979                    if refused {
1980                        return Reply::new(412, "");
1981                    }
1982                    let holder = uid_line(&request.body).and_then(|uid| self.calendars[*calendar].iter().find(|(name, (_, text))| name.as_str() != *item && uid_line(text) == Some(uid))).map(|(name, _)| name.clone());
1983                    if current.is_none()
1984                        && let (Some((status, named)), Some(holder)) = (self.uid_clash, holder)
1985                    {
1986                        let body = if named {
1987                            format!(r#"<?xml version="1.0" encoding="utf-8"?><d:error xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><c:no-uid-conflict><d:href>/cal/{calendar}/{holder}</d:href></c:no-uid-conflict></d:error>"#)
1988                        } else {
1989                            "Calendar object with uid already exists in this calendar collection.".to_string()
1990                        };
1991                        return Reply::new(status, body);
1992                    }
1993                    let etag = self.store(calendar, item, &request.body);
1994                    Reply { status: if current.is_some() { 204 } else { 201 }, headers: vec![("ETag", etag)], body: String::new() }
1995                }
1996                ("DELETE", ["cal", calendar, item]) => match (self.calendars[*calendar].get(*item), request.header("If-Match")) {
1997                    (None, _) => Reply::new(404, ""),
1998                    (Some((etag, _)), Some(wanted)) if etag != wanted => Reply::new(412, ""),
1999                    _ => {
2000                        self.delete(calendar, item);
2001                        Reply::new(204, "")
2002                    }
2003                },
2004                ("GET", ["cal", calendar, item]) => match self.calendars[*calendar].get(*item) {
2005                    Some((etag, text)) => Reply { status: 200, headers: vec![("ETag", etag.clone())], body: text.clone() },
2006                    None => Reply::new(404, ""),
2007                },
2008                _ => Reply::new(405, ""),
2009            }
2010        }
2011
2012        /// RFC 6578: what changed since a token of this calendar; a token it never gave is refused.
2013        fn changes_since(&self, calendar: &str, body: &str) -> Reply {
2014            let doc = roxmltree::Document::parse(body).unwrap();
2015            let token = doc.descendants().find(|n| n.has_tag_name(("DAV:", "sync-token"))).and_then(|n| n.text()).unwrap_or("");
2016            let Some(since) = token.trim().strip_prefix("token-").and_then(|t| t.parse::<u64>().ok()) else { return Reply::new(403, "") };
2017            let changed: BTreeSet<&str> = self.changes.iter().filter(|(v, c, _)| *v > since && c == calendar).map(|(_, _, item)| item.as_str()).collect();
2018            let mut out = String::new();
2019            for item in changed {
2020                let href = format!("/cal/{calendar}/{item}");
2021                out += &match self.calendars[calendar].get(item) {
2022                    Some((etag, _)) => found(&href, &etag_prop(etag)),
2023                    None => missing(&href),
2024                };
2025            }
2026            out += &format!("<d:sync-token>token-{}</d:sync-token>", self.last(calendar));
2027            multistatus(&out)
2028        }
2029
2030        /// RFC 4791 §7.8: the items whose UID holds the text asked (a UID filter, as `holding_uid` asks).
2031        fn query(&self, calendar: &str, body: &str) -> Reply {
2032            let doc = roxmltree::Document::parse(body).unwrap();
2033            let uid = doc.descendants().find(|n| n.tag_name().name() == "text-match").and_then(|n| n.text()).unwrap_or("").to_string();
2034            let mut out = String::new();
2035            for (item, (etag, text)) in &self.calendars[calendar] {
2036                if !uid.is_empty() && uid_line(text).is_some_and(|u| u.contains(&uid)) {
2037                    out += &found(&format!("/cal/{calendar}/{item}"), &etag_prop(etag));
2038                }
2039            }
2040            multistatus(&out)
2041        }
2042
2043        fn multiget(&self, calendar: &str, body: &str) -> Reply {
2044            let doc = roxmltree::Document::parse(body).unwrap();
2045            let mut out = String::new();
2046            for href in doc.descendants().filter(|n| n.has_tag_name(("DAV:", "href"))).filter_map(|n| n.text()) {
2047                let key = super::path_key(href);
2048                let item = key.rsplit('/').next().unwrap_or("");
2049                out += &match self.calendars[calendar].get(item) {
2050                    Some((etag, text)) => found(href, &format!("{}<c:calendar-data>{}</c:calendar-data>", etag_prop(etag), super::xml_escape(text))),
2051                    None => missing(href),
2052                };
2053            }
2054            multistatus(&out)
2055        }
2056    }
2057}
2058
2059#[cfg(test)]
2060mod tests {
2061    use super::stand_in::{Dav, Fault, home, nth};
2062    use super::*;
2063    use std::sync::Arc;
2064
2065    /// A contacts-and-calendars account, the test's own.
2066    fn account(id: &str) -> Account {
2067        home();
2068        toml::from_str(&format!("id = \"{id}\"\nkind = \"dav\"\naddress = \"jane@example.org\"\nhost = \"127.0.0.1\"\n")).unwrap()
2069    }
2070
2071    fn client() -> Client {
2072        Client::new("jane", "secret")
2073    }
2074
2075    fn event(uid: &str, summary: &str) -> String {
2076        format!(
2077            "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sioul//tests//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261005T120000Z\r\nDTSTART:20261006T090000Z\r\nDTEND:20261006T100000Z\r\nSUMMARY:{summary}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
2078        )
2079    }
2080
2081    /// A calendar as the stand-in lists it now.
2082    fn listed(client: &Client, home: &str, name: &str) -> Listed {
2083        collections(client, Kind::Calendars, home).unwrap().into_iter().find(|l| l.id == name).unwrap()
2084    }
2085
2086    /// One calendar synced, as the account's sync does it.
2087    fn sync_one(client: &Client, account: &str, home: &str, name: &str) -> Result<Report, SyncError> {
2088        let mut report = Report::default();
2089        sync_collection(client, account, &listed(client, home, name), &mut report).map(|()| report)
2090    }
2091
2092    fn folder(account: &str, calendar: &str) -> PathBuf {
2093        Kind::Calendars.root().join(account).join(calendar)
2094    }
2095
2096    fn state_of(account: &str, calendar: &str) -> State {
2097        State::load(&vdir::state_path(account, Kind::Calendars, calendar))
2098    }
2099
2100    fn puts(dav: &Dav) -> Vec<String> {
2101        dav.seen().into_iter().filter(|s| s.starts_with("PUT")).collect()
2102    }
2103
2104    /// F1: the first pull of a big calendar, cut at its second batch (Android
2105    /// killed Sioul, the network dropped), resumes at the next sync: nothing
2106    /// sent back, nothing set aside.
2107    #[test]
2108    fn an_interrupted_pull_resumes() {
2109        home();
2110        let (dav, home) = Dav::start(&["big"]);
2111        for n in 0..120 {
2112            dav.put("big", &format!("e{n}.ics"), &event(&format!("uid-{n}"), &format!("Event {n}")));
2113        }
2114        dav.fault(nth(2, |r| r.method == "REPORT" && r.body.contains("multiget"), Fault::Drop));
2115        let client = client();
2116        assert!(matches!(sync_one(&client, "resumes", &home, "big"), Err(SyncError::Network(_))));
2117        let report = sync_one(&client, "resumes", &home, "big").unwrap();
2118        assert_eq!((report.sent, report.received, report.conflicts.len()), (0, 70, 0), "{report:?}");
2119        assert_eq!(local_files(&folder("resumes", "big"), Kind::Calendars).len(), 120);
2120        assert_eq!(dav.items("big").len(), 120);
2121        assert!(puts(&dav).is_empty(), "{:?}", dav.seen());
2122    }
2123
2124    /// F1: an event made here is sent, then the network drops before the
2125    /// calendar is read: the next sync knows it was sent.
2126    #[test]
2127    fn a_sent_event_is_not_sent_again() {
2128        home();
2129        let (dav, home) = Dav::start(&["work"]);
2130        dav.put("work", "known.ics", &event("known", "Known"));
2131        let client = client();
2132        sync_one(&client, "sent-once", &home, "work").unwrap();
2133        dav.put("work", "theirs.ics", &event("theirs", "Theirs"));
2134        std::fs::write(folder("sent-once", "work").join("mine.ics"), event("mine", "Mine")).unwrap();
2135        dav.fault(nth(1, |r| r.method == "REPORT" && r.body.contains("sync-collection"), Fault::Drop));
2136        dav.fault(nth(1, |r| r.method == "PROPFIND" && r.path == "/cal/work/", Fault::Drop));
2137        assert!(sync_one(&client, "sent-once", &home, "work").is_err());
2138        let report = sync_one(&client, "sent-once", &home, "work").unwrap();
2139        assert_eq!((report.sent, report.received, report.conflicts.len()), (0, 1, 0), "{report:?}");
2140        assert_eq!(puts(&dav), ["PUT /cal/work/mine.ics 201"]);
2141        assert_eq!(dav.items("work").len(), 3);
2142    }
2143
2144    /// F1: the answer to a new event's PUT is lost (stored there, the
2145    /// connection dropped): sent again, it is found there, the same: no
2146    /// conflict. Another item at that address is one: the server's kept, yours set aside.
2147    #[test]
2148    fn a_lost_answer_is_not_a_conflict() {
2149        home();
2150        let (dav, home) = Dav::start(&["home"]);
2151        let client = client();
2152        sync_one(&client, "lost", &home, "home").unwrap();
2153        let dir = folder("lost", "home");
2154        std::fs::write(dir.join("new.ics"), event("new", "New")).unwrap();
2155        dav.fault(nth(1, |r| r.method == "PUT", Fault::Lost));
2156        assert!(matches!(sync_one(&client, "lost", &home, "home"), Err(SyncError::Network(_))));
2157        let report = sync_one(&client, "lost", &home, "home").unwrap();
2158        assert!(report.conflicts.is_empty(), "{report:?}");
2159        assert_eq!(dav.items("home").len(), 1);
2160        assert_eq!(std::fs::read_to_string(dir.join("new.ics")).unwrap(), event("new", "New"));
2161
2162        dav.put("home", "clash.ics", &event("theirs", "Theirs"));
2163        std::fs::write(dir.join("clash.ics"), event("mine", "Mine")).unwrap();
2164        let report = sync_one(&client, "lost", &home, "home").unwrap();
2165        assert_eq!(report.conflicts.len(), 1, "{report:?}");
2166        assert!(std::fs::read_to_string(&report.conflicts[0]).unwrap().contains("SUMMARY:Mine"));
2167        assert!(std::fs::read_to_string(dir.join("clash.ics")).unwrap().contains("SUMMARY:Theirs"));
2168        let report = sync_one(&client, "lost", &home, "home").unwrap();
2169        assert_eq!((report.sent, report.received, report.conflicts.len()), (0, 0, 0), "{report:?}");
2170    }
2171
2172    /// A server that only notes what it is asked, and with what login.
2173    #[derive(Default)]
2174    struct Elsewhere {
2175        seen: Mutex<Vec<String>>,
2176    }
2177
2178    impl stand_in::Server for Elsewhere {
2179        fn misbehaves(&self, _: &stand_in::Request) -> Option<Fault> {
2180            None
2181        }
2182
2183        fn answer(&self, request: &stand_in::Request) -> stand_in::Reply {
2184            self.seen.lock().unwrap().push(format!("{} {} {}", request.method, request.path, request.header("Authorization").unwrap_or("")));
2185            stand_in::Reply::new(404, "")
2186        }
2187    }
2188
2189    /// A server whose answers name another server (a calendar there, an item
2190    /// there, as a hostile or broken one would): the login never goes there,
2191    /// the sync says so, and its own calendars sync all the same.
2192    #[test]
2193    fn the_login_stays_on_the_account_s_server() {
2194        let account = account("kept-to");
2195        let elsewhere = Arc::new(Elsewhere::default());
2196        let there = stand_in::serve(elsewhere.clone());
2197        // "mine" holds an item of its own; "only-there" none but the one it names there,
2198        // which a multiget does not bring, so that it is asked for alone.
2199        let (dav, home) = Dav::start(&["mine", "only-there"]);
2200        Homes { calendars: Some(home.clone()), contacts: None }.save(&account.id).unwrap();
2201        dav.put("mine", "new.ics", &event("new", "New here"));
2202        dav.name_elsewhere(&there);
2203        let report = sync_with(&client(), &account).unwrap();
2204        assert!(matches!(report.problems.first(), Some(SyncError::Elsewhere(url)) if url.starts_with(&there)), "{report:?}");
2205        assert!(folder("kept-to", "mine").join("new.ics").exists());
2206        assert!(dav.seen().iter().any(|s| s.starts_with("REPORT /cal/only-there/")), "{:?}", dav.seen());
2207        assert!(elsewhere.seen.lock().unwrap().is_empty(), "{:?}", elsewhere.seen.lock().unwrap());
2208        // Said in words, with the address.
2209        let said = report.problems[0].sentence(&sioul_core::i18n::Translator::new("en"), "Calendars");
2210        assert!(said.starts_with("Calendars: the server named an address on another server") && said.contains(&there), "{said}");
2211        // Its own server's addresses go through, whatever their spelling.
2212        let kept = client();
2213        kept.keep_to(&Homes { calendars: Some(home.clone()), contacts: None });
2214        assert!(kept.mine(&format!("{home}mine/new.ics")).is_ok());
2215        assert!(kept.mine(&home.replacen("http://", "HTTP://", 1)).is_ok());
2216        assert!(kept.mine(&format!("{there}/cal/")).is_err());
2217        assert!(kept.mine("https://127.0.0.1/cal/").is_err());
2218        // Before the homes are known, finding them keeps to the domain started from.
2219        let pointing = stand_in::serve(Arc::new(Pointing(format!("{}/principals/jane/", there.replace("127.0.0.1", "localhost")))));
2220        assert!(matches!(super::home(&client(), Kind::Calendars, &format!("{pointing}/")), Err(SyncError::Elsewhere(url)) if url.contains("localhost")));
2221        assert!(elsewhere.seen.lock().unwrap().is_empty());
2222    }
2223
2224    /// A server whose principal is at the address it is given.
2225    struct Pointing(String);
2226
2227    impl stand_in::Server for Pointing {
2228        fn misbehaves(&self, _: &stand_in::Request) -> Option<Fault> {
2229            None
2230        }
2231
2232        fn answer(&self, _: &stand_in::Request) -> stand_in::Reply {
2233            stand_in::Reply::new(
2234                207,
2235                format!(
2236                    r#"<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/</d:href><d:propstat><d:prop><d:current-user-principal><d:href>{}</d:href></d:current-user-principal></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#,
2237                    self.0
2238                ),
2239            )
2240        }
2241    }
2242
2243    #[test]
2244    fn origins_are_scheme_host_and_port() {
2245        assert_eq!(origin("https://Dav.Example.org/remote.php/dav/").as_deref(), Some("https://dav.example.org:443"));
2246        assert_eq!(origin("https://dav.example.org:443/x"), origin("https://dav.example.org/y?z"));
2247        assert_ne!(origin("https://dav.example.org:8443/"), origin("https://dav.example.org/"));
2248        assert_ne!(origin("http://dav.example.org/"), origin("https://dav.example.org/"));
2249        // A login before the host is not the host.
2250        assert_eq!(origin("https://dav.example.org@other.example.org/").as_deref(), Some("https://other.example.org:443"));
2251        assert_eq!(origin("https://[2001:db8::1]:8443/").as_deref(), Some("https://[2001:db8::1]:8443"));
2252        assert_eq!(origin("https://[2001:db8::1]/").as_deref(), Some("https://[2001:db8::1]:443"));
2253        assert_eq!(origin("/cal/a/"), None);
2254        assert_eq!(origin("https://dav.example.org:x/"), None);
2255        assert_eq!(origin("ftp://dav.example.org/"), None);
2256    }
2257
2258    /// F1: a calendar the server fails on, or whose connection drops, does
2259    /// not keep the others from syncing.
2260    #[test]
2261    fn one_bad_calendar_does_not_stop_the_others() {
2262        let account = account("bad-one");
2263        let (dav, home) = Dav::start(&["a", "b"]);
2264        Homes { calendars: Some(home), contacts: None }.save(&account.id).unwrap();
2265        dav.put("b", "new.ics", &event("new", "New in b"));
2266        dav.fault(nth(1, |r| r.method == "PROPFIND" && r.path == "/cal/a/", Fault::Status(500)));
2267        let report = sync_with(&client(), &account).unwrap();
2268        assert!(matches!(report.problems.first(), Some(SyncError::Server(e)) if e.contains("/cal/a/")), "{report:?}");
2269        assert_eq!((report.listed, report.collections), (2, 1));
2270        assert!(folder("bad-one", "b").join("new.ics").exists());
2271        dav.put("b", "newer.ics", &event("newer", "Newer in b"));
2272        dav.fault(nth(1, |r| r.method == "PROPFIND" && r.path == "/cal/a/", Fault::Drop));
2273        assert!(matches!(sync_with(&client(), &account).unwrap().problems.first(), Some(SyncError::Network(_))));
2274        assert!(folder("bad-one", "b").join("newer.ics").exists());
2275        assert_eq!(sync_with(&client(), &account).unwrap().collections, 2);
2276    }
2277
2278    /// (Each test's calendars named apart: a batch's pace is kept by the
2279    /// calendar's path, in this process, `PACE`; the big one first in the
2280    /// server's order, which is the names'.)
2281    ///
2282    /// The phone's calendars of 21 October 2026: every calendar the server
2283    /// lists is made here (its folder, name and state) before any is pulled,
2284    /// so that each is listed, can be written to and is reached even while
2285    /// a big first one's pull is cut; and the big one has turns, never
2286    /// keeping the others waiting.
2287    #[test]
2288    fn every_listed_calendar_exists_before_any_is_pulled() {
2289        let account = account("listed-first");
2290        let (dav, home) = Dav::start(&["early", "later-a", "later-b"]);
2291        Homes { calendars: Some(home), contacts: None }.save(&account.id).unwrap();
2292        for n in 0..300 {
2293            dav.put("early", &format!("e{n}.ics"), &event(&format!("uid-{n}"), &format!("Event {n}")));
2294        }
2295        dav.put("later-a", "x.ics", &event("x", "In a"));
2296        dav.put("later-b", "y.ics", &event("y", "In b"));
2297        // Each of early's batches slow: its turn ends after one, the others have theirs.
2298        dav.fault(|r: &stand_in::Request| (r.method == "REPORT" && r.path == "/cal/early/" && r.body.contains("multiget")).then_some(Fault::Late(Duration::from_millis(200))));
2299        let report = sync_with(&client(), &account).unwrap();
2300        assert_eq!((report.listed, report.collections, report.registered), (3, 3, 3), "{report:?}");
2301        assert_eq!(sync_with(&client(), &account).unwrap().registered, 0, "made already: nothing new for the pages");
2302        let mine: Vec<_> = vdir::collections(Kind::Calendars).into_iter().filter(|c| c.account == "listed-first").collect();
2303        assert_eq!(mine.len(), 3, "{mine:?}");
2304        for name in ["early", "later-a", "later-b"] {
2305            assert!(vdir::state_path("listed-first", Kind::Calendars, name).exists(), "{name}: its state");
2306        }
2307        assert_eq!(local_files(&folder("listed-first", "early"), Kind::Calendars).len(), 300);
2308        let seen = dav.seen();
2309        let first_a = seen.iter().position(|s| s.starts_with("REPORT /cal/later-a/")).unwrap();
2310        let last_big = seen.iter().rposition(|s| s.starts_with("REPORT /cal/early/")).unwrap();
2311        assert!(first_a < last_big, "a waited for early's whole pull: {seen:?}");
2312    }
2313
2314    /// A first pull cut short (Android killed Sioul, the line dropped): the
2315    /// later calendars are there already, and the next sync loses nothing.
2316    #[test]
2317    fn a_killed_first_pull_does_not_hide_the_others() {
2318        let account = account("killed-first");
2319        let (dav, home) = Dav::start(&["first", "second", "third"]);
2320        Homes { calendars: Some(home), contacts: None }.save(&account.id).unwrap();
2321        for n in 0..120 {
2322            dav.put("first", &format!("e{n}.ics"), &event(&format!("uid-{n}"), &format!("Event {n}")));
2323        }
2324        dav.put("third", "y.ics", &event("y", "In b"));
2325        // Cut at first's first batch, as a process killed then would be: nothing after it ran. At
2326        // that moment, second and third are made already, before any pull (the root of the bug).
2327        let made_before = Arc::new(std::sync::atomic::AtomicBool::new(false));
2328        let seen_made = Arc::clone(&made_before);
2329        dav.fault(move |r: &stand_in::Request| {
2330            if r.method == "REPORT" && r.path == "/cal/first/" && r.body.contains("multiget") && !seen_made.swap(true, std::sync::atomic::Ordering::SeqCst) {
2331                let made = ["second", "third"].iter().all(|name| folder("killed-first", name).join("displayname").exists()) && vdir::state_path("killed-first", Kind::Calendars, "third").exists();
2332                seen_made.store(made, std::sync::atomic::Ordering::SeqCst);
2333                return Some(Fault::Drop);
2334            }
2335            None
2336        });
2337        let report = sync_with(&client(), &account).unwrap();
2338        assert!(matches!(report.problems.first(), Some(SyncError::Network(_))), "{report:?}");
2339        assert!(["first", "second", "third"].iter().all(|name| folder("killed-first", name).is_dir()), "every calendar made before the cut");
2340        assert!(made_before.load(std::sync::atomic::Ordering::SeqCst), "second and third were made before first's first pull");
2341        assert!(folder("killed-first", "third").join("y.ics").exists());
2342        let report = sync_with(&client(), &account).unwrap();
2343        assert!(report.problems.is_empty() && report.collections == 3, "{report:?}");
2344        assert_eq!(local_files(&folder("killed-first", "first"), Kind::Calendars).len(), 120);
2345        assert!(puts(&dav).is_empty(), "nothing sent back: {:?}", dav.seen());
2346    }
2347
2348    /// A lasting error on one calendar (a calendar shared with you refusing
2349    /// your login): that one alone fails, said with the counts; the others
2350    /// sync, and the account is not stopped.
2351    #[test]
2352    fn a_lasting_error_on_one_calendar_keeps_the_others() {
2353        let account = account("lasting-one");
2354        let (dav, home) = Dav::start(&["a", "b", "c"]);
2355        Homes { calendars: Some(home), contacts: None }.save(&account.id).unwrap();
2356        dav.put("a", "x.ics", &event("x", "In a"));
2357        dav.put("c", "z.ics", &event("z", "In c"));
2358        dav.fault(|r: &stand_in::Request| (r.path.starts_with("/cal/b/")).then_some(Fault::Status(401)));
2359        let report = sync_with(&client(), &account).unwrap();
2360        assert_eq!((report.listed, report.collections), (3, 2), "{report:?}");
2361        assert!(matches!(report.problems.as_slice(), [SyncError::Login(_)]), "{report:?}");
2362        assert!(folder("lasting-one", "a").join("x.ics").exists() && folder("lasting-one", "c").join("z.ics").exists());
2363        assert!(folder("lasting-one", "b").is_dir(), "listed, made, to be pulled once it answers");
2364    }
2365
2366    /// Calendars never wait for contacts: an address book failing first, or
2367    /// its home refusing the login, and the calendars sync all the same.
2368    #[test]
2369    fn calendars_do_not_wait_for_contacts() {
2370        let account = account("contacts-first");
2371        let (dav, home) = Dav::start(&["a"]);
2372        let books = home.replace("/cal/", "/book/");
2373        Homes { calendars: Some(home), contacts: Some(books) }.save(&account.id).unwrap();
2374        dav.put("a", "x.ics", &event("x", "In a"));
2375        dav.fault(nth(1, |r| r.path.starts_with("/book/"), Fault::Status(500)));
2376        let report = sync_with(&client(), &account).unwrap();
2377        assert!(folder("contacts-first", "a").join("x.ics").exists(), "{report:?}");
2378        assert!(!report.problems.is_empty(), "the book's failure said");
2379        dav.put("a", "y.ics", &event("y", "Again in a"));
2380        dav.fault(|r: &stand_in::Request| r.path.starts_with("/book/").then_some(Fault::Status(401)));
2381        let result = sync_with(&client(), &account);
2382        assert!(matches!(result, Err(SyncError::Login(_))), "the contacts' home refused: the account's error, {result:?}");
2383        assert!(folder("contacts-first", "a").join("y.ics").exists(), "the calendars synced first all the same");
2384        let seen = dav.seen();
2385        let book = seen.iter().position(|s| s.contains("/book/")).unwrap();
2386        let calendar = seen.iter().position(|s| s.contains("/cal/a/")).unwrap();
2387        assert!(calendar < book, "calendars first: {seen:?}");
2388    }
2389
2390    /// An event moved to another calendar of the same server: the new copy is
2391    /// sent first, and the old one deleted only once the server took it. A
2392    /// refused PUT leaves the old one there, on every device (the review of 22
2393    /// October 2026, F6).
2394    #[test]
2395    fn a_moved_event_is_deleted_there_only_once_its_new_copy_is() {
2396        let account = account("moved-later");
2397        let (dav, home) = Dav::start(&["a", "b"]);
2398        Homes { calendars: Some(home.clone()), contacts: None }.save(&account.id).unwrap();
2399        dav.put("a", "x.ics", &event("x", "Dentist"));
2400        sync_with(&client(), &account).unwrap();
2401        let (from, to) = (folder("moved-later", "a").join("x.ics"), folder("moved-later", "b").join("x-moved.ics"));
2402        assert!(from.exists());
2403        vdir::move_item(&from, &to).unwrap();
2404        let refusing = Arc::new(std::sync::atomic::AtomicBool::new(true));
2405        let refuse = Arc::clone(&refusing);
2406        dav.fault(move |r: &stand_in::Request| (r.method == "PUT" && r.path.starts_with("/cal/b/") && refuse.load(std::sync::atomic::Ordering::Relaxed)).then_some(Fault::Status(403)));
2407        let _ = sync_with(&client(), &account);
2408        assert!(dav.items("a").contains_key("x.ics"), "the old copy stays while the new one is refused: {:?}", dav.seen());
2409        assert!(dav.items("b").is_empty());
2410        refusing.store(false, std::sync::atomic::Ordering::Relaxed);
2411        for _ in 0..2 {
2412            sync_with(&client(), &account).unwrap();
2413        }
2414        assert!(dav.items("a").is_empty(), "then the old one goes: {:?}", dav.seen());
2415        assert_eq!(dav.items("b").len(), 1);
2416        let seen = dav.seen();
2417        let put = seen.iter().position(|s| s.starts_with("PUT /cal/b/") && s.ends_with(" 201")).unwrap();
2418        let delete = seen.iter().position(|s| s.starts_with("DELETE /cal/a/")).unwrap();
2419        assert!(put < delete, "the new copy first: {seen:?}");
2420    }
2421
2422    thread_local! {
2423        /// Item files read on this thread: each test syncs on its own.
2424        pub(super) static READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
2425    }
2426
2427    fn reads() -> usize {
2428        READS.with(std::cell::Cell::get)
2429    }
2430
2431    /// F9: a sync with nothing changed here reads no file, each known by its
2432    /// size and time; a file edited since is read, and sent.
2433    #[test]
2434    fn unchanged_files_are_not_read() {
2435        home();
2436        let (dav, home) = Dav::start(&["many"]);
2437        for n in 0..1000 {
2438            dav.put("many", &format!("e{n}.ics"), &event(&format!("u{n}"), "Same"));
2439        }
2440        let client = client();
2441        sync_one(&client, "unread", &home, "many").unwrap();
2442        // Written by that sync a moment ago, their times are not trusted yet; an hour later they are.
2443        let dir = folder("unread", "many");
2444        let hour_ago = std::time::SystemTime::now() - Duration::from_secs(3600);
2445        for file in local_files(&dir, Kind::Calendars) {
2446            std::fs::File::options().write(true).open(dir.join(file)).unwrap().set_modified(hour_ago).unwrap();
2447        }
2448        sync_one(&client, "unread", &home, "many").unwrap();
2449        let before = reads();
2450        assert_eq!(sync_one(&client, "unread", &home, "many").unwrap().sent, 0);
2451        assert_eq!(reads() - before, 0, "nothing read");
2452        std::fs::write(dir.join("e7.ics"), event("u7", "Edited")).unwrap();
2453        let before = reads();
2454        assert_eq!(sync_one(&client, "unread", &home, "many").unwrap().sent, 1);
2455        assert_eq!(reads() - before, 1, "the edited file only");
2456        assert!(dav.items("many")["e7.ics"].contains("SUMMARY:Edited"));
2457    }
2458
2459    /// F7: an answer that keeps coming, however slowly, is read to its end
2460    /// (here over six times the stall limit); one that stops coming is given
2461    /// up after one stall, not after the whole budget.
2462    #[test]
2463    fn a_slow_answer_is_read_while_it_moves() {
2464        home();
2465        let (dav, home) = Dav::start(&["slow"]);
2466        for n in 0..40 {
2467            dav.put("slow", &format!("e{n}.ics"), &event(&format!("u{n}"), "Slow"));
2468        }
2469        let budget = Budget { connect: Duration::from_secs(5), stall: Duration::from_millis(500), whole: Duration::from_secs(60) };
2470        let client = Client { agent: agent(&budget), ..client() };
2471        dav.fault(nth(1, |r| r.body.contains("multiget"), Fault::Trickle(Duration::from_secs(3))));
2472        let started = std::time::Instant::now();
2473        assert_eq!(sync_one(&client, "slow", &home, "slow").unwrap().received, 40);
2474        assert!(started.elapsed() > Duration::from_secs(3));
2475        dav.put("slow", "late.ics", &event("late", "Late"));
2476        dav.fault(nth(1, |r| r.body.contains("sync-collection"), Fault::Late(Duration::from_secs(5))));
2477        dav.fault(nth(1, |r| r.method == "PROPFIND" && r.path == "/cal/slow/", Fault::Late(Duration::from_secs(5))));
2478        let started = std::time::Instant::now();
2479        assert!(matches!(sync_one(&client, "slow", &home, "slow"), Err(SyncError::Network(_))));
2480        assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed());
2481    }
2482
2483    /// Item 12 of 9 October: a slow multiget halves the next batch of its
2484    /// collection, a cut one too (at the next sync), and quick answers grow
2485    /// it back, twice as large each time, to 50.
2486    #[test]
2487    fn batches_follow_the_answers() {
2488        home();
2489        let (dav, home) = Dav::start(&["paced", "cut"]);
2490        for n in 0..200 {
2491            dav.put("paced", &format!("e{n}.ics"), &event(&format!("p{n}"), "Paced"));
2492        }
2493        for n in 0..120 {
2494            dav.put("cut", &format!("e{n}.ics"), &event(&format!("c{n}"), "Cut"));
2495        }
2496        // How many items each multiget asks for, by calendar.
2497        let asked = std::sync::Arc::new(Mutex::new(Vec::<(String, usize)>::new()));
2498        let noted = std::sync::Arc::clone(&asked);
2499        dav.fault(move |r| {
2500            if r.body.contains("multiget") {
2501                noted.lock().unwrap().push((r.path.clone(), r.body.matches("<d:href>").count()));
2502            }
2503            None
2504        });
2505        let of = |calendar: &str| asked.lock().unwrap().iter().filter(|(path, _)| *path == format!("/cal/{calendar}/")).map(|(_, n)| *n).collect::<Vec<_>>();
2506        dav.fault(nth(1, |r| r.path == "/cal/paced/" && r.body.contains("multiget"), Fault::Late(SLOW + Duration::from_millis(300))));
2507        let client = client();
2508        assert_eq!(sync_one(&client, "paced", &home, "paced").unwrap().received, 200);
2509        assert_eq!(of("paced"), [50, 25, 50, 50, 25]);
2510        dav.fault(nth(1, |r| r.path == "/cal/cut/" && r.body.contains("multiget"), Fault::Drop));
2511        assert!(matches!(sync_one(&client, "paced-cut", &home, "cut"), Err(SyncError::Network(_))));
2512        assert_eq!(sync_one(&client, "paced-cut", &home, "cut").unwrap().received, 120);
2513        assert_eq!(of("cut"), [50, 25, 50, 45]);
2514    }
2515
2516    /// Item 5 of 9 October: an invitation answered on two devices. The phone
2517    /// answered first: its copy is on the server. The computer's, sent as new
2518    /// under another name before its sync brought the phone's, meets that
2519    /// UID there (403 or 409 naming the other item, RFC 4791; Nextcloud's 400
2520    /// or a 412 naming none: found by a query): the server's item takes its
2521    /// place, in one file, sent no more. The same answer is said nowhere;
2522    /// another answer, the server's is kept, and said.
2523    #[test]
2524    fn an_invitation_answered_on_two_devices() {
2525        home();
2526        let invitation = |uid: &str, answer: &str| {
2527            format!(
2528                "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sioul//tests//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261005T120000Z\r\nDTSTART:20261012T090000Z\r\nDTEND:20261012T100000Z\r\nSUMMARY:Review\r\nORGANIZER;CN=Jane:mailto:jane@example.org\r\nATTENDEE;CN=Me;PARTSTAT={answer}:mailto:me@example.net\r\nATTENDEE;CN=Paul:mailto:paul@example.org\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
2529            )
2530        };
2531        for (n, (status, named)) in [(403, true), (409, true), (400, false), (412, false)].into_iter().enumerate() {
2532            let (dav, home) = Dav::start(&["invited"]);
2533            dav.one_uid_each(status, named);
2534            let (client, account) = (client(), format!("invited-{n}"));
2535            sync_one(&client, &account, &home, "invited").unwrap();
2536            let dir = folder(&account, "invited");
2537            // Accepted on the phone, then on the computer.
2538            dav.put("invited", "phone.ics", &invitation("review-1", "ACCEPTED"));
2539            std::fs::write(dir.join("desk.ics"), invitation("review-1", "ACCEPTED")).unwrap();
2540            let report = sync_one(&client, &account, &home, "invited").unwrap();
2541            assert!(report.answered.is_empty() && report.conflicts.is_empty(), "{status}: {report:?}");
2542            assert_eq!(local_files(&dir, Kind::Calendars), BTreeSet::from(["desk.ics".to_string()]), "{status}: one file, under its name here");
2543            assert_eq!(dav.items("invited").len(), 1, "{status}");
2544            let again = sync_one(&client, &account, &home, "invited").unwrap();
2545            assert_eq!((again.sent, again.received), (0, 0), "{status}: settled: {again:?}");
2546            // Maybe on the phone, accepted on the computer: the phone's answer is kept here, and said.
2547            dav.put("invited", "phone-2.ics", &invitation("review-2", "TENTATIVE"));
2548            std::fs::write(dir.join("desk-2.ics"), invitation("review-2", "ACCEPTED")).unwrap();
2549            let report = sync_one(&client, &account, &home, "invited").unwrap();
2550            assert_eq!(report.answered, [("Review".to_string(), "TENTATIVE".to_string())], "{status}: {report:?}");
2551            assert!(report.conflicts.is_empty(), "{status}: {report:?}");
2552            assert!(std::fs::read_to_string(dir.join("desk-2.ics")).unwrap().contains("PARTSTAT=TENTATIVE:mailto:me@example.net"), "{status}");
2553            assert_eq!(local_files(&dir, Kind::Calendars).len(), 2, "{status}");
2554            assert!(dav.items("invited")["phone-2.ics"].contains("PARTSTAT=TENTATIVE"), "{status}: the server's left as it was");
2555            assert!(!puts(&dav).iter().any(|p| p.ends_with(" 201") || p.ends_with(" 204")), "{status}: nothing written there: {:?}", dav.seen());
2556        }
2557    }
2558
2559    /// A2: after a refused password the watcher waits, parked, instead of
2560    /// ending: nudged with the same password kept, it does not try again
2561    /// (retries can lock an account), unless "Sync now" asks; with another
2562    /// password, it does at once.
2563    #[test]
2564    fn a_refused_password_parks_the_watcher() {
2565        let control = Control::default();
2566        let syncs = std::sync::atomic::AtomicUsize::new(0);
2567        let kept = std::sync::Mutex::new("old".to_string());
2568        let (said, heard) = std::sync::mpsc::channel();
2569        std::thread::scope(|s| {
2570            s.spawn(|| {
2571                let sync = || match syncs.fetch_add(1, std::sync::atomic::Ordering::Relaxed) {
2572                    0 | 1 => Err(SyncError::Login("PROPFIND: 401".into())),
2573                    _ => Ok(Report::default()),
2574                };
2575                watch_with(&control, Duration::from_secs(3600), sync, || Some(kept.lock().unwrap().clone()), |result| said.send(result.is_ok()).unwrap());
2576            });
2577            assert_eq!(heard.recv_timeout(Duration::from_secs(5)), Ok(false), "refused");
2578            control.nudge();
2579            assert!(heard.recv_timeout(Duration::from_millis(300)).is_err(), "the same password: parked");
2580            control.retry();
2581            assert_eq!(heard.recv_timeout(Duration::from_secs(5)), Ok(false), "Sync now: tried, refused again");
2582            *kept.lock().unwrap() = "new".into();
2583            control.nudge();
2584            assert_eq!(heard.recv_timeout(Duration::from_secs(5)), Ok(true), "another: synced at once");
2585            control.stop();
2586        });
2587        assert_eq!(syncs.load(std::sync::atomic::Ordering::Relaxed), 3);
2588    }
2589
2590    /// F8: an event deleted there while changed here: your version is set
2591    /// aside before its file goes, never lost.
2592    #[test]
2593    fn a_change_to_an_item_gone_there_is_set_aside() {
2594        home();
2595        let (dav, home) = Dav::start(&["gone"]);
2596        dav.put("gone", "x.ics", &event("x", "Before"));
2597        let client = client();
2598        sync_one(&client, "gone-edited", &home, "gone").unwrap();
2599        let file = folder("gone-edited", "gone").join("x.ics");
2600        dav.delete("gone", "x.ics");
2601        std::fs::write(&file, event("x", "Edited here")).unwrap();
2602        let report = sync_one(&client, "gone-edited", &home, "gone").unwrap();
2603        assert_eq!((report.removed, report.conflicts.len()), (1, 1), "{report:?}");
2604        assert!(std::fs::read_to_string(&report.conflicts[0]).unwrap().contains("SUMMARY:Edited here"));
2605        assert!(!file.exists());
2606        assert!(state_of("gone-edited", "gone").items.is_empty());
2607    }
2608
2609    /// F5: two syncs of one account at once (the window's watcher and `sioul
2610    /// task add`) take turns: a new event is sent once, and known once.
2611    #[test]
2612    fn one_sync_of_an_account_at_a_time() {
2613        let account = account("one-at-a-time");
2614        let (dav, home) = Dav::start(&["a"]);
2615        Homes { calendars: Some(home), contacts: None }.save(&account.id).unwrap();
2616        sync_with(&client(), &account).unwrap();
2617        std::fs::write(folder("one-at-a-time", "a").join("new.ics"), event("new", "New")).unwrap();
2618        dav.fault(|r| (r.method == "PUT").then_some(Fault::Late(Duration::from_millis(300))));
2619        let both: Vec<_> = (0..2)
2620            .map(|_| {
2621                let account = account.clone();
2622                std::thread::spawn(move || sync_with(&client(), &account))
2623            })
2624            .collect();
2625        for sync in both {
2626            sync.join().unwrap().unwrap();
2627        }
2628        assert_eq!(puts(&dav), ["PUT /cal/a/new.ics 201"]);
2629        assert_eq!(state_of("one-at-a-time", "a").items.iter().filter(|i| i.file == "new.ics").count(), 1);
2630    }
2631
2632    /// F1: a push cut by the network keeps its place: the items sent before
2633    /// carry their new ETags, the others are still known as they were.
2634    #[test]
2635    fn a_cut_push_keeps_its_place() {
2636        home();
2637        let (dav, home) = Dav::start(&["four"]);
2638        for n in 0..4 {
2639            dav.put("four", &format!("e{n}.ics"), &event(&format!("u{n}"), "Before"));
2640        }
2641        let client = client();
2642        sync_one(&client, "cut-push", &home, "four").unwrap();
2643        let dir = folder("cut-push", "four");
2644        for n in 0..4 {
2645            std::fs::write(dir.join(format!("e{n}.ics")), event(&format!("u{n}"), "After")).unwrap();
2646        }
2647        let before = state_of("cut-push", "four").items;
2648        dav.fault(nth(3, |r| r.method == "PUT", Fault::Drop));
2649        let mut state = state_of("cut-push", "four");
2650        let (mut refetch, mut report) = (BTreeSet::new(), Report::default());
2651        assert!(push(&client, Kind::Calendars, &dir, &listed(&client, &home, "four"), &mut state, &mut refetch, &mut report).is_err());
2652        assert_eq!(state.items.len(), 4, "every item still known: {:?}", state.items);
2653        for (now, then) in state.items.iter().zip(&before).take(2) {
2654            assert_ne!(now.etag, then.etag);
2655            assert_eq!(Some(now.hash.clone()), hash_of(&dir.join(&now.file)));
2656        }
2657        assert_eq!(state.items[2..], before[2..]);
2658    }
2659
2660    #[test]
2661    fn reads_a_multistatus() {
2662        let xml = r#"<?xml version="1.0"?>
2663<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:x="http://apple.com/ns/ical/">
2664 <d:response><d:href>/dav/calendars/jane/personal/</d:href>
2665  <d:propstat><d:prop><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype><d:displayname>Personal</d:displayname>
2666   <x:calendar-color>#4C6B5CFF</x:calendar-color><cal:supported-calendar-component-set><cal:comp name="VEVENT"/></cal:supported-calendar-component-set>
2667   <d:current-user-privilege-set><d:privilege><d:read/></d:privilege></d:current-user-privilege-set></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
2668  <d:propstat><d:prop><d:getctag/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response>
2669 <d:response><d:href>/dav/calendars/jane/personal/gone.ics</d:href><d:status>HTTP/1.1 404 Not Found</d:status></d:response>
2670 <d:sync-token>http://example.org/sync/42</d:sync-token>
2671</d:multistatus>"#;
2672        let (responses, token) = multistatus(xml).unwrap();
2673        assert_eq!(token.as_deref(), Some("http://example.org/sync/42"));
2674        assert!(responses[0].is(CALDAV, "calendar"));
2675        assert_eq!(responses[0].text(DAV, "displayname").as_deref(), Some("Personal"));
2676        assert_eq!(responses[0].prop(CALDAV, "supported-calendar-component-set").unwrap().comps, vec!["VEVENT"]);
2677        assert!(responses[0].prop(CALSERVER, "getctag").is_none());
2678        assert_eq!(responses[1].status, Some(404));
2679    }
2680
2681    #[test]
2682    fn addresses_and_names() {
2683        assert_eq!(absolute("https://h.example/remote.php/dav/", "/remote.php/dav/principals/jane/"), "https://h.example/remote.php/dav/principals/jane/");
2684        assert_eq!(absolute("https://h.example/a/b/", "c.ics"), "https://h.example/a/b/c.ics");
2685        assert_eq!(path_key("https://h.example/a%20b/c.ics"), "/a b/c.ics");
2686        assert_eq!(path_key("/a/b/"), "/a/b");
2687        let taken: BTreeSet<String> = ["abc.ics".to_string()].into_iter().collect();
2688        assert_eq!(file_name("/cal/abc.ics", "ics", &taken), "abc-2.ics");
2689        assert_eq!(file_name("/cal/a%20b:c.ics", "ics", &BTreeSet::new()), "a_b_c.ics");
2690        assert_eq!(file_name("/cal/..hidden.ics", "ics", &BTreeSet::new()), "hidden.ics", "never a hidden file");
2691        assert_eq!(file_name("/book/CON.vcf", "vcf", &BTreeSet::new()), "_CON.vcf", "nor a name Windows keeps");
2692        assert_eq!(file_name("/book/com1.x.vcf", "vcf", &BTreeSet::new()), "_com1.x.vcf");
2693        assert_eq!(url_segment("sioul-1.vcf"), "sioul-1.vcf");
2694        assert!(allowed("http://example.org/").is_err());
2695        // Redirects carry the password: followed within the domain asked only.
2696        assert_eq!(domain("https://caldav.example.org:8443/dav/"), domain("https://example.org/.well-known/caldav"));
2697        assert_ne!(domain("https://parking.example.net/x"), domain("https://example.org/.well-known/caldav"));
2698        assert_eq!(xml_escape(r#"a<"b"&"#), "a&lt;&quot;b&quot;&amp;");
2699    }
2700}