Как выглядит zip-архив и что мы с этим можем сделать

в 1:08, , рубрики: php, zip, извращения, ненормальное программирование

Доброго времени суток, уважаемый Хабр!

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

Ой, что-то мы отвлеклись от основной темы. Не буду больше вас утомлять пространными речами, а перейду к делу.

Создаем Zip-архив

В принципе переписывать спецификацию сюда я не буду. Да и в целом описывать структуру тоже смысла нет, потому что все это сделали до меня.

Для тех кому лень переходить по ссылкам просто вкратце обрисую, что любой zip-архив должен содержать в себе:

  • Запись о файле:
    • Local File Header
    • Полезные данные
    • Data descriptor (опционально, используется в случае, когда мы не знаем размер файла и его хэш до тех пор, пока до конца его не прочтем)
  • Central Directory File Header (для каждого файла. это как оглавление книги, где указан каждый раздел и страница, на которой его можно найти)
  • End Of Central Directory Record

Зная это мы можем попробовать записать простейший архив, который будет содержать всего два файла:

<?php

// В архиве у нас будет два файла (1.txt и 2.txt) с соответствующим содержимым:
$entries = [
    '1.txt' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc id ante ultrices, fermentum nibh eleifend, ullamcorper nunc. Sed dignissim ut odio et imperdiet. Nunc id felis et ligula viverra blandit a sit amet magna. Vestibulum facilisis venenatis enim sed bibendum. Duis maximus felis in suscipit bibendum. Mauris suscipit turpis eleifend nibh commodo imperdiet. Donec tincidunt porta interdum. Aenean interdum condimentum ligula, vitae ornare lorem auctor in. Suspendisse metus ipsum, porttitor et sapien id, fringilla aliquam nibh. Curabitur sem lacus, ultrices quis felis sed, blandit commodo metus. Duis tincidunt vel mauris at accumsan. Integer et ipsum fermentum leo viverra blandit.',
    
    '2.txt' => 'Mauris in purus sit amet ante tempor finibus nec sed justo. Integer ac nibh tempus, mollis sem vel, consequat diam. Pellentesque ut condimentum ex. Praesent finibus volutpat gravida. Vivamus eleifend neque sit amet diam scelerisque lacinia. Nunc imperdiet augue in suscipit lacinia. Curabitur orci diam, iaculis non ligula vitae, porta pellentesque est. Duis dolor erat, placerat a lacus eu, scelerisque egestas massa. Aliquam molestie pulvinar faucibus. Quisque consequat, dolor mattis lacinia pretium, eros eros tempor neque, volutpat consectetur elit elit non diam. In faucibus nulla justo, non dignissim erat maximus consectetur. Sed porttitor turpis nisl, elementum aliquam dui tincidunt nec. Nunc eu enim at nibh molestie porta ut ac erat. Sed tortor sem, mollis eget sodales vel, faucibus in dolor.',
];

// А сохраним архив мы как Lorem.zip, он появится у нас в cwd (обычно в одной папке с запускаемым файлом)
$destination = 'Lorem.zip';
$handle = fopen($destination, 'w');

// Нам нужно следить сколько мы записали, чтоб потом указать смещение, с которого начинается каждый файл, в нашем "оглавлении" Central Directory File Header
$written = 0;
$dictionary = [];
foreach ($entries as $filename => $content) {
    // Для каждого файла нам нужно сначала записать структуру Local File Header, а потом его содержимое
    // В этой статье мы не будем рассматривать сжатие, поэтому данные будут храниться как есть.
    
    $fileInfo = [
        // минимальная версия для распаковки
        'versionToExtract'      => 10,                                      
        // должен быть 0, если мы сразу указываем длинну файла и хэш-суму
        'generalPurposeBitFlag' => 0,                                       
        // у нас хранятся данные без сжатия, так что тоже 0
        'compressionMethod'     => 0,                                       
        // по-хорошему тут нужно указать mtime файла, но кому какая разница кто и когда трогал этот файл?
        'modificationTime'      => 28021,                                   
        // ну вы поняли, да?
        'modificationDate'      => 20072,
        // а вот тут уже халтурить нельзя. вообще можно указать любое значение, но мы же хотим получит валидный архив, не так ли?
        'crc32'                 => hexdec(hash('crc32b', $content)),
        // размер сжатых и несжатых данных. в нашем случае одно и то же число. 
        // тоже настоятельно рекомендую указывать реальные данные :)
        'compressedSize'        => $size = strlen($content),
        'uncompressedSize'      => $size,
        // Длинна имени файла
        'filenameLength'        => strlen($filename),
        // Дополнительная информация. Мы её не пишем, так что 0.
        'extraFieldLength'      => 0,
    ];
    
    // Упакуем все это в нужный вид.
    $LFH = pack('LSSSSSLLLSSa*', ...array_values([
        'signature' => 0x04034b50, // Сигнатура Local File Header
    ] + $fileInfo + ['filename' => $filename]));
    
    // А информацию о файле сохраним на потом, ведь в конце нам еще писать Central Directory File Header
    $dictionary[$filename] = [
        'signature'     => 0x02014b50, // Сигнатура Central Directory File Header
        'versionMadeBy' => 798,        // Версия создания. Я стащил это значение разбирая какой-то из архивов.
    ] + $fileInfo + [
        'fileCommentLength'      => 0,          // Длинна комментария к файлу. No comments
        'diskNumber'             => 0,          // Мне обычно попадался везде 0, а в особенности я решил не вникать.
        'internalFileAttributes' => 0,          // Внутренние аттрибуты файла
        'externalFileAttributes' => 2176057344, // Внешние аттрибуты файла
        'localFileHeaderOffset'  => $written,   // Смешение в файле до его Local File Header
        'filename'               => $filename,  // Имя файла.
    ];
    
    // А теперь запишем наш заголовок
    $written += fwrite($handle, $LFH);
    // И сами данные
    $written += fwrite($handle, $content);
}

// Теперь, когда мы записали все данные, можно приступать к оглавлению.
// Но давайте немного забежим вперед и начнем создавать структуру End of central directory record (EOCD)
$EOCD = [
    // Сигнатура EOCD
    'signature'                    => 0x06054b50, 
    // Номер диска. У нас этого нет, так что 0
    'diskNumber'                   => 0,          
    // И этого у нас нет - тоже 0
    'startDiskNumber'              => 0,          
    // Количество записей в архиве на текущем диске.
    'numberCentralDirectoryRecord' => $records = count($dictionary), 
    // Всего записей в архиве. У нас один архив, так что идентично предыдущему
    'totalCentralDirectoryRecord'  => $records, 
    // Размер записей Central Directory Record. 
    // Мы его пока еще не знаем, но нужно будет обязательно указать
    'sizeOfCentralDirectory'       => 0, 
    // Смешение, с которого начинаются Central Directory Records
    'centralDirectoryOffset'       => $written,
    // И снова без комментариев
    'commentLength'                => 0
];

// А вот теперь точно можно! Пишем оглавление
foreach ($dictionary as $entryInfo) {
    $CDFH = pack('LSSSSSSLLLSSSSSLLa*', ...array_values($entryInfo));
    $written += fwrite($handle, $CDFH);
}

// Все, разобрались со словарем. Давайте отметим где он закачивается
$EOCD['sizeOfCentralDirectory'] = $written - $EOCD['centralDirectoryOffset'];
    
// А теперь можно записывать End of central directory record
$EOCD = pack('LSSSSLLS', ...array_values($EOCD));
$written += fwrite($handle, $EOCD);

// Архив готов. 
fclose($handle);

echo 'Размер архива составил: ' . $written . ' байт' . PHP_EOL;
echo 'Для проверки валидности архива запустите `unzip -tq ' . $destination . '`' . PHP_EOL;
echo PHP_EOL;

Попробуйте запустить этот примитивный код и на выходе вы получите файл Lorem.zip, который будет содержать 1.txt и 2.txt.

А зачем?

Конечно, любой адекватный человек скажет, что писать архиваторы на php это бесполезная затея, тем более что для такого формата, как zip, есть куча готовых реализаций на любой вкус и цвет. И в том же php есть готовые библиотеки. Я тоже так скажу :)

Но зачем же тогда вот вся эта статья, зачем я тратил время на её написание, а вы на прочтение?
А затем, что все не так просто и знание того, как работает zip, открывает нам некоторые дополнительные возможности.

Во-первых, я надеюсь, хоть немного, но поможет желающим понять структуру zip.
А во-вторых, создавая архив своими руками, мы имеем контроль, и, главное, доступ к его внутренним данным.

Мы можем предварительно расчитать Local File Header и Central Directory File Header, а потом on-demand генерировать zip-архив на лету с любым содержанием и порядком файлов, просто подставляя эти данные. И никаких накладных расходов, кроме как на ввод-вывод.

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

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

Автор: NiceDay

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js