Как вручную прочитать метаданные из 7z-архива?

У меня появилась задача прочитать 7z-архив вручную, без использования утилиты 7z. Архив создавался с настройками по умолчанию, так что скорее всего данные в нём сжаты алгоритмом LZMA2, а метаданные сжаты алгоритмом LZMA (если верить документации).

Методом научного тыка у меня получилось прочитать данные:

import lzma
with open('archive.7z', 'rb') as fp:
    fp.seek(32)  # Пропускаем 7z-заголовок
    data = lzma.LZMAFile(fp, format=lzma.FORMAT_RAW, filters=[{"id": lzma.FILTER_LZMA2}]).read()

Однако разобраться, как прочитать метаданные, я так и не смог.

Подскажите, как они закодированы и какие параметры нужно задать lzma-декомпрессору, чтобы прочитать метаданные?


Ответы (1 шт):

Автор решения: andreymal

Наткнулся на неофициальную спецификацию формата от проекта py7zr. Её тяжеловато читать и, похоже, иногда попадаются ошибки, но всё-таки эта спецификация, дополненная чтением исходников py7zr, помогла мне достучаться до метаданных.

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

  • в формате есть специальный тип NUMBER — это число переменной длины, где длина числа задаётся старшими битами в первом байте, и все NUMBER'ы нужно не забывать правильно декодировать;

  • если End Header начинается с байта 0x17, значит метаданные закодированы (сжаты или зашифрованы), и End Header содержит инструкции для расшифровки. Если он начинается на 0x01, значит метаданные записаны прямо в End Header без кодирования, но такие архивы попадаются крайне редко (я в своей коллекции нашёл всего один такой);

  • в формате есть сложные концепции папок (folders), потоков, кодеров и фильтров, которые также влияют и на кодирование метаданных и усложняют парсинг End Header. Но, судя по всему, для метаданных всегда используется один folder, один stream и один кодер LZMA1 без специальных фильтров (но это не точно);

В общем, структура EncodedHeader начинается с 0x17 и содержит в себе две другие структуры: PackInfo (0x06) — информация о положении и размере закодированных данных; и UnpackInfo (0x07) — информация о настройке кодеров и фильтров, а также размеры декодированных данных и их CRC32-хэши.

Прочитав всю эту информацию из EncodedHeader, мы можем вычитать сжатые метаданные и успешно распаковать их:

# Параметры, прочитанные из структуры EncodedHeader
pack_position = 0x1d
pack_size = 0x71
coder1_properties = b"\x5d\x00\x10\x00\x00"
unpack_size = 1202

with open("archive.7z", "rb") as fp:
    # Пропускаем заголовок и переходим к сжатым метаданным
    fp.seek(0x20 + pack_position)
    # Читаем сжатые метаданные
    meta_compressed = fp.read(pack_size)
    # Готовим декодер
    decompressor = lzma.LZMADecompressor(
        format=lzma.FORMAT_RAW,
        filters=[lzma._decode_filter_properties(lzma.FILTER_LZMA1, coder1_properties)],
    )
    # Декодируем
    meta = decompressor.decompress(meta_compressed)
    # Декодер может выдать лишние байты, поэтому обязательно обрезаем под нужный размер
    meta = meta[:unpack_size]

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

В примере архива, который я показал в картинке к вопросу, End Header устроен так (клик для увеличения):

→ Ссылка