Skip to content
Draft
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
96 changes: 96 additions & 0 deletions lib/Horde/ActiveSync.php
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<?php

/**
Expand Down Expand Up @@ -789,7 +789,7 @@

// Autodiscovery handles authentication on it's own.
if ($cmd == 'Autodiscover') {
$request = new Horde_ActiveSync_Request_Autodiscover($this, new Horde_ActiveSync_Device($this->_state));

Check failure on line 792 in lib/Horde/ActiveSync.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 1

Class Horde_ActiveSync_Request_Autodiscover constructor invoked with 2 parameters, 1 required. [arguments.count]

if (!empty(self::$_logger)) {
$request->setLogger(self::$_logger);
Expand Down Expand Up @@ -1127,7 +1127,7 @@
*/
public function getSupportedCommands()
{
switch ($this->_maxVersion) {

Check failure on line 1130 in lib/Horde/ActiveSync.php

View workflow job for this annotation

GitHub Actions / CI

PHPStan level 1

Method Horde_ActiveSync::getSupportedCommands() should return string but return statement is missing. [return.missing]
case self::VERSION_TWOFIVE:
return 'Sync,SendMail,SmartForward,SmartReply,GetAttachment,GetHierarchy,CreateCollection,DeleteCollection,MoveCollection,FolderSync,FolderCreate,FolderDelete,FolderUpdate,MoveItems,GetItemEstimate,MeetingResponse,ResolveRecipients,ValidateCert,Provision,Search,Ping';

Expand Down Expand Up @@ -1357,4 +1357,100 @@
}
}

/**
* 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 <torben@dannhauer.de>
*
* @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 <torben@dannhauer.de>
*
* @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
);
}
}

}
15 changes: 14 additions & 1 deletion lib/Horde/ActiveSync/Request/Sync.php
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<?php

/**
Expand Down Expand Up @@ -430,6 +430,13 @@
$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
Expand Down Expand Up @@ -1218,7 +1225,6 @@
));
}


/**
* Import client-sent Sync commands that were queued during request
* parsing (streaming only).
Expand Down Expand Up @@ -1300,6 +1306,7 @@
$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'])) {
Expand Down Expand Up @@ -2050,6 +2057,12 @@
return;
}

// Optional server ceiling for BodyPreference / MIMETruncation (0 = off).
Horde_ActiveSync::applyMaximumTruncationSize(
$options,
(int)($this->_driver->getSyncConfig()['maximumtruncationsize'] ?? 0)
);

$collection = array_merge($collection, $options);
}

Expand Down
71 changes: 71 additions & 0 deletions test/unit/Horde/ActiveSync/TruncationCapTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

/**
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* @author Torben Dannhauer <torben@dannhauer.de>
* @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']);
}
}
Loading