From 624c4feab4b380f976c72725155b17c5d80d93b5 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Thu, 30 Jul 2026 13:30:15 +0200 Subject: [PATCH] feat(activesync): add optional Sync TruncationSize ceiling Allow admins to cap client BodyPreference TruncationSize during Sync (0 = honor client), without affecting ItemOperations Fetch. --- lib/Horde/ActiveSync.php | 96 +++++++++++++++++++ lib/Horde/ActiveSync/Request/Sync.php | 15 ++- .../Horde/ActiveSync/TruncationCapTest.php | 71 ++++++++++++++ 3 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 test/unit/Horde/ActiveSync/TruncationCapTest.php diff --git a/lib/Horde/ActiveSync.php b/lib/Horde/ActiveSync.php index defd2f4f..923afe57 100644 --- a/lib/Horde/ActiveSync.php +++ b/lib/Horde/ActiveSync.php @@ -1357,4 +1357,100 @@ public static function getTruncSize($truncation) } } + /** + * Cap a client-requested truncation size against a server maximum. + * + * Mirrors the WindowSize override: a maximum of 0 (or less) leaves the + * client value unchanged. When a positive maximum is set, unlimited + * client requests are reduced to the maximum, and larger requests are + * clamped with min(client, maximum). + * + * @author Torben Dannhauer + * + * @param integer|false|null $size Client size in bytes, or false/null + * for unlimited / no truncation. + * @param integer $max Server maximum in bytes; 0 disables. + * @param boolean $zeroMeansUnlimited When true, treat 0 like unlimited + * (BodyPreference TruncationSize). When + * false, 0 stays 0 (MIMETruncation + * "truncate all"). + * + * @return integer|false|null The effective truncation size. + */ + public static function capTruncationSize($size, $max, $zeroMeansUnlimited = false) + { + $max = (int)$max; + if ($max <= 0) { + return $size; + } + + if ($size === false || $size === null || $size === '') { + return $max; + } + + $size = (int)$size; + if ($zeroMeansUnlimited && $size === 0) { + return $max; + } + + return ($size > $max) ? $max : $size; + } + + /** + * Apply activesync/sync/maximumtruncationsize to collection/options data. + * + * Mutates bodyprefs, bodypartprefs, truncation, and mimetruncation in + * place when a positive maximum is configured. + * + * @author Torben Dannhauer + * + * @param array $options Sync/collection options (by reference). + * @param integer $max Server maximum TruncationSize in bytes; 0 = off. + */ + public static function applyMaximumTruncationSize(array &$options, $max) + { + $max = (int)$max; + if ($max <= 0) { + return; + } + + if (!empty($options['bodyprefs']) && is_array($options['bodyprefs'])) { + foreach ($options['bodyprefs'] as &$pref) { + if (!is_array($pref)) { + continue; + } + $pref['truncationsize'] = self::capTruncationSize( + $pref['truncationsize'] ?? 0, + $max, + true + ); + } + unset($pref); + } + + if (!empty($options['bodypartprefs']) && is_array($options['bodypartprefs'])) { + $options['bodypartprefs']['truncationsize'] = self::capTruncationSize( + $options['bodypartprefs']['truncationsize'] ?? 0, + $max, + true + ); + } + + if (array_key_exists('mimetruncation', $options)) { + $options['mimetruncation'] = self::capTruncationSize( + $options['mimetruncation'], + $max, + false + ); + } + + if (array_key_exists('truncation', $options)) { + $options['truncation'] = self::capTruncationSize( + $options['truncation'], + $max, + false + ); + } + } + } diff --git a/lib/Horde/ActiveSync/Request/Sync.php b/lib/Horde/ActiveSync/Request/Sync.php index d989d152..3ca6c2eb 100644 --- a/lib/Horde/ActiveSync/Request/Sync.php +++ b/lib/Horde/ActiveSync/Request/Sync.php @@ -430,6 +430,13 @@ protected function _handle() $statusCode = self::STATUS_SUCCESS; $changecount = 0; + // Cap Sync body truncation only (not ItemOperations Fetch). + // Covers partial Sync where Options were restored from SyncCache. + Horde_ActiveSync::applyMaximumTruncationSize( + $collection, + (int)($syncSettings['maximumtruncationsize'] ?? 0) + ); + if ($over_window || $cnt_global > $this->_collections->getDefaultWindowSize()) { // Client-sent commands must still be imported (matching the // non-streaming flow, where imports happen during parsing @@ -1218,7 +1225,6 @@ protected function _resolveOrphanInstanceIdRemoves(array &$collection) )); } - /** * Import client-sent Sync commands that were queued during request * parsing (streaming only). @@ -1300,6 +1306,7 @@ protected function _runDeferredSyncCommands(array &$collection) $count += count($deferred['removes']); $keepAlives += $this->_emitKeepAlive(); } + // Orphans may have been queued without a ServerEntryId; resolve them // against a single successful co-batched Add before importing. if (!empty($deferred['orphan_instanceid_removes'])) { @@ -2050,6 +2057,12 @@ public function _parseSyncOptions(&$collection) return; } + // Optional server ceiling for BodyPreference / MIMETruncation (0 = off). + Horde_ActiveSync::applyMaximumTruncationSize( + $options, + (int)($this->_driver->getSyncConfig()['maximumtruncationsize'] ?? 0) + ); + $collection = array_merge($collection, $options); } diff --git a/test/unit/Horde/ActiveSync/TruncationCapTest.php b/test/unit/Horde/ActiveSync/TruncationCapTest.php new file mode 100644 index 00000000..95f0dc01 --- /dev/null +++ b/test/unit/Horde/ActiveSync/TruncationCapTest.php @@ -0,0 +1,71 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package ActiveSync + */ + +namespace Horde\ActiveSync; + +use Horde_ActiveSync; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\TestCase; + +#[CoversNothing] +class TruncationCapTest extends TestCase +{ + public function testCapDisabledLeavesClientValue() + { + $this->assertSame(200000, Horde_ActiveSync::capTruncationSize(200000, 0, true)); + $this->assertSame(false, Horde_ActiveSync::capTruncationSize(false, 0, false)); + $this->assertSame(0, Horde_ActiveSync::capTruncationSize(0, 0, true)); + } + + public function testCapClampsLargerClientBodyPreference() + { + $this->assertSame(5000, Horde_ActiveSync::capTruncationSize(200000, 5000, true)); + $this->assertSame(500, Horde_ActiveSync::capTruncationSize(500, 5000, true)); + } + + public function testCapTreatsZeroBodyPreferenceAsUnlimited() + { + $this->assertSame(5000, Horde_ActiveSync::capTruncationSize(0, 5000, true)); + $this->assertSame(5000, Horde_ActiveSync::capTruncationSize(null, 5000, true)); + } + + public function testCapPreservesZeroMimeTruncation() + { + $this->assertSame(0, Horde_ActiveSync::capTruncationSize(0, 5000, false)); + $this->assertSame(5000, Horde_ActiveSync::capTruncationSize(false, 5000, false)); + $this->assertSame(4096, Horde_ActiveSync::capTruncationSize(4096, 5000, false)); + $this->assertSame(5000, Horde_ActiveSync::capTruncationSize(102400, 5000, false)); + } + + public function testApplyMaximumTruncationSizeToOptions() + { + $options = [ + 'bodyprefs' => [ + Horde_ActiveSync::BODYPREF_TYPE_HTML => [ + 'type' => Horde_ActiveSync::BODYPREF_TYPE_HTML, + 'truncationsize' => 200000, + ], + ], + 'mimetruncation' => false, + 'truncation' => 51200, + ]; + + Horde_ActiveSync::applyMaximumTruncationSize($options, 0); + $this->assertSame(200000, $options['bodyprefs'][Horde_ActiveSync::BODYPREF_TYPE_HTML]['truncationsize']); + $this->assertSame(false, $options['mimetruncation']); + + Horde_ActiveSync::applyMaximumTruncationSize($options, 5000); + $this->assertSame(5000, $options['bodyprefs'][Horde_ActiveSync::BODYPREF_TYPE_HTML]['truncationsize']); + $this->assertSame(5000, $options['mimetruncation']); + $this->assertSame(5000, $options['truncation']); + } +}