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