From 45e90105a6bd9e37c42f970f94e740fc2e7fe474 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Mon, 20 Jul 2026 22:14:33 +0200 Subject: [PATCH] fix(imap_client): avoid array-to-string warning in getNamespaces Skip non-scalar additional namespace entries instead of passing them to strval(), which triggered "Array to string conversion" when IMP forwarded a nested backends.php namespace config (horde/imp#94). Also compare namespace names with strval rather than strlen when filtering already detected namespaces. --- lib/Horde/Imap/Client/Base.php | 19 ++- test/Unit/Base/GetNamespacesTest.php | 165 +++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 test/Unit/Base/GetNamespacesTest.php diff --git a/lib/Horde/Imap/Client/Base.php b/lib/Horde/Imap/Client/Base.php index 618349a0..7ef486de 100644 --- a/lib/Horde/Imap/Client/Base.php +++ b/lib/Horde/Imap/Client/Base.php @@ -712,7 +712,8 @@ abstract protected function _noop(); * additional namespaces to add to the * namespace list that are not broadcast by * the server. The namespaces must be UTF-8 - * strings. + * strings. Non-scalar entries (e.g. nested + * arrays) are ignored. * @param array $opts Additional options: * - ob_return: (boolean) If true, returns a * Horde_Imap_Client_Namespace_List object instead of an @@ -741,7 +742,19 @@ public function getNamespaces( array $additional = [], array $opts = [] ) { - $additional = array_map('strval', $additional); + /* Only scalar/Stringable values are valid namespace names. Nested + * arrays (e.g. from a misconfigured IMP backends.php 'namespace' + * entry) must not be passed to strval() — that triggers + * "Array to string conversion" on PHP 8+. */ + $normalized = []; + foreach ($additional as $val) { + if (is_array($val) || + (is_object($val) && !($val instanceof Stringable))) { + continue; + } + $normalized[] = strval($val); + } + $additional = $normalized; $sig = hash( 'md5', json_encode($additional) . intval(empty($opts['ob_return'])) @@ -756,7 +769,7 @@ public function getNamespaces( /* Skip namespaces if we have already auto-detected them. Also, * hidden namespaces cannot be empty. */ - $to_process = array_diff(array_filter($additional, 'strlen'), array_map('strlen', iterator_to_array($ns))); + $to_process = array_diff(array_filter($additional, 'strlen'), array_map('strval', iterator_to_array($ns))); if (!empty($to_process)) { foreach ($this->listMailboxes($to_process, Horde_Imap_Client::MBOX_ALL, ['delimiter' => true]) as $key => $val) { $ob = new Horde_Imap_Client_Data_Namespace(); diff --git a/test/Unit/Base/GetNamespacesTest.php b/test/Unit/Base/GetNamespacesTest.php new file mode 100644 index 00000000..4975a481 --- /dev/null +++ b/test/Unit/Base/GetNamespacesTest.php @@ -0,0 +1,165 @@ + + * @category Horde + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Imap_Client + */ + +namespace Horde\Imap\Client\Test\Unit\Base; + +use Horde\Imap\Client\Test\Stub\Base; +use Horde_Imap_Client_Data_Namespace; +use Horde_Imap_Client_Mailbox; +use Horde_Imap_Client_Namespace_List; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\TestCase; + +/** + * Tests for Horde_Imap_Client_Base#getNamespaces(). + * + * @author Torben Dannhauer + * @copyright 2026 The Horde Project + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +#[CoversNothing] +class GetNamespacesTest extends TestCase +{ + /** + * Nested-array additional namespaces must not trigger + * "Array to string conversion" (horde/imp#94). + */ + public function testAdditionalNestedArraysDoNotWarn(): void + { + $ob = new class ([ + 'username' => 'user', + 'password' => 'pass', + ]) extends Base { + protected function _getNamespaces() + { + $ns = new Horde_Imap_Client_Data_Namespace(); + $ns->delimiter = '/'; + $ns->name = ''; + $ns->type = Horde_Imap_Client_Data_Namespace::NS_PERSONAL; + + return new Horde_Imap_Client_Namespace_List([$ns]); + } + }; + + $result = $ob->getNamespaces( + [ + '#shared/', + '#broken' => ['delimiter' => '/'], + ['nested'], + ], + ['ob_return' => true] + ); + + $this->assertInstanceOf(Horde_Imap_Client_Namespace_List::class, $result); + $this->assertCount(1, $result); + } + + /** + * Already-advertised namespaces in $additional must be skipped + * (array_map must use strval, not strlen, on the namespace list). + */ + public function testSkipsAlreadyDetectedAdditionalNamespaces(): void + { + $listed = []; + + $ob = new class ([ + 'username' => 'user', + 'password' => 'pass', + ], $listed) extends Base { + /** @var array */ + private $listedRef; + + public function __construct(array $params, array &$listed) + { + parent::__construct($params); + $this->listedRef = &$listed; + } + + protected function _getNamespaces() + { + $ns = new Horde_Imap_Client_Data_Namespace(); + $ns->delimiter = '/'; + $ns->name = '#shared/'; + $ns->type = Horde_Imap_Client_Data_Namespace::NS_SHARED; + + return new Horde_Imap_Client_Namespace_List([$ns]); + } + + protected function _listMailboxes($pattern, $mode, $options) + { + $this->listedRef[] = $pattern; + + return []; + } + }; + + $ob->getNamespaces(['#shared/', '#public/'], ['ob_return' => true]); + + $this->assertCount(1, $listed); + $this->assertSame( + ['#public/'], + array_map('strval', $listed[0]) + ); + } + + public function testStringableAdditionalNamespacesAreAccepted(): void + { + $listed = []; + + $ob = new class ([ + 'username' => 'user', + 'password' => 'pass', + ], $listed) extends Base { + /** @var array */ + private $listedRef; + + public function __construct(array $params, array &$listed) + { + parent::__construct($params); + $this->listedRef = &$listed; + } + + protected function _getNamespaces() + { + $ns = new Horde_Imap_Client_Data_Namespace(); + $ns->delimiter = '/'; + $ns->name = ''; + $ns->type = Horde_Imap_Client_Data_Namespace::NS_PERSONAL; + + return new Horde_Imap_Client_Namespace_List([$ns]); + } + + protected function _listMailboxes($pattern, $mode, $options) + { + $this->listedRef[] = $pattern; + + return []; + } + }; + + $ob->getNamespaces( + [Horde_Imap_Client_Mailbox::get('#shared/')], + ['ob_return' => true] + ); + + $this->assertCount(1, $listed); + $this->assertSame( + ['#shared/'], + array_map('strval', $listed[0]) + ); + } +}