ZIP을 풀기 전에 Python으로 파일 수와 선언 크기 점검하기

작은 합성 ZIP 사전 점검을 요약한 설명용 도해입니다. 실제 앱 화면이 아니며 CRC는 손상 점검입니다. 악성 여부나 추출·실행의 안전성을 보장하지 않습니다.

ZIP을 받았다면 먼저 항목 수, 이름 중복, 선언된 압축 해제 크기를 확인하세요. 이 실습은 가상 ZIP의 세 파일과 합계 31바이트를 목록으로 확인하고, 정한 한도를 넘으면 내부 파일을 읽는 CRC 검사도 시작하지 않습니다. 파일을 디스크에 풀거나 내용 앱을 여는 기능은 없습니다.

Python 표준 라이브러리로 로컬 실습 자료만 읽습니다. 입력은 모두 직접 만든 짧은 합성 자료입니다. 출처가 불분명한 ZIP이나 실제 악성 파일을 이 예제로 시험하지 마세요. 이 코드는 수업용 사전 점검기이며, 적대적인 압축 파일을 격리하는 보안 도구가 아닙니다.

실습 ZIP 다운로드

1 압축 파일 크기와 압축 해제 선언 크기를 나눕니다

fixtures/main.zip 안에는 다음 세 파일만 들어 있습니다. 이름과 순서는 이 실습의 고정 계약입니다. ASCII 문자열이며 마지막 LF 줄바꿈도 한 바이트로 셉니다.

ZIP 안 이름고정 내용직접 센 바이트
memo.txthello 뒤 LF 하나5+1=6
table.csvitem,qty 뒤 LF, A1,2 뒤 LF, B2,3 뒤 LF9+5+5=19
notes.txtdone! 뒤 LF 하나5+1=6
합계파일 3개, 디렉터리 항목 0개6+19+6=31

표의 table.csv 내용은 다음과 같습니다. 개별 내부 파일을 추출하지 않아도 첨부의 expected/hand_calculation.txt에서 같은 바이트 계약을 확인할 수 있습니다.

item,qty
A1,2
B2,3

이 ZIP은 STORED 방식으로 만들었으므로 압축된 내용 부분의 합도 31바이트입니다. 헤더와 이름까지 포함한 ZIP 파일 자체는 333바이트입니다. ZIP 파일 크기 333과 내부 선언 크기 31을 혼동하지 않습니다. 작은 내용이라 압축하지 않은 것이며, 크기를 작게 보이도록 공격용 파일을 만든 실습이 아닙니다.

infolist()는 항목별 ZipInfo 목록을 ZIP 순서대로 돌려줍니다. file_size는 압축 해제 크기, compress_size는 압축된 내용 크기 필드입니다. 여기서는 둘 다 제작자가 적어 둔 메타데이터 선언값으로 취급합니다. 이름과 합계가 맞아도 내부 바이트를 읽은 결과는 아직 없습니다. Python 3.12 ZIP 목록과 필드

흐린 노트북 화면 옆 빈 체크리스트와 닫힌 서류 폴더를 준비한 AI 연출 사진
AI로 만든 연출 이미지. 첨부 파일의 목록과 점검 기준을 준비하는 상황이며, 실제 ZIP 목록·CRC 검사·안전성 판정 결과가 아닙니다.

2 이 실습의 한도를 먼저 정합니다

명령 옵션을 생략하면 아래 기본 한도를 적용합니다. 모든 값은 양의 정수이며, 옵션으로 1부터 기본값까지 줄일 수 있습니다. 기본값보다 큰 값은 실행 오류로 거부합니다. 실제 조직의 승인 기준을 이 숫자로 대신하지 마세요.

옵션기본 상한검사 시점
--max-archive-bytes65536바이트ZipFile을 만들기 전 입력 파일 크기와 실제 읽은 ZIP 바이트
--max-entries16항목디렉터리 표시를 포함한 모든 ZipInfo 수
--max-file-bytes4096바이트각 항목의 선언 크기, CRC 단계의 반환 바이트 관측치
--max-total-bytes8192바이트모든 항목의 선언 크기 합, CRC 반환 바이트의 누적 관측치
--max-ratio100선언 크기 ≤ 100 × max(압축된 내용 크기, 1)

max(..., 1)은 압축 크기가 0일 때 나누기 0을 피하는 이 실습의 정책입니다. 높은 압축비를 보류한다고 공격 여부가 확정되는 것은 아닙니다. 반복 문자처럼 정상 자료도 높은 압축비를 가질 수 있습니다.

다음 메타데이터가 하나라도 걸리면 보고서는 HOLD이고, --crc를 요청해도 내부 항목 open 호출은 0입니다.

  • 완전히 같은 ZIP 이름이 둘 이상 있음, 빈 ZIP, 항목 수나 선언 크기 한도 초과
  • 위 압축비 상한 초과, 암호화 플래그가 있음, 허용한 STORED·DEFLATED 외 방식
  • .., 절대경로, 드라이브, 역슬래시, 빈 경로 구성요소, 제어문자 등 이름 우려
  • Unix 종류 메타데이터가 링크·특수 파일을 가리킴, 디렉터리 표시 항목에 내용 크기가 있음

이름은 자동 수정하지 않습니다. 같은 이름 검사는 대소문자를 구분하며 A.txt와 a.txt의 충돌이나 모든 Windows 예약 이름까지 판별하지 않습니다. 메타데이터의 링크 표식 역시 모든 형식·운영체제의 링크를 인증하는 검사가 아닙니다. 이 글의 끝점은 확인할 우려를 표시하고 읽기를 보류하는 것입니다. 안전한 추출 대상 경로를 만드는 구현은 포함하지 않습니다.

3 메타데이터만 먼저 읽고 새 보고서를 만듭니다

내려받은 바깥 실습 ZIP만 새 빈 로컬 연습 폴더에 직접 풀고 README.txt가 있는 위치에서 터미널을 엽니다. fixtures 안의 ZIP은 풀지 마세요. 다른 앱이 입력을 수정하지 않는 자기 소유의 작은 실습 폴더에서 한 번에 한 작업만 실행합니다.

실제 검수 환경은 CPython 3.12.14, Linux 6.18.44, x86_64입니다. Windows·macOS·탐색기·Excel·별도 압축 앱의 실행은 확인하지 않았습니다. 추가 패키지나 API 키는 필요 없습니다.

python --version
python tests/verify_checksums.py
python -m unittest discover -s tests -v
python code/zip_preflight.py fixtures/main.zip --out main_metadata.json

python3가 같은 Python 3.12 인터프리터를 가리키는 환경이면 명령 첫 단어만 바꿉니다. 다른 환경에서는 제공 시험을 먼저 실행하고 실패한 상태에서 업무 자료를 넣지 않습니다. 정상 메타데이터 명령의 종료코드는 0이며 화면은 다음과 같습니다.

PASS entries=3 declared_bytes=31 crc=not_requested observed_bytes=0 extracted_files=0

main_metadata.json을 텍스트 편집기로 확인합니다. expected/main_metadata.json은 같은 전체 보고서 구조의 독립 기대값이며, 원본 바이트 지문인 source_sha256만 별도로 대조합니다.

  • entry_count=3, file_count=3, directory_count=0, declared_total_bytes=31
  • entries의 이름과 순서가 memo.txt·table.csv·notes.txt, 선언 크기가 6·19·6
  • duplicate_names=[], issues=[], extracted_files=0
  • crc.requested=false, performed=false, complete=false, state="not_requested"
  • 각 항목 observed_bytes=null, crc_state="not_requested"

이 PASS는 메타데이터 정책을 통과했다는 뜻입니다. 관측 합계 0과 null은 내부 바이트를 검사하지 않은 상태를 뜻하며, CRC 성공이나 빈 내용 판정으로 읽지 않습니다.

4 정책을 통과했을 때만 CRC 읽기를 선택합니다

이번 정상 합성 자료에 대해서만 --crc를 붙이고 또 다른 새 이름을 씁니다.

python code/zip_preflight.py fixtures/main.zip --out main_crc.json --crc
PASS entries=3 declared_bytes=31 crc=passed observed_bytes=31 extracted_files=0

종료코드는 0입니다. 보고서의 crc 값은 다음과 같아야 합니다.

{
  "requested": true,
  "performed": true,
  "complete": true,
  "state": "passed",
  "reason": null,
  "started_entries": 3,
  "passed_entries": 3,
  "observed_total_bytes": 31
}

CRC 경로는 ZipFile.open(info, 'r')에서 받은 읽기 전용 객체를 1024바이트 이하 요청으로 읽습니다. 메타데이터 검사가 먼저 끝나며, 정책 실패에서는 여기까지 오지 않습니다. ZipFile.open의 읽기 모드

코드는 매번 호출자에게 반환된 바이트를 파일별·전체로 셉니다. 남은 한도보다 한 바이트까지 추가 요청해 초과 여부를 확인하고, 관측치가 한도나 선언 크기를 넘으면 즉시 멈춥니다. 끝까지 읽은 관측치가 선언 크기와 달라도 보류합니다. 이 수는 압축 라이브러리가 내부에서 처리한 전체 양이나 CPU 비용을 독립적으로 측정한 값이 아닙니다.

complete=true와 passed_entries는 읽기 대상인 파일 항목 전체에 관한 값입니다. 끝이 /인 디렉터리 표시 항목은 열지 않고 directory_not_read로 남깁니다. 기본 예제에는 디렉터리 표시가 없어 세 항목 모두 파일입니다. 파일 항목 없이 디렉터리 표시만 있다면 state="no_file_entries", performed=false, complete=false로 남겨 CRC를 실제 수행했다고 표시하지 않습니다.

일반 파일의 읽기가 끝나면 zipfile의 CRC 오류도 검사 경로에서 드러납니다. testzip()은 전체 파일을 읽는 함수이므로 이 코드에서는 호출하지 않습니다. testzip의 전체 읽기 설명

CRC는 우발적 손상 점검입니다. 악성코드 검사, 작성자 인증, 출처 확인, 문서의 업무상 정확성, 압축 해제·실행 승인으로 사용하지 않습니다. CRC를 맞춘 거짓 메타데이터까지 모두 발견한다는 보장도 없습니다.

가상 ZIP의 선언 31바이트와 선택 CRC 반환 관측 31바이트를 구분하며 미요청 관측 null 및 물리 ABC가 선언2 CRC AB 반환AB2로 좁은 검사를 통과하는 한계를 보여 주는 설명용 도해
설명용 도해이며 실제 실행 화면이 아닙니다. 주 합성 ZIP은 선언 6+19+6=31바이트이고 CRC 선택 시 반환 관측 합계도 31입니다. 별도 작은 한계 예제는 물리 ABC와 선언2·CRC(AB)가 반환 AB 2로 이 범위 검사를 통과할 수 있음을 보여 줍니다. 전체 물리 내용·악성 여부·안전성을 증명하지 않습니다.

5 ZIP과 같은 전체 코드

다음은 첨부의 code/zip_preflight.py 전문입니다. 입력은 읽기 모드이며 내부 내용은 디스크에 쓰지 않습니다. 출력은 검사 JSON 한 개뿐입니다.

"""Read-only preflight for tiny, controlled ZIP fixtures. Never extract."""
import argparse
from collections import Counter
import hashlib
import io
import json
import os
from pathlib import Path, PureWindowsPath
import stat
import sys
import tempfile
import zipfile
import zlib

CHUNK = 1024
DEFAULTS = {'max_archive_bytes': 65536, 'max_entries': 16,
            'max_file_bytes': 4096, 'max_total_bytes': 8192, 'max_ratio': 100}
SUPPORTED = {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}


def policy(**values):
    result = DEFAULTS | values
    if set(result) != set(DEFAULTS):
        raise ValueError('UNKNOWN_LIMIT')
    for key, value in result.items():
        if type(value) is not int or not 1 <= value <= DEFAULTS[key]:
            raise ValueError(f'{key}: choose 1..{DEFAULTS[key]}')
    return result


def plain_path(value):
    path = Path(value)
    if '..' in path.parts:
        raise ValueError('PATH_DOTDOT')
    path = path.absolute()
    for node in [*reversed(path.parents), path]:
        info = node.lstat()
        if stat.S_ISLNK(info.st_mode) or getattr(info, 'st_file_attributes', 0) & 0x400:
            raise ValueError('PATH_LINK')
    return path


def read_archive(value, limit):
    path = plain_path(value)
    info = path.lstat()
    if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
        raise ValueError('INPUT_NOT_PLAIN_FILE')
    if info.st_size > limit:
        raise ValueError('ARCHIVE_BYTE_LIMIT')
    flags = os.O_RDONLY | getattr(os, 'O_BINARY', 0)
    flags |= getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0)
    with os.fdopen(os.open(path, flags), 'rb') as handle:
        before = os.fstat(handle.fileno())
        if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1:
            raise ValueError('INPUT_NOT_PLAIN_FILE')
        if (before.st_dev, before.st_ino) != (info.st_dev, info.st_ino):
            raise ValueError('INPUT_CHANGED')
        raw = handle.read(limit + 1)
        after = os.fstat(handle.fileno())
    if len(raw) > limit:
        raise ValueError('ARCHIVE_BYTE_LIMIT')
    stamp = lambda s: (s.st_dev, s.st_ino, s.st_size, s.st_mtime_ns, s.st_ctime_ns)
    if stamp(before) != stamp(after) or len(raw) != before.st_size:
        raise ValueError('INPUT_CHANGED')
    return path, raw


def name_concern(info):
    name = info.orig_filename
    clean = name[:-1] if info.is_dir() else name
    return (not clean or name.startswith('/') or '\\' in name or ':' in name
            or bool(PureWindowsPath(name).drive)
            or any(ord(c) < 32 for c in name)
            or any(p in ('', '.', '..') for p in clean.split('/')))


def metadata(infos, limits):
    names = Counter(i.filename for i in infos)
    duplicates = sorted(n for n, count in names.items() if count > 1)
    issues, rows = set(), []
    if not infos:
        issues.add('empty_archive')
    if len(infos) > limits['max_entries']:
        issues.add('entry_count_limit')
    if duplicates:
        issues.add('duplicate_name')
    total = sum(i.file_size for i in infos)
    if total > limits['max_total_bytes']:
        issues.add('declared_total_limit')
    for index, info in enumerate(infos):
        concerns = []
        if info.file_size > limits['max_file_bytes']:
            concerns.append('declared_file_limit')
        if info.file_size > limits['max_ratio'] * max(info.compress_size, 1):
            concerns.append('ratio_limit')
        if info.flag_bits & 1:
            concerns.append('encrypted_entry')
        if info.compress_type not in SUPPORTED:
            concerns.append('unsupported_method')
        if name_concern(info):
            concerns.append('path_concern')
        kind = stat.S_IFMT(info.external_attr >> 16) if info.create_system == 3 else 0
        if kind not in (0, stat.S_IFREG, stat.S_IFDIR):
            concerns.append('link_or_special_entry')
        if info.is_dir() and info.file_size != 0:
            concerns.append('directory_has_data')
        issues.update(concerns)
        rows.append({'index': index, 'name': info.filename, 'directory': info.is_dir(),
                     'declared_bytes': info.file_size, 'compressed_bytes': info.compress_size,
                     'method': info.compress_type, 'encrypted': bool(info.flag_bits & 1),
                     'concerns': sorted(concerns), 'observed_bytes': None,
                     'crc_state': 'not_requested'})
    return rows, sorted(issues), duplicates, total


def bounded_crc(z, infos, rows, limits, state):
    """Count only bytes returned by ZipExtFile; these are not sandbox limits."""
    if not any(not info.is_dir() for info in infos):
        state.update(state='no_file_entries', reason='no_file_entries')
        for row in rows:
            row['crc_state'] = 'directory_not_read'
        return None
    state.update(performed=True, state='passed', reason=None)
    for row in rows:
        row['crc_state'] = 'not_checked'
    for info, row in zip(infos, rows):
        if info.is_dir():
            row['crc_state'] = 'directory_not_read'
            continue
        state['started_entries'] += 1
        row.update(observed_bytes=0, crc_state='reading')
        failure = None
        try:
            with z.open(info, 'r') as stream:
                while True:
                    remaining = min(limits['max_file_bytes'] - row['observed_bytes'],
                                    limits['max_total_bytes'] - state['observed_total_bytes'])
                    block = stream.read(min(CHUNK, remaining + 1))
                    if not block:
                        break
                    row['observed_bytes'] += len(block)
                    state['observed_total_bytes'] += len(block)
                    if (row['observed_bytes'] > limits['max_file_bytes']
                            or state['observed_total_bytes'] > limits['max_total_bytes']):
                        failure = 'observed_byte_limit'
                        break
                    if row['observed_bytes'] > info.file_size:
                        failure = 'observed_size_mismatch'
                        break
            if failure is None and row['observed_bytes'] != info.file_size:
                failure = 'observed_size_mismatch'
        except (zipfile.BadZipFile, EOFError, RuntimeError, NotImplementedError,
                OSError, ValueError, zlib.error):
            failure = 'crc_or_structure_error'
        if failure:
            row['crc_state'] = 'failed'
            state.update(state='failed', complete=False, reason=failure)
            return failure
        row['crc_state'] = 'passed'
        state['passed_entries'] += 1
    state['complete'] = True
    return None


def inspect(source, crc=False, limits=None):
    limits = policy(**(limits or {}))
    path, raw = read_archive(source, limits['max_archive_bytes'])
    source_hash = hashlib.sha256(raw).hexdigest()
    state = {'requested': bool(crc), 'performed': False, 'complete': False,
             'state': 'not_requested', 'reason': 'option_not_selected',
             'started_entries': 0, 'passed_entries': 0, 'observed_total_bytes': 0}
    with zipfile.ZipFile(io.BytesIO(raw), 'r') as z:
        infos = z.infolist()
        rows, issues, duplicates, total = metadata(infos, limits)
        if crc and issues:
            state.update(state='blocked_by_policy', reason='metadata_policy_failed')
            for row in rows:
                row['crc_state'] = 'blocked_by_policy'
        elif crc:
            failure = bounded_crc(z, infos, rows, limits, state)
            if failure:
                issues.append(failure)
    _, after = read_archive(path, limits['max_archive_bytes'])
    if hashlib.sha256(after).hexdigest() != source_hash:
        raise ValueError('INPUT_CHANGED')
    return {'schema_version': 1, 'status': 'HOLD' if issues else 'PASS',
            'limits': limits, 'archive_bytes': len(raw), 'entry_count': len(infos),
            'file_count': sum(not i.is_dir() for i in infos),
            'directory_count': sum(i.is_dir() for i in infos),
            'declared_total_bytes': total, 'duplicate_names': duplicates,
            'issues': sorted(issues), 'entries': rows, 'crc': state,
            'source_sha256': source_hash, 'source_unchanged': True,
            'extracted_files': 0, 'archive_content_written': False}


def run(source, output, crc=False, limits=None):
    source = plain_path(source)
    target = Path(output)
    if '..' in target.parts:
        raise ValueError('PATH_DOTDOT')
    parent = plain_path(target.parent)
    target = parent / target.name
    if source == target:
        raise ValueError('INPUT_OUTPUT_SAME')
    if target.suffix.lower() != '.json':
        raise ValueError('OUTPUT_MUST_BE_JSON')
    if not parent.is_dir():
        raise ValueError('OUTPUT_PARENT_NOT_DIRECTORY')
    if os.path.lexists(target):
        raise FileExistsError('OUTPUT_EXISTS: choose a new name')
    report = inspect(source, crc, limits)
    stage = None
    published = False
    try:
        with tempfile.NamedTemporaryFile('w', encoding='utf-8', newline='\n',
                dir=parent, prefix='.p36-', suffix='.tmp', delete=False) as handle:
            stage = Path(handle.name)
            handle.write(json.dumps(report, ensure_ascii=True, indent=2) + '\n')
            handle.flush()
            os.fsync(handle.fileno())
        os.link(stage, target)
        published = True
    finally:
        if stage is not None:
            try:
                stage.unlink(missing_ok=True)
            except OSError:
                if not published:
                    raise
                print('TEMP_CLEANUP_WARNING: complete JSON retained', file=sys.stderr)
    return report


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('archive')
    parser.add_argument('--out', required=True)
    parser.add_argument('--crc', action='store_true')
    for key, value in DEFAULTS.items():
        parser.add_argument('--' + key.replace('_', '-'), type=int, default=value,
                            help=f'1..{value}; default {value}')
    args = parser.parse_args(argv)
    try:
        limits = policy(**{key: getattr(args, key) for key in DEFAULTS})
        report = run(args.archive, args.out, args.crc, limits)
    except (ValueError, OSError, zipfile.BadZipFile, zipfile.LargeZipFile,
            NotImplementedError, UnicodeError, EOFError) as error:
        print('ERROR: ' + str(error), file=sys.stderr)
        return 2
    print(f"{report['status']} entries={report['entry_count']} "
          f"declared_bytes={report['declared_total_bytes']} crc={report['crc']['state']} "
          f"observed_bytes={report['crc']['observed_total_bytes']} extracted_files=0")
    return 0 if report['status'] == 'PASS' else 1


if __name__ == '__main__':
    raise SystemExit(main())

입력 ZIP의 실제 바이트 상한을 ZipFile 생성 전 적용하고 메모리의 바이트 사본을 검사합니다. 목록 수 제한은 ZipFile이 디렉터리 정보를 파싱한 뒤에 적용됩니다. 이 순서를 목록 수 검사 전에 라이브러리 파서가 어떤 자원도 쓰지 않는다는 보장으로 바꾸지 마세요.

원본 ZIP을 다시 읽어 처음의 SHA-256과 비교하고, 차이가 나면 INPUT_CHANGED로 최종 JSON을 만들지 않습니다. source_unchanged=true는 그 두 번의 바이트 비교에 한정합니다. hashlib SHA-256

입력과 부모의 심볼릭 링크·검출 가능한 reparse 표식, 하드 링크 입력, .. 경로 별칭은 거부합니다. FIFO·장치·폴더도 ZIP 입력으로 열지 않습니다. Path.lstat, 일반 파일 종류 검사

6 중복과 한도 초과에서는 CRC를 시작하지 않습니다

다음은 한 가지 우려씩 만든 작은 반례입니다. 각각 새 보고서 이름을 사용합니다. 각 명령의 종료코드는 1입니다.

python code/zip_preflight.py fixtures/duplicate.zip --out duplicate.json --crc
python code/zip_preflight.py fixtures/total_limit.zip --out total_limit.json --crc
python code/zip_preflight.py fixtures/too_many.zip --out too_many.json --crc
python code/zip_preflight.py fixtures/encrypted_flag.zip --out encrypted_flag.json --crc
python code/zip_preflight.py fixtures/unsupported_method.zip --out unsupported_method.json --crc
반례고정 기대값issues의 이유
duplicate.zipsame.txt 두 항목, 선언 1+1=2바이트duplicate_name
total_limit.zip4096+4096+1=8193, 기본 합계 한도보다 1 큼declared_total_limit
too_many.zip한 바이트 파일 17개, 기본 개수 한도보다 1 많음entry_count_limit
encrypted_flag.zipa.txt의 암호화 플래그만 의도적으로 켬encrypted_entry
unsupported_method.zipa.txt의 압축방식 숫자를 99로 표시unsupported_method

이 다섯 보고서에서 requested=true, performed=false, started_entries=0, passed_entries=0, state="blocked_by_policy", reason="metadata_policy_failed"가 나와야 합니다. 항목의 관측 크기는 null이며 실제 내용을 읽지 않았습니다.

encrypted_flag.zip은 진짜 암호화 ZIP을 만든 것이 아닙니다. 아주 작은 파일의 헤더 플래그를 수정한 메타데이터 반례입니다. unsupported_method.zip도 실제 미지원 코덱으로 압축한 자료가 아니라 헤더 방식 숫자만 수정했습니다. 암호 입력·암호 해독·지원 앱 설치를 실습하지 않습니다.

이름 중복은 어느 항목을 선택해야 할지 먼저 공급자에게 확인할 이유입니다. 이름만 같다고 같은 내용이거나 둘 중 하나를 삭제해도 된다고 판단하지 않습니다. 한도 초과도 임의로 기준을 올려 통과시키지 말고, 승인된 기준과 패키지 구성을 확인한 별도 사본으로 다시 검사하세요.

fixtures/ratio_limit.zip, path_concerns.zip, link_entry.zip, file_limit.zip도 첨부 기대값과 자동 대조합니다. 경로와 링크 반례는 메타데이터를 표시할 뿐 실제 경로 밖 쓰기나 심볼릭 링크 추출을 시도하지 않습니다.

7 CRC 손상은 부분 관측치와 함께 보류합니다

fixtures/crc_corrupt.zip은 main.zip의 세 번째 파일 첫 내용 바이트만 바꾸고 CRC 선언을 그대로 둔 반례입니다. 크기와 이름은 처음과 같아서 메타데이터 검사만으로는 걸리지 않습니다.

python code/zip_preflight.py fixtures/crc_corrupt.zip --out crc_corrupt.json --crc
HOLD entries=3 declared_bytes=31 crc=failed observed_bytes=25 extracted_files=0

종료코드는 1이고 완성된 보류 보고서가 생깁니다. issues=["crc_or_structure_error"], complete=false, started_entries=3, passed_entries=2입니다. 앞의 두 파일에서 6+19=25바이트가 반환됐고 세 번째 읽기는 CRC 오류로 실패했습니다.

세 번째 항목의 observed_bytes=0은 이번 읽기가 오류를 내기 전에 호출자에게 바이트를 반환하지 않았다는 뜻입니다. 라이브러리 내부에서도 아무 바이트를 처리하지 않았거나 파일이 비었다는 뜻이 아닙니다. 뒤에 아직 읽지 않은 파일이 있으면 not_checked로 남으며, 앞의 부분 성공을 ZIP 전체 통과로 합치지 않습니다.

첨부의 misdeclared_large.zip은 실제 ABC 세 바이트를 선언 크기 4로 표시합니다. --crc로 읽으면 관측 3과 선언 4가 달라 observed_size_mismatch로 보류합니다. 선언 2인 작은 반례는 남은 CRC와 맞지 않아 crc_or_structure_error입니다. 이 두 반례를 검출했다고 해서 모든 거짓 크기·정교한 ZIP 구조까지 검증한 것은 아닙니다.

손상 우려가 있으면 원본을 보존하고 제공자가 승인한 새 사본을 받거나 확인합니다. CRC를 바꾸어 오류를 숨기거나 내부 파일을 시험 삼아 실행하지 않습니다. 새 사본은 이전 보류 보고서와 다른 이름으로 검사합니다.

8 종료코드와 최종 보고서 상태를 대조합니다

종료코드최종 JSON 상태해석
0새 완성 보고서 있음선택한 검사 범위 PASS, CRC 미요청이면 CRC 미검사
1새 완성 보고서 있음메타데이터 정책 또는 CRC 읽기 HOLD, 부분 CRC 여부를 함께 확인
2이번 실행의 새 최종 JSON 없음인수·입력·파싱·경로·원본 변경·저장 오류, 기존 출력은 보존

파일 자체가 상한보다 크면 ZipFile 파싱 전 종료코드 2로 멈춥니다. 아직 목록을 읽지 않았으므로 entry_count가 있는 보류 보고서를 꾸며 만들지 않습니다.

python code/zip_preflight.py fixtures/main.zip --out archive_too_large.json --max-archive-bytes 332
python code/zip_preflight.py fixtures/bad_zip.zip --out not_zip.json
python code/zip_preflight.py fixtures/missing.zip --out missing.json

위 세 명령은 종료코드 2이며 지정한 새 결과는 없어야 합니다. main.zip은 333바이트라 첫 명령의 332바이트 상한을 넘습니다. bad_zip.zip은 ZIP 구조가 없는 짧은 텍스트이고, missing.zip은 일부러 제공하지 않았습니다.

이미 만든 main_crc.json으로 다시 실행하면 OUTPUT_EXISTS와 종료코드 2가 정상입니다. 이전 보고서를 그대로 두고 새 이름을 지정합니다.

python code/zip_preflight.py fixtures/main.zip --out main_crc.json --crc
python code/zip_preflight.py fixtures/main.zip --out main_crc_again.json --crc

첫 명령은 앞에서 파일을 만들었을 때 기존 출력 오류입니다. 두 번째는 새 이름일 때 종료코드 0입니다. 쉘의 >는 기존 파일을 먼저 덮을 수 있으므로 보고서 저장에는 --out만 사용합니다. &&는 의도한 종료코드 1에서도 다음 명령을 멈출 수 있으므로 반례 명령은 한 줄씩 실행합니다.

출력 부모는 미리 존재해야 합니다. 코드는 같은 폴더의 임시 JSON을 끝까지 쓰고 fsync한 뒤, os.link로 아직 없는 최종 이름을 만듭니다. 하드 링크 미지원·이름 선점·저장 실패에서는 덮어쓰기 방식으로 바꾸지 않습니다. 임시 파일에는 NamedTemporaryFile을 씁니다.

처리된 저장 오류에서는 새 최종 JSON이 없습니다. 이미 확정된 뒤 임시 파일 정리만 실패하면 완성 JSON은 남고 TEMP_CLEANUP_WARNING이 stderr에 나옵니다. .p36- 임시 파일을 완성 결과로 쓰지 마세요. 강제 종료·정전에서는 임시 파일이 남을 수 있으며 이 상황은 시험하지 않았습니다.

9 경계값과 원본 보존까지 다시 확인합니다

정상 합성 자료의 정확한 경계는 항목 3개·파일당 최대 19·합계 31입니다. 그 값은 허용되고, 합계만 30으로 낮추면 CRC 시작 전에 보류됩니다.

python code/zip_preflight.py fixtures/main.zip --out strict_pass.json --crc --max-entries 3 --max-file-bytes 19 --max-total-bytes 31
python code/zip_preflight.py fixtures/main.zip --out strict_hold.json --crc --max-total-bytes 30
python tests/run_fixture_checks.py

두 사전 점검의 종료코드는 각각 0과 1입니다. 마지막 재검산기는 임시 연습 사본에서 CLI 26건을 실행해 고정 기대 JSON, 종료코드, 콘솔, 기존 출력 보존, 오류 시 새 출력 부재를 대조합니다. 화면에 scenario_count=26, all_passed=true, all_source_bytes_unchanged=true가 나와야 합니다.

제공 unittest는 38개, 실패 0·오류 0·건너뜀 0으로 Linux에서 실행했습니다. 메타데이터 검사만 할 때와 정책 실패 때 내부 open 호출 0, 정확 경계, CRC 부분 실패·선언 길이 불일치, 입력 ZIP 바이트 상한의 파싱 전 적용, 링크·기존 출력·원본 변경·저장 오류도 확인했습니다. 관측 바이트 초과 분기는 작은 통제된 읽기 객체를 넣는 모의시험이며 실제 압축 공격 파일로 시험한 결과가 아닙니다. unittest discover

실습 입력 파일 16개의 실행 전후 SHA-256은 같았습니다. 보고서의 해시도 해당 ZIP 바이트에서 독립 계산한 값과 대조합니다. SHA-256 목록은 배포 파일의 변경 확인용이며 작성자 신원이나 내부 안전성을 증명하지 않습니다. 읽기 전용도 OS의 접근시각 갱신까지 막는다는 뜻은 아닙니다.

첨부에는 본문과 같은 코드, 작은 ZIP 입력 15개와 ZIP 형식이 아닌 텍스트 반례 1개, 독립 기대 JSON 18개·손계산, fixture 재생성 도구, 테스트, README, 체크섬만 들어 있습니다. 실행한 실제 출력이나 검수 로그는 미리 넣지 않았으므로 안내한 새 보고서 이름으로 직접 생성할 수 있습니다.

CRC가 통과해도 선언보다 더 많은 내용이 있을 수 있나요

그럴 수 있습니다. 첨부의 spoofed_prefix.zip은 a.txt에 물리적으로 ABC 세 바이트를 넣고, 두 헤더의 크기를 2와 AB의 CRC로 맞춘 아주 작은 한계 반례입니다. 이는 앞의 손상 검출 반례와 구분합니다.

python code/zip_preflight.py fixtures/spoofed_prefix.zip --out spoofed_prefix.json --crc

검수한 CPython 3.12.14에서 실제 출력은 다음과 같았으며 종료코드는 0입니다.

PASS entries=1 declared_bytes=2 crc=passed observed_bytes=2 extracted_files=0

ZipExtFile이 선언에 맞춰 반환한 AB 두 바이트의 CRC가 같으므로 이 좁은 검사에서는 통과합니다. complete=true도 선택한 읽기 경로가 끝났다는 뜻입니다. ZIP에 물리적으로 들어 있는 전체 내용이 정확히 두 바이트라는 독립 증명은 아닙니다. 이 반례의 정답도 PASS로 명시하여, 예상과 다른 안전 보증을 덧붙이지 않도록 연습합니다. 해당 ZIP을 추출하거나 내부 내용을 실행하는 시험은 하지 않습니다.

원본 내용의 기준이 필요하면 승인된 출처·별도 내용 계약과 전문적인 검증 방식이 필요합니다. 이 도구는 입력 ZIP 자체의 SHA-256을 기록하지만, 신뢰한 공급 원본과 비교하거나 내부 문서의 정답을 승인하지 않습니다.

적용 범위와 다음 행동

이 예제는 외부 ZIP을 추출 전에 어떻게 보류할지 연습하는 작은 읽기 전용 도구입니다. 검사 단계에서 파일을 풀거나, 내부 스크립트를 실행하거나, 문서를 다른 앱으로 열지 않습니다. 한도 실패 ZIP에서 testzip()이나 내용을 읽는 다른 검사로 우회하지 않습니다.

작은 입력 상한과 반환 바이트 집계가 있어도 악성 압축 해제의 CPU 비용, ZipFile의 메모리 디렉터리 파싱, 라이브러리 내부 버퍼, 조작된 모든 메타데이터를 완전히 통제하지 못합니다. 자원 격리·시간 제한·운영용 보안 스캐너가 필요할 때 이 예제를 그대로 쓰지 않습니다. Python 공식 문서도 압축 해제의 자원 부족과 형식·CRC·암호 오류를 별도로 다룹니다. 압축 해제의 자원·형식 한계

동기화·네트워크·여러 사용자가 수정하는 폴더, 적대적인 동시 경로 교체, 검사 후 변경은 보호하지 않습니다. 경로 확인과 SHA 재읽기는 일부 실수를 찾아내는 장치이며 원자적 스냅샷이나 파일 잠금이 아닙니다.

직접 배포 ZIP을 만들 때 넣을 파일을 고르는 작업은 필요한 파일만 ZIP에 넣는 허용목록 방식에서 이어서 볼 수 있습니다. 탐색기에서 형식·암호 안내를 나눠 기록하려면 압축 파일 형식과 암호 여부 확인을 참고하세요. 이번 글에서는 추출 없이 사전 점검 결과와 미검사 범위를 남깁니다.

공식 문서 확인일은 2026-10-06 UTC입니다. 열람한 Python 3.12 문서의 표제는 3.12.15이며 실제 실행한 인터프리터는 CPython 3.12.14입니다. 문서 열람, 합성 자료의 로컬 실행, 미실행 앱 동작을 구분했습니다.

게시 전 재검수는 2026-10-07 KST에 했습니다. 기존 준비 원고·실습 ZIP을 보존하고 Linux CPython 3.12.14에서 38개 자동시험과 26건 CLI 대조, 41개 배포 체크섬을 다시 통과했습니다. 같은 날 Python 3.12 공식 ZIP 문서를 재확인했으며 표시 게시일과 실제 자료 확인·재검수일은 구분합니다.

작성자 소개