Чтение 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, проверяя сигнатуру, если сигнатуры неправильная, значит все файлы считаны.

Дополнительные ссылки по теме

16 комментариев
  1. написал(а) Аноним (08.05.2018 22:01)

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

  2. написал(а) Figoro (06.11.2020 21:51)

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

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

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

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

    1. написал(а) eJ (06.11.2020 23:39)

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

  3. написал(а) Figoro (06.11.2020 21:57)

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

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

    1. написал(а) eJ (06.11.2020 23:43)

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

  4. написал(а) Figoro (06.11.2020 23:17)

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

    1. написал(а) eJ (06.11.2020 23:44)

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

  5. написал(а) Figoro (10.11.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.11.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.11.2020 20:08)

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

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

        1. написал(а) eJ (10.11.2020 20:24)

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

  6. написал(а) Figoro (10.11.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.11.2020 20:32)

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

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

  7. написал(а) Figoro (10.11.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.11.2020 00:14)

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

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

Ваш адрес email не будет опубликован.