From 32752e41b312fea565ff7eb38d9ea3e1ca99ffd2 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Wed, 22 Jul 2026 08:09:44 +0200 Subject: [PATCH] feat(sync): evict ghost items via deferred synthetic deletions A mail deleted through another vector (IMAP client, webmail) may leave a "ghost" item on a client that fails to apply the Remove command sent at deletion time; iOS Mail then retries the SYNC_FETCH for the vanished item forever. Detect this case (Fetch fails with NotFound and the uid is untracked in the folder state), record the uid in the folder state, and export a synthetic deletion through the regular change pipeline on a later GetChanges Sync. The eviction Remove is deliberately NOT emitted in the same response as the Fetch reply: iOS maild crashes (SIGABRT assertion loop) when a Sync response combines a Fetch reply with a Remove command for the same item. --- .../ActiveSync/Connector/Exporter/Sync.php | 27 +++ lib/Horde/ActiveSync/Folder/Imap.php | 56 +++++ lib/Horde/ActiveSync/Request/Sync.php | 71 ++++++ lib/Horde/ActiveSync/State/Base.php | 93 ++++++++ .../Connector/Exporter/SyncTest.php | 101 +++++++++ test/unit/Horde/ActiveSync/ImapFolderTest.php | 39 ++++ .../ActiveSync/Request/SyncGhostFetchTest.php | 172 +++++++++++++++ .../StateTest/GhostUidEvictionTest.php | 203 ++++++++++++++++++ 8 files changed, 762 insertions(+) create mode 100644 test/unit/Horde/ActiveSync/Request/SyncGhostFetchTest.php create mode 100644 test/unit/Horde/ActiveSync/StateTest/GhostUidEvictionTest.php diff --git a/lib/Horde/ActiveSync/Connector/Exporter/Sync.php b/lib/Horde/ActiveSync/Connector/Exporter/Sync.php index b05430fa..ee3ac3df 100644 --- a/lib/Horde/ActiveSync/Connector/Exporter/Sync.php +++ b/lib/Horde/ActiveSync/Connector/Exporter/Sync.php @@ -18,6 +18,7 @@ * * @copyright 2009-2020 Horde LLC (http://www.horde.org) * @author Michael J Rubinsky + * @author Torben Dannhauer * @package ActiveSync */ class Horde_ActiveSync_Connector_Exporter_Sync extends Horde_ActiveSync_Connector_Exporter_Base @@ -29,6 +30,15 @@ class Horde_ActiveSync_Connector_Exporter_Sync extends Horde_ActiveSync_Connecto */ protected $_seenObjects = []; + /** + * Server ids of SYNC_FETCH requests that failed with a NotFound error + * during the last fetchIds() call. Used by the Sync handler to detect + * "ghost" items still present on the client but gone from the server. + * + * @var array + */ + protected $_failedFetchIds = []; + /** * Currently syncing collection. * @@ -303,10 +313,15 @@ public function missingRemove($collection) /** * Send the SYNC_FETCH response for any items requested by the client. * + * Fetch requests that fail because the item no longer exists are + * answered with STATUS_NOTFOUND and recorded; they can be retrieved + * with getFailedFetchIds() after this method returns. + * * @param array $collection The collection array. */ public function fetchIds($driver, $collection) { + $this->_failedFetchIds = []; foreach ($collection['fetchids'] as $fetch_id) { try { $data = $driver->fetch($collection['serverid'], $fetch_id, $collection); @@ -328,6 +343,7 @@ public function fetchIds($driver, $collection) $fetch_id ) ); + $this->_failedFetchIds[] = $fetch_id; $this->_encoder->startTag(Horde_ActiveSync::SYNC_FETCH); $this->_encoder->startTag(Horde_ActiveSync::SYNC_SERVERENTRYID); $this->_encoder->content($fetch_id); @@ -340,6 +356,17 @@ public function fetchIds($driver, $collection) } } + /** + * Return the server ids of Fetch requests that failed with a NotFound + * error during the last fetchIds() call. + * + * @return array The list of server ids. + */ + public function getFailedFetchIds() + { + return $this->_failedFetchIds; + } + /** * Send the SYNC_FOLDERTYPE node if needed. * diff --git a/lib/Horde/ActiveSync/Folder/Imap.php b/lib/Horde/ActiveSync/Folder/Imap.php index ac18c200..1236e562 100644 --- a/lib/Horde/ActiveSync/Folder/Imap.php +++ b/lib/Horde/ActiveSync/Folder/Imap.php @@ -17,6 +17,7 @@ * * @copyright 2012-2020 Horde LLC (http://www.horde.org) * @author Michael J Rubinsky + * @author Torben Dannhauer * @package ActiveSync */ class Horde_ActiveSync_Folder_Imap extends Horde_ActiveSync_Folder_Base implements Serializable @@ -116,6 +117,19 @@ class Horde_ActiveSync_Folder_Imap extends Horde_ActiveSync_Folder_Base implemen */ protected $_pingStatus = []; + /** + * UIDs of "ghost" items: mail the client still holds (and e.g. retries + * SYNC_FETCH for) although it no longer exists in the IMAP folder and is + * untracked in $_messages. The regular change diff engine can never + * delete such items again, so they are recorded here and exported as + * synthetic deletions through the normal change pipeline. + * + * @see Horde_ActiveSync_State_Base::getChanges() + * + * @var array + */ + protected $_ghostUids = []; + /** * Set message changes. * @@ -555,6 +569,44 @@ public function removed() return $this->_removed; } + /** + * Record "ghost" UIDs for deferred eviction from the client. + * + * @param array $uids The IMAP UIDs. + */ + public function addGhostUids(array $uids) + { + $this->_ghostUids = array_values( + array_unique(array_merge($this->_ghostUids, $uids)) + ); + } + + /** + * Return the recorded "ghost" UIDs awaiting eviction. + * + * @return array The IMAP UIDs. + */ + public function ghostUids() + { + return $this->_ghostUids; + } + + /** + * Forget recorded "ghost" UIDs, e.g. after the eviction deletion was + * exported to the client. + * + * @param array $uids The IMAP UIDs. + */ + public function removeGhostUids(array $uids) + { + if (!count($this->_ghostUids)) { + return; + } + $this->_ghostUids = array_values( + array_diff($this->_ghostUids, $uids) + ); + } + /** * Return the minimum IMAP UID contained in this folder. * @@ -601,6 +653,9 @@ public function __serialize(): array if (!empty($this->_pingStatus)) { $data['ps'] = $this->_pingStatus; } + if (!empty($this->_ghostUids)) { + $data['g'] = $this->_ghostUids; + } return $data; } @@ -627,6 +682,7 @@ public function __unserialize(array $data): void ? (bool) $data['hi'] : !empty($this->_messages); $this->_pingStatus = !empty($data['ps']) ? $data['ps'] : []; + $this->_ghostUids = !empty($data['g']) ? $data['g'] : []; if (!empty($this->_status[self::HIGHESTMODSEQ]) && is_string($this->_messages)) { $this->_messages = $this->_fromSequenceString($this->_messages); diff --git a/lib/Horde/ActiveSync/Request/Sync.php b/lib/Horde/ActiveSync/Request/Sync.php index 3733f885..71fdd82b 100644 --- a/lib/Horde/ActiveSync/Request/Sync.php +++ b/lib/Horde/ActiveSync/Request/Sync.php @@ -768,6 +768,22 @@ protected function _handle() // End SYNC_REPLIES $this->_encoder->endTag(); + + // Self-heal ghost items: a Fetch for a mail that is gone + // from IMAP and untracked in the folder state means the + // client still holds an item the server deleted long ago + // (e.g. removed via IMAP/webmail; some clients never act + // on the Fetch STATUS_NOTFOUND reply and retry forever). + // Record the ghost id in the folder state; the eviction + // deletion is exported through the regular change + // pipeline on a later GetChanges Sync. It MUST NOT be + // emitted in this response: iOS maild asserts (SIGABRT + // crash loop) when a Sync response combines a Fetch + // reply with a Remove command for the same item. + $ghostIds = $this->_getGhostFetchIds($exporter, $collection); + if (count($ghostIds)) { + $this->_state->addGhostUids($ghostIds); + } } // Save state @@ -929,6 +945,61 @@ protected function _isSyncTimeBudgetExceeded( && (microtime(true) - $syncOutputStart) >= $budget; } + /** + * Return the server ids of failed client Fetch requests that reference + * "ghost" items: mail that no longer exists in the IMAP folder and is + * unknown to the server's folder state. + * + * Such items were deleted through another vector (IMAP client, webmail) + * and the Remove command sent back then was not (fully) applied by the + * client, which now retries the Fetch indefinitely against an item the + * change diff engine will never delete again. The caller records these + * ids in the folder state for deferred eviction through the regular + * change pipeline. + * + * Items that failed to fetch but are still tracked in the folder state + * are NOT reported: their deletion (if any) is detected and sent by the + * regular change diff engine. + * + * @param Horde_ActiveSync_Connector_Exporter_Sync $exporter The + * exporter that handled the collection's Fetch requests. + * @param array $collection The collection array. + * + * @return array The list of ghost server ids to evict. + */ + protected function _getGhostFetchIds( + Horde_ActiveSync_Connector_Exporter_Sync $exporter, + array $collection + ) { + if (empty($collection['fetchids']) + || $collection['class'] != Horde_ActiveSync::CLASS_EMAIL) { + return []; + } + $failed = $exporter->getFailedFetchIds(); + if (!count($failed)) { + return []; + } + $folderState = $this->_state->getFolderState(); + if (!($folderState instanceof Horde_ActiveSync_Folder_Imap)) { + return []; + } + $known = $folderState->messages(); + $ghosts = []; + foreach ($failed as $fetch_id) { + if (in_array($fetch_id, $known)) { + continue; + } + $this->_logger->info(sprintf( + 'SYNC: Fetch for %s in %s failed and the item is untracked in folder state; recording ghost uid for deferred eviction from the client.', + $fetch_id, + $collection['id'] + )); + $ghosts[] = $fetch_id; + } + + return $ghosts; + } + /** * Import a single client-sent ADD or MODIFY command and record the * result in the collection array (replies, failures, atchash, ...). diff --git a/lib/Horde/ActiveSync/State/Base.php b/lib/Horde/ActiveSync/State/Base.php index 91760902..1f83c5fb 100644 --- a/lib/Horde/ActiveSync/State/Base.php +++ b/lib/Horde/ActiveSync/State/Base.php @@ -295,6 +295,32 @@ protected function _assertValidSyncFolderBeforeSave() } } + /** + * Return the folder state object loaded for the current collection. + * + * @return Horde_ActiveSync_Folder_Base|array|null The folder state, an + * empty array for FOLDERSYNC state, or null if no state loaded. + */ + public function getFolderState() + { + return $this->_folder ?? null; + } + + /** + * Record "ghost" UIDs in the loaded IMAP folder state for deferred + * eviction: the deletion is exported through the regular change pipeline + * on a later GetChanges SYNC (@see getChanges()). No-op for non-email + * folder state. + * + * @param array $uids The IMAP UIDs. + */ + public function addGhostUids(array $uids) + { + if ($this->_folder instanceof Horde_ActiveSync_Folder_Imap) { + $this->_folder->addGhostUids($uids); + } + } + /** * Update the $oldKey syncState to $newKey. * @@ -831,6 +857,12 @@ public function getChanges(array $options = []) $this->_logger->meta('STATE: No client changes, returning all messages.'); $this->_changes = $changes; } + + // Evict "ghost" items recorded in the folder state: deletions + // the diff engine can never produce again (@see addGhostUids()). + // Injected in PING mode too so the client is woken to pick up + // the eviction on its next SYNC. + $this->_injectGhostUidDeletions(); } else { // FOLDERSYNC changes. $this->_getFolderChanges(); @@ -1344,6 +1376,67 @@ protected function _acknowledgeExportedChange($type, array $change) $this->_folder->acknowledgeExportedMessage($change['id']); } break; + + case Horde_ActiveSync::CHANGE_TYPE_DELETE: + // A delivered deletion also settles any recorded ghost + // eviction for this uid (no-op for regular deletions). + if (!empty($change['id'])) { + $this->_folder->removeGhostUids([$change['id']]); + } + break; + } + } + + /** + * Append synthetic deletions for recorded "ghost" UIDs to the current + * change set. + * + * Ghost items exist only on the client (e.g. a deletion Remove was never + * applied and the client retries SYNC_FETCH for the item forever). They + * are untracked in the folder state, so the diff engine will never + * delete them again; exporting a synthetic deletion through the normal + * change pipeline evicts them. The recorded uid is cleared once the + * deletion is exported (@see _acknowledgeExportedChange()). + * + * @see Horde_ActiveSync_Folder_Imap::addGhostUids() + */ + protected function _injectGhostUidDeletions() + { + if (!$this->_folder instanceof Horde_ActiveSync_Folder_Imap) { + return; + } + $ghosts = $this->_folder->ghostUids(); + if (!count($ghosts)) { + return; + } + // Initial sync batches are bare uid lists; never mix change hashes + // into them. Ghosts are delivered once the structure is normalized. + if (count($this->_changes) && !is_array(reset($this->_changes))) { + return; + } + $pending = []; + foreach ($this->_changes as $change) { + $pending[$change['id']] = true; + } + $known = $this->_folder->messages(); + foreach ($ghosts as $uid) { + if (!empty($pending[$uid])) { + continue; + } + if (in_array($uid, $known)) { + // Tracked (again); the regular diff engine owns this uid. + $this->_folder->removeGhostUids([$uid]); + continue; + } + $this->_logger->info(sprintf( + 'STATE: Injecting deletion for ghost uid %s in %s to evict it from the client.', + $uid, + $this->_collection['id'] + )); + $this->_changes[] = [ + 'id' => $uid, + 'type' => Horde_ActiveSync::CHANGE_TYPE_DELETE, + ]; } } diff --git a/test/unit/Horde/ActiveSync/Connector/Exporter/SyncTest.php b/test/unit/Horde/ActiveSync/Connector/Exporter/SyncTest.php index e6accd19..64a9832c 100644 --- a/test/unit/Horde/ActiveSync/Connector/Exporter/SyncTest.php +++ b/test/unit/Horde/ActiveSync/Connector/Exporter/SyncTest.php @@ -68,6 +68,53 @@ public function testMissingRemoveReplyEmittedForEachMissingId() ); } + public function testFetchIdsRecordsNotFoundFailuresAndRepliesStatusNotFound() + { + $fixture = $this->_createExporter(Horde_ActiveSync::VERSION_FOURTEEN); + + $driver = $this->createMock(Horde_ActiveSync_Driver_Base::class); + $driver->method('fetch') + ->willThrowException(new \Horde_Exception_NotFound()); + + $collection = [ + 'serverid' => 'INBOX', + 'fetchids' => ['169864'], + ]; + $fixture->exporter->fetchIds($driver, $collection); + + $this->assertSame(['169864'], $fixture->exporter->getFailedFetchIds()); + + $replies = $this->_decodeFetchReplies($this->_readExporterOutput($fixture)); + $this->assertCount(1, $replies); + $this->assertSame('169864', $replies[0]['serverEntryId']); + $this->assertSame( + Horde_ActiveSync_Request_Sync::STATUS_NOTFOUND, + $replies[0]['status'] + ); + } + + public function testFetchIdsResetsFailureListOnSuccessfulFetch() + { + $fixture = $this->_createExporter(Horde_ActiveSync::VERSION_FOURTEEN); + + $failingDriver = $this->createMock(Horde_ActiveSync_Driver_Base::class); + $failingDriver->method('fetch') + ->willThrowException(new \Horde_Exception_NotFound()); + $collection = [ + 'serverid' => 'INBOX', + 'fetchids' => ['169864'], + ]; + $fixture->exporter->fetchIds($failingDriver, $collection); + $this->assertSame(['169864'], $fixture->exporter->getFailedFetchIds()); + + $message = $this->createMock(\Horde_ActiveSync_Message_Base::class); + $driver = $this->createMock(Horde_ActiveSync_Driver_Base::class); + $driver->method('fetch')->willReturn($message); + $fixture->exporter->fetchIds($driver, $collection); + + $this->assertSame([], $fixture->exporter->getFailedFetchIds()); + } + public function testSyncModifiedResponseIncludesServerEntryIdForEmailAttachmentChanges() { $collection = [ @@ -161,6 +208,9 @@ protected function _createExporter(string $clientVersion): object $versionRef = new ReflectionProperty(Horde_ActiveSync::class, '_version'); $versionRef->setAccessible(true); $versionRef->setValue(null, $clientVersion); + $loggerRef = new ReflectionProperty(Horde_ActiveSync::class, '_logger'); + $loggerRef->setAccessible(true); + $loggerRef->setValue(null, $logger); $exporter = new Horde_ActiveSync_Connector_Exporter_Sync($server, $encoder); @@ -232,6 +282,53 @@ protected function _decodeRemoveReplies(string $wbxml): array return $replies; } + /** + * @return list + */ + protected function _decodeFetchReplies(string $wbxml): array + { + $stream = fopen('php://memory', 'wb+'); + fwrite($stream, $wbxml); + rewind($stream); + + $decoder = new Horde_ActiveSync_Wbxml_Decoder($stream); + $decoder->readWbxmlHeader(); + + $replies = []; + while (($next = $decoder->peek()) !== false + && $next[Horde_ActiveSync_Wbxml::EN_TYPE] === Horde_ActiveSync_Wbxml::EN_TYPE_STARTTAG + && $next[Horde_ActiveSync_Wbxml::EN_TAG] === Horde_ActiveSync::SYNC_FETCH + ) { + $decoder->getElementStartTag(Horde_ActiveSync::SYNC_FETCH); + $reply = [ + 'serverEntryId' => null, + 'status' => null, + ]; + + while (($child = $decoder->peek()) !== false + && $child[Horde_ActiveSync_Wbxml::EN_TYPE] === Horde_ActiveSync_Wbxml::EN_TYPE_STARTTAG + ) { + $tag = $child[Horde_ActiveSync_Wbxml::EN_TAG]; + $decoder->getElementStartTag($tag); + $content = $decoder->getElementContent(); + $decoder->getElementEndTag(); + + if ($tag === Horde_ActiveSync::SYNC_SERVERENTRYID) { + $reply['serverEntryId'] = $content; + } elseif ($tag === Horde_ActiveSync::SYNC_STATUS) { + $reply['status'] = (int) $content; + } + } + + $decoder->getElementEndTag(); + $replies[] = $reply; + } + + fclose($stream); + + return $replies; + } + /** * @return list */ @@ -305,6 +402,10 @@ protected function tearDown(): void $versionRef->setAccessible(true); $versionRef->setValue(null, null); + $loggerRef = new ReflectionProperty(Horde_ActiveSync::class, '_logger'); + $loggerRef->setAccessible(true); + $loggerRef->setValue(null, null); + parent::tearDown(); } } diff --git a/test/unit/Horde/ActiveSync/ImapFolderTest.php b/test/unit/Horde/ActiveSync/ImapFolderTest.php index 86d5c6c2..9ecad272 100644 --- a/test/unit/Horde/ActiveSync/ImapFolderTest.php +++ b/test/unit/Horde/ActiveSync/ImapFolderTest.php @@ -379,5 +379,44 @@ public function testIncrementalModseqUpdateStillCompletesInitialSync() $this->assertEquals($msg_changes, $folder->messages()); } + public function testGhostUidTracking() + { + $folder = new Horde_ActiveSync_Folder_Imap('INBOX', Horde_ActiveSync::CLASS_EMAIL); + + $this->assertEquals([], $folder->ghostUids()); + + $folder->addGhostUids(['999']); + $folder->addGhostUids(['999', '1000']); + $this->assertEquals(['999', '1000'], $folder->ghostUids()); + + $folder->removeGhostUids(['999']); + $this->assertEquals(['1000'], $folder->ghostUids()); + + // Removing an unknown uid is a no-op. + $folder->removeGhostUids(['12345']); + $this->assertEquals(['1000'], $folder->ghostUids()); + } + + public function testGhostUidsSurviveSerialization() + { + $folder = new Horde_ActiveSync_Folder_Imap('INBOX', Horde_ActiveSync::CLASS_EMAIL); + $folder->setStatus([ + Horde_ActiveSync_Folder_Imap::UIDVALIDITY => 100, + Horde_ActiveSync_Folder_Imap::UIDNEXT => 105, + Horde_ActiveSync_Folder_Imap::HIGHESTMODSEQ => 200, + ]); + $folder->setChanges([100, 101]); + $folder->updateState(); + $folder->addGhostUids(['999']); + + $restored = unserialize(serialize($folder)); + $this->assertEquals(['999'], $restored->ghostUids()); + $this->assertEquals([100, 101], $restored->messages()); + // Serialized data without recorded ghosts unserializes to an + // empty list (BC with pre-ghost state rows). + $folder->removeGhostUids(['999']); + $restored = unserialize(serialize($folder)); + $this->assertEquals([], $restored->ghostUids()); + } } diff --git a/test/unit/Horde/ActiveSync/Request/SyncGhostFetchTest.php b/test/unit/Horde/ActiveSync/Request/SyncGhostFetchTest.php new file mode 100644 index 00000000..e3678ee7 --- /dev/null +++ b/test/unit/Horde/ActiveSync/Request/SyncGhostFetchTest.php @@ -0,0 +1,172 @@ + + * @license http://www.horde.org/licenses/gpl GPLv2 + * @copyright 2026 The Horde Project + * @package ActiveSync + */ + +namespace Horde\ActiveSync; + +use Horde_ActiveSync; +use Horde_ActiveSync_Connector_Exporter_Sync; +use Horde_ActiveSync_Folder_Imap; +use Horde_ActiveSync_Log_Logger; +use Horde_ActiveSync_Request_Sync; +use Horde_ActiveSync_State_Base; +use Horde_Log_Handler_Null; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use ReflectionClass; + +#[CoversClass(Horde_ActiveSync_Request_Sync::class)] +class SyncGhostFetchTest extends TestCase +{ + public function testGhostFetchIdDetectedWhenUntrackedInFolderState() + { + $sync = $this->_syncRequest([100, 101, 102], ['999']); + + $this->assertSame( + ['999'], + $this->_getGhostFetchIds($sync, $this->_emailCollection(['999'])) + ); + } + + public function testFailedFetchOfTrackedIdIsNotEvicted() + { + // Item known to the folder state: the regular change diff engine is + // responsible for its deletion (or the failure was transient). + // Loose comparison must match the client's string id against the + // integer uid in state. + $sync = $this->_syncRequest([100, 101, 102], ['102']); + + $this->assertSame( + [], + $this->_getGhostFetchIds($sync, $this->_emailCollection(['102'])) + ); + } + + public function testMixedFailuresOnlyEvictUntrackedIds() + { + $sync = $this->_syncRequest([100, 101, 102], ['102', '999']); + + $this->assertSame( + ['999'], + $this->_getGhostFetchIds($sync, $this->_emailCollection(['102', '999'])) + ); + } + + public function testNoEvictionForNonEmailCollections() + { + $sync = $this->_syncRequest([100, 101, 102], ['999']); + + $collection = $this->_emailCollection(['999']); + $collection['class'] = Horde_ActiveSync::CLASS_CALENDAR; + + $this->assertSame([], $this->_getGhostFetchIds($sync, $collection)); + } + + public function testNoEvictionWithoutImapFolderState() + { + $sync = $this->_syncRequest([], ['999'], false); + + $this->assertSame( + [], + $this->_getGhostFetchIds($sync, $this->_emailCollection(['999'])) + ); + } + + /** + * Build a Sync request handler with mocked state and exporter. + * + * @param array $stateUids Uids tracked in the IMAP folder state. + * @param array $failedIds Fetch ids that failed with NotFound. + * @param boolean $imapState Provide an IMAP folder state object. + */ + protected function _syncRequest( + array $stateUids, + array $failedIds, + $imapState = true + ) { + $ref = new ReflectionClass(Horde_ActiveSync_Request_Sync::class); + $sync = $ref->newInstanceWithoutConstructor(); + + if ($imapState) { + $folder = new Horde_ActiveSync_Folder_Imap( + 'INBOX', + Horde_ActiveSync::CLASS_EMAIL + ); + if (count($stateUids)) { + $flags = []; + foreach ($stateUids as $uid) { + $flags[$uid] = ['read' => 0, 'flagged' => 0]; + } + $folder->setChanges($stateUids, $flags); + $folder->setStatus([ + Horde_ActiveSync_Folder_Imap::UIDVALIDITY => 100, + Horde_ActiveSync_Folder_Imap::UIDNEXT => max($stateUids) + 1, + ]); + $folder->updateState(); + } + $folderState = $folder; + } else { + // FOLDERSYNC-style state. + $folderState = []; + } + + $state = $this->createMock(Horde_ActiveSync_State_Base::class); + $state->method('getFolderState')->willReturn($folderState); + + $exporter = $this->createMock(Horde_ActiveSync_Connector_Exporter_Sync::class); + $exporter->method('getFailedFetchIds')->willReturn($failedIds); + + foreach ([ + '_state' => $state, + '_logger' => new Horde_ActiveSync_Log_Logger(new Horde_Log_Handler_Null()), + ] as $property => $value) { + $prop = $ref->getProperty($property); + $prop->setAccessible(true); + $prop->setValue($sync, $value); + } + + return (object) [ + 'request' => $sync, + 'exporter' => $exporter, + ]; + } + + protected function _emailCollection(array $fetchids) + { + return [ + 'id' => 'F1', + 'class' => Horde_ActiveSync::CLASS_EMAIL, + 'fetchids' => $fetchids, + ]; + } + + protected function _getGhostFetchIds(object $fixture, array $collection) + { + $ref = new ReflectionClass($fixture->request); + $method = $ref->getMethod('_getGhostFetchIds'); + $method->setAccessible(true); + + return $method->invoke( + $fixture->request, + $fixture->exporter, + $collection + ); + } +} diff --git a/test/unit/Horde/ActiveSync/StateTest/GhostUidEvictionTest.php b/test/unit/Horde/ActiveSync/StateTest/GhostUidEvictionTest.php new file mode 100644 index 00000000..ab1f27be --- /dev/null +++ b/test/unit/Horde/ActiveSync/StateTest/GhostUidEvictionTest.php @@ -0,0 +1,203 @@ + + * @license http://www.horde.org/licenses/gpl GPLv2 + * @copyright 2026 The Horde Project + * @package ActiveSync + */ + +namespace Horde\ActiveSync\StateTest; + +use Horde_ActiveSync; +use Horde_ActiveSync_Folder_Imap; +use Horde_ActiveSync_State_Base; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use ReflectionClass; + +#[CoversClass(Horde_ActiveSync_State_Base::class)] +class GhostUidEvictionTest extends TestCase +{ + public function testGhostUidInjectedAsDeletion() + { + $state = $this->_state(['999'], [100, 101]); + + $this->_injectGhostUidDeletions($state); + + $this->assertEquals( + [[ + 'id' => '999', + 'type' => Horde_ActiveSync::CHANGE_TYPE_DELETE, + ]], + $this->_changes($state) + ); + } + + public function testGhostUidAppendedToExistingChanges() + { + $existing = [ + ['id' => 101, 'type' => Horde_ActiveSync::CHANGE_TYPE_FLAGS, 'flags' => ['read' => 1]], + ]; + $state = $this->_state(['999'], [100, 101], $existing); + + $this->_injectGhostUidDeletions($state); + + $changes = $this->_changes($state); + $this->assertCount(2, $changes); + $this->assertEquals( + ['id' => '999', 'type' => Horde_ActiveSync::CHANGE_TYPE_DELETE], + $changes[1] + ); + } + + public function testGhostUidNotInjectedTwiceWhenDeletionAlreadyPending() + { + $existing = [ + ['id' => '999', 'type' => Horde_ActiveSync::CHANGE_TYPE_DELETE], + ]; + $state = $this->_state(['999'], [100, 101], $existing); + + $this->_injectGhostUidDeletions($state); + + $this->assertCount(1, $this->_changes($state)); + } + + public function testTrackedUidIsDroppedFromGhostListWithoutInjection() + { + // The uid is tracked in folder state (again): the regular diff + // engine owns its lifecycle. + $state = $this->_state([101], [100, 101]); + + $this->_injectGhostUidDeletions($state); + + $this->assertEquals([], $this->_changes($state)); + $this->assertEquals([], $this->_folder($state)->ghostUids()); + } + + public function testNoInjectionIntoBareUidInitialSyncBatch() + { + // Initial sync exports bare uid lists; change hashes must not be + // mixed into that structure. + $state = $this->_state(['999'], [], [100, 101, 102]); + + $this->_injectGhostUidDeletions($state); + + $this->assertEquals([100, 101, 102], $this->_changes($state)); + $this->assertEquals(['999'], $this->_folder($state)->ghostUids()); + } + + public function testExportedDeletionClearsGhostUid() + { + $state = $this->_state(['999'], [100, 101]); + + $ref = new ReflectionClass($state); + $method = $ref->getMethod('_acknowledgeExportedChange'); + $method->setAccessible(true); + $method->invoke( + $state, + Horde_ActiveSync::CHANGE_TYPE_DELETE, + ['id' => '999'] + ); + + $this->assertEquals([], $this->_folder($state)->ghostUids()); + } + + public function testExportedChangeDoesNotClearGhostUid() + { + $state = $this->_state(['999'], [100, 101]); + + $ref = new ReflectionClass($state); + $method = $ref->getMethod('_acknowledgeExportedChange'); + $method->setAccessible(true); + $method->invoke( + $state, + Horde_ActiveSync::CHANGE_TYPE_CHANGE, + ['id' => 100] + ); + + $this->assertEquals(['999'], $this->_folder($state)->ghostUids()); + } + + /** + * Build a state fixture with an IMAP folder state. + * + * @param array $ghostUids Recorded ghost uids. + * @param array $stateUids Uids tracked in the folder state. + * @param array $changes Preexisting change set. + * + * @return Horde_ActiveSync_State_Base + */ + protected function _state( + array $ghostUids, + array $stateUids, + array $changes = [] + ) { + $folder = new Horde_ActiveSync_Folder_Imap( + 'INBOX', + Horde_ActiveSync::CLASS_EMAIL + ); + if (count($stateUids)) { + $folder->setStatus([ + Horde_ActiveSync_Folder_Imap::UIDVALIDITY => 100, + Horde_ActiveSync_Folder_Imap::UIDNEXT => max($stateUids) + 1, + Horde_ActiveSync_Folder_Imap::HIGHESTMODSEQ => 200, + ]); + $folder->setChanges($stateUids); + $folder->updateState(); + } + $folder->addGhostUids($ghostUids); + + $state = $this->getMockForAbstractClass( + Horde_ActiveSync_State_Base::class, + [[]] + ); + + $ref = new ReflectionClass(Horde_ActiveSync_State_Base::class); + foreach ([ + '_folder' => $folder, + '_changes' => $changes, + '_collection' => [ + 'id' => 'F1', + 'class' => Horde_ActiveSync::CLASS_EMAIL, + ], + ] as $property => $value) { + $prop = $ref->getProperty($property); + $prop->setAccessible(true); + $prop->setValue($state, $value); + } + + return $state; + } + + protected function _injectGhostUidDeletions($state) + { + $ref = new ReflectionClass($state); + $method = $ref->getMethod('_injectGhostUidDeletions'); + $method->setAccessible(true); + $method->invoke($state); + } + + protected function _changes($state) + { + $ref = new ReflectionClass(Horde_ActiveSync_State_Base::class); + $prop = $ref->getProperty('_changes'); + $prop->setAccessible(true); + + return $prop->getValue($state); + } + + protected function _folder($state) + { + return $state->getFolderState(); + } +}