Files
ave-cms/setup/PackageExtractor.php
T
2026-07-27 12:58:44 +03:00

348 lines
12 KiB
PHP

<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file setup/PackageExtractor.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
defined('AVE_SETUP') || die('Direct access to this location is not allowed.');
/** Порционная и проверяемая распаковка runtime-архива до запуска установщика. */
class AvePackageExtractor
{
const SESSION_KEY = 'setup_package_extraction';
const BATCH_FILES = 60;
const MAX_FILE_BYTES = 134217728;
const MAX_ARCHIVE_BYTES = 536870912;
protected $root;
protected $payload;
protected $checksumFile;
public function __construct($root)
{
$canonicalRoot = realpath((string) $root);
if ($canonicalRoot === false || !is_dir($canonicalRoot)) {
throw new RuntimeException('Корневой каталог установки не найден');
}
$this->root = rtrim(str_replace('\\', '/', $canonicalRoot), '/');
$this->payload = $this->root . '/setup/runtime.zip';
$this->checksumFile = $this->root . '/setup/runtime.sha256';
}
public function available()
{
return is_file($this->payload);
}
public function step()
{
if (!class_exists('ZipArchive')) {
throw new RuntimeException('Для распаковки требуется расширение PHP ZipArchive');
}
$state = isset($_SESSION[self::SESSION_KEY]) && is_array($_SESSION[self::SESSION_KEY])
? $_SESSION[self::SESSION_KEY]
: $this->initialize();
$this->assertPayload($state);
$zip = new ZipArchive();
if ($zip->open($this->payload) !== true) {
throw new RuntimeException('Не удалось открыть runtime-архив');
}
$startedAt = microtime(true);
$processed = 0;
try {
while ($state['index'] < $zip->numFiles && $processed < self::BATCH_FILES && microtime(true) - $startedAt < 1.8) {
$this->extractEntry($zip, (int) $state['index'], $state);
$state['index']++;
$processed++;
}
} finally {
$zip->close();
}
$state['updated_at'] = time();
$_SESSION[self::SESSION_KEY] = $state;
if ($state['index'] >= $state['entries']) {
$this->complete($state);
return array(
'success' => true,
'complete' => true,
'progress' => 100,
'current' => $state['entries'],
'total' => $state['entries'],
'file' => 'Runtime AVE.cms подготовлен',
'redirect' => './',
);
}
return array(
'success' => true,
'complete' => false,
'progress' => max(1, min(99, (int) floor(($state['index'] / max(1, $state['entries'])) * 100))),
'current' => $state['index'],
'total' => $state['entries'],
'file' => $state['current_file'],
);
}
protected function initialize()
{
if (!is_file($this->payload) || (int) filesize($this->payload) <= 0
|| (int) filesize($this->payload) > self::MAX_ARCHIVE_BYTES) {
throw new RuntimeException('Размер runtime-архива недопустим');
}
if (!is_file($this->checksumFile)) {
throw new RuntimeException('Рядом с архивом отсутствует runtime.sha256');
}
$expectedArchive = strtolower(trim((string) file_get_contents($this->checksumFile)));
if (!preg_match('/^[a-f0-9]{64}$/', $expectedArchive)) {
throw new RuntimeException('Контрольная сумма runtime-архива имеет неверный формат');
}
$actualArchive = hash_file('sha256', $this->payload);
if ($actualArchive === false || !hash_equals($expectedArchive, strtolower($actualArchive))) {
throw new RuntimeException('Runtime-архив повреждён: SHA-256 не совпадает');
}
$zip = new ZipArchive();
if ($zip->open($this->payload) !== true) {
throw new RuntimeException('Не удалось открыть runtime-архив');
}
try {
$manifestRaw = $zip->getFromName('PACKAGE-MANIFEST.json');
$manifest = $manifestRaw !== false ? json_decode($manifestRaw, true) : null;
if (!is_array($manifest) || !isset($manifest['format']) || $manifest['format'] !== 'ave-core-package-v2'
|| empty($manifest['files']) || !is_array($manifest['files'])) {
throw new RuntimeException('Runtime-архив не содержит корректный manifest AVE.cms');
}
foreach ($manifest['files'] as $path => $checksum) {
$this->normalizePath($path);
if (!preg_match('/^[a-f0-9]{64}$/', (string) $checksum)) {
throw new RuntimeException('Некорректная сумма файла в manifest: ' . $path);
}
}
$entries = array();
for ($index = 0; $index < $zip->numFiles; $index++) {
$name = $this->normalizePath($zip->getNameIndex($index));
if (isset($entries[$name])) {
throw new RuntimeException('Путь продублирован в runtime-архиве: ' . $name);
}
$entries[$name] = true;
}
if (!isset($entries['PACKAGE-MANIFEST.json'])) {
throw new RuntimeException('Runtime-архив не содержит PACKAGE-MANIFEST.json');
}
foreach ($manifest['files'] as $path => $checksum) {
if (!isset($entries[$path])) {
throw new RuntimeException('Заявленный файл отсутствует в архиве: ' . $path);
}
}
return array(
'index' => 0,
'entries' => $zip->numFiles,
'archive_size' => (int) filesize($this->payload),
'archive_mtime' => (int) filemtime($this->payload),
'archive_sha256' => $expectedArchive,
'manifest_sha256' => hash('sha256', $manifestRaw),
'files' => $manifest['files'],
'current_file' => '',
'created_at' => time(),
);
} finally {
$zip->close();
}
}
protected function assertPayload(array $state)
{
if (!is_file($this->payload)
|| (int) filesize($this->payload) !== (int) $state['archive_size']
|| (int) filemtime($this->payload) !== (int) $state['archive_mtime']) {
throw new RuntimeException('Runtime-архив изменился во время распаковки');
}
}
protected function extractEntry(ZipArchive $zip, $index, array &$state)
{
$stat = $zip->statIndex($index);
if (!is_array($stat) || !isset($stat['name'])) {
throw new RuntimeException('Не удалось прочитать элемент runtime-архива');
}
$rawName = (string) $stat['name'];
$name = $this->normalizePath($rawName);
$state['current_file'] = $name;
$isDirectory = substr($rawName, -1) === '/';
$this->assertRegularEntry($zip, $index, $name, $isDirectory);
$target = $this->root . '/' . $name;
if ($isDirectory) {
$this->ensureDirectory(rtrim($target, '/'));
return;
}
if (!isset($state['files'][$name]) && $name !== 'PACKAGE-MANIFEST.json') {
throw new RuntimeException('Файл не заявлен в manifest: ' . $name);
}
$size = isset($stat['size']) ? (int) $stat['size'] : 0;
if ($size < 0 || $size > self::MAX_FILE_BYTES) {
throw new RuntimeException('Недопустимый размер файла: ' . $name);
}
$this->ensureDirectory(dirname($target));
if (is_link($target) || is_dir($target)) {
throw new RuntimeException('Целевой путь занят небезопасным объектом: ' . $name);
}
$source = $zip->getStream($rawName);
if (!$source) {
throw new RuntimeException('Не удалось прочитать файл из архива: ' . $name);
}
$temporary = $target . '.setup-' . bin2hex(random_bytes(4));
$output = @fopen($temporary, 'xb');
if (!$output) {
fclose($source);
throw new RuntimeException('Не удалось создать файл: ' . $name);
}
$hash = hash_init('sha256');
$written = 0;
try {
while (!feof($source)) {
$chunk = fread($source, 1048576);
if ($chunk === false) { throw new RuntimeException('Ошибка чтения: ' . $name); }
if ($chunk === '') { continue; }
$length = strlen($chunk);
if (fwrite($output, $chunk) !== $length) { throw new RuntimeException('Ошибка записи: ' . $name); }
hash_update($hash, $chunk);
$written += $length;
}
} catch (Throwable $e) {
fclose($source);
fclose($output);
@unlink($temporary);
throw $e;
}
fclose($source);
fclose($output);
$expected = $name === 'PACKAGE-MANIFEST.json'
? (string) $state['manifest_sha256']
: strtolower((string) $state['files'][$name]);
if ($written !== $size || !hash_equals($expected, hash_final($hash))) {
@unlink($temporary);
throw new RuntimeException('Контрольная сумма файла не совпала: ' . $name);
}
if (!@rename($temporary, $target)) {
@unlink($temporary);
throw new RuntimeException('Не удалось завершить запись файла: ' . $name);
}
@chmod($target, $this->entryMode($zip, $index));
}
protected function complete(array $state)
{
foreach ($state['files'] as $path => $checksum) {
$target = $this->root . '/' . $path;
if (!is_file($target) || !hash_equals((string) $checksum, (string) hash_file('sha256', $target))) {
throw new RuntimeException('Финальная проверка не пройдена: ' . $path);
}
}
if (!@unlink($this->payload) && is_file($this->payload)) {
throw new RuntimeException('Файлы распакованы, но runtime.zip не удалось удалить');
}
if (!@unlink($this->checksumFile) && is_file($this->checksumFile)) {
throw new RuntimeException('Файлы распакованы, но runtime.sha256 не удалось удалить');
}
unset($_SESSION[self::SESSION_KEY]);
}
protected function normalizePath($path)
{
$path = str_replace('\\', '/', (string) $path);
if ($path === '' || strpos($path, "\0") !== false || $path[0] === '/' || preg_match('/^[A-Za-z]:/', $path)) {
throw new RuntimeException('Некорректный путь в runtime-архиве');
}
$segments = explode('/', trim($path, '/'));
foreach ($segments as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
throw new RuntimeException('Небезопасный путь в runtime-архиве: ' . $path);
}
}
return implode('/', $segments) . (substr($path, -1) === '/' ? '/' : '');
}
protected function assertRegularEntry(ZipArchive $zip, $index, $name, $directory)
{
$opsys = 0;
$attributes = 0;
if ($zip->getExternalAttributesIndex($index, $opsys, $attributes)) {
$type = ($attributes >> 16) & 0170000;
if ($type !== 0 && $type !== 0100000 && !($directory && $type === 0040000)) {
throw new RuntimeException('Ссылки и специальные файлы запрещены: ' . $name);
}
}
}
protected function entryMode(ZipArchive $zip, $index)
{
$opsys = 0;
$attributes = 0;
if ($zip->getExternalAttributesIndex($index, $opsys, $attributes)) {
$mode = ($attributes >> 16) & 0777;
if ($mode > 0) { return $mode; }
}
return 0644;
}
protected function ensureDirectory($directory)
{
$directory = rtrim(str_replace('\\', '/', (string) $directory), '/');
if ($directory !== $this->root && strpos($directory . '/', $this->root . '/') !== 0) {
throw new RuntimeException('Каталог назначения находится за пределами установки');
}
$relative = trim(substr($directory, strlen($this->root)), '/');
$current = $this->root;
foreach ($relative === '' ? array() : explode('/', $relative) as $segment) {
$current .= '/' . $segment;
if (is_link($current)) {
throw new RuntimeException('Каталог назначения является ссылкой: ' . $segment);
}
if (!is_dir($current) && !@mkdir($current, 0775) && !is_dir($current)) {
throw new RuntimeException('Не удалось создать каталог: ' . $current);
}
}
}
}