use std::path::Path;
use cosmic_pim_accounts::{Account, AccountStore, 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};
#[derive(Debug)]
pub struct CollectionReport {
pub collection_id: String,
pub display_name: String,
pub href: String,
pub flavor: Flavor,
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
}
#[must_use]
pub fn has_unpushed_changes(&self) -> bool {
self.pushed.deferred > 0 || self.pushed.skipped > 0
}
#[must_use]
pub fn conflicts(&self) -> usize {
match &self.outcome {
Ok(outcome) => outcome.conflicts,
Err(_) => 0,
}
}
#[must_use]
pub fn needs_attention(&self) -> bool {
self.pushed.needs_attention() || self.conflicts() > 0
}
}
#[derive(Debug)]
pub struct AccountReport {
pub account_id: String,
pub display_name: String,
pub collections: Result<Vec<CollectionReport>>,
pub contacts_unavailable: Option<String>,
}
impl AccountReport {
#[must_use]
pub fn changed(&self) -> bool {
matches!(&self.collections, Ok(reports) if reports.iter().any(CollectionReport::changed))
}
#[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
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SyncTally {
pub fetched: usize,
pub deleted: usize,
pub pushed: usize,
pub failed: usize,
pub conflicts: usize,
pub held: usize,
pub account_error: Option<String>,
pub contacts_unavailable: Option<String>,
}
impl SyncTally {
#[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()
}
#[must_use]
pub fn is_quiet(&self) -> bool {
self.fetched == 0 && self.deleted == 0 && self.pushed == 0 && !self.has_problems()
}
}
pub fn sync_all(
store: &mut AccountStore,
registry: &Registry,
calendar_root: &Path,
contacts_root: &Path,
) -> Vec<AccountReport> {
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,
};
let calendar_url = service_url(account, registry, Service::Calendar);
let contacts_url = service_url(account, registry, Service::Contacts);
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);
}
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),
};
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()))
}
};
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 = (|| {
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)?;
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,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Service {
Calendar,
Contacts,
}
pub(crate) fn service_url(account: &Account, registry: &Registry, service: Service) -> String {
if !account.url.trim().is_empty() {
return account.url.clone();
}
account
.provider
.as_deref()
.and_then(|id| registry.get(id))
.and_then(|provider| match service {
Service::Calendar => provider.calendar_url(&account.username),
Service::Contacts => provider.contacts_url(&account.username),
})
.unwrap_or_default()
}
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()),
}
}
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)
}
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};
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() {
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() {
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() {
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; 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 an_account_that_typed_its_own_url_keeps_it() {
let registry = Registry::load_from(Path::new("/nonexistent"));
let mut account = Account::new("Nextcloud", "https://cloud.example/dav/", "ada");
account.provider = Some("google".into());
assert_eq!(
service_url(&account, ®istry, Service::Calendar),
"https://cloud.example/dav/"
);
}
#[test]
fn an_account_created_from_a_provider_takes_the_manifests_address() {
let registry = Registry::load_from(Path::new("/nonexistent"));
let mut account = Account::new("Google", "", "ada@gmail.com");
account.provider = Some("google".into());
assert_eq!(
service_url(&account, ®istry, Service::Calendar),
"https://apidata.googleusercontent.com/caldav/v2/"
);
assert!(
service_url(&account, ®istry, Service::Contacts).contains("ada@gmail.com"),
"the username was not substituted into the CardDAV address"
);
}
#[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() {
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();
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"
);
}
}