cosmic-pim-sync 3.3.0

Ties accounts, credentials, CalDAV, and the vdir into one sync cycle
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
// Copyright 2026 Dominikos Pritis
// SPDX-License-Identifier: MPL-2.0

//! One sync pass over every enabled account.
//!
//! # Failure policy
//!
//! Nothing here short-circuits. One unreachable server, one calendar whose
//! PROPFIND 500s, one account with a stale password — none of them may stop the
//! other accounts and calendars from syncing. Every failure is captured in the
//! report rather than propagated, so the caller ends up with a complete picture
//! of what worked and what did not, and the UI can show a per-calendar error
//! instead of a single opaque "sync failed".
//!
//! The one thing that *is* fatal to an account is a missing password, because
//! every subsequent request would fail identically and hammering the server
//! with known-bad credentials is how an account gets rate-limited or locked.

use std::path::Path;

use cosmic_pim_accounts::{Account, AccountStore, DavService, Registry, Secret};
use cosmic_pim_caldav::push::{DrainOutcome, drain};
use cosmic_pim_caldav::{Auth, CaldavClient, Flavor, SyncOutcome, sync_collection};

use crate::error::{Error, Result};
use crate::provision::{Provisioned, open_store, provision_account};

/// What happened to one collection.
#[derive(Debug)]
pub struct CollectionReport {
    pub collection_id: String,
    pub display_name: String,
    pub href: String,
    /// Calendar or address book.
    pub flavor: Flavor,
    /// What the local writeback queue did before the pull ran.
    pub pushed: DrainOutcome,
    pub outcome: Result<SyncOutcome>,
}

impl CollectionReport {
    #[must_use]
    pub fn changed(&self) -> bool {
        matches!(&self.outcome, Ok(o) if o.changed()) || self.pushed.succeeded > 0
    }

    /// Local edits still waiting to reach the server.
    #[must_use]
    pub fn has_unpushed_changes(&self) -> bool {
        self.pushed.deferred > 0 || self.pushed.skipped > 0
    }

    /// Resources the server changed while we were holding an unsent change to
    /// the same ones. Read the records with [`crate::conflicts`].
    #[must_use]
    pub fn conflicts(&self) -> usize {
        match &self.outcome {
            Ok(outcome) => outcome.conflicts,
            Err(_) => 0,
        }
    }

    /// Whether this collection needs a person rather than another sync: a
    /// password to re-enter, write access it does not have, a full account, or
    /// a conflict to decide.
    #[must_use]
    pub fn needs_attention(&self) -> bool {
        self.pushed.needs_attention() || self.conflicts() > 0
    }
}

/// What happened to one account.
#[derive(Debug)]
pub struct AccountReport {
    pub account_id: String,
    pub display_name: String,
    /// Every collection of both kinds, calendars first.
    ///
    /// `Err` means the account failed before any collection was reached —
    /// no password, discovery refused, the host is down.
    pub collections: Result<Vec<CollectionReport>>,
    /// Why address books were not reached, when they were not.
    ///
    /// Separate from `collections` because "this server offers no CardDAV" is
    /// the ordinary case for a calendar-only account and must not present as a
    /// failed sync — while a CardDAV server that *is* there and refused us has
    /// to be visible rather than silently skipped. There is no collection to
    /// hang such an error on, so it hangs here.
    pub contacts_unavailable: Option<String>,
}

impl AccountReport {
    /// Whether anything landed on disk, so the caller can skip a redraw.
    #[must_use]
    pub fn changed(&self) -> bool {
        matches!(&self.collections, Ok(reports) if reports.iter().any(CollectionReport::changed))
    }

    /// What this pass did, as numbers an application words in its own
    /// language.
    ///
    /// Data rather than a sentence: the English one-liner this replaced was
    /// shown verbatim by every app, untranslatable, and it left out the
    /// contacts half of the account — a CardDAV server that refused us read
    /// as "up to date" (audit F-47, F-48; Circle S-01, S-02).
    #[must_use]
    pub fn tally(&self) -> SyncTally {
        let mut tally = SyncTally {
            contacts_unavailable: self.contacts_unavailable.clone(),
            ..SyncTally::default()
        };
        match &self.collections {
            Err(why) => tally.account_error = Some(why.to_string()),
            Ok(reports) => {
                for report in reports {
                    match &report.outcome {
                        Ok(outcome) => {
                            tally.fetched += outcome.fetched;
                            tally.deleted += outcome.deleted;
                        }
                        Err(_) => tally.failed += 1,
                    }
                    tally.pushed += report.pushed.succeeded;
                    tally.conflicts += report.conflicts();
                    tally.held += report.pushed.needs_user + report.pushed.needs_reconcile;
                }
            }
        }
        tally
    }
}

/// The counts behind one account's sync pass. See [`AccountReport::tally`].
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SyncTally {
    /// Resources brought down from the server.
    pub fetched: usize,
    /// Resources removed because the server removed them.
    pub deleted: usize,
    /// Local changes the server accepted.
    pub pushed: usize,
    /// Collections whose pass failed.
    pub failed: usize,
    /// Resources both sides changed, waiting for the user.
    pub conflicts: usize,
    /// Local changes held for the user or for a reconcile: a password to
    /// re-enter, no write access, a stale edit.
    pub held: usize,
    /// Why the account failed before any collection was reached.
    pub account_error: Option<String>,
    /// Why address books were not reached, when a CardDAV server that exists
    /// refused or failed. `None` also for an account that offers no CardDAV.
    pub contacts_unavailable: Option<String>,
}

impl SyncTally {
    /// Whether anything went wrong that the user should see.
    #[must_use]
    pub fn has_problems(&self) -> bool {
        self.failed > 0
            || self.conflicts > 0
            || self.held > 0
            || self.account_error.is_some()
            || self.contacts_unavailable.is_some()
    }

    /// Whether the pass moved nothing and found nothing wrong.
    #[must_use]
    pub fn is_quiet(&self) -> bool {
        self.fetched == 0 && self.deleted == 0 && self.pushed == 0 && !self.has_problems()
    }
}

/// Syncs every enabled account, recording new collection bindings as it goes.
///
/// Two roots because the suite keeps two: `$XDG_DATA_HOME/calendars` and
/// `$XDG_DATA_HOME/contacts`. One account can offer both, and a server offering
/// neither is not an error.
pub fn sync_all(
    store: &mut AccountStore,
    registry: &Registry,
    calendar_root: &Path,
    contacts_root: &Path,
) -> Vec<AccountReport> {
    // Clone the account list up front: `bind_collection` needs `&mut store`
    // while we are iterating, and the alternative is threading indices through
    // the whole call chain for no benefit.
    let accounts: Vec<Account> = store.enabled().cloned().collect();

    accounts
        .iter()
        .map(|account| sync_one(store, registry, account, calendar_root, contacts_root))
        .collect()
}

fn sync_one(
    store: &mut AccountStore,
    registry: &Registry,
    account: &Account,
    calendar_root: &Path,
    contacts_root: &Path,
) -> AccountReport {
    let report = |collections, contacts_unavailable| AccountReport {
        account_id: account.id.clone(),
        display_name: account.display_name.clone(),
        collections,
        contacts_unavailable,
    };

    // Where the server is. A typed-in account carries its own URL; an account
    // created from a provider has the manifest's, because nobody types
    // `https://apidata.googleusercontent.com/caldav/v2/` from memory.
    let calendar_url = account
        .service_url(registry, DavService::Calendar)
        .unwrap_or_default();
    let contacts_url = account
        .service_url(registry, DavService::Contacts)
        .unwrap_or_default();

    // A mail-only account (IMAP/SMTP, no DAV address, no provider to supply
    // one) has nothing to sync here. Settled before the secret is resolved:
    // resolving can mean asking the keyring to unlock, and that prompt must
    // not appear for an account this pass was never going to contact.
    if calendar_url.trim().is_empty() && contacts_url.trim().is_empty() {
        tracing::debug!(
            account = account.display_name,
            "no DAV address; not a calendar or contacts account"
        );
        return report(Ok(Vec::new()), None);
    }

    // One resolution for the whole pass, whichever way this account signs in.
    // For OAuth that includes renewing an expired token and storing the result
    // back, which is why the store is taken by value here and not by reference:
    // a pass that renewed without persisting would renew again on every cycle,
    // and against a provider that rotates refresh tokens it would invalidate
    // the one on disk the first time.
    let secret = match cosmic_pim_auth::resolve(store, registry, &account.id) {
        Ok(secret) => secret,
        Err(why) => return report(Err(Error::Auth(why)), None),
    };
    let auth = dav_auth(account, &secret);

    let mut client = CaldavClient::with_auth(&calendar_url, Flavor::CalDav, &auth);

    let calendars = match provision_account(&mut client, account, calendar_root) {
        Ok(provisioned) => provisioned,
        Err(why) => return report(Err(why), None),
    };

    // Address books are discovered with a second client because CardDAV has
    // its own principal and home-set. Failing to find any is not an account
    // failure: plenty of servers offer calendars and no contacts at all, and
    // treating that as a broken account would light up every calendar-only
    // setup with a permanent error.
    let mut carddav = CaldavClient::with_auth(&contacts_url, Flavor::CardDav, &auth);
    let (address_books, contacts_unavailable) =
        match provision_account(&mut carddav, account, contacts_root) {
            Ok(provisioned) => (provisioned, None),
            Err(why) => {
                tracing::info!(
                    account = account.display_name, %why,
                    "no address books reached for this account; syncing calendars only"
                );
                (Vec::new(), Some(why.to_string()))
            }
        };

    // Persist bindings before syncing. If sync then fails, the next run still
    // recognises these collections instead of creating duplicates beside them.
    for entry in calendars.iter().chain(&address_books).filter(|p| p.created) {
        if let Err(why) = store.bind_collection(&account.id, &entry.href, &entry.collection_id) {
            tracing::warn!(
                account = account.display_name, href = entry.href, %why,
                "could not persist a collection binding; the next sync may duplicate it"
            );
        }
    }

    let reports = calendars
        .iter()
        .map(|entry| sync_provisioned(&client, entry, calendar_root))
        .chain(
            address_books
                .iter()
                .map(|entry| sync_provisioned(&carddav, entry, contacts_root)),
        )
        .collect();

    report(Ok(reports), contacts_unavailable)
}

fn sync_provisioned(client: &CaldavClient, entry: &Provisioned, root: &Path) -> CollectionReport {
    let mut pushed = DrainOutcome::default();

    let outcome = (|| {
        // Nothing about a contested collection improves by trying: the marker
        // is in the directory, the other engine is running on its own
        // schedule, and one round of both pushing is enough to start the
        // oscillation. The error carries the reason to the UI, which is where
        // the decision belongs.
        if let Some(marker) = &entry.contested {
            return Err(Error::ForeignSyncOwner {
                collection: entry.display_name.clone(),
                marker: marker.clone(),
            });
        }

        let meta =
            crate::provision::open_collection(root, &entry.collection_id).ok_or_else(|| {
                Error::CalDav(cosmic_pim_caldav::Error::internal(format!(
                    "collection “{}” vanished between provisioning and sync",
                    entry.collection_id
                )))
            })?;

        let mut store = open_store(entry.flavor, meta)?;

        // Push before pulling. Draining afterwards would mean the pull
        // overwrites a local edit with the server's older copy, and the queued
        // push then re-uploads what the pull just clobbered — a write/read
        // ordering bug that presents as edits mysteriously reverting.
        pushed = drain(client, &mut store, chrono_now_ms());

        Ok(sync_collection(client, &entry.href, &mut store)?)
    })();

    CollectionReport {
        collection_id: entry.collection_id.clone(),
        display_name: entry.display_name.clone(),
        href: entry.href.clone(),
        flavor: entry.flavor,
        pushed,
        outcome,
    }
}

/// The scheme this account's resolved secret is sent with.
pub(crate) fn dav_auth(account: &Account, secret: &Secret) -> Auth {
    match secret {
        Secret::Password(password) => Auth::Basic {
            username: account.username.clone(),
            password: password.clone(),
        },
        Secret::AccessToken(token) => Auth::Bearer(token.clone()),
    }
}

/// Wall-clock milliseconds. Isolated so the drain schedule has one source of
/// truth and tests can reason about it.
fn chrono_now_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
        .unwrap_or(0)
}

/// Syncs one account, for "test this account" in a settings dialog.
pub fn sync_account(
    store: &mut AccountStore,
    registry: &Registry,
    account_id: &str,
    calendar_root: &Path,
    contacts_root: &Path,
) -> Result<AccountReport> {
    let account = store
        .get(account_id)
        .ok_or_else(|| cosmic_pim_accounts::Error::UnknownAccount(account_id.to_owned()))?
        .clone();
    Ok(sync_one(
        store,
        registry,
        &account,
        calendar_root,
        contacts_root,
    ))
}

#[cfg(test)]
mod tests {
    use super::*;
    use cosmic_pim_accounts::{Account, AccountStore, AuthMethod, OAuthCredential, SecretStore};

    /// A store backed by a temp directory and the envelope secret backend, so
    /// a test never touches the developer's real keychain.
    fn accounts_at(dir: &Path) -> AccountStore {
        let secrets = SecretStore::open_envelope_only("cosmic-pim-test", dir);
        AccountStore::open(&dir.join("accounts.toml"), secrets).unwrap()
    }

    #[test]
    fn an_oauth_account_with_no_saved_sign_in_says_so_rather_than_guessing() {
        // Before OAuth was supported this sent the refresh token as a Basic
        // password and reported the 401 as a wrong password. Now the account
        // never reaches the network, and the report says what is actually
        // wrong with it.
        let dir = tempfile::tempdir().unwrap();
        let mut store = accounts_at(dir.path());
        let mut account = Account::new("Google", "", "ada@gmail.com");
        account.auth = AuthMethod::OAuth;
        account.provider = Some("google".into());
        let id = account.id.clone();
        store.add(account, "unused").unwrap();

        let report = sync_account(
            &mut store,
            &Registry::load_from(dir.path()),
            &id,
            dir.path(),
            dir.path(),
        )
        .unwrap();

        let Err(Error::Auth(why)) = report.collections else {
            panic!("an OAuth account without a grant was attempted anyway");
        };
        assert!(why.needs_sign_in(), "expected a sign-in prompt, got {why}");
    }

    #[test]
    fn a_password_account_gets_as_far_as_the_network() {
        // The guard has to be about what is missing, not about accounts in
        // general. `dav.example.com` does not resolve, so this fails at
        // discovery — which is exactly how far it should get.
        let dir = tempfile::tempdir().unwrap();
        let mut store = accounts_at(dir.path());
        let account = Account::new("Work", "https://dav.example.com/", "ada@example.com");
        let id = account.id.clone();
        store.add(account, "s3cret").unwrap();

        let report = sync_account(
            &mut store,
            &Registry::load_from(dir.path()),
            &id,
            dir.path(),
            dir.path(),
        )
        .unwrap();

        assert!(
            !matches!(report.collections, Err(Error::Auth(_))),
            "a password account was stopped by the credential resolver"
        );
    }

    #[test]
    fn a_mail_only_account_is_not_synced_as_dav() {
        // An IMAP/SMTP account has no DAV address and no provider to supply
        // one. It used to go through discovery against the empty string and
        // log "PROPFIND : builder error" on every pass — after resolving its
        // secret first, which can prompt to unlock the keyring for nothing.
        let dir = tempfile::tempdir().unwrap();
        let mut store = accounts_at(dir.path());
        let mut account = Account::new("Mail", "", "ada@example.com");
        account.auth = AuthMethod::OAuth; // no grant stored: resolving would fail
        let id = account.id.clone();
        store.add(account, "unused").unwrap();

        let report = sync_account(
            &mut store,
            &Registry::load_from(dir.path()),
            &id,
            dir.path(),
            dir.path(),
        )
        .unwrap();

        let Ok(collections) = report.collections else {
            panic!("a mail-only account was treated as a failed DAV sync");
        };
        assert!(collections.is_empty());
        assert_eq!(report.contacts_unavailable, None);
    }

    #[test]
    fn a_token_is_sent_as_a_bearer_and_a_password_as_basic() {
        let account = Account::new("Work", "https://dav.example/", "ada");

        assert_eq!(
            dav_auth(&account, &Secret::AccessToken("ya29.token".into())),
            Auth::Bearer("ya29.token".into())
        );
        assert_eq!(
            dav_auth(&account, &Secret::Password("pw".into())),
            Auth::Basic {
                username: "ada".into(),
                password: "pw".into()
            }
        );
    }

    #[test]
    fn a_renewed_token_is_stored_before_the_pass_uses_it() {
        // Not an optimisation: a provider that rotates refresh tokens
        // invalidates the stored one the first time a renewal uses it, so a
        // pass that renewed without persisting would leave an account that can
        // never be renewed again.
        let dir = tempfile::tempdir().unwrap();
        let mut store = accounts_at(dir.path());
        let account = Account::new("Google", "", "ada@gmail.com");
        let id = account.id.clone();
        let grant = OAuthCredential {
            access_token: "still-valid".into(),
            refresh_token: Some("rt".into()),
            expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(1)),
            scopes: Vec::new(),
            token_type: "Bearer".into(),
        };
        store.add_oauth(account, "google", &grant).unwrap();

        // A live token needs no renewal, so the stored grant is untouched and
        // no token endpoint is contacted.
        let secret =
            cosmic_pim_auth::resolve(&mut store, &Registry::load_from(dir.path()), &id).unwrap();

        assert_eq!(secret, Secret::AccessToken("still-valid".into()));
        assert_eq!(
            store.credential(&id).unwrap().unwrap().access_token,
            "still-valid"
        );
    }

    #[test]
    fn a_contacts_server_that_refused_us_is_not_up_to_date() {
        let report = AccountReport {
            account_id: "a".into(),
            display_name: "Work".into(),
            collections: Ok(Vec::new()),
            contacts_unavailable: Some("HTTP 401".into()),
        };
        let tally = report.tally();
        assert_eq!(tally.contacts_unavailable.as_deref(), Some("HTTP 401"));
        assert!(tally.has_problems());
        assert!(
            !tally.is_quiet(),
            "a refused address book read as up to date"
        );
    }
}