AddressBook: do not let an empty side wipe the other during sync

Sync() treats an empty list on either side as authoritative:

* a local contact holding an etag but missing from the remote listing is
  deleted locally, so an empty or unparsed listing removes every
  previously synced contact;
* a local tombstone is deleted remotely, so a local store that is empty
  apart from tombstones prunes the server.

An empty list is far more often a fault than a real "delete everything":
a fresh or rebuilt local store, a listing that failed to parse, a stale
server-side index. On a server whose DAV index had lost its records the
listing came back empty and the first sync removed 7 local contacts that
existed on both sides moments earlier.

Skip the deletions in whichever direction the source list is empty and
let the existing import/export reconcile instead. Both guards log a
warning with the counts, so a skipped pass is visible rather than silent.
When both sides hold data the behaviour is unchanged.
This commit is contained in:
Fathi Ben Nasr 2026-08-12 13:12:07 +00:00
parent c154d23cfe
commit 1a0c9872b2

View file

@ -160,6 +160,31 @@ class PdoAddressBook
$bReadWrite = $this->isDAVReadWrite();
/**
* An empty list on either side is far more often a fault than a real
* "delete everything": a fresh or rebuilt local store, a DAV listing
* that failed to parse, a stale server-side index. Acting on it
* destroys data the other side still holds, so skip the deletions in
* that direction and let the following import/export reconcile.
*/
$iLocalLive = 0;
foreach ($aLocalSyncData as $aGuardData) {
if (empty($aGuardData['deleted'])) {
++$iLocalLive;
}
}
$iRemoteCount = \count($aRemoteSyncData);
$bProtectRemote = (0 === $iLocalLive && 0 < $iRemoteCount);
$bProtectLocal = (0 === $iRemoteCount && 0 < $iLocalLive);
if ($bProtectRemote) {
\SnappyMail\Log::warning('PdoAddressBook', "Sync() local store is empty while remote holds"
. " {$iRemoteCount} contacts: importing only, no remote deletions");
}
if ($bProtectLocal) {
\SnappyMail\Log::warning('PdoAddressBook', "Sync() remote listing is empty while local holds"
. " {$iLocalLive} contacts: keeping local, no local deletions");
}
// Delete remote when Mode = read + write
if ($bReadWrite) {
\SnappyMail\Log::info('PdoAddressBook', 'Sync() is import and export');
@ -168,7 +193,7 @@ class PdoAddressBook
if ($aData['deleted']) {
++$iCount;
unset($aLocalSyncData[$sKey]);
if (isset($aRemoteSyncData[$sKey], $aRemoteSyncData[$sKey]['vcf'])) {
if (!$bProtectRemote && isset($aRemoteSyncData[$sKey], $aRemoteSyncData[$sKey]['vcf'])) {
\SnappyMail\HTTP\Stream::JSON(['messsage'=>"Delete remote {$sKey}"]);
$this->davClientRequest($oClient, 'DELETE', $sPath.$aRemoteSyncData[$sKey]['vcf']);
}
@ -183,9 +208,11 @@ class PdoAddressBook
// Delete local
$aIdsForDeletion = array();
foreach ($aLocalSyncData as $sKey => $aData) {
if (!empty($aData['etag']) && !isset($aRemoteSyncData[$sKey])) {
$aIdsForDeletion[] = $aData['id_contact'];
if (!$bProtectLocal) {
foreach ($aLocalSyncData as $sKey => $aData) {
if (!empty($aData['etag']) && !isset($aRemoteSyncData[$sKey])) {
$aIdsForDeletion[] = $aData['id_contact'];
}
}
}
if (\count($aIdsForDeletion)) {