From 623505b9c8a6aa02475a09b987bbdebd23d0eea1 Mon Sep 17 00:00:00 2001 From: randogoth Date: Thu, 10 Sep 2026 13:49:38 +0300 Subject: [PATCH] Close the remaining M1 gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight items were still open at the end of M1: two integration tests that had only been run by hand, and six known gaps. Recurrence overrides now reach Google. Google addresses an exception through the series rather than as an event of its own, so the master is sent first and each override is then matched to its instance by original start time and patched. Matching needs the two sides' spellings reduced to one key: iCalendar writes a zoned local time, Google an absolute offset. An override matching no occurrence is counted rather than forced — that means a stale RECURRENCE-ID left behind by an edited RRULE, and inventing an event for it would put something in the calendar the series does not contain. Reading one component apart from another needed a view `properties` cannot give: it flattens every VEVENT together, which is right for the UID a series shares and wrong for an override, whose SUMMARY and the master's are then indistinguishable. `Calendar::events` splits them. A TZID now travels with the VTIMEZONE that defines it, derived from the zone's own transition table as the yearly rule it implies. This changes the content hash of every zoned recurring event, so the first cycle after this re-pushes them. A Google authorisation is filed under the account it was granted for rather than the endpoint that asked for it, so two endpoints on one account no longer need a login each. The account is read from the primary calendar's id, which needs no scope beyond the calendar one already granted. Authorisations written by the previous scheme are still honoured, and move across at the next login. An unreachable Google endpoint no longer ends the cycle — one lapsed token used to stop the CalDAV side too. It is named, only the aggregates depending on it stand down, and the run exits non-zero so a partial cycle cannot pass for success. The CalDAV leg cannot be narrowed the same way: pimsync is one process covering every pair, so a failure does not say which pair it belongs to. `--dry-run` now pulls for real, into a throwaway copy of the local mirrors and through a pimsync configuration that only ever reads from a server. What it reports is measured against the calendars as they are now rather than against whatever the last real cycle left behind. `calcalist prune` reports local mirrors of endpoints the configuration no longer names, and removes them under --force. The integration tests run against a real Radicale server and a real iCal feed: convergence and idempotence, the dry run, prune, and the scheduling rule asserted on the bytes that actually reached the server. The plan asked for an SMTP sink for that last one; Radicale implements no RFC 6638 scheduling, so a quiet SMTP port would have proved nothing about the transform. Co-Authored-By: Claude Opus 5 --- SPECS.md | 4 + TODO.md | 66 +++--- src/cli.rs | 6 + src/config.rs | 15 ++ src/doctor.rs | 7 +- src/google/api.rs | 117 ++++++++++- src/google/auth.rs | 273 ++++++++++++++++++++++++- src/google/convert.rs | 456 ++++++++++++++++++++++++++++++++++++++++-- src/ical.rs | 98 ++++++++- src/main.rs | 79 +++++++- src/pimsync.rs | 46 ++++- src/sync.rs | 401 ++++++++++++++++++++++++++++++------- tests/caldav.rs | 274 +++++++++++++++++++++++++ tests/support/mod.rs | 310 ++++++++++++++++++++++++++++ 14 files changed, 2008 insertions(+), 144 deletions(-) create mode 100644 tests/caldav.rs create mode 100644 tests/support/mod.rs diff --git a/SPECS.md b/SPECS.md index 7300e38..710f76a 100644 --- a/SPECS.md +++ b/SPECS.md @@ -14,6 +14,7 @@ Every endpoint — sources and aggregate targets alike — is mirrored to a loca - The Google leg is calcalist's own, via the Calendar REST API. pimsync cannot reach Google — it has no REST storage, and its config exposes only HTTP Basic auth, which Google's CalDAV endpoint has rejected since 2025-03-14. - Sync state (event mappings, hashes, sync tokens) lives in a JSON sidecar under `$XDG_STATE_HOME/calcalist/`, never in the portable config. - Secrets are never stored in the config either. Credentials come from `*_command` fields that are executed to fetch them. +- A Google authorisation is filed under the account it was granted for, not the endpoint that asked for it, so every endpoint on that account shares it. An endpoint names its `account` only when calcalist is logged in to more than one. ## Sync semantics @@ -27,6 +28,9 @@ Every endpoint — sources and aggregate targets alike — is mirrored to a loca | Attendees (routing) | Preserved — the sink server sends real invitations, which is intended | | Alarms | Always preserved, with no option to strip them | | Retargeting | `sync` refuses on target drift; `aggregate retarget` performs it deliberately | +| Recurrence | A series and its exceptions share one file. Google addresses an exception through the series, so the master is sent first and each override is matched to its instance by original start time and patched | +| Unreachable endpoints | Named in the report; only the aggregates depending on them stand down, and the run exits non-zero | +| Dry runs | Pull for real, into a throwaway copy of the local mirrors and through a read-only pimsync configuration. Nothing outside the copy is written | The governing rule behind the attendee handling: **writes to an aggregate must never emit scheduling mail; writes to a source schedule normally.** Google can be told not to notify, so attendees survive intact there. CalDAV offers no portable way to suppress RFC 6638 scheduling, so inertness is achieved structurally by dropping the live properties instead. diff --git a/TODO.md b/TODO.md index 266842f..34b4537 100644 --- a/TODO.md +++ b/TODO.md @@ -14,8 +14,7 @@ sync semantics these items implement. ## M1 — bidirectional sync -Functionally complete. Everything below is done except the two automated -integration tests, which are listed with the remaining gaps at the end. +Complete. Every item from the plan is built, tested and verified live. ### Core modules @@ -61,6 +60,26 @@ integration tests, which are listed with the remaining gaps at the end. - [x] `calcalist status` — endpoints, aggregates, how many events are mirrored, and the exact `@marker` names an event can carry, so routing is discoverable without opening the config file +- [x] Recurrence overrides are pushed to Google. Google addresses an exception + through the series rather than as an event of its own, so the master goes up + first and each override is then matched to its instance by original start + time and patched. An override matching no occurrence is counted, not forced. +- [x] A `TZID` now travels with the `VTIMEZONE` that defines it, derived from the + zone's own transition table as the yearly rule it implies. A zone that does + not shift gets one fixed observance. +- [x] One Google authorisation covers every endpoint on that account. Tokens are + keyed by the account, discovered at login from the primary calendar's id — + which needs no scope beyond the calendar one already granted. An endpoint + names its `account` only when calcalist is logged in to more than one. +- [x] A Google endpoint that cannot be reached no longer ends the cycle. It is + named, only the aggregates depending on it stand down, and the run exits + non-zero so a lapsed token cannot pass for success. +- [x] `calcalist prune` — reports local mirrors of endpoints the configuration no + longer names, and removes them under `--force` +- [x] `--dry-run` pulls for real, into a throwaway copy of the local mirrors and + through a pimsync configuration that only ever reads from a server. What it + reports is measured against the calendars as they are now, and nothing + outside the copy is written. ### Tests @@ -93,33 +112,30 @@ calendar: - The mass-deletion guard refusing a 100% removal until `--force` - `pimsync check` validating the generated config against a live CalDAV server -## Remaining +### Integration -### Tests not yet automated +- [x] Against a real Radicale server and a real iCal feed, over pimsync: two + sources converge on a CalDAV target, and the next cycle is a no-op. +- [x] Safety, against the same server: no live `ATTENDEE` or `ORGANIZER` reaches + the aggregate, the guest list survives as inert data, the alarm survives + intact, and the source keeps its scheduling properties. -- [ ] Integration against Radicale plus a WebCal fixture, asserting convergence and - idempotence. Done by hand twice; not yet a test that runs in CI. -- [ ] Safety integration: a real CalDAV server with an SMTP sink, proving no mail is - emitted on mirror writes or mirror deletions. + The plan asked for an SMTP sink here. Radicale implements no RFC 6638 + scheduling, so a quiet SMTP port would have proved nothing about the + transform — no server in reach of a test sends calendar mail at all. What + is asserted instead is the bytes that reached the server, which is the + thing the transform is actually responsible for. Proving the Google half + was settled separately, from the API discovery document. +- [x] `--dry-run` reaches the servers, reports what it found, and leaves both the + target and the local mirrors untouched. +- [x] A retired endpoint's mirror is reported by `prune` and removed under + `--force`. -### Known gaps +## Known gaps -- [ ] A recurring series' *exceptions* are not pushed to Google. Pulling them works. - Google models them as separate events against an already existing series, so - pushing needs `events.instances` plus a patch per exception. Reported per sync - rather than dropped silently. -- [ ] Each Google endpoint needs its own `google login`, even for the same account and - OAuth client, because tokens are keyed by endpoint id. Two endpoints on one - account should share a credential. -- [ ] A failed Google pull aborts the whole cycle, including the CalDAV side. Safe — - reconciling against a stale snapshot could read as mass deletion — but a lapsed - token stops everything. Skipping only the affected aggregates would be better. -- [ ] Removing an endpoint from the config leaves its vdir behind, holding events - nothing manages any more. -- [ ] `--dry-run` skips the pull entirely, so it reports against whatever the last - real cycle left behind rather than against current remote state. -- [ ] A `TZID` is emitted without an accompanying `VTIMEZONE`. Tolerated by the servers - tested so far, and confined to recurring events, but not strictly conformant. +- [ ] A `pimsync sync` that fails takes the whole CalDAV leg with it. Unlike the + Google side this cannot be narrowed: pimsync is one process covering every + pair, so a failure does not say which pair it belongs to. ## M2 — interface and packaging diff --git a/src/cli.rs b/src/cli.rs index faffd2c..0dbcd93 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -47,6 +47,12 @@ pub enum Command { #[command(subcommand)] command: AggregateCommand, }, + /// Remove local mirrors of endpoints the configuration no longer names + Prune { + /// Delete them; without this the command only reports what it found + #[arg(long)] + force: bool, + }, /// Check that the environment and configuration are usable Doctor, } diff --git a/src/config.rs b/src/config.rs index 779f039..4e606d4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,6 +43,13 @@ pub enum EndpointKind { calendar_id: String, client_id: String, client_secret_command: Option, + /// Which authorised Google account this calendar belongs to. + /// + /// One authorisation covers every calendar an account can see, so + /// several endpoints on one account share it and this can be left out. + /// It is only needed to say which account is meant when calcalist has + /// been logged in to more than one. + account: Option, }, Webcal { /// The feed URL, when it is not sensitive. @@ -68,6 +75,14 @@ impl EndpointKind { } } + /// Which authorised Google account this endpoint names, if it says. + pub fn google_account(&self) -> Option<&str> { + match self { + Self::Google { account, .. } => account.as_deref(), + _ => None, + } + } + pub fn scheduling_suppression(&self) -> SchedulingSuppression { match self { Self::Google { .. } => SchedulingSuppression::Native, diff --git a/src/doctor.rs b/src/doctor.rs index 228c4ea..348e0be 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -143,7 +143,12 @@ fn check_google_authorisation(config: &Config) -> Vec { let outcome = match auth::credentials_for(config, &endpoint.id) { Err(error) => Outcome::Fail(error.to_string()), Ok(credentials) => { - match auth::access_token(&state_dir, &endpoint.id, &credentials) { + match auth::access_token( + &state_dir, + &endpoint.id, + endpoint.kind.google_account(), + &credentials, + ) { Ok(_) => Outcome::Ok(format!("`{}` is authorised", endpoint.id)), Err(error @ (AuthError::NotAuthorised(_) | AuthError::Expired { .. })) => { Outcome::Warn(error.to_string()) diff --git a/src/google/api.rs b/src/google/api.rs index 29bfa54..03fd12e 100644 --- a/src/google/api.rs +++ b/src/google/api.rs @@ -161,6 +161,44 @@ impl Client { Self::event_id(&body) } + /// The occurrences of a series inside a window. + /// + /// Exceptions are addressed through the series rather than created + /// directly, so applying one means finding the instance it replaces. + pub fn instances( + &self, + master_id: &str, + time_min: &str, + time_max: &str, + ) -> Result, ApiError> { + let path = format!("events/{}/instances", percent_encode(master_id)); + let query = [ + ("maxResults".to_string(), PAGE_SIZE.to_string()), + ("timeMin".to_string(), time_min.to_string()), + ("timeMax".to_string(), time_max.to_string()), + ("showDeleted".to_string(), "false".to_string()), + ]; + let body = self.get(&path, &query)?; + let page: serde_json::Value = + serde_json::from_str(&body).map_err(|error| ApiError::Malformed(error.to_string()))?; + let items = page + .get("items") + .cloned() + .unwrap_or(serde_json::Value::Array(Vec::new())); + serde_json::from_value(items).map_err(|error| ApiError::Malformed(error.to_string())) + } + + /// Merges fields into an existing event, leaving the rest alone. + /// + /// An instance carries properties calcalist does not model — its + /// `recurringEventId` and `originalStartTime` above all, which say which + /// occurrence it replaces — so an override is patched rather than replaced. + pub fn patch(&self, id: &str, event: &serde_json::Value) -> Result<(), ApiError> { + let path = format!("events/{}", percent_encode(id)); + self.patch_json(&path, &SUPPRESS_NOTIFICATION, event)?; + Ok(()) + } + pub fn delete(&self, id: &str) -> Result<(), ApiError> { let path = format!("events/{}", percent_encode(id)); match self.delete_path(&path, &SUPPRESS_NOTIFICATION) { @@ -223,6 +261,26 @@ impl Client { ) } + fn patch_json( + &self, + path: &str, + query: &[(&str, &str)], + body: &serde_json::Value, + ) -> Result { + let mut request = self + .agent + .patch(&self.url(path)) + .header("Authorization", format!("Bearer {}", self.access_token)); + for (key, value) in query { + request = request.query(*key, *value); + } + Self::finish( + request + .send_json(body) + .map_err(|error| ApiError::Request(error.to_string()))?, + ) + } + fn delete_path(&self, path: &str, query: &[(&str, &str)]) -> Result { let mut request = self .agent @@ -358,7 +416,9 @@ pub struct PushReport { pub created: usize, pub updated: usize, pub deleted: usize, - /// Series whose exceptions could not be applied; see `push`. + /// Recurrence overrides applied to their instances. + pub exceptions_applied: usize, + /// Overrides whose instance Google did not offer; see `apply_exceptions`. pub exceptions_skipped: usize, } @@ -388,16 +448,11 @@ pub fn push( } }; // A series' exceptions are separate events against an existing series, - // so they cannot ride along with the master. Reported rather than - // silently dropped. - if item - .calendar - .properties("VEVENT", "RECURRENCE-ID") - .next() - .is_some() - { - report.exceptions_skipped += 1; - } + // so they cannot ride along with the master and are applied once it is + // in place. + let applied = apply_exceptions(client, &id, &item.calendar)?; + report.exceptions_applied += applied.applied; + report.exceptions_skipped += applied.skipped; state.events.insert( uid.clone(), GoogleItem { @@ -423,6 +478,46 @@ pub fn push( Ok(report) } +#[derive(Debug, Default)] +struct ExceptionReport { + applied: usize, + skipped: usize, +} + +/// Applies a stored series' recurrence overrides to the instances they replace. +/// +/// Google has no way to send a series and its exceptions in one request: an +/// exception is a separate event, addressed through the series and identified by +/// the occurrence it stands in for. So the master goes up first, then each +/// override is matched to an instance by original start time and patched. +/// +/// An override with no matching instance is counted rather than forced. That +/// happens when the occurrence falls outside the rule — a stale `RECURRENCE-ID` +/// left behind by an edited `RRULE` — and inventing an event for it would put +/// something in the calendar that the series does not contain. +fn apply_exceptions( + client: &Client, + master_id: &str, + calendar: &Calendar, +) -> Result { + let mut report = ExceptionReport::default(); + for exception in convert::exceptions(calendar) { + let Some(key) = convert::recurrence_key(&exception) else { + report.skipped += 1; + continue; + }; + let (time_min, time_max) = key.window(); + let instances = client.instances(master_id, &time_min, &time_max)?; + let Some(instance) = convert::find_instance(&instances, &key) else { + report.skipped += 1; + continue; + }; + client.patch(&instance.id, &convert::to_exception(&exception)?)?; + report.applied += 1; + } + Ok(report) +} + /// Percent-encodes a path segment; calendar ids contain `@` and event ids may /// contain characters that would otherwise change the URL's meaning. fn percent_encode(value: &str) -> String { diff --git a/src/google/auth.rs b/src/google/auth.rs index ac5045f..b7c92b4 100644 --- a/src/google/auth.rs +++ b/src/google/auth.rs @@ -74,6 +74,21 @@ pub enum AuthError { NoRefreshToken, #[error("endpoint `{0}` has not been authorised; run `calcalist google login {0}`")] NotAuthorised(String), + #[error( + "endpoint `{endpoint}` names account `{account}`, which has not been authorised; \ + run `calcalist google login {endpoint}`" + )] + UnknownAccount { endpoint: String, account: String }, + #[error( + "endpoint `{endpoint}` does not say which Google account it belongs to, and calcalist \ + is logged in to several ({accounts}). Add `account = \"…\"` to the endpoint." + )] + AmbiguousAccount { endpoint: String, accounts: String }, + #[error( + "the authorisation stored for account `{account}` was issued to a different OAuth \ + client than endpoint `{endpoint}` configures; run `calcalist google login {endpoint}`" + )] + ClientMismatch { endpoint: String, account: String }, #[error("could not access {path}: {source}")] Io { path: PathBuf, @@ -98,6 +113,15 @@ pub struct StoredToken { /// Unix seconds at which `access_token` stops being usable. #[serde(default)] pub expires_at: u64, + /// The account this authorisation belongs to. Absent in files written + /// before authorisations were shared between endpoints. + #[serde(default)] + pub account: Option, + /// The OAuth client the refresh token was issued to. A refresh token is + /// only valid for the client that obtained it, so a changed `client_id` + /// has to be caught here rather than as an opaque `invalid_grant`. + #[serde(default)] + pub client_id: Option, } impl StoredToken { @@ -109,11 +133,93 @@ impl StoredToken { } } -/// Where an endpoint's refresh token lives. -pub fn token_path(state_dir: &Path, endpoint_id: &str) -> PathBuf { +/// Where an authorisation lives, keyed by the account it was granted for. +/// +/// An authorisation covers every calendar the account can see, so two endpoints +/// on one account share this file and only one of them ever has to log in. +pub fn account_token_path(state_dir: &Path, account: &str) -> PathBuf { + state_dir + .join("google") + .join("accounts") + .join(format!("{}.json", sanitise(account))) +} + +/// Where an endpoint's refresh token used to live, before authorisations were +/// keyed by account. Still read, so an existing installation keeps working +/// until its next login moves it across. +pub fn legacy_token_path(state_dir: &Path, endpoint_id: &str) -> PathBuf { state_dir.join("google").join(format!("{endpoint_id}.json")) } +/// Keeps an account name to one harmless path segment. +fn sanitise(account: &str) -> String { + account + .chars() + .map(|ch| match ch { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '@' => ch, + _ => '_', + }) + .collect() +} + +/// Every account calcalist currently holds an authorisation for. +pub fn authorised_accounts(state_dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(state_dir.join("google").join("accounts")) else { + return Vec::new(); + }; + let mut accounts: Vec = entries + .flatten() + .filter_map(|entry| load_token(&entry.path()).ok().flatten()) + .filter_map(|token| token.account) + .collect(); + accounts.sort(); + accounts.dedup(); + accounts +} + +/// Picks the stored authorisation an endpoint should use. +/// +/// The endpoint may name its account outright. Failing that, an authorisation +/// left over from the per-endpoint scheme is honoured, and then a single +/// account is taken to be the one meant — which is the ordinary case, and what +/// makes a second endpoint on the same account need no login of its own. +fn resolve_token( + state_dir: &Path, + endpoint_id: &str, + account: Option<&str>, +) -> Result<(PathBuf, StoredToken), AuthError> { + if let Some(account) = account { + let path = account_token_path(state_dir, account); + let stored = load_token(&path)?.ok_or_else(|| AuthError::UnknownAccount { + endpoint: endpoint_id.to_string(), + account: account.to_string(), + })?; + return Ok((path, stored)); + } + + let legacy = legacy_token_path(state_dir, endpoint_id); + if let Some(stored) = load_token(&legacy)? { + return Ok((legacy, stored)); + } + + let accounts = authorised_accounts(state_dir); + match accounts.as_slice() { + [] => Err(AuthError::NotAuthorised(endpoint_id.to_string())), + [only] => { + let path = account_token_path(state_dir, only); + let stored = load_token(&path)?.ok_or_else(|| AuthError::UnknownAccount { + endpoint: endpoint_id.to_string(), + account: only.clone(), + })?; + Ok((path, stored)) + } + several => Err(AuthError::AmbiguousAccount { + endpoint: endpoint_id.to_string(), + accounts: several.join(", "), + }), + } +} + /// Runs an external command and returns its first line of output. /// /// Secrets are fetched this way rather than stored, so the configuration file @@ -181,11 +287,18 @@ pub fn credentials_for( pub fn access_token( state_dir: &Path, endpoint_id: &str, + account: Option<&str>, credentials: &Credentials, ) -> Result { - let path = token_path(state_dir, endpoint_id); - let mut stored = - load_token(&path)?.ok_or_else(|| AuthError::NotAuthorised(endpoint_id.to_string()))?; + let (path, mut stored) = resolve_token(state_dir, endpoint_id, account)?; + if let Some(issued_to) = &stored.client_id + && *issued_to != credentials.client_id + { + return Err(AuthError::ClientMismatch { + endpoint: endpoint_id.to_string(), + account: stored.account.clone().unwrap_or_else(|| "?".to_string()), + }); + } if let Some(token) = stored.usable_access_token() { return Ok(token.to_string()); @@ -248,19 +361,66 @@ pub fn login( ])?; let refresh_token = issued.refresh_token.ok_or(AuthError::NoRefreshToken)?; - let path = token_path(state_dir, endpoint_id); + // Which account consented is not in the token response, but the primary + // calendar's id is the account's own address — and reading it needs nothing + // beyond the calendar scope already granted, unlike the userinfo endpoint. + let account = primary_calendar_id(&issued.access_token); + let path = match &account { + Some(account) => account_token_path(state_dir, account), + None => legacy_token_path(state_dir, endpoint_id), + }; save_token( &path, &StoredToken { refresh_token, access_token: Some(issued.access_token), expires_at: unix_now() + issued.expires_in, + account: account.clone(), + client_id: Some(credentials.client_id.clone()), }, )?; - println!("Authorised. Refresh token stored at {}", path.display()); + // The authorisation now lives under the account, so the copy this endpoint + // used to keep would only shadow it. + let legacy = legacy_token_path(state_dir, endpoint_id); + if account.is_some() && legacy.exists() { + let _ = std::fs::remove_file(&legacy); + } + match &account { + Some(account) => println!( + "Authorised {account}. Every endpoint on this account can use it; \ + the refresh token is stored at {}", + path.display() + ), + None => println!("Authorised. Refresh token stored at {}", path.display()), + } Ok(()) } +/// The address of the authorised account, read from its primary calendar. +/// +/// Returned as an option rather than an error: failing to name the account only +/// costs the sharing, and the authorisation itself is perfectly good without it. +fn primary_calendar_id(access_token: &str) -> Option { + let agent: ureq::Agent = ureq::Agent::config_builder() + .http_status_as_error(false) + .build() + .into(); + let mut response = agent + .get("https://www.googleapis.com/calendar/v3/calendars/primary") + .header("Authorization", format!("Bearer {access_token}")) + .call() + .ok()?; + if !(200..300).contains(&response.status().as_u16()) { + return None; + } + let body = response.body_mut().read_to_string().ok()?; + let value: serde_json::Value = serde_json::from_str(&body).ok()?; + value + .get("id") + .and_then(|id| id.as_str()) + .map(str::to_string) +} + /// Accepts one loopback request and extracts the authorisation code from it. fn wait_for_code(listener: &TcpListener, expected_state: &str) -> Result { let (mut stream, _) = listener.accept().map_err(AuthError::Listen)?; @@ -586,6 +746,8 @@ mod tests { refresh_token: "r".into(), access_token: Some("a".into()), expires_at: unix_now() + 3600, + account: None, + client_id: None, }; assert_eq!(fresh.usable_access_token(), Some("a")); @@ -602,4 +764,101 @@ mod tests { }; assert_eq!(nearly.usable_access_token(), None); } + + fn stored(account: &str, client_id: &str) -> StoredToken { + StoredToken { + refresh_token: format!("refresh-for-{account}"), + access_token: None, + expires_at: 0, + account: Some(account.to_string()), + client_id: Some(client_id.to_string()), + } + } + + /// The gap this closes: two endpoints on one account needed a login each, + /// because the authorisation was filed under the endpoint rather than the + /// account it actually belongs to. + #[test] + fn one_authorisation_serves_every_endpoint_on_that_account() { + let dir = tempfile::tempdir().expect("temp"); + save_token( + &account_token_path(dir.path(), "me@example.com"), + &stored("me@example.com", "client-a"), + ) + .expect("save"); + + for endpoint in ["work", "personal", "never-logged-in"] { + let (_, token) = resolve_token(dir.path(), endpoint, None).expect("resolve"); + assert_eq!(token.refresh_token, "refresh-for-me@example.com"); + } + } + + /// Two accounts cannot be guessed between, so the endpoint has to say. + #[test] + fn several_accounts_have_to_be_told_apart() { + let dir = tempfile::tempdir().expect("temp"); + for account in ["one@example.com", "two@example.com"] { + save_token( + &account_token_path(dir.path(), account), + &stored(account, "client-a"), + ) + .expect("save"); + } + + let error = resolve_token(dir.path(), "work", None).expect_err("ambiguous"); + assert!( + matches!(error, AuthError::AmbiguousAccount { .. }), + "{error:?}" + ); + assert!(error.to_string().contains("one@example.com"), "{error}"); + + let (_, token) = + resolve_token(dir.path(), "work", Some("two@example.com")).expect("resolve"); + assert_eq!(token.refresh_token, "refresh-for-two@example.com"); + } + + /// An authorisation written by an earlier version keeps working untouched. + #[test] + fn an_authorisation_from_the_old_layout_is_still_honoured() { + let dir = tempfile::tempdir().expect("temp"); + save_token( + &legacy_token_path(dir.path(), "work"), + &StoredToken { + refresh_token: "old".into(), + access_token: None, + expires_at: 0, + account: None, + client_id: None, + }, + ) + .expect("save"); + + let (_, token) = resolve_token(dir.path(), "work", None).expect("resolve"); + assert_eq!(token.refresh_token, "old"); + // A different endpoint has nothing to fall back on. + let error = resolve_token(dir.path(), "other", None).expect_err("not authorised"); + assert!(matches!(error, AuthError::NotAuthorised(_)), "{error:?}"); + } + + /// A refresh token only works for the client it was issued to, so a changed + /// client_id has to say so rather than surface as an opaque invalid_grant. + #[test] + fn a_token_from_another_oauth_client_is_refused_by_name() { + let dir = tempfile::tempdir().expect("temp"); + save_token( + &account_token_path(dir.path(), "me@example.com"), + &stored("me@example.com", "client-a"), + ) + .expect("save"); + + let credentials = Credentials { + client_id: "client-b".into(), + client_secret: "secret".into(), + }; + let error = access_token(dir.path(), "work", None, &credentials).expect_err("mismatch"); + assert!( + matches!(error, AuthError::ClientMismatch { .. }), + "{error:?}" + ); + } } diff --git a/src/google/convert.rs b/src/google/convert.rs index e69ec99..4543f32 100644 --- a/src/google/convert.rs +++ b/src/google/convert.rs @@ -14,9 +14,9 @@ //! * An exception that was deleted comes back as an override with //! `status: cancelled`, which is an `EXDATE` on the master rather than an event. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; -use jiff::{Timestamp, tz::TimeZone}; +use jiff::{SignedDuration, Timestamp, tz::Dst, tz::TimeZone}; use serde::Deserialize; use thiserror::Error; @@ -157,34 +157,186 @@ pub fn to_ical(uid: &str, group: &Group) -> Result { }); }; - let mut out = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calcalist//EN\r\n"); - out.push_str(&render_event(uid, master, None)?); + let mut zones = BTreeSet::new(); + let mut body = render_event(uid, master, None, &mut zones)?; // A deleted occurrence is an absence, not an event: it belongs on the master // as an EXDATE. for cancelled in group.overrides.iter().filter(|event| event.is_cancelled()) { if let Some(start) = &cancelled.original_start_time { - let line = time_property("EXDATE", start, &cancelled.id, master.is_recurring())?; + let line = time_property( + "EXDATE", + start, + &cancelled.id, + master.is_recurring(), + &mut zones, + )?; // Insert before the master's END:VEVENT. - let end = out.rfind("END:VEVENT\r\n").unwrap_or(out.len()); - out.insert_str(end, &format!("{line}\r\n")); + let end = body.rfind("END:VEVENT\r\n").unwrap_or(body.len()); + body.insert_str(end, &format!("{line}\r\n")); } } for moved in group.overrides.iter().filter(|event| !event.is_cancelled()) { - out.push_str(&render_event( + body.push_str(&render_event( uid, moved, moved.original_start_time.as_ref(), + &mut zones, )?); } + + let mut out = String::from("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calcalist//EN\r\n"); + // A TZID names a zone the reader is expected to resolve, but RFC 5545 wants + // the definition to travel with the calendar: a client with no zone database + // of its own has nothing else to expand the recurrence against. + let reference = master + .start + .as_ref() + .and_then(instant_of) + .unwrap_or_else(Timestamp::now); + for zone in &zones { + if let Some(block) = vtimezone(zone, reference) { + out.push_str(&block); + } + } + out.push_str(&body); out.push_str("END:VCALENDAR\r\n"); Ok(out) } +/// The instant an event time names, whether it is dated or timed. +fn instant_of(time: &EventTime) -> Option { + match (&time.date, &time.date_time) { + (_, Some(value)) => value.parse().ok(), + (Some(date), None) => midnight_utc(date), + (None, None) => None, + } +} + +/// An RFC 5545 `VTIMEZONE` for `name`, describing the rules around `reference`. +/// +/// Two observances describe every zone in current use — one standard, one +/// daylight — each recurring yearly on the same weekday of the same month. A +/// zone that does not shift gets a single fixed observance instead, which is +/// also the fallback when the transition table says nothing about this period. +fn vtimezone(name: &str, reference: Timestamp) -> Option { + let zone = TimeZone::get(name).ok()?; + // Start a little over a year back, so a full cycle of transitions is in + // view however close the event sits to one of them. + let from = reference + .checked_sub(SignedDuration::from_hours(24 * 400)) + .ok()?; + let upcoming: Vec<_> = zone.following(from).take(4).collect(); + + let mut out = format!("BEGIN:VTIMEZONE\r\nTZID:{name}\r\n"); + let daylight = upcoming + .iter() + .find(|transition| transition.dst() == Dst::Yes); + let standard = upcoming + .iter() + .find(|transition| transition.dst() == Dst::No); + + match (daylight, standard) { + (Some(daylight), Some(standard)) => { + out.push_str(&observance(&zone, daylight, "DAYLIGHT")?); + out.push_str(&observance(&zone, standard, "STANDARD")?); + } + _ => { + let offset = zone.to_offset(reference); + let text = format_offset(offset); + out.push_str(&format!( + "BEGIN:STANDARD\r\nDTSTART:19700101T000000\r\nTZOFFSETFROM:{text}\r\n\ + TZOFFSETTO:{text}\r\nEND:STANDARD\r\n" + )); + } + } + out.push_str("END:VTIMEZONE\r\n"); + Some(out) +} + +/// One `STANDARD` or `DAYLIGHT` block, as the yearly rule that produced it. +fn observance( + zone: &TimeZone, + transition: &jiff::tz::TimeZoneTransition<'_>, + component: &str, +) -> Option { + let to = transition.offset(); + // The offset in force a moment earlier is the one the shift moved away from. + let before = transition + .timestamp() + .checked_sub(SignedDuration::from_secs(1)) + .ok()?; + let from = zone.to_offset(before); + // A transition's local start is written in the *old* offset, since that is + // the clock a reader is still on when the rule fires. + let local = transition + .timestamp() + .to_zoned(TimeZone::fixed(from)) + .datetime(); + + let mut block = format!( + "BEGIN:{component}\r\nDTSTART:{}\r\nTZOFFSETFROM:{}\r\nTZOFFSETTO:{}\r\n\ + RRULE:{}\r\n", + local.strftime("%Y%m%dT%H%M%S"), + format_offset(from), + format_offset(to), + yearly_rule(local) + ); + let abbreviation = transition.abbreviation(); + if !abbreviation.is_empty() { + block.push_str(&format!("TZNAME:{abbreviation}\r\n")); + } + block.push_str(&format!("END:{component}\r\n")); + Some(block) +} + +/// The yearly recurrence a transition date implies, as "the nth weekday of the +/// month" — the form every zone's rules are actually written in. A date within +/// seven days of the month's end is the *last* such weekday rather than a fixed +/// ordinal, which is what keeps the rule stable across years of differing length. +fn yearly_rule(local: jiff::civil::DateTime) -> String { + let day = local.day(); + let ordinal = if day + 7 > local.days_in_month() { + -1 + } else { + (day - 1) / 7 + 1 + }; + format!( + "FREQ=YEARLY;BYMONTH={};BYDAY={ordinal}{}", + local.month(), + weekday_code(local.weekday()) + ) +} + +fn weekday_code(weekday: jiff::civil::Weekday) -> &'static str { + match weekday { + jiff::civil::Weekday::Monday => "MO", + jiff::civil::Weekday::Tuesday => "TU", + jiff::civil::Weekday::Wednesday => "WE", + jiff::civil::Weekday::Thursday => "TH", + jiff::civil::Weekday::Friday => "FR", + jiff::civil::Weekday::Saturday => "SA", + jiff::civil::Weekday::Sunday => "SU", + } +} + +/// `+0200` — RFC 5545's UTC offset form, which has no colon. +fn format_offset(offset: jiff::tz::Offset) -> String { + let total = offset.seconds(); + let sign = if total < 0 { '-' } else { '+' }; + let magnitude = total.abs(); + format!( + "{sign}{:02}{:02}", + magnitude / 3600, + (magnitude % 3600) / 60 + ) +} + fn render_event( uid: &str, event: &Event, recurrence_id: Option<&EventTime>, + zones: &mut BTreeSet, ) -> Result { let recurring = event.is_recurring() || recurrence_id.is_some(); let mut out = String::from("BEGIN:VEVENT\r\n"); @@ -197,16 +349,19 @@ fn render_event( original, &event.id, recurring, + zones, )?); out.push_str("\r\n"); } let start = event.start.as_ref().ok_or_else(|| ConvertError::NoStart { id: event.id.clone(), })?; - out.push_str(&time_property("DTSTART", start, &event.id, recurring)?); + out.push_str(&time_property( + "DTSTART", start, &event.id, recurring, zones, + )?); out.push_str("\r\n"); if let Some(end) = &event.end { - out.push_str(&time_property("DTEND", end, &event.id, recurring)?); + out.push_str(&time_property("DTEND", end, &event.id, recurring, zones)?); out.push_str("\r\n"); } for line in event.recurrence.iter().flatten() { @@ -269,6 +424,7 @@ fn time_property( time: &EventTime, id: &str, recurring: bool, + zones: &mut BTreeSet, ) -> Result { if let Some(date) = &time.date { return Ok(format!("{name};VALUE=DATE:{}", date.replace('-', ""))); @@ -292,6 +448,7 @@ fn time_property( match zone { Some((zone_name, zone)) => { + zones.insert(zone_name.to_string()); let local = instant.to_zoned(zone); Ok(format!( "{name};TZID={zone_name}:{}", @@ -319,18 +476,61 @@ fn stamp(event: &Event) -> Result { Ok(instant.strftime("%Y%m%dT%H%M%SZ").to_string()) } +/// Which half of a series a body describes. +/// +/// Google models an exception as a separate event addressed through the series +/// it belongs to, so it carries neither the series' `iCalUID` — which would +/// name the master — nor a recurrence rule of its own. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Role { + Master, + Exception, +} + /// Renders a stored calendar as the JSON body Google expects. /// /// Only the master is described. A series' exceptions cannot be expressed in the -/// same request — Google models them as separate events against an already -/// existing series — so the caller applies those afterwards. +/// same request, so the caller applies those afterwards with `to_exception`. pub fn to_google(calendar: &Calendar) -> Result { - let uid = calendar.uid().ok_or_else(|| ConvertError::NoUid { + let master = master_of(calendar).ok_or_else(|| ConvertError::NoUid { id: "".to_string(), })?; + to_body(&master, Role::Master) +} +/// The master event of a stored file: the one component without a `RECURRENCE-ID`. +/// +/// Falls back to the file as a whole when it holds no `VEVENT` at all, so a +/// malformed item still reports through the ordinary error path. +pub fn master_of(calendar: &Calendar) -> Option { + calendar + .events() + .into_iter() + .find(|event| event.properties("VEVENT", "RECURRENCE-ID").next().is_none()) +} + +/// Every recurrence override in a stored file, in the order they appear. +pub fn exceptions(calendar: &Calendar) -> Vec { + calendar + .events() + .into_iter() + .filter(|event| event.properties("VEVENT", "RECURRENCE-ID").next().is_some()) + .collect() +} + +/// Renders one recurrence override as a patch against its Google instance. +pub fn to_exception(event: &Calendar) -> Result { + to_body(event, Role::Exception) +} + +fn to_body(calendar: &Calendar, role: Role) -> Result { let mut event = serde_json::Map::new(); - event.insert("iCalUID".into(), uid.into()); + if role == Role::Master { + let uid = calendar.uid().ok_or_else(|| ConvertError::NoUid { + id: "".to_string(), + })?; + event.insert("iCalUID".into(), uid.into()); + } event.insert("status".into(), status_of(calendar).into()); for (property, field) in [ @@ -356,6 +556,7 @@ pub fn to_google(calendar: &Calendar) -> Result // verbatim. Their unfolded form is what Google expects. let recurrence: Vec = ["RRULE", "EXDATE", "RDATE"] .iter() + .filter(|_| role == Role::Master) .flat_map(|name| calendar.property_lines("VEVENT", name)) .map(|line| serde_json::Value::from(line.to_string())) .collect(); @@ -517,6 +718,83 @@ fn google_time(calendar: &Calendar, name: &str) -> Result (String, String) { + let centre = match self { + InstanceKey::Instant(instant) => *instant, + InstanceKey::Date(date) => midnight_utc(date).unwrap_or_else(Timestamp::now), + }; + let margin = jiff::SignedDuration::from_hours(36); + ((centre - margin).to_string(), (centre + margin).to_string()) + } +} + +fn midnight_utc(date: &str) -> Option { + let date: jiff::civil::Date = date.parse().ok()?; + date.to_zoned(TimeZone::UTC) + .ok() + .map(|zoned| zoned.timestamp()) +} + +/// The occurrence a stored recurrence override refers to. +pub fn recurrence_key(event: &Calendar) -> Option { + let property = event.properties("VEVENT", "RECURRENCE-ID").next()?; + let value = property.value; + if property.param("VALUE") == Some("DATE") || value.len() == 8 { + return Some(InstanceKey::Date(hyphenate(value))); + } + let civil = parse_basic(value, "RECURRENCE-ID").ok()?; + let zone = match property.param("TZID") { + Some(name) => TimeZone::get(name).ok()?, + None => TimeZone::UTC, + }; + civil + .to_zoned(zone) + .ok() + .map(|zoned| InstanceKey::Instant(zoned.timestamp())) +} + +/// The occurrence a Google override refers to. +pub fn original_start_key(time: &EventTime) -> Option { + if let Some(date) = &time.date { + return Some(InstanceKey::Date(date.clone())); + } + time.date_time + .as_deref()? + .parse() + .ok() + .map(InstanceKey::Instant) +} + +/// Picks the instance an override belongs to out of a page of them. +pub fn find_instance<'a>(instances: &'a [Event], key: &InstanceKey) -> Option<&'a Event> { + instances.iter().find(|instance| { + instance + .original_start_time + .as_ref() + .and_then(original_start_key) + .is_some_and(|candidate| candidate == *key) + }) +} + /// Parses `YYYYMMDDTHHMMSS`, with or without a trailing Z. fn parse_basic(value: &str, field: &'static str) -> Result { let trimmed = value.trim_end_matches('Z'); @@ -860,4 +1138,154 @@ mod tests { "{ics}" ); } + + /// A zone that shifts gets both observances, each as the yearly rule the + /// transition implies: the EU moves on the last Sunday of March and October. + #[test] + fn a_zoned_recurrence_carries_its_timezone_definition() { + let json = r#"[{ + "id": "berlin", "status": "confirmed", "summary": "Standup", + "start": {"dateTime": "2026-03-03T09:00:00+01:00", "timeZone": "Europe/Berlin"}, + "end": {"dateTime": "2026-03-03T09:30:00+01:00", "timeZone": "Europe/Berlin"}, + "recurrence": ["RRULE:FREQ=WEEKLY;BYDAY=TU"], + "iCalUID": "berlin@google.com", "updated": "2026-01-01T00:00:00.000Z" + }]"#; + let groups = group_by_uid(parse(json)); + let ics = to_ical("berlin@google.com", &groups["berlin@google.com"]).expect("convert"); + + assert!( + ics.contains("BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\n"), + "{ics}" + ); + assert!(ics.contains("BEGIN:DAYLIGHT"), "{ics}"); + assert!( + ics.contains("TZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200"), + "{ics}" + ); + assert!( + ics.contains("RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU"), + "{ics}" + ); + assert!(ics.contains("BEGIN:STANDARD"), "{ics}"); + assert!( + ics.contains("TZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100"), + "{ics}" + ); + assert!( + ics.contains("RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU"), + "{ics}" + ); + // The definition has to precede the event that references it. + assert!( + ics.find("BEGIN:VTIMEZONE") < ics.find("BEGIN:VEVENT"), + "{ics}" + ); + assert!(Calendar::parse(&ics).is_ok(), "{ics}"); + } + + /// Pakistan abandoned daylight saving in 2009, so there is no rule to state — + /// only the offset that has applied ever since. + #[test] + fn a_zone_that_never_shifts_gets_one_fixed_observance() { + let groups = group_by_uid(parse(KARACHI_MASTER)); + let ics = to_ical("master1@google.com", &groups["master1@google.com"]).expect("convert"); + assert!(ics.contains("TZID:Asia/Karachi"), "{ics}"); + assert!( + ics.contains("TZOFFSETFROM:+0500\r\nTZOFFSETTO:+0500"), + "{ics}" + ); + assert!(!ics.contains("BEGIN:DAYLIGHT"), "{ics}"); + } + + /// A one-off is written in UTC, so it names no zone and needs no definition. + #[test] + fn an_unzoned_event_carries_no_timezone_definition() { + let json = r#"[{ + "id": "one", "status": "confirmed", "summary": "Once", + "start": {"dateTime": "2026-04-01T10:00:00+02:00", "timeZone": "Europe/Berlin"}, + "end": {"dateTime": "2026-04-01T11:00:00+02:00", "timeZone": "Europe/Berlin"}, + "iCalUID": "one@google.com", "updated": "2026-01-01T00:00:00.000Z" + }]"#; + let groups = group_by_uid(parse(json)); + let ics = to_ical("one@google.com", &groups["one@google.com"]).expect("convert"); + assert!(!ics.contains("VTIMEZONE"), "{ics}"); + } + + /// The master's rule must not leak into the override's body, or Google would + /// read the exception as a series of its own. + #[test] + fn an_exception_body_carries_no_recurrence_and_no_uid() { + let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ + BEGIN:VEVENT\r\nUID:series@calcalist\r\nDTSTAMP:20260101T000000Z\r\n\ + DTSTART;TZID=Europe/Berlin:20260303T090000\r\nRRULE:FREQ=WEEKLY;BYDAY=TU\r\n\ + SUMMARY:Standup\r\nEND:VEVENT\r\n\ + BEGIN:VEVENT\r\nUID:series@calcalist\r\nDTSTAMP:20260101T000000Z\r\n\ + RECURRENCE-ID;TZID=Europe/Berlin:20260310T090000\r\n\ + DTSTART;TZID=Europe/Berlin:20260310T110000\r\n\ + SUMMARY:Standup, moved\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + let calendar = Calendar::parse(ics).expect("parse"); + + let exceptions = exceptions(&calendar); + assert_eq!(exceptions.len(), 1); + let body = to_exception(&exceptions[0]).expect("convert"); + assert!(body.get("recurrence").is_none(), "{body}"); + assert!(body.get("iCalUID").is_none(), "{body}"); + assert_eq!(body["summary"], "Standup, moved"); + assert_eq!(body["start"]["dateTime"], "2026-03-10T10:00:00Z"); + + // The master keeps its own summary rather than the override's. + let master = to_google(&calendar).expect("convert"); + assert_eq!(master["summary"], "Standup"); + assert_eq!(master["iCalUID"], "series@calcalist"); + assert_eq!(master["recurrence"][0], "RRULE:FREQ=WEEKLY;BYDAY=TU"); + } + + /// RECURRENCE-ID and originalStartTime spell the same instant differently: + /// one as a zoned local time, the other as an absolute offset. + #[test] + fn an_override_matches_its_instance_across_the_two_spellings() { + let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ + BEGIN:VEVENT\r\nUID:s@calcalist\r\nDTSTAMP:20260101T000000Z\r\n\ + RECURRENCE-ID;TZID=Europe/Berlin:20260310T090000\r\n\ + DTSTART;TZID=Europe/Berlin:20260310T110000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + let calendar = Calendar::parse(ics).expect("parse"); + let key = recurrence_key(&calendar).expect("a recurrence id"); + + let instances = parse( + r#"[ + {"id": "abc_20260303T080000Z", "status": "confirmed", + "start": {"dateTime": "2026-03-03T09:00:00+01:00"}, + "originalStartTime": {"dateTime": "2026-03-03T09:00:00+01:00"}}, + {"id": "abc_20260310T080000Z", "status": "confirmed", + "start": {"dateTime": "2026-03-10T11:00:00+01:00"}, + "originalStartTime": {"dateTime": "2026-03-10T09:00:00+01:00"}} + ]"#, + ); + let found = find_instance(&instances, &key).expect("a matching instance"); + assert_eq!(found.id, "abc_20260310T080000Z"); + + // The window has to contain the occurrence it was built for. + let (min, max) = key.window(); + assert!(min.as_str() < "2026-03-10T08:00:00Z", "{min}"); + assert!(max.as_str() > "2026-03-10T08:00:00Z", "{max}"); + } + + /// An all-day series identifies its occurrences by date on both sides. + #[test] + fn an_all_day_override_matches_by_date() { + let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ + BEGIN:VEVENT\r\nUID:s@calcalist\r\nDTSTAMP:20260101T000000Z\r\n\ + RECURRENCE-ID;VALUE=DATE:20260310\r\n\ + DTSTART;VALUE=DATE:20260311\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + let calendar = Calendar::parse(ics).expect("parse"); + let key = recurrence_key(&calendar).expect("a recurrence id"); + assert_eq!(key, InstanceKey::Date("2026-03-10".into())); + + let instances = parse( + r#"[{"id": "abc_20260310", "status": "confirmed", + "start": {"date": "2026-03-11"}, + "originalStartTime": {"date": "2026-03-10"}}]"#, + ); + assert!(find_instance(&instances, &key).is_some()); + } } diff --git a/src/ical.rs b/src/ical.rs index 78c188b..89524c3 100644 --- a/src/ical.rs +++ b/src/ical.rs @@ -134,15 +134,62 @@ impl Calendar { pub fn to_ics(&self) -> String { let mut out = String::with_capacity(self.source.len() + 64); for line in &self.lines { - match &line.body { - Body::Original(span) => out.push_str(&self.source[span.clone()]), - Body::Generated(text) => out.push_str(&fold(text, &self.newline)), - } + out.push_str(&self.rendered(line)); out.push_str(&self.newline); } out } + /// One line as it will be emitted: verbatim when untouched, folded when built. + fn rendered(&self, line: &Line) -> String { + match &line.body { + Body::Original(span) => self.source[span.clone()].to_string(), + Body::Generated(text) => fold(text, &self.newline), + } + } + + /// Each `VEVENT` as a calendar of its own, nested components included. + /// + /// `properties` deliberately flattens every component of a name together, + /// which is right for the UID a series shares but wrong for a recurrence + /// override: there its `SUMMARY` and the master's are indistinguishable. + /// Splitting gives each component a view of its own. It re-parses, unlike + /// the rest of this module, which is acceptable because the only caller + /// converts to another model rather than round-tripping back to iCalendar. + pub fn events(&self) -> Vec { + let newline = &self.newline; + let mut out = Vec::new(); + let mut block: Option = None; + for line in &self.lines { + let Some(depth) = line + .path + .iter() + .position(|name| name.eq_ignore_ascii_case("VEVENT")) + else { + continue; + }; + let outermost = depth + 1 == line.path.len(); + if outermost && line.has_name("BEGIN") { + block = Some(String::new()); + } + let Some(text) = block.as_mut() else { + continue; + }; + text.push_str(&self.rendered(line)); + text.push_str(newline); + if outermost + && line.has_name("END") + && let Some(text) = block.take() + && let Ok(event) = Calendar::parse(&format!( + "BEGIN:VCALENDAR{newline}VERSION:2.0{newline}{text}END:VCALENDAR{newline}" + )) + { + out.push(event); + } + } + out + } + /// The UID shared by the event and any recurrence overrides. pub fn uid(&self) -> Option<&str> { self.properties("VEVENT", "UID").next().map(|p| p.value) @@ -678,4 +725,47 @@ mod tests { IcalError::DanglingContinuation { line: 1 } ); } + + /// Splitting has to keep each component's nested parts with it, and give a + /// master and its override separate views of the properties they share. + #[test] + fn events_are_split_with_their_nested_components() { + let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ + BEGIN:VEVENT\r\nUID:s@x\r\nSUMMARY:Series\r\n\ + BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT10M\r\nEND:VALARM\r\n\ + END:VEVENT\r\n\ + BEGIN:VEVENT\r\nUID:s@x\r\nRECURRENCE-ID:20260310T090000Z\r\n\ + SUMMARY:Moved\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + let calendar = Calendar::parse(ics).expect("parse"); + + // Flattened, the two summaries are indistinguishable. + assert_eq!(calendar.properties("VEVENT", "SUMMARY").count(), 2); + + let events = calendar.events(); + assert_eq!(events.len(), 2); + assert_eq!( + events[0] + .properties("VEVENT", "SUMMARY") + .next() + .unwrap() + .value, + "Series" + ); + assert_eq!(events[0].properties("VALARM", "TRIGGER").count(), 1); + assert!( + events[0] + .properties("VEVENT", "RECURRENCE-ID") + .next() + .is_none() + ); + assert_eq!( + events[1] + .properties("VEVENT", "SUMMARY") + .next() + .unwrap() + .value, + "Moved" + ); + assert_eq!(events[1].properties("VALARM", "TRIGGER").count(), 0); + } } diff --git a/src/main.rs b/src/main.rs index 3bb5c2f..bb23631 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,10 @@ fn main() -> ExitCode { command: Command::Sync { dry_run, force }, .. } => run_sync(cli, *dry_run, *force), + cli @ Cli { + command: Command::Prune { force }, + .. + } => run_prune(cli, *force), cli @ Cli { command: Command::Google { @@ -84,12 +88,53 @@ fn run_sync(cli: &Cli, dry_run: bool, force: bool) -> ExitCode { match sync::run(&config, &state_dir, dry_run, force) { Ok(report) => { print_report(&report); - ExitCode::SUCCESS + // A cycle that could not reach an endpoint did real work, but saying + // so only in passing would let a lapsed token go unnoticed until the + // calendars had drifted a long way apart. + if report.is_complete() { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } } Err(error) => fail(&error), } } +/// Removes local mirrors of endpoints the configuration no longer names. +fn run_prune(cli: &Cli, force: bool) -> ExitCode { + let (config, _) = match Config::load(cli.config.as_deref()) { + Ok(loaded) => loaded, + Err(error) => return fail(&error), + }; + let state_dir = match paths::state_dir() { + Ok(dir) => dir, + Err(error) => return fail(&error), + }; + let orphans = sync::orphan_vdirs(&config, &state_dir); + if orphans.is_empty() { + println!("nothing to prune: every local mirror belongs to a configured endpoint"); + return ExitCode::SUCCESS; + } + for orphan in &orphans { + if !force { + println!("would remove {}", orphan.display()); + continue; + } + match std::fs::remove_dir_all(orphan) { + Ok(()) => println!("removed {}", orphan.display()), + Err(error) => { + eprintln!("calcalist: could not remove {}: {error}", orphan.display()); + return ExitCode::FAILURE; + } + } + } + if !force { + println!("re-run with --force to remove them"); + } + ExitCode::SUCCESS +} + /// Authorises one Google endpoint, storing a refresh token for later cycles. fn run_google_login(cli: &Cli, endpoint_id: &str) -> ExitCode { let (config, _) = match Config::load(cli.config.as_deref()) { @@ -162,6 +207,27 @@ fn run_retarget(cli: &Cli, id: &str, to: &str, purge_old: bool) -> ExitCode { } fn print_report(report: &Report) { + for failure in &report.failures { + println!("{}: unavailable — {}", failure.endpoint, failure.reason); + } + for blocked in &report.blocked { + println!( + "{}: left alone; {} could not be reached this cycle", + blocked.id, + blocked + .endpoints + .iter() + .map(|id| format!("`{id}`")) + .collect::>() + .join(", ") + ); + } + for orphan in &report.orphan_vdirs { + println!( + "note: {} mirrors an endpoint that is no longer configured; `calcalist prune` removes it", + orphan.display() + ); + } for google in &report.google { let resync = if google.full_resync { " (Google rejected the sync cursor, so everything was refetched)" @@ -177,9 +243,15 @@ fn print_report(report: &Report) { google.updated_remotely, google.deleted_remotely, ); + if google.exceptions_applied > 0 { + println!( + " {} recurrence override(s) applied to their instances", + google.exceptions_applied + ); + } if google.exceptions_skipped > 0 { println!( - " {} recurring series had exceptions that were not pushed; Google models those as separate events against an existing series", + " {} recurrence override(s) matched no occurrence of their series and were left alone", google.exceptions_skipped ); } @@ -208,7 +280,7 @@ fn print_report(report: &Report) { } } if report.dry_run { - println!("(dry run — nothing was written)"); + println!("(dry run — pulled into a throwaway copy; nothing was written)"); } } @@ -257,6 +329,7 @@ fn unimplemented(command: &Command) -> ExitCode { Command::Serve { .. } => "serve", Command::Google { .. } => "google", Command::Aggregate { .. } => "aggregate", + Command::Prune { .. } => "prune", Command::Doctor => "doctor", }; eprintln!("calcalist: `{name}` is not implemented yet"); diff --git a/src/pimsync.rs b/src/pimsync.rs index 3af46f9..5731345 100644 --- a/src/pimsync.rs +++ b/src/pimsync.rs @@ -106,16 +106,31 @@ pub fn config_path(state_dir: &Path) -> PathBuf { state_dir.join("pimsync.conf") } -fn status_path(state_dir: &Path) -> PathBuf { +pub(crate) fn status_path(state_dir: &Path) -> PathBuf { state_dir.join("pimsync-status") } +/// Which way a generated configuration lets items travel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + /// The ordinary cycle: remote and local converge on each other. + Both, + /// Remote to local only, for `--dry-run`. pimsync writes nothing to any + /// server in this mode — but it does delete local items the remote has + /// never seen, so it is only ever pointed at a throwaway copy of the vdirs. + PullOnly, +} + /// Builds a pimsync configuration covering every CalDAV and WebCal endpoint. /// /// Google endpoints are absent by design: pimsync has no REST storage, and its /// CalDAV storage speaks only HTTP Basic auth, which Google's endpoint has /// rejected since March 2025. calcalist syncs those itself. -pub fn generate(config: &Config, state_dir: &Path) -> Result { +pub fn generate_for( + config: &Config, + state_dir: &Path, + direction: Direction, +) -> Result { let mut out = String::new(); out.push_str("# Generated by calcalist. Edits here are overwritten on every sync.\n\n"); out.push_str(&format!( @@ -129,7 +144,7 @@ pub fn generate(config: &Config, state_dir: &Path) -> Result out.push_str(&caldav_pair(endpoint)?), + EndpointKind::Caldav { .. } => out.push_str(&caldav_pair(endpoint, direction)?), EndpointKind::Webcal { url, url_command } => { out.push_str(&webcal_pair( endpoint, @@ -144,7 +159,7 @@ pub fn generate(config: &Config, state_dir: &Path) -> Result Result { +fn caldav_pair(endpoint: &Endpoint, direction: Direction) -> Result { let EndpointKind::Caldav { url, username, @@ -175,10 +190,15 @@ fn caldav_pair(endpoint: &Endpoint) -> Result { // The remote is storage_a, so a concurrent server-side change wins over a // local one calcalist has not yet reconciled. Nothing is lost: the next // cycle sees the server's version and decides properly. + let resolution = match direction { + // `one_way` never produces a conflict, so there is nothing to resolve. + Direction::PullOnly => "\tone_way\n", + Direction::Both => "\tconflict_resolution keep a\n", + }; block.push_str(&format!( "\npair {id} {{\n\tstorage_a {id}_remote\n\tstorage_b {LOCAL_STORAGE}\n\ \tcollection {{\n\t\talias {id}\n\t\thref_a {}\n\t\tid_b {id}\n\t}}\n\ - \tconflict_resolution keep a\n\ton_empty skip\n\ton_delete skip\n}}\n", + {resolution}\ton_empty skip\n\ton_delete skip\n}}\n", quote(&href) )); Ok(block) @@ -235,8 +255,16 @@ fn quote(value: &str) -> String { /// Writes the generated configuration, returning its path. pub fn write_config(config: &Config, state_dir: &Path) -> Result { + write_config_for(config, state_dir, Direction::Both) +} + +pub fn write_config_for( + config: &Config, + state_dir: &Path, + direction: Direction, +) -> Result { let path = config_path(state_dir); - let text = generate(config, state_dir)?; + let text = generate_for(config, state_dir, direction)?; fs::create_dir_all(status_path(state_dir)).map_err(|source| PimsyncError::Write { path: status_path(state_dir), source, @@ -327,7 +355,8 @@ url = "https://example.org/holidays.ics" fn generated() -> String { let config: Config = toml::from_str(CONFIG).expect("config should parse"); - generate(&config, Path::new("/var/state/calcalist")).expect("generation should succeed") + generate_for(&config, Path::new("/var/state/calcalist"), Direction::Both) + .expect("generation should succeed") } #[test] @@ -404,7 +433,8 @@ url_command = "secret-tool lookup service calcalist account gcal-ics" "#, ) .expect("config should parse"); - let text = generate(&config, Path::new("/var/state/calcalist")).expect("generate"); + let text = generate_for(&config, Path::new("/var/state/calcalist"), Direction::Both) + .expect("generate"); assert!(text.contains("url {")); assert!(text.contains("shell secret-tool lookup service calcalist account gcal-ics")); diff --git a/src/sync.rs b/src/sync.rs index b6e1f1d..1494c70 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -5,7 +5,8 @@ //! out of those vdirs is the job of pimsync (CalDAV, WebCal) and the Google //! module, which bracket this step. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; use std::path::{Path, PathBuf}; use thiserror::Error; @@ -13,7 +14,7 @@ use thiserror::Error; use crate::config::{Aggregate, Config, Endpoint, EndpointKind}; use crate::google::api::{self, ApiError}; use crate::google::auth::{self, AuthError}; -use crate::pimsync::{self, PimsyncError}; +use crate::pimsync::{self, Direction, PimsyncError}; use crate::reconcile::{self, Action, Conflict, Policy, ReconcileError, Skipped, SourceView}; use crate::state::{State, Target}; use crate::vdir::{self, VdirError}; @@ -69,9 +70,37 @@ pub enum SyncError { pub struct Report { pub aggregates: Vec, pub google: Vec, + pub failures: Vec, + pub blocked: Vec, + pub orphan_vdirs: Vec, pub dry_run: bool, } +impl Report { + /// Whether the cycle covered everything it was asked to. + /// + /// A partial cycle is not a crash — the aggregates that could be reconciled + /// were — but it must not read as success either, or a lapsed token would go + /// unnoticed until the calendars had drifted a long way apart. + pub fn is_complete(&self) -> bool { + self.failures.is_empty() && self.blocked.is_empty() + } +} + +/// An endpoint that could not be reached this cycle. +#[derive(Debug)] +pub struct EndpointFailure { + pub endpoint: String, + pub reason: String, +} + +/// An aggregate left alone because an endpoint it depends on was unreachable. +#[derive(Debug)] +pub struct BlockedAggregate { + pub id: String, + pub endpoints: Vec, +} + #[derive(Debug)] pub struct GoogleReport { pub endpoint: String, @@ -81,6 +110,7 @@ pub struct GoogleReport { pub created_remotely: usize, pub updated_remotely: usize, pub deleted_remotely: usize, + pub exceptions_applied: usize, pub exceptions_skipped: usize, } @@ -106,133 +136,257 @@ pub fn run( let mut state = State::load(&state_path)?; let mut report = Report { dry_run, + orphan_vdirs: orphan_vdirs(config, state_dir), ..Report::default() }; prepare_vdirs(config, state_dir)?; - // A dry run must leave the vdirs exactly as it found them, and pimsync - // writes to them, so the pulls and pushes are skipped along with everything - // else. What it reports is therefore what the *last* pull left behind. - let pimsync_config = if dry_run || !needs_pimsync(config) { - None + // A dry run pulls for real, so that what it reports is measured against the + // calendars as they are now — but into a throwaway copy of the local + // mirrors, and through a configuration that only ever reads from a server. + // Nothing outside that copy is written, and it is removed at the end. + let workspace = if dry_run { + Some(Workspace::open(state_dir)?) } else { - Some(pimsync::write_config(config, state_dir)?) + None + }; + let work_dir = workspace.as_ref().map_or(state_dir, Workspace::path); + + let pimsync_config = if needs_pimsync(config) { + let direction = if dry_run { + Direction::PullOnly + } else { + Direction::Both + }; + Some(pimsync::write_config_for(config, work_dir, direction)?) + } else { + None }; // Pull first, so the reconciler sees one consistent snapshot of every remote. if let Some(path) = &pimsync_config { pimsync::sync(path)?; } - if !dry_run { - report.google = pull_google(config, state_dir, &mut state)?; - } + let unavailable = pull_google(config, state_dir, work_dir, &mut state, &mut report); for aggregate in &config.aggregates { + // An aggregate whose target or any source could not be pulled is left + // untouched. Reconciling it would compare against a stale snapshot, and + // an endpoint that failed to answer looks exactly like an emptied one. + let endpoints: Vec = std::iter::once(&aggregate.target) + .chain(aggregate.sources.iter()) + .filter(|id| unavailable.contains(*id)) + .cloned() + .collect(); + if !endpoints.is_empty() { + report.blocked.push(BlockedAggregate { + id: aggregate.id.clone(), + endpoints, + }); + continue; + } + let target = resolve(config, aggregate, &aggregate.target)?; check_target_drift(&state, aggregate, target)?; report.aggregates.push(sync_aggregate( - config, aggregate, target, state_dir, &mut state, dry_run, force, + config, aggregate, target, work_dir, &mut state, dry_run, force, )?); } // Push what the reconciler decided out to the remotes. if !dry_run { - push_google(config, state_dir, &mut state, &mut report)?; - } - if let Some(path) = &pimsync_config { - pimsync::sync(path)?; - } - - if !dry_run { + push_google(config, state_dir, &mut state, &mut report, &unavailable); + if let Some(path) = &pimsync_config { + pimsync::sync(path)?; + } state.save(&state_path)?; } + if let Some(workspace) = workspace { + workspace.discard(); + } Ok(report) } -/// Brings every Google endpoint's vdir into step with its remote calendar. +/// A throwaway copy of the local mirrors, used by `--dry-run`. +/// +/// Pulling needs somewhere to put what it reads. Writing into the real mirrors +/// would leave them describing a moment the recorded state knows nothing about, +/// so a dry run gets its own copy and the originals are never opened for writing. +struct Workspace { + root: PathBuf, +} + +impl Workspace { + const DIRECTORY: &'static str = "dry-run"; + + fn open(state_dir: &Path) -> Result { + let root = state_dir.join(Self::DIRECTORY); + // A previous run that was interrupted may have left one behind. + if root.exists() { + fs::remove_dir_all(&root).map_err(|source| SyncError::Prepare { + path: root.clone(), + source, + })?; + } + copy_tree(&vdir_root(state_dir), &root.join("vdir"))?; + // pimsync's own record of what it has seen comes too, or it would treat + // every local item as new and plan to create it on the server. + copy_tree( + &pimsync::status_path(state_dir), + &pimsync::status_path(&root), + )?; + Ok(Workspace { root }) + } + + fn path(&self) -> &Path { + &self.root + } + + fn discard(self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +/// Copies a directory tree, treating a missing source as an empty one. +fn copy_tree(from: &Path, to: &Path) -> Result<(), SyncError> { + fs::create_dir_all(to).map_err(|source| SyncError::Prepare { + path: to.to_path_buf(), + source, + })?; + let Ok(entries) = fs::read_dir(from) else { + return Ok(()); + }; + for entry in entries.flatten() { + let target = to.join(entry.file_name()); + let copied = if entry.path().is_dir() { + copy_tree(&entry.path(), &target) + } else { + fs::copy(entry.path(), &target) + .map(|_| ()) + .map_err(|source| SyncError::Prepare { + path: target, + source, + }) + }; + copied?; + } + Ok(()) +} + +/// Local mirrors belonging to endpoints the configuration no longer names. +/// +/// Removing an endpoint leaves its events sitting in the state directory, where +/// nothing manages them any more. They are reported rather than deleted: the +/// endpoint may have been renamed, or commented out for an afternoon. +pub fn orphan_vdirs(config: &Config, state_dir: &Path) -> Vec { + let Ok(entries) = fs::read_dir(vdir_root(state_dir)) else { + return Vec::new(); + }; + let mut orphans: Vec = entries + .flatten() + .filter(|entry| entry.path().is_dir()) + .filter(|entry| { + let name = entry.file_name().to_string_lossy().into_owned(); + config.endpoint(&name).is_none() + }) + .map(|entry| entry.path()) + .collect(); + orphans.sort(); + orphans +} + +/// Brings every Google endpoint's vdir into step with its remote calendar, +/// returning the endpoints that could not be reached. /// /// This is the Google equivalent of the pimsync pull: pimsync cannot reach /// Google at all, so calcalist does this leg itself. +/// +/// A failure here used to end the cycle. It no longer does: one lapsed token +/// would otherwise stop the CalDAV side too, which has nothing to do with it. +/// The affected endpoints are named, and only the aggregates that depend on +/// them stand down. fn pull_google( config: &Config, state_dir: &Path, + vdir_base: &Path, state: &mut State, -) -> Result, SyncError> { - let mut reports = Vec::new(); + report: &mut Report, +) -> BTreeSet { + let mut unavailable = BTreeSet::new(); for endpoint in &config.endpoints { let EndpointKind::Google { calendar_id, .. } = &endpoint.kind else { continue; }; - let credentials = auth::credentials_for(config, &endpoint.id).map_err(|source| { - SyncError::GoogleAuth { + let pulled = client_for(config, state_dir, endpoint, calendar_id).and_then(|client| { + let dir = vdir_path(vdir_base, &endpoint.id); + let entry = state.google.entry(endpoint.id.clone()).or_default(); + api::pull(&client, &dir, entry).map_err(|source| SyncError::Google { endpoint: endpoint.id.clone(), source, - } - })?; - let token = - auth::access_token(state_dir, &endpoint.id, &credentials).map_err(|source| { - SyncError::GoogleAuth { - endpoint: endpoint.id.clone(), - source, - } - })?; - - let client = api::Client::new(token, calendar_id.clone()); - let dir = vdir_path(state_dir, &endpoint.id); - let entry = state.google.entry(endpoint.id.clone()).or_default(); - let pulled = api::pull(&client, &dir, entry).map_err(|source| SyncError::Google { - endpoint: endpoint.id.clone(), - source, - })?; - reports.push(GoogleReport { - endpoint: endpoint.id.clone(), - written: pulled.written, - deleted: pulled.deleted, - full_resync: pulled.full_resync, - created_remotely: 0, - updated_remotely: 0, - deleted_remotely: 0, - exceptions_skipped: 0, + }) }); + match pulled { + Ok(pulled) => report.google.push(GoogleReport { + endpoint: endpoint.id.clone(), + written: pulled.written, + deleted: pulled.deleted, + full_resync: pulled.full_resync, + created_remotely: 0, + updated_remotely: 0, + deleted_remotely: 0, + exceptions_applied: 0, + exceptions_skipped: 0, + }), + Err(error) => { + unavailable.insert(endpoint.id.clone()); + report.failures.push(EndpointFailure { + endpoint: endpoint.id.clone(), + reason: chain(&error), + }); + } + } } - Ok(reports) + unavailable } /// Sends each Google endpoint's local changes up to its calendar. /// /// Runs after reconciliation, so what goes up is what the reconciler decided. +/// An endpoint whose pull failed is skipped: its vdir describes some earlier +/// moment, and pushing that would undo whatever has happened since. fn push_google( config: &Config, state_dir: &Path, state: &mut State, report: &mut Report, -) -> Result<(), SyncError> { + unavailable: &BTreeSet, +) { for endpoint in &config.endpoints { let EndpointKind::Google { calendar_id, .. } = &endpoint.kind else { continue; }; - let credentials = auth::credentials_for(config, &endpoint.id).map_err(|source| { - SyncError::GoogleAuth { + if unavailable.contains(&endpoint.id) { + continue; + } + let pushed = client_for(config, state_dir, endpoint, calendar_id).and_then(|client| { + let dir = vdir_path(state_dir, &endpoint.id); + let entry = state.google.entry(endpoint.id.clone()).or_default(); + api::push(&client, &dir, entry).map_err(|source| SyncError::Google { endpoint: endpoint.id.clone(), source, - } - })?; - let token = - auth::access_token(state_dir, &endpoint.id, &credentials).map_err(|source| { - SyncError::GoogleAuth { + }) + }); + let pushed = match pushed { + Ok(pushed) => pushed, + Err(error) => { + report.failures.push(EndpointFailure { endpoint: endpoint.id.clone(), - source, - } - })?; - - let client = api::Client::new(token, calendar_id.clone()); - let dir = vdir_path(state_dir, &endpoint.id); - let entry = state.google.entry(endpoint.id.clone()).or_default(); - let pushed = api::push(&client, &dir, entry).map_err(|source| SyncError::Google { - endpoint: endpoint.id.clone(), - source, - })?; + reason: chain(&error), + }); + continue; + } + }; if let Some(existing) = report .google @@ -242,10 +396,46 @@ fn push_google( existing.created_remotely = pushed.created; existing.updated_remotely = pushed.updated; existing.deleted_remotely = pushed.deleted; + existing.exceptions_applied = pushed.exceptions_applied; existing.exceptions_skipped = pushed.exceptions_skipped; } } - Ok(()) +} + +/// An authorised client for one Google endpoint. +fn client_for( + config: &Config, + state_dir: &Path, + endpoint: &Endpoint, + calendar_id: &str, +) -> Result { + let credentials = + auth::credentials_for(config, &endpoint.id).map_err(|source| SyncError::GoogleAuth { + endpoint: endpoint.id.clone(), + source, + })?; + let token = auth::access_token( + state_dir, + &endpoint.id, + endpoint.kind.google_account(), + &credentials, + ) + .map_err(|source| SyncError::GoogleAuth { + endpoint: endpoint.id.clone(), + source, + })?; + Ok(api::Client::new(token, calendar_id.to_string())) +} + +/// An error and its causes on one line, for a report that keeps going. +fn chain(error: &dyn std::error::Error) -> String { + let mut out = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + out.push_str(&format!(": {cause}")); + source = cause.source(); + } + out } /// Whether any endpoint is one pimsync handles. A Google-only setup needs none. @@ -705,4 +895,73 @@ sources = ["gcal"] uids(dir.path(), "work") ); } + + /// An endpoint calcalist cannot reach must not take the whole cycle with it. + /// Here every endpoint is Google and none is authorised, so all of them fail + /// — and the run still returns, naming what it could not do. + #[test] + fn an_unreachable_endpoint_blocks_only_what_depends_on_it() { + let dir = tempfile::tempdir().expect("temp"); + let config: Config = toml::from_str(&format!( + "{CONFIG}\n[[aggregate]]\nid = \"unified\"\ntarget = \"posteo\"\nsources = [\"work\"]\n" + )) + .expect("config"); + seed(dir.path(), "work", "a@example.com", "Standup"); + + let report = run(&config, dir.path(), false, false).expect("the cycle still returns"); + + assert!(!report.failures.is_empty(), "{report:?}"); + assert_eq!(report.blocked.len(), 1, "{report:?}"); + assert_eq!(report.blocked[0].id, "unified"); + assert!(report.blocked[0].endpoints.contains(&"work".to_string())); + assert!(!report.is_complete(), "a partial cycle is not a success"); + // Nothing was reconciled against the stale snapshot. + assert!(report.aggregates.is_empty(), "{report:?}"); + assert!(uids(dir.path(), "posteo").is_empty()); + } + + /// A mirror whose endpoint has gone from the configuration is reported, not + /// deleted: the endpoint may simply have been renamed. + #[test] + fn a_mirror_with_no_endpoint_is_reported_as_an_orphan() { + let dir = tempfile::tempdir().expect("temp"); + let config: Config = toml::from_str(CONFIG).expect("config"); + seed(dir.path(), "work", "a@example.com", "Standup"); + seed(dir.path(), "retired", "b@example.com", "Old"); + + let orphans = orphan_vdirs(&config, dir.path()); + + assert_eq!(orphans, vec![vdir_path(dir.path(), "retired")]); + } + + /// The dry run's copy is thrown away, and the mirrors it was made from are + /// left exactly as they were. + #[test] + fn a_dry_run_leaves_the_real_mirrors_alone() { + let dir = tempfile::tempdir().expect("temp"); + let config: Config = toml::from_str(&format!( + "{CONFIG}\n[[aggregate]]\nid = \"unified\"\ntarget = \"posteo\"\nsources = [\"work\"]\n" + )) + .expect("config"); + seed(dir.path(), "work", "a@example.com", "Standup"); + let before = fs::read_to_string(vdir_path(dir.path(), "work").join("a@example.com.ics")) + .expect("read"); + + let report = run(&config, dir.path(), true, false).expect("dry run"); + + assert!(report.dry_run); + assert!( + !dir.path().join("dry-run").exists(), + "the workspace should be gone" + ); + assert_eq!( + fs::read_to_string(vdir_path(dir.path(), "work").join("a@example.com.ics")) + .expect("read"), + before + ); + assert!( + !dir.path().join(crate::state::FILE_NAME).exists(), + "a dry run records nothing" + ); + } } diff --git a/tests/caldav.rs b/tests/caldav.rs new file mode 100644 index 0000000..68bdb1a --- /dev/null +++ b/tests/caldav.rs @@ -0,0 +1,274 @@ +//! End-to-end tests against a real CalDAV server and a real iCal feed. +//! +//! The unit tests establish that the reconciler decides correctly. These +//! establish that the decisions survive the round trip through pimsync and a +//! server that rewrites what it stores — which is where every bug found by hand +//! during M1 actually lived. +//! +//! Radicale and pimsync both come from devbox, so `devbox run check` has them. +//! Outside that shell the tests report what is missing and pass, rather than +//! failing for a reason that has nothing to do with the code. + +mod support; + +use support::{Calcalist, Feed, Radicale}; + +/// An event with guests, an organiser and an alarm — the combination the +/// scheduling rules are about. +const MEETING: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\ + BEGIN:VEVENT\r\nUID:meeting@work\r\nDTSTAMP:20260101T000000Z\r\n\ + DTSTART:20260910T090000Z\r\nDTEND:20260910T100000Z\r\nSUMMARY:Planning\r\n\ + ORGANIZER;CN=Chair:mailto:chair@example.com\r\n\ + ATTENDEE;CN=Guest;PARTSTAT=ACCEPTED:mailto:guest@example.com\r\n\ + BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nDESCRIPTION:Soon\r\n\ + END:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + +const HOLIDAY_FEED: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//feed//EN\r\n\ + BEGIN:VEVENT\r\nUID:newyear@feed\r\nDTSTAMP:20260101T000000Z\r\n\ + DTSTART;VALUE=DATE:20260101\r\nDTEND;VALUE=DATE:20260102\r\n\ + SUMMARY:New Year\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + +struct Fixture { + _root: tempfile::TempDir, + server: Radicale, + _feed: Feed, + calcalist: Calcalist, +} + +/// A CalDAV source, a read-only feed, and a CalDAV target to aggregate into. +fn fixture() -> Option { + let missing = support::missing_binaries(); + if !missing.is_empty() { + eprintln!( + "skipped: {} not on PATH; run under devbox", + missing.join(", ") + ); + return None; + } + + let root = tempfile::tempdir().expect("temp"); + let server = Radicale::start(root.path()); + server.create_calendar("work"); + server.create_calendar("unified"); + server.put_event("work", "meeting.ics", MEETING); + let feed = Feed::start(HOLIDAY_FEED.to_string()); + + let config = format!( + r#" +version = 1 + +[[endpoint]] +id = "work" +type = "caldav" +url = "{work}" +username = "{user}" +secret_command = "printf password" + +[[endpoint]] +id = "published" +type = "caldav" +url = "{unified}" +username = "{user}" +secret_command = "printf password" + +[[endpoint]] +id = "holidays" +type = "webcal" +url = "{feed}" + +[[aggregate]] +id = "everything" +target = "published" +sources = ["work", "holidays"] +default_sink = "work" +"#, + work = server.url("work"), + unified = server.url("unified"), + user = support::USER, + feed = feed.url(), + ); + + let calcalist = Calcalist::new(root.path(), &config); + Some(Fixture { + _root: root, + server, + _feed: feed, + calcalist, + }) +} + +/// Both sources reach the target, and running again changes nothing. +/// +/// Idempotence is the property that matters most: a cycle that is not a no-op +/// against unchanged input would rewrite every event on every run, and each +/// rewrite is a chance for the server to hand back something slightly different. +#[test] +fn a_cycle_converges_and_the_next_one_does_nothing() { + let Some(fixture) = fixture() else { return }; + + let first = fixture.calcalist.run(&["sync"]); + assert!( + first.succeeded(), + "first sync failed\n{}\n{}", + first.stdout, + first.stderr + ); + + let published = fixture.server.stored("unified"); + assert_eq!( + published.len(), + 2, + "both sources should arrive: {published:?}" + ); + let all = published.join("\n"); + assert!(all.contains("Planning"), "{all}"); + assert!(all.contains("New Year"), "{all}"); + // Provenance travels with each mirror, so the aggregate knows where its + // events came from without consulting the state file. + assert!(all.contains("X-CALCALIST-SOURCE:work"), "{all}"); + assert!(all.contains("X-CALCALIST-SOURCE:holidays"), "{all}"); + + let second = fixture.calcalist.run(&["sync"]); + assert!( + second.succeeded(), + "second sync failed\n{}\n{}", + second.stdout, + second.stderr + ); + assert!( + second.stdout.contains("everything: 0 mirrored"), + "the second cycle should be a no-op:\n{}", + second.stdout + ); + assert_eq!( + fixture.server.stored("unified"), + published, + "the second cycle rewrote the target" + ); +} + +/// The scheduling rule, checked against what actually reached the server. +/// +/// A CalDAV server has no portable way to be told not to send invitations, so +/// inertness is structural: the mirror carries the guest list as data and not as +/// live scheduling properties. The alarm is the deliberate exception — stripping +/// it would destroy every reminder in the one calendar the user subscribes to. +/// +/// Radicale implements no scheduling of its own, so this asserts on the bytes +/// stored rather than on a mail sink: with nothing to send mail, a quiet SMTP +/// port would prove nothing about the transform. +#[test] +fn a_mirror_carries_no_live_scheduling_properties() { + let Some(fixture) = fixture() else { return }; + + let run = fixture.calcalist.run(&["sync"]); + assert!( + run.succeeded(), + "sync failed\n{}\n{}", + run.stdout, + run.stderr + ); + + let mirror = fixture + .server + .stored("unified") + .into_iter() + .find(|item| item.contains("Planning")) + .expect("the meeting should have been mirrored"); + + for property in ["ATTENDEE;", "ATTENDEE:", "ORGANIZER;", "ORGANIZER:"] { + assert!( + !mirror + .lines() + .any(|line| line.trim_start().starts_with(property)), + "a live {property} reached the aggregate:\n{mirror}" + ); + } + // The guests are still there, as something nothing will act on. + assert!( + mirror.contains("X-CALCALIST-ATTENDEES"), + "the guest list was lost:\n{mirror}" + ); + assert!( + mirror.contains("guest@example.com"), + "the guest list was lost:\n{mirror}" + ); + assert!( + mirror.contains("BEGIN:VALARM") && mirror.contains("TRIGGER:-PT15M"), + "the alarm did not survive:\n{mirror}" + ); + + // The source keeps its scheduling properties: only the aggregate is inert. + let source = fixture + .server + .stored("work") + .into_iter() + .find(|item| item.contains("Planning")) + .expect("the meeting should still be in its source"); + assert!(source.contains("ATTENDEE"), "{source}"); + assert!(source.contains("ORGANIZER"), "{source}"); +} + +/// A dry run must reach the servers to be worth anything, and change nothing. +#[test] +fn a_dry_run_reports_without_touching_anything() { + let Some(fixture) = fixture() else { return }; + + let dry = fixture.calcalist.run(&["sync", "--dry-run"]); + assert!( + dry.succeeded(), + "dry run failed\n{}\n{}", + dry.stdout, + dry.stderr + ); + assert!( + dry.stdout.contains("everything: 2 mirrored"), + "the dry run should have seen both sources:\n{}", + dry.stdout + ); + assert!( + fixture.server.stored("unified").is_empty(), + "the dry run published events" + ); + + // And the real cycle that follows is not confused by it. + let real = fixture.calcalist.run(&["sync"]); + assert!( + real.succeeded(), + "sync after a dry run failed\n{}\n{}", + real.stdout, + real.stderr + ); + assert_eq!(fixture.server.stored("unified").len(), 2); +} + +/// Removing an endpoint used to leave its events sitting in the state directory +/// with nothing managing them. They are now reported, and removed on request. +#[test] +fn a_retired_endpoints_mirror_is_reported_and_then_removed() { + let Some(fixture) = fixture() else { return }; + + assert!(fixture.calcalist.run(&["sync"]).succeeded()); + let mirror = fixture.calcalist.state.join("calcalist/vdir/holidays"); + assert!(mirror.is_dir(), "the feed should have been mirrored"); + + // The feed is dropped from the configuration, as a user would drop it. + std::fs::write( + &fixture.calcalist.config, + fixture.calcalist.config_without_feed(), + ) + .expect("rewrite config"); + + let listed = fixture.calcalist.run(&["prune"]); + assert!(listed.succeeded(), "{}", listed.stderr); + assert!( + listed.stdout.contains("would remove") && listed.stdout.contains("holidays"), + "prune should say what it found:\n{}", + listed.stdout + ); + assert!(mirror.is_dir(), "listing must not delete anything"); + + let removed = fixture.calcalist.run(&["prune", "--force"]); + assert!(removed.succeeded(), "{}", removed.stderr); + assert!(!mirror.exists(), "the orphaned mirror should be gone"); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs new file mode 100644 index 0000000..605818c --- /dev/null +++ b/tests/support/mod.rs @@ -0,0 +1,310 @@ +//! Scaffolding for the integration tests: a real CalDAV server, a real feed. +//! +//! Everything here talks HTTP over a plain socket rather than through a client +//! crate. The requests involved are few and mostly unusual — `MKCALENDAR`, `PUT` +//! of an `.ics` — and writing them out makes exactly what the server is asked +//! for visible in the test. + +use std::io::{Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +/// How long to wait for a server to start answering. +const STARTUP_TIMEOUT: Duration = Duration::from_secs(20); + +/// The account Radicale files everything under. With authentication disabled it +/// accepts whatever name arrives, and `owner_only` rights then grant that name +/// its own tree — so the URLs below all live beneath it. +pub const USER: &str = "calcalist"; + +/// A binary the integration tests need, and whether it is here. +pub fn missing_binaries() -> Vec<&'static str> { + ["radicale", "pimsync"] + .into_iter() + .filter(|binary| which(binary).is_none()) + .collect() +} + +fn which(binary: &str) -> Option { + std::env::var_os("PATH")? + .to_str()? + .split(':') + .map(|dir| Path::new(dir).join(binary)) + .find(|candidate| candidate.is_file()) +} + +/// A Radicale instance with its own storage, shut down when dropped. +pub struct Radicale { + process: Child, + pub port: u16, + storage: PathBuf, +} + +impl Radicale { + pub fn start(root: &Path) -> Radicale { + let storage = root.join("radicale"); + std::fs::create_dir_all(&storage).expect("create storage"); + let port = free_port(); + + let process = Command::new("radicale") + .arg("--config") + .arg("") + .arg("--server-hosts") + .arg(format!("127.0.0.1:{port}")) + .arg("--auth-type") + .arg("none") + .arg("--storage-filesystem-folder") + .arg(&storage) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("radicale should start"); + + let server = Radicale { + process, + port, + storage, + }; + wait_until_listening(port, "radicale"); + server + } + + pub fn url(&self, calendar: &str) -> String { + format!("http://127.0.0.1:{}/{USER}/{calendar}/", self.port) + } + + /// Creates a calendar collection. pimsync deliberately never creates one, + /// so the calendars a test syncs have to exist on the server first. + pub fn create_calendar(&self, calendar: &str) { + let body = "\ + \ + calendar\ + "; + let response = self.request( + "MKCALENDAR", + &format!("/{USER}/{calendar}/"), + "application/xml; charset=utf-8", + body, + ); + // Radicale answers HTTP/1.0, so the status is read out of the line + // rather than matched against a whole prefix. + assert!( + matches!(status_of(&response), Some(201 | 405)), + "creating {calendar}: {response}" + ); + } + + /// Stores an event, as a calendar client would. + pub fn put_event(&self, calendar: &str, name: &str, ics: &str) { + let response = self.request( + "PUT", + &format!("/{USER}/{calendar}/{name}"), + "text/calendar; charset=utf-8", + ics, + ); + assert!( + status_of(&response).is_some_and(|status| (200..300).contains(&status)), + "storing {name}: {response}" + ); + } + + /// Everything the server holds in a calendar, as stored. + /// + /// Read from Radicale's own storage rather than fetched back, so what is + /// asserted on is the bytes that reached the server. + pub fn stored(&self, calendar: &str) -> Vec { + let dir = self + .storage + .join("collection-root") + .join(USER) + .join(calendar); + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut items: Vec = entries + .flatten() + .filter(|entry| { + entry + .path() + .extension() + .is_some_and(|extension| extension == "ics") + }) + .filter_map(|entry| std::fs::read_to_string(entry.path()).ok()) + .collect(); + items.sort(); + items + } + + fn request(&self, method: &str, path: &str, content_type: &str, body: &str) -> String { + let address = SocketAddr::from((Ipv4Addr::LOCALHOST, self.port)); + let mut stream = TcpStream::connect(address).expect("connect to radicale"); + // With authentication disabled the credentials only pick the account. + let authorization = base64(format!("{USER}:password").as_bytes()); + let request = format!( + "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\n\ + Authorization: Basic {authorization}\r\nContent-Type: {content_type}\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + self.port, + body.len() + ); + stream.write_all(request.as_bytes()).expect("send request"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("read the response"); + response + } +} + +impl Drop for Radicale { + fn drop(&mut self) { + let _ = self.process.kill(); + let _ = self.process.wait(); + } +} + +/// A one-file HTTP server, standing in for a published iCal feed. +pub struct Feed { + pub port: u16, +} + +impl Feed { + /// Serves `ics` at any path, for as long as the test runs. + pub fn start(ics: String) -> Feed { + let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) + .expect("bind the feed port"); + let port = listener.local_addr().expect("feed address").port(); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + serve_once(stream, &ics); + } + }); + Feed { port } + } + + pub fn url(&self) -> String { + format!("http://127.0.0.1:{}/holidays.ics", self.port) + } +} + +fn serve_once(mut stream: TcpStream, ics: &str) { + // Enough of the request to reach the blank line; the path does not matter. + let mut buffer = [0u8; 2048]; + let _ = stream.read(&mut buffer); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/calendar; charset=utf-8\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{ics}", + ics.len() + ); + let _ = stream.write_all(response.as_bytes()); +} + +/// Runs `calcalist` against a config and state directory of the test's own. +pub struct Calcalist { + pub config: PathBuf, + pub state: PathBuf, +} + +/// What one `calcalist` invocation printed and returned. +pub struct Run { + pub status: Option, + pub stdout: String, + pub stderr: String, +} + +impl Run { + pub fn succeeded(&self) -> bool { + self.status == Some(0) + } +} + +impl Calcalist { + pub fn new(root: &Path, config: &str) -> Calcalist { + let config_path = root.join("calcalist.toml"); + std::fs::write(&config_path, config).expect("write config"); + let state = root.join("state"); + std::fs::create_dir_all(&state).expect("create state directory"); + Calcalist { + config: config_path, + state, + } + } + + /// The same configuration with the read-only feed taken out, for the test + /// that retires an endpoint. + pub fn config_without_feed(&self) -> String { + let text = std::fs::read_to_string(&self.config).expect("read config"); + text.split("\n\n") + .filter(|block| !block.contains("id = \"holidays\"")) + .map(|block| block.replace(", \"holidays\"", "")) + .collect::>() + .join("\n\n") + } + + pub fn run(&self, arguments: &[&str]) -> Run { + let output = Command::new(env!("CARGO_BIN_EXE_calcalist")) + .arg("--config") + .arg(&self.config) + .args(arguments) + // The state directory is resolved through XDG, so this is what + // keeps the test off the developer's own calendars and state. + .env("XDG_STATE_HOME", &self.state) + .output() + .expect("calcalist should run"); + Run { + status: output.status.code(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + } + } +} + +/// The status code out of a response's first line, whatever HTTP version it +/// claims. +fn status_of(response: &str) -> Option { + response + .lines() + .next()? + .split_whitespace() + .nth(1)? + .parse() + .ok() +} + +fn free_port() -> u16 { + let listener = + TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).expect("bind a free port"); + listener.local_addr().expect("port").port() +} + +fn wait_until_listening(port: u16, what: &str) { + let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + let deadline = Instant::now() + STARTUP_TIMEOUT; + while Instant::now() < deadline { + if TcpStream::connect_timeout(&address, Duration::from_millis(200)).is_ok() { + return; + } + std::thread::sleep(Duration::from_millis(100)); + } + panic!("{what} did not start listening on {port} within {STARTUP_TIMEOUT:?}"); +} + +fn base64(input: &[u8]) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for chunk in input.chunks(3) { + let mut block = [0u8; 3]; + block[..chunk.len()].copy_from_slice(chunk); + let packed = u32::from(block[0]) << 16 | u32::from(block[1]) << 8 | u32::from(block[2]); + for index in 0..4 { + if index <= chunk.len() { + out.push(ALPHABET[(packed >> (18 - index * 6)) as usize & 0x3f] as char); + } else { + out.push('='); + } + } + } + out +}