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 fn mine(&self, url: &str) -> Result<(), SyncError> {
180 match &*self.own.lock().unwrap_or_else(std::sync::PoisonError::into_inner) {
181 Some(own) if !origin(url).is_some_and(|o| own.contains(&o)) => Err(SyncError::Elsewhere(url.to_string())),
182 _ => Ok(()),
183 }
184 }
185
186 fn send(&self, method: &str, url: &str, headers: &[(&str, &str)], body: Option<&str>) -> Result<Answer, SyncError> {
187 match (self.send_once(method, url, headers, body), &self.google) {
188 (Err(SyncError::Login(_)), Some(address)) => {
190 let token = crate::google::access_token(address, true)?;
191 if let Ok(mut authorization) = self.authorization.lock() {
192 *authorization = format!("Bearer {token}");
193 }
194 self.send_once(method, url, headers, body)
195 }
196 (result, _) => result,
197 }
198 }
199
200 fn send_once(&self, method: &str, url: &str, headers: &[(&str, &str)], body: Option<&str>) -> Result<Answer, SyncError> {
201 allowed(url)?;
202 self.mine(url)?;
203 let authorization = self.authorization.lock().map(|a| a.clone()).unwrap_or_default();
204 let mut request = ureq::http::Request::builder().method(method).uri(url).header("Authorization", &authorization).header("User-Agent", "Sioul");
205 for (name, value) in headers {
206 request = request.header(*name, *value);
207 }
208 let request = request.body(body.unwrap_or("").to_string()).map_err(|e| SyncError::Server(e.to_string()))?;
209 let mut response = self.agent.run(request).map_err(|e| SyncError::Network(e.to_string()))?;
210 let header = |name: &str| response.headers().get(name).and_then(|v| v.to_str().ok()).map(str::to_string);
211 let (etag, location) = (header("ETag"), header("Location"));
212 let status = response.status().as_u16();
213 let body = response.body_mut().with_config().limit(64 * 1024 * 1024).read_to_string().unwrap_or_default();
214 if status == 401 {
215 return Err(SyncError::Login(format!("{method} {url}: 401")));
216 }
217 Ok(Answer { status, etag, location, body })
218 }
219
220 fn propfind(&self, url: &str, depth: &str, props: &str) -> Result<(u16, Vec<Response>), SyncError> {
221 let body = format!(
222 r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:" xmlns:c="{CALDAV}" xmlns:a="{CARDDAV}" xmlns:cs="{CALSERVER}" xmlns:ic="{APPLE}"><d:prop>{props}</d:prop></d:propfind>"#
223 );
224 let answer = self.send("PROPFIND", url, &[("Depth", depth), ("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
225 if answer.status != 207 {
226 return Ok((answer.status, Vec::new()));
227 }
228 Ok((answer.status, multistatus(&answer.body)?.0))
229 }
230}
231
232pub(crate) fn allowed(url: &str) -> Result<(), SyncError> {
234 if url.starts_with("https://") {
235 return Ok(());
236 }
237 #[cfg(feature = "insecure-test-tls")]
238 if std::env::var_os("SIOUL_TEST_INSECURE_TLS").is_some() && (url.starts_with("http://localhost") || url.starts_with("http://127.0.0.1")) {
239 return Ok(());
240 }
241 #[cfg(test)]
243 if url.starts_with("http://127.0.0.1:") {
244 return Ok(());
245 }
246 Err(SyncError::Tls(format!("{url}: not encrypted")))
247}
248
249#[derive(Debug, Default, Clone)]
251pub(crate) struct Response {
252 pub(crate) href: String,
253 pub(crate) status: Option<u16>,
255 pub(crate) props: Vec<Prop>,
256}
257
258#[derive(Debug, Default, Clone)]
260pub(crate) struct Prop {
261 ns: String,
262 name: String,
263 status: u16,
264 text: String,
265 pub(crate) hrefs: Vec<String>,
266 children: Vec<(String, String)>,
268 comps: Vec<String>,
270}
271
272impl Response {
273 pub(crate) fn prop(&self, ns: &str, name: &str) -> Option<&Prop> {
274 self.props.iter().find(|p| p.ns == ns && p.name == name && p.status / 100 == 2)
275 }
276
277 pub(crate) fn text(&self, ns: &str, name: &str) -> Option<String> {
278 self.prop(ns, name).map(|p| p.text.trim().to_string()).filter(|t| !t.is_empty())
279 }
280
281 pub(crate) fn is(&self, ns: &str, kind: &str) -> bool {
282 self.prop(DAV, "resourcetype").is_some_and(|p| p.children.iter().any(|(n, k)| n == ns && k == kind))
283 }
284}
285
286fn status_code(text: &str) -> Option<u16> {
287 text.split_whitespace().nth(1).and_then(|c| c.parse().ok())
288}
289
290pub(crate) fn multistatus(xml: &str) -> Result<(Vec<Response>, Option<String>), SyncError> {
292 let doc = roxmltree::Document::parse(xml).map_err(|e| SyncError::Server(format!("XML: {e}")))?;
293 let mut responses = Vec::new();
294 let mut token = None;
295 for node in doc.root_element().children().filter(roxmltree::Node::is_element) {
296 if node.has_tag_name((DAV, "sync-token")) {
297 token = node.text().map(|t| t.trim().to_string());
298 continue;
299 }
300 if !node.has_tag_name((DAV, "response")) {
301 continue;
302 }
303 let mut response = Response::default();
304 for part in node.children().filter(roxmltree::Node::is_element) {
305 if part.has_tag_name((DAV, "href")) {
306 response.href = part.text().unwrap_or("").trim().to_string();
307 } else if part.has_tag_name((DAV, "status")) {
308 response.status = status_code(part.text().unwrap_or(""));
309 } else if part.has_tag_name((DAV, "propstat")) {
310 let status = part.children().find(|c| c.has_tag_name((DAV, "status"))).and_then(|s| status_code(s.text().unwrap_or(""))).unwrap_or(200);
311 for prop in part.children().filter(|c| c.has_tag_name((DAV, "prop"))).flat_map(|p| p.children().filter(roxmltree::Node::is_element)) {
312 let tag = prop.tag_name();
313 let elements: Vec<roxmltree::Node> = prop.descendants().filter(|d| d.is_element() && *d != prop).collect();
314 response.props.push(Prop {
315 ns: tag.namespace().unwrap_or("").to_string(),
316 name: tag.name().to_string(),
317 status,
318 text: prop.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect::<String>(),
319 hrefs: elements.iter().filter(|d| d.has_tag_name((DAV, "href"))).filter_map(|d| d.text()).map(|t| t.trim().to_string()).collect(),
320 children: elements.iter().map(|d| (d.tag_name().namespace().unwrap_or("").to_string(), d.tag_name().name().to_string())).collect(),
321 comps: elements.iter().filter(|d| d.tag_name().name() == "comp").filter_map(|d| d.attribute("name")).map(str::to_string).collect(),
322 });
323 }
324 }
325 }
326 responses.push(response);
327 }
328 Ok((responses, token))
329}
330
331pub(crate) fn origin(url: &str) -> Option<String> {
335 let (scheme, rest) = url.split_once("://")?;
336 let scheme = scheme.to_ascii_lowercase();
337 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
338 let at = authority.rsplit_once('@').map_or(authority, |(_, host)| host);
340 let (host, port) = match at.rsplit_once(':') {
341 Some((host, port)) if !port.contains(']') => (host, port.parse::<u16>().ok()?),
343 _ => (at, if scheme == "https" { 443 } else if scheme == "http" { 80 } else { return None }),
344 };
345 (!host.is_empty()).then(|| format!("{scheme}://{}:{port}", host.to_ascii_lowercase()))
346}
347
348pub fn absolute(base: &str, href: &str) -> String {
352 if href.starts_with("http://") || href.starts_with("https://") {
353 return href.to_string();
354 }
355 let origin_end = base.find("://").map_or(0, |i| i + 3);
356 let origin = match base[origin_end..].find('/') {
357 Some(slash) => &base[..origin_end + slash],
358 None => base,
359 };
360 if href.starts_with('/') {
361 return format!("{origin}{href}");
362 }
363 let directory = base.rsplit_once('/').map_or(base, |(d, _)| d);
364 format!("{directory}/{href}")
365}
366
367pub(crate) fn path_key(url: &str) -> String {
369 let path = url.find("://").and_then(|i| url[i + 3..].find('/').map(|j| &url[i + 3 + j..])).unwrap_or(url);
370 let mut out = Vec::with_capacity(path.len());
371 let bytes = path.as_bytes();
372 let mut i = 0;
373 while i < bytes.len() {
374 if bytes[i] == b'%'
375 && let Some(byte) = path.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok())
376 {
377 out.push(byte);
378 i += 3;
379 continue;
380 }
381 out.push(bytes[i]);
382 i += 1;
383 }
384 let decoded = String::from_utf8_lossy(&out).to_string();
385 decoded.trim_end_matches('/').to_string()
386}
387
388fn candidates(kind: Kind, given: Option<&str>, address: &str, mail_host: Option<&str>) -> Vec<String> {
391 let mut out: Vec<String> = given.map(|g| vec![g.trim().to_string()]).unwrap_or_default();
392 let domain = address.rsplit_once('@').map(|(_, d)| d.to_ascii_lowercase()).unwrap_or_default();
393 let service = match kind {
394 Kind::Calendars => "caldav",
395 Kind::Contacts => "carddav",
396 };
397 if !domain.is_empty() {
398 out.push(format!("https://{domain}/.well-known/{service}"));
399 out.push(format!("https://{service}.{domain}/.well-known/{service}"));
400 }
401 let known: &[(&str, &str, &str)] = &[
402 ("murena.io", "https://murena.io/remote.php/dav/", "https://murena.io/remote.php/dav/"),
403 ("e.email", "https://murena.io/remote.php/dav/", "https://murena.io/remote.php/dav/"),
404 ("fastmail.com", "https://caldav.fastmail.com/dav/", "https://carddav.fastmail.com/dav/"),
405 ("icloud.com", "https://caldav.icloud.com/", "https://contacts.icloud.com/"),
406 ("me.com", "https://caldav.icloud.com/", "https://contacts.icloud.com/"),
407 ("posteo.de", "https://posteo.de:8443/", "https://posteo.de:8843/"),
408 ("mailbox.org", "https://dav.mailbox.org/", "https://dav.mailbox.org/"),
409 ];
410 if let Some((_, calendars, contacts)) = known.iter().find(|(d, _, _)| *d == domain) {
411 out.push(match kind {
412 Kind::Calendars => calendars.to_string(),
413 Kind::Contacts => contacts.to_string(),
414 });
415 }
416 if let Some(host) = mail_host {
418 out.push(format!("https://{host}:2080/.well-known/{service}"));
419 out.push(format!("https://{host}/.well-known/{service}"));
420 }
421 out.dedup();
422 out
423}
424
425#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
427pub struct Homes {
428 #[serde(default)]
429 pub calendars: Option<String>,
430 #[serde(default)]
431 pub contacts: Option<String>,
432}
433
434fn homes_path(account: &str) -> PathBuf {
435 state_dir().join("dav").join(account).join("homes.toml")
436}
437
438impl Homes {
439 pub fn load(account: &str) -> Homes {
440 std::fs::read_to_string(homes_path(account)).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
441 }
442
443 pub fn save(&self, account: &str) -> Result<(), SyncError> {
444 let path = homes_path(account);
445 let fail = |e: std::io::Error| SyncError::Disk(format!("{}: {e}", path.display()));
446 if let Some(parent) = path.parent() {
447 std::fs::create_dir_all(parent).map_err(fail)?;
448 }
449 std::fs::write(&path, toml::to_string(self).map_err(|e| SyncError::Disk(e.to_string()))?).map_err(fail)
450 }
451
452 pub fn is_empty(&self) -> bool {
453 self.calendars.is_none() && self.contacts.is_none()
454 }
455}
456
457pub fn discover(client: &Client, address: &str, given: Option<&str>, mail_host: Option<&str>) -> Result<Homes, SyncError> {
460 if client.is_google() {
461 let homes = Homes { calendars: home(client, Kind::Calendars, &crate::google::caldav_start(address))?, contacts: home(client, Kind::Contacts, crate::google::CARDDAV_START)? };
463 return if homes.is_empty() { Err(SyncError::NotFound("google.com".into())) } else { Ok(homes) };
464 }
465 let mut homes = Homes::default();
466 let mut last_error = None;
467 for kind in [Kind::Calendars, Kind::Contacts] {
468 for start in candidates(kind, given, address, mail_host) {
469 match home(client, kind, &start) {
470 Ok(Some(found)) => {
471 match kind {
472 Kind::Calendars => homes.calendars = Some(found),
473 Kind::Contacts => homes.contacts = Some(found),
474 }
475 break;
476 }
477 Ok(None) => {}
478 Err(e @ SyncError::Login(_)) => return Err(e),
480 Err(e) => last_error = Some(e),
481 }
482 }
483 }
484 if homes.is_empty() {
485 let domain = address.rsplit_once('@').map_or(address, |(_, d)| d).to_string();
486 return Err(last_error.unwrap_or(SyncError::NotFound(domain)));
487 }
488 Ok(homes)
489}
490
491fn domain(url: &str) -> String {
493 sioul_core::sites::domain_of(&sioul_core::sites::host_of(url))
494}
495
496fn home(client: &Client, kind: Kind, start: &str) -> Result<Option<String>, SyncError> {
498 let mut url = start.to_string();
499 for _ in 0..5 {
505 let answer = client.send("PROPFIND", &url, &[("Depth", "0"), ("Content-Type", "application/xml; charset=utf-8")], Some(PRINCIPAL))?;
506 match answer.status {
507 301 | 302 | 303 | 307 | 308 => match answer.location.map(|location| absolute(&url, &location)) {
508 Some(next) if client.is_google() || domain(&next) == domain(start) => url = next,
509 _ => return Ok(None),
510 },
511 207 => {
512 let (responses, _) = multistatus(&answer.body)?;
513 let principal = responses
515 .iter()
516 .find_map(|r| r.prop(DAV, "current-user-principal").and_then(|p| p.hrefs.first().cloned()))
517 .map_or_else(|| url.clone(), |href| absolute(&url, &href));
518 let ours = |next: &str| if client.is_google() || domain(next) == domain(start) { Ok(()) } else { Err(SyncError::Elsewhere(next.to_string())) };
522 ours(&principal)?;
523 let (ns, name) = match kind {
524 Kind::Calendars => (CALDAV, "calendar-home-set"),
525 Kind::Contacts => (CARDDAV, "addressbook-home-set"),
526 };
527 let prop = match kind {
528 Kind::Calendars => "<c:calendar-home-set/>",
529 Kind::Contacts => "<a:addressbook-home-set/>",
530 };
531 let (_, responses) = client.propfind(&principal, "0", prop)?;
532 let home = responses.iter().find_map(|r| r.prop(ns, name).and_then(|p| p.hrefs.first().cloned())).map(|href| absolute(&principal, &href));
533 if let Some(home) = &home {
534 ours(home)?;
535 }
536 return Ok(home);
537 }
538 _ => return Ok(None),
539 }
540 }
541 Ok(None)
542}
543
544const PRINCIPAL: &str = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>"#;
545
546#[derive(Debug, Clone, PartialEq, Eq)]
548pub struct Listed {
549 pub kind: Kind,
550 pub url: String,
551 pub id: String,
552 pub name: String,
553 pub color: Option<String>,
554 pub read_only: bool,
555 pub ctag: Option<String>,
556 pub sync_token: Option<String>,
557 pub components: Vec<String>,
559}
560
561pub fn collections(client: &Client, kind: Kind, home: &str) -> Result<Vec<Listed>, SyncError> {
563 let props = "<d:resourcetype/><d:displayname/><cs:getctag/><d:sync-token/><d:current-user-privilege-set/><ic:calendar-color/><c:supported-calendar-component-set/>";
564 let (status, responses) = client.propfind(home, "1", props)?;
565 if status != 207 {
566 return Err(SyncError::Server(format!("PROPFIND {home}: {status}")));
567 }
568 let (ns, wanted) = match kind {
569 Kind::Calendars => (CALDAV, "calendar"),
570 Kind::Contacts => (CARDDAV, "addressbook"),
571 };
572 let mut found: Vec<Listed> = Vec::new();
573 for response in responses.iter().filter(|r| r.is(ns, wanted)) {
574 let url = absolute(home, &response.href);
575 let privileges = response.prop(DAV, "current-user-privilege-set");
576 let writable = privileges.is_none_or(|p| p.children.iter().any(|(n, k)| n == DAV && matches!(k.as_str(), "write" | "write-content" | "all" | "bind")));
577 let mut id = vdir::folder_id(&response.href);
578 while found.iter().any(|l| l.id == id) {
580 id.push('_');
581 }
582 found.push(Listed {
583 kind,
584 name: response.text(DAV, "displayname").unwrap_or_else(|| id.clone()),
585 color: response.text(APPLE, "calendar-color").map(|c| c.chars().take(7).collect()),
586 read_only: !writable,
587 ctag: response.text(CALSERVER, "getctag"),
588 sync_token: response.text(DAV, "sync-token"),
589 components: response.prop(CALDAV, "supported-calendar-component-set").map(|p| p.comps.clone()).unwrap_or_default(),
590 url,
591 id,
592 });
593 }
594 Ok(found)
595}
596
597#[derive(Debug, Clone, Default, PartialEq, Eq)]
599pub struct Report {
600 pub sent: usize,
602 pub received: usize,
604 pub removed: usize,
605 pub conflicts: Vec<PathBuf>,
607 pub collections: usize,
608 pub answered: Vec<(String, String)>,
612}
613
614pub fn client_for(account: &Account) -> Result<Client, SyncError> {
616 let login = account.login().ok_or(SyncError::NoServer)?;
617 if account.auth.as_deref() == Some("google") {
618 return Client::google(account.address.as_deref().unwrap_or(login));
619 }
620 Ok(Client::new(login, &crate::secret::password(account)?))
621}
622
623pub fn sync(account: &Account) -> Result<Report, SyncError> {
628 sync_with(&client_for(account)?, account)
629}
630
631fn sync_with(client: &Client, account: &Account) -> Result<Report, SyncError> {
632 let login = account.login().ok_or(SyncError::NoServer)?;
633 let _lock = crate::fetch::lock(&state_dir().join("dav").join(format!("{}.lock", account.id)))?;
637 let mut homes = Homes::load(&account.id);
638 if homes.is_empty() {
639 homes = discover(client, account.address.as_deref().unwrap_or(login), account.url.as_deref(), None)?;
640 homes.save(&account.id)?;
641 }
642 client.keep_to(&homes);
643 let mut report = Report::default();
644 let mut refused = None;
645 for (kind, home) in [(Kind::Contacts, &homes.contacts), (Kind::Calendars, &homes.calendars)] {
646 let Some(home) = home else { continue };
647 if !client.is_google() {
649 go_on(&mut refused, send_changes(client, &account.id, kind))?;
650 go_on(&mut refused, create_pending(client, &account.id, kind, home))?;
651 }
652 let mut listed = match collections(client, kind, home) {
653 Ok(listed) => listed,
654 Err(e) => {
655 go_on(&mut refused, Err(e))?;
656 continue;
657 }
658 };
659 if client.is_google() {
661 listed.iter_mut().filter(|l| l.kind == Kind::Calendars && l.components.is_empty()).for_each(|l| l.components = vec!["VEVENT".into()]);
662 }
663 forget_gone(&account.id, kind, &listed);
664 for collection in &listed {
665 match sync_collection(client, &account.id, collection, &mut report) {
666 Ok(()) => report.collections += 1,
667 Err(e) => go_on(&mut refused, Err(e))?,
668 }
669 }
670 }
671 if client.is_google() {
673 match crate::google_tasks::sync(account) {
674 Ok(tasks) => {
675 report.sent += tasks.sent;
676 report.received += tasks.received;
677 report.removed += tasks.removed;
678 report.collections += tasks.collections;
679 }
680 Err(e) => go_on(&mut refused, Err(e))?,
681 }
682 }
683 refused.map_or(Ok(report), Err)
684}
685
686pub fn watch(account: &Account, control: &Control, every: Duration, report: impl FnMut(Result<Report, SyncError>)) {
693 watch_with(control, every, || sync(account), || crate::secret::password(account).ok(), report);
694}
695
696fn watch_with(control: &Control, every: Duration, mut sync: impl FnMut() -> Result<Report, SyncError>, password: impl Fn() -> Option<String>, mut report: impl FnMut(Result<Report, SyncError>)) {
698 while !control.stopped() {
699 let result = sync();
700 let parked = result.as_ref().err().filter(|e| e.is_lasting()).map(|e| Parked::after(e, if e.wants_password() { password() } else { None }));
701 report(result);
702 let Some(parked) = parked else {
703 control.pause(every);
704 continue;
705 };
706 control.retried();
708 loop {
709 if !control.park() {
710 return;
711 }
712 if !parked.refused() || parked.again(password().as_deref()) || control.retried() {
713 break;
714 }
715 }
716 }
717}
718
719fn go_on(first: &mut Option<SyncError>, result: Result<(), SyncError>) -> Result<(), SyncError> {
723 match result {
724 Err(e) if !e.is_lasting() => {
725 first.get_or_insert(e);
726 Ok(())
727 }
728 other => other,
729 }
730}
731
732fn forget_gone(account: &str, kind: Kind, listed: &[Listed]) {
736 let kept: BTreeSet<&str> = listed.iter().map(|l| l.id.as_str()).collect();
737 for collection in vdir::collections(kind).into_iter().filter(|c| c.account == account && !kept.contains(c.id.as_str())) {
738 let state = State::load(&collection.state_path());
739 let unsent = has_local_changes(&collection.dir, kind, &state);
740 if state.pending || unsent || crate::google_tasks::is_tasks_list(&state.url) {
742 continue;
743 }
744 let _ = std::fs::remove_dir_all(&collection.dir);
745 let _ = std::fs::remove_file(collection.state_path());
746 }
747}
748
749fn send_changes(client: &Client, account: &str, kind: Kind) -> Result<(), SyncError> {
754 let mut refused = None;
755 for collection in vdir::every_collection(kind).into_iter().filter(|c| c.account == account) {
756 let path = collection.state_path();
757 let mut state = State::load(&path);
758 if state.url.is_empty() {
759 continue;
760 }
761 if state.renamed {
762 let body = format!(r#"<?xml version="1.0" encoding="utf-8"?><d:propertyupdate xmlns:d="DAV:"><d:set><d:prop><d:displayname>{}</d:displayname></d:prop></d:set></d:propertyupdate>"#, xml_escape(&collection.name));
763 let answer = client.send("PROPPATCH", &state.url, &[("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
764 if !(200..300).contains(&answer.status) {
765 refused.get_or_insert(SyncError::Server(format!("PROPPATCH {}: {}", state.url, answer.status)));
766 continue;
767 }
768 state.renamed = false;
769 go_on(&mut refused, state.save(&path).map_err(SyncError::Disk))?;
770 }
771 if state.deleted {
772 let (_, found) = client.propfind(&state.url, "1", "<d:getetag/>")?;
773 let held = found.iter().filter(|r| path_key(&r.href) != path_key(&state.url)).count();
774 if held > 0 {
775 state.deleted = false;
776 } else {
777 let answer = client.send("DELETE", &state.url, &[], None)?;
778 if !(200..300).contains(&answer.status) && answer.status != 404 {
779 refused.get_or_insert(SyncError::Server(format!("DELETE {}: {}", state.url, answer.status)));
780 continue;
781 }
782 let _ = std::fs::remove_dir_all(&collection.dir);
783 let _ = std::fs::remove_file(&path);
784 continue;
785 }
786 go_on(&mut refused, state.save(&path).map_err(SyncError::Disk))?;
787 }
788 }
789 refused.map_or(Ok(()), Err)
790}
791
792fn create_pending(client: &Client, account: &str, kind: Kind, home: &str) -> Result<(), SyncError> {
798 let mut refused = None;
799 for collection in vdir::collections(kind).into_iter().filter(|c| c.account == account) {
800 let path = collection.state_path();
801 let mut state = State::load(&path);
802 if !state.pending {
803 continue;
804 }
805 let url = format!("{}/{}/", home.trim_end_matches('/'), url_segment(&collection.id));
806 let name = format!("<d:displayname>{}</d:displayname>", xml_escape(&collection.name));
807 let color = collection.color.as_deref().map(|c| format!("<ic:calendar-color>{}</ic:calendar-color>", xml_escape(c))).unwrap_or_default();
808 let (method, body) = match kind {
809 Kind::Calendars => {
810 let comps: String = state.components.iter().map(|c| format!(r#"<c:comp name="{}"/>"#, xml_escape(c))).collect();
811 let set = if comps.is_empty() { String::new() } else { format!("<c:supported-calendar-component-set>{comps}</c:supported-calendar-component-set>") };
812 (
813 "MKCALENDAR",
814 format!(r#"<?xml version="1.0" encoding="utf-8"?><c:mkcalendar xmlns:d="DAV:" xmlns:c="{CALDAV}" xmlns:ic="{APPLE}"><d:set><d:prop>{name}{color}{set}</d:prop></d:set></c:mkcalendar>"#),
815 )
816 }
817 Kind::Contacts => (
818 "MKCOL",
819 format!(r#"<?xml version="1.0" encoding="utf-8"?><d:mkcol xmlns:d="DAV:" xmlns:a="{CARDDAV}"><d:set><d:prop><d:resourcetype><d:collection/><a:addressbook/></d:resourcetype>{name}</d:prop></d:set></d:mkcol>"#),
820 ),
821 };
822 let answer = client.send(method, &url, &[("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
823 match answer.status {
824 200..=299 | 405 => {
825 state.pending = false;
826 state.url = url;
827 go_on(&mut refused, state.save(&path).map_err(SyncError::Disk))?;
828 }
829 status => {
830 refused.get_or_insert(SyncError::Server(format!("{method} {url}: {status}")));
831 }
832 }
833 }
834 refused.map_or(Ok(()), Err)
835}
836
837fn hash_of(path: &Path) -> Option<String> {
838 read_item(path).ok().map(|bytes| vdir::content_hash(&bytes))
839}
840
841fn read_item(path: &Path) -> std::io::Result<Vec<u8>> {
843 #[cfg(test)]
844 tests::READS.with(|reads| reads.set(reads.get() + 1));
845 std::fs::read(path)
846}
847
848fn read_text(path: &Path) -> Result<String, SyncError> {
850 let fail = |e: &dyn std::fmt::Display| SyncError::Disk(format!("{}: {e}", path.display()));
851 String::from_utf8(read_item(path).map_err(|e| fail(&e))?).map_err(|e| fail(&e))
852}
853
854fn file_name(href: &str, extension: &str, taken: &BTreeSet<String>) -> String {
856 let last = path_key(href).rsplit('/').next().unwrap_or("item").to_string();
857 let stem = last.strip_suffix(&format!(".{extension}")).unwrap_or(&last);
858 let stem: String = stem.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '@') { c } else { '_' }).take(120).collect();
859 let mut stem = stem.trim_start_matches('.').to_string();
862 let device = stem.split('.').next().unwrap_or("").to_ascii_uppercase();
863 if matches!(device.as_str(), "CON" | "PRN" | "AUX" | "NUL") || (device.len() == 4 && (device.starts_with("COM") || device.starts_with("LPT")) && device.ends_with(|c: char| c.is_ascii_digit())) {
864 stem.insert(0, '_');
865 }
866 if stem.is_empty() {
867 stem = vdir::new_name();
868 }
869 let mut name = format!("{stem}.{extension}");
870 let mut n = 2;
871 while taken.contains(&name) {
872 name = format!("{stem}-{n}.{extension}");
873 n += 1;
874 }
875 name
876}
877
878fn sync_collection(client: &Client, account: &str, listed: &Listed, report: &mut Report) -> Result<(), SyncError> {
882 let kind = listed.kind;
883 let dir = vdir::prepare(kind, account, &listed.id, &listed.name, listed.color.as_deref()).map_err(|e| SyncError::Disk(e.to_string()))?;
884 let state_path = vdir::state_path(account, kind, &listed.id);
885 let mut state = State::load(&state_path);
886 state.url = listed.url.clone();
887 state.read_only = listed.read_only;
888 state.components = listed.components.clone();
889 let synced = both_ways(client, &dir, listed, &mut state, &state_path, report);
890 state.save(&state_path).map_err(SyncError::Disk)?;
891 synced
892}
893
894fn both_ways(client: &Client, dir: &Path, listed: &Listed, state: &mut State, state_path: &Path, report: &mut Report) -> Result<(), SyncError> {
898 let mut refetch: BTreeSet<String> = BTreeSet::new();
899 let refused = if listed.read_only { None } else { push(client, listed.kind, dir, listed, state, &mut refetch, report)? };
900 let unchanged = state.ctag.is_some() && state.ctag == listed.ctag && refetch.is_empty() && refused.is_none();
901 if !unchanged {
902 pull(client, dir, listed, state, state_path, &refetch, report)?;
903 }
904 state.ctag = listed.ctag.clone();
905 refused.map_or(Ok(()), Err)
906}
907
908fn has_local_changes(dir: &Path, kind: Kind, state: &State) -> bool {
909 let known: BTreeSet<&str> = state.items.iter().map(|i| i.file.as_str()).collect();
910 let files = local_files(dir, kind);
911 files.iter().any(|f| !known.contains(f.as_str())) || state.items.iter().any(|i| !unchanged(&dir.join(&i.file), i))
912}
913
914fn unchanged(path: &Path, item: &ItemState) -> bool {
917 std::fs::metadata(path).is_ok_and(|m| as_noted(&m, item)) || hash_of(path).is_some_and(|hash| hash == item.hash)
918}
919
920const SETTLED: Duration = Duration::from_secs(2);
924
925fn stamp(metadata: &std::fs::Metadata) -> (u64, i64) {
929 let settled = metadata.modified().ok().filter(|m| std::time::SystemTime::now().duration_since(*m).is_ok_and(|age| age >= SETTLED));
930 let mtime = settled.and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).and_then(|d| i64::try_from(d.as_nanos()).ok()).unwrap_or(0);
931 (metadata.len(), mtime)
932}
933
934fn as_noted(metadata: &std::fs::Metadata, item: &ItemState) -> bool {
936 item.mtime != 0 && stamp(metadata) == (item.size, item.mtime)
937}
938
939fn local_files(dir: &Path, kind: Kind) -> BTreeSet<String> {
940 std::fs::read_dir(dir)
941 .into_iter()
942 .flatten()
943 .filter_map(Result::ok)
944 .filter_map(|e| e.file_name().into_string().ok())
945 .filter(|n| n.ends_with(&format!(".{}", kind.extension())) && !n.starts_with('.'))
946 .collect()
947}
948
949fn push(client: &Client, kind: Kind, dir: &Path, listed: &Listed, state: &mut State, refetch: &mut BTreeSet<String>, report: &mut Report) -> Result<Option<SyncError>, SyncError> {
954 let mut refused = None;
955 let mut i = 0;
956 while i < state.items.len() {
957 match send_known(client, kind, dir, listed, &state.items[i], refetch, report)? {
958 Sent::Deleted => {
959 state.items.remove(i);
960 continue;
961 }
962 Sent::Agreed(item) => state.items[i] = item,
963 Sent::Kept => {}
964 Sent::Refused(e) => {
965 refused.get_or_insert(e);
966 }
967 }
968 i += 1;
969 }
970 let known: BTreeSet<String> = state.items.iter().map(|i| i.file.clone()).collect();
971 for file in local_files(dir, kind).into_iter().filter(|f| !known.contains(f)) {
972 let path = dir.join(&file);
973 let (size, mtime) = std::fs::metadata(&path).map(|m| stamp(&m)).unwrap_or_default();
975 let text = match read_text(&path) {
976 Ok(text) => text,
977 Err(e) => {
978 refused.get_or_insert(e);
979 continue;
980 }
981 };
982 let href = format!("{}/{}", listed.url.trim_end_matches('/'), url_segment(&file));
983 if client.is_google() {
984 match create_on_google(client, kind, listed, &file, &href, &text, refetch) {
985 Ok(item) => {
986 state.items.push(ItemState { size, mtime, ..item });
987 report.sent += 1;
988 state.sync_token = None;
990 }
991 Err(e @ SyncError::Server(_)) => {
992 refused.get_or_insert(e);
993 }
994 Err(e) => return Err(e),
995 }
996 continue;
997 }
998 let answer = client.send("PUT", &href, &[("If-None-Match", "*"), ("Content-Type", kind.media_type())], Some(&text))?;
999 if matches!(answer.status, 400 | 403 | 409 | 412)
1002 && let Some(theirs) = same_uid_there(client, kind, listed, &href, &text, &answer.body)?
1003 {
1004 state.items.push(adopt(dir, listed, &file, &text, theirs, report)?);
1005 continue;
1006 }
1007 match answer.status {
1008 200..=299 => {
1009 report.sent += 1;
1010 let etag = answer.etag.unwrap_or_default();
1011 if etag.is_empty() {
1012 refetch.insert(path_key(&href));
1013 }
1014 state.items.push(ItemState { href: path_key(&href), file, etag, hash: vdir::content_hash(text.as_bytes()), size, mtime });
1015 }
1016 412 => {
1020 refetch.insert(path_key(&href));
1021 state.items.push(ItemState { href: path_key(&href), file, ..ItemState::default() });
1022 }
1023 status => {
1024 refused.get_or_insert(SyncError::Server(format!("PUT {href}: {status}")));
1025 }
1026 }
1027 }
1028 Ok(refused)
1029}
1030
1031fn same_uid_there(client: &Client, kind: Kind, listed: &Listed, href: &str, text: &str, refusal: &str) -> Result<Option<(String, String, String)>, SyncError> {
1039 let Some(uid) = uid_of(text) else { return Ok(None) };
1040 let mut hrefs: Vec<String> = named_in_refusal(refusal).into_iter().collect();
1041 if hrefs.is_empty() {
1042 hrefs = holding_uid(client, kind, &listed.url, &uid, &component_of(text))?;
1043 }
1044 let mine = path_key(href);
1045 hrefs.retain(|h| path_key(&absolute(&listed.url, h)) != mine);
1046 if hrefs.is_empty() {
1047 return Ok(None);
1048 }
1049 Ok(fetch(client, kind, &listed.url, &hrefs)?.into_iter().find(|(_, _, data)| uid_of(data).as_deref() == Some(uid.as_str())))
1050}
1051
1052fn uid_of(text: &str) -> Option<String> {
1054 sioul_core::lines::unfold(text).iter().find(|l| sioul_core::lines::name(l) == "UID").map(|l| sioul_core::lines::value(l).trim().to_string()).filter(|uid| !uid.is_empty())
1055}
1056
1057fn component_of(text: &str) -> String {
1059 sioul_core::lines::unfold(text)
1060 .iter()
1061 .filter(|l| sioul_core::lines::name(l) == "BEGIN")
1062 .map(|l| sioul_core::lines::value(l).trim().to_ascii_uppercase())
1063 .find(|c| !matches!(c.as_str(), "VCALENDAR" | "VTIMEZONE" | "STANDARD" | "DAYLIGHT" | "VALARM"))
1064 .unwrap_or_else(|| "VEVENT".into())
1065}
1066
1067fn named_in_refusal(body: &str) -> Option<String> {
1069 let doc = roxmltree::Document::parse(body).ok()?;
1070 let clash = doc.descendants().find(|n| n.tag_name().name() == "no-uid-conflict")?;
1071 clash.descendants().find(|n| n.has_tag_name((DAV, "href"))).and_then(|n| n.text()).map(|h| h.trim().to_string()).filter(|h| !h.is_empty())
1072}
1073
1074fn holding_uid(client: &Client, kind: Kind, url: &str, uid: &str, component: &str) -> Result<Vec<String>, SyncError> {
1077 let (uid, component) = (xml_escape(uid), xml_escape(component));
1078 let body = match kind {
1079 Kind::Calendars => format!(
1080 r#"<?xml version="1.0" encoding="utf-8"?><c:calendar-query xmlns:d="DAV:" xmlns:c="{CALDAV}"><d:prop><d:getetag/></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="{component}"><c:prop-filter name="UID"><c:text-match>{uid}</c:text-match></c:prop-filter></c:comp-filter></c:comp-filter></c:filter></c:calendar-query>"#
1081 ),
1082 Kind::Contacts => format!(
1083 r#"<?xml version="1.0" encoding="utf-8"?><a:addressbook-query xmlns:d="DAV:" xmlns:a="{CARDDAV}"><d:prop><d:getetag/></d:prop><a:filter><a:prop-filter name="UID"><a:text-match match-type="equals">{uid}</a:text-match></a:prop-filter></a:filter></a:addressbook-query>"#
1084 ),
1085 };
1086 let answer = client.send("REPORT", url, &[("Depth", "1"), ("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
1087 if answer.status != 207 {
1088 return Ok(Vec::new());
1089 }
1090 let Ok((responses, _)) = multistatus(&answer.body) else { return Ok(Vec::new()) };
1091 let collection = path_key(url);
1092 Ok(responses.into_iter().filter(|r| r.status.is_none_or(|s| s / 100 == 2) && path_key(&absolute(url, &r.href)) != collection).map(|r| r.href).collect())
1093}
1094
1095fn adopt(dir: &Path, listed: &Listed, file: &str, mine: &str, (href, etag, data): (String, String, String), report: &mut Report) -> Result<ItemState, SyncError> {
1103 let path = dir.join(file);
1104 let (ours, theirs) = (sioul_core::agenda::answers(mine), sioul_core::agenda::answers(&data));
1105 if ours != theirs {
1106 let kept = theirs.iter().find(|(who, said)| ours.get(*who) != Some(*said)).map_or_else(String::new, |(_, said)| said.clone());
1108 report.answered.push((summary_of(&data), kept));
1109 } else if ours.is_empty() && !same_text(mine.as_bytes(), &data) {
1110 report.conflicts.push(set_aside(&path, mine.as_bytes())?);
1111 }
1112 let text = crlf(&data);
1113 vdir::write_item(&path, &text).map_err(SyncError::Disk)?;
1114 report.received += 1;
1115 Ok(ItemState { href: path_key(&absolute(&listed.url, &href)), file: file.to_string(), etag, hash: vdir::content_hash(text.as_bytes()), ..ItemState::default() })
1117}
1118
1119fn summary_of(text: &str) -> String {
1121 sioul_core::lines::unfold(text).iter().find(|l| sioul_core::lines::name(l) == "SUMMARY").map(|l| sioul_core::lines::unescape(sioul_core::lines::value(l).trim())).unwrap_or_default()
1122}
1123
1124enum Sent {
1126 Deleted,
1128 Agreed(ItemState),
1130 Kept,
1132 Refused(SyncError),
1134}
1135
1136fn send_known(client: &Client, kind: Kind, dir: &Path, listed: &Listed, item: &ItemState, refetch: &mut BTreeSet<String>, report: &mut Report) -> Result<Sent, SyncError> {
1139 let path = dir.join(&item.file);
1140 let url = absolute(&listed.url, &item.href);
1141 let unreadable = |e: std::io::Error| Sent::Refused(SyncError::Disk(format!("{}: {e}", path.display())));
1142 let metadata = match std::fs::metadata(&path) {
1143 Ok(metadata) => metadata,
1144 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1146 let answer = client.send("DELETE", &url, &[("If-Match", &item.etag)], None)?;
1147 return Ok(match answer.status {
1148 200..=299 | 404 => {
1149 report.sent += 1;
1150 Sent::Deleted
1151 }
1152 412 => {
1153 refetch.insert(path_key(&url));
1154 Sent::Kept
1155 }
1156 status => Sent::Refused(SyncError::Server(format!("DELETE {url}: {status}"))),
1157 });
1158 }
1159 Err(e) => return Ok(unreadable(e)),
1160 };
1161 if as_noted(&metadata, item) {
1162 return Ok(Sent::Kept);
1163 }
1164 let (size, mtime) = stamp(&metadata);
1166 let bytes = match read_item(&path) {
1167 Ok(bytes) => bytes,
1168 Err(e) => return Ok(unreadable(e)),
1169 };
1170 let hash = vdir::content_hash(&bytes);
1171 if hash == item.hash {
1172 return Ok(if (size, mtime) == (item.size, item.mtime) { Sent::Kept } else { Sent::Agreed(ItemState { size, mtime, ..item.clone() }) });
1174 }
1175 if item.hash.is_empty() {
1176 refetch.insert(path_key(&url));
1178 return Ok(Sent::Kept);
1179 }
1180 let text = match String::from_utf8(bytes) {
1181 Ok(text) => text,
1182 Err(e) => return Ok(Sent::Refused(SyncError::Disk(format!("{}: {e}", path.display())))),
1183 };
1184 let answer = client.send("PUT", &url, &[("If-Match", &item.etag), ("Content-Type", kind.media_type())], Some(&text))?;
1185 Ok(match answer.status {
1186 200..=299 => {
1187 report.sent += 1;
1188 let etag = answer.etag.unwrap_or_default();
1190 if etag.is_empty() {
1191 refetch.insert(path_key(&url));
1192 }
1193 Sent::Agreed(ItemState { etag, hash, size, mtime, ..item.clone() })
1194 }
1195 412 => {
1197 refetch.insert(path_key(&url));
1198 Sent::Kept
1199 }
1200 status => Sent::Refused(SyncError::Server(format!("PUT {url}: {status}"))),
1201 })
1202}
1203
1204fn create_on_google(client: &Client, kind: Kind, listed: &Listed, file: &str, href: &str, text: &str, refetch: &mut BTreeSet<String>) -> Result<ItemState, SyncError> {
1209 let sent = if kind == Kind::Contacts { sioul_core::contacts::as_vcard3(text) } else { text.to_string() };
1210 let mut answer = client.send("PUT", href, &[("Content-Type", kind.media_type())], Some(&sent))?;
1211 if kind == Kind::Contacts && matches!(answer.status, 403 | 405 | 409) {
1212 answer = client.send("POST", &listed.url, &[("Content-Type", kind.media_type())], Some(&sent))?;
1213 }
1214 if !(200..300).contains(&answer.status) {
1215 return Err(SyncError::Server(format!("PUT {href}: {}", answer.status)));
1216 }
1217 let at = answer.location.as_deref().map_or_else(|| path_key(href), |l| path_key(&absolute(&listed.url, l)));
1218 refetch.insert(at.clone());
1219 Ok(ItemState { href: at, file: file.to_string(), etag: answer.etag.unwrap_or_default(), hash: vdir::content_hash(text.as_bytes()), ..ItemState::default() })
1220}
1221
1222fn url_segment(name: &str) -> String {
1224 name.bytes()
1225 .map(|b| match b {
1226 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'@' => (b as char).to_string(),
1227 _ => format!("%{b:02X}"),
1228 })
1229 .collect()
1230}
1231
1232fn set_aside(path: &Path, mine: &[u8]) -> Result<PathBuf, SyncError> {
1241 let root = crate::history::root(&state_dir().join("share").join("memory.json"));
1242 let data = sioul_core::config::data_dir();
1243 let file = path.strip_prefix(&data).ok().map(|relative| format!("data/{}", relative.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/"))).unwrap_or_else(|| format!("data/{}", path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()));
1244 crate::history::keep_bytes(&root, crate::history::ACCOUNTS, &file, mine, jiff::Timestamp::now().as_millisecond()).map_err(SyncError::Disk)
1245}
1246
1247fn changed_here(path: &Path, item: &ItemState) -> Option<Vec<u8>> {
1249 if std::fs::metadata(path).is_ok_and(|m| as_noted(&m, item)) {
1250 return None;
1251 }
1252 read_item(path).ok().filter(|bytes| item.hash.is_empty() || vdir::content_hash(bytes) != item.hash)
1253}
1254
1255fn crlf(text: &str) -> String {
1257 text.replace("\r\n", "\n").replace('\n', "\r\n")
1258}
1259
1260fn same_text(mine: &[u8], theirs: &str) -> bool {
1262 crlf(&String::from_utf8_lossy(mine)).trim_end() == crlf(theirs).trim_end()
1263}
1264
1265fn pull(client: &Client, dir: &Path, listed: &Listed, state: &mut State, state_path: &Path, refetch: &BTreeSet<String>, report: &mut Report) -> Result<(), SyncError> {
1269 let key = |href: &str| path_key(&absolute(&listed.url, href));
1270 let by_href: BTreeMap<String, usize> = state.items.iter().enumerate().map(|(i, item)| (key(&item.href), i)).collect();
1271 let (changed, gone, token) = match state.sync_token.clone().and_then(|token| changes_since(client, &listed.url, &token).ok().flatten()) {
1272 Some(found) => found,
1273 None => everything(client, &listed.url, state, &by_href)?,
1274 };
1275 let gone: BTreeSet<String> = gone.iter().map(|h| key(h)).collect();
1278 let mut i = 0;
1279 while i < state.items.len() {
1280 if !gone.contains(&key(&state.items[i].href)) {
1281 i += 1;
1282 continue;
1283 }
1284 let path = dir.join(&state.items[i].file);
1285 if let Some(mine) = changed_here(&path, &state.items[i]) {
1286 report.conflicts.push(set_aside(&path, &mine)?);
1287 }
1288 let _ = std::fs::remove_file(&path);
1289 state.items.remove(i);
1290 report.removed += 1;
1291 }
1292 let known: BTreeMap<String, usize> = state.items.iter().enumerate().map(|(i, item)| (key(&item.href), i)).collect();
1294 let mut wanted: BTreeMap<String, String> = changed
1295 .into_iter()
1296 .filter_map(|(href, etag)| {
1297 let at = key(&href);
1298 let stale = known.get(&at).is_none_or(|&i| etag.as_deref().is_none_or(|e| e != state.items[i].etag));
1299 (stale || refetch.contains(&at)).then_some((at, href))
1300 })
1301 .collect();
1302 for at in refetch {
1303 if let Some(&i) = known.get(at) {
1304 wanted.entry(at.clone()).or_insert_with(|| state.items[i].href.clone());
1305 }
1306 }
1307 let hrefs: Vec<String> = wanted.into_values().collect();
1308 let mut at = 0;
1309 while at < hrefs.len() {
1310 let batch = &hrefs[at..(at + batch_of(&listed.url)).min(hrefs.len())];
1312 let started = Instant::now();
1313 let fetched = fetch(client, listed.kind, &listed.url, batch);
1314 match &fetched {
1315 Ok(_) => pace(&listed.url, Some(started.elapsed())),
1316 Err(SyncError::Network(_)) => pace(&listed.url, None),
1317 Err(_) => {}
1318 }
1319 for (href, etag, data) in fetched? {
1320 store(dir, listed.kind, state, &listed.url, (&href, &etag, &data), report)?;
1321 }
1322 state.save(state_path).map_err(SyncError::Disk)?;
1324 at += batch.len();
1325 }
1326 state.sync_token = token.or_else(|| listed.sync_token.clone());
1327 Ok(())
1328}
1329
1330#[allow(clippy::type_complexity)]
1333fn changes_since(client: &Client, url: &str, token: &str) -> Result<Option<(Vec<(String, Option<String>)>, Vec<String>, Option<String>)>, SyncError> {
1334 let body = format!(
1335 r#"<?xml version="1.0" encoding="utf-8"?><d:sync-collection xmlns:d="DAV:"><d:sync-token>{}</d:sync-token><d:sync-level>1</d:sync-level><d:prop><d:getetag/></d:prop></d:sync-collection>"#,
1336 xml_escape(token)
1337 );
1338 let answer = client.send("REPORT", url, &[("Depth", "0"), ("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
1339 if answer.status != 207 {
1340 return Ok(None);
1341 }
1342 let (responses, new_token) = multistatus(&answer.body)?;
1343 let collection = path_key(url);
1344 let mut changed = Vec::new();
1345 let mut gone = Vec::new();
1346 for response in responses.into_iter().filter(|r| path_key(&absolute(url, &r.href)) != collection) {
1347 if response.status == Some(404) {
1348 gone.push(response.href);
1349 } else {
1350 changed.push((response.href.clone(), response.text(DAV, "getetag")));
1351 }
1352 }
1353 Ok(Some((changed, gone, new_token)))
1354}
1355
1356#[allow(clippy::type_complexity)]
1358fn everything(client: &Client, url: &str, state: &State, known: &BTreeMap<String, usize>) -> Result<(Vec<(String, Option<String>)>, Vec<String>, Option<String>), SyncError> {
1359 let (status, responses) = client.propfind(url, "1", "<d:getetag/><d:resourcetype/>")?;
1360 if status != 207 {
1361 return Err(SyncError::Server(format!("PROPFIND {url}: {status}")));
1362 }
1363 let collection = path_key(url);
1364 let items: Vec<&Response> = responses.iter().filter(|r| path_key(&absolute(url, &r.href)) != collection && !r.is(DAV, "collection")).collect();
1365 let present: BTreeSet<String> = items.iter().map(|r| path_key(&absolute(url, &r.href))).collect();
1366 let changed = items.iter().map(|r| (r.href.clone(), r.text(DAV, "getetag"))).collect();
1367 let gone = known.keys().filter(|k| !present.contains(*k)).filter_map(|k| known.get(k).map(|&i| state.items[i].href.clone())).collect();
1368 Ok((changed, gone, None))
1369}
1370
1371fn xml_escape(text: &str) -> String {
1373 text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
1374}
1375
1376fn fetch(client: &Client, kind: Kind, url: &str, hrefs: &[String]) -> Result<Vec<(String, String, String)>, SyncError> {
1378 if hrefs.is_empty() {
1379 return Ok(Vec::new());
1380 }
1381 let (root, ns, data) = match kind {
1382 Kind::Calendars => ("c:calendar-multiget", CALDAV, "calendar-data"),
1383 Kind::Contacts => ("a:addressbook-multiget", CARDDAV, "address-data"),
1384 };
1385 let prefix = if kind == Kind::Calendars { "c" } else { "a" };
1386 let list: String = hrefs.iter().map(|h| format!("<d:href>{}</d:href>", xml_escape(h))).collect();
1387 let body = format!(
1388 r#"<?xml version="1.0" encoding="utf-8"?><{root} xmlns:d="DAV:" xmlns:c="{CALDAV}" xmlns:a="{CARDDAV}"><d:prop><d:getetag/><{prefix}:{data}/></d:prop>{list}</{root}>"#
1389 );
1390 let answer = client.send("REPORT", url, &[("Depth", "1"), ("Content-Type", "application/xml; charset=utf-8")], Some(&body))?;
1391 if answer.status == 207 {
1392 let (responses, _) = multistatus(&answer.body)?;
1393 let found: Vec<(String, String, String)> = responses
1394 .iter()
1395 .filter_map(|r| Some((r.href.clone(), r.text(DAV, "getetag").unwrap_or_default(), r.prop(ns, data).map(|p| p.text.clone()).filter(|t| !t.trim().is_empty())?)))
1396 .collect();
1397 if !found.is_empty() || hrefs.is_empty() {
1398 return Ok(found);
1399 }
1400 }
1401 let mut found = Vec::new();
1403 for href in hrefs {
1404 let answer = client.send("GET", &absolute(url, href), &[], None)?;
1405 if answer.status / 100 == 2 {
1406 found.push((href.clone(), answer.etag.unwrap_or_default(), answer.body));
1407 }
1408 }
1409 Ok(found)
1410}
1411
1412fn store(dir: &Path, kind: Kind, state: &mut State, url: &str, (href, etag, data): (&str, &str, &str), report: &mut Report) -> Result<(), SyncError> {
1416 let key = path_key(&absolute(url, href));
1417 let position = state.items.iter().position(|i| path_key(&absolute(url, &i.href)) == key);
1418 let file = match position {
1419 Some(i) => state.items[i].file.clone(),
1420 None => file_name(href, kind.extension(), &state.items.iter().map(|i| i.file.clone()).chain(local_files(dir, kind)).collect()),
1421 };
1422 let path = dir.join(&file);
1423 if let Some(i) = position
1424 && let Some(mine) = changed_here(&path, &state.items[i])
1425 && !same_text(&mine, data)
1426 {
1427 report.conflicts.push(set_aside(&path, &mine)?);
1428 }
1429 let text = crlf(data);
1430 vdir::write_item(&path, &text).map_err(SyncError::Disk)?;
1431 let item = ItemState { href: href.to_string(), file, etag: etag.to_string(), hash: vdir::content_hash(text.as_bytes()), ..ItemState::default() };
1433 match position {
1434 Some(i) => state.items[i] = item,
1435 None => state.items.push(item),
1436 }
1437 report.received += 1;
1438 Ok(())
1439}
1440
1441pub fn test(address: &str, login: &str, password: &str, given: Option<&str>, mail_host: Option<&str>) -> Result<Homes, SyncError> {
1443 discover(&Client::new(login, password), address, given, mail_host)
1444}
1445
1446pub fn test_google(address: &str) -> Result<Homes, SyncError> {
1448 discover(&Client::google(address)?, address, None, None)
1449}
1450
1451#[cfg(test)]
1455pub(crate) mod stand_in {
1456 use std::collections::{BTreeMap, BTreeSet};
1457 use std::io::{BufRead, BufReader, Read, Write};
1458 use std::net::{TcpListener, TcpStream};
1459 use std::path::{Path, PathBuf};
1460 use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
1461 use std::time::Duration;
1462
1463 pub(crate) fn home() -> &'static Path {
1468 static HOME: OnceLock<PathBuf> = OnceLock::new();
1469 HOME.get_or_init(|| {
1470 forget_old_homes();
1471 let root = std::env::temp_dir().join(format!("sioul-sync-tests-{}", std::process::id()));
1472 let _ = std::fs::remove_dir_all(&root);
1473 for (variable, folder) in [("XDG_CONFIG_HOME", "config"), ("XDG_DATA_HOME", "data"), ("XDG_STATE_HOME", "state"), ("XDG_CACHE_HOME", "cache")] {
1474 let dir = root.join(folder);
1475 std::fs::create_dir_all(&dir).unwrap();
1476 unsafe { std::env::set_var(variable, &dir) };
1479 }
1480 root
1481 })
1482 }
1483
1484 fn forget_old_homes() {
1488 let hour_ago = std::time::SystemTime::now() - Duration::from_secs(3600);
1489 for entry in std::fs::read_dir(std::env::temp_dir()).into_iter().flatten().filter_map(Result::ok) {
1490 let name = entry.file_name().to_string_lossy().into_owned();
1491 let Some(process) = name.strip_prefix("sioul-sync-tests-") else { continue };
1492 let over = cfg!(target_os = "linux") && !Path::new("/proc").join(process).exists();
1493 let old = entry.metadata().and_then(|m| m.modified()).is_ok_and(|t| t < hour_ago);
1494 if over || old {
1495 let _ = std::fs::remove_dir_all(entry.path());
1496 }
1497 }
1498 }
1499
1500 pub(crate) struct Request {
1502 pub method: String,
1503 pub path: String,
1505 headers: Vec<(String, String)>,
1506 pub body: String,
1507 }
1508
1509 impl Request {
1510 pub fn header(&self, name: &str) -> Option<&str> {
1511 self.headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str())
1512 }
1513 }
1514
1515 pub(crate) struct Reply {
1516 pub status: u16,
1517 pub headers: Vec<(&'static str, String)>,
1518 pub body: String,
1519 }
1520
1521 impl Reply {
1522 pub fn new(status: u16, body: impl Into<String>) -> Reply {
1523 Reply { status, headers: Vec::new(), body: body.into() }
1524 }
1525 }
1526
1527 #[derive(Debug, Clone, Copy)]
1529 pub(crate) enum Fault {
1530 Status(u16),
1532 Drop,
1534 Lost,
1536 Late(Duration),
1538 Trickle(Duration),
1540 }
1541
1542 pub(crate) trait Server: Send + Sync + 'static {
1544 fn misbehaves(&self, request: &Request) -> Option<Fault>;
1545 fn answer(&self, request: &Request) -> Reply;
1546 }
1547
1548 pub(crate) fn serve(server: Arc<dyn Server>) -> String {
1551 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1552 let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
1553 std::thread::spawn(move || {
1554 for stream in listener.incoming().filter_map(Result::ok) {
1555 let server = Arc::clone(&server);
1556 std::thread::spawn(move || exchange(stream, server.as_ref()));
1557 }
1558 });
1559 base
1560 }
1561
1562 fn exchange(mut stream: TcpStream, server: &dyn Server) {
1563 let Some(request) = read(&stream) else { return };
1564 let fault = server.misbehaves(&request);
1565 let reply = match fault {
1566 Some(Fault::Status(status)) => Reply::new(status, ""),
1567 Some(Fault::Drop) => return,
1568 Some(Fault::Lost) => {
1569 server.answer(&request);
1570 return;
1571 }
1572 Some(Fault::Late(wait)) => {
1573 std::thread::sleep(wait);
1574 server.answer(&request)
1575 }
1576 Some(Fault::Trickle(_)) | None => server.answer(&request),
1577 };
1578 let headers: String = reply.headers.iter().map(|(name, value)| format!("{name}: {value}\r\n")).collect();
1579 let head = format!("HTTP/1.1 {} Stand-in\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n", reply.status, reply.body.len());
1580 if stream.write_all(head.as_bytes()).is_err() {
1581 return;
1582 }
1583 match fault {
1584 Some(Fault::Trickle(over)) => {
1585 let pieces = 30;
1586 for piece in reply.body.as_bytes().chunks(reply.body.len() / pieces + 1) {
1587 std::thread::sleep(over / pieces as u32);
1588 if stream.write_all(piece).and_then(|()| stream.flush()).is_err() {
1589 return;
1590 }
1591 }
1592 }
1593 _ => {
1594 let _ = stream.write_all(reply.body.as_bytes());
1595 }
1596 }
1597 }
1598
1599 fn read(stream: &TcpStream) -> Option<Request> {
1600 let mut reader = BufReader::new(stream);
1601 let mut line = String::new();
1602 reader.read_line(&mut line).ok()?;
1603 let mut words = line.split_whitespace();
1604 let (method, path) = (words.next()?.to_string(), words.next()?.to_string());
1605 let mut headers: Vec<(String, String)> = Vec::new();
1606 loop {
1607 let mut line = String::new();
1608 reader.read_line(&mut line).ok()?;
1609 let Some((name, value)) = line.trim_end().split_once(':') else { break };
1610 headers.push((name.trim().to_string(), value.trim().to_string()));
1611 }
1612 let length = headers.iter().find(|(n, _)| n.eq_ignore_ascii_case("content-length")).and_then(|(_, v)| v.parse().ok()).unwrap_or(0);
1613 let mut body = vec![0; length];
1614 reader.read_exact(&mut body).ok()?;
1615 Some(Request { method, path, headers, body: String::from_utf8_lossy(&body).into_owned() })
1616 }
1617
1618 pub(crate) fn nth(n: usize, matches: impl Fn(&Request) -> bool + Send + 'static, fault: Fault) -> impl FnMut(&Request) -> Option<Fault> + Send + 'static {
1620 let mut count = 0;
1621 move |request| {
1622 if !matches(request) {
1623 return None;
1624 }
1625 count += 1;
1626 (count == n).then_some(fault)
1627 }
1628 }
1629
1630 type Faults = Vec<Box<dyn FnMut(&Request) -> Option<Fault> + Send>>;
1631
1632 #[derive(Default)]
1636 pub(crate) struct Dav {
1637 held: Mutex<Held>,
1638 faults: Mutex<Faults>,
1639 }
1640
1641 #[derive(Default)]
1642 struct Held {
1643 calendars: BTreeMap<String, BTreeMap<String, (String, String)>>,
1645 version: u64,
1646 changes: Vec<(u64, String, String)>,
1648 seen: Vec<String>,
1650 uid_clash: Option<(u16, bool)>,
1653 elsewhere: Option<String>,
1657 }
1658
1659 impl Dav {
1660 pub(crate) fn start(calendars: &[&str]) -> (Arc<Dav>, String) {
1662 let dav = Arc::new(Dav::default());
1663 for name in calendars {
1664 dav.held().calendars.insert(name.to_string(), BTreeMap::new());
1665 }
1666 let base = serve(dav.clone());
1667 (dav, format!("{base}/cal/"))
1668 }
1669
1670 fn held(&self) -> MutexGuard<'_, Held> {
1671 self.held.lock().unwrap()
1672 }
1673
1674 pub(crate) fn put(&self, calendar: &str, item: &str, text: &str) {
1676 self.held().store(calendar, item, text);
1677 }
1678
1679 pub(crate) fn delete(&self, calendar: &str, item: &str) {
1681 self.held().delete(calendar, item);
1682 }
1683
1684 pub(crate) fn items(&self, calendar: &str) -> BTreeMap<String, String> {
1686 self.held().calendars[calendar].iter().map(|(name, (_, text))| (name.clone(), text.clone())).collect()
1687 }
1688
1689 pub(crate) fn seen(&self) -> Vec<String> {
1691 self.held().seen.clone()
1692 }
1693
1694 pub(crate) fn fault(&self, fault: impl FnMut(&Request) -> Option<Fault> + Send + 'static) {
1696 self.faults.lock().unwrap().push(Box::new(fault));
1697 }
1698
1699 pub(crate) fn one_uid_each(&self, status: u16, named: bool) {
1703 self.held().uid_clash = Some((status, named));
1704 }
1705
1706 pub(crate) fn name_elsewhere(&self, base: &str) {
1708 self.held().elsewhere = Some(base.to_string());
1709 }
1710 }
1711
1712 fn uid_line(text: &str) -> Option<&str> {
1714 text.lines().find_map(|l| l.trim_end_matches('\r').strip_prefix("UID:"))
1715 }
1716
1717 impl Server for Dav {
1718 fn misbehaves(&self, request: &Request) -> Option<Fault> {
1719 let fault = self.faults.lock().unwrap().iter_mut().find_map(|f| f(request));
1720 if let Some(fault @ (Fault::Status(_) | Fault::Drop | Fault::Lost)) = fault {
1722 self.held().seen.push(format!("{} {} {fault:?}", request.method, request.path));
1723 }
1724 fault
1725 }
1726
1727 fn answer(&self, request: &Request) -> Reply {
1728 let mut held = self.held();
1729 let reply = held.answer(request);
1730 held.seen.push(format!("{} {} {}", request.method, request.path, reply.status));
1731 reply
1732 }
1733 }
1734
1735 fn found(href: &str, props: &str) -> String {
1736 format!("<d:response><d:href>{href}</d:href><d:propstat><d:prop>{props}</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>")
1737 }
1738
1739 fn missing(href: &str) -> String {
1740 format!("<d:response><d:href>{href}</d:href><d:status>HTTP/1.1 404 Not Found</d:status></d:response>")
1741 }
1742
1743 fn multistatus(responses: &str) -> Reply {
1744 Reply::new(207, format!(r#"<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">{responses}</d:multistatus>"#))
1745 }
1746
1747 fn etag_prop(etag: &str) -> String {
1748 format!("<d:getetag>{}</d:getetag>", super::xml_escape(etag))
1749 }
1750
1751 impl Held {
1752 fn store(&mut self, calendar: &str, item: &str, text: &str) -> String {
1753 self.version += 1;
1754 let etag = format!("\"e{}\"", self.version);
1755 self.calendars.get_mut(calendar).expect("a calendar of the stand-in").insert(item.to_string(), (etag.clone(), text.to_string()));
1756 self.changes.push((self.version, calendar.to_string(), item.to_string()));
1757 etag
1758 }
1759
1760 fn delete(&mut self, calendar: &str, item: &str) {
1761 if self.calendars.get_mut(calendar).and_then(|items| items.remove(item)).is_some() {
1762 self.version += 1;
1763 self.changes.push((self.version, calendar.to_string(), item.to_string()));
1764 }
1765 }
1766
1767 fn last(&self, calendar: &str) -> u64 {
1769 self.changes.iter().filter(|(_, c, _)| c == calendar).map(|(v, _, _)| *v).max().unwrap_or(0)
1770 }
1771
1772 fn calendar_props(&self, calendar: &str) -> String {
1773 let last = self.last(calendar);
1774 format!("<d:resourcetype><d:collection/><c:calendar/></d:resourcetype><d:displayname>{calendar}</d:displayname><cs:getctag>ctag-{last}</cs:getctag><d:sync-token>token-{last}</d:sync-token>")
1775 }
1776
1777 fn answer(&mut self, request: &Request) -> Reply {
1778 let path = super::path_key(&request.path);
1779 let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
1780 match (request.method.as_str(), parts.as_slice()) {
1781 ("PROPFIND", ["cal"]) => {
1782 let mut out = found("/cal/", "<d:resourcetype><d:collection/></d:resourcetype>");
1783 for calendar in self.calendars.keys() {
1784 out += &found(&format!("/cal/{calendar}/"), &self.calendar_props(calendar));
1785 }
1786 if let Some(base) = &self.elsewhere {
1787 out += &found(&format!("{base}/cal/theirs/"), "<d:resourcetype><d:collection/><c:calendar/></d:resourcetype><d:displayname>theirs</d:displayname>");
1788 }
1789 multistatus(&out)
1790 }
1791 (_, ["cal", calendar, ..]) if !self.calendars.contains_key(*calendar) => Reply::new(404, ""),
1792 ("PROPFIND", ["cal", calendar]) => {
1793 let mut out = found(&format!("/cal/{calendar}/"), &self.calendar_props(calendar));
1794 for (item, (etag, _)) in &self.calendars[*calendar] {
1795 out += &found(&format!("/cal/{calendar}/{item}"), &format!("{}<d:resourcetype/>", etag_prop(etag)));
1796 }
1797 if let Some(base) = &self.elsewhere {
1798 out += &found(&format!("{base}/cal/{calendar}/there.ics"), &format!("{}<d:resourcetype/>", etag_prop("\"there\"")));
1799 }
1800 multistatus(&out)
1801 }
1802 ("REPORT", ["cal", calendar]) if request.body.contains("sync-collection") => self.changes_since(calendar, &request.body),
1803 ("REPORT", ["cal", calendar]) if request.body.contains("calendar-query") => self.query(calendar, &request.body),
1804 ("REPORT", ["cal", calendar]) => self.multiget(calendar, &request.body),
1805 ("PUT", ["cal", calendar, item]) => {
1806 let current = self.calendars[*calendar].get(*item).map(|(etag, _)| etag.clone());
1807 let refused = match (request.header("If-None-Match"), request.header("If-Match")) {
1808 (Some("*"), _) => current.is_some(),
1809 (_, Some(wanted)) => current.as_deref() != Some(wanted),
1810 _ => false,
1811 };
1812 if refused {
1813 return Reply::new(412, "");
1814 }
1815 let holder = uid_line(&request.body).and_then(|uid| self.calendars[*calendar].iter().find(|(name, (_, text))| name.as_str() != *item && uid_line(text) == Some(uid))).map(|(name, _)| name.clone());
1816 if current.is_none()
1817 && let (Some((status, named)), Some(holder)) = (self.uid_clash, holder)
1818 {
1819 let body = if named {
1820 format!(r#"<?xml version="1.0" encoding="utf-8"?><d:error xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><c:no-uid-conflict><d:href>/cal/{calendar}/{holder}</d:href></c:no-uid-conflict></d:error>"#)
1821 } else {
1822 "Calendar object with uid already exists in this calendar collection.".to_string()
1823 };
1824 return Reply::new(status, body);
1825 }
1826 let etag = self.store(calendar, item, &request.body);
1827 Reply { status: if current.is_some() { 204 } else { 201 }, headers: vec![("ETag", etag)], body: String::new() }
1828 }
1829 ("DELETE", ["cal", calendar, item]) => match (self.calendars[*calendar].get(*item), request.header("If-Match")) {
1830 (None, _) => Reply::new(404, ""),
1831 (Some((etag, _)), Some(wanted)) if etag != wanted => Reply::new(412, ""),
1832 _ => {
1833 self.delete(calendar, item);
1834 Reply::new(204, "")
1835 }
1836 },
1837 ("GET", ["cal", calendar, item]) => match self.calendars[*calendar].get(*item) {
1838 Some((etag, text)) => Reply { status: 200, headers: vec![("ETag", etag.clone())], body: text.clone() },
1839 None => Reply::new(404, ""),
1840 },
1841 _ => Reply::new(405, ""),
1842 }
1843 }
1844
1845 fn changes_since(&self, calendar: &str, body: &str) -> Reply {
1847 let doc = roxmltree::Document::parse(body).unwrap();
1848 let token = doc.descendants().find(|n| n.has_tag_name(("DAV:", "sync-token"))).and_then(|n| n.text()).unwrap_or("");
1849 let Some(since) = token.trim().strip_prefix("token-").and_then(|t| t.parse::<u64>().ok()) else { return Reply::new(403, "") };
1850 let changed: BTreeSet<&str> = self.changes.iter().filter(|(v, c, _)| *v > since && c == calendar).map(|(_, _, item)| item.as_str()).collect();
1851 let mut out = String::new();
1852 for item in changed {
1853 let href = format!("/cal/{calendar}/{item}");
1854 out += &match self.calendars[calendar].get(item) {
1855 Some((etag, _)) => found(&href, &etag_prop(etag)),
1856 None => missing(&href),
1857 };
1858 }
1859 out += &format!("<d:sync-token>token-{}</d:sync-token>", self.last(calendar));
1860 multistatus(&out)
1861 }
1862
1863 fn query(&self, calendar: &str, body: &str) -> Reply {
1865 let doc = roxmltree::Document::parse(body).unwrap();
1866 let uid = doc.descendants().find(|n| n.tag_name().name() == "text-match").and_then(|n| n.text()).unwrap_or("").to_string();
1867 let mut out = String::new();
1868 for (item, (etag, text)) in &self.calendars[calendar] {
1869 if !uid.is_empty() && uid_line(text).is_some_and(|u| u.contains(&uid)) {
1870 out += &found(&format!("/cal/{calendar}/{item}"), &etag_prop(etag));
1871 }
1872 }
1873 multistatus(&out)
1874 }
1875
1876 fn multiget(&self, calendar: &str, body: &str) -> Reply {
1877 let doc = roxmltree::Document::parse(body).unwrap();
1878 let mut out = String::new();
1879 for href in doc.descendants().filter(|n| n.has_tag_name(("DAV:", "href"))).filter_map(|n| n.text()) {
1880 let key = super::path_key(href);
1881 let item = key.rsplit('/').next().unwrap_or("");
1882 out += &match self.calendars[calendar].get(item) {
1883 Some((etag, text)) => found(href, &format!("{}<c:calendar-data>{}</c:calendar-data>", etag_prop(etag), super::xml_escape(text))),
1884 None => missing(href),
1885 };
1886 }
1887 multistatus(&out)
1888 }
1889 }
1890}
1891
1892#[cfg(test)]
1893mod tests {
1894 use super::stand_in::{Dav, Fault, home, nth};
1895 use super::*;
1896 use std::sync::Arc;
1897
1898 fn account(id: &str) -> Account {
1900 home();
1901 toml::from_str(&format!("id = \"{id}\"\nkind = \"dav\"\naddress = \"jane@example.org\"\nhost = \"127.0.0.1\"\n")).unwrap()
1902 }
1903
1904 fn client() -> Client {
1905 Client::new("jane", "secret")
1906 }
1907
1908 fn event(uid: &str, summary: &str) -> String {
1909 format!(
1910 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sioul//tests//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261005T120000Z\r\nDTSTART:20261006T090000Z\r\nDTEND:20261006T100000Z\r\nSUMMARY:{summary}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
1911 )
1912 }
1913
1914 fn listed(client: &Client, home: &str, name: &str) -> Listed {
1916 collections(client, Kind::Calendars, home).unwrap().into_iter().find(|l| l.id == name).unwrap()
1917 }
1918
1919 fn sync_one(client: &Client, account: &str, home: &str, name: &str) -> Result<Report, SyncError> {
1921 let mut report = Report::default();
1922 sync_collection(client, account, &listed(client, home, name), &mut report).map(|()| report)
1923 }
1924
1925 fn folder(account: &str, calendar: &str) -> PathBuf {
1926 Kind::Calendars.root().join(account).join(calendar)
1927 }
1928
1929 fn state_of(account: &str, calendar: &str) -> State {
1930 State::load(&vdir::state_path(account, Kind::Calendars, calendar))
1931 }
1932
1933 fn puts(dav: &Dav) -> Vec<String> {
1934 dav.seen().into_iter().filter(|s| s.starts_with("PUT")).collect()
1935 }
1936
1937 #[test]
1941 fn an_interrupted_pull_resumes() {
1942 home();
1943 let (dav, home) = Dav::start(&["big"]);
1944 for n in 0..120 {
1945 dav.put("big", &format!("e{n}.ics"), &event(&format!("uid-{n}"), &format!("Event {n}")));
1946 }
1947 dav.fault(nth(2, |r| r.method == "REPORT" && r.body.contains("multiget"), Fault::Drop));
1948 let client = client();
1949 assert!(matches!(sync_one(&client, "resumes", &home, "big"), Err(SyncError::Network(_))));
1950 let report = sync_one(&client, "resumes", &home, "big").unwrap();
1951 assert_eq!((report.sent, report.received, report.conflicts.len()), (0, 70, 0), "{report:?}");
1952 assert_eq!(local_files(&folder("resumes", "big"), Kind::Calendars).len(), 120);
1953 assert_eq!(dav.items("big").len(), 120);
1954 assert!(puts(&dav).is_empty(), "{:?}", dav.seen());
1955 }
1956
1957 #[test]
1960 fn a_sent_event_is_not_sent_again() {
1961 home();
1962 let (dav, home) = Dav::start(&["work"]);
1963 dav.put("work", "known.ics", &event("known", "Known"));
1964 let client = client();
1965 sync_one(&client, "sent-once", &home, "work").unwrap();
1966 dav.put("work", "theirs.ics", &event("theirs", "Theirs"));
1967 std::fs::write(folder("sent-once", "work").join("mine.ics"), event("mine", "Mine")).unwrap();
1968 dav.fault(nth(1, |r| r.method == "REPORT" && r.body.contains("sync-collection"), Fault::Drop));
1969 dav.fault(nth(1, |r| r.method == "PROPFIND" && r.path == "/cal/work/", Fault::Drop));
1970 assert!(sync_one(&client, "sent-once", &home, "work").is_err());
1971 let report = sync_one(&client, "sent-once", &home, "work").unwrap();
1972 assert_eq!((report.sent, report.received, report.conflicts.len()), (0, 1, 0), "{report:?}");
1973 assert_eq!(puts(&dav), ["PUT /cal/work/mine.ics 201"]);
1974 assert_eq!(dav.items("work").len(), 3);
1975 }
1976
1977 #[test]
1981 fn a_lost_answer_is_not_a_conflict() {
1982 home();
1983 let (dav, home) = Dav::start(&["home"]);
1984 let client = client();
1985 sync_one(&client, "lost", &home, "home").unwrap();
1986 let dir = folder("lost", "home");
1987 std::fs::write(dir.join("new.ics"), event("new", "New")).unwrap();
1988 dav.fault(nth(1, |r| r.method == "PUT", Fault::Lost));
1989 assert!(matches!(sync_one(&client, "lost", &home, "home"), Err(SyncError::Network(_))));
1990 let report = sync_one(&client, "lost", &home, "home").unwrap();
1991 assert!(report.conflicts.is_empty(), "{report:?}");
1992 assert_eq!(dav.items("home").len(), 1);
1993 assert_eq!(std::fs::read_to_string(dir.join("new.ics")).unwrap(), event("new", "New"));
1994
1995 dav.put("home", "clash.ics", &event("theirs", "Theirs"));
1996 std::fs::write(dir.join("clash.ics"), event("mine", "Mine")).unwrap();
1997 let report = sync_one(&client, "lost", &home, "home").unwrap();
1998 assert_eq!(report.conflicts.len(), 1, "{report:?}");
1999 assert!(std::fs::read_to_string(&report.conflicts[0]).unwrap().contains("SUMMARY:Mine"));
2000 assert!(std::fs::read_to_string(dir.join("clash.ics")).unwrap().contains("SUMMARY:Theirs"));
2001 let report = sync_one(&client, "lost", &home, "home").unwrap();
2002 assert_eq!((report.sent, report.received, report.conflicts.len()), (0, 0, 0), "{report:?}");
2003 }
2004
2005 #[derive(Default)]
2007 struct Elsewhere {
2008 seen: Mutex<Vec<String>>,
2009 }
2010
2011 impl stand_in::Server for Elsewhere {
2012 fn misbehaves(&self, _: &stand_in::Request) -> Option<Fault> {
2013 None
2014 }
2015
2016 fn answer(&self, request: &stand_in::Request) -> stand_in::Reply {
2017 self.seen.lock().unwrap().push(format!("{} {} {}", request.method, request.path, request.header("Authorization").unwrap_or("")));
2018 stand_in::Reply::new(404, "")
2019 }
2020 }
2021
2022 #[test]
2026 fn the_login_stays_on_the_account_s_server() {
2027 let account = account("kept-to");
2028 let elsewhere = Arc::new(Elsewhere::default());
2029 let there = stand_in::serve(elsewhere.clone());
2030 let (dav, home) = Dav::start(&["mine", "only-there"]);
2033 Homes { calendars: Some(home.clone()), contacts: None }.save(&account.id).unwrap();
2034 dav.put("mine", "new.ics", &event("new", "New here"));
2035 dav.name_elsewhere(&there);
2036 let result = sync_with(&client(), &account);
2037 assert!(matches!(&result, Err(SyncError::Elsewhere(url)) if url.starts_with(&there)), "{result:?}");
2038 assert!(folder("kept-to", "mine").join("new.ics").exists());
2039 assert!(dav.seen().iter().any(|s| s.starts_with("REPORT /cal/only-there/")), "{:?}", dav.seen());
2040 assert!(elsewhere.seen.lock().unwrap().is_empty(), "{:?}", elsewhere.seen.lock().unwrap());
2041 let said = result.unwrap_err().sentence(&sioul_core::i18n::Translator::new("en"), "Calendars");
2043 assert!(said.starts_with("Calendars: the server named an address on another server") && said.contains(&there), "{said}");
2044 let kept = client();
2046 kept.keep_to(&Homes { calendars: Some(home.clone()), contacts: None });
2047 assert!(kept.mine(&format!("{home}mine/new.ics")).is_ok());
2048 assert!(kept.mine(&home.replacen("http://", "HTTP://", 1)).is_ok());
2049 assert!(kept.mine(&format!("{there}/cal/")).is_err());
2050 assert!(kept.mine("https://127.0.0.1/cal/").is_err());
2051 let pointing = stand_in::serve(Arc::new(Pointing(format!("{}/principals/jane/", there.replace("127.0.0.1", "localhost")))));
2053 assert!(matches!(super::home(&client(), Kind::Calendars, &format!("{pointing}/")), Err(SyncError::Elsewhere(url)) if url.contains("localhost")));
2054 assert!(elsewhere.seen.lock().unwrap().is_empty());
2055 }
2056
2057 struct Pointing(String);
2059
2060 impl stand_in::Server for Pointing {
2061 fn misbehaves(&self, _: &stand_in::Request) -> Option<Fault> {
2062 None
2063 }
2064
2065 fn answer(&self, _: &stand_in::Request) -> stand_in::Reply {
2066 stand_in::Reply::new(
2067 207,
2068 format!(
2069 r#"<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/</d:href><d:propstat><d:prop><d:current-user-principal><d:href>{}</d:href></d:current-user-principal></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#,
2070 self.0
2071 ),
2072 )
2073 }
2074 }
2075
2076 #[test]
2077 fn origins_are_scheme_host_and_port() {
2078 assert_eq!(origin("https://Dav.Example.org/remote.php/dav/").as_deref(), Some("https://dav.example.org:443"));
2079 assert_eq!(origin("https://dav.example.org:443/x"), origin("https://dav.example.org/y?z"));
2080 assert_ne!(origin("https://dav.example.org:8443/"), origin("https://dav.example.org/"));
2081 assert_ne!(origin("http://dav.example.org/"), origin("https://dav.example.org/"));
2082 assert_eq!(origin("https://dav.example.org@other.example.org/").as_deref(), Some("https://other.example.org:443"));
2084 assert_eq!(origin("https://[2001:db8::1]:8443/").as_deref(), Some("https://[2001:db8::1]:8443"));
2085 assert_eq!(origin("https://[2001:db8::1]/").as_deref(), Some("https://[2001:db8::1]:443"));
2086 assert_eq!(origin("/cal/a/"), None);
2087 assert_eq!(origin("https://dav.example.org:x/"), None);
2088 assert_eq!(origin("ftp://dav.example.org/"), None);
2089 }
2090
2091 #[test]
2094 fn one_bad_calendar_does_not_stop_the_others() {
2095 let account = account("bad-one");
2096 let (dav, home) = Dav::start(&["a", "b"]);
2097 Homes { calendars: Some(home), contacts: None }.save(&account.id).unwrap();
2098 dav.put("b", "new.ics", &event("new", "New in b"));
2099 dav.fault(nth(1, |r| r.method == "PROPFIND" && r.path == "/cal/a/", Fault::Status(500)));
2100 let result = sync_with(&client(), &account);
2101 assert!(matches!(&result, Err(SyncError::Server(e)) if e.contains("/cal/a/")), "{result:?}");
2102 assert!(folder("bad-one", "b").join("new.ics").exists());
2103 dav.put("b", "newer.ics", &event("newer", "Newer in b"));
2104 dav.fault(nth(1, |r| r.method == "PROPFIND" && r.path == "/cal/a/", Fault::Drop));
2105 assert!(matches!(sync_with(&client(), &account), Err(SyncError::Network(_))));
2106 assert!(folder("bad-one", "b").join("newer.ics").exists());
2107 assert_eq!(sync_with(&client(), &account).unwrap().collections, 2);
2108 }
2109
2110 thread_local! {
2111 pub(super) static READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
2113 }
2114
2115 fn reads() -> usize {
2116 READS.with(std::cell::Cell::get)
2117 }
2118
2119 #[test]
2122 fn unchanged_files_are_not_read() {
2123 home();
2124 let (dav, home) = Dav::start(&["many"]);
2125 for n in 0..1000 {
2126 dav.put("many", &format!("e{n}.ics"), &event(&format!("u{n}"), "Same"));
2127 }
2128 let client = client();
2129 sync_one(&client, "unread", &home, "many").unwrap();
2130 let dir = folder("unread", "many");
2132 let hour_ago = std::time::SystemTime::now() - Duration::from_secs(3600);
2133 for file in local_files(&dir, Kind::Calendars) {
2134 std::fs::File::options().write(true).open(dir.join(file)).unwrap().set_modified(hour_ago).unwrap();
2135 }
2136 sync_one(&client, "unread", &home, "many").unwrap();
2137 let before = reads();
2138 assert_eq!(sync_one(&client, "unread", &home, "many").unwrap().sent, 0);
2139 assert_eq!(reads() - before, 0, "nothing read");
2140 std::fs::write(dir.join("e7.ics"), event("u7", "Edited")).unwrap();
2141 let before = reads();
2142 assert_eq!(sync_one(&client, "unread", &home, "many").unwrap().sent, 1);
2143 assert_eq!(reads() - before, 1, "the edited file only");
2144 assert!(dav.items("many")["e7.ics"].contains("SUMMARY:Edited"));
2145 }
2146
2147 #[test]
2151 fn a_slow_answer_is_read_while_it_moves() {
2152 home();
2153 let (dav, home) = Dav::start(&["slow"]);
2154 for n in 0..40 {
2155 dav.put("slow", &format!("e{n}.ics"), &event(&format!("u{n}"), "Slow"));
2156 }
2157 let budget = Budget { connect: Duration::from_secs(5), stall: Duration::from_millis(500), whole: Duration::from_secs(60) };
2158 let client = Client { agent: agent(&budget), ..client() };
2159 dav.fault(nth(1, |r| r.body.contains("multiget"), Fault::Trickle(Duration::from_secs(3))));
2160 let started = std::time::Instant::now();
2161 assert_eq!(sync_one(&client, "slow", &home, "slow").unwrap().received, 40);
2162 assert!(started.elapsed() > Duration::from_secs(3));
2163 dav.put("slow", "late.ics", &event("late", "Late"));
2164 dav.fault(nth(1, |r| r.body.contains("sync-collection"), Fault::Late(Duration::from_secs(5))));
2165 dav.fault(nth(1, |r| r.method == "PROPFIND" && r.path == "/cal/slow/", Fault::Late(Duration::from_secs(5))));
2166 let started = std::time::Instant::now();
2167 assert!(matches!(sync_one(&client, "slow", &home, "slow"), Err(SyncError::Network(_))));
2168 assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed());
2169 }
2170
2171 #[test]
2175 fn batches_follow_the_answers() {
2176 home();
2177 let (dav, home) = Dav::start(&["paced", "cut"]);
2178 for n in 0..200 {
2179 dav.put("paced", &format!("e{n}.ics"), &event(&format!("p{n}"), "Paced"));
2180 }
2181 for n in 0..120 {
2182 dav.put("cut", &format!("e{n}.ics"), &event(&format!("c{n}"), "Cut"));
2183 }
2184 let asked = std::sync::Arc::new(Mutex::new(Vec::<(String, usize)>::new()));
2186 let noted = std::sync::Arc::clone(&asked);
2187 dav.fault(move |r| {
2188 if r.body.contains("multiget") {
2189 noted.lock().unwrap().push((r.path.clone(), r.body.matches("<d:href>").count()));
2190 }
2191 None
2192 });
2193 let of = |calendar: &str| asked.lock().unwrap().iter().filter(|(path, _)| *path == format!("/cal/{calendar}/")).map(|(_, n)| *n).collect::<Vec<_>>();
2194 dav.fault(nth(1, |r| r.path == "/cal/paced/" && r.body.contains("multiget"), Fault::Late(SLOW + Duration::from_millis(300))));
2195 let client = client();
2196 assert_eq!(sync_one(&client, "paced", &home, "paced").unwrap().received, 200);
2197 assert_eq!(of("paced"), [50, 25, 50, 50, 25]);
2198 dav.fault(nth(1, |r| r.path == "/cal/cut/" && r.body.contains("multiget"), Fault::Drop));
2199 assert!(matches!(sync_one(&client, "paced-cut", &home, "cut"), Err(SyncError::Network(_))));
2200 assert_eq!(sync_one(&client, "paced-cut", &home, "cut").unwrap().received, 120);
2201 assert_eq!(of("cut"), [50, 25, 50, 45]);
2202 }
2203
2204 #[test]
2212 fn an_invitation_answered_on_two_devices() {
2213 home();
2214 let invitation = |uid: &str, answer: &str| {
2215 format!(
2216 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sioul//tests//EN\r\nBEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261005T120000Z\r\nDTSTART:20261012T090000Z\r\nDTEND:20261012T100000Z\r\nSUMMARY:Review\r\nORGANIZER;CN=Jane:mailto:jane@example.org\r\nATTENDEE;CN=Me;PARTSTAT={answer}:mailto:me@example.net\r\nATTENDEE;CN=Paul:mailto:paul@example.org\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
2217 )
2218 };
2219 for (n, (status, named)) in [(403, true), (409, true), (400, false), (412, false)].into_iter().enumerate() {
2220 let (dav, home) = Dav::start(&["invited"]);
2221 dav.one_uid_each(status, named);
2222 let (client, account) = (client(), format!("invited-{n}"));
2223 sync_one(&client, &account, &home, "invited").unwrap();
2224 let dir = folder(&account, "invited");
2225 dav.put("invited", "phone.ics", &invitation("review-1", "ACCEPTED"));
2227 std::fs::write(dir.join("desk.ics"), invitation("review-1", "ACCEPTED")).unwrap();
2228 let report = sync_one(&client, &account, &home, "invited").unwrap();
2229 assert!(report.answered.is_empty() && report.conflicts.is_empty(), "{status}: {report:?}");
2230 assert_eq!(local_files(&dir, Kind::Calendars), BTreeSet::from(["desk.ics".to_string()]), "{status}: one file, under its name here");
2231 assert_eq!(dav.items("invited").len(), 1, "{status}");
2232 let again = sync_one(&client, &account, &home, "invited").unwrap();
2233 assert_eq!((again.sent, again.received), (0, 0), "{status}: settled: {again:?}");
2234 dav.put("invited", "phone-2.ics", &invitation("review-2", "TENTATIVE"));
2236 std::fs::write(dir.join("desk-2.ics"), invitation("review-2", "ACCEPTED")).unwrap();
2237 let report = sync_one(&client, &account, &home, "invited").unwrap();
2238 assert_eq!(report.answered, [("Review".to_string(), "TENTATIVE".to_string())], "{status}: {report:?}");
2239 assert!(report.conflicts.is_empty(), "{status}: {report:?}");
2240 assert!(std::fs::read_to_string(dir.join("desk-2.ics")).unwrap().contains("PARTSTAT=TENTATIVE:mailto:me@example.net"), "{status}");
2241 assert_eq!(local_files(&dir, Kind::Calendars).len(), 2, "{status}");
2242 assert!(dav.items("invited")["phone-2.ics"].contains("PARTSTAT=TENTATIVE"), "{status}: the server's left as it was");
2243 assert!(!puts(&dav).iter().any(|p| p.ends_with(" 201") || p.ends_with(" 204")), "{status}: nothing written there: {:?}", dav.seen());
2244 }
2245 }
2246
2247 #[test]
2252 fn a_refused_password_parks_the_watcher() {
2253 let control = Control::default();
2254 let syncs = std::sync::atomic::AtomicUsize::new(0);
2255 let kept = std::sync::Mutex::new("old".to_string());
2256 let (said, heard) = std::sync::mpsc::channel();
2257 std::thread::scope(|s| {
2258 s.spawn(|| {
2259 let sync = || match syncs.fetch_add(1, std::sync::atomic::Ordering::Relaxed) {
2260 0 | 1 => Err(SyncError::Login("PROPFIND: 401".into())),
2261 _ => Ok(Report::default()),
2262 };
2263 watch_with(&control, Duration::from_secs(3600), sync, || Some(kept.lock().unwrap().clone()), |result| said.send(result.is_ok()).unwrap());
2264 });
2265 assert_eq!(heard.recv_timeout(Duration::from_secs(5)), Ok(false), "refused");
2266 control.nudge();
2267 assert!(heard.recv_timeout(Duration::from_millis(300)).is_err(), "the same password: parked");
2268 control.retry();
2269 assert_eq!(heard.recv_timeout(Duration::from_secs(5)), Ok(false), "Sync now: tried, refused again");
2270 *kept.lock().unwrap() = "new".into();
2271 control.nudge();
2272 assert_eq!(heard.recv_timeout(Duration::from_secs(5)), Ok(true), "another: synced at once");
2273 control.stop();
2274 });
2275 assert_eq!(syncs.load(std::sync::atomic::Ordering::Relaxed), 3);
2276 }
2277
2278 #[test]
2281 fn a_change_to_an_item_gone_there_is_set_aside() {
2282 home();
2283 let (dav, home) = Dav::start(&["gone"]);
2284 dav.put("gone", "x.ics", &event("x", "Before"));
2285 let client = client();
2286 sync_one(&client, "gone-edited", &home, "gone").unwrap();
2287 let file = folder("gone-edited", "gone").join("x.ics");
2288 dav.delete("gone", "x.ics");
2289 std::fs::write(&file, event("x", "Edited here")).unwrap();
2290 let report = sync_one(&client, "gone-edited", &home, "gone").unwrap();
2291 assert_eq!((report.removed, report.conflicts.len()), (1, 1), "{report:?}");
2292 assert!(std::fs::read_to_string(&report.conflicts[0]).unwrap().contains("SUMMARY:Edited here"));
2293 assert!(!file.exists());
2294 assert!(state_of("gone-edited", "gone").items.is_empty());
2295 }
2296
2297 #[test]
2300 fn one_sync_of_an_account_at_a_time() {
2301 let account = account("one-at-a-time");
2302 let (dav, home) = Dav::start(&["a"]);
2303 Homes { calendars: Some(home), contacts: None }.save(&account.id).unwrap();
2304 sync_with(&client(), &account).unwrap();
2305 std::fs::write(folder("one-at-a-time", "a").join("new.ics"), event("new", "New")).unwrap();
2306 dav.fault(|r| (r.method == "PUT").then_some(Fault::Late(Duration::from_millis(300))));
2307 let both: Vec<_> = (0..2)
2308 .map(|_| {
2309 let account = account.clone();
2310 std::thread::spawn(move || sync_with(&client(), &account))
2311 })
2312 .collect();
2313 for sync in both {
2314 sync.join().unwrap().unwrap();
2315 }
2316 assert_eq!(puts(&dav), ["PUT /cal/a/new.ics 201"]);
2317 assert_eq!(state_of("one-at-a-time", "a").items.iter().filter(|i| i.file == "new.ics").count(), 1);
2318 }
2319
2320 #[test]
2323 fn a_cut_push_keeps_its_place() {
2324 home();
2325 let (dav, home) = Dav::start(&["four"]);
2326 for n in 0..4 {
2327 dav.put("four", &format!("e{n}.ics"), &event(&format!("u{n}"), "Before"));
2328 }
2329 let client = client();
2330 sync_one(&client, "cut-push", &home, "four").unwrap();
2331 let dir = folder("cut-push", "four");
2332 for n in 0..4 {
2333 std::fs::write(dir.join(format!("e{n}.ics")), event(&format!("u{n}"), "After")).unwrap();
2334 }
2335 let before = state_of("cut-push", "four").items;
2336 dav.fault(nth(3, |r| r.method == "PUT", Fault::Drop));
2337 let mut state = state_of("cut-push", "four");
2338 let (mut refetch, mut report) = (BTreeSet::new(), Report::default());
2339 assert!(push(&client, Kind::Calendars, &dir, &listed(&client, &home, "four"), &mut state, &mut refetch, &mut report).is_err());
2340 assert_eq!(state.items.len(), 4, "every item still known: {:?}", state.items);
2341 for (now, then) in state.items.iter().zip(&before).take(2) {
2342 assert_ne!(now.etag, then.etag);
2343 assert_eq!(Some(now.hash.clone()), hash_of(&dir.join(&now.file)));
2344 }
2345 assert_eq!(state.items[2..], before[2..]);
2346 }
2347
2348 #[test]
2349 fn reads_a_multistatus() {
2350 let xml = r#"<?xml version="1.0"?>
2351<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:x="http://apple.com/ns/ical/">
2352 <d:response><d:href>/dav/calendars/jane/personal/</d:href>
2353 <d:propstat><d:prop><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype><d:displayname>Personal</d:displayname>
2354 <x:calendar-color>#4C6B5CFF</x:calendar-color><cal:supported-calendar-component-set><cal:comp name="VEVENT"/></cal:supported-calendar-component-set>
2355 <d:current-user-privilege-set><d:privilege><d:read/></d:privilege></d:current-user-privilege-set></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
2356 <d:propstat><d:prop><d:getctag/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response>
2357 <d:response><d:href>/dav/calendars/jane/personal/gone.ics</d:href><d:status>HTTP/1.1 404 Not Found</d:status></d:response>
2358 <d:sync-token>http://example.org/sync/42</d:sync-token>
2359</d:multistatus>"#;
2360 let (responses, token) = multistatus(xml).unwrap();
2361 assert_eq!(token.as_deref(), Some("http://example.org/sync/42"));
2362 assert!(responses[0].is(CALDAV, "calendar"));
2363 assert_eq!(responses[0].text(DAV, "displayname").as_deref(), Some("Personal"));
2364 assert_eq!(responses[0].prop(CALDAV, "supported-calendar-component-set").unwrap().comps, vec!["VEVENT"]);
2365 assert!(responses[0].prop(CALSERVER, "getctag").is_none());
2366 assert_eq!(responses[1].status, Some(404));
2367 }
2368
2369 #[test]
2370 fn addresses_and_names() {
2371 assert_eq!(absolute("https://h.example/remote.php/dav/", "/remote.php/dav/principals/jane/"), "https://h.example/remote.php/dav/principals/jane/");
2372 assert_eq!(absolute("https://h.example/a/b/", "c.ics"), "https://h.example/a/b/c.ics");
2373 assert_eq!(path_key("https://h.example/a%20b/c.ics"), "/a b/c.ics");
2374 assert_eq!(path_key("/a/b/"), "/a/b");
2375 let taken: BTreeSet<String> = ["abc.ics".to_string()].into_iter().collect();
2376 assert_eq!(file_name("/cal/abc.ics", "ics", &taken), "abc-2.ics");
2377 assert_eq!(file_name("/cal/a%20b:c.ics", "ics", &BTreeSet::new()), "a_b_c.ics");
2378 assert_eq!(file_name("/cal/..hidden.ics", "ics", &BTreeSet::new()), "hidden.ics", "never a hidden file");
2379 assert_eq!(file_name("/book/CON.vcf", "vcf", &BTreeSet::new()), "_CON.vcf", "nor a name Windows keeps");
2380 assert_eq!(file_name("/book/com1.x.vcf", "vcf", &BTreeSet::new()), "_com1.x.vcf");
2381 assert_eq!(url_segment("sioul-1.vcf"), "sioul-1.vcf");
2382 assert!(allowed("http://example.org/").is_err());
2383 assert_eq!(domain("https://caldav.example.org:8443/dav/"), domain("https://example.org/.well-known/caldav"));
2385 assert_ne!(domain("https://parking.example.net/x"), domain("https://example.org/.well-known/caldav"));
2386 assert_eq!(xml_escape(r#"a<"b"&"#), "a<"b"&");
2387 }
2388}