Skip to content

Commit 6487bb3

Browse files
Olenclaude
andcommitted
fix(dav): handle content line folding in DTSTAMP regex
Update regex to match DTSTAMP with parameters (DTSTAMP;TZID=...) and handle RFC 5545 content line folding where long lines are split with CRLF followed by a space or tab. Signed-off-by: Olen <regopa@gmail.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: olen <ola@nytt.no>
1 parent 36fdb9a commit 6487bb3

File tree

2 files changed

+70
-3
lines changed

2 files changed

+70
-3
lines changed

apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ public function refreshSubscription(string $principalUri, string $uri) {
117117
// to the current time on every feed request per RFC 5545, causing
118118
// every event to appear modified on every refresh.
119119
// DTSTAMP is kept in the stored data as it is a required property.
120-
$sObjectForEtag = preg_replace('/^DTSTAMP:.*\r?\n/m', '', $sObject);
120+
$sObjectForEtag = preg_replace('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m', '', $sObject);
121121
$etag = md5($sObjectForEtag);
122122

123123
// No existing object with this UID, create it

apps/dav/tests/unit/CalDAV/WebcalCaching/RefreshWebcalServiceTest.php

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ public function testDtstampChangeDoesNotTriggerUpdate(): void {
493493
->willReturn(['data' => $stream, 'format' => 'ical']);
494494

495495
// The stored etag was computed from the DTSTAMP-stripped serialization
496-
$existingEtag = md5(preg_replace('/^DTSTAMP:.*\r?\n/m', '', $body));
496+
$existingEtag = md5(preg_replace('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m', '', $body));
497497

498498
$this->caldavBackend->expects(self::once())
499499
->method('getLimitedCalendarObjects')
@@ -525,10 +525,77 @@ public function testDtstampChangeDoesNotTriggerUpdate(): void {
525525
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
526526
}
527527

528+
public function testFoldedDtstampChangeDoesNotTriggerUpdate(): void {
529+
$refreshWebcalService = new RefreshWebcalService(
530+
$this->caldavBackend,
531+
$this->logger,
532+
$this->connection,
533+
$this->timeFactory,
534+
$this->importService
535+
);
536+
537+
$this->caldavBackend->expects(self::once())
538+
->method('getSubscriptionsForUser')
539+
->with('principals/users/testuser')
540+
->willReturn([
541+
[
542+
'id' => '42',
543+
'uri' => 'sub123',
544+
RefreshWebcalService::STRIP_TODOS => '1',
545+
RefreshWebcalService::STRIP_ALARMS => '1',
546+
RefreshWebcalService::STRIP_ATTACHMENTS => '1',
547+
'source' => 'webcal://foo.bar/bla2',
548+
'lastmodified' => 0,
549+
],
550+
]);
551+
552+
// DTSTAMP with TZID parameter exceeds 75 bytes, triggering RFC 5545 content line folding
553+
$body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nUID:folded-dtstamp-test\r\nDTSTAMP;X-VOBJ-ORIGINAL-TZID=America/Argentina/Buenos_Aires:20260209T120000Z\r\nDTSTART:20260301T100000Z\r\nSUMMARY:Test Event\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
554+
$stream = $this->createStreamFromString($body);
555+
556+
$this->connection->expects(self::once())
557+
->method('queryWebcalFeed')
558+
->willReturn(['data' => $stream, 'format' => 'ical']);
559+
560+
// Compute etag from the serialized output (which will be folded) minus DTSTAMP
561+
$vCalForEtag = VObject\Reader::read($body);
562+
$serialized = $vCalForEtag->serialize();
563+
$existingEtag = md5(preg_replace('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m', '', $serialized));
564+
565+
$this->caldavBackend->expects(self::once())
566+
->method('getLimitedCalendarObjects')
567+
->willReturn([
568+
'folded-dtstamp-test' => [
569+
'id' => 1,
570+
'uid' => 'folded-dtstamp-test',
571+
'etag' => $existingEtag,
572+
'uri' => 'folded-dtstamp-test.ics',
573+
],
574+
]);
575+
576+
$vCalendar = VObject\Reader::read($body);
577+
$generator = function () use ($vCalendar) {
578+
yield $vCalendar;
579+
};
580+
581+
$this->importService->expects(self::once())
582+
->method('importText')
583+
->willReturn($generator());
584+
585+
// Folded DTSTAMP change must NOT trigger an update
586+
$this->caldavBackend->expects(self::never())
587+
->method('updateCalendarObject');
588+
589+
$this->caldavBackend->expects(self::never())
590+
->method('createCalendarObject');
591+
592+
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
593+
}
594+
528595
public static function identicalDataProvider(): array {
529596
$icalBody = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
530597
// Etag is computed from DTSTAMP-stripped serialization
531-
$etag = md5(preg_replace('/^DTSTAMP:.*\r?\n/m', '', $icalBody));
598+
$etag = md5(preg_replace('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m', '', $icalBody));
532599

533600
return [
534601
[

0 commit comments

Comments
 (0)