1use 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
49const BATCH: usize = 50;
51
52const SLOW: Duration = if cfg!(test) { Duration::from_millis(600) } else { Duration::from_secs(20) };
58
59const QUICK: Duration = if cfg!(test) { Duration::from_millis(300) } else { Duration::from_secs(5) };
62
63static PACE: Mutex<BTreeMap<String, usize>> = Mutex::new(BTreeMap::new());
68
69fn batch_of(url: &str) -> usize {
71 PACE.lock().ok().and_then(|pace| pace.get(&path_key(url)).copied()).unwrap_or(BATCH)
72}
73
74fn 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
87pub struct Client {
89 agent: ureq::Agent,
90 authorization: Mutex<String>,
91 google: Option<String>,
94 own: Mutex<Option<Vec<String>>>,
98}
99
100struct Answer {
102 status: u16,
103 etag: Option<String>,
104 location: Option<String>,
105 body: String,
106}
107
108pub(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 crate::stalls::agent(config(budget), budget.stall, false)
123}
124
125pub(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 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 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 pub(crate) fn keep_to_origin(&self, server: &str) {
181 *self.own.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(origin(server).into_iter().collect());
182 }
183
184 fn mine(&self, url: &str) -> Result<(), SyncError> {
187 match &*self.own.lock().unwrap_or_else(std::sync::PoisonError::into_inner) {
188 Some(own) if !origin(url).is_some_and(|o| own.contains(&o)) => Err(SyncError::Elsewhere(url.to_string())),
189 _ => Ok(()),
190 }
191 }
192
193 fn send(&self, method: &str, url: &str, headers: &[(&str, &str)], body: Option<&str>) -> Result<Answer, SyncError> {
194 match (self.send_once(method, url, headers, body), &self.google) {
195 (Err(SyncError::Login(_)), Some(address)) => {
197 let token = crate::google::access_token(address, true)?;
198 if let Ok(mut authorization) = self.authorization.lock() {
199 *authorization = format!("Bearer {token}");
200 }
201 self.send_once(method, url, headers, body)
202 }
203 (result, _) => result,
204 }
205 }
206
207 fn send_once(&self, method: &str, url: &str, headers: &[(&str, &str)], body: Option<&str>) -> Result<Answer, SyncError> {
208 allowed(url)?;
209 self.mine(url)?;
210 let authorization = self.authorization.lock().map(|a| a.clone()).unwrap_or_default();
211 let mut request = ureq::http::Request::builder().method(method).uri(url).header("Authorization", &authorization).header("User-Agent", "Sioul");
212 for (name, value) in headers {
213 request = request.header(*name, *value);
214 }
215 let request = request.body(body.unwrap_or("").to_string()).map_err(|e| SyncError::Server(e.to_string()))?;
216 let mut response = self.agent.run(request).map_err(|e| SyncError::Network(e.to_string()))?;
217 let header = |name: &str| response.headers().get(name).and_then(|v| v.to_str().ok()).map(str::to_string);
218 let (etag, location) = (header("ETag"), header("Location"));
219 let status = response.status().as_u16();
220 let body = response.body_mut().with_config().limit(64 * 1024 * 1024).read_to_string().unwrap_or_default();
221 if status == 401 {
222 return Err(SyncError::Login(format!("{method} {url}: 401")));
223 }
224 Ok(Answer { status, etag, location, body })
225 }
226
227 fn propfind(&self, url: &str, depth: &str, props: &str) -> Result<(u16, Vec<Response>), SyncError> {
228 let body = format!(
229 r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:" xmlns:c="{CALDAV}" xmlns:a="{CARDDAV}" xmlns:cs="{CALSERVER}" xmlns:ic="{APPLE}"><d:prop>{props}</d:prop></d:propfind>"#
230 );
231 let answer = self.send("PROPFIND", url, &[("Depth", depth), ("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
232 if answer.status != 207 {
233 return Ok((answer.status, Vec::new()));
234 }
235 Ok((answer.status, multistatus(&answer.body)?.0))
236 }
237}
238
239pub(crate) fn allowed(url: &str) -> Result<(), SyncError> {
241 if url.starts_with("https://") {
242 return Ok(());
243 }
244 #[cfg(feature = "insecure-test-tls")]
245 if std::env::var_os("SIOUL_TEST_INSECURE_TLS").is_some() && (url.starts_with("http://localhost") || url.starts_with("http://127.0.0.1")) {
246 return Ok(());
247 }
248 #[cfg(test)]
250 if url.starts_with("http://127.0.0.1:") {
251 return Ok(());
252 }
253 Err(SyncError::Tls(format!("{url}: not encrypted")))
254}
255
256#[derive(Debug, Default, Clone)]
258pub(crate) struct Response {
259 pub(crate) href: String,
260 pub(crate) status: Option<u16>,
262 pub(crate) props: Vec<Prop>,
263}
264
265#[derive(Debug, Default, Clone)]
267pub(crate) struct Prop {
268 ns: String,
269 name: String,
270 status: u16,
271 text: String,
272 pub(crate) hrefs: Vec<String>,
273 children: Vec<(String, String)>,
275 comps: Vec<String>,
277}
278
279impl Response {
280 pub(crate) fn prop(&self, ns: &str, name: &str) -> Option<&Prop> {
281 self.props.iter().find(|p| p.ns == ns && p.name == name && p.status / 100 == 2)
282 }
283
284 pub(crate) fn text(&self, ns: &str, name: &str) -> Option<String> {
285 self.prop(ns, name).map(|p| p.text.trim().to_string()).filter(|t| !t.is_empty())
286 }
287
288 pub(crate) fn is(&self, ns: &str, kind: &str) -> bool {
289 self.prop(DAV, "resourcetype").is_some_and(|p| p.children.iter().any(|(n, k)| n == ns && k == kind))
290 }
291}
292
293fn status_code(text: &str) -> Option<u16> {
294 text.split_whitespace().nth(1).and_then(|c| c.parse().ok())
295}
296
297pub(crate) fn multistatus(xml: &str) -> Result<(Vec<Response>, Option<String>), SyncError> {
299 let doc = roxmltree::Document::parse(xml).map_err(|e| SyncError::Server(format!("XML: {e}")))?;
300 let mut responses = Vec::new();
301 let mut token = None;
302 for node in doc.root_element().children().filter(roxmltree::Node::is_element) {
303 if node.has_tag_name((DAV, "sync-token")) {
304 token = node.text().map(|t| t.trim().to_string());
305 continue;
306 }
307 if !node.has_tag_name((DAV, "response")) {
308 continue;
309 }
310 let mut response = Response::default();
311 for part in node.children().filter(roxmltree::Node::is_element) {
312 if part.has_tag_name((DAV, "href")) {
313 response.href = part.text().unwrap_or("").trim().to_string();
314 } else if part.has_tag_name((DAV, "status")) {
315 response.status = status_code(part.text().unwrap_or(""));
316 } else if part.has_tag_name((DAV, "propstat")) {
317 let status = part.children().find(|c| c.has_tag_name((DAV, "status"))).and_then(|s| status_code(s.text().unwrap_or(""))).unwrap_or(200);
318 for prop in part.children().filter(|c| c.has_tag_name((DAV, "prop"))).flat_map(|p| p.children().filter(roxmltree::Node::is_element)) {
319 let tag = prop.tag_name();
320 let elements: Vec<roxmltree::Node> = prop.descendants().filter(|d| d.is_element() && *d != prop).collect();
321 response.props.push(Prop {
322 ns: tag.namespace().unwrap_or("").to_string(),
323 name: tag.name().to_string(),
324 status,
325 text: prop.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect::<String>(),
326 hrefs: elements.iter().filter(|d| d.has_tag_name((DAV, "href"))).filter_map(|d| d.text()).map(|t| t.trim().to_string()).collect(),
327 children: elements.iter().map(|d| (d.tag_name().namespace().unwrap_or("").to_string(), d.tag_name().name().to_string())).collect(),
328 comps: elements.iter().filter(|d| d.tag_name().name() == "comp").filter_map(|d| d.attribute("name")).map(str::to_string).collect(),
329 });
330 }
331 }
332 }
333 responses.push(response);
334 }
335 Ok((responses, token))
336}
337
338pub fn origin(url: &str) -> Option<String> {
342 let (scheme, rest) = url.split_once("://")?;
343 let scheme = scheme.to_ascii_lowercase();
344 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
345 let at = authority.rsplit_once('@').map_or(authority, |(_, host)| host);
347 let (host, port) = match at.rsplit_once(':') {
348 Some((host, port)) if !port.contains(']') => (host, port.parse::<u16>().ok()?),
350 _ => (at, if scheme == "https" { 443 } else if scheme == "http" { 80 } else { return None }),
351 };
352 (!host.is_empty()).then(|| format!("{scheme}://{}:{port}", host.to_ascii_lowercase()))
353}
354
355pub fn absolute(base: &str, href: &str) -> String {
359 if href.starts_with("http://") || href.starts_with("https://") {
360 return href.to_string();
361 }
362 let origin_end = base.find("://").map_or(0, |i| i + 3);
363 let origin = match base[origin_end..].find('/') {
364 Some(slash) => &base[..origin_end + slash],
365 None => base,
366 };
367 if href.starts_with('/') {
368 return format!("{origin}{href}");
369 }
370 let directory = base.rsplit_once('/').map_or(base, |(d, _)| d);
371 format!("{directory}/{href}")
372}
373
374pub(crate) fn path_key(url: &str) -> String {
376 let path = url.find("://").and_then(|i| url[i + 3..].find('/').map(|j| &url[i + 3 + j..])).unwrap_or(url);
377 let mut out = Vec::with_capacity(path.len());
378 let bytes = path.as_bytes();
379 let mut i = 0;
380 while i < bytes.len() {
381 if bytes[i] == b'%'
382 && let Some(byte) = path.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok())
383 {
384 out.push(byte);
385 i += 3;
386 continue;
387 }
388 out.push(bytes[i]);
389 i += 1;
390 }
391 let decoded = String::from_utf8_lossy(&out).to_string();
392 decoded.trim_end_matches('/').to_string()
393}
394
395fn candidates(kind: Kind, given: Option<&str>, address: &str, mail_host: Option<&str>) -> Vec<String> {
398 let mut out: Vec<String> = given.map(|g| vec![g.trim().to_string()]).unwrap_or_default();
399 let domain = address.rsplit_once('@').map(|(_, d)| d.to_ascii_lowercase()).unwrap_or_default();
400 let service = match kind {
401 Kind::Calendars => "caldav",
402 Kind::Contacts => "carddav",
403 };
404 if !domain.is_empty() {
405 out.push(format!("https://{domain}/.well-known/{service}"));
406 out.push(format!("https://{service}.{domain}/.well-known/{service}"));
407 }
408 let known: &[(&str, &str, &str)] = &[
409 ("murena.io", "https://murena.io/remote.php/dav/", "https://murena.io/remote.php/dav/"),
410 ("e.email", "https://murena.io/remote.php/dav/", "https://murena.io/remote.php/dav/"),
411 ("fastmail.com", "https://caldav.fastmail.com/dav/", "https://carddav.fastmail.com/dav/"),
412 ("icloud.com", "https://caldav.icloud.com/", "https://contacts.icloud.com/"),
413 ("me.com", "https://caldav.icloud.com/", "https://contacts.icloud.com/"),
414 ("posteo.de", "https://posteo.de:8443/", "https://posteo.de:8843/"),
415 ("mailbox.org", "https://dav.mailbox.org/", "https://dav.mailbox.org/"),
416 ];
417 if let Some((_, calendars, contacts)) = known.iter().find(|(d, _, _)| *d == domain) {
418 out.push(match kind {
419 Kind::Calendars => calendars.to_string(),
420 Kind::Contacts => contacts.to_string(),
421 });
422 }
423 if let Some(host) = mail_host {
425 out.push(format!("https://{host}:2080/.well-known/{service}"));
426 out.push(format!("https://{host}/.well-known/{service}"));
427 }
428 out.dedup();
429 out
430}
431
432#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
434pub struct Homes {
435 #[serde(default)]
436 pub calendars: Option<String>,
437 #[serde(default)]
438 pub contacts: Option<String>,
439}
440
441fn homes_path(account: &str) -> PathBuf {
442 state_dir().join("dav").join(account).join("homes.toml")
443}
444
445impl Homes {
446 pub fn load(account: &str) -> Homes {
447 std::fs::read_to_string(homes_path(account)).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
448 }
449
450 pub fn save(&self, account: &str) -> Result<(), SyncError> {
451 let path = homes_path(account);
452 let fail = |e: std::io::Error| SyncError::Disk(format!("{}: {e}", path.display()));
453 if let Some(parent) = path.parent() {
454 std::fs::create_dir_all(parent).map_err(fail)?;
455 }
456 std::fs::write(&path, toml::to_string(self).map_err(|e| SyncError::Disk(e.to_string()))?).map_err(fail)
457 }
458
459 pub fn is_empty(&self) -> bool {
460 self.calendars.is_none() && self.contacts.is_none()
461 }
462}
463
464pub fn discover(client: &Client, address: &str, given: Option<&str>, mail_host: Option<&str>) -> Result<Homes, SyncError> {
467 if client.is_google() {
468 let homes = Homes { calendars: home(client, Kind::Calendars, &crate::google::caldav_start(address))?, contacts: home(client, Kind::Contacts, crate::google::CARDDAV_START)? };
470 return if homes.is_empty() { Err(SyncError::NotFound("google.com".into())) } else { Ok(homes) };
471 }
472 let mut homes = Homes::default();
473 let mut last_error = None;
474 for kind in [Kind::Calendars, Kind::Contacts] {
475 for start in candidates(kind, given, address, mail_host) {
476 match home(client, kind, &start) {
477 Ok(Some(found)) => {
478 match kind {
479 Kind::Calendars => homes.calendars = Some(found),
480 Kind::Contacts => homes.contacts = Some(found),
481 }
482 break;
483 }
484 Ok(None) => {}
485 Err(e @ SyncError::Login(_)) => return Err(e),
487 Err(e) => last_error = Some(e),
488 }
489 }
490 }
491 if homes.is_empty() {
492 let domain = address.rsplit_once('@').map_or(address, |(_, d)| d).to_string();
493 return Err(last_error.unwrap_or(SyncError::NotFound(domain)));
494 }
495 Ok(homes)
496}
497
498pub(crate) fn discover_on(client: &Client, server: &str) -> Result<Homes, SyncError> {
505 let server = server.trim_end_matches('/');
506 let Some(origin) = origin(server) else { return Err(SyncError::NotFound(server.to_string())) };
507 client.keep_to_origin(server);
508 let root = server.split_once("://").and_then(|(scheme, rest)| rest.split('/').next().map(|host| format!("{scheme}://{host}"))).unwrap_or_else(|| server.to_string());
509 let mut homes = Homes::default();
510 let mut last_error = None;
511 for kind in [Kind::Calendars, Kind::Contacts] {
512 let service = match kind {
513 Kind::Calendars => "caldav",
514 Kind::Contacts => "carddav",
515 };
516 for start in [format!("{server}/remote.php/dav/"), format!("{root}/.well-known/{service}")] {
517 match home(client, kind, &start) {
518 Ok(Some(found)) if self::origin(&found).as_deref() == Some(origin.as_str()) => {
519 match kind {
520 Kind::Calendars => homes.calendars = Some(found),
521 Kind::Contacts => homes.contacts = Some(found),
522 }
523 break;
524 }
525 Ok(_) => {}
526 Err(e @ SyncError::Login(_)) => return Err(e),
527 Err(e) => last_error = Some(e),
528 }
529 }
530 }
531 if homes.is_empty() {
532 return Err(last_error.unwrap_or_else(|| SyncError::NotFound(crate::remote::host_of(server))));
533 }
534 Ok(homes)
535}
536
537fn domain(url: &str) -> String {
539 sioul_core::sites::domain_of(&sioul_core::sites::host_of(url))
540}
541
542fn home(client: &Client, kind: Kind, start: &str) -> Result<Option<String>, SyncError> {
544 let mut url = start.to_string();
545 for _ in 0..5 {
551 let answer = client.send("PROPFIND", &url, &[("Depth", "0"), ("Content-Type", "application/xml; charset=utf-8")], Some(PRINCIPAL))?;
552 match answer.status {
553 301 | 302 | 303 | 307 | 308 => match answer.location.map(|location| absolute(&url, &location)) {
554 Some(next) if client.is_google() || domain(&next) == domain(start) => url = next,
555 _ => return Ok(None),
556 },
557 207 => {
558 let (responses, _) = multistatus(&answer.body)?;
559 let principal = responses
561 .iter()
562 .find_map(|r| r.prop(DAV, "current-user-principal").and_then(|p| p.hrefs.first().cloned()))
563 .map_or_else(|| url.clone(), |href| absolute(&url, &href));
564 let ours = |next: &str| if client.is_google() || domain(next) == domain(start) { Ok(()) } else { Err(SyncError::Elsewhere(next.to_string())) };
568 ours(&principal)?;
569 let (ns, name) = match kind {
570 Kind::Calendars => (CALDAV, "calendar-home-set"),
571 Kind::Contacts => (CARDDAV, "addressbook-home-set"),
572 };
573 let prop = match kind {
574 Kind::Calendars => "<c:calendar-home-set/>",
575 Kind::Contacts => "<a:addressbook-home-set/>",
576 };
577 let (_, responses) = client.propfind(&principal, "0", prop)?;
578 let home = responses.iter().find_map(|r| r.prop(ns, name).and_then(|p| p.hrefs.first().cloned())).map(|href| absolute(&principal, &href));
579 if let Some(home) = &home {
580 ours(home)?;
581 }
582 return Ok(home);
583 }
584 _ => return Ok(None),
585 }
586 }
587 Ok(None)
588}
589
590const PRINCIPAL: &str = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>"#;
591
592#[derive(Debug, Clone, PartialEq, Eq)]
594pub struct Listed {
595 pub kind: Kind,
596 pub url: String,
597 pub id: String,
598 pub name: String,
599 pub color: Option<String>,
600 pub read_only: bool,
601 pub ctag: Option<String>,
602 pub sync_token: Option<String>,
603 pub components: Vec<String>,
605}
606
607pub fn collections(client: &Client, kind: Kind, home: &str) -> Result<Vec<Listed>, SyncError> {
609 let props = "<d:resourcetype/><d:displayname/><cs:getctag/><d:sync-token/><d:current-user-privilege-set/><ic:calendar-color/><c:supported-calendar-component-set/>";
610 let (status, responses) = client.propfind(home, "1", props)?;
611 if status != 207 {
612 return Err(SyncError::Server(format!("PROPFIND {home}: {status}")));
613 }
614 let (ns, wanted) = match kind {
615 Kind::Calendars => (CALDAV, "calendar"),
616 Kind::Contacts => (CARDDAV, "addressbook"),
617 };
618 let mut found: Vec<Listed> = Vec::new();
619 for response in responses.iter().filter(|r| r.is(ns, wanted)) {
620 let url = absolute(home, &response.href);
621 let privileges = response.prop(DAV, "current-user-privilege-set");
622 let writable = privileges.is_none_or(|p| p.children.iter().any(|(n, k)| n == DAV && matches!(k.as_str(), "write" | "write-content" | "all" | "bind")));
623 let mut id = vdir::folder_id(&response.href);
624 while found.iter().any(|l| l.id == id) {
626 id.push('_');
627 }
628 found.push(Listed {
629 kind,
630 name: response.text(DAV, "displayname").unwrap_or_else(|| id.clone()),
631 color: response.text(APPLE, "calendar-color").map(|c| c.chars().take(7).collect()),
632 read_only: !writable,
633 ctag: response.text(CALSERVER, "getctag"),
634 sync_token: response.text(DAV, "sync-token"),
635 components: response.prop(CALDAV, "supported-calendar-component-set").map(|p| p.comps.clone()).unwrap_or_default(),
636 url,
637 id,
638 });
639 }
640 Ok(found)
641}
642
643#[derive(Debug, Clone, Default, PartialEq, Eq)]
645pub struct Report {
646 pub sent: usize,
648 pub received: usize,
650 pub removed: usize,
651 pub conflicts: Vec<PathBuf>,
653 pub collections: usize,
655 pub listed: usize,
657 pub calendars_listed: usize,
659 pub calendars_synced: usize,
660 pub calendars_read_only: usize,
663 pub calendars_without_events: usize,
664 pub registered: usize,
667 pub problems: Vec<SyncError>,
671 pub answered: Vec<(String, String)>,
675}
676
677pub fn client_for(account: &Account) -> Result<Client, SyncError> {
679 let login = account.login().ok_or(SyncError::NoServer)?;
680 if account.auth.as_deref() == Some("google") {
681 return Client::google(account.address.as_deref().unwrap_or(login));
682 }
683 Ok(Client::new(login, &crate::secret::password(account)?))
684}
685
686pub fn sync(account: &Account) -> Result<Report, SyncError> {
697 sync_with(&client_for(account)?, account)
698}
699
700const SLICE: Duration = if cfg!(test) { Duration::from_millis(150) } else { Duration::from_secs(20) };
702
703fn sync_with(client: &Client, account: &Account) -> Result<Report, SyncError> {
704 let login = account.login().ok_or(SyncError::NoServer)?;
705 let _lock = crate::fetch::lock(&state_dir().join("dav").join(format!("{}.lock", account.id)))?;
709 let mut homes = Homes::load(&account.id);
710 if homes.is_empty() {
711 homes = discover(client, account.address.as_deref().unwrap_or(login), account.url.as_deref(), None)?;
712 homes.save(&account.id)?;
713 }
714 client.keep_to(&homes);
715 vdir::finish_moves();
717 let mut report = Report::default();
718 let mut account_error: Option<SyncError> = None;
720 for (kind, home) in [(Kind::Calendars, &homes.calendars), (Kind::Contacts, &homes.contacts)] {
722 let Some(home) = home else { continue };
723 if !client.is_google() {
725 if let Err(e) = send_changes(client, &account.id, kind) {
726 report.problems.push(e);
727 }
728 if let Err(e) = create_pending(client, &account.id, kind, home) {
729 report.problems.push(e);
730 }
731 }
732 let mut listed = match collections(client, kind, home) {
733 Ok(listed) => listed,
734 Err(e) if e.is_lasting() => {
735 account_error.get_or_insert(e);
736 continue;
737 }
738 Err(e) => {
739 report.problems.push(e);
740 continue;
741 }
742 };
743 if client.is_google() {
745 listed.iter_mut().filter(|l| l.kind == Kind::Calendars && l.components.is_empty()).for_each(|l| l.components = vec!["VEVENT".into()]);
746 }
747 forget_gone(&account.id, kind, &listed);
748 match register(&account.id, &listed) {
749 Ok(made) => report.registered += made,
750 Err(e) => report.problems.push(e),
751 }
752 report.listed += listed.len();
753 let synced_before = report.collections;
754 let mut pending: Vec<&Listed> = listed.iter().collect();
756 let mut first = true;
757 while !pending.is_empty() {
758 let mut again = Vec::new();
759 for collection in pending {
760 match sync_part(client, &account.id, collection, &mut report, first, Some(Instant::now() + SLICE)) {
761 Ok(true) => report.collections += 1,
762 Ok(false) => again.push(collection),
763 Err(e) => report.problems.push(e),
764 }
765 }
766 pending = again;
767 first = false;
768 }
769 if kind == Kind::Calendars {
770 report.calendars_listed = listed.len();
771 report.calendars_synced = report.collections - synced_before;
772 report.calendars_read_only = listed.iter().filter(|l| l.read_only).count();
773 report.calendars_without_events = listed.iter().filter(|l| !l.components.is_empty() && !l.components.iter().any(|c| c.eq_ignore_ascii_case("VEVENT"))).count();
774 }
775 }
776 if client.is_google() {
778 match crate::google_tasks::sync(account) {
779 Ok(tasks) => {
780 report.sent += tasks.sent;
781 report.received += tasks.received;
782 report.removed += tasks.removed;
783 report.collections += tasks.collections;
784 report.listed += tasks.collections;
785 }
786 Err(e) if e.is_lasting() && report.listed == 0 => {
787 account_error.get_or_insert(e);
788 }
789 Err(e) => report.problems.push(e),
790 }
791 }
792 account_error.map_or(Ok(report), Err)
793}
794
795fn register(account: &str, listed: &[Listed]) -> Result<usize, SyncError> {
801 let mut failed = None;
802 let mut changed = 0;
803 for collection in listed {
804 let dir = collection.kind.root().join(account).join(&collection.id);
805 let read = |name: &str| std::fs::read_to_string(dir.join(name)).ok().map(|t| t.trim().to_string());
806 if read("displayname").as_deref() != Some(collection.name.trim()) || read("color") != collection.color.as_ref().map(|c| c.trim().to_string()) {
807 changed += 1;
808 }
809 let made = vdir::prepare(collection.kind, account, &collection.id, &collection.name, collection.color.as_deref()).map_err(|e| SyncError::Disk(e.to_string())).and_then(|_| {
810 let path = vdir::state_path(account, collection.kind, &collection.id);
811 let mut state = State::load(&path);
812 state.url = collection.url.clone();
813 state.read_only = collection.read_only;
814 state.components = collection.components.clone();
815 state.save(&path).map_err(SyncError::Disk)
816 });
817 if let Err(e) = made {
818 failed.get_or_insert(e);
819 }
820 }
821 failed.map_or(Ok(changed), Err)
822}
823
824pub fn watch(account: &Account, control: &Control, every: Duration, report: impl FnMut(Result<Report, SyncError>)) {
831 watch_with(control, every, || sync(account), || crate::secret::password(account).ok(), report);
832}
833
834fn watch_with(control: &Control, every: Duration, mut sync: impl FnMut() -> Result<Report, SyncError>, password: impl Fn() -> Option<String>, mut report: impl FnMut(Result<Report, SyncError>)) {
836 while !control.stopped() {
837 let result = sync();
838 let parked = result.as_ref().err().filter(|e| e.is_lasting()).map(|e| Parked::after(e, if e.wants_password() { password() } else { None }));
839 report(result);
840 let Some(parked) = parked else {
841 control.pause(every);
842 continue;
843 };
844 control.retried();
846 loop {
847 if !control.park() {
848 return;
849 }
850 if !parked.refused() || parked.again(password().as_deref()) || control.retried() {
851 break;
852 }
853 }
854 }
855}
856
857fn go_on(first: &mut Option<SyncError>, result: Result<(), SyncError>) -> Result<(), SyncError> {
861 match result {
862 Err(e) if !e.is_lasting() => {
863 first.get_or_insert(e);
864 Ok(())
865 }
866 other => other,
867 }
868}
869
870fn forget_gone(account: &str, kind: Kind, listed: &[Listed]) {
874 let kept: BTreeSet<&str> = listed.iter().map(|l| l.id.as_str()).collect();
875 for collection in vdir::collections(kind).into_iter().filter(|c| c.account == account && !kept.contains(c.id.as_str())) {
876 let state = State::load(&collection.state_path());
877 let unsent = has_local_changes(&collection.dir, kind, &state);
878 if state.pending || unsent || crate::google_tasks::is_tasks_list(&state.url) {
880 continue;
881 }
882 let _ = std::fs::remove_dir_all(&collection.dir);
883 let _ = std::fs::remove_file(collection.state_path());
884 }
885}
886
887fn send_changes(client: &Client, account: &str, kind: Kind) -> Result<(), SyncError> {
892 let mut refused = None;
893 for collection in vdir::every_collection(kind).into_iter().filter(|c| c.account == account) {
894 let path = collection.state_path();
895 let mut state = State::load(&path);
896 if state.url.is_empty() {
897 continue;
898 }
899 if state.renamed {
900 let body = format!(r#"<?xml version="1.0" encoding="utf-8"?><d:propertyupdate xmlns:d="DAV:"><d:set><d:prop><d:displayname>{}</d:displayname></d:prop></d:set></d:propertyupdate>"#, xml_escape(&collection.name));
901 let answer = client.send("PROPPATCH", &state.url, &[("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
902 if !(200..300).contains(&answer.status) {
903 refused.get_or_insert(SyncError::Server(format!("PROPPATCH {}: {}", state.url, answer.status)));
904 continue;
905 }
906 state.renamed = false;
907 go_on(&mut refused, state.save(&path).map_err(SyncError::Disk))?;
908 }
909 if state.deleted {
910 let (_, found) = client.propfind(&state.url, "1", "<d:getetag/>")?;
911 let held = found.iter().filter(|r| path_key(&r.href) != path_key(&state.url)).count();
912 if held > 0 {
913 state.deleted = false;
914 } else {
915 let answer = client.send("DELETE", &state.url, &[], None)?;
916 if !(200..300).contains(&answer.status) && answer.status != 404 {
917 refused.get_or_insert(SyncError::Server(format!("DELETE {}: {}", state.url, answer.status)));
918 continue;
919 }
920 let _ = std::fs::remove_dir_all(&collection.dir);
921 let _ = std::fs::remove_file(&path);
922 continue;
923 }
924 go_on(&mut refused, state.save(&path).map_err(SyncError::Disk))?;
925 }
926 }
927 refused.map_or(Ok(()), Err)
928}
929
930fn create_pending(client: &Client, account: &str, kind: Kind, home: &str) -> Result<(), SyncError> {
936 let mut refused = None;
937 for collection in vdir::collections(kind).into_iter().filter(|c| c.account == account) {
938 let path = collection.state_path();
939 let mut state = State::load(&path);
940 if !state.pending {
941 continue;
942 }
943 let url = format!("{}/{}/", home.trim_end_matches('/'), url_segment(&collection.id));
944 let name = format!("<d:displayname>{}</d:displayname>", xml_escape(&collection.name));
945 let color = collection.color.as_deref().map(|c| format!("<ic:calendar-color>{}</ic:calendar-color>", xml_escape(c))).unwrap_or_default();
946 let (method, body) = match kind {
947 Kind::Calendars => {
948 let comps: String = state.components.iter().map(|c| format!(r#"<c:comp name="{}"/>"#, xml_escape(c))).collect();
949 let set = if comps.is_empty() { String::new() } else { format!("<c:supported-calendar-component-set>{comps}</c:supported-calendar-component-set>") };
950 (
951 "MKCALENDAR",
952 format!(r#"<?xml version="1.0" encoding="utf-8"?><c:mkcalendar xmlns:d="DAV:" xmlns:c="{CALDAV}" xmlns:ic="{APPLE}"><d:set><d:prop>{name}{color}{set}</d:prop></d:set></c:mkcalendar>"#),
953 )
954 }
955 Kind::Contacts => (
956 "MKCOL",
957 format!(r#"<?xml version="1.0" encoding="utf-8"?><d:mkcol xmlns:d="DAV:" xmlns:a="{CARDDAV}"><d:set><d:prop><d:resourcetype><d:collection/><a:addressbook/></d:resourcetype>{name}</d:prop></d:set></d:mkcol>"#),
958 ),
959 };
960 let answer = client.send(method, &url, &[("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
961 match answer.status {
962 200..=299 | 405 => {
963 state.pending = false;
964 state.url = url;
965 go_on(&mut refused, state.save(&path).map_err(SyncError::Disk))?;
966 }
967 status => {
968 refused.get_or_insert(SyncError::Server(format!("{method} {url}: {status}")));
969 }
970 }
971 }
972 refused.map_or(Ok(()), Err)
973}
974
975fn hash_of(path: &Path) -> Option<String> {
976 read_item(path).ok().map(|bytes| vdir::content_hash(&bytes))
977}
978
979fn read_item(path: &Path) -> std::io::Result<Vec<u8>> {
981 #[cfg(test)]
982 tests::READS.with(|reads| reads.set(reads.get() + 1));
983 std::fs::read(path)
984}
985
986fn read_text(path: &Path) -> Result<String, SyncError> {
988 let fail = |e: &dyn std::fmt::Display| SyncError::Disk(format!("{}: {e}", path.display()));
989 String::from_utf8(read_item(path).map_err(|e| fail(&e))?).map_err(|e| fail(&e))
990}
991
992fn file_name(href: &str, extension: &str, taken: &BTreeSet<String>) -> String {
994 let last = path_key(href).rsplit('/').next().unwrap_or("item").to_string();
995 let stem = last.strip_suffix(&format!(".{extension}")).unwrap_or(&last);
996 let stem: String = stem.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '@') { c } else { '_' }).take(120).collect();
997 let mut stem = stem.trim_start_matches('.').to_string();
1000 let device = stem.split('.').next().unwrap_or("").to_ascii_uppercase();
1001 if matches!(device.as_str(), "CON" | "PRN" | "AUX" | "NUL") || (device.len() == 4 && (device.starts_with("COM") || device.starts_with("LPT")) && device.ends_with(|c: char| c.is_ascii_digit())) {
1002 stem.insert(0, '_');
1003 }
1004 if stem.is_empty() {
1005 stem = vdir::new_name();
1006 }
1007 let mut name = format!("{stem}.{extension}");
1008 let mut n = 2;
1009 while taken.contains(&name) {
1010 name = format!("{stem}-{n}.{extension}");
1011 n += 1;
1012 }
1013 name
1014}
1015
1016#[cfg(test)]
1020fn sync_collection(client: &Client, account: &str, listed: &Listed, report: &mut Report) -> Result<(), SyncError> {
1021 sync_part(client, account, listed, report, true, None).map(|_| ())
1022}
1023
1024fn sync_part(client: &Client, account: &str, listed: &Listed, report: &mut Report, first: bool, until: Option<Instant>) -> Result<bool, SyncError> {
1028 let kind = listed.kind;
1029 let dir = vdir::prepare(kind, account, &listed.id, &listed.name, listed.color.as_deref()).map_err(|e| SyncError::Disk(e.to_string()))?;
1030 let state_path = vdir::state_path(account, kind, &listed.id);
1031 let mut state = State::load(&state_path);
1032 state.url = listed.url.clone();
1033 state.read_only = listed.read_only;
1034 state.components = listed.components.clone();
1035 let synced = both_ways(client, &dir, listed, &mut state, &state_path, report, first, until);
1036 state.save(&state_path).map_err(SyncError::Disk)?;
1037 synced
1038}
1039
1040#[allow(clippy::too_many_arguments)]
1045fn both_ways(client: &Client, dir: &Path, listed: &Listed, state: &mut State, state_path: &Path, report: &mut Report, first: bool, until: Option<Instant>) -> Result<bool, SyncError> {
1046 let mut refetch: BTreeSet<String> = BTreeSet::new();
1047 let refused = if listed.read_only || !first { None } else { push(client, listed.kind, dir, listed, state, &mut refetch, report)? };
1048 let unchanged = state.ctag.is_some() && state.ctag == listed.ctag && refetch.is_empty() && refused.is_none();
1049 if !unchanged && !pull(client, dir, listed, state, state_path, &refetch, report, until)? {
1050 return Ok(false);
1051 }
1052 state.ctag = listed.ctag.clone();
1053 refused.map_or(Ok(true), Err)
1054}
1055
1056fn has_local_changes(dir: &Path, kind: Kind, state: &State) -> bool {
1057 let known: BTreeSet<&str> = state.items.iter().map(|i| i.file.as_str()).collect();
1058 let files = local_files(dir, kind);
1059 files.iter().any(|f| !known.contains(f.as_str())) || state.items.iter().any(|i| !unchanged(&dir.join(&i.file), i))
1060}
1061
1062fn unchanged(path: &Path, item: &ItemState) -> bool {
1065 std::fs::metadata(path).is_ok_and(|m| as_noted(&m, item)) || hash_of(path).is_some_and(|hash| hash == item.hash)
1066}
1067
1068const SETTLED: Duration = Duration::from_secs(2);
1072
1073fn stamp(metadata: &std::fs::Metadata) -> (u64, i64) {
1077 let settled = metadata.modified().ok().filter(|m| std::time::SystemTime::now().duration_since(*m).is_ok_and(|age| age >= SETTLED));
1078 let mtime = settled.and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).and_then(|d| i64::try_from(d.as_nanos()).ok()).unwrap_or(0);
1079 (metadata.len(), mtime)
1080}
1081
1082fn as_noted(metadata: &std::fs::Metadata, item: &ItemState) -> bool {
1084 item.mtime != 0 && stamp(metadata) == (item.size, item.mtime)
1085}
1086
1087fn local_files(dir: &Path, kind: Kind) -> BTreeSet<String> {
1088 std::fs::read_dir(dir)
1089 .into_iter()
1090 .flatten()
1091 .filter_map(Result::ok)
1092 .filter_map(|e| e.file_name().into_string().ok())
1093 .filter(|n| n.ends_with(&format!(".{}", kind.extension())) && !n.starts_with('.'))
1094 .collect()
1095}
1096
1097fn push(client: &Client, kind: Kind, dir: &Path, listed: &Listed, state: &mut State, refetch: &mut BTreeSet<String>, report: &mut Report) -> Result<Option<SyncError>, SyncError> {
1102 let mut refused = None;
1103 let mut i = 0;
1104 while i < state.items.len() {
1105 match send_known(client, kind, dir, listed, &state.items[i], refetch, report)? {
1106 Sent::Deleted => {
1107 state.items.remove(i);
1108 continue;
1109 }
1110 Sent::Agreed(item) => state.items[i] = item,
1111 Sent::Kept => {}
1112 Sent::Refused(e) => {
1113 refused.get_or_insert(e);
1114 }
1115 }
1116 i += 1;
1117 }
1118 let known: BTreeSet<String> = state.items.iter().map(|i| i.file.clone()).collect();
1119 for file in local_files(dir, kind).into_iter().filter(|f| !known.contains(f)) {
1120 let path = dir.join(&file);
1121 let (size, mtime) = std::fs::metadata(&path).map(|m| stamp(&m)).unwrap_or_default();
1123 let text = match read_text(&path) {
1124 Ok(text) => text,
1125 Err(e) => {
1126 refused.get_or_insert(e);
1127 continue;
1128 }
1129 };
1130 let href = format!("{}/{}", listed.url.trim_end_matches('/'), url_segment(&file));
1131 if client.is_google() {
1132 match create_on_google(client, kind, listed, &file, &href, &text, refetch) {
1133 Ok(item) => {
1134 state.items.push(ItemState { size, mtime, ..item });
1135 report.sent += 1;
1136 state.sync_token = None;
1138 }
1139 Err(e @ SyncError::Server(_)) => {
1140 refused.get_or_insert(e);
1141 }
1142 Err(e) => return Err(e),
1143 }
1144 continue;
1145 }
1146 let answer = client.send("PUT", &href, &[("If-None-Match", "*"), ("Content-Type", kind.media_type())], Some(&text))?;
1147 if matches!(answer.status, 400 | 403 | 409 | 412)
1150 && let Some(theirs) = same_uid_there(client, kind, listed, &href, &text, &answer.body)?
1151 {
1152 if vdir::moved_here(&path) {
1155 refused.get_or_insert(SyncError::Server(format!("PUT {href}: {}: its old copy is still there", answer.status)));
1156 continue;
1157 }
1158 state.items.push(adopt(dir, listed, &file, &text, theirs, report)?);
1159 continue;
1160 }
1161 match answer.status {
1162 200..=299 => {
1163 report.sent += 1;
1164 let etag = answer.etag.unwrap_or_default();
1165 if etag.is_empty() {
1166 refetch.insert(path_key(&href));
1167 }
1168 state.items.push(ItemState { href: path_key(&href), file, etag, hash: vdir::content_hash(text.as_bytes()), size, mtime });
1169 }
1170 412 => {
1174 refetch.insert(path_key(&href));
1175 state.items.push(ItemState { href: path_key(&href), file, ..ItemState::default() });
1176 }
1177 status => {
1178 refused.get_or_insert(SyncError::Server(format!("PUT {href}: {status}")));
1179 }
1180 }
1181 }
1182 Ok(refused)
1183}
1184
1185fn same_uid_there(client: &Client, kind: Kind, listed: &Listed, href: &str, text: &str, refusal: &str) -> Result<Option<(String, String, String)>, SyncError> {
1193 let Some(uid) = uid_of(text) else { return Ok(None) };
1194 let mut hrefs: Vec<String> = named_in_refusal(refusal).into_iter().collect();
1195 if hrefs.is_empty() {
1196 hrefs = holding_uid(client, kind, &listed.url, &uid, &component_of(text))?;
1197 }
1198 let mine = path_key(href);
1199 hrefs.retain(|h| path_key(&absolute(&listed.url, h)) != mine);
1200 if hrefs.is_empty() {
1201 return Ok(None);
1202 }
1203 Ok(fetch(client, kind, &listed.url, &hrefs)?.into_iter().find(|(_, _, data)| uid_of(data).as_deref() == Some(uid.as_str())))
1204}
1205
1206fn uid_of(text: &str) -> Option<String> {
1208 sioul_core::lines::unfold(text).iter().find(|l| sioul_core::lines::name(l) == "UID").map(|l| sioul_core::lines::value(l).trim().to_string()).filter(|uid| !uid.is_empty())
1209}
1210
1211fn component_of(text: &str) -> String {
1213 sioul_core::lines::unfold(text)
1214 .iter()
1215 .filter(|l| sioul_core::lines::name(l) == "BEGIN")
1216 .map(|l| sioul_core::lines::value(l).trim().to_ascii_uppercase())
1217 .find(|c| !matches!(c.as_str(), "VCALENDAR" | "VTIMEZONE" | "STANDARD" | "DAYLIGHT" | "VALARM"))
1218 .unwrap_or_else(|| "VEVENT".into())
1219}
1220
1221fn named_in_refusal(body: &str) -> Option<String> {
1223 let doc = roxmltree::Document::parse(body).ok()?;
1224 let clash = doc.descendants().find(|n| n.tag_name().name() == "no-uid-conflict")?;
1225 clash.descendants().find(|n| n.has_tag_name((DAV, "href"))).and_then(|n| n.text()).map(|h| h.trim().to_string()).filter(|h| !h.is_empty())
1226}
1227
1228fn holding_uid(client: &Client, kind: Kind, url: &str, uid: &str, component: &str) -> Result<Vec<String>, SyncError> {
1231 let (uid, component) = (xml_escape(uid), xml_escape(component));
1232 let body = match kind {
1233 Kind::Calendars => format!(
1234 r#"<?xml version="1.0" encoding="utf-8"?><c:calendar-query xmlns:d="DAV:" xmlns:c="{CALDAV}"><d:prop><d:getetag/></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="{component}"><c:prop-filter name="UID"><c:text-match>{uid}</c:text-match></c:prop-filter></c:comp-filter></c:comp-filter></c:filter></c:calendar-query>"#
1235 ),
1236 Kind::Contacts => format!(
1237 r#"<?xml version="1.0" encoding="utf-8"?><a:addressbook-query xmlns:d="DAV:" xmlns:a="{CARDDAV}"><d:prop><d:getetag/></d:prop><a:filter><a:prop-filter name="UID"><a:text-match match-type="equals">{uid}</a:text-match></a:prop-filter></a:filter></a:addressbook-query>"#
1238 ),
1239 };
1240 let answer = client.send("REPORT", url, &[("Depth", "1"), ("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
1241 if answer.status != 207 {
1242 return Ok(Vec::new());
1243 }
1244 let Ok((responses, _)) = multistatus(&answer.body) else { return Ok(Vec::new()) };
1245 let collection = path_key(url);
1246 Ok(responses.into_iter().filter(|r| r.status.is_none_or(|s| s / 100 == 2) && path_key(&absolute(url, &r.href)) != collection).map(|r| r.href).collect())
1247}
1248
1249fn adopt(dir: &Path, listed: &Listed, file: &str, mine: &str, (href, etag, data): (String, String, String), report: &mut Report) -> Result<ItemState, SyncError> {
1257 let path = dir.join(file);
1258 let (ours, theirs) = (sioul_core::agenda::answers(mine), sioul_core::agenda::answers(&data));
1259 if ours != theirs {
1260 let kept = theirs.iter().find(|(who, said)| ours.get(*who) != Some(*said)).map_or_else(String::new, |(_, said)| said.clone());
1262 report.answered.push((summary_of(&data), kept));
1263 } else if ours.is_empty() && !same_text(mine.as_bytes(), &data) {
1264 report.conflicts.push(set_aside(&path, mine.as_bytes())?);
1265 }
1266 let text = crlf(&data);
1267 vdir::write_item(&path, &text).map_err(SyncError::Disk)?;
1268 report.received += 1;
1269 Ok(ItemState { href: path_key(&absolute(&listed.url, &href)), file: file.to_string(), etag, hash: vdir::content_hash(text.as_bytes()), ..ItemState::default() })
1271}
1272
1273fn summary_of(text: &str) -> String {
1275 sioul_core::lines::unfold(text).iter().find(|l| sioul_core::lines::name(l) == "SUMMARY").map(|l| sioul_core::lines::unescape(sioul_core::lines::value(l).trim())).unwrap_or_default()
1276}
1277
1278enum Sent {
1280 Deleted,
1282 Agreed(ItemState),
1284 Kept,
1286 Refused(SyncError),
1288}
1289
1290fn send_known(client: &Client, kind: Kind, dir: &Path, listed: &Listed, item: &ItemState, refetch: &mut BTreeSet<String>, report: &mut Report) -> Result<Sent, SyncError> {
1293 let path = dir.join(&item.file);
1294 let url = absolute(&listed.url, &item.href);
1295 let unreadable = |e: std::io::Error| Sent::Refused(SyncError::Disk(format!("{}: {e}", path.display())));
1296 let metadata = match std::fs::metadata(&path) {
1297 Ok(metadata) => metadata,
1298 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1300 if vdir::deletion_waits_for(&path).is_some() {
1303 return Ok(Sent::Kept);
1304 }
1305 let answer = client.send("DELETE", &url, &[("If-Match", &item.etag)], None)?;
1306 return Ok(match answer.status {
1307 200..=299 | 404 => {
1308 report.sent += 1;
1309 vdir::deletion_done(&path);
1310 Sent::Deleted
1311 }
1312 412 => {
1313 refetch.insert(path_key(&url));
1314 Sent::Kept
1315 }
1316 status => Sent::Refused(SyncError::Server(format!("DELETE {url}: {status}"))),
1317 });
1318 }
1319 Err(e) => return Ok(unreadable(e)),
1320 };
1321 if as_noted(&metadata, item) {
1322 return Ok(Sent::Kept);
1323 }
1324 let (size, mtime) = stamp(&metadata);
1326 let bytes = match read_item(&path) {
1327 Ok(bytes) => bytes,
1328 Err(e) => return Ok(unreadable(e)),
1329 };
1330 let hash = vdir::content_hash(&bytes);
1331 if hash == item.hash {
1332 return Ok(if (size, mtime) == (item.size, item.mtime) { Sent::Kept } else { Sent::Agreed(ItemState { size, mtime, ..item.clone() }) });
1334 }
1335 if item.hash.is_empty() {
1336 refetch.insert(path_key(&url));
1338 return Ok(Sent::Kept);
1339 }
1340 let text = match String::from_utf8(bytes) {
1341 Ok(text) => text,
1342 Err(e) => return Ok(Sent::Refused(SyncError::Disk(format!("{}: {e}", path.display())))),
1343 };
1344 let answer = client.send("PUT", &url, &[("If-Match", &item.etag), ("Content-Type", kind.media_type())], Some(&text))?;
1345 Ok(match answer.status {
1346 200..=299 => {
1347 report.sent += 1;
1348 let etag = answer.etag.unwrap_or_default();
1350 if etag.is_empty() {
1351 refetch.insert(path_key(&url));
1352 }
1353 Sent::Agreed(ItemState { etag, hash, size, mtime, ..item.clone() })
1354 }
1355 412 => {
1357 refetch.insert(path_key(&url));
1358 Sent::Kept
1359 }
1360 status => Sent::Refused(SyncError::Server(format!("PUT {url}: {status}"))),
1361 })
1362}
1363
1364fn create_on_google(client: &Client, kind: Kind, listed: &Listed, file: &str, href: &str, text: &str, refetch: &mut BTreeSet<String>) -> Result<ItemState, SyncError> {
1369 let sent = if kind == Kind::Contacts { sioul_core::contacts::as_vcard3(text) } else { text.to_string() };
1370 let mut answer = client.send("PUT", href, &[("Content-Type", kind.media_type())], Some(&sent))?;
1371 if kind == Kind::Contacts && matches!(answer.status, 403 | 405 | 409) {
1372 answer = client.send("POST", &listed.url, &[("Content-Type", kind.media_type())], Some(&sent))?;
1373 }
1374 if !(200..300).contains(&answer.status) {
1375 return Err(SyncError::Server(format!("PUT {href}: {}", answer.status)));
1376 }
1377 let at = answer.location.as_deref().map_or_else(|| path_key(href), |l| path_key(&absolute(&listed.url, l)));
1378 refetch.insert(at.clone());
1379 Ok(ItemState { href: at, file: file.to_string(), etag: answer.etag.unwrap_or_default(), hash: vdir::content_hash(text.as_bytes()), ..ItemState::default() })
1380}
1381
1382fn url_segment(name: &str) -> String {
1384 name.bytes()
1385 .map(|b| match b {
1386 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'@' => (b as char).to_string(),
1387 _ => format!("%{b:02X}"),
1388 })
1389 .collect()
1390}
1391
1392fn set_aside(path: &Path, mine: &[u8]) -> Result<PathBuf, SyncError> {
1401 let root = crate::history::root(&state_dir().join("share").join("memory.json"));
1402 let data = sioul_core::config::data_dir();
1403 let file = path.strip_prefix(&data).ok().map(|relative| format!("data/{}", relative.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/"))).unwrap_or_else(|| format!("data/{}", path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()));
1404 crate::history::keep_bytes(&root, crate::history::ACCOUNTS, &file, mine, jiff::Timestamp::now().as_millisecond()).map_err(SyncError::Disk)
1405}
1406
1407fn changed_here(path: &Path, item: &ItemState) -> Option<Vec<u8>> {
1409 if std::fs::metadata(path).is_ok_and(|m| as_noted(&m, item)) {
1410 return None;
1411 }
1412 read_item(path).ok().filter(|bytes| item.hash.is_empty() || vdir::content_hash(bytes) != item.hash)
1413}
1414
1415fn crlf(text: &str) -> String {
1417 text.replace("\r\n", "\n").replace('\n', "\r\n")
1418}
1419
1420fn same_text(mine: &[u8], theirs: &str) -> bool {
1422 crlf(&String::from_utf8_lossy(mine)).trim_end() == crlf(theirs).trim_end()
1423}
1424
1425#[allow(clippy::too_many_arguments)]
1431fn pull(client: &Client, dir: &Path, listed: &Listed, state: &mut State, state_path: &Path, refetch: &BTreeSet<String>, report: &mut Report, until: Option<Instant>) -> Result<bool, SyncError> {
1432 let key = |href: &str| path_key(&absolute(&listed.url, href));
1433 let by_href: BTreeMap<String, usize> = state.items.iter().enumerate().map(|(i, item)| (key(&item.href), i)).collect();
1434 let (changed, gone, token) = match state.sync_token.clone().and_then(|token| changes_since(client, &listed.url, &token).ok().flatten()) {
1435 Some(found) => found,
1436 None => everything(client, &listed.url, state, &by_href)?,
1437 };
1438 let gone: BTreeSet<String> = gone.iter().map(|h| key(h)).collect();
1441 let mut i = 0;
1442 while i < state.items.len() {
1443 if !gone.contains(&key(&state.items[i].href)) {
1444 i += 1;
1445 continue;
1446 }
1447 let path = dir.join(&state.items[i].file);
1448 if let Some(mine) = changed_here(&path, &state.items[i]) {
1449 report.conflicts.push(set_aside(&path, &mine)?);
1450 }
1451 let _ = std::fs::remove_file(&path);
1452 state.items.remove(i);
1453 report.removed += 1;
1454 }
1455 let known: BTreeMap<String, usize> = state.items.iter().enumerate().map(|(i, item)| (key(&item.href), i)).collect();
1457 let mut wanted: BTreeMap<String, String> = changed
1458 .into_iter()
1459 .filter_map(|(href, etag)| {
1460 let at = key(&href);
1461 let stale = known.get(&at).is_none_or(|&i| etag.as_deref().is_none_or(|e| e != state.items[i].etag));
1462 (stale || refetch.contains(&at)).then_some((at, href))
1463 })
1464 .collect();
1465 for at in refetch {
1466 if let Some(&i) = known.get(at) {
1467 wanted.entry(at.clone()).or_insert_with(|| state.items[i].href.clone());
1468 }
1469 }
1470 let hrefs: Vec<String> = wanted.into_values().collect();
1471 let mut at = 0;
1472 while at < hrefs.len() {
1473 let batch = &hrefs[at..(at + batch_of(&listed.url)).min(hrefs.len())];
1475 let started = Instant::now();
1476 let fetched = fetch(client, listed.kind, &listed.url, batch);
1477 match &fetched {
1478 Ok(_) => pace(&listed.url, Some(started.elapsed())),
1479 Err(SyncError::Network(_)) => pace(&listed.url, None),
1480 Err(_) => {}
1481 }
1482 for (href, etag, data) in fetched? {
1483 store(dir, listed.kind, state, &listed.url, (&href, &etag, &data), report)?;
1484 }
1485 state.save(state_path).map_err(SyncError::Disk)?;
1487 at += batch.len();
1488 if at < hrefs.len() && until.is_some_and(|until| Instant::now() >= until) {
1490 return Ok(false);
1491 }
1492 }
1493 state.sync_token = token.or_else(|| listed.sync_token.clone());
1494 Ok(true)
1495}
1496
1497#[allow(clippy::type_complexity)]
1500fn changes_since(client: &Client, url: &str, token: &str) -> Result<Option<(Vec<(String, Option<String>)>, Vec<String>, Option<String>)>, SyncError> {
1501 let body = format!(
1502 r#"<?xml version="1.0" encoding="utf-8"?><d:sync-collection xmlns:d="DAV:"><d:sync-token>{}</d:sync-token><d:sync-level>1</d:sync-level><d:prop><d:getetag/></d:prop></d:sync-collection>"#,
1503 xml_escape(token)
1504 );
1505 let answer = client.send("REPORT", url, &[("Depth", "0"), ("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
1506 if answer.status != 207 {
1507 return Ok(None);
1508 }
1509 let (responses, new_token) = multistatus(&answer.body)?;
1510 let collection = path_key(url);
1511 let mut changed = Vec::new();
1512 let mut gone = Vec::new();
1513 for response in responses.into_iter().filter(|r| path_key(&absolute(url, &r.href)) != collection) {
1514 if response.status == Some(404) {
1515 gone.push(response.href);
1516 } else {
1517 changed.push((response.href.clone(), response.text(DAV, "getetag")));
1518 }
1519 }
1520 Ok(Some((changed, gone, new_token)))
1521}
1522
1523#[allow(clippy::type_complexity)]
1525fn everything(client: &Client, url: &str, state: &State, known: &BTreeMap<String, usize>) -> Result<(Vec<(String, Option<String>)>, Vec<String>, Option<String>), SyncError> {
1526 let (status, responses) = client.propfind(url, "1", "<d:getetag/><d:resourcetype/>")?;
1527 if status != 207 {
1528 return Err(SyncError::Server(format!("PROPFIND {url}: {status}")));
1529 }
1530 let collection = path_key(url);
1531 let items: Vec<&Response> = responses.iter().filter(|r| path_key(&absolute(url, &r.href)) != collection && !r.is(DAV, "collection")).collect();
1532 let present: BTreeSet<String> = items.iter().map(|r| path_key(&absolute(url, &r.href))).collect();
1533 let changed = items.iter().map(|r| (r.href.clone(), r.text(DAV, "getetag"))).collect();
1534 let gone = known.keys().filter(|k| !present.contains(*k)).filter_map(|k| known.get(k).map(|&i| state.items[i].href.clone())).collect();
1535 Ok((changed, gone, None))
1536}
1537
1538fn xml_escape(text: &str) -> String {
1540 text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
1541}
1542
1543fn fetch(client: &Client, kind: Kind, url: &str, hrefs: &[String]) -> Result<Vec<(String, String, String)>, SyncError> {
1545 if hrefs.is_empty() {
1546 return Ok(Vec::new());
1547 }
1548 let (root, ns, data) = match kind {
1549 Kind::Calendars => ("c:calendar-multiget", CALDAV, "calendar-data"),
1550 Kind::Contacts => ("a:addressbook-multiget", CARDDAV, "address-data"),
1551 };
1552 let prefix = if kind == Kind::Calendars { "c" } else { "a" };
1553 let list: String = hrefs.iter().map(|h| format!("<d:href>{}</d:href>", xml_escape(h))).collect();
1554 let body = format!(
1555 r#"<?xml version="1.0" encoding="utf-8"?><{root} xmlns:d="DAV:" xmlns:c="{CALDAV}" xmlns:a="{CARDDAV}"><d:prop><d:getetag/><{prefix}:{data}/></d:prop>{list}</{root}>"#
1556 );
1557 let answer = client.send("REPORT", url, &[("Depth", "1"), ("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
1558 if answer.status == 207 {
1559 let (responses, _) = multistatus(&answer.body)?;
1560 let found: Vec<(String, String, String)> = responses
1561 .iter()
1562 .filter_map(|r| Some((r.href.clone(), r.text(DAV, "getetag").unwrap_or_default(), r.prop(ns, data).map(|p| p.text.clone()).filter(|t| !t.trim().is_empty())?)))
1563 .collect();
1564 if !found.is_empty() || hrefs.is_empty() {
1565 return Ok(found);
1566 }
1567 }
1568 let mut found = Vec::new();
1570 for href in hrefs {
1571 let answer = client.send("GET", &absolute(url, href), &[], None)?;
1572 if answer.status / 100 == 2 {
1573 found.push((href.clone(), answer.etag.unwrap_or_default(), answer.body));
1574 }
1575 }
1576 Ok(found)
1577}
1578
1579fn store(dir: &Path, kind: Kind, state: &mut State, url: &str, (href, etag, data): (&str, &str, &str), report: &mut Report) -> Result<(), SyncError> {
1583 let key = path_key(&absolute(url, href));
1584 let position = state.items.iter().position(|i| path_key(&absolute(url, &i.href)) == key);
1585 let file = match position {
1586 Some(i) => state.items[i].file.clone(),
1587 None => file_name(href, kind.extension(), &state.items.iter().map(|i| i.file.clone()).chain(local_files(dir, kind)).collect()),
1588 };
1589 let path = dir.join(&file);
1590 if let Some(i) = position
1591 && let Some(mine) = changed_here(&path, &state.items[i])
1592 && !same_text(&mine, data)
1593 {
1594 report.conflicts.push(set_aside(&path, &mine)?);
1595 }
1596 let text = crlf(data);
1597 vdir::write_item(&path, &text).map_err(SyncError::Disk)?;
1598 let item = ItemState { href: href.to_string(), file, etag: etag.to_string(), hash: vdir::content_hash(text.as_bytes()), ..ItemState::default() };
1600 match position {
1601 Some(i) => state.items[i] = item,
1602 None => state.items.push(item),
1603 }
1604 report.received += 1;
1605 Ok(())
1606}
1607
1608pub fn test(address: &str, login: &str, password: &str, given: Option<&str>, mail_host: Option<&str>) -> Result<Homes, SyncError> {
1610 discover(&Client::new(login, password), address, given, mail_host)
1611}
1612
1613pub fn test_google(address: &str) -> Result<Homes, SyncError> {
1615 discover(&Client::google(address)?, address, None, None)
1616}
1617
1618#[cfg(test)]
1622pub(crate) mod stand_in {
1623 use std::collections::{BTreeMap, BTreeSet};
1624 use std::io::{BufRead, BufReader, Read, Write};
1625 use std::net::{TcpListener, TcpStream};
1626 use std::path::{Path, PathBuf};
1627 use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
1628 use std::time::Duration;
1629
1630 pub(crate) fn home() -> &'static Path {
1635 static HOME: OnceLock<PathBuf> = OnceLock::new();
1636 HOME.get_or_init(|| {
1637 forget_old_homes();
1638 let root = std::env::temp_dir().join(format!("sioul-sync-tests-{}", std::process::id()));
1639 let _ = std::fs::remove_dir_all(&root);
1640 for (variable, folder) in [("XDG_CONFIG_HOME", "config"), ("XDG_DATA_HOME", "data"), ("XDG_STATE_HOME", "state"), ("XDG_CACHE_HOME", "cache")] {
1641 let dir = root.join(folder);
1642 std::fs::create_dir_all(&dir).unwrap();
1643 unsafe { std::env::set_var(variable, &dir) };
1646 }
1647 root
1648 })
1649 }
1650
1651 fn forget_old_homes() {
1655 let hour_ago = std::time::SystemTime::now() - Duration::from_secs(3600);
1656 for entry in std::fs::read_dir(std::env::temp_dir()).into_iter().flatten().filter_map(Result::ok) {
1657 let name = entry.file_name().to_string_lossy().into_owned();
1658 let Some(process) = name.strip_prefix("sioul-sync-tests-") else { continue };
1659 let over = cfg!(target_os = "linux") && !Path::new("/proc").join(process).exists();
1660 let old = entry.metadata().and_then(|m| m.modified()).is_ok_and(|t| t < hour_ago);
1661 if over || old {
1662 let _ = std::fs::remove_dir_all(entry.path());
1663 }
1664 }
1665 }
1666
1667 pub(crate) struct Request {
1669 pub method: String,
1670 pub path: String,
1672 headers: Vec<(String, String)>,
1673 pub body: String,
1674 }
1675
1676 impl Request {
1677 pub fn header(&self, name: &str) -> Option<&str> {
1678 self.headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str())
1679 }
1680 }
1681
1682 pub(crate) struct Reply {
1683 pub status: u16,
1684 pub headers: Vec<(&'static str, String)>,
1685 pub body: String,
1686 }
1687
1688 impl Reply {
1689 pub fn new(status: u16, body: impl Into<String>) -> Reply {
1690 Reply { status, headers: Vec::new(), body: body.into() }
1691 }
1692 }
1693
1694 #[derive(Debug, Clone, Copy)]
1696 pub(crate) enum Fault {
1697 Status(u16),
1699 Drop,
1701 Lost,
1703 Late(Duration),
1705 Trickle(Duration),
1707 }
1708
1709 pub(crate) trait Server: Send + Sync + 'static {
1711 fn misbehaves(&self, request: &Request) -> Option<Fault>;
1712 fn answer(&self, request: &Request) -> Reply;
1713 }
1714
1715 pub(crate) fn serve(server: Arc<dyn Server>) -> String {
1718 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1719 let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
1720 std::thread::spawn(move || {
1721 for stream in listener.incoming().filter_map(Result::ok) {
1722 let server = Arc::clone(&server);
1723 std::thread::spawn(move || exchange(stream, server.as_ref()));
1724 }
1725 });
1726 base
1727 }
1728
1729 fn exchange(mut stream: TcpStream, server: &dyn Server) {
1730 let Some(request) = read(&stream) else { return };
1731 let fault = server.misbehaves(&request);
1732 let reply = match fault {
1733 Some(Fault::Status(status)) => Reply::new(status, ""),
1734 Some(Fault::Drop) => return,
1735 Some(Fault::Lost) => {
1736 server.answer(&request);
1737 return;
1738 }
1739 Some(Fault::Late(wait)) => {
1740 std::thread::sleep(wait);
1741 server.answer(&request)
1742 }
1743 Some(Fault::Trickle(_)) | None => server.answer(&request),
1744 };
1745 let headers: String = reply.headers.iter().map(|(name, value)| format!("{name}: {value}\r\n")).collect();
1746 let head = format!("HTTP/1.1 {} Stand-in\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n", reply.status, reply.body.len());
1747 if stream.write_all(head.as_bytes()).is_err() {
1748 return;
1749 }
1750 match fault {
1751 Some(Fault::Trickle(over)) => {
1752 let pieces = 30;
1753 for piece in reply.body.as_bytes().chunks(reply.body.len() / pieces + 1) {
1754 std::thread::sleep(over / pieces as u32);
1755 if stream.write_all(piece).and_then(|()| stream.flush()).is_err() {
1756 return;
1757 }
1758 }
1759 }
1760 _ => {
1761 let _ = stream.write_all(reply.body.as_bytes());
1762 }
1763 }
1764 }
1765
1766 fn read(stream: &TcpStream) -> Option<Request> {
1767 let mut reader = BufReader::new(stream);
1768 let mut line = String::new();
1769 reader.read_line(&mut line).ok()?;
1770 let mut words = line.split_whitespace();
1771 let (method, path) = (words.next()?.to_string(), words.next()?.to_string());
1772 let mut headers: Vec<(String, String)> = Vec::new();
1773 loop {
1774 let mut line = String::new();
1775 reader.read_line(&mut line).ok()?;
1776 let Some((name, value)) = line.trim_end().split_once(':') else { break };
1777 headers.push((name.trim().to_string(), value.trim().to_string()));
1778 }
1779 let length = headers.iter().find(|(n, _)| n.eq_ignore_ascii_case("content-length")).and_then(|(_, v)| v.parse().ok()).unwrap_or(0);
1780 let mut body = vec![0; length];
1781 reader.read_exact(&mut body).ok()?;
1782 Some(Request { method, path, headers, body: String::from_utf8_lossy(&body).into_owned() })
1783 }
1784
1785 pub(crate) fn nth(n: usize, matches: impl Fn(&Request) -> bool + Send + 'static, fault: Fault) -> impl FnMut(&Request) -> Option<Fault> + Send + 'static {
1787 let mut count = 0;
1788 move |request| {
1789 if !matches(request) {
1790 return None;
1791 }
1792 count += 1;
1793 (count == n).then_some(fault)
1794 }
1795 }
1796
1797 type Faults = Vec<Box<dyn FnMut(&Request) -> Option<Fault> + Send>>;
1798
1799 #[derive(Default)]
1803 pub(crate) struct Dav {
1804 held: Mutex<Held>,
1805 faults: Mutex<Faults>,
1806 }
1807
1808 #[derive(Default)]
1809 struct Held {
1810 calendars: BTreeMap<String, BTreeMap<String, (String, String)>>,
1812 version: u64,
1813 changes: Vec<(u64, String, String)>,
1815 seen: Vec<String>,
1817 uid_clash: Option<(u16, bool)>,
1820 elsewhere: Option<String>,
1824 }
1825
1826 impl Dav {
1827 pub(crate) fn start(calendars: &[&str]) -> (Arc<Dav>, String) {
1829 let dav = Arc::new(Dav::default());
1830 for name in calendars {
1831 dav.held().calendars.insert(name.to_string(), BTreeMap::new());
1832 }
1833 let base = serve(dav.clone());
1834 (dav, format!("{base}/cal/"))
1835 }
1836
1837 fn held(&self) -> MutexGuard<'_, Held> {
1838 self.held.lock().unwrap()
1839 }
1840
1841 pub(crate) fn put(&self, calendar: &str, item: &str, text: &str) {
1843 self.held().store(calendar, item, text);
1844 }
1845
1846 pub(crate) fn delete(&self, calendar: &str, item: &str) {
1848 self.held().delete(calendar, item);
1849 }
1850
1851 pub(crate) fn items(&self, calendar: &str) -> BTreeMap<String, String> {
1853 self.held().calendars[calendar].iter().map(|(name, (_, text))| (name.clone(), text.clone())).collect()
1854 }
1855
1856 pub(crate) fn seen(&self) -> Vec<String> {
1858 self.held().seen.clone()
1859 }
1860
1861 pub(crate) fn fault(&self, fault: impl FnMut(&Request) -> Option<Fault> + Send + 'static) {
1863 self.faults.lock().unwrap().push(Box::new(fault));
1864 }
1865
1866 pub(crate) fn one_uid_each(&self, status: u16, named: bool) {
1870 self.held().uid_clash = Some((status, named));
1871 }
1872
1873 pub(crate) fn name_elsewhere(&self, base: &str) {
1875 self.held().elsewhere = Some(base.to_string());
1876 }
1877 }
1878
1879 fn uid_line(text: &str) -> Option<&str> {
1881 text.lines().find_map(|l| l.trim_end_matches('\r').strip_prefix("UID:"))
1882 }
1883
1884 impl Server for Dav {
1885 fn misbehaves(&self, request: &Request) -> Option<Fault> {
1886 let fault = self.faults.lock().unwrap().iter_mut().find_map(|f| f(request));
1887 if let Some(fault @ (Fault::Status(_) | Fault::Drop | Fault::Lost)) = fault {
1889 self.held().seen.push(format!("{} {} {fault:?}", request.method, request.path));
1890 }
1891 fault
1892 }
1893
1894 fn answer(&self, request: &Request) -> Reply {
1895 let mut held = self.held();
1896 let reply = held.answer(request);
1897 held.seen.push(format!("{} {} {}", request.method, request.path, reply.status));
1898 reply
1899 }
1900 }
1901
1902 fn found(href: &str, props: &str) -> String {
1903 format!("<d:response><d:href>{href}</d:href><d:propstat><d:prop>{props}</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>")
1904 }
1905
1906 fn missing(href: &str) -> String {
1907 format!("<d:response><d:href>{href}</d:href><d:status>HTTP/1.1 404 Not Found</d:status></d:response>")
1908 }
1909
1910 fn multistatus(responses: &str) -> Reply {
1911 Reply::new(207, format!(r#"<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">{responses}</d:multistatus>"#))
1912 }
1913
1914 fn etag_prop(etag: &str) -> String {
1915 format!("<d:getetag>{}</d:getetag>", super::xml_escape(etag))
1916 }
1917
1918 impl Held {
1919 fn store(&mut self, calendar: &str, item: &str, text: &str) -> String {
1920 self.version += 1;
1921 let etag = format!("\"e{}\"", self.version);
1922 self.calendars.get_mut(calendar).expect("a calendar of the stand-in").insert(item.to_string(), (etag.clone(), text.to_string()));
1923 self.changes.push((self.version, calendar.to_string(), item.to_string()));
1924 etag
1925 }
1926
1927 fn delete(&mut self, calendar: &str, item: &str) {
1928 if self.calendars.get_mut(calendar).and_then(|items| items.remove(item)).is_some() {
1929 self.version += 1;
1930 self.changes.push((self.version, calendar.to_string(), item.to_string()));
1931 }
1932 }
1933
1934 fn last(&self, calendar: &str) -> u64 {
1936 self.changes.iter().filter(|(_, c, _)| c == calendar).map(|(v, _, _)| *v).max().unwrap_or(0)
1937 }
1938
1939 fn calendar_props(&self, calendar: &str) -> String {
1940 let last = self.last(calendar);
1941 format!("<d:resourcetype><d:collection/><c:calendar/></d:resourcetype><d:displayname>{calendar}</d:displayname><cs:getctag>ctag-{last}</cs:getctag><d:sync-token>token-{last}</d:sync-token>")
1942 }
1943
1944 fn answer(&mut self, request: &Request) -> Reply {
1945 let path = super::path_key(&request.path);
1946 let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
1947 match (request.method.as_str(), parts.as_slice()) {
1948 ("PROPFIND", ["cal"]) => {
1949 let mut out = found("/cal/", "<d:resourcetype><d:collection/></d:resourcetype>");
1950 for calendar in self.calendars.keys() {
1951 out += &found(&format!("/cal/{calendar}/"), &self.calendar_props(calendar));
1952 }
1953 if let Some(base) = &self.elsewhere {
1954 out += &found(&format!("{base}/cal/theirs/"), "<d:resourcetype><d:collection/><c:calendar/></d:resourcetype><d:displayname>theirs</d:displayname>");
1955 }
1956 multistatus(&out)
1957 }
1958 (_, ["cal", calendar, ..]) if !self.calendars.contains_key(*calendar) => Reply::new(404, ""),
1959 ("PROPFIND", ["cal", calendar]) => {
1960 let mut out = found(&format!("/cal/{calendar}/"), &self.calendar_props(calendar));
1961 for (item, (etag, _)) in &self.calendars[*calendar] {
1962 out += &found(&format!("/cal/{calendar}/{item}"), &format!("{}<d:resourcetype/>", etag_prop(etag)));
1963 }
1964 if let Some(base) = &self.elsewhere {
1965 out += &found(&format!("{base}/cal/{calendar}/there.ics"), &format!("{}<d:resourcetype/>", etag_prop("\"there\"")));
1966 }
1967 multistatus(&out)
1968 }
1969 ("REPORT", ["cal", calendar]) if request.body.contains("sync-collection") => self.changes_since(calendar, &request.body),
1970 ("REPORT", ["cal", calendar]) if request.body.contains("calendar-query") => self.query(calendar, &request.body),
1971 ("REPORT", ["cal", calendar]) => self.multiget(calendar, &request.body),
1972 ("PUT", ["cal", calendar, item]) => {
1973 let current = self.calendars[*calendar].get(*item).map(|(etag, _)| etag.clone());
1974 let refused = match (request.header("If-None-Match"), request.header("If-Match")) {
1975 (Some("*"), _) => current.is_some(),
1976 (_, Some(wanted)) => current.as_deref() != Some(wanted),
1977 _ => false,
1978 };
1979 if refused {
1980 return Reply::new(412, "");
1981 }
1982 let holder = uid_line(&request.body).and_then(|uid| self.calendars[*calendar].iter().find(|(name, (_, text))| name.as_str() != *item && uid_line(text) == Some(uid))).map(|(name, _)| name.clone());
1983 if current.is_none()
1984 && let (Some((status, named)), Some(holder)) = (self.uid_clash, holder)
1985 {
1986 let body = if named {
1987 format!(r#"<?xml version="1.0" encoding="utf-8"?><d:error xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><c:no-uid-conflict><d:href>/cal/{calendar}/{holder}</d:href></c:no-uid-conflict></d:error>"#)
1988 } else {
1989 "Calendar object with uid already exists in this calendar collection.".to_string()
1990 };
1991 return Reply::new(status, body);
1992 }
1993 let etag = self.store(calendar, item, &request.body);
1994 Reply { status: if current.is_some() { 204 } else { 201 }, headers: vec![("ETag", etag)], body: String::new() }
1995 }
1996 ("DELETE", ["cal", calendar, item]) => match (self.calendars[*calendar].get(*item), request.header("If-Match")) {
1997 (None, _) => Reply::new(404, ""),
1998 (Some((etag, _)), Some(wanted)) if etag != wanted => Reply::new(412, ""),
1999 _ => {
2000 self.delete(calendar, item);
2001 Reply::new(204, "")
2002 }
2003 },
2004 ("GET", ["cal", calendar, item]) => match self.calendars[*calendar].get(*item) {
2005 Some((etag, text)) => Reply { status: 200, headers: vec![("ETag", etag.clone())], body: text.clone() },
2006 None => Reply::new(404, ""),
2007 },
2008 _ => Reply::new(405, ""),
2009 }
2010 }
2011
2012 fn changes_since(&self, calendar: &str, body: &str) -> Reply {
2014 let doc = roxmltree::Document::parse(body).unwrap();
2015 let token = doc.descendants().find(|n| n.has_tag_name(("DAV:", "sync-token"))).and_then(|n| n.text()).unwrap_or("");
2016 let Some(since) = token.trim().strip_prefix("token-").and_then(|t| t.parse::<u64>().ok()) else { return Reply::new(403, "") };
2017 let changed: BTreeSet<&str> = self.changes.iter().filter(|(v, c, _)| *v > since && c == calendar).map(|(_, _, item)| item.as_str()).collect();
2018 let mut out = String::new();
2019 for item in changed {
2020 let href = format!("/cal/{calendar}/{item}");
2021 out += &match self.calendars[calendar].get(item) {
2022 Some((etag, _)) => found(&href, &etag_prop(etag)),
2023 None => missing(&href),
2024 };
2025 }
2026 out += &format!("<d:sync-token>token-{}</d:sync-token>", self.last(calendar));
2027 multistatus(&out)
2028 }
2029
2030 fn query(&self, calendar: &str, body: &str) -> Reply {
2032 let doc = roxmltree::Document::parse(body).unwrap();
2033 let uid = doc.descendants().find(|n| n.tag_name().name() == "text-match").and_then(|n| n.text()).unwrap_or("").to_string();
2034 let mut out = String::new();
2035 for (item, (etag, text)) in &self.calendars[calendar] {
2036 if !uid.is_empty() && uid_line(text).is_some_and(|u| u.contains(&uid)) {
2037 out += &found(&format!("/cal/{calendar}/{item}"), &etag_prop(etag));
2038 }
2039 }
2040 multistatus(&out)
2041 }
2042
2043 fn multiget(&self, calendar: &str, body: &str) -> Reply {
2044 let doc = roxmltree::Document::parse(body).unwrap();
2045 let mut out = String::new();
2046 for href in doc.descendants().filter(|n| n.has_tag_name(("DAV:", "href"))).filter_map(|n| n.text()) {
2047 let key = super::path_key(href);
2048 let item = key.rsplit('/').next().unwrap_or("");
2049 out += &match self.calendars[calendar].get(item) {
2050 Some((etag, text)) => found(href, &format!("{}<c:calendar-data>{}</c:calendar-data>", etag_prop(etag), super::xml_escape(text))),
2051 None => missing(href),
2052 };
2053 }
2054 multistatus(&out)
2055 }
2056 }
2057}
2058
2059#[cfg(test)]
2060mod tests {
2061 use super::stand_in::{Dav, Fault, home, nth};
2062 use super::*;
2063 use std::sync::Arc;
2064
2065 fn account(id: &str) -> Account {
2067 home();
2068 toml::from_str(&format!("id = \"{id}\"\nkind = \"dav\"\naddress = \"jane@example.org\"\nhost = \"127.0.0.1\"\n")).unwrap()
2069 }
2070
2071 fn client() -> Client {
2072 Client::new("jane", "secret")
2073 }
2074
2075 fn event(uid: &str, summary: &str) -> String {
2076 format!(
2077 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sioul//tests//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261005T120000Z\r\nDTSTART:20261006T090000Z\r\nDTEND:20261006T100000Z\r\nSUMMARY:{summary}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
2078 )
2079 }
2080
2081 fn listed(client: &Client, home: &str, name: &str) -> Listed {
2083 collections(client, Kind::Calendars, home).unwrap().into_iter().find(|l| l.id == name).unwrap()
2084 }
2085
2086 fn sync_one(client: &Client, account: &str, home: &str, name: &str) -> Result<Report, SyncError> {
2088 let mut report = Report::default();
2089 sync_collection(client, account, &listed(client, home, name), &mut report).map(|()| report)
2090 }
2091
2092 fn folder(account: &str, calendar: &str) -> PathBuf {
2093 Kind::Calendars.root().join(account).join(calendar)
2094 }
2095
2096 fn state_of(account: &str, calendar: &str) -> State {
2097 State::load(&vdir::state_path(account, Kind::Calendars, calendar))
2098 }
2099
2100 fn puts(dav: &Dav) -> Vec<String> {
2101 dav.seen().into_iter().filter(|s| s.starts_with("PUT")).collect()
2102 }
2103
2104 #[test]
2108 fn an_interrupted_pull_resumes() {
2109 home();
2110 let (dav, home) = Dav::start(&["big"]);
2111 for n in 0..120 {
2112 dav.put("big", &format!("e{n}.ics"), &event(&format!("uid-{n}"), &format!("Event {n}")));
2113 }
2114 dav.fault(nth(2, |r| r.method == "REPORT" && r.body.contains("multiget"), Fault::Drop));
2115 let client = client();
2116 assert!(matches!(sync_one(&client, "resumes", &home, "big"), Err(SyncError::Network(_))));
2117 let report = sync_one(&client, "resumes", &home, "big").unwrap();
2118 assert_eq!((report.sent, report.received, report.conflicts.len()), (0, 70, 0), "{report:?}");
2119 assert_eq!(local_files(&folder("resumes", "big"), Kind::Calendars).len(), 120);
2120 assert_eq!(dav.items("big").len(), 120);
2121 assert!(puts(&dav).is_empty(), "{:?}", dav.seen());
2122 }
2123
2124 #[test]
2127 fn a_sent_event_is_not_sent_again() {
2128 home();
2129 let (dav, home) = Dav::start(&["work"]);
2130 dav.put("work", "known.ics", &event("known", "Known"));
2131 let client = client();
2132 sync_one(&client, "sent-once", &home, "work").unwrap();
2133 dav.put("work", "theirs.ics", &event("theirs", "Theirs"));
2134 std::fs::write(folder("sent-once", "work").join("mine.ics"), event("mine", "Mine")).unwrap();
2135 dav.fault(nth(1, |r| r.method == "REPORT" && r.body.contains("sync-collection"), Fault::Drop));
2136 dav.fault(nth(1, |r| r.method == "PROPFIND" && r.path == "/cal/work/", Fault::Drop));
2137 assert!(sync_one(&client, "sent-once", &home, "work").is_err());
2138 let report = sync_one(&client, "sent-once", &home, "work").unwrap();
2139 assert_eq!((report.sent, report.received, report.conflicts.len()), (0, 1, 0), "{report:?}");
2140 assert_eq!(puts(&dav), ["PUT /cal/work/mine.ics 201"]);
2141 assert_eq!(dav.items("work").len(), 3);
2142 }
2143
2144 #[test]
2148 fn a_lost_answer_is_not_a_conflict() {
2149 home();
2150 let (dav, home) = Dav::start(&["home"]);
2151 let client = client();
2152 sync_one(&client, "lost", &home, "home").unwrap();
2153 let dir = folder("lost", "home");
2154 std::fs::write(dir.join("new.ics"), event("new", "New")).unwrap();
2155 dav.fault(nth(1, |r| r.method == "PUT", Fault::Lost));
2156 assert!(matches!(sync_one(&client, "lost", &home, "home"), Err(SyncError::Network(_))));
2157 let report = sync_one(&client, "lost", &home, "home").unwrap();
2158 assert!(report.conflicts.is_empty(), "{report:?}");
2159 assert_eq!(dav.items("home").len(), 1);
2160 assert_eq!(std::fs::read_to_string(dir.join("new.ics")).unwrap(), event("new", "New"));
2161
2162 dav.put("home", "clash.ics", &event("theirs", "Theirs"));
2163 std::fs::write(dir.join("clash.ics"), event("mine", "Mine")).unwrap();
2164 let report = sync_one(&client, "lost", &home, "home").unwrap();
2165 assert_eq!(report.conflicts.len(), 1, "{report:?}");
2166 assert!(std::fs::read_to_string(&report.conflicts[0]).unwrap().contains("SUMMARY:Mine"));
2167 assert!(std::fs::read_to_string(dir.join("clash.ics")).unwrap().contains("SUMMARY:Theirs"));
2168 let report = sync_one(&client, "lost", &home, "home").unwrap();
2169 assert_eq!((report.sent, report.received, report.conflicts.len()), (0, 0, 0), "{report:?}");
2170 }
2171
2172 #[derive(Default)]
2174 struct Elsewhere {
2175 seen: Mutex<Vec<String>>,
2176 }
2177
2178 impl stand_in::Server for Elsewhere {
2179 fn misbehaves(&self, _: &stand_in::Request) -> Option<Fault> {
2180 None
2181 }
2182
2183 fn answer(&self, request: &stand_in::Request) -> stand_in::Reply {
2184 self.seen.lock().unwrap().push(format!("{} {} {}", request.method, request.path, request.header("Authorization").unwrap_or("")));
2185 stand_in::Reply::new(404, "")
2186 }
2187 }
2188
2189 #[test]
2193 fn the_login_stays_on_the_account_s_server() {
2194 let account = account("kept-to");
2195 let elsewhere = Arc::new(Elsewhere::default());
2196 let there = stand_in::serve(elsewhere.clone());
2197 let (dav, home) = Dav::start(&["mine", "only-there"]);
2200 Homes { calendars: Some(home.clone()), contacts: None }.save(&account.id).unwrap();
2201 dav.put("mine", "new.ics", &event("new", "New here"));
2202 dav.name_elsewhere(&there);
2203 let report = sync_with(&client(), &account).unwrap();
2204 assert!(matches!(report.problems.first(), Some(SyncError::Elsewhere(url)) if url.starts_with(&there)), "{report:?}");
2205 assert!(folder("kept-to", "mine").join("new.ics").exists());
2206 assert!(dav.seen().iter().any(|s| s.starts_with("REPORT /cal/only-there/")), "{:?}", dav.seen());
2207 assert!(elsewhere.seen.lock().unwrap().is_empty(), "{:?}", elsewhere.seen.lock().unwrap());
2208 let said = report.problems[0].sentence(&sioul_core::i18n::Translator::new("en"), "Calendars");
2210 assert!(said.starts_with("Calendars: the server named an address on another server") && said.contains(&there), "{said}");
2211 let kept = client();
2213 kept.keep_to(&Homes { calendars: Some(home.clone()), contacts: None });
2214 assert!(kept.mine(&format!("{home}mine/new.ics")).is_ok());
2215 assert!(kept.mine(&home.replacen("http://", "HTTP://", 1)).is_ok());
2216 assert!(kept.mine(&format!("{there}/cal/")).is_err());
2217 assert!(kept.mine("https://127.0.0.1/cal/").is_err());
2218 let pointing = stand_in::serve(Arc::new(Pointing(format!("{}/principals/jane/", there.replace("127.0.0.1", "localhost")))));
2220 assert!(matches!(super::home(&client(), Kind::Calendars, &format!("{pointing}/")), Err(SyncError::Elsewhere(url)) if url.contains("localhost")));
2221 assert!(elsewhere.seen.lock().unwrap().is_empty());
2222 }
2223
2224 struct Pointing(String);
2226
2227 impl stand_in::Server for Pointing {
2228 fn misbehaves(&self, _: &stand_in::Request) -> Option<Fault> {
2229 None
2230 }
2231
2232 fn answer(&self, _: &stand_in::Request) -> stand_in::Reply {
2233 stand_in::Reply::new(
2234 207,
2235 format!(
2236 r#"<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/</d:href><d:propstat><d:prop><d:current-user-principal><d:href>{}</d:href></d:current-user-principal></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#,
2237 self.0
2238 ),
2239 )
2240 }
2241 }
2242
2243 #[test]
2244 fn origins_are_scheme_host_and_port() {
2245 assert_eq!(origin("https://Dav.Example.org/remote.php/dav/").as_deref(), Some("https://dav.example.org:443"));
2246 assert_eq!(origin("https://dav.example.org:443/x"), origin("https://dav.example.org/y?z"));
2247 assert_ne!(origin("https://dav.example.org:8443/"), origin("https://dav.example.org/"));
2248 assert_ne!(origin("http://dav.example.org/"), origin("https://dav.example.org/"));
2249 assert_eq!(origin("https://dav.example.org@other.example.org/").as_deref(), Some("https://other.example.org:443"));
2251 assert_eq!(origin("https://[2001:db8::1]:8443/").as_deref(), Some("https://[2001:db8::1]:8443"));
2252 assert_eq!(origin("https://[2001:db8::1]/").as_deref(), Some("https://[2001:db8::1]:443"));
2253 assert_eq!(origin("/cal/a/"), None);
2254 assert_eq!(origin("https://dav.example.org:x/"), None);
2255 assert_eq!(origin("ftp://dav.example.org/"), None);
2256 }
2257
2258 #[test]
2261 fn one_bad_calendar_does_not_stop_the_others() {
2262 let account = account("bad-one");
2263 let (dav, home) = Dav::start(&["a", "b"]);
2264 Homes { calendars: Some(home), contacts: None }.save(&account.id).unwrap();
2265 dav.put("b", "new.ics", &event("new", "New in b"));
2266 dav.fault(nth(1, |r| r.method == "PROPFIND" && r.path == "/cal/a/", Fault::Status(500)));
2267 let report = sync_with(&client(), &account).unwrap();
2268 assert!(matches!(report.problems.first(), Some(SyncError::Server(e)) if e.contains("/cal/a/")), "{report:?}");
2269 assert_eq!((report.listed, report.collections), (2, 1));
2270 assert!(folder("bad-one", "b").join("new.ics").exists());
2271 dav.put("b", "newer.ics", &event("newer", "Newer in b"));
2272 dav.fault(nth(1, |r| r.method == "PROPFIND" && r.path == "/cal/a/", Fault::Drop));
2273 assert!(matches!(sync_with(&client(), &account).unwrap().problems.first(), Some(SyncError::Network(_))));
2274 assert!(folder("bad-one", "b").join("newer.ics").exists());
2275 assert_eq!(sync_with(&client(), &account).unwrap().collections, 2);
2276 }
2277
2278 #[test]
2288 fn every_listed_calendar_exists_before_any_is_pulled() {
2289 let account = account("listed-first");
2290 let (dav, home) = Dav::start(&["early", "later-a", "later-b"]);
2291 Homes { calendars: Some(home), contacts: None }.save(&account.id).unwrap();
2292 for n in 0..300 {
2293 dav.put("early", &format!("e{n}.ics"), &event(&format!("uid-{n}"), &format!("Event {n}")));
2294 }
2295 dav.put("later-a", "x.ics", &event("x", "In a"));
2296 dav.put("later-b", "y.ics", &event("y", "In b"));
2297 dav.fault(|r: &stand_in::Request| (r.method == "REPORT" && r.path == "/cal/early/" && r.body.contains("multiget")).then_some(Fault::Late(Duration::from_millis(200))));
2299 let report = sync_with(&client(), &account).unwrap();
2300 assert_eq!((report.listed, report.collections, report.registered), (3, 3, 3), "{report:?}");
2301 assert_eq!(sync_with(&client(), &account).unwrap().registered, 0, "made already: nothing new for the pages");
2302 let mine: Vec<_> = vdir::collections(Kind::Calendars).into_iter().filter(|c| c.account == "listed-first").collect();
2303 assert_eq!(mine.len(), 3, "{mine:?}");
2304 for name in ["early", "later-a", "later-b"] {
2305 assert!(vdir::state_path("listed-first", Kind::Calendars, name).exists(), "{name}: its state");
2306 }
2307 assert_eq!(local_files(&folder("listed-first", "early"), Kind::Calendars).len(), 300);
2308 let seen = dav.seen();
2309 let first_a = seen.iter().position(|s| s.starts_with("REPORT /cal/later-a/")).unwrap();
2310 let last_big = seen.iter().rposition(|s| s.starts_with("REPORT /cal/early/")).unwrap();
2311 assert!(first_a < last_big, "a waited for early's whole pull: {seen:?}");
2312 }
2313
2314 #[test]
2317 fn a_killed_first_pull_does_not_hide_the_others() {
2318 let account = account("killed-first");
2319 let (dav, home) = Dav::start(&["first", "second", "third"]);
2320 Homes { calendars: Some(home), contacts: None }.save(&account.id).unwrap();
2321 for n in 0..120 {
2322 dav.put("first", &format!("e{n}.ics"), &event(&format!("uid-{n}"), &format!("Event {n}")));
2323 }
2324 dav.put("third", "y.ics", &event("y", "In b"));
2325 let made_before = Arc::new(std::sync::atomic::AtomicBool::new(false));
2328 let seen_made = Arc::clone(&made_before);
2329 dav.fault(move |r: &stand_in::Request| {
2330 if r.method == "REPORT" && r.path == "/cal/first/" && r.body.contains("multiget") && !seen_made.swap(true, std::sync::atomic::Ordering::SeqCst) {
2331 let made = ["second", "third"].iter().all(|name| folder("killed-first", name).join("displayname").exists()) && vdir::state_path("killed-first", Kind::Calendars, "third").exists();
2332 seen_made.store(made, std::sync::atomic::Ordering::SeqCst);
2333 return Some(Fault::Drop);
2334 }
2335 None
2336 });
2337 let report = sync_with(&client(), &account).unwrap();
2338 assert!(matches!(report.problems.first(), Some(SyncError::Network(_))), "{report:?}");
2339 assert!(["first", "second", "third"].iter().all(|name| folder("killed-first", name).is_dir()), "every calendar made before the cut");
2340 assert!(made_before.load(std::sync::atomic::Ordering::SeqCst), "second and third were made before first's first pull");
2341 assert!(folder("killed-first", "third").join("y.ics").exists());
2342 let report = sync_with(&client(), &account).unwrap();
2343 assert!(report.problems.is_empty() && report.collections == 3, "{report:?}");
2344 assert_eq!(local_files(&folder("killed-first", "first"), Kind::Calendars).len(), 120);
2345 assert!(puts(&dav).is_empty(), "nothing sent back: {:?}", dav.seen());
2346 }
2347
2348 #[test]
2352 fn a_lasting_error_on_one_calendar_keeps_the_others() {
2353 let account = account("lasting-one");
2354 let (dav, home) = Dav::start(&["a", "b", "c"]);
2355 Homes { calendars: Some(home), contacts: None }.save(&account.id).unwrap();
2356 dav.put("a", "x.ics", &event("x", "In a"));
2357 dav.put("c", "z.ics", &event("z", "In c"));
2358 dav.fault(|r: &stand_in::Request| (r.path.starts_with("/cal/b/")).then_some(Fault::Status(401)));
2359 let report = sync_with(&client(), &account).unwrap();
2360 assert_eq!((report.listed, report.collections), (3, 2), "{report:?}");
2361 assert!(matches!(report.problems.as_slice(), [SyncError::Login(_)]), "{report:?}");
2362 assert!(folder("lasting-one", "a").join("x.ics").exists() && folder("lasting-one", "c").join("z.ics").exists());
2363 assert!(folder("lasting-one", "b").is_dir(), "listed, made, to be pulled once it answers");
2364 }
2365
2366 #[test]
2369 fn calendars_do_not_wait_for_contacts() {
2370 let account = account("contacts-first");
2371 let (dav, home) = Dav::start(&["a"]);
2372 let books = home.replace("/cal/", "/book/");
2373 Homes { calendars: Some(home), contacts: Some(books) }.save(&account.id).unwrap();
2374 dav.put("a", "x.ics", &event("x", "In a"));
2375 dav.fault(nth(1, |r| r.path.starts_with("/book/"), Fault::Status(500)));
2376 let report = sync_with(&client(), &account).unwrap();
2377 assert!(folder("contacts-first", "a").join("x.ics").exists(), "{report:?}");
2378 assert!(!report.problems.is_empty(), "the book's failure said");
2379 dav.put("a", "y.ics", &event("y", "Again in a"));
2380 dav.fault(|r: &stand_in::Request| r.path.starts_with("/book/").then_some(Fault::Status(401)));
2381 let result = sync_with(&client(), &account);
2382 assert!(matches!(result, Err(SyncError::Login(_))), "the contacts' home refused: the account's error, {result:?}");
2383 assert!(folder("contacts-first", "a").join("y.ics").exists(), "the calendars synced first all the same");
2384 let seen = dav.seen();
2385 let book = seen.iter().position(|s| s.contains("/book/")).unwrap();
2386 let calendar = seen.iter().position(|s| s.contains("/cal/a/")).unwrap();
2387 assert!(calendar < book, "calendars first: {seen:?}");
2388 }
2389
2390 #[test]
2395 fn a_moved_event_is_deleted_there_only_once_its_new_copy_is() {
2396 let account = account("moved-later");
2397 let (dav, home) = Dav::start(&["a", "b"]);
2398 Homes { calendars: Some(home.clone()), contacts: None }.save(&account.id).unwrap();
2399 dav.put("a", "x.ics", &event("x", "Dentist"));
2400 sync_with(&client(), &account).unwrap();
2401 let (from, to) = (folder("moved-later", "a").join("x.ics"), folder("moved-later", "b").join("x-moved.ics"));
2402 assert!(from.exists());
2403 vdir::move_item(&from, &to).unwrap();
2404 let refusing = Arc::new(std::sync::atomic::AtomicBool::new(true));
2405 let refuse = Arc::clone(&refusing);
2406 dav.fault(move |r: &stand_in::Request| (r.method == "PUT" && r.path.starts_with("/cal/b/") && refuse.load(std::sync::atomic::Ordering::Relaxed)).then_some(Fault::Status(403)));
2407 let _ = sync_with(&client(), &account);
2408 assert!(dav.items("a").contains_key("x.ics"), "the old copy stays while the new one is refused: {:?}", dav.seen());
2409 assert!(dav.items("b").is_empty());
2410 refusing.store(false, std::sync::atomic::Ordering::Relaxed);
2411 for _ in 0..2 {
2412 sync_with(&client(), &account).unwrap();
2413 }
2414 assert!(dav.items("a").is_empty(), "then the old one goes: {:?}", dav.seen());
2415 assert_eq!(dav.items("b").len(), 1);
2416 let seen = dav.seen();
2417 let put = seen.iter().position(|s| s.starts_with("PUT /cal/b/") && s.ends_with(" 201")).unwrap();
2418 let delete = seen.iter().position(|s| s.starts_with("DELETE /cal/a/")).unwrap();
2419 assert!(put < delete, "the new copy first: {seen:?}");
2420 }
2421
2422 thread_local! {
2423 pub(super) static READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
2425 }
2426
2427 fn reads() -> usize {
2428 READS.with(std::cell::Cell::get)
2429 }
2430
2431 #[test]
2434 fn unchanged_files_are_not_read() {
2435 home();
2436 let (dav, home) = Dav::start(&["many"]);
2437 for n in 0..1000 {
2438 dav.put("many", &format!("e{n}.ics"), &event(&format!("u{n}"), "Same"));
2439 }
2440 let client = client();
2441 sync_one(&client, "unread", &home, "many").unwrap();
2442 let dir = folder("unread", "many");
2444 let hour_ago = std::time::SystemTime::now() - Duration::from_secs(3600);
2445 for file in local_files(&dir, Kind::Calendars) {
2446 std::fs::File::options().write(true).open(dir.join(file)).unwrap().set_modified(hour_ago).unwrap();
2447 }
2448 sync_one(&client, "unread", &home, "many").unwrap();
2449 let before = reads();
2450 assert_eq!(sync_one(&client, "unread", &home, "many").unwrap().sent, 0);
2451 assert_eq!(reads() - before, 0, "nothing read");
2452 std::fs::write(dir.join("e7.ics"), event("u7", "Edited")).unwrap();
2453 let before = reads();
2454 assert_eq!(sync_one(&client, "unread", &home, "many").unwrap().sent, 1);
2455 assert_eq!(reads() - before, 1, "the edited file only");
2456 assert!(dav.items("many")["e7.ics"].contains("SUMMARY:Edited"));
2457 }
2458
2459 #[test]
2463 fn a_slow_answer_is_read_while_it_moves() {
2464 home();
2465 let (dav, home) = Dav::start(&["slow"]);
2466 for n in 0..40 {
2467 dav.put("slow", &format!("e{n}.ics"), &event(&format!("u{n}"), "Slow"));
2468 }
2469 let budget = Budget { connect: Duration::from_secs(5), stall: Duration::from_millis(500), whole: Duration::from_secs(60) };
2470 let client = Client { agent: agent(&budget), ..client() };
2471 dav.fault(nth(1, |r| r.body.contains("multiget"), Fault::Trickle(Duration::from_secs(3))));
2472 let started = std::time::Instant::now();
2473 assert_eq!(sync_one(&client, "slow", &home, "slow").unwrap().received, 40);
2474 assert!(started.elapsed() > Duration::from_secs(3));
2475 dav.put("slow", "late.ics", &event("late", "Late"));
2476 dav.fault(nth(1, |r| r.body.contains("sync-collection"), Fault::Late(Duration::from_secs(5))));
2477 dav.fault(nth(1, |r| r.method == "PROPFIND" && r.path == "/cal/slow/", Fault::Late(Duration::from_secs(5))));
2478 let started = std::time::Instant::now();
2479 assert!(matches!(sync_one(&client, "slow", &home, "slow"), Err(SyncError::Network(_))));
2480 assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed());
2481 }
2482
2483 #[test]
2487 fn batches_follow_the_answers() {
2488 home();
2489 let (dav, home) = Dav::start(&["paced", "cut"]);
2490 for n in 0..200 {
2491 dav.put("paced", &format!("e{n}.ics"), &event(&format!("p{n}"), "Paced"));
2492 }
2493 for n in 0..120 {
2494 dav.put("cut", &format!("e{n}.ics"), &event(&format!("c{n}"), "Cut"));
2495 }
2496 let asked = std::sync::Arc::new(Mutex::new(Vec::<(String, usize)>::new()));
2498 let noted = std::sync::Arc::clone(&asked);
2499 dav.fault(move |r| {
2500 if r.body.contains("multiget") {
2501 noted.lock().unwrap().push((r.path.clone(), r.body.matches("<d:href>").count()));
2502 }
2503 None
2504 });
2505 let of = |calendar: &str| asked.lock().unwrap().iter().filter(|(path, _)| *path == format!("/cal/{calendar}/")).map(|(_, n)| *n).collect::<Vec<_>>();
2506 dav.fault(nth(1, |r| r.path == "/cal/paced/" && r.body.contains("multiget"), Fault::Late(SLOW + Duration::from_millis(300))));
2507 let client = client();
2508 assert_eq!(sync_one(&client, "paced", &home, "paced").unwrap().received, 200);
2509 assert_eq!(of("paced"), [50, 25, 50, 50, 25]);
2510 dav.fault(nth(1, |r| r.path == "/cal/cut/" && r.body.contains("multiget"), Fault::Drop));
2511 assert!(matches!(sync_one(&client, "paced-cut", &home, "cut"), Err(SyncError::Network(_))));
2512 assert_eq!(sync_one(&client, "paced-cut", &home, "cut").unwrap().received, 120);
2513 assert_eq!(of("cut"), [50, 25, 50, 45]);
2514 }
2515
2516 #[test]
2524 fn an_invitation_answered_on_two_devices() {
2525 home();
2526 let invitation = |uid: &str, answer: &str| {
2527 format!(
2528 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sioul//tests//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261005T120000Z\r\nDTSTART:20261012T090000Z\r\nDTEND:20261012T100000Z\r\nSUMMARY:Review\r\nORGANIZER;CN=Jane:mailto:jane@example.org\r\nATTENDEE;CN=Me;PARTSTAT={answer}:mailto:me@example.net\r\nATTENDEE;CN=Paul:mailto:paul@example.org\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
2529 )
2530 };
2531 for (n, (status, named)) in [(403, true), (409, true), (400, false), (412, false)].into_iter().enumerate() {
2532 let (dav, home) = Dav::start(&["invited"]);
2533 dav.one_uid_each(status, named);
2534 let (client, account) = (client(), format!("invited-{n}"));
2535 sync_one(&client, &account, &home, "invited").unwrap();
2536 let dir = folder(&account, "invited");
2537 dav.put("invited", "phone.ics", &invitation("review-1", "ACCEPTED"));
2539 std::fs::write(dir.join("desk.ics"), invitation("review-1", "ACCEPTED")).unwrap();
2540 let report = sync_one(&client, &account, &home, "invited").unwrap();
2541 assert!(report.answered.is_empty() && report.conflicts.is_empty(), "{status}: {report:?}");
2542 assert_eq!(local_files(&dir, Kind::Calendars), BTreeSet::from(["desk.ics".to_string()]), "{status}: one file, under its name here");
2543 assert_eq!(dav.items("invited").len(), 1, "{status}");
2544 let again = sync_one(&client, &account, &home, "invited").unwrap();
2545 assert_eq!((again.sent, again.received), (0, 0), "{status}: settled: {again:?}");
2546 dav.put("invited", "phone-2.ics", &invitation("review-2", "TENTATIVE"));
2548 std::fs::write(dir.join("desk-2.ics"), invitation("review-2", "ACCEPTED")).unwrap();
2549 let report = sync_one(&client, &account, &home, "invited").unwrap();
2550 assert_eq!(report.answered, [("Review".to_string(), "TENTATIVE".to_string())], "{status}: {report:?}");
2551 assert!(report.conflicts.is_empty(), "{status}: {report:?}");
2552 assert!(std::fs::read_to_string(dir.join("desk-2.ics")).unwrap().contains("PARTSTAT=TENTATIVE:mailto:me@example.net"), "{status}");
2553 assert_eq!(local_files(&dir, Kind::Calendars).len(), 2, "{status}");
2554 assert!(dav.items("invited")["phone-2.ics"].contains("PARTSTAT=TENTATIVE"), "{status}: the server's left as it was");
2555 assert!(!puts(&dav).iter().any(|p| p.ends_with(" 201") || p.ends_with(" 204")), "{status}: nothing written there: {:?}", dav.seen());
2556 }
2557 }
2558
2559 #[test]
2564 fn a_refused_password_parks_the_watcher() {
2565 let control = Control::default();
2566 let syncs = std::sync::atomic::AtomicUsize::new(0);
2567 let kept = std::sync::Mutex::new("old".to_string());
2568 let (said, heard) = std::sync::mpsc::channel();
2569 std::thread::scope(|s| {
2570 s.spawn(|| {
2571 let sync = || match syncs.fetch_add(1, std::sync::atomic::Ordering::Relaxed) {
2572 0 | 1 => Err(SyncError::Login("PROPFIND: 401".into())),
2573 _ => Ok(Report::default()),
2574 };
2575 watch_with(&control, Duration::from_secs(3600), sync, || Some(kept.lock().unwrap().clone()), |result| said.send(result.is_ok()).unwrap());
2576 });
2577 assert_eq!(heard.recv_timeout(Duration::from_secs(5)), Ok(false), "refused");
2578 control.nudge();
2579 assert!(heard.recv_timeout(Duration::from_millis(300)).is_err(), "the same password: parked");
2580 control.retry();
2581 assert_eq!(heard.recv_timeout(Duration::from_secs(5)), Ok(false), "Sync now: tried, refused again");
2582 *kept.lock().unwrap() = "new".into();
2583 control.nudge();
2584 assert_eq!(heard.recv_timeout(Duration::from_secs(5)), Ok(true), "another: synced at once");
2585 control.stop();
2586 });
2587 assert_eq!(syncs.load(std::sync::atomic::Ordering::Relaxed), 3);
2588 }
2589
2590 #[test]
2593 fn a_change_to_an_item_gone_there_is_set_aside() {
2594 home();
2595 let (dav, home) = Dav::start(&["gone"]);
2596 dav.put("gone", "x.ics", &event("x", "Before"));
2597 let client = client();
2598 sync_one(&client, "gone-edited", &home, "gone").unwrap();
2599 let file = folder("gone-edited", "gone").join("x.ics");
2600 dav.delete("gone", "x.ics");
2601 std::fs::write(&file, event("x", "Edited here")).unwrap();
2602 let report = sync_one(&client, "gone-edited", &home, "gone").unwrap();
2603 assert_eq!((report.removed, report.conflicts.len()), (1, 1), "{report:?}");
2604 assert!(std::fs::read_to_string(&report.conflicts[0]).unwrap().contains("SUMMARY:Edited here"));
2605 assert!(!file.exists());
2606 assert!(state_of("gone-edited", "gone").items.is_empty());
2607 }
2608
2609 #[test]
2612 fn one_sync_of_an_account_at_a_time() {
2613 let account = account("one-at-a-time");
2614 let (dav, home) = Dav::start(&["a"]);
2615 Homes { calendars: Some(home), contacts: None }.save(&account.id).unwrap();
2616 sync_with(&client(), &account).unwrap();
2617 std::fs::write(folder("one-at-a-time", "a").join("new.ics"), event("new", "New")).unwrap();
2618 dav.fault(|r| (r.method == "PUT").then_some(Fault::Late(Duration::from_millis(300))));
2619 let both: Vec<_> = (0..2)
2620 .map(|_| {
2621 let account = account.clone();
2622 std::thread::spawn(move || sync_with(&client(), &account))
2623 })
2624 .collect();
2625 for sync in both {
2626 sync.join().unwrap().unwrap();
2627 }
2628 assert_eq!(puts(&dav), ["PUT /cal/a/new.ics 201"]);
2629 assert_eq!(state_of("one-at-a-time", "a").items.iter().filter(|i| i.file == "new.ics").count(), 1);
2630 }
2631
2632 #[test]
2635 fn a_cut_push_keeps_its_place() {
2636 home();
2637 let (dav, home) = Dav::start(&["four"]);
2638 for n in 0..4 {
2639 dav.put("four", &format!("e{n}.ics"), &event(&format!("u{n}"), "Before"));
2640 }
2641 let client = client();
2642 sync_one(&client, "cut-push", &home, "four").unwrap();
2643 let dir = folder("cut-push", "four");
2644 for n in 0..4 {
2645 std::fs::write(dir.join(format!("e{n}.ics")), event(&format!("u{n}"), "After")).unwrap();
2646 }
2647 let before = state_of("cut-push", "four").items;
2648 dav.fault(nth(3, |r| r.method == "PUT", Fault::Drop));
2649 let mut state = state_of("cut-push", "four");
2650 let (mut refetch, mut report) = (BTreeSet::new(), Report::default());
2651 assert!(push(&client, Kind::Calendars, &dir, &listed(&client, &home, "four"), &mut state, &mut refetch, &mut report).is_err());
2652 assert_eq!(state.items.len(), 4, "every item still known: {:?}", state.items);
2653 for (now, then) in state.items.iter().zip(&before).take(2) {
2654 assert_ne!(now.etag, then.etag);
2655 assert_eq!(Some(now.hash.clone()), hash_of(&dir.join(&now.file)));
2656 }
2657 assert_eq!(state.items[2..], before[2..]);
2658 }
2659
2660 #[test]
2661 fn reads_a_multistatus() {
2662 let xml = r#"<?xml version="1.0"?>
2663<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:x="http://apple.com/ns/ical/">
2664 <d:response><d:href>/dav/calendars/jane/personal/</d:href>
2665 <d:propstat><d:prop><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype><d:displayname>Personal</d:displayname>
2666 <x:calendar-color>#4C6B5CFF</x:calendar-color><cal:supported-calendar-component-set><cal:comp name="VEVENT"/></cal:supported-calendar-component-set>
2667 <d:current-user-privilege-set><d:privilege><d:read/></d:privilege></d:current-user-privilege-set></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
2668 <d:propstat><d:prop><d:getctag/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response>
2669 <d:response><d:href>/dav/calendars/jane/personal/gone.ics</d:href><d:status>HTTP/1.1 404 Not Found</d:status></d:response>
2670 <d:sync-token>http://example.org/sync/42</d:sync-token>
2671</d:multistatus>"#;
2672 let (responses, token) = multistatus(xml).unwrap();
2673 assert_eq!(token.as_deref(), Some("http://example.org/sync/42"));
2674 assert!(responses[0].is(CALDAV, "calendar"));
2675 assert_eq!(responses[0].text(DAV, "displayname").as_deref(), Some("Personal"));
2676 assert_eq!(responses[0].prop(CALDAV, "supported-calendar-component-set").unwrap().comps, vec!["VEVENT"]);
2677 assert!(responses[0].prop(CALSERVER, "getctag").is_none());
2678 assert_eq!(responses[1].status, Some(404));
2679 }
2680
2681 #[test]
2682 fn addresses_and_names() {
2683 assert_eq!(absolute("https://h.example/remote.php/dav/", "/remote.php/dav/principals/jane/"), "https://h.example/remote.php/dav/principals/jane/");
2684 assert_eq!(absolute("https://h.example/a/b/", "c.ics"), "https://h.example/a/b/c.ics");
2685 assert_eq!(path_key("https://h.example/a%20b/c.ics"), "/a b/c.ics");
2686 assert_eq!(path_key("/a/b/"), "/a/b");
2687 let taken: BTreeSet<String> = ["abc.ics".to_string()].into_iter().collect();
2688 assert_eq!(file_name("/cal/abc.ics", "ics", &taken), "abc-2.ics");
2689 assert_eq!(file_name("/cal/a%20b:c.ics", "ics", &BTreeSet::new()), "a_b_c.ics");
2690 assert_eq!(file_name("/cal/..hidden.ics", "ics", &BTreeSet::new()), "hidden.ics", "never a hidden file");
2691 assert_eq!(file_name("/book/CON.vcf", "vcf", &BTreeSet::new()), "_CON.vcf", "nor a name Windows keeps");
2692 assert_eq!(file_name("/book/com1.x.vcf", "vcf", &BTreeSet::new()), "_com1.x.vcf");
2693 assert_eq!(url_segment("sioul-1.vcf"), "sioul-1.vcf");
2694 assert!(allowed("http://example.org/").is_err());
2695 assert_eq!(domain("https://caldav.example.org:8443/dav/"), domain("https://example.org/.well-known/caldav"));
2697 assert_ne!(domain("https://parking.example.net/x"), domain("https://example.org/.well-known/caldav"));
2698 assert_eq!(xml_escape(r#"a<"b"&"#), "a<"b"&");
2699 }
2700}