Чтение ZIP файла

Алгоритм получения данных из ZIP файла:

  • находим запись EOCD
  • загружаем записи CentralDirectoryFileHeader
  • для каждой CentralDirectoryFileHeader, находим и загружаем LocalFileHeader
  • данные располагаются сразу после LocalFileHeader, иногда размер данных записывается после самих данных в структуру DataDescriptor, об этом сигнализирует флаг generalPurposeBitFlag

Теперь каждый шаг подробнее.

Находим запись EOCD

Эта запись располагается в конце файла. Ее поиск производится от конца файла к началу по сигнатуре. Стартовое место поиска равно размеру файла минус размер структуры EOCD. Если сигнатура не найдена, то смещаемся к началу файла по одному байту за раз.

Пример кода

struct EOCD 
{
    uint16_t diskNumber;
    uint16_t startDiskNumber;
    uint16_t numberCentralDirectoryRecord;
    uint16_t totalCentralDirectoryRecord;
    uint32_t sizeOfCentralDirectory;
    uint32_t centralDirectoryOffset;
    uint16_t commentLength;
 
} __attribute__((packed));
 
// Вычисляем размер файла
is.seekg(0, is.end);
const size_t fileSize = is.tellg();
 
// Ищем сигнатуру EOCD
for (size_t offset = fileSize - sizeof(EOCD); offset != 0; --offset)
{
    uint32_t signature = 0;
 
    // Считываем четыре байта сигнатуры
    is.seekg(offset, is.beg);
    is.read((char *) &signature, sizeof(signature));
 
    if (0x06054b50 == signature)
    {
        // Есть контакт!
        break;
    }
}
 
EOCD eocd;
is.read((char *) &eocd, sizeof(eocd));
 
// Считываем комментарий, если есть
if (eocd.commentLength)
{
    // Длина комментария
    uint8_t *comment = new uint8_t[eocd.commentLength + 1];
    is.read((char *) comment, eocd.commentLength);
 
    // Выведем комментарий в консоль
    comment[eocd.commentLength] = 0;
    std::cout << comment;
 
    delete [] comment;
}

Загружаем записи Central directory file header

Смещение для поиска первой записи Central directory file header находится в EOCD, который мы загрузили на предыдущем шаге. Записи располагаются последовательно одна за другой. Загрузка происходит следующим образом: считываем поля, размер которых известен, затем загружаем остальные поля (filename, extraField, fileComment).

Пример кода

struct CentralDirectoryFileHeader
{
    uint32_t signature;
    uint16_t versionMadeBy;
    uint16_t versionToExtract;
    uint16_t generalPurposeBitFlag;
    uint16_t compressionMethod;
    uint16_t modificationTime;
    uint16_t modificationDate;
    uint32_t crc32;
    uint32_t compressedSize;
    uint32_t uncompressedSize;
    uint16_t filenameLength;
    uint16_t extraFieldLength;
    uint16_t fileCommentLength;
    uint16_t diskNumber;
    uint16_t internalFileAttributes;
    uint32_t externalFileAttributes;
    uint32_t localFileHeaderOffset;
 
} __attribute__((packed));
 
// Смещение первой записи
is.seekg(eocd.centralDirectoryOffset, is.beg);
 
for (uint16_t i = 0; i < eocd.numberCentralDirectoryRecord; ++i)
{
    CentralDirectoryFileHeader cdfh;
 
    is.read((char *) &cdfh, sizeof(cdfh));
 
    if (0x02014b50 != cdfh.signature)
    {
        // Ошибка
        break;
    }
 
    // Считываем имя файла/папки
    if (cdfh.filenameLength)
    {
        uint8_t *filename = new uint8_t[cdfh.filenameLength + 1];
        is.read((char *) filename, cdfh.filenameLength);
 
        filename[cdfh.filenameLength] = 0;
        std::cout << filename << "\n";
 
        delete [] filename;
    }
 
    // Пропускаем дополнительные поля
    if (cdfh.extraFieldLength)
    {
        is.seekg(cdfh.extraFieldLength, is.cur);
    }
 
    // Пропускаем комментарий
    if (cdfh.fileCommentLength)
    {
        is.seekg(cdfh.fileCommentLength, is.cur);
    }
 
}

Загружаем записи Local File Header

Для каждой записи типа CentralDirectoryFileHeader существует запись типа Local File Header. Ее расположение в файле описывается полем CentralDirectoryFileHeader.localFileHeaderOffset. Переходим по смещению, считываем поля известной длины, затем остальные поля (filename, extraField).

Пример кода

struct LocalFileHeader
{
    uint32_t signature;
    uint16_t versionToExtract;
    uint16_t generalPurposeBitFlag;
    uint16_t compressionMethod;
    uint16_t modificationTime;
    uint16_t modificationDate;
    uint32_t crc32;
    uint32_t compressedSize;
    uint32_t uncompressedSize;
    uint16_t filenameLength;
    uint16_t extraFieldLength;
 
} __attribute__((packed));
 
// Переходим к началу записи
is.seekg(cdfh.localFileHeaderOffset, is.beg);
 
// Считываем ее целиком
LocalFileHeader lfh;
is.read((char *) &lfh, sizeof(lfh));
 
if (0x04034b50 != lfh.signature)
{
    // Ошибка
    break;
}
 
// Пропускаем название файла
is.seekg(lfh.filenameLength, is.cur);
// Пропускаем дополнительную информацию
is.seekg(lfh.extraFieldLength, is.cur);

Загружаем данные

Для этого шага нам понадобится библиотека zlib, потому что данные сжимаются методом deflate. Как правило, сразу после LocalFileHeader идут данные размером compressedSize. Если данные сжаты, то флаг compressionMethod равен 8 (или Z_DEFLATED), иначе он нулевой (или Z_STORED). Сжатые данные необходимо считать во временный буфер размером compressedSize и распаковать с помощью библиотеки zlib в буфер размером uncompressedSize.

// Выделяем буфер для чтения данных
uint8_t *readBuffer = new uint8_t[lfh.compressedSize];
// Считываем данные в буфер
is.read((char *) readBuffer, lfh.compressedSize);
 
// Если сжатие не применяется
if (0 == lfh.compressionMethod)
{
    // То необходимые данные уже лежат в буфере для чтения
}
// Данные сжаты методом deflate
else if (Z_DEFLATED == lfh.compressionMethod)
{
    // Выделяем буфер для распакованных данных
    uint8_t *result = new uint8_t[lfh.uncompressedSize];
 
    // Инициализация структуры для распаковки
    z_stream zs;
    memset(&zs, 0, sizeof(zs));
    inflateInit2(&zs, -MAX_WBITS);
 
    // Размер входного буфера
    zs.avail_in = lfh.compressedSize;
    // Входной буфер
    zs.next_in = readBuffer;
    // Размер выходного буфера
    zs.avail_out = lfh.uncompressedSize;
    // Выходной буфер
    zs.next_out = result;
 
    // Распаковка
    inflate(&zs, Z_FINISH);
 
    // Очищаем структуру для распаковки
    inflateEnd(&zs);
 
    // Удаляем буфер для распакованных данных
    delete [] result;
}
// Удаляем буфер для чтения
delete [] readBuffer;

Общие комментарии по коду

  • странная конструкция __attribute__((packed)) всего лишь означает команду для GCC не использовать выравнивание внутри структуры, для Visual Studio читайте про #pragma push(pack/pop)
  • обратите внимание, что поток типа z_stream инициализируется с помощью функции inflateInit2(). Это сделано потому, что стандартная функция inflateInit() ожидает в начале потока данных специальный заголовок, который отсутствует в ZIP файле
  • is — это объект типа std::istream (например std::ifstream)
  • не стоит выделять буфер для чтения каждый раз, лучше вычислить его заранее и выделить один раз в начале программы
  • проверьте метод сжатия перед чтением данных, тогда при отсутствии сжатия, можно читать данные сразу в нужный буфер
  • если размер файла внутри ZIP архива неизвестен и может быть очень большим, то распаковывайте его частями
  • структуру типа z_stream можно инициализировать и удалять только один раз, а перед работой с ней вызвать функцию inflateReset2()
  • в коде совсем не учитывается порядок байтов, так что будьте внимательны при переносе на другую платформу

P.S. Есть способ чтения быстрее и короче: с начала файла считываем все структуры типа LocalFileHeader, проверяя сигнатуру, если сигнатуры неправильная, значит все файлы считаны.

18 комментариев
  1. написал(а) Аноним (8 мая 2018, 22:01)

    Отличная статья

    1. написал(а) eJ (10 мая 2018, 17:33)

      Спасибо!

  2. написал(а) Figoro (6 ноября 2020, 21:51)

    «Эта запись располагается в конце файла. Ее поиск производится от конца файла к началу по сигнатуре. Стартовое место поиска равно размеру файла минус размер структуры EOCD. Если сигнатура не найдена, то смещаемся к началу файла по одному байту за раз.»(с)

    Опять ниче не понятно. Ищем начало EOCD по сигнатуре 0x06054b50 (uint32_t signature). Далее вы пишите:

    «Если сигнатура не найдена, то смещаемся к началу файла по одному байту за раз.»(с)

    Это блин как ?? Если обязательная сигнатура не найдена, то файл или битый или вообще не zip. Какой смысл смещаться к началу файла ???

    1. написал(а) eJ (6 ноября 2020, 23:39)

      Да, ZIP файлы бывают битыми без структуры EOCD. Битые файлы могут содержать только наборы LocalFileHeader с данными. Такое бывает и надо быть к этому готовым, на всякий случай. :)

  3. написал(а) Figoro (6 ноября 2020, 21:57)

    «странная конструкция __attribute__((packed)) всего лишь означает команду для GCC не использовать выравнивание внутри структуры»(с)

    А зачем ? Почему бы компиляторы не выровнять структуру ? На что это в данном случае влияет ?

    1. написал(а) eJ (6 ноября 2020, 23:43)

      Если убрать выравнивание, тогда метод is.read() не сможет прочитать структуру за один вызов (в зависимости от настроек компилятора) и придётся читать её побайтно.

  4. написал(а) Figoro (6 ноября 2020, 23:17)

    Про выравнивание понял, чтобы не мучится с определением размера структуры и начальной точкой поиска.

    1. написал(а) eJ (6 ноября 2020, 23:44)

      Чтоб не писать кучу функций типа readUINT32 для простого примера.

  5. написал(а) Figoro (10 ноября 2020, 19:18)

    А может быть можете пожалуйста еще подсказать: я создал обычный zip архив — поместил туда несколько текстовых файлов и папку.

    Просто считал первые 38 байт(то есть это данные структуры Local File Header) этого ZIP файла методом read в простой буффер char`ов.

    Далее смотрю, какие байты считались c 29 по 32 байты (это диапазон параметра uint8_t *filename). И там в этих 4ех байтах такие значения(десятичные): 0 0 97 50 — эта комбинация байт, если ее привезти в int покажет десятичное целое — 845 217 792.

    Я не могу понять, о чем должна говорить эта цифра ?? Куда она должна указывать ?

    1. написал(а) eJ (10 ноября 2020, 19:37)

      LocalFileHeader нельзя считать за один раз. Сначала надо считать те данные, длина которых известна, а это ровно 30 байт (по моим подсчётам — без filename и extraField). У меня в примере приведена эта структура для чтения.

      Сначала читаете 30 байт, потом выделяете память под название файла размером fileNameLength, затем считываете fileNameLength в эту переменную.

      Псевдокод будет примерно таким (язык Си):

      // Читаем 30 байт
      fread(&lfs, sizeof(lfs), 1, f);
      // Выделяем память под имя файла с учётом завершающего нуля
      uint8_t *filename = malloc(lfs.fileNameLength + 1);
      // Читаем имя файла
      fread(filename, lfs.fileNameLength, 1, f);
      // Ставим ноль в конце (таким образом в Си устроены строки)
      filename[lfs.fileNameLength] = 0;

      И ещё я обратите внимание, что для папок тоже создаётся LocalFileHeader.

      1. написал(а) Figoro (10 ноября 2020, 20:08)

        Спасибо!
        Но, я как раз в этом и запутался, где увидеть информацию о кол-ве байт под filename ?
        Или я запутался вот в этом —> uint8_t *filename; В данном случае это переменная типа указатель или звездочка здесь для красоты? Если эта указатель, то он будет заниматься минимум 4 байта(на 32 битной системе) и 8 байт(на 64битной) или же это просто переменная uint8_t — занимающая 1 байт ?

        И если это просто переменная занимающая 1 байт, то тогда, как я понял — там и находится размер имени файла в байтах (максимум 255 байт) и самое имя следует, как раз за этим uint8_t байтом.

        1. написал(а) eJ (10 ноября 2020, 20:24)

          Есть структура, которая физически лежит в файле на диске и есть структура, где вы будете хранить считанные данные в памяти. Разумеется «uint8_t *» не может лежать на диске, потому что это указатель. Количество байт под filename хранится в поле fileNameLength. Фактически, структура LocalFileHeader имеет переменную длину.

  6. написал(а) Figoro (10 ноября 2020, 20:24)

    То есть я считал бинарно данные моего zip-файла в массив buff.

    Если вывести 30 элемент массива buff и привести его к int: (/Не знаю как код оформить на Вашем форуме)

    cout<<(int)buff[30]<<endl; //То выведется значение 97.

    Если вывести элементы buff c 31 по 35, уже не как значения, а как символы то выведется: 1.txt — то есть это название моего зазиповнного файла идущего сразу после uint8_t *filename, НО тогда вопрос, что за значение 97 — указанное в 30 элементе буффера, то есть в параметре filename ? Явно же не длинна ? Так как длинна файла 1.txt — всего 5 байт.

    1. написал(а) eJ (10 ноября 2020, 20:32)

      29 и 30 байт — это поле extraFieldLength структуры LocalFileHeader. Оно определяет длину дополнительных данных (extraField), которые идут сразу после filename. Про это поле я немного писал в статье про Расширение ZIP64. Не имею ни малейшего понятия — какая информация там может быть в вашем случае.

      P.S. Поправил форматирование в вашем комментарии.

  7. написал(а) Figoro (10 ноября 2020, 21:41)

    Спасибо! Разобрался.

    Но тогда у Вас небольшая неточность в описании: «Описание формата ZIP файла» — https://blog2k.ru/archives/3391

    Что и вызвало путаницу :)

    У Вас в описании Local File Header, два последних параметра этой структуры некорректно записаны:

    struct LocalFileHeader
    {

    uint8_t *filename; // Название файла (размером filenameLength)
    uint8_t *extraField; // Дополнительные данные (размером extraFieldLength)
    };

    -То есть звездочка реально путает, как будто бы это переменная типа указатель на uint8_t, что естественно не так.
    -И в соответствии с описанием на сайте: https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT —-> filename и extraField — занимают два байта, а у Вас как бы однобайтовый int получается.

    1. написал(а) eJ (11 ноября 2020, 00:14)

      Скорее всего вы путаете эти указатели с полями fileNameLength и extraFieldLength, которые действительно занимают каждый по два байта, что означает, что максимальная длина имени файла не может превышать 65536 байт.

  8. написал(а) Renji_kun (23 февраля 2021, 23:17)

    Приветствую,

    Честно говоря так и не смог вкурить, зачем нужен заголовок центрального каталога:
    -По сути поля Центрального каталога на 90% дублируются Заголовком Локального каталога. Зачем тогда лишнее место занимать?
    -Почему бы просто не указать в Центрального каталога — те поля, которые не совпадают с Локальным ? Или даже просто сделать один объеденный каталог, где будут все поля и Локального и Центрального ?

    Случаем не знаете в чем сакральный смысл таких ZIP заголовков ?

    1. написал(а) eJ (24 февраля 2021, 00:30)

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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Блог Евгения Жирнова