Продолжение цикла о Zip-архивах и PHP. Предыдущие статьи: Часть 1, Часть 2, Часть 3

Доброго времени суток, дорогие читатели.
На этот раз я хотел бы представить, наверное, заключительную часть цикла о Zip-архивах и PHP.

В этой статье я покажу как прочесть уже существующий архив и для примера мы возьмем photos.zip из прошлой статьи. Чтоб не повторять все процедуры воспользуемся готовым — https://github.com/userqq/images/raw/master/photos.zip.

А теперь давайте на минутку отвлечемся и вспомним, из чего состоит наш архив: сначала идет набор данных упакованных файлов, где каждый упакованный файл предварён структурой Local File Header (LFH), после всех данных у нас идет набор структур Central Directory File Header (CDFH) — это такое оглавление по нашему архиву, в котором перечислены все элементы и позиции их смещения относительно начала файла. А завершает архив End Of Central Directory Record (EOCD) — тут указана позиция начала структур CDFH, их количество и общая длина в байтах. Поэтому архив следует читать с конца, чтоб сначала найти EOCD, потом прочесть структуры CDFH и таким образом получить список файлов в архиве.

FYI: А некоторые форматы, например JPEG, читаются с начала. Поэтому мы можем склеить картинку с архивом, даже банально через cat image.jpeg archive.zip > imagearchive.jpeg, не потеряв функционала. Браузеры и приложения для просмотра картинок будут без каких-либо проблем показывать нам картинку. В то время как любое приложение для чтения zip-архивов, будь то 7z или unzip, сможет преспокойно работать с файлом как с архивом. Например, вот — https://github.com/userqq/images/blob/master/jpegarchive.jpg (Осторожно, эта штука весит около 20мб, поэтому не советую открывать с телефонов или если вам дорог трафик). Таким образом, если вы знаете хостинг картинок, на котором изображения не перекодируются и не обрезаются, вы можете заливать туда не только картинки:) Хотя, мне кажется, сейчас таких уже не найти.

Для начала определим вспомогательную функцию, которая несколько облегчит нам работу с unpack (я подсмотрел эту идею у clue в каком-то из репозиториев для работы с socks и немного модицифировал для нашего случая):

function readBytes($fh, $formatArray)
{
    // что значат эти буковки можно посмотреть в статье про pack()
    // https://www.php.net/manual/ru/function.pack.php
    // а циферки это длина значения в байтах
    // например, L = unsigned long 32bit = 4 байта
    static $lengths = ['L' => 4, 'l' => 4, 'i' => 4, 'I' => 4, 'S' => 2, 'a' => 1]; 
    
    // будем считать длину нашей структуры, 
    // чтоб указать функции fread() сколько именно ей нужно прочесть.
    $totalLength = 0;
    
    // и соберем наш массив в формат, с которым работает функция unpack();
    $unpackFormat = [];
    
    // Может статься так, что мы случайно передадим в качестве аргументов что-то, что будет иметь длину 0.
    // Например, у нас будут структуры, у которых длина комментария будет 0, а мы не хотим это проверять. 
    // пусть функция сделает все за нас
    $nullData = [];
    
    foreach ($formatArray as $name => $format) {
        $length = 1;
        // Мы можем передать в функцию как, например, ['versionMadeBy' => 'S'],
        // тогда мы должны получить на выходе SversionMadeBy
        // так и ['filename' => ['a', $filenameLength]]
        // и результатом будет "a{$filenameLength}filename"
        if (is_array($format)) {
            [$format, $length] = $format;
        }
        
        // Если так уж вышло, что мы передали что-то 
        // вида ['extraFieldLength' => ['a', 0]]
        // то нам не нужно ничего читать 
        // А потом мы просто вернем ['extraFieldLength' => null]
        if ($length < 1) {
            $nullData[] = $name;
            continue;
        }
        
        $totalLength += $lengths[$format] * $length;
        $unpackFormat[] = $format . (($length > 1) ? $length : '') . $name;
    }
    
    $packet = [];    
    if ($totalLength > 0) {
        $packet = unpack(implode('/', $unpackFormat), fread($fh, $totalLength));
    }
    
    foreach ($nullData as $empty) {
        $packet[$empty] = null;
    }
    
    return $packet;
}

Теперь попробуем найти структуру EOCD. Минимальная её длина, если отсутствует комментарий, будет 22 байта, из которых первые 4 это сигнатура. Поэтому сначала мы смещаемся на позицию filesize($file) — 22, читаем следующие 4 байта и, если нам повезло и эти байты равны сигнатуре (0x06054b50), значит это и есть наша EOCD. Если не повезло — побайтово двигаемся к началу файла, пока не найдем или не закончится файл — тогда, наверное, это не архив.

$fh = fopen('photos.zip', 'r');

for ($offset = 22, $length = fstat($fh)['size']; $offset <= $length; $offset++) {
    fseek($fh, $offset * -1, SEEK_END);
    
    // "\x50\x4b\x05\x06" === pack('L', 0x06054b50), сигнатура EOCD
    if ("\x50\x4b\x05\x06" === $bytes = fread($fh, 4)) {
        echo 'EOCD нашелся на смещении ' . ($length - $offset) . PHP_EOL;
        break;
    }
}

$EOCD = readBytes($fh, [
    'diskNumber'                   => 'S',
    'startDiskNumber'              => 'S',
    'numberCentralDirectoryRecord' => 'S',
    'totalCentralDirectoryRecord'  => 'S',
    'sizeOfCentralDirectory'       => 'L',
    'centralDirectoryOffset'       => 'L',
    'commentLength'                => 'S',
]);

echo 'Элементов в архиве: ' . $EOCD['numberCentralDirectoryRecord'] . PHP_EOL;
echo 'Смещение CDFH: ' . $EOCD['centralDirectoryOffset'] . PHP_EOL;

Теперь мы знаем сколько у нас элементов в архиве и где начинается «оглавление» — список CDFH структур. Мы можем их перебрать и получить имена, размер данных и позицию начала LFH для каждого из элементов архива.

echo 'Список файлов в архиве:' . PHP_EOL;

// Сохраним позицию, так как мы будем бегать между CDFH и LFH туда-обратно
$offset = $EOCD['centralDirectoryOffset'];
for ($i = 0; $i < $EOCD['numberCentralDirectoryRecord']; $i++) {
    fseek($fh, $offset, SEEK_SET);
    
    // "\x50\x4b\x01\x02" === pack('L', 0x02014b50), сигнатура CDFH
    if ("\x50\x4b\x01\x02" !== $bytes = fread($fh, 4)) {
        exit('Неправильная сигнатура CDFH' . PHP_EOL);
    }
    
    // читаем CDFH
    $CDFH = readBytes($fh, [
        'versionMadeBy' => 'S',
        'versionToExtract' => 'S',
        'generalPurposeBitFlag' => 'S',
        'compressionMethod' => 'S',
        'modificationTime' => 'S',
        'modificationDate' => 'S',
        'crc32' => 'L',
        'compressedSize' => 'L',
        'uncompressedSize' => 'L',
        'filenameLength' => 'S',
        'extraFieldLength' => 'S',
        'fileCommentLength' => 'S',
        'diskNumber' => 'S',
        'internalFileAttributes' => 'S',
        'externalFileAttributes' => 'L',
        'localFileHeaderOffset' => 'L',        
    ]);
    $CDFH += readBytes($fh, [
        'filename' => ['a', $CDFH['filenameLength']],
        'extraField' => ['a', $CDFH['extraFieldLength']],
        'fileComment' => ['a', $CDFH['fileCommentLength']],
    ]);
    
    // Запомним, где у нас закончилась очередная структура CDFH
    $offset = ftell($fh);
    
    // Перейдем к LFH
    fseek($fh, $CDFH['localFileHeaderOffset'], SEEK_SET);
    
    
    // "\x50\x4b\x03\x04" === pack('L', 0x04034b50), сигнатура LFH
    if ("\x50\x4b\x03\x04" !== $bytes = fread($fh, 4)) {
        exit('Неправильная сигнатура LFH' . PHP_EOL);
    }
    
    // Читаем LFH
    $LFH = readBytes($fh, [
        'versionToExtract'      => 'S',
        'generalPurposeBitFlag' => 'S',
        'compressionMethod'     => 'S',
        'modificationTime'      => 'S',
        'modificationDate'      => 'S',
        'crc32'                 => 'L',
        'compressedSize'        => 'L',
        'uncompressedSize'      => 'L',
        'filenameLength'        => 'S',
        'extraFieldLength'      => 'S',
    ]);
    $LFH += readBytes($fh, [
        'filename'    => ['a', $LFH['filenameLength']],
        'extraField'  => ['a', $LFH['extraFieldLength']],
    ]);
    
    $dataOffset = ftell($fh);
    
    echo '> ' . $CDFH['filename'] . ' ' . $CDFH['compressedSize'] . ' байт, ';
    echo 'LFH: '  . $CDFH['localFileHeaderOffset'] . ', ';
    echo 'Начало данных: '  . $dataOffset . ', ';
    echo 'Конец данных: ' . ($dataOffset + $CDFH['compressedSize']);
    echo PHP_EOL;
    
} 

fclose($fp);

Вообще бегать по файлу туда-сюда для каждой из записей — не самая лучшая идея с точки зрения быстродействия, поэтому я бы рекомендовал прочесть все CDFH структуры, а потом уже перейти перейти к чтению LFH и данных, если это необходимо, но у нас тут «Нетрадиционное программирование», поэтому нам можно.

Полный код скрипта
<?php

function readBytes($fh, $formatArray)
{
    static $lengths = ['L' => 4, 'l' => 4, 'i' => 4, 'I' => 4, 'S' => 2, 'a' => 1]; 
    
    $totalLength = 0;
    $unpackFormat = [];
    $nullData = [];
    
    foreach ($formatArray as $name => $format) {
        $length = 1;

        if (is_array($format)) {
            [$format, $length] = $format;
        }
        
        if ($length < 1) {
            $nullData[] = $name;
            continue;
        }
        
        $totalLength += $lengths[$format] * $length;
        $unpackFormat[] = $format . (($length > 1) ? $length : '') . $name;
    }
    
    $packet = [];    
    if ($totalLength > 0) {
        $packet = unpack(implode('/', $unpackFormat), fread($fh, $totalLength));
    }
    
    foreach ($nullData as $empty) {
        $packet[$empty] = null;
    }
    
    return $packet;
}

$fh = fopen('photos.zip', 'r');

for ($offset = 22, $length = fstat($fh)['size']; $offset <= $length; $offset++) {
    fseek($fh, $offset * -1, SEEK_END);
    
    if ("\x50\x4b\x05\x06" === $bytes = fread($fh, 4)) {
        echo 'EOCD нашелся на смещении ' . ($length - $offset) . PHP_EOL;
        break;
    }
}

$EOCD = readBytes($fh, [
    'diskNumber'                   => 'S',
    'startDiskNumber'              => 'S',
    'numberCentralDirectoryRecord' => 'S',
    'totalCentralDirectoryRecord'  => 'S',
    'sizeOfCentralDirectory'       => 'L',
    'centralDirectoryOffset'       => 'L',
    'commentLength'                => 'S',
]);

echo 'Элементов в архиве: ' . $EOCD['numberCentralDirectoryRecord'] . PHP_EOL;
echo 'Смещение записей CDFH: ' . $EOCD['centralDirectoryOffset'] . PHP_EOL;

echo 'Список файлов в архиве:' . PHP_EOL;

$offset = $EOCD['centralDirectoryOffset'];
for ($i = 0; $i < $EOCD['numberCentralDirectoryRecord']; $i++) {
    fseek($fh, $offset, SEEK_SET);
    
    if ("\x50\x4b\x01\x02" !== $bytes = fread($fh, 4)) {
        exit('Неправильная сигнатура CDFH' . PHP_EOL);
    }
    
    $CDFH = readBytes($fh, [
        'versionMadeBy' => 'S',
        'versionToExtract' => 'S',
        'generalPurposeBitFlag' => 'S',
        'compressionMethod' => 'S',
        'modificationTime' => 'S',
        'modificationDate' => 'S',
        'crc32' => 'L',
        'compressedSize' => 'L',
        'uncompressedSize' => 'L',
        'filenameLength' => 'S',
        'extraFieldLength' => 'S',
        'fileCommentLength' => 'S',
        'diskNumber' => 'S',
        'internalFileAttributes' => 'S',
        'externalFileAttributes' => 'L',
        'localFileHeaderOffset' => 'L',        
    ]);
    $CDFH += readBytes($fh, [
        'filename' => ['a', $CDFH['filenameLength']],
        'extraField' => ['a', $CDFH['extraFieldLength']],
        'fileComment' => ['a', $CDFH['fileCommentLength']],
    ]);
    
    $offset = ftell($fh);
    
    fseek($fh, $CDFH['localFileHeaderOffset'], SEEK_SET);
    
    
    if ("\x50\x4b\x03\x04" !== $bytes = fread($fh, 4)) {
        exit('Неправильная сигнатура LFH' . PHP_EOL);
    }
    
    $LFH = readBytes($fh, [
        'versionToExtract'      => 'S',
        'generalPurposeBitFlag' => 'S',
        'compressionMethod'     => 'S',
        'modificationTime'      => 'S',
        'modificationDate'      => 'S',
        'crc32'                 => 'L',
        'compressedSize'        => 'L',
        'uncompressedSize'      => 'L',
        'filenameLength'        => 'S',
        'extraFieldLength'      => 'S',
    ]);
    $LFH += readBytes($fh, [
        'filename'    => ['a', $LFH['filenameLength']],
        'extraField'  => ['a', $LFH['extraFieldLength']],
    ]);
    
    $dataOffset = ftell($fh);
    
    echo '> ' . $CDFH['filename'] . ' ' . $CDFH['compressedSize'] . ' байт, ';
    echo 'LFH: '  . $CDFH['localFileHeaderOffset'] . ', ';
    echo 'Начало данных: '  . $dataOffset . ', ';
    echo 'Конец данных: ' . ($dataOffset + $CDFH['compressedSize']);
    echo PHP_EOL;
    
}

fclose($fh);

В результате работы скрипта мы должны получить информацию об архиве примерно следующего вида:

$ php readzip.php
EOCD нашелся на смещении 18873702
Элементов в архиве: 172
Смещение CDFH: 18864696
Список файлов в архиве:
> 0.jpg 135021 байт, LFH: 0, Начало данных: 35, Конец данных: 135056
> 1.jpg 205686 байт, LFH: 135056, Начало данных: 135091, Конец данных: 340777
> 2.jpg 81393 байт, LFH: 340777, Начало данных: 340812, Конец данных: 422205
> 3.jpg 64892 байт, LFH: 422205, Начало данных: 422240, Конец данных: 487132
...
> 171.jpg 50465 байт, LFH: 18814194, Начало данных: 18814231, Конец данных: 18864696

И, в общем-то благодаря этим данным мы уже можем извлечь все или конкретный файл.

Мы обошли стороной сжатие с шифрованием и рассматриваем только случай, когда у нас в архиве лежат данные как есть, но для понимания структуры это не нужно, а тем кто хочет заморочиться будет не сложно прочесть спецификацию и на основании этого цикла статей добавить недостающий функционал.

Поэтому, я думаю, что основной цикл мы можем считать оконченным. Если у вас остались вопросы или я вспомню какие-то упоминания в комментариях к предыдущим статьям, то, возможно, будут некоторые статьи-дополнения.

За сим всё.

И я надеюсь, что вам хоть и немного, но все же было интересно:)