Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1404,9 +1404,12 @@ Besprechung sagte sie dem Gast ab. Jetzt:
folgenden“ wird nicht angeboten: Das schriebe die Regel um, die nur der
Organisator ändern darf.
- Eine Ablehnung reist als Marke (`cal_core::WriteRefusal`:
`reply-only-invitation:`, `server-refused:`, `identity-unknown:`), und
beide Oberflächen sagen daraus einen Satz in der Sprache des Nutzers
(`shared/eventWriteError.ts`). `CACHE_GENERATION` 4, damit eine
`reply-only-invitation:`, `server-refused:`, `identity-unknown:`,
`occurrence-not-writable:`, `unsafe-to-write:`), und beide Oberflächen
sagen daraus einen Satz in der Sprache des Nutzers
(`shared/eventWriteError.ts`). Eine Marke heißt auch: Es wurde sicher
nichts geschrieben (`writeNeverLanded`); beim Ändern eines Termins melden
alle Adapter eine Ablehnung des Servers so (147). `CACHE_GENERATION` 4, damit eine
gespeicherte Kalenderliste ohne das neue Merkmal neu gelesen wird.

**Free/Busy-Abfrage (implementiert).** Im Termin-Dialog prüft „Verfügbarkeit
Expand Down
43 changes: 31 additions & 12 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -2409,22 +2409,41 @@ Siehe DESIGN §4.2.
„weg oder nie hier“, die Suche über die Kalender geht weiter und zählt
es nur als gelöscht, wenn kein anderer Kalender den Termin hat
(`DeleteWalk`). Nach einem gescheiterten Verbindungsaufbau gilt nichts
davon: da ging nichts hinaus.
davon: da ging nichts hinaus. Am Handy kommen nur `forbidden`,
`conflict` und `network` mit Code an (B9); eine Ablehnung mit Marke
(`server-refused`, `unsafe-to-write`) reist aber als `forbidden` und
wird erkannt, jede andere gilt dort vorsichtig als unklar. Handy ohne
Testläufer — ↻ im Test.
- 🚩 CalDAV `create_task_list`: MKCALENDAR läuft über `send_retrying`.
Kam der erste Versuch an und brach dann die Verbindung ab, antwortet die
Wiederholung 405 (die Liste gibt es schon): gemeldet als Fehler, ein
erneuter Versuch legt eine zweite Liste an. Vor #97 schon so.
- 🚩 **Sichere Ablehnungen als Ablehnung kennzeichnen** (147, eigener PR
direkt nach #97): EWS-Fehlerantworten, HTTP 400/429 bei Google und
Microsoft, CalDAV-Prüfungen „nothing was saved“ kommen als `protocol` an;
beim Teilen bleiben dann beide Serien mit Warnung stehen, obwohl sicher
nichts gekürzt wurde. Die Adapter sollen sie mit `server-refused`
kennzeichnen. Dazu Graph: nach erfolgreichem `/cancel` liefert das
folgende DELETE womöglich 404 (die Absage verschiebt das Ereignis), das
Löschen gilt dann als gescheitert. Am Handy kommen
nur `forbidden`, `conflict` und `network` mit Code an (B9), jede andere
Ablehnung gilt dort also vorsichtig als unklar. Handy ohne Testläufer —
↻ im Test.
- ✅ **Sichere Ablehnungen als Ablehnung gekennzeichnet** (147). EWS-
Fehlerantworten, HTTP 400/429 bei Google und Microsoft und CalDAVs eigene
Prüfungen „nothing was saved“ kamen als `protocol` an; beim Teilen
blieben dann beide Serien mit Warnung stehen, obwohl sicher nichts
gekürzt wurde. Jetzt meldet jeder Adapter beim Ändern eines Termins
(`update_event`) eine Ablehnung als solche: ein HTTP-Status, mit dem der
Server das Schreiben ganz abgelehnt hat
(`WriteRefusal::refused_status`: 400, 413, 415, 422, 429, 507), samt dem
Grund, den Google und Microsoft nennen, und bei EWS die bekannten Prüf-
und Drosselungsfehler (`REFUSED_UPDATE_CODES`, auch als SOAP-Fault mit
HTTP 500), als `server-refused`; jeder andere EWS-Code bleibt unklar,
weil eine Besprechung erst gespeichert und dann verschickt wird; die
Konfliktcodes auch, denn Aperio schreibt mit `AlwaysOverwrite`. CalDAVs
eigene Prüfungen kommen als neue Marke `unsafe-to-write` („Aperio kann
diesen Termin nicht sicher ändern“). `server-refused` und
`unsafe-to-write` reisen als `forbidden` und kommen so auch am Handy an.
Antwortet der Server auf eine CalDAV-Wiederholung mit einem Fehler,
bleibt es unklar (`network`, die Antwort steht im Protokoll): der erste
Versuch kann angekommen sein. Scheitert bei Google oder Microsoft die
Erneuerung des Tokens (400/401 am Token-Endpunkt), ist es ein
Anmeldefehler (`auth`), keine Ablehnung des Kalenderservers; am Handy
kommt er ohne Code an und bleibt dort unklar. Graph: ein DELETE, das nach erfolgreichem `/cancel`
nichts mehr findet, gilt als erledigt (die Absage verschiebt das
Ereignis nach „Gelöschte Elemente“, mit neuer Id). Nicht hier: Anlegen
und Löschen melden solche Ablehnungen weiter als `protocol` (für das
Teilen zählt nur das Kürzen).
- 🚩 **Weckerberechnung bei einem Ende vor dem Beginn** (124: jetzt nicht).
`expand_on` nimmt bei einer Regel, die vor ihrem Beginn endet, weiter den
Serienbeginn. Aperio schreibt solche Regeln nicht mehr, aber ein Enddatum,
Expand Down
124 changes: 106 additions & 18 deletions crates/adapter-caldav/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,20 +331,32 @@ async fn update_master(
.map_err(|e| CaldavError::Config(format!("event.calendar_id is not a URL: {e}")))?;
let resource = resource_url_for_event(&cal_url, &event.id)?;
let (body, server_etag) = get_resource(client, &resource, credentials).await?;
let refused =
|why: &str| CaldavError::Protocol(format!("{}: {why}; nothing was saved", event.id));
// Nothing is written, and the caller must know it for certain: splitting a
// series takes its new part back only after a refusal like this one
// (decision 144), so it travels as one, not as a protocol error.
let refused = |token: &str, why: &str| {
tracing::warn!(event_id = %event.id, why, "refusing to write the event; nothing was saved");
CaldavError::Forbidden(WriteRefusal::UnsafeToWrite.message(token))
};
let own = ctx.identity.clone().unwrap_or_default();
let blocks = vevent_blocks(&body, cal_url.as_str(), &own)
.ok_or_else(|| refused("its resource cannot be read block by block"))?;
let blocks = vevent_blocks(&body, cal_url.as_str(), &own).ok_or_else(|| {
refused(
"unreadable-blocks",
"its resource cannot be read block by block",
)
})?;
let (_, uid) = decode_event_id(&event.id);
let master = blocks
.iter()
.find(|b| {
override_recurrence_id(&b.event.id).is_none() && decode_event_id(&b.event.id).1 == uid
})
.ok_or_else(|| refused("its resource holds no such event"))?;
.ok_or_else(|| refused("no-such-event", "its resource holds no such event"))?;
if !one_organizer(&body, &blocks) {
return Err(refused("its components name different organizers"));
return Err(refused(
"mixed-organizers",
"its components name different organizers",
));
}
if ctx.schedules && attendee_copy(&body[master.range.clone()], ctx)? {
return write_attendee_copy(
Expand Down Expand Up @@ -381,7 +393,12 @@ async fn update_master(
|rid| cutoff.is_none_or(|until| rid <= until),
|block| apply_to_block(block, &plan.change),
)
.ok_or_else(|| refused("its resource cannot be read block by block"))?;
.ok_or_else(|| {
refused(
"unreadable-blocks",
"its resource cannot be read block by block",
)
})?;
let if_match = event.etag.as_deref().or(server_etag.as_deref());
let new_etag = put_resource(client, &resource, new_body, if_match, credentials).await?;

Expand Down Expand Up @@ -793,18 +810,27 @@ async fn put_resource(
.body(body)
.send_retrying_marked()
.await?;
// A 412 on the replay of a guarded write: the connection died after the
// first PUT went out, and that one may have landed — its new ETag is what
// refuses the replay. Read as a refusal, a caller would undo around a write
// that went through: splitting a series deleted its new part while the old
// part was already cut short (decision 144). So it is what it is, unsure.
if first_may_have_landed
&& if_match.is_some()
&& response.status() == StatusCode::PRECONDITION_FAILED
{
// Any refusal of a replay whose first attempt may have landed: the
// connection died after the first PUT went out, and the answer may be
// about that one — a 412 because its new ETag refuses the replay, a 429 or
// a 507 because it was taken. Read as a refusal, a caller would undo
// around a write that went through: splitting a series deleted its new
// part while the old part was already cut short (decision 144). So it is
// what it is, unsure.
if first_may_have_landed && !response.status().is_success() {
// The answer itself is kept for the log: it may be the first
// attempt's, or a refusal the replay met on its own.
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
tracing::warn!(
%resource,
status,
body = %body.chars().take(200).collect::<String>(),
"a replayed PUT was answered with a failure; the first attempt may have been saved",
);
return Err(CaldavError::Network(format!(
"the connection to '{resource}' broke after the change was sent; \
it may have been saved"
"the connection to '{resource}' broke after the change was sent \
(the replay was answered HTTP {status}); it may have been saved"
)));
}
check_write(response).await
Expand Down Expand Up @@ -1852,6 +1878,30 @@ END:VCALENDAR</c:calendar-data>
assert!(matches!(err, CaldavError::Network(_)), "{err:?}");
}

/// ...and so is any other refusal of that replay: a 429 may be the server
/// throttling a second copy of what it already stored.
#[tokio::test]
async fn any_refusal_of_a_replayed_put_is_unsure() {
let base =
first_attempt_lost_then(b"HTTP/1.1 429 Too Many Requests\r\ncontent-length: 0\r\n\r\n")
.await;
let resource = Url::parse(&format!("{base}/calendars/alice/work/abc.ics")).unwrap();
let err = put_resource(
&client(),
&resource,
standup_body("Cut short"),
None,
&creds(&base),
)
.await
.expect_err("the replay was refused");
match err {
// The replay's answer is kept, for whoever reads the error.
CaldavError::Network(msg) => assert!(msg.contains("HTTP 429"), "{msg}"),
other => panic!("expected unsure, got {other:?}"),
}
}

/// Without a replay, a 412 is what it says: the copy moved on.
#[tokio::test]
async fn a_412_without_a_replay_is_a_conflict() {
Expand Down Expand Up @@ -2206,6 +2256,44 @@ END:VCALENDAR\r
}
}

/// A resource whose blocks name different organizers is not written: a
/// refusal of Aperio's own, said as one (decision 144), not a protocol
/// error the caller has to treat as maybe written.
#[tokio::test]
async fn an_update_aperio_will_not_write_is_a_refusal() {
let mut server = Server::new_async().await;
let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\
BEGIN:VEVENT\r\nUID:abc-123@aperio\r\nDTSTAMP:20260520T060000Z\r\nSUMMARY:Standup\r\n\
DTSTART:20260520T080000Z\r\nDTEND:20260520T083000Z\r\nRRULE:FREQ=DAILY\r\n\
ORGANIZER:mailto:alice@example.org\r\nEND:VEVENT\r\n\
BEGIN:VEVENT\r\nUID:abc-123@aperio\r\nDTSTAMP:20260520T060000Z\r\nSUMMARY:Standup\r\n\
RECURRENCE-ID:20260521T080000Z\r\nDTSTART:20260521T090000Z\r\nDTEND:20260521T093000Z\r\n\
ORGANIZER:mailto:bob@example.org\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
let _get = serve_copy(&mut server, body.to_string()).await;
let put = server
.mock(
"PUT",
mockito::Matcher::Regex(r"^/calendars/alice/work/.+\.ics$".into()),
)
.expect(0)
.create_async()
.await;
let cal_url = Url::parse(&format!("{}/calendars/alice/work/", server.url())).unwrap();
let err = update_event(
&client(),
sample_existing_event(&cal_url),
&creds(&server.url()),
&WriteCtx::default(),
)
.await
.unwrap_err();
match err {
CaldavError::Forbidden(msg) => assert_eq!(msg, "unsafe-to-write: mixed-organizers"),
other => panic!("expected a refusal, got {other:?}"),
}
put.assert_async().await;
}

#[tokio::test]
async fn update_event_412_surfaces_as_conflict() {
let mut server = Server::new_async().await;
Expand Down
52 changes: 51 additions & 1 deletion crates/adapter-caldav/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,23 @@ impl CaldavAdapter {
}
}

/// [`to_core_error`] for an update. A status with which the server turned the
/// write down whole ([`cal_core::WriteRefusal::refused_status`]) is a refusal,
/// not a protocol error: a caller deciding whether the write may have landed —
/// splitting a series undoes its new part only when the cut certainly did not
/// (decision 144) — must be told that nothing was written.
fn to_update_error(err: CaldavError) -> CoreError {
match err {
CaldavError::Http { status, message } if cal_core::WriteRefusal::refused_status(status) => {
tracing::warn!(status, %message, "the server refused the update");
CoreError::Forbidden(
cal_core::WriteRefusal::ServerRefused.message(&format!("HTTP {status}")),
)
}
other => to_core_error(other),
}
}

/// Translate a CalDAV-specific error into the shared `cal_core::Error`
/// shape so the rest of the app can pattern-match it uniformly.
fn to_core_error(err: CaldavError) -> CoreError {
Expand Down Expand Up @@ -1091,7 +1108,7 @@ impl CalendarFeature for CaldavAdapter {
let ctx = self.write_ctx().await?;
events::update_event(&self.http, event, &self.credentials, &ctx)
.await
.map_err(to_core_error)
.map_err(to_update_error)
}

async fn add_event_exdate(
Expand Down Expand Up @@ -1693,6 +1710,39 @@ fn contact_matches(c: &Contact, needle_lower: &str) -> bool {
.any(|e| e.value.to_lowercase().contains(needle_lower))
}

#[cfg(test)]
mod update_refusal_tests {
use super::*;

/// A status with which the server turned the update down whole is a
/// refusal (decision 144); a server failure and the named statuses keep
/// their error.
#[test]
fn an_update_the_server_turned_down_is_a_refusal() {
for status in [400, 413, 415, 422, 429, 507] {
match to_update_error(CaldavError::Http {
status,
message: "no".into(),
}) {
CoreError::Forbidden(msg) => {
assert_eq!(msg, format!("server-refused: HTTP {status}"))
}
other => panic!("{status}: {other:?}"),
}
}
let http = |status| CaldavError::Http {
status,
message: "no".into(),
};
assert!(matches!(to_update_error(http(500)), CoreError::Protocol(_)));
assert!(matches!(to_update_error(http(412)), CoreError::Conflict(_)));
assert!(matches!(
to_update_error(CaldavError::Network("reset".into())),
CoreError::Network(_)
));
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading
Loading