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
4 changes: 4 additions & 0 deletions .horde.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,7 @@ autoload:
psr-4:
Horde\Util\: src/
vendor: horde

quality:
phpstan:
level: 2
77 changes: 52 additions & 25 deletions lib/Horde/String.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,31 +111,23 @@ public static function convertCharset($input, $from, $to, $force = false)
* @param string $to See self::convertCharset().
*
* @return string The converted string.
* @throws RuntimeException If charset conversion fails.
*/
protected static function _convertCharset($input, $from, $to)
{
/* Use utf8_[en|de]code() if possible and if the string isn't too
* large (less than 16 MB = 16 * 1024 * 1024 = 16777216 bytes) - these
* functions use more memory. */
if (Horde_Util::extensionExists('xml')
&& ((strlen($input) < 16777216)
|| !Horde_Util::extensionExists('iconv')
|| !Horde_Util::extensionExists('mbstring'))) {
if (($to == 'utf-8')
&& function_exists('utf8_encode')
&& in_array($from, ['iso-8859-1', 'us-ascii', 'utf-8'])) {
return @utf8_encode($input);
}

if (($from == 'utf-8')
&& function_exists('utf8_decode')
&& in_array($to, ['iso-8859-1', 'us-ascii', 'utf-8'])) {
return @utf8_decode($input);
}
/* Early return for same charset (should already be handled by caller). */
$fromLower = self::lower($from);
$toLower = self::lower($to);
if ($fromLower == $toLower) {
return $input;
}

$attemptedMethods = [];
$failureReasons = [];

/* Try UTF7-IMAP conversions. */
if (($from == 'utf7-imap') || ($to == 'utf7-imap')) {
$attemptedMethods[] = 'utf7-imap';
try {
if ($from == 'utf7-imap') {
return self::convertCharset(Horde_Imap_Client_Utf7imap::Utf7ImapToUtf8($input), 'UTF-8', $to);
Expand All @@ -154,26 +146,64 @@ protected static function _convertCharset($input, $from, $to)

/* Try iconv with transliteration. */
if (Horde_Util::extensionExists('iconv')) {
if (($out = self::_convertCharsetIconv($input, $from, $to)) !== false) {
$attemptedMethods[] = 'iconv';
$out = self::_convertCharsetIconv($input, $from, $to);
if ($out !== false) {
return $out;
}
$failureReasons[] = 'iconv failed or does not support charset';
}

/* Try mbstring. */
if (Horde_Util::extensionExists('mbstring')) {
$attemptedMethods[] = 'mbstring';
$mbTo = CharacterSets::toMbstring($to);
$mbFrom = CharacterSets::toMbstring($from);
try {
$out = @mb_convert_encoding($input, $mbTo, self::_mbstringCharset($mbFrom));
$out = mb_convert_encoding($input, $mbTo, self::_mbstringCharset($mbFrom));
if (!empty($out)) {
return $out;
}
$failureReasons[] = 'mbstring returned empty result';
} catch (ValueError $e) {
// catch error thrown under PHP 8.0, if mbstring does not support the encoding
$failureReasons[] = 'mbstring: ' . $e->getMessage();
} catch (Error $e) {
$failureReasons[] = 'mbstring: ' . $e->getMessage();
}
}

return $input;
/* Try intl UConverter as last resort. */
if (class_exists('UConverter')) {
$attemptedMethods[] = 'UConverter';
try {
$conv = new UConverter($to, $from);
$out = $conv->convert($input);
if ($out !== false && $out !== '') {
return $out;
}
$failureReasons[] = 'UConverter returned empty/false result';
} catch (Exception $e) {
$failureReasons[] = 'UConverter: ' . $e->getMessage();
}
}

/* All conversion methods failed. */
$message = sprintf(
'Unable to convert character set from "%s" to "%s". ',
$from,
$to
);

if (empty($attemptedMethods)) {
$message .= 'No conversion methods available (install mbstring, iconv, or intl extension).';
} else {
$message .= 'Attempted methods: ' . implode(', ', $attemptedMethods) . '. ';
if (!empty($failureReasons)) {
$message .= 'Failures: ' . implode('; ', $failureReasons) . '.';
}
}

throw new RuntimeException($message);
}

/**
Expand Down Expand Up @@ -414,9 +444,6 @@ public static function length($string, $charset = 'UTF-8')
if ($charset == 'utf-8' || $charset == 'utf8') {
if (Horde_Util::extensionExists('mbstring')) {
return strlen(mb_convert_encoding($string, 'ISO-8859-1', 'UTF-8'));

} elseif (function_exists('utf8_decode')) {
return strlen(@utf8_decode($string));
}
}

Expand Down
83 changes: 60 additions & 23 deletions src/HordeString.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
use ValueError;
use InvalidArgumentException;
use Stringable as StringableInterface;
use Error;
use Horde_Imap_Client_Exception;
use Horde_Imap_Client_Utf7imap;
use PEAR_Error;
use RuntimeException;
use UConverter;

/**
* Provides static methods for charset and locale safe string manipulation.
Expand Down Expand Up @@ -88,7 +94,7 @@ public static function convertCharset($input, $from, $to, $force = false)
// reach this line, but add a check.
// Also check for legacy PEAR_Error if the class exists.
if (($input instanceof Exception)
|| (class_exists('PEAR_Error', false) && $input instanceof \PEAR_Error)) {
|| (class_exists('PEAR_Error', false) && $input instanceof PEAR_Error)) {
return '';
}

Expand All @@ -115,42 +121,36 @@ public static function convertCharset($input, $from, $to, $force = false)
* @param string $to See self::convertCharset().
*
* @return string The converted string.
* @throws RuntimeException If charset conversion fails.
*/
protected static function _convertCharset($input, $from, $to)
{
/* Use utf8_[en|de]code() if possible and if the string isn't too
* large (less than 16 MB = 16 * 1024 * 1024 = 16777216 bytes) - these
* functions use more memory. */
if (Util::extensionExists('xml')
&& ((strlen($input) < 16777216)
|| !Util::extensionExists('iconv')
|| !Util::extensionExists('mbstring'))) {
if (($to == 'utf-8')
&& in_array($from, ['iso-8859-1', 'us-ascii', 'utf-8'])) {
return mb_convert_encoding($input, 'UTF-8', 'ISO-8859-1');
}

if (($from == 'utf-8')
&& in_array($to, ['iso-8859-1', 'us-ascii', 'utf-8'])) {
return mb_convert_encoding($input, 'ISO-8859-1', 'UTF-8');
}
/* Early return for same charset (should already be handled by caller). */
$fromLower = self::lower($from);
$toLower = self::lower($to);
if ($fromLower == $toLower) {
return $input;
}

$attemptedMethods = [];
$failureReasons = [];

/* Try UTF7-IMAP conversions. */
if (($from == 'utf7-imap') || ($to == 'utf7-imap')) {
if (class_exists('Horde_Imap_Client_Utf7imap', true)) {
$attemptedMethods[] = 'utf7-imap';
try {
if ($from == 'utf7-imap') {
return self::convertCharset(\Horde_Imap_Client_Utf7imap::Utf7ImapToUtf8($input), 'UTF-8', $to);
return self::convertCharset(Horde_Imap_Client_Utf7imap::Utf7ImapToUtf8($input), 'UTF-8', $to);
} else {
if ($from == 'utf-8') {
$conv = $input;
} else {
$conv = self::convertCharset($input, $from, 'UTF-8');
}
return \Horde_Imap_Client_Utf7imap::Utf8ToUtf7Imap($conv);
return Horde_Imap_Client_Utf7imap::Utf8ToUtf7Imap($conv);
}
} catch (\Horde_Imap_Client_Exception $e) {
} catch (Horde_Imap_Client_Exception $e) {
return $input;
} catch (Exception $e) {
// Class doesn't exist or other error
Expand All @@ -161,28 +161,65 @@ protected static function _convertCharset($input, $from, $to)

/* Try iconv with transliteration. */
if (Util::extensionExists('iconv')) {
$attemptedMethods[] = 'iconv';
$out = @iconv($from, $to . '//TRANSLIT', $input);
$errmsg = error_get_last();
if (!$errmsg && $out !== false) {
return $out;
}
$failureReasons[] = 'iconv failed or does not support charset';
}

/* Try mbstring. */
if (Util::extensionExists('mbstring')) {
$attemptedMethods[] = 'mbstring';
$mbTo = CharacterSets::toMbstring($to);
$mbFrom = CharacterSets::toMbstring($from);
try {
$out = @mb_convert_encoding($input, $mbTo, self::_mbstringCharset($mbFrom));
$out = mb_convert_encoding($input, $mbTo, self::_mbstringCharset($mbFrom));
if (!empty($out)) {
return $out;
}
$failureReasons[] = 'mbstring returned empty result';
} catch (ValueError $e) {
// catch error thrown under PHP 8.0, if mbstring does not support the encoding
$failureReasons[] = 'mbstring: ' . $e->getMessage();
} catch (Error $e) {
$failureReasons[] = 'mbstring: ' . $e->getMessage();
}
}

/* Try intl UConverter as last resort. */
if (class_exists('UConverter')) {
$attemptedMethods[] = 'UConverter';
try {
$conv = new UConverter($to, $from);
$out = $conv->convert($input);
if ($out !== false && $out !== '') {
return $out;
}
$failureReasons[] = 'UConverter returned empty/false result';
} catch (Exception $e) {
$failureReasons[] = 'UConverter: ' . $e->getMessage();
}
}

/* All conversion methods failed. */
$message = sprintf(
'Unable to convert character set from "%s" to "%s". ',
$from,
$to
);

if (empty($attemptedMethods)) {
$message .= 'No conversion methods available (install mbstring, iconv, or intl extension).';
} else {
$message .= 'Attempted methods: ' . implode(', ', $attemptedMethods) . '. ';
if (!empty($failureReasons)) {
$message .= 'Failures: ' . implode('; ', $failureReasons) . '.';
}
}

return $input;
throw new RuntimeException($message);
}

/**
Expand Down
12 changes: 6 additions & 6 deletions test/HordeStringTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Horde\Util\HordeString;
use RuntimeException;

#[CoversClass(HordeString::class)]
class HordeStringTest extends TestCase
Expand Down Expand Up @@ -517,13 +518,12 @@ public static function substrProvider()

public function testSubstrWithUnsupportedCharset()
{
// Test that substr gracefully handles unsupported charsets
// This validates the fix from commit 63d0ea4
$result = HordeString::substr('test string', 0, 4, 'UNSUPPORTED-CHARSET-12345');
// Test that substr throws exception for unsupported charsets
// Updated after removing deprecated utf8_encode/utf8_decode
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Unable to convert character set');

// Should fall back to other methods or return empty string
// rather than throwing an error
$this->assertIsString($result);
HordeString::substr('test string', 0, 4, 'UNSUPPORTED-CHARSET-12345');
}

public function testWordwrap()
Expand Down
4 changes: 3 additions & 1 deletion test/SimpleTest.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?php

/**
* Simple test to verify PHPUnit setup
*/
Expand All @@ -7,6 +8,7 @@

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\CoversNothing;
use Horde_Util;

#[CoversNothing]
class SimpleTest extends TestCase
Expand All @@ -33,7 +35,7 @@ public function testHordeUtilClassExists()

public function testCanInstantiateHordeUtil()
{
$util = new \Horde_Util();
$util = new Horde_Util();
$this->assertInstanceOf('Horde_Util', $util);
}
}
2 changes: 1 addition & 1 deletion test/TransliterateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ public function testTransliterateToAsciiIconvBad($str, $expected)
$this->markTestSkipped('iconv extension not installed');
}

set_error_handler(function() {});
set_error_handler(function () {});
$result = Transliterate::testIconv($str);
restore_error_handler();

Expand Down
7 changes: 4 additions & 3 deletions test/Unnamespaced/StringTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Horde_String;
use Horde_Util_Mock_String;

#[CoversClass(Horde_String::class)]
class StringTest extends TestCase
Expand Down Expand Up @@ -802,7 +803,7 @@ public static function invalidUtf8Provider()
#[DataProvider('ConvertCharsetIconvProvider')]
public function testConvertCharsetIconv(string $input, string $from, string $to, $expected): void
{
$result = \Horde_Util_Mock_String::testConvertCharsetIconv($input, $from, $to);
$result = Horde_Util_Mock_String::testConvertCharsetIconv($input, $from, $to);

if ($expected === null) {
// Test expects either false or successful conversion (iconv behavior varies)
Expand Down Expand Up @@ -832,7 +833,7 @@ public function testPosMbstring(string $haystack, string $needle, int $offset, s
{
$this->assertEquals(
$expected,
\Horde_Util_Mock_String::testPosMbstring($haystack, $needle, $offset, $charset, $func)
Horde_Util_Mock_String::testPosMbstring($haystack, $needle, $offset, $charset, $func)
);
}

Expand All @@ -852,7 +853,7 @@ public function testPosIntl(string $haystack, string $needle, int $offset, strin
{
$this->assertEquals(
$expected,
\Horde_Util_Mock_String::testPosIntl($haystack, $needle, $offset, $charset, $func)
Horde_Util_Mock_String::testPosIntl($haystack, $needle, $offset, $charset, $func)
);
}

Expand Down
2 changes: 1 addition & 1 deletion test/Unnamespaced/TransliterateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ public function testTransliterateToAsciiIconvBad($str, $expected)
$this->markTestSkipped('iconv extension not installed');
}

set_error_handler(function() {});
set_error_handler(function () {});
$result = Transliterate::testIconv($str);
restore_error_handler();

Expand Down
1 change: 1 addition & 0 deletions test/bootstrap.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?php

/**
* PHPUnit bootstrap file for Horde\Util
*/
Expand Down
Loading