CalDAV: Allow organized properties to be present in PUT requests if they are equal to the existing ones

This commit is contained in:
Maurus Decimus
2026-04-18 16:56:35 +02:00
parent 11c58120cd
commit 7209c5fc06
4 changed files with 233 additions and 42 deletions

View File

@@ -5,15 +5,15 @@
*/ */
use crate::scheduling::{ use crate::scheduling::{
Email, InstanceId, ItipEntryValue, ItipError, ItipMessage, ItipSnapshot, ItipSnapshots, Email, InstanceId, ItipEntry, ItipEntryValue, ItipError, ItipMessage, ItipSnapshot,
ItipSummary, ItipSnapshots, ItipSummary,
itip::{ itip::{
ItipExportAs, can_attendee_modify_property, itip_add_tz, itip_build_envelope, ItipExportAs, can_attendee_modify_property, itip_add_tz, itip_build_envelope,
itip_export_component, itip_export_component,
}, },
organizer::organizer_request_full, organizer::organizer_request_full,
}; };
use ahash::AHashSet; use ahash::{AHashMap, AHashSet};
use calcard::{ use calcard::{
common::PartialDateTime, common::PartialDateTime,
icalendar::{ icalendar::{
@@ -46,6 +46,11 @@ pub(crate) fn attendee_handle_update(
(Some(local_attendee), Some(old_local_attendee)) (Some(local_attendee), Some(old_local_attendee))
if local_attendee.email == old_local_attendee.email => if local_attendee.email == old_local_attendee.email =>
{ {
// Distinguish a genuine add/remove of a restricted property from a value-only drift
// caused by a client re-encoding the same property
let old_name_counts = count_entry_names(&old_instance.entries);
let new_name_counts = count_entry_names(&instance.entries);
// Check added fields // Check added fields
let mut send_update = false; let mut send_update = false;
for new_entry in instance.entries.difference(&old_instance.entries) { for new_entry in instance.entries.difference(&old_instance.entries) {
@@ -80,9 +85,13 @@ pub(crate) fn attendee_handle_update(
&instance.comp.component_type, &instance.comp.component_type,
new_entry.name, new_entry.name,
) { ) {
return Err(ItipError::CannotModifyProperty( if name_count(&new_name_counts, new_entry.name)
new_entry.name.clone(), > name_count(&old_name_counts, new_entry.name)
)); {
return Err(ItipError::CannotModifyProperty(
new_entry.name.clone(),
));
}
} else { } else {
send_update = send_update send_update = send_update
|| (instance.comp.component_type || (instance.comp.component_type
@@ -149,7 +158,9 @@ pub(crate) fn attendee_handle_update(
if !can_attendee_modify_property( if !can_attendee_modify_property(
&instance.comp.component_type, &instance.comp.component_type,
removed_entry.name, removed_entry.name,
) { ) && name_count(&old_name_counts, removed_entry.name)
> name_count(&new_name_counts, removed_entry.name)
{
// Removing these properties is not allowed // Removing these properties is not allowed
return Err(ItipError::CannotModifyProperty( return Err(ItipError::CannotModifyProperty(
removed_entry.name.clone(), removed_entry.name.clone(),
@@ -351,3 +362,18 @@ pub(crate) fn attendee_decline<'x>(
(cancel_comp, &local_attendee.email) (cancel_comp, &local_attendee.email)
}) })
} }
fn count_entry_names<'a>(
entries: &'a AHashSet<ItipEntry<'a>>,
) -> AHashMap<&'a ICalendarProperty, usize> {
let mut counts = AHashMap::with_capacity(entries.len());
for entry in entries {
*counts.entry(entry.name).or_insert(0) += 1;
}
counts
}
#[inline]
fn name_count(counts: &AHashMap<&ICalendarProperty, usize>, name: &ICalendarProperty) -> usize {
counts.get(name).copied().unwrap_or(0)
}

View File

@@ -19,6 +19,7 @@ use registry::{
types::{EnumImpl, datetime::UTCDateTime}, types::{EnumImpl, datetime::UTCDateTime},
}; };
use std::{ use std::{
borrow::Cow,
fs::{self, File}, fs::{self, File},
io::{self, BufRead, BufReader, Read, Seek, SeekFrom}, io::{self, BufRead, BufReader, Read, Seek, SeekFrom},
path::Path, path::Path,
@@ -267,6 +268,7 @@ fn read_log_entries(
} }
fn log_from_line(line: &str) -> Option<Log> { fn log_from_line(line: &str) -> Option<Log> {
let line = strip_ansi(line);
let (timestamp, rest) = line.split_once(' ')?; let (timestamp, rest) = line.split_once(' ')?;
let timestamp = DateTime::parse_from_rfc3339(timestamp).ok()?; let timestamp = DateTime::parse_from_rfc3339(timestamp).ok()?;
let (level, rest) = rest.trim().split_once(' ')?; let (level, rest) = rest.trim().split_once(' ')?;
@@ -281,6 +283,43 @@ fn log_from_line(line: &str) -> Option<Log> {
}) })
} }
fn strip_ansi(line: &str) -> Cow<'_, str> {
if !line.contains('\x1b') {
return Cow::Borrowed(line);
}
let mut out = String::with_capacity(line.len());
let mut chars = line.chars();
while let Some(c) = chars.next() {
if c != '\x1b' {
out.push(c);
continue;
}
match chars.next() {
Some('[') => {
for c in chars.by_ref() {
if matches!(c as u32, 0x40..=0x7e) {
break;
}
}
}
Some(']') => {
while let Some(c) = chars.next() {
if c == '\x07' {
break;
}
if c == '\x1b' {
chars.next();
break;
}
}
}
_ => {}
}
}
Cow::Owned(out)
}
/* /*
* SPDX-FileCopyrightText: 2017 Michael Coyne <mjc@hey.com> * SPDX-FileCopyrightText: 2017 Michael Coyne <mjc@hey.com>
* *

View File

@@ -62,17 +62,7 @@ pub trait SieveScriptSet: Sync + Send {
update: Option<(u32, Archive<&'x ArchivedSieveScript>)>, update: Option<(u32, Archive<&'x ArchivedSieveScript>)>,
ctx: &SetContext, ctx: &SetContext,
session_id: u64, session_id: u64,
) -> impl Future< ) -> impl Future<Output = trc::Result<Result<SetItemResponse<'x>, SetError<SieveProperty>>>> + Send;
Output = trc::Result<
Result<
(
ObjectIndexBuilder<&'x ArchivedSieveScript, SieveScript>,
Option<Vec<u8>>,
),
SetError<SieveProperty>,
>,
>,
> + Send;
} }
impl SieveScriptSet for Server { impl SieveScriptSet for Server {
@@ -112,6 +102,7 @@ impl SieveScriptSet for Server {
// Process creates // Process creates
let mut batch = BatchBuilder::new(); let mut batch = BatchBuilder::new();
let mut activations = Vec::new();
for (id, object) in request.unwrap_create() { for (id, object) in request.unwrap_create() {
if sieve_ids.len() if sieve_ids.len()
< self.object_quota(account.object_quotas(), StorageQuota::MaxSieveScripts) as u64 < self.object_quota(account.object_quotas(), StorageQuota::MaxSieveScripts) as u64
@@ -120,11 +111,16 @@ impl SieveScriptSet for Server {
.sieve_set_item(object, None, &ctx, session.session_id) .sieve_set_item(object, None, &ctx, session.session_id)
.await? .await?
{ {
Ok((mut builder, Some(blob))) => { Ok(mut result) => {
// Store blob // Store blob
let sieve = &mut builder.changes_mut().unwrap(); let sieve = &mut result.builder.changes_mut().unwrap();
let (blob_hash, blob_hold) = let (blob_hash, blob_hold) = self
self.put_temporary_blob(account_id, &blob, 60).await?; .put_temporary_blob(
account_id,
result.blob_update.as_ref().unwrap(),
60,
)
.await?;
sieve.blob_hash = blob_hash; sieve.blob_hash = blob_hash;
let blob_size = sieve.size as usize; let blob_size = sieve.size as usize;
let blob_hash = sieve.blob_hash.clone(); let blob_hash = sieve.blob_hash.clone();
@@ -139,11 +135,20 @@ impl SieveScriptSet for Server {
.with_account_id(account_id) .with_account_id(account_id)
.with_collection(Collection::SieveScript) .with_collection(Collection::SieveScript)
.with_document(document_id) .with_document(document_id)
.custom(builder.with_changed_by(ctx.access_token.account_tenant_ids())) .custom(
result
.builder
.with_changed_by(ctx.access_token.account_tenant_ids()),
)
.caused_by(trc::location!())? .caused_by(trc::location!())?
.clear(blob_hold) .clear(blob_hold)
.commit_point(); .commit_point();
// Set isActive if needed
if let Some(set_item) = result.set_item {
activations.push((document_id, set_item));
}
let mut result = Map::with_capacity(1) let mut result = Map::with_capacity(1)
.with_key_value(SieveProperty::Id, SieveValue::Id(document_id.into())) .with_key_value(SieveProperty::Id, SieveValue::Id(document_id.into()))
.with_key_value( .with_key_value(
@@ -179,7 +184,6 @@ impl SieveScriptSet for Server {
Err(err) => { Err(err) => {
ctx.response.not_created.append(id, err); ctx.response.not_created.append(id, err);
} }
_ => unreachable!(),
} }
} else { } else {
ctx.response.not_created.append( ctx.response.not_created.append(
@@ -226,16 +230,16 @@ impl SieveScriptSet for Server {
) )
.await? .await?
{ {
Ok((mut builder, blob)) => { Ok(mut result) => {
// Prepare write batch // Prepare write batch
batch batch
.with_account_id(account_id) .with_account_id(account_id)
.with_collection(Collection::SieveScript) .with_collection(Collection::SieveScript)
.with_document(document_id); .with_document(document_id);
let blob_id = if let Some(blob) = blob { let blob_id = if let Some(blob) = result.blob_update.take() {
// Store blob // Store blob
let sieve = &mut builder.changes_mut().unwrap(); let sieve = &mut result.builder.changes_mut().unwrap();
let (blob_hash, blob_hold) = let (blob_hash, blob_hold) =
self.put_temporary_blob(account_id, &blob, 60).await?; self.put_temporary_blob(account_id, &blob, 60).await?;
sieve.blob_hash = blob_hash; sieve.blob_hash = blob_hash;
@@ -259,9 +263,18 @@ impl SieveScriptSet for Server {
None None
}; };
// Set isActive if needed
if let Some(set_item) = result.set_item {
activations.push((document_id, set_item));
}
// Write record // Write record
batch batch
.custom(builder.with_changed_by(ctx.access_token.account_tenant_ids())) .custom(
result
.builder
.with_changed_by(ctx.access_token.account_tenant_ids()),
)
.caused_by(trc::location!())? .caused_by(trc::location!())?
.commit_point(); .commit_point();
@@ -328,11 +341,25 @@ impl SieveScriptSet for Server {
} }
} }
// Activate / deactivate scripts // Non-standard script activation handling
let on_success_deactivate_script = request let mut on_success_deactivate_script = request
.arguments .arguments
.on_success_deactivate_script .on_success_deactivate_script
.unwrap_or(false); .unwrap_or(false);
if activations.len() == 1 {
let (document_id, set_item) = activations[0];
let is_active = active_script_id.is_some_and(|active_id| active_id == document_id);
if set_item {
if request.arguments.on_success_activate_script.is_none() && !is_active {
request.arguments.on_success_activate_script =
Some(MaybeIdReference::Id(document_id.into()));
}
} else if !on_success_deactivate_script && is_active {
on_success_deactivate_script = true;
}
}
// Activate / deactivate scripts
if ctx.response.not_created.is_empty() if ctx.response.not_created.is_empty()
&& ctx.response.not_updated.is_empty() && ctx.response.not_updated.is_empty()
&& ctx.response.not_destroyed.is_empty() && ctx.response.not_destroyed.is_empty()
@@ -375,15 +402,7 @@ impl SieveScriptSet for Server {
update: Option<(u32, Archive<&'x ArchivedSieveScript>)>, update: Option<(u32, Archive<&'x ArchivedSieveScript>)>,
ctx: &SetContext<'_>, ctx: &SetContext<'_>,
session_id: u64, session_id: u64,
) -> trc::Result< ) -> trc::Result<Result<SetItemResponse<'x>, SetError<SieveProperty>>> {
Result<
(
ObjectIndexBuilder<&'x ArchivedSieveScript, SieveScript>,
Option<Vec<u8>>,
),
SetError<SieveProperty>,
>,
> {
// Vacation script cannot be modified // Vacation script cannot be modified
if update if update
.as_ref() .as_ref()
@@ -396,6 +415,7 @@ impl SieveScriptSet for Server {
} }
// Parse properties // Parse properties
let mut set_item = None;
let mut changes = update let mut changes = update
.as_ref() .as_ref()
.map(|(_, obj)| obj.deserialize().unwrap_or_default()) .map(|(_, obj)| obj.deserialize().unwrap_or_default())
@@ -450,6 +470,10 @@ impl SieveScriptSet for Server {
(Key::Property(SieveProperty::Name), Value::Null) => { (Key::Property(SieveProperty::Name), Value::Null) => {
continue; continue;
} }
(Key::Property(SieveProperty::IsActive), Value::Bool(value)) => {
set_item = Some(value);
continue;
}
_ => { _ => {
return Ok(Err(SetError::invalid_properties() return Ok(Err(SetError::invalid_properties()
.with_property(property.into_owned()) .with_property(property.into_owned())
@@ -528,11 +552,18 @@ impl SieveScriptSet for Server {
}; };
// Validate // Validate
Ok(Ok(( Ok(Ok(SetItemResponse {
ObjectIndexBuilder::new() builder: ObjectIndexBuilder::new()
.with_changes(changes) .with_changes(changes)
.with_current_opt(update.map(|(_, current)| current)), .with_current_opt(update.map(|(_, current)| current)),
blob_update, blob_update,
))) set_item,
}))
} }
} }
pub struct SetItemResponse<'x> {
builder: ObjectIndexBuilder<&'x ArchivedSieveScript, SieveScript>,
blob_update: Option<Vec<u8>>,
set_item: Option<bool>,
}

View File

@@ -890,3 +890,98 @@ SEQUENCE:3
END:VEVENT END:VEVENT
END:VCALENDAR END:VCALENDAR
# Attendee decline of a recurring event tolerates client-side RRULE drift
> put a@example.com rrule-drift@example.com
BEGIN:VCALENDAR
PRODID:-//Example/ExampleCalendarClient//EN
VERSION:2.0
BEGIN:VEVENT
UID:rrule-drift@example.com
SEQUENCE:0
DTSTAMP:19970526T083000Z
DTSTART:19970603T210000Z
DTEND:19970603T220000Z
SUMMARY:Weekly Sync
RRULE:FREQ=WEEKLY;BYDAY=TU
ORGANIZER:mailto:a@example.com
ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:a@example.com
ATTENDEE;RSVP=TRUE:mailto:b@example.com
END:VEVENT
END:VCALENDAR
> expect
from: a@example.com
to: b@example.com
summary: invite
summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: None, is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 865371600, tz_id: 32768 })
summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count: None, interval: None, bysecond: [], byminute: [], byhour: [], byday: [ICalendarDay { ordwk: None, weekday: Tuesday }], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: None, rscale: None, skip: None })
summary.summary: Text("Weekly Sync")
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Weekly Sync
DTEND:19970603T220000Z
DTSTART:19970603T210000Z
ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:a@example.com
ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:b@example.com
ORGANIZER:mailto:a@example.com
UID:rrule-drift@example.com
RRULE:FREQ=WEEKLY;BYDAY=TU
DTSTAMP:0
SEQUENCE:1
END:VEVENT
END:VCALENDAR
# Deliver the REQUEST to B
> send
# B declines the master. Their client re-encoded the RRULE to include an explicit INTERVAL=1
> put b@example.com rrule-drift@example.com
BEGIN:VCALENDAR
PRODID:-//Example/ExampleCalendarClient//EN
VERSION:2.0
BEGIN:VEVENT
UID:rrule-drift@example.com
SEQUENCE:0
DTSTAMP:19970526T090000Z
DTSTART:19970603T210000Z
DTEND:19970603T220000Z
SUMMARY:Weekly Sync
RRULE:FREQ=WEEKLY;BYDAY=TU;INTERVAL=1
ORGANIZER:mailto:a@example.com
ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:a@example.com
ATTENDEE;RSVP=TRUE;PARTSTAT=DECLINED:mailto:b@example.com
END:VEVENT
END:VCALENDAR
> expect
from: b@example.com
to: a@example.com
summary: rsvp DECLINED
summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: None, is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 865371600, tz_id: 32768 })
summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count: None, interval: Some(1), bysecond: [], byminute: [], byhour: [], byday: [ICalendarDay { ordwk: None, weekday: Tuesday }], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: None, rscale: None, skip: None })
summary.summary: Text("Weekly Sync")
BEGIN:VCALENDAR
METHOD:REPLY
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Weekly Sync
DTEND:19970603T220000Z
DTSTART:19970603T210000Z
ATTENDEE;RSVP=TRUE;PARTSTAT=DECLINED:mailto:b@example.com
ORGANIZER:mailto:a@example.com
UID:rrule-drift@example.com
DTSTAMP:0
SEQUENCE:0
REQUEST-STATUS:2.0;Success
END:VEVENT
END:VCALENDAR
# Deliver the REPLY to A
> send