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(); + } +}