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
161 changes: 150 additions & 11 deletions lib/Horde/ActiveSync/Collections.php
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<?php

/**
Expand Down Expand Up @@ -841,9 +841,9 @@
if (isset($folder->serverid)
&& $syncFolder = $this->_cache->getFolder($folder->serverid)
&& in_array($folder->serverid, $seenFolders)
&& $syncFolder['parentid'] == $folder->parentid

Check failure on line 844 in lib/Horde/ActiveSync/Collections.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 1

Variable $syncFolder might not be defined. [variable.undefined]
&& $syncFolder['displayname'] == $folder->displayname

Check failure on line 845 in lib/Horde/ActiveSync/Collections.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 1

Variable $syncFolder might not be defined. [variable.undefined]
&& $syncFolder['type'] == $folder->type) {

Check failure on line 846 in lib/Horde/ActiveSync/Collections.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 1

Variable $syncFolder might not be defined. [variable.undefined]

$this->_logger->meta(
sprintf(
Expand Down Expand Up @@ -963,8 +963,8 @@
$this->save();
return false;
}
$this->shortSyncRequest = true;

Check failure on line 966 in lib/Horde/ActiveSync/Collections.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 1

Access to an undefined property Horde_ActiveSync_Collections::$shortSyncRequest. [property.notFound]
$this->hangingSync = true;

Check failure on line 967 in lib/Horde/ActiveSync/Collections.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 1

Access to an undefined property Horde_ActiveSync_Collections::$hangingSync. [property.notFound]
$this->save(true);

return true;
Expand Down Expand Up @@ -1301,11 +1301,11 @@
)
);

if (!empty($options['pingable'])) {
if ($this->collectionsNeedFolderResync()) {
return self::COLLECTION_ERR_FOLDERSYNC_REQUIRED;
}
// Repair collection/folder-cache skew on the server. Incremental
// FolderSync cannot do this (valid hierarchy synckey → Count 0).
$this->healCollectionsMissingFolderCache();

if (!empty($options['pingable'])) {
$this->restorePingableCollectionsFromCache();

// If pinging, make sure we have pingable collections. Note we can't
Expand All @@ -1332,7 +1332,7 @@

// Need to update AND SAVE the timestamp for race conditions to be
// detected.
$this->lasthbsyncstarted = $started;

Check failure on line 1335 in lib/Horde/ActiveSync/Collections.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 1

Access to an undefined property Horde_ActiveSync_Collections::$lasthbsyncstarted. [property.notFound]
$this->save();

// We only check for remote wipe request once every 5 iterations to
Expand Down Expand Up @@ -1655,7 +1655,7 @@
sprintf(
'COLLECTIONS: Looping Sync complete: DataAvailable: %s, DataImported: %s',
$dataavailable,
$this->importedChanges

Check failure on line 1658 in lib/Horde/ActiveSync/Collections.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 1

Access to an undefined property Horde_ActiveSync_Collections::$importedChanges. [property.notFound]
)
);

Expand Down Expand Up @@ -1702,34 +1702,173 @@
/**
* True if a collection has item sync state but no folder cache entry.
*
* The client must run FolderSync to remap folder ids (common after a
* partial cache reset or hierarchy invalidation).
* Historically this asked the client for FolderSync. Incremental FolderSync
* cannot repair that cache skew (no hierarchy changes → Count 0), so this
* now self-heals via healCollectionsMissingFolderCache() and returns false.
* Missing hierarchy synckey is still handled separately via haveHierarchy().
*
* @return boolean
*/
public function collectionsNeedFolderResync()
{
$this->healCollectionsMissingFolderCache();

return false;
}

/**
* Drop or rehydrate collections that have a synckey but no folders[] entry.
*
* If the uid is still in the persistent foldermap, restore the folders
* cache entry so PING/SYNC can resolve the backend id. Otherwise the
* collection is a ghost (deleted or remapped folder) and is removed.
* Requesting FolderSync is the wrong recovery: a valid hierarchy synckey
* produces Count 0 and livelocks cooperating clients (PING 7 → FolderSync).
*
* @return boolean True if the sync cache was modified.
* @author Torben Dannhauer <torben@dannhauer.de>
*/
public function healCollectionsMissingFolderCache()
{
if ($this->_cache->countCollections() == 0) {
return false;
}

$changed = false;
$uidToBackend = array_flip($this->_cache->getFolderMap());

foreach ($this->_cache->getCollections(false) as $id => $collection) {
if ($id === 'RI') {
continue;
}
$synckey = $collection['synckey'] ?? '';
if ($synckey === '' && !empty($collection['lastsynckey'])) {
$synckey = $collection['lastsynckey'];
}
if ($synckey === '' || $synckey === '0') {
continue;
}
if (!$this->_cache->getFolder($id)) {
if ($this->_cache->getFolder($id)) {
continue;
}

$backendId = $uidToBackend[$id] ?? '';
if ($backendId !== '' && $backendId !== false) {
$this->_restoreFolderCacheEntry($id, (string)$backendId, $collection);
$this->_logger->info(
sprintf(
'COLLECTIONS: Collection %s has sync state but no folder cache entry; FolderSync required.',
$id
'COLLECTIONS: Restored folder cache entry for %s from foldermap (%s).',
$id,
$backendId
)
);
$changed = true;
continue;
}

return true;
$this->_logger->notice(
sprintf(
'COLLECTIONS: Dropping collection %s; has sync state but no folder cache entry or foldermap mapping.',
$id
)
);
$this->_cache->removeCollection($id, true);
unset($this->_collections[$id]);
$changed = true;
}

if ($changed) {
$this->save();
}

return $changed;
}

/**
* Rebuild a folders[] cache row from a known backend id.
*
* @param string $uid EAS folder uid.
* @param string $backendId Backend folder id.
* @param array $collection Collection cache row.
*/
protected function _restoreFolderCacheEntry($uid, $backendId, array $collection)
{
$class = !empty($collection['class'])
? $collection['class']
: $this->_collectionClassFromFolderUid($uid);
$type = !empty($collection['type'])
? $collection['type']
: $this->_folderTypeFromClass($class);

$version = !empty($this->_as->device->version)
? $this->_as->device->version
: Horde_ActiveSync::VERSION_FOURTEEN;
$folder = new Horde_ActiveSync_Message_Folder(['protocolversion' => $version]);
$folder->serverid = $uid;
$folder->_serverid = $backendId;
$folder->type = $type;
$this->_cache->updateFolder($folder);

$cols = $this->_cache->getCollections(false);
if (!empty($cols[$uid])) {
$cols[$uid]['serverid'] = $backendId;
if (empty($cols[$uid]['class'])) {
$cols[$uid]['class'] = $class;
}
$this->_cache->updateCollection($cols[$uid]);
}
if (isset($this->_collections[$uid])) {
$this->_collections[$uid]['serverid'] = $backendId;
if (empty($this->_collections[$uid]['class'])) {
$this->_collections[$uid]['class'] = $class;
}
}
}

/**
* Derive a collection class from the deterministic EAS uid prefix.
*
* @param string $uid EAS folder uid.
*
* @return string Horde_ActiveSync::CLASS_* value.
*/
protected function _collectionClassFromFolderUid($uid)
{
switch (substr($uid, 0, 1)) {
case 'A':
return Horde_ActiveSync::CLASS_CALENDAR;
case 'C':
return Horde_ActiveSync::CLASS_CONTACTS;
case 'T':
return Horde_ActiveSync::CLASS_TASKS;
case 'N':
return Horde_ActiveSync::CLASS_NOTES;
default:
return Horde_ActiveSync::CLASS_EMAIL;
}
}

return false;
/**
* Default folder type for a collection class when restoring a cache row.
*
* @param string $class Horde_ActiveSync::CLASS_* value.
*
* @return integer Horde_ActiveSync::FOLDER_TYPE_* value.
*/
protected function _folderTypeFromClass($class)
{
switch ($class) {
case Horde_ActiveSync::CLASS_CALENDAR:
return Horde_ActiveSync::FOLDER_TYPE_USER_APPOINTMENT;
case Horde_ActiveSync::CLASS_CONTACTS:
return Horde_ActiveSync::FOLDER_TYPE_USER_CONTACT;
case Horde_ActiveSync::CLASS_TASKS:
return Horde_ActiveSync::FOLDER_TYPE_USER_TASK;
case Horde_ActiveSync::CLASS_NOTES:
return Horde_ActiveSync::FOLDER_TYPE_USER_NOTE;
default:
return Horde_ActiveSync::FOLDER_TYPE_USER_MAIL;
}
}

/**
Expand Down
13 changes: 7 additions & 6 deletions lib/Horde/ActiveSync/Request/Ping.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,12 @@ protected function _handle()
$this->_logger->info('Handling empty PING request.');
$isEmpty = true;
$collections->loadCollectionsFromCache();
$collections->healCollectionsMissingFolderCache();
$collections->restorePingableCollectionsFromCache();
if ($collections->collectionCount() == 0
|| !$collections->havePingableCollections()) {
if ($collections->collectionsNeedFolderResync()
|| ($this->_device->version >= Horde_ActiveSync::VERSION_TWELVEONE
&& !$collections->haveHierarchy())) {
if ($this->_device->version >= Horde_ActiveSync::VERSION_TWELVEONE
&& !$collections->haveHierarchy()) {
$this->_logger->info(
'Empty PING with stale folder or hierarchy state; requesting FolderSync.'
);
Expand Down Expand Up @@ -220,11 +220,11 @@ protected function _handle()
'No collections loaded from explicit PING folder list; using cached collections.'
);
$collections->loadCollectionsFromCache();
$collections->healCollectionsMissingFolderCache();
$collections->restorePingableCollectionsFromCache();
if ($collections->collectionCount() == 0) {
if ($collections->collectionsNeedFolderResync()
|| ($this->_device->version >= Horde_ActiveSync::VERSION_TWELVEONE
&& !$collections->haveHierarchy())) {
if ($this->_device->version >= Horde_ActiveSync::VERSION_TWELVEONE
&& !$collections->haveHierarchy()) {
$this->_logger->info(
'Explicit PING with stale folder or hierarchy state; requesting FolderSync.'
);
Expand All @@ -246,6 +246,7 @@ protected function _handle()
} else {
// No FOLDERS supplied, use the cache.
$collections->loadCollectionsFromCache();
$collections->healCollectionsMissingFolderCache();
if ($collections->collectionCount() == 0) {
$this->_logger->warn('Empty PING request with no cached collections. Request full PING.');
$this->_statusCode = self::STATUS_MISSING;
Expand Down
4 changes: 2 additions & 2 deletions test/unit/Horde/ActiveSync/BacklogPingRecoveryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ protected function _collectionsFixture(Horde_ActiveSync_SyncCache $cache)
'initCollectionState',
'updateCollectionsFromCache',
'checkStaleRequest',
'collectionsNeedFolderResync',
'healCollectionsMissingFolderCache',
'restorePingableCollectionsFromCache',
'havePingableCollections',
'haveHierarchy',
Expand All @@ -191,7 +191,7 @@ protected function _collectionsFixture(Horde_ActiveSync_SyncCache $cache)
])
->getMock();

$collections->method('collectionsNeedFolderResync')->willReturn(false);
$collections->method('healCollectionsMissingFolderCache')->willReturn(false);
$collections->method('havePingableCollections')->willReturn(true);
$collections->method('haveHierarchy')->willReturn(true);
$collections->method('checkStaleRequest')->willReturn(false);
Expand Down
Loading
Loading