From 799215655f391669a9bae1ea5da9c85ab264e24f Mon Sep 17 00:00:00 2001 From: John Rayes Date: Sun, 19 Apr 2026 20:13:30 -0700 Subject: [PATCH 01/17] add html entities on display, not in source --- Sources/PackageManager/PackageManager.php | 4 ++-- Sources/PackageManager/PackageUtils.php | 10 ---------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/Sources/PackageManager/PackageManager.php b/Sources/PackageManager/PackageManager.php index 97196cf263..9a1dca8429 100644 --- a/Sources/PackageManager/PackageManager.php +++ b/Sources/PackageManager/PackageManager.php @@ -1615,7 +1615,7 @@ public function browse(): void 'style' => 'width: 25%;', ], 'data' => [ - 'db' => 'name', + 'db_htmlsafe' => 'name', ], 'sort' => [ 'default' => 'name', @@ -1627,7 +1627,7 @@ public function browse(): void 'value' => Lang::getTxt('package_version_header', file: 'Packages'), ], 'data' => [ - 'db' => 'version', + 'db_htmlsafe' => 'version', ], 'sort' => [ 'default' => 'version', diff --git a/Sources/PackageManager/PackageUtils.php b/Sources/PackageManager/PackageUtils.php index 67800fbd4c..87d62b4e03 100644 --- a/Sources/PackageManager/PackageUtils.php +++ b/Sources/PackageManager/PackageUtils.php @@ -586,19 +586,9 @@ public static function getPackageInfo(string $gzfilename): array|string $packageInfo = $packageInfo->path('package-info[0]'); $package = $packageInfo->to_array(); - $package = Utils::htmlspecialcharsRecursive($package); $package['xml'] = $packageInfo; $package['filename'] = $gzfilename; - // Don't want to mess with code... - $types = ['install', 'uninstall', 'upgrade']; - - foreach ($types as $type) { - if (isset($package[$type]['code'])) { - $package[$type]['code'] = Utils::htmlspecialcharsDecode($package[$type]['code']); - } - } - if (!isset($package['type'])) { $package['type'] = 'modification'; } From b40ffdf7479c06d2ade87a9fba11b54f61b210c5 Mon Sep 17 00:00:00 2001 From: John Rayes Date: Sun, 19 Apr 2026 20:24:21 -0700 Subject: [PATCH 02/17] template layers --- Sources/PackageManager/PackageManager.php | 13 +++++++++-- Themes/default/Packages.template.php | 27 ++++++++++++----------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/Sources/PackageManager/PackageManager.php b/Sources/PackageManager/PackageManager.php index 9a1dca8429..87b2abe5f3 100644 --- a/Sources/PackageManager/PackageManager.php +++ b/Sources/PackageManager/PackageManager.php @@ -1689,8 +1689,17 @@ public function browse(): void new ItemList($listOptions); } - Utils::$context['sub_template'] = 'browse'; - Utils::$context['default_list'] = 'packages_lists'; + if (Utils::$context['available_packages'] === 0) { + Utils::$context['sub_templates'][] = 'no_packages'; + } else { + foreach (Utils::$context['modification_types'] as $type) { + if (!empty(Utils::$context['packages_lists_' . $type]['rows'])) { + Utils::$context['sub_templates'][] = ['show_list', ['packages_lists_' . $type]]; + } + } + } + + Utils::$context['template_layers'][] = 'browse'; $get_versions = Db::$db->query( 'SELECT data FROM {db_prefix}admin_info_files WHERE filename={string:versionsfile} AND path={string:smf}', diff --git a/Themes/default/Packages.template.php b/Themes/default/Packages.template.php index 6beda4e4d7..61cdbef03d 100644 --- a/Themes/default/Packages.template.php +++ b/Themes/default/Packages.template.php @@ -553,7 +553,7 @@ function template_examine() /** * List all packages */ -function template_browse() +function template_browse_above() { echo '
@@ -608,21 +608,22 @@ function template_browse() echo ' '; +} - if (Utils::$context['available_packages'] == 0) { - echo ' +/** + * List all packages + */ +function template_no_packages() +{ + echo '
', Lang::getTxt('no_packages', file: 'Packages'), '
'; - } else { - foreach (Utils::$context['modification_types'] as $type) { - if (!empty(Utils::$context['packages_lists_' . $type]['rows'])) { - template_show_list('packages_lists_' . $type); - } - } - - echo ' -
'; - } +} +/** + * List all packages + */ +function template_browse_below() +{ // The advanced (emulation) box, collapsed by default echo '
From 56636459a0975a023fc324f78cb7f7e8864426de Mon Sep 17 00:00:00 2001 From: John Rayes Date: Sun, 19 Apr 2026 20:31:33 -0700 Subject: [PATCH 03/17] do not display id --- Languages/en_US/Packages.php | 1 - Sources/PackageManager/PackageManager.php | 12 ------------ 2 files changed, 13 deletions(-) diff --git a/Languages/en_US/Packages.php b/Languages/en_US/Packages.php index 23004a845d..0b75e301fb 100644 --- a/Languages/en_US/Packages.php +++ b/Languages/en_US/Packages.php @@ -5,7 +5,6 @@ $txt['package_proceed'] = 'Proceed'; $txt['php_script'] = 'Modification file was extracted, but this modification also comes with a PHP script which should be executed before it will work'; $txt['package_run'] = 'Run'; -$txt['package_id'] = 'ID'; $txt['package_read'] = 'Read'; $txt['script_output'] = 'Script output:'; $txt['additional_notes'] = 'Additional notes'; diff --git a/Sources/PackageManager/PackageManager.php b/Sources/PackageManager/PackageManager.php index 87b2abe5f3..5a43352ed7 100644 --- a/Sources/PackageManager/PackageManager.php +++ b/Sources/PackageManager/PackageManager.php @@ -1597,18 +1597,6 @@ public function browse(): void 'base_href' => Config::$scripturl . '?action=admin;area=packages;sa=browse;type=' . $type, 'default_sort_col' => 'id' . $type, 'columns' => [ - 'id' . $type => [ - 'header' => [ - 'value' => Lang::getTxt('package_id', file: 'Packages'), - ], - 'data' => [ - 'db' => 'sort_id', - ], - 'sort' => [ - 'default' => 'sort_id', - 'reverse' => 'sort_id', - ], - ], 'package_name' . $type => [ 'header' => [ 'value' => Lang::getTxt('package_name_header', file: 'Packages'), From 7210c02af24d3ae3ba858fe6bb84d454844fa75f Mon Sep 17 00:00:00 2001 From: John Rayes Date: Sun, 19 Apr 2026 20:38:08 -0700 Subject: [PATCH 04/17] each list shall have its own sorting variable --- Sources/PackageManager/PackageManager.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/PackageManager/PackageManager.php b/Sources/PackageManager/PackageManager.php index 5a43352ed7..756cc697a0 100644 --- a/Sources/PackageManager/PackageManager.php +++ b/Sources/PackageManager/PackageManager.php @@ -1593,11 +1593,14 @@ public function browse(): void 'get_items' => [ 'function' => [$this, 'list_getPackages'], 'params' => [$type], + 'base_href' => Config::$scripturl . '?action=admin;area=packages;sa=browse', + 'default_sort_col' => 'time_installed', + 'default_sort_dir' => 'desc', + 'request_vars' => [ + 'sort' => $type . '_sort', ], - 'base_href' => Config::$scripturl . '?action=admin;area=packages;sa=browse;type=' . $type, - 'default_sort_col' => 'id' . $type, 'columns' => [ - 'package_name' . $type => [ + 'package_name' => [ 'header' => [ 'value' => Lang::getTxt('package_name_header', file: 'Packages'), 'style' => 'width: 25%;', @@ -1610,7 +1613,7 @@ public function browse(): void 'reverse' => 'name', ], ], - 'version' . $type => [ + 'version' => [ 'header' => [ 'value' => Lang::getTxt('package_version_header', file: 'Packages'), ], @@ -1622,7 +1625,7 @@ public function browse(): void 'reverse' => 'version', ], ], - 'time_installed' . $type => [ + 'time_installed' => [ 'header' => [ 'value' => Lang::getTxt('package_installed_time', file: 'Packages'), ], @@ -1639,10 +1642,7 @@ public function browse(): void 'reverse' => 'time_installed', ], ], - 'operations' . $type => [ - 'header' => [ - 'value' => '', - ], + 'operations' => [ 'data' => [ 'function' => function ($package) use ($type) { $return = ''; From 5ec91464049da3ab37754e86c98ea6066640675e Mon Sep 17 00:00:00 2001 From: John Rayes Date: Sun, 19 Apr 2026 20:49:33 -0700 Subject: [PATCH 05/17] sort each list separately --- Sources/PackageManager/PackageManager.php | 57 ++++++++++------------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/Sources/PackageManager/PackageManager.php b/Sources/PackageManager/PackageManager.php index 756cc697a0..1e2d1676e6 100644 --- a/Sources/PackageManager/PackageManager.php +++ b/Sources/PackageManager/PackageManager.php @@ -1579,7 +1579,8 @@ public function browse(): void Utils::$context['page_title'] .= ' - ' . Lang::getTxt('browse_packages', file: 'Admin'); Utils::$context['forum_version'] = SMF_FULL_VERSION; - Utils::$context['available_packages'] = 0; + $packages = self::getPackages(); + Utils::$context['available_packages'] = \count($packages); Utils::$context['modification_types'] = ['modification', 'avatar', 'language', 'unknown', 'smiley']; IntegrationHook::call('integrate_modification_types'); @@ -1591,8 +1592,22 @@ public function browse(): void 'title' => Lang::getTxt($type . '_package', file: 'Packages'), 'no_items_label' => Lang::getTxt('no_packages', file: 'Packages'), 'get_items' => [ - 'function' => [$this, 'list_getPackages'], - 'params' => [$type], + 'function' => function($start, $items_per_page, $sort) use ($packages, $type) + { + if (!isset($packages[$type])) + return []; + + $col = strtok($sort, ' '); + $dir = strtok(' '); + array_multisort( + array_column($packages[$type], $col), + $dir === 'desc' ? SORT_DESC : SORT_ASC, + $packages[$type] + ); + + return $packages[$type]; + }, + ], 'base_href' => Config::$scripturl . '?action=admin;area=packages;sa=browse', 'default_sort_col' => 'time_installed', 'default_sort_dir' => 'desc', @@ -1610,7 +1625,7 @@ public function browse(): void ], 'sort' => [ 'default' => 'name', - 'reverse' => 'name', + 'reverse' => 'name desc', ], ], 'version' => [ @@ -1622,7 +1637,7 @@ public function browse(): void ], 'sort' => [ 'default' => 'version', - 'reverse' => 'version', + 'reverse' => 'version desc', ], ], 'time_installed' => [ @@ -1639,7 +1654,7 @@ public function browse(): void ], 'sort' => [ 'default' => 'time_installed', - 'reverse' => 'time_installed', + 'reverse' => 'time_installed desc', ], ], 'operations' => [ @@ -3182,18 +3197,13 @@ public function serverRemove(): void * Determines whether the package has been installed or not by * checking it against {@link loadInstalledPackages()}. * - * @param int $start The item to start with (not used here) - * @param int $items_per_page The number of items to show per page (not used here) - * @param string $sort A string indicating how to sort the results - * @param string $params Type of packages * @return array An array of information about the packages */ - public function list_getPackages(int $start, int $items_per_page, string $sort, string $params): array + public function getPackages(): array { static $installed_mods; $packages = []; - $column = []; // We need the packages directory to be writable for this. if (!@is_writable(Config::$packagesdir)) { @@ -3279,11 +3289,9 @@ public function list_getPackages(int $start, int $items_per_page, string $sort, continue; } - if (!empty($packageInfo)) { + if ($packageInfo !== []) { if (!isset($sort_id[$packageInfo['type']])) { - $packageInfo['sort_id'] = $sort_id['unknown']; - } else { - $packageInfo['sort_id'] = $sort_id[$packageInfo['type']]; + $packageInfo['type'] = 'unknown'; } $packageInfo['time_installed'] = 0; @@ -3371,26 +3379,11 @@ public function list_getPackages(int $start, int $items_per_page, string $sort, // Save some memory by not passing the XmlArray object into context. unset($packageInfo['xml']); - if (isset($sort_id[$packageInfo['type']]) && $params == $packageInfo['type']) { - $column[] = $packageInfo[$sort]; - $sort_id[$packageInfo['type']]++; - $packages[] = $packageInfo; - } elseif (!isset($sort_id[$packageInfo['type']]) && $params == 'unknown') { - $column[] = $packageInfo[$sort]; - $packageInfo['sort_id'] = $sort_id['unknown']; - $sort_id['unknown']++; - $packages[] = $packageInfo; - } + $packages[$packageInfo['type']][] = $packageInfo; } } closedir($dir); } - Utils::$context['available_packages'] += \count($packages); - array_multisort( - $column, - isset($_GET['desc']) ? SORT_DESC : SORT_ASC, - $packages, - ); return $packages; } From 115f8400bdb78717e957c70a3681d32e647caaee Mon Sep 17 00:00:00 2001 From: John Rayes Date: Sun, 19 Apr 2026 20:54:08 -0700 Subject: [PATCH 06/17] unpack supports offsets now --- Sources/PackageManager/PackageUtils.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/PackageManager/PackageUtils.php b/Sources/PackageManager/PackageUtils.php index 87d62b4e03..7c2efb05c4 100644 --- a/Sources/PackageManager/PackageUtils.php +++ b/Sources/PackageManager/PackageUtils.php @@ -315,7 +315,7 @@ public static function readZipData(string $data, ?string $destination, bool $sin $return = []; // End of central directory record (EOCD) - $cdir = unpack('vdisk/@4/vdisk_entries/ventries/@12/Voffset', substr($data, $data_ecr + 4, 16)); + $cdir = unpack('vdisk/@4/vdisk_entries/ventries/@12/Voffset', $data, $data_ecr + 4); // We only support a single disk. if ($cdir['disk_entries'] != $cdir['entries']) { @@ -327,7 +327,7 @@ public static function readZipData(string $data, ?string $destination, bool $sin for ($i = 0; $i < $cdir['entries']; $i++) { // Central directory file header - $header = unpack('Vcompressed_size/@8/vlen1/vlen2/vlen3/vdisk/@22/Voffset', substr($data, $pos_entry + 20, 26)); + $header = unpack('Vcompressed_size/@8/vlen1/vlen2/vlen3/vdisk/@22/Voffset', $data, $pos_entry + 20); // Sanity check: same disk? if ($header['disk'] != $cdir['disk']) { @@ -340,11 +340,12 @@ public static function readZipData(string $data, ?string $destination, bool $sin // Local file header (so called because it is in the same file as the data in multi-part archives) $file_info = unpack( 'vflag/vcompression/vmtime/vmdate/Vcrc/Vcompressed_size/Vsize/vfilename_len/vextra_len', - substr($data, $header['offset'] + 6, 24), + $data, + $header['offset'] + 6 ); $file_info['filename'] = substr($data, $header['offset'] + 30, $file_info['filename_len']); - $is_file = !str_ends_with($file_info['filename'], '/'); + $is_file = $file_info['filename'][-1] !== '/'; /* * If the bit at offset 3 (0x08) of the general-purpose flags field @@ -361,7 +362,7 @@ public static function readZipData(string $data, ?string $destination, bool $sin $gplen += 4; } - if (($general_purpose = unpack('Vcrc/Vcompressed_size/Vsize', substr($data, $gplen, 12))) !== false) { + if (($general_purpose = unpack('Vcrc/Vcompressed_size/Vsize', $data, $gplen)) !== false) { $file_info = $general_purpose + $file_info; } } From 5fabc5a1d537bf0b68817a1aeb56dc8c355bcaf9 Mon Sep 17 00:00:00 2001 From: John Rayes Date: Sun, 19 Apr 2026 21:07:29 -0700 Subject: [PATCH 07/17] only extract data when we know for sure that we want that file --- Sources/PackageManager/PackageUtils.php | 47 ++++++++++++------------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/Sources/PackageManager/PackageUtils.php b/Sources/PackageManager/PackageUtils.php index 7c2efb05c4..9bce83b0bb 100644 --- a/Sources/PackageManager/PackageUtils.php +++ b/Sources/PackageManager/PackageUtils.php @@ -214,7 +214,6 @@ public static function readTgzData(string $data, ?string $destination, bool $sin } $size = ceil($current['size'] / 512); - $current['data'] = substr($data, ++$offset << 9, $current['size']); $offset += $size; // If hunting for a file in subdirectories, pass to subsequent write test... @@ -243,6 +242,8 @@ public static function readTgzData(string $data, ?string $destination, bool $sin } if ($write_this && $destination !== null) { + $current['data'] = substr($data, $offset + 512, $current['size']); + if (str_contains($current['filename'], '/') && !$single_file) { self::mktree($destination . '/' . \dirname($current['filename']), 0777); } @@ -268,8 +269,6 @@ public static function readTgzData(string $data, ?string $destination, bool $sin if (!str_ends_with($current['filename'], '/')) { $return[] = [ 'filename' => $current['filename'], - 'md5' => md5($current['data']), - 'preview' => substr($current['data'], 0, 100), 'size' => $current['size'], 'skipped' => false, ]; @@ -388,29 +387,29 @@ public static function readZipData(string $data, ?string $destination, bool $sin } } - // Get the actual compressed data. - $file_info['data'] = substr( - $data, - $header['offset'] + 30 + $file_info['filename_len'] + $file_info['extra_len'], - $file_info['compressed_size'], - ); + // Okay! We can write this file, looks good from here... + if ($write_this) { + // Get the actual compressed data. + $file_info['data'] = substr( + $data, + $header['offset'] + 30 + $file_info['filename_len'] + $file_info['extra_len'], + $file_info['compressed_size'], + ); - // Only for the deflate method (the most common) - if ($file_info['compression'] == 8) { - $file_info['data'] = gzinflate($file_info['data']); - } - // We do not support any other compression methods. - elseif ($file_info['compression'] != 0) { - continue; - } + // Only for the deflate method (the most common) + if ($file_info['compression'] === 8) { + $file_info['data'] = gzinflate($file_info['data']); + } + // We do not support any other compression methods. + elseif ($file_info['compression'] !== 0) { + continue; + } - // PKZip/ITU-T V.42 CRC-32 - if (hash('crc32b', $file_info['data']) !== \sprintf('%08x', $file_info['crc'])) { - continue; - } + // PKZip/ITU-T V.42 CRC-32 + if (hash('crc32b', $file_info['data']) !== dechex($file_info['crc'])) { + continue; + } - // Okay! We can write this file, looks good from here... - if ($write_this) { // If we're looking for a specific file, and this is it... ka-bam, baby. if ($single_file && ($destination == $file_info['filename'] || $destination == '*/' . basename($file_info['filename']))) { return $file_info['data']; @@ -431,8 +430,6 @@ public static function readZipData(string $data, ?string $destination, bool $sin if ($is_file) { $return[] = [ 'filename' => $file_info['filename'], - 'md5' => md5($file_info['data']), - 'preview' => substr($file_info['data'], 0, 100), 'size' => $file_info['size'], 'skipped' => false, ]; From 1ee1c5a16dd72da0d35c2ba50a2515f53a3f6583 Mon Sep 17 00:00:00 2001 From: John Rayes Date: Sun, 19 Apr 2026 23:47:02 -0700 Subject: [PATCH 08/17] reformat installed mods into a hash map that we can use directly --- Sources/PackageManager/PackageManager.php | 34 ++++++++--------------- Sources/PackageManager/PackageUtils.php | 13 +++------ 2 files changed, 15 insertions(+), 32 deletions(-) diff --git a/Sources/PackageManager/PackageManager.php b/Sources/PackageManager/PackageManager.php index 1e2d1676e6..153fcba5bd 100644 --- a/Sources/PackageManager/PackageManager.php +++ b/Sources/PackageManager/PackageManager.php @@ -3201,8 +3201,6 @@ public function serverRemove(): void */ public function getPackages(): array { - static $installed_mods; - $packages = []; // We need the packages directory to be writable for this. @@ -3232,22 +3230,10 @@ public function getPackages(): array unset($_SESSION['single_version_emulate']); } - if (empty($installed_mods)) { - $instmods = PackageUtils::loadInstalledPackages(); - $installed_mods = []; + $installed_mods = PackageUtils::loadInstalledPackages(); - // Look through the list of installed mods... - foreach ($instmods as $installed_mod) { - $installed_mods[$installed_mod['package_id']] = [ - 'id' => $installed_mod['id'], - 'version' => $installed_mod['version'], - 'time_installed' => $installed_mod['time_installed'], - ]; - } - - // Get a list of all the ids installed, so the latest packages won't include already installed ones. - Utils::$context['installed_mods'] = array_keys($installed_mods); - } + // Get a list of all the ids installed, so the latest packages widget won't include already installed ones. + Utils::$context['installed_mods'] = array_keys($installed_mods); if ($dir = @opendir(Config::$packagesdir)) { $dirs = []; @@ -3295,15 +3281,17 @@ public function getPackages(): array } $packageInfo['time_installed'] = 0; - $packageInfo['is_installed'] = isset($installed_mods[$packageInfo['id']]); + $id = $packageInfo['id']; + $packageInfo['is_installed'] = isset($installed_mods[$id]); if ($packageInfo['is_installed']) { - $packageInfo['is_current'] = $installed_mods[$packageInfo['id']]['version'] == $packageInfo['version']; - $packageInfo['is_newer'] = $installed_mods[$packageInfo['id']]['version'] > $packageInfo['version']; - $packageInfo['installed_id'] = $installed_mods[$packageInfo['id']]['id']; + $installed_mod = $installed_mods[$id]; + $packageInfo['is_current'] = $installed_mod['version'] == $packageInfo['version']; + $packageInfo['is_newer'] = $installed_mod['version'] > $packageInfo['version']; + $packageInfo['installed_id'] = $installed_mod['id']; if ($packageInfo['is_current']) { - $packageInfo['time_installed'] = $installed_mods[$packageInfo['id']]['time_installed']; + $packageInfo['time_installed'] = $installed_mod['time_installed']; } } @@ -3345,7 +3333,7 @@ public function getPackages(): array foreach ($upgrades as $upgrade) { // Even if it is for this SMF, is it for the installed version of the mod? if (!$upgrade->exists('@for') || PackageUtils::matchPackageVersion($the_version, $upgrade->fetch('@for'))) { - if (!$upgrade->exists('@from') || PackageUtils::matchPackageVersion((string) $installed_mods[$packageInfo['id']]['version'], $upgrade->fetch('@from'))) { + if (!$upgrade->exists('@from') || PackageUtils::matchPackageVersion((string) $installed_mod['version'], $upgrade->fetch('@from'))) { $packageInfo['can_upgrade'] = true; break; } diff --git a/Sources/PackageManager/PackageUtils.php b/Sources/PackageManager/PackageUtils.php index 9bce83b0bb..0703a3c25c 100644 --- a/Sources/PackageManager/PackageUtils.php +++ b/Sources/PackageManager/PackageUtils.php @@ -495,24 +495,19 @@ public static function loadInstalledPackages(): array ], ); $installed = []; - $found = []; while ($row = Db::$db->fetch_assoc($request)) { // Already found this? If so don't add it twice! - if (\in_array($row['package_id'], $found)) { + if (isset($installed[$row['package_id']])) { continue; } - $found[] = $row['package_id']; - - $row = Utils::htmlspecialcharsRecursive($row, ENT_QUOTES); - - $installed[] = [ + $installed[$row['package_id']] = [ 'id' => $row['id_install'], - 'name' => Utils::htmlspecialchars($row['name']), + 'name' => $row['name'], 'filename' => $row['filename'], 'package_id' => $row['package_id'], - 'version' => Utils::htmlspecialchars($row['version']), + 'version' => $row['version'], 'time_installed' => !empty($row['time_installed']) ? $row['time_installed'] : 0, ]; } From 83314ace195e86af8ec831f8be77a4b50ac3aeaa Mon Sep 17 00:00:00 2001 From: John Rayes Date: Mon, 20 Apr 2026 00:01:48 -0700 Subject: [PATCH 09/17] ord cache --- Sources/PackageManager/PackageUtils.php | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Sources/PackageManager/PackageUtils.php b/Sources/PackageManager/PackageUtils.php index 0703a3c25c..ffe3d521ef 100644 --- a/Sources/PackageManager/PackageUtils.php +++ b/Sources/PackageManager/PackageUtils.php @@ -194,9 +194,15 @@ public static function readTgzData(string $data, ?string $destination, bool $sin $current[$k] = trim($v); } } + static $ord_cache = null; if ($current['type'] == '5' && !str_ends_with($current['filename'], '/')) { $current['filename'] .= '/'; + if ($ord_cache === null) { + $ord_cache = []; + for ($i = 0; $i < 256; $i++) { + $ord_cache[\chr($i)] = $i; + } } $checksum = 256; @@ -3706,18 +3712,27 @@ private static function writeTgzFile( '', // Padding ); + static $ord_cache = null; + + if ($ord_cache === null) { + $ord_cache = []; + for ($i = 0; $i < 256; $i++) { + $ord_cache[\chr($i)] = $i; + } + } + // Calculate checksum for the tar header. $checksum = 256; for ($i = 0; $i < 148; $i++) { if ($data_first[$i] !== "\0") { - $checksum += \ord($data_first[$i]); + $checksum += $ord_cache[$data_first[$i]]; } } for ($i = 0; $i < 356; $i++) { if ($data_last[$i] !== "\0") { - $checksum += \ord($data_last[$i]); + $checksum += $ord_cache[$data_last[$i]]; } } From a7498d977f1ad0236a0ad4f3427c5de54426d983 Mon Sep 17 00:00:00 2001 From: John Rayes Date: Mon, 20 Apr 2026 00:08:27 -0700 Subject: [PATCH 10/17] more readable code for directory traversal --- Sources/PackageManager/PackageManager.php | 27 +++++++++++++---------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/Sources/PackageManager/PackageManager.php b/Sources/PackageManager/PackageManager.php index 153fcba5bd..7350ba207c 100644 --- a/Sources/PackageManager/PackageManager.php +++ b/Sources/PackageManager/PackageManager.php @@ -3247,26 +3247,29 @@ public function getPackages(): array IntegrationHook::call('integrate_packages_sort_id', [&$sort_id, &$packages]); while ($package = readdir($dir)) { - if ($package == '.' || $package == '..' || $package == 'temp' || (!(is_dir(Config::$packagesdir . '/' . $package) && file_exists(Config::$packagesdir . '/' . $package . '/package-info.xml')) && !str_ends_with(strtolower($package), '.tar.gz') && !str_ends_with(strtolower($package), '.tgz') && !str_ends_with(strtolower($package), '.zip'))) { + // Skip hidden files and directories. + if ($package[0] === '.' || $package === 'temp') { continue; } - // Skip directories or files that are named the same. - if (is_dir(Config::$packagesdir . '/' . $package)) { + $is_dir = is_dir(Config::$packagesdir . '/' . $package); + if ($is_dir) + { + // Skip packages that are named the same. if (\in_array($package, $dirs)) { continue; } $dirs[] = $package; - } elseif (str_ends_with(strtolower($package), '.tar.gz')) { - if (\in_array(substr($package, 0, -7), $dirs)) { - continue; - } - $dirs[] = substr($package, 0, -7); - } elseif (str_ends_with(strtolower($package), '.zip') || str_ends_with(strtolower($package), '.tgz')) { - if (\in_array(substr($package, 0, -4), $dirs)) { - continue; + } else { + // pathinfo() does not parse complex file extensions correctly, so do it manually. + if (preg_match('/^.*(?=\.(?:zip|t(?:ar\.)?gz)$)/i', $package, $m)) { + $basename = $m[0]; + + if (\in_array($basename, $dirs)) { + continue; + } + $dirs[] = $basename; } - $dirs[] = substr($package, 0, -4); } $packageInfo = PackageUtils::getPackageInfo($package); From 0047777795b553ced42683adef53498ab35c05b2 Mon Sep 17 00:00:00 2001 From: John Rayes Date: Mon, 20 Apr 2026 00:12:23 -0700 Subject: [PATCH 11/17] rewrite tar reader to make fewer temp strings --- Sources/PackageManager/PackageUtils.php | 145 +++++++++++++----------- 1 file changed, 80 insertions(+), 65 deletions(-) diff --git a/Sources/PackageManager/PackageUtils.php b/Sources/PackageManager/PackageUtils.php index ffe3d521ef..b1b79b5670 100644 --- a/Sources/PackageManager/PackageUtils.php +++ b/Sources/PackageManager/PackageUtils.php @@ -139,31 +139,45 @@ public static function readTgzData(string $data, ?string $destination, bool $sin self::mktree($destination, 0777); } - $flags = unpack('Ct/Cf', substr($data, 2, 2)); - // Not deflate! - if ($flags['t'] != 8) { + if ($data[2] != "\x08") { return false; } - $flags = $flags['f']; + // RFC 1952 + $flags = ord($data[3]); + + // Gzip file header is always 10 bytes. $offset = 10; - $octdec = ['mode', 'uid', 'gid', 'size', 'mtime', 'checksum']; - // "Read" the filename and comment. - // @todo Might be mussed. - if ($flags & 12) { - while ($flags & 8 && $data[$offset++] != "\0") { - continue; - } + // fHCRC + if ($flags & 0x2) { + $offset += 2; + } - while ($flags & 4 && $data[$offset++] != "\0") { - continue; - } + // fEXTRA + if ($flags & 0x4) { + $xlen = unpack('v', $data, $offset)[1]; + $offset += 2 + $xlen; } - $crc = unpack('Vcrc32/Visize', substr($data, \strlen($data) - 8, 8)); - $data = @gzinflate(substr($data, $offset, \strlen($data) - 8 - $offset)); + // fNAME + while ($flags & 0x8 && $data[$offset++] !== "\0") { + continue; + } + + // fCOMMENT + while ($flags & 0x10 && $data[$offset++] !== "\0") { + continue; + } + + $len = \strlen($data); + $crc = unpack('V', $data, $len - 8)[1]; + $data = gzinflate(substr($data, $offset, -8)); + + if ($data === false) { + return false; + } // Compare as hex strings rather than integers in order to avoid // inconsistencies between 32-bit and 64-bit systems. @@ -171,33 +185,23 @@ public static function readTgzData(string $data, ?string $destination, bool $sin return false; } - $blocks = \strlen($data) / 512 - 1; - $offset = 0; - + $blocks = $len / 512 - 1; + $block = 0; $return = []; - while ($offset < $blocks) { - $header = substr($data, $offset << 9, 512); - $current = unpack('a100filename/a8mode/a8uid/a8gid/a12size/a12mtime/a8checksum/a1type/a100linkname/a6magic/a2version/a32uname/a32gname/a8devmajor/a8devminor/a155path', $header); - - // Blank record? This is probably at the end of the file. - if (empty($current['filename'])) { - $offset += 512; + // All data is block-aligned by 512 bytes. + while ($block < $blocks) + { + $offset = $block++ << 9; + $file_info = unpack('Z100filename/@124/A12size/A12mtime/A8checksum/Atype/@345/Z155prefix', $data, $offset); + // Blank record? This is probably at the end of the file. + if (empty($file_info['filename'])) { continue; } - foreach ($current as $k => $v) { - if (\in_array($k, $octdec)) { - $current[$k] = octdec(trim($v)); - } else { - $current[$k] = trim($v); - } - } static $ord_cache = null; - if ($current['type'] == '5' && !str_ends_with($current['filename'], '/')) { - $current['filename'] .= '/'; if ($ord_cache === null) { $ord_cache = []; for ($i = 0; $i < 256; $i++) { @@ -207,40 +211,55 @@ public static function readTgzData(string $data, ?string $destination, bool $sin $checksum = 256; - for ($i = 0; $i < 148; $i++) { - $checksum += \ord($header[$i]); + for ($i = 0, $j = $offset; $i < 148; $i++, $j++) { + $checksum += $ord_cache[$data[$j]]; + } + + for ($i = 156, $j = $offset + 156; $i < 512; $i++, $j++) { + $checksum += $ord_cache[$data[$j]]; + } + + if (octdec($file_info['checksum']) != $checksum) { + continue; } - for ($i = 156; $i < 512; $i++) { - $checksum += \ord($header[$i]); + // Handle ustar Posix compliant path prefixes. + if (!empty($file_info['prefix'])) { + $file_info['filename'] = $file_info['prefix'] . '/' . $file_info['filename']; } - if ($current['checksum'] != $checksum) { - break; + // Ensure that directory listings end in a slash. + if ($file_info['type'] === '5' && $file_info['filename'][-1] !== '/') { + $file_info['filename'] .= '/'; } - $size = ceil($current['size'] / 512); - $offset += $size; + $is_file = $file_info['filename'][-1] !== '/'; + $file_info['size'] = octdec($file_info['size']); + $file_info['mtime'] = octdec($file_info['mtime']); + + // Calculate the number of blocks taken up by the data. + $size = ceil($file_info['size'] / 512); + $block += $size; // If hunting for a file in subdirectories, pass to subsequent write test... - if ($single_file && $destination !== null && (str_starts_with($destination, '*/'))) { + if ($single_file && $destination !== null && str_starts_with($destination, '*/')) { $write_this = true; } // Not a directory and doesn't exist already... - elseif (!str_ends_with($current['filename'], '/') && $destination !== null && !file_exists($destination . '/' . $current['filename'])) { + elseif ($is_file && $destination !== null && !file_exists($destination . '/' . $file_info['filename'])) { $write_this = true; } // File exists... check if it is newer. - elseif (!str_ends_with($current['filename'], '/')) { - $write_this = $overwrite || ($destination !== null && filemtime($destination . '/' . $current['filename']) < $current['mtime']); + elseif ($is_file) { + $write_this = $overwrite || ($destination !== null && filemtime($destination . '/' . $file_info['filename']) < $file_info['mtime']); } // Folder... create. elseif ($destination !== null && !$single_file) { // Protect from accidental parent directory writing... - $current['filename'] = strtr($current['filename'], ['../' => '', '/..' => '']); + $file_info['filename'] = strtr($file_info['filename'], ['../' => '', '/..' => '']); - if (!file_exists($destination . '/' . $current['filename'])) { - self::mktree($destination . '/' . $current['filename'], 0777); + if (!file_exists($destination . '/' . $file_info['filename'])) { + self::mktree($destination . '/' . $file_info['filename'], 0777); } $write_this = false; } else { @@ -248,15 +267,15 @@ public static function readTgzData(string $data, ?string $destination, bool $sin } if ($write_this && $destination !== null) { - $current['data'] = substr($data, $offset + 512, $current['size']); + $file_info['data'] = substr($data, $offset + 512, $file_info['size']); - if (str_contains($current['filename'], '/') && !$single_file) { - self::mktree($destination . '/' . \dirname($current['filename']), 0777); + if (str_contains($file_info['filename'], '/') && !$single_file) { + self::mktree($destination . '/' . \dirname($file_info['filename']), 0777); } // Is this the file we're looking for? - if ($single_file && ($destination == $current['filename'] || $destination == '*/' . basename($current['filename']))) { - return $current['data']; + if ($single_file && ($destination === $file_info['filename'] || $destination === '*/' . basename($file_info['filename']))) { + return $file_info['data']; } // If we're looking for another file, keep going. @@ -265,17 +284,17 @@ public static function readTgzData(string $data, ?string $destination, bool $sin } // Looking for restricted files? - if ($files_to_extract !== null && !\in_array($current['filename'], $files_to_extract)) { + if ($files_to_extract !== null && !\in_array($file_info['filename'], $files_to_extract)) { continue; } - self::packagePutContents($destination . '/' . $current['filename'], $current['data']); + self::packagePutContents($destination . '/' . $file_info['filename'], $file_info['data']); } - if (!str_ends_with($current['filename'], '/')) { + if ($is_file) { $return[] = [ - 'filename' => $current['filename'], - 'size' => $current['size'], + 'filename' => $file_info['filename'], + 'size' => $file_info['size'], 'skipped' => false, ]; } @@ -285,11 +304,7 @@ public static function readTgzData(string $data, ?string $destination, bool $sin self::flushCache(); } - if ($single_file) { - return false; - } - - return $return; + return $single_file ? false : $return; } /** From 2d6e4cef084ca5dffc07fcecda0292601e7ff40e Mon Sep 17 00:00:00 2001 From: John Rayes Date: Mon, 20 Apr 2026 00:17:20 -0700 Subject: [PATCH 12/17] build dirname in temp string once and reuse it --- Sources/PackageManager/PackageUtils.php | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Sources/PackageManager/PackageUtils.php b/Sources/PackageManager/PackageUtils.php index b1b79b5670..fc0c5c87fc 100644 --- a/Sources/PackageManager/PackageUtils.php +++ b/Sources/PackageManager/PackageUtils.php @@ -549,18 +549,21 @@ public static function loadInstalledPackages(): array */ public static function getPackageInfo(string $gzfilename): array|string { - // Extract package-info.xml from downloaded file. (*/ is used because it could be in any directory.) + $path = Config::$packagesdir . '/' . $gzfilename; + + // Extract package-info.xml if (str_contains($gzfilename, 'http://') || str_contains($gzfilename, 'https://')) { $packageInfo = self::readTgzData($gzfilename, 'package-info.xml', true); } else { - if (!file_exists(Config::$packagesdir . '/' . $gzfilename)) { + if (!file_exists($path)) { return 'package_get_error_not_found'; } - if (is_file(Config::$packagesdir . '/' . $gzfilename)) { - $packageInfo = self::readTgzFile(Config::$packagesdir . '/' . $gzfilename, '*/package-info.xml', true); - } elseif (file_exists(Config::$packagesdir . '/' . $gzfilename . '/package-info.xml')) { - $packageInfo = file_get_contents(Config::$packagesdir . '/' . $gzfilename . '/package-info.xml'); + + if (is_file($path)) { + $packageInfo = self::readTgzFile($path, '*/package-info.xml', true); + } elseif (file_exists($path . '/package-info.xml')) { + $packageInfo = file_get_contents($path . '/package-info.xml'); } else { return 'package_get_error_missing_xml'; } @@ -569,20 +572,17 @@ public static function getPackageInfo(string $gzfilename): array|string // Nothing? if (empty($packageInfo)) { // Perhaps they are trying to install a theme, lets tell them nicely this is the wrong function - $packageInfo = self::readTgzFile(Config::$packagesdir . '/' . $gzfilename, '*/theme_info.xml', true); + $packageInfo = self::readTgzFile($path, '*/theme_info.xml', true); if (!empty($packageInfo)) { return 'package_get_error_is_theme'; } - if ( - is_file(Config::$packagesdir . '/' . $gzfilename) - && filesize(Config::$packagesdir . '/' . $gzfilename) === 0 - ) { + if (is_file($path) && filesize($path) === 0) { return 'package_get_error_is_zero'; } - if (!file_exists(Config::$packagesdir . '/' . $gzfilename . '/package-info.xml')) { + if (!file_exists($path . '/package-info.xml')) { return 'package_get_error_missing_xml'; } From 356b8052415f781cb9142711569d1efee3faeb0c Mon Sep 17 00:00:00 2001 From: John Rayes Date: Mon, 20 Apr 2026 00:19:25 -0700 Subject: [PATCH 13/17] wrong function name used --- Sources/PackageManager/PackageUtils.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/PackageManager/PackageUtils.php b/Sources/PackageManager/PackageUtils.php index fc0c5c87fc..874a572c18 100644 --- a/Sources/PackageManager/PackageUtils.php +++ b/Sources/PackageManager/PackageUtils.php @@ -3481,7 +3481,7 @@ public static function createBackup(string $id = 'backup'): bool ]; $output_ext = \function_exists('gzopen') ? 'tgz' : 'tar'; $dirname = Config::$packagesdir . '/backups'; - $output_file = self::package_unique_filename( + $output_file = self::generateUniqueFilename( $dirname, date('Y-m-d_') . preg_replace('/[$\\/:<>|?*"\']/', '', $id), $output_ext, From 8c84c1007bb3754ba80c6f61978970dc9b77a9d4 Mon Sep 17 00:00:00 2001 From: John Rayes Date: Mon, 20 Apr 2026 00:27:11 -0700 Subject: [PATCH 14/17] avoid sprintf() when building tar backups --- Sources/PackageManager/PackageUtils.php | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/Sources/PackageManager/PackageUtils.php b/Sources/PackageManager/PackageUtils.php index 874a572c18..370d07d756 100644 --- a/Sources/PackageManager/PackageUtils.php +++ b/Sources/PackageManager/PackageUtils.php @@ -3690,6 +3690,7 @@ private static function writeTgzFile( ): bool { $stream_prefix = \function_exists('gzopen') ? 'compress.zlib://' : ''; $output = fopen($stream_prefix . $dirname . '/' . $output_file . '.' . $output_ext, 'w'); + $root_len = \strlen($root); // Iterate through each file and write its data to the archive. foreach ($files as $file_info) { @@ -3705,12 +3706,12 @@ private static function writeTgzFile( // Create the tar header data. $data_first = pack( 'a100a8a8a8a12a12', - str_replace($root, '', $file_info->getPathname()),// Relative path - \sprintf('%6o ', $stat['mode']), // File permissions - \sprintf('%6o ', $stat['uid']), // Owner ID - \sprintf('%6o ', $stat['gid']), // Group ID - \sprintf('%11o ', $is_dir ? 0 : $stat['size']), // File size - \sprintf('%11o ', $stat['mtime']), // Last modification time + substr($file_info->getPathname(), $root_len), // Relative path + decoct($stat['mode']), // File permissions + decoct($stat['uid']), // Owner ID + decoct($stat['gid']), // Group ID + decoct($is_dir ? 0 : $stat['size']), // File size + decoct($stat['mtime']), // Last modification time ); $data_last = pack( @@ -3718,13 +3719,13 @@ private static function writeTgzFile( $is_dir ? '5' : '0', // File type ('0' = file, '5' = directory) '', // Link name 'ustar', // UStar indicator - '', // Version + '00', // Version \function_exists('posix_getpwuid') ? posix_getpwuid($stat['uid'])['name'] : '', // Owner name \function_exists('posix_getgrgid') ? posix_getgrgid($stat['gid'])['name'] : '', // Group name '', // Device major number '', // Device minor number '', // Root directory for paths - '', // Padding + '', // Padding ); static $ord_cache = null; @@ -3752,7 +3753,9 @@ private static function writeTgzFile( } // Write the header to the archive. - fwrite($output, $data_first . pack('a8', decoct($checksum)) . $data_last); + fwrite($output, $data_first); + fwrite($output, pack('a8', decoct($checksum))); + fwrite($output, $data_last); // If the file is a directory, skip writing file contents. if ($is_dir) { From 685370fa42dfd7242a8b0899968f9fd03d4f5acc Mon Sep 17 00:00:00 2001 From: John Rayes Date: Mon, 20 Apr 2026 00:28:37 -0700 Subject: [PATCH 15/17] user/group cache --- Sources/PackageManager/PackageUtils.php | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Sources/PackageManager/PackageUtils.php b/Sources/PackageManager/PackageUtils.php index 370d07d756..1f37b50f8b 100644 --- a/Sources/PackageManager/PackageUtils.php +++ b/Sources/PackageManager/PackageUtils.php @@ -3714,14 +3714,32 @@ private static function writeTgzFile( decoct($stat['mtime']), // Last modification time ); + static $uid_cache = []; + static $gid_cache = []; + + $uid = $stat['uid']; + $gid = $stat['gid']; + + if (!isset($uid_cache[$uid])) { + $uid_cache[$uid] = function_exists('posix_getpwuid') + ? (posix_getpwuid($uid)['name'] ?? '') + : ''; + } + + if (!isset($gid_cache[$gid])) { + $gid_cache[$gid] = function_exists('posix_getgrgid') + ? (posix_getgrgid($gid)['name'] ?? '') + : ''; + } + $data_last = pack( 'a1a100a6a2a32a32a8a8a155a12', $is_dir ? '5' : '0', // File type ('0' = file, '5' = directory) '', // Link name 'ustar', // UStar indicator '00', // Version - \function_exists('posix_getpwuid') ? posix_getpwuid($stat['uid'])['name'] : '', // Owner name - \function_exists('posix_getgrgid') ? posix_getgrgid($stat['gid'])['name'] : '', // Group name + $uid_cache[$uid], // Owner name + $gid_cache[$gid], // Group name '', // Device major number '', // Device minor number '', // Root directory for paths From d876e8871d9a082b8c11f25d0195f80a5843a7d8 Mon Sep 17 00:00:00 2001 From: John Rayes Date: Thu, 30 Apr 2026 22:55:26 -0700 Subject: [PATCH 16/17] Apply suggestion from @live627 --- Sources/PackageManager/PackageUtils.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/PackageManager/PackageUtils.php b/Sources/PackageManager/PackageUtils.php index 1f37b50f8b..eecd76b21d 100644 --- a/Sources/PackageManager/PackageUtils.php +++ b/Sources/PackageManager/PackageUtils.php @@ -181,7 +181,7 @@ public static function readTgzData(string $data, ?string $destination, bool $sin // Compare as hex strings rather than integers in order to avoid // inconsistencies between 32-bit and 64-bit systems. - if (hash('crc32b', $data) !== dechex($crc['crc32'])) { + if (hash('crc32b', $data) !== dechex($crc)) { return false; } From b513efce0ecc29025d05e0e1036b10ab5dd5cfca Mon Sep 17 00:00:00 2001 From: John Rayes Date: Thu, 30 Apr 2026 22:58:51 -0700 Subject: [PATCH 17/17] Apply suggestion from @live627 Co-authored-by: Jon Stovell --- Sources/PackageManager/PackageUtils.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/PackageManager/PackageUtils.php b/Sources/PackageManager/PackageUtils.php index eecd76b21d..e5cc6bcbf0 100644 --- a/Sources/PackageManager/PackageUtils.php +++ b/Sources/PackageManager/PackageUtils.php @@ -427,7 +427,7 @@ public static function readZipData(string $data, ?string $destination, bool $sin } // PKZip/ITU-T V.42 CRC-32 - if (hash('crc32b', $file_info['data']) !== dechex($file_info['crc'])) { + if (hash('crc32b', $file_info['data']) !== dechex($file_info['crc'])) { continue; }