Разбор ZIP в CPython 3.12.14: как testzip пропускает повреждённый дубликат

Пост опубликован в блогах iXBT.com, его автор не имеет отношения к редакции iXBT.com

Эксперимент с CRC, дубликатами путей и восстановлением

Проверка ZIP может пропустить неправильную версию файла, а при дубликате имени в CPython 3.12.14 даже повреждённую запись. Разберём это на семи собственных архивах: от пропущенного файла до ошибки CRC, которую видно только при чтении конкретного экземпляра.


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

Концептуальная иллюстрация, созданная ИИ; не снимок эксперимента
Автор: Создано с помощью ИИ для GOALTII

Что именно проверено

Повторный расширенный опыт выполнен 7 ноября 2026 года в Linux с CPython 3.12.14. Код целиком приведён в конце статьи. Он использует стандартную библиотеку и сам создаёт все файлы. Windows, другие архиваторы, скорость сжатия и надёжность накопителей не проверялись.

В исходной папке было четыре файла. current/report.txt содержал строку version=2, previous/report.txt — version=1. Оба занимали по 10 байт с учётом перевода строки. Ещё были notes. txt и крошечное изображение pixel.ppm. Повторяющееся имя report. txt выбрано специально: для сравнения нужен путь относительно корня, а не только последнее имя файла.

До упаковки сохраняется baseline. json: для каждого относительного пути записаны размер и SHA-256. Первые четыре архива моделируют ошибки комплектования. Здесь используется ZIP_STORED, без сжатия: размер архива не измеряется, а позднее понадобится изменить один известный байт полезной нагрузки.

• correct. zip — все четыре файла без изменений;


• missing. zip — без notes. txt;

• wrong_version.zip — с version=1 вместо version=2 в current/report.txt;

• extra. zip — с дополнительным unexpected.txt.

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

Почему обычная проверка не возражает

По документации testzip() читает файлы, проверяет CRC и заголовки и возвращает None, если не нашёл плохой файл [1]. Для архива без повторных имён это полезный контроль целостности. Однако в проверенной реализации CPython есть важная деталь чтения по имени. Ниже покажем, почему при дубликатах нельзя приравнивать None к проверке каждого экземпляра.

Именно это произошло в опыте. testzip() вернул None для всех четырёх ZIP. После этого каждый архив был распакован в собственную новую пустую папку. Для распакованных файлов рассчитан такой же манифест, а результат сравнён с исходным.

Получились следующие результаты:

correct: 4 файла, расхождений нет, matches_baseline=true.

missing: 3 файла, missing=[notes.txt], matches_baseline=false.


wrong_version: 4 файла, changed=[current/report.txt], matches_baseline=false.

extra: 5 файлов, extra=[unexpected.txt], matches_baseline=false.

Здесь false означает, что состав или содержимое не совпали с эталоном. Это не сообщение о невозможности распаковки. Все три неподходящих комплекта распаковались.

Все четыре ZIP прошли testzip(), но только correct. zip совпал с исходным набором
Автор: Подготовлено с помощью ИИ для GOALTII

Размер тоже не гарантирует совпадение

Для wrong_version размер current/report.txt остался равным 10 байтам. Совпали относительный путь и длина файла. Различалась только одна цифра в содержимом. Сравнение SHA-256 обнаружило эту замену.

Одинаковый путь и размер: version=2 и version=1 дают разные SHA-256
Автор: Подготовлено с помощью ИИ для GOALTII

Размеры version=1 и version=2 одинаковы, но SHA-256 различается. В новой версии скрипта hashlib. file_digest читает файл через файловый объект, без нашего read_bytes для каждого файла [2]. Хеш сравнивает байты с эталоном, а не определяет, какая версия правильная.

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

Когда один путь соответствует двум записям

Создадим ещё два архива. duplicate_last_old содержит правильный комплект, а затем ещё один current/report.txt со строкой version=1. В duplicate_last_current порядок обратный: старая запись идёт первой, затем правильный комплект. У обоих пять записей и четыре уникальных имени. Python предупреждает Duplicate name, но завершает запись.

infolist() возвращает оба экземпляра. Если передавать каждый ZipInfo в read(), видны обе версии: [version=2, version=1] и [version=1, version=2]. При чтении по строковому имени в нашем запуске выбиралась последняя запись. extractall() в этой реализации использует имена, поэтому результат восстановления тоже соответствует последнему экземпляру. Это не универсальное правило всех архиваторов.

В duplicate_last_old меняется current/report.txt. У duplicate_last_current итоговая папка полностью совпадает с эталоном, хотя неоднозначность внутри ZIP остаётся. Поэтому одних конечных хешей мало: повторные имена нужно обнаруживать до восстановления. В коде для этого используется Counter(z.namelist()).

Как testzip пропустил повреждённый экземпляр

Теперь испортим только первую запись duplicate_last_current. Архив создан нами без сжатия. По header_offset находим локальный заголовок, проверяем сигнатуру PK, считываем длины имени и дополнительного поля, затем меняем один байт данных. Сохранённый CRC не пересчитываем. Другие записи и центральный каталог остаются прежними. Все смещения в коде относятся к этому контролируемому учебному ZIP, не к произвольному файлу.

Результат на CPython 3.12.14: testzip() вернул None; read('current/report.txt') вернул version=2; чтение конкретного первого ZipInfo вызвало BadZipFile с сообщением Bad CRC-32 for file 'current/report.txt'. Повреждение не исчезло. Проверка по имени просто снова открыла последний исправный экземпляр.

Причина видна в реализации testzip: цикл перебирает zinfo, но вызывает self. open(zinfo.filename, 'r'). Строковое имя проходит через getinfo и словарь NameToInfo, где для совпавшего имени остаётся последняя запись. При повторе имени цикл может дважды прочитать один и тот же экземпляр. Это вывод по коду и выполненному опыту, а не заявление о всех версиях Python.

Читать запись нужно до конца

Функция inspect_entries ниже открывает z. open(info), передавая объект ZipInfo, и читает блоками по 1 МиБ до конца. Именно полное чтение даёт проверке CRC дойти до результата. В отчёте сохранены индекс и имя повреждённой записи: одного имени при дубликатах недостаточно. Для учебной проверки перехватывается BadZipFile; ошибки ввода-вывода, неподдерживаемое сжатие и другие исключения не замалчиваются.

Как повторить опыт

Полный код zip_lab.py приведён в конце статьи. В проверенной среде он запускается командой:

python3 zip_lab.py

Сохраните полный код ниже как zip_lab.py и выполните указанную команду в терминале из той же папки. Скрипт создаёт новую папку ixbt-zip-deep-… и печатает её путь в root. В ней остаются исходники, baseline. json, семь ZIP, каталоги восстановления и results.json. Ничего автоматически не удаляется.

В results первые шесть вариантов должны дать matches_baseline: true, false, false, false, false, true. У двух последних заполнен duplicates. В corrupt_duplicate ожидаются testzip=null и одна entry_errors для записи с index=0. Проверки используют RuntimeError, а не assert; обычный запуск и запуск с python3 -O выполнены успешно.

Поле utc фиксирует время, system и python обозначают среду. null в JSON соответствует None в Python, а не обещанию безопасности архива. При другом результате скрипт останавливается: изучите свою версию Python, а не убирайте проверку ради зелёного итога. Изменения исходной тестовой папки не обнаружены.

Что делать с реальной копией

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

Отдельный тест распаковал correct. zip в папку с заранее созданным obsolete.txt. Старый файл остался, и сравнение показало extra=['obsolete.txt']. Распаковка не синхронизирует каталоги и не удаляет остатки. Не превращайте этот результат в автоматическое удаление всего лишнего: сначала восстановите отдельно и проверьте, какие файлы вам нужны.

У этого опыта есть границы

Здесь проверяются байты обычных файлов и относительные пути. Права, владельцы, временные метки, пустые каталоги, ссылки, дополнительные потоки Windows, занятые файлы и согласованность базы данных не проверены. Даже совпавший SHA-256 не сделает исходно испорченный документ пригодным для приложения: важные файлы стоит открыть.

Извлечение допускается только для созданных скриптом архивов. Повреждённый вариант вообще не извлекается. Не подставляйте чужой ZIP: нужна отдельная защита от опасных путей, огромного распакованного объёма и конфликтов имён. Проверка точных дубликатов также не покрывает совпадения после нормализации пути или регистра на другой файловой системе.

Эталон должен иметь понятное происхождение и храниться независимо от проверяемой копии. В лаборатории он лежит рядом ради удобства. Если одновременно подменить файлы и эталон, сравнение будет бесполезно. CRC и SHA-256 в этом опыте не подтверждают автора, безопасность содержимого или сохранность диска.

Практический вывод

У успешного восстановления несколько независимых условий: проверены все записи, состав однозначен, а восстановленные байты совпали с осмысленным эталоном. В CPython 3.12.14 testzip() один не обеспечивает даже первое условие при дубликатах. Список ZipInfo и воспроизводимый тест показывают, где именно заканчивается его гарантия.

Источники

[1] Документация Python: zipfile. Поведение воспроизведено именно на CPython 3.12.14; текущая страница документации ветки 3.12 помечена 3.12.16.

https://docs.python.org/3.12/library/zipfile.html#zipfile.ZipFile.testzip

Исходная реализация CPython 3.12.14, методы testzip, getinfo и extractall: https://github.com/python/cpython/blob/v3.12.14/Lib/zipfile/__init__.py

[2] Python 3.12, hashlib.

https://docs.python.org/3.12/library/hashlib.html

Полный код zip_lab.py

"""Synthetic ZIP lab. No arguments, network or external archives.
Creates a new temporary directory and leaves it for inspection.
"""
import hashlib, json, platform, struct, tempfile, warnings, zipfile
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path

def require(condition, message):
    if not condition:
        raise RuntimeError(message)

def manifest(root):
    result = {}
    for p in sorted(root.rglob('*')):
        if p.is_file():
            with p.open('rb') as f:
                digest = hashlib.file_digest(f, 'sha256').hexdigest()
            result[p.relative_to(root).as_posix()] = {'size': p.stat().st_size, 'sha256': digest}
    return result

def compare(expected, actual):
    return {'missing': sorted(expected.keys() - actual.keys()),
            'extra': sorted(actual.keys() - expected.keys()),
            'changed': sorted(k for k in expected.keys() & actual.keys() if expected[k] != actual[k])}

def inspect_entries(z):
    errors = []
    for index, info in enumerate(z.infolist()):
        try:
            with z.open(info) as f:
                while f.read(1024 * 1024):
                    pass
        except zipfile.BadZipFile as exc:
            errors.append({'index': index, 'name': info.filename, 'error': str(exc)})
    return errors

root = Path(tempfile.mkdtemp(prefix='ixbt-zip-deep-'))
source = root / 'source'
payload = {'current/report.txt': b'version=2' + bytes([10]),
           'previous/report.txt': b'version=1' + bytes([10]),
           'notes.txt': b'synthetic only' + bytes([10]),
           'pixel.ppm': bytes.fromhex('50360a3120310a3235350aff0000')}
for name, data in payload.items():
    p = source / name
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_bytes(data)
baseline = manifest(source)
(root / 'baseline.json').write_text(json.dumps(baseline, indent=2), encoding='utf-8')
base = list(payload.items())
cases = {'correct': base,
         'missing': [(k,v) for k,v in base if k != 'notes.txt'],
         'wrong_version': [(k, b'version=1' + bytes([10]) if k == 'current/report.txt' else v) for k,v in base],
         'extra': base + [('unexpected.txt', b'extra' + bytes([10]))],
         'duplicate_last_old': base + [('current/report.txt', b'version=1' + bytes([10]))],
         'duplicate_last_current': [('current/report.txt', b'version=1' + bytes([10]))] + base}
results = []
for label, entries in cases.items():
    archive = root / (label + '.zip')
    with warnings.catch_warnings(record=True) as emitted:
        warnings.simplefilter('always', UserWarning)
        with zipfile.ZipFile(archive, 'w', compression=zipfile.ZIP_STORED) as z:
            for name, data in entries:
                z.writestr(name, data)
    restored = root / ('restored_' + label)
    restored.mkdir()
    with zipfile.ZipFile(archive) as z:
        duplicates = sorted(k for k,v in Counter(z.namelist()).items() if v > 1)
        crc = z.testzip()
        per_entry = inspect_entries(z)
        versions = [z.read(info).decode() for info in z.infolist() if info.filename == 'current/report.txt']
        # All archives here were created from known synthetic data above.
        z.extractall(restored)
    delta = compare(baseline, manifest(restored))
    results.append({'case': label, 'testzip': crc, 'entry_errors': per_entry,
                    'members': len(entries), 'duplicates': duplicates, 'versions': versions,
                    **delta, 'matches_baseline': not any(delta.values())})
    require(crc is None and not per_entry, label + ': unexpected corruption')
require([r['matches_baseline'] for r in results] == [True, False, False, False, False, True], 'wrong result matrix')
require(results[1]['missing'] == ['notes.txt'], 'missing')
require(results[2]['changed'] == ['current/report.txt'], 'changed')
require(results[3]['extra'] == ['unexpected.txt'], 'extra')
require(results[4]['duplicates'] == results[5]['duplicates'] == ['current/report.txt'], 'duplicates')

# Corrupt only the first payload in our own uncompressed duplicate archive.
# ZIP local header has 30 fixed bytes, then filename and extra data.
archive = root / 'duplicate_corrupt_first.zip'
archive.write_bytes((root / 'duplicate_last_current.zip').read_bytes())
with zipfile.ZipFile(archive) as z:
    info = z.infolist()[0]
    offset = info.header_offset
raw = bytearray(archive.read_bytes())
require(raw[offset:offset+4] == bytes.fromhex('504b0304'), 'local header signature')
name_len, extra_len = struct.unpack_from('<HH', raw, offset + 26)
data_start = offset + 30 + name_len + extra_len
require(raw[data_start:data_start+10] == b'version=1' + bytes([10]), 'unexpected payload')
raw[data_start] ^= 1
archive.write_bytes(raw)
with zipfile.ZipFile(archive) as z:
    crc = z.testzip()
    per_entry = inspect_entries(z)
    by_name = z.read('current/report.txt').decode()
require(crc is None, 'testzip behavior differs: inspect this Python version')
require(len(per_entry) == 1 and per_entry[0]['index'] == 0, 'entry corruption was missed')
require(by_name == 'version=2' + chr(10), 'name lookup differs')
corrupt_result = {'testzip': crc, 'entry_errors': per_entry, 'read_by_name': by_name}

residue = root / 'existing_destination'
residue.mkdir()
(residue / 'obsolete.txt').write_bytes(b'old file' + bytes([10]))
with zipfile.ZipFile(root / 'correct.zip') as z:
    z.extractall(residue)
residue_result = compare(baseline, manifest(residue))
require(residue_result['extra'] == ['obsolete.txt'], 'residue disappeared')
require(manifest(source) == baseline, 'source changed')
report = {'utc': datetime.now(timezone.utc).isoformat(), 'system': platform.system(),
          'python': platform.python_version(), 'root': str(root), 'results': results,
          'corrupt_duplicate': corrupt_result, 'existing_destination': residue_result}
(root / 'results.json').write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(report, ensure_ascii=False, indent=2))

Читайте также

Новости

Публикации