Close the remaining M1 gaps
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 <noreply@anthropic.com>
This commit is contained in:
parent
06e661b0c4
commit
623505b9c8
14 changed files with 2008 additions and 144 deletions
|
|
@ -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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
let Ok(entries) = std::fs::read_dir(state_dir.join("google").join("accounts")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut accounts: Vec<String> = 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<String, AuthError> {
|
||||
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<String> {
|
||||
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<String, AuthError> {
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue