Files
hkuds--deeptutor/tests/utils/test_archive_extractor.py
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

163 lines
5.7 KiB
Python

"""Tests for the safe ZIP extractor (``deeptutor.utils.archive_extractor``).
These lock in the security guards that a naive ``extractall`` lacks: Zip Slip
defusal, extension whitelisting, nested-archive rejection, duplicate handling,
and the zip-bomb size/count/ratio limits.
"""
from __future__ import annotations
from pathlib import Path
import zipfile
import pytest
from deeptutor.utils.archive_extractor import (
ArchiveTooLargeError,
ZipExtractionLimits,
safe_extract_zip,
)
DOC_EXTS = {".txt", ".md", ".pdf", ".zip"} # .zip is intentionally never extracted
def _make_zip(path: Path, entries: list[tuple[str, bytes]], *, deflate: bool = False) -> Path:
mode = zipfile.ZIP_DEFLATED if deflate else zipfile.ZIP_STORED
with zipfile.ZipFile(path, "w", compression=mode) as zf:
for name, data in entries:
# A ZipInfo carries its own compress_type, so set it explicitly —
# otherwise writestr(ZipInfo, ...) ignores the archive's mode.
info = zipfile.ZipInfo(name)
info.compress_type = mode
zf.writestr(info, data)
return path
def test_extracts_allowed_files_flat(tmp_path: Path) -> None:
src = _make_zip(tmp_path / "a.zip", [("notes.txt", b"hello"), ("paper.md", b"# title")])
out = tmp_path / "out"
result = safe_extract_zip(src, out, allowed_extensions=DOC_EXTS)
names = sorted(p.name for p in result.extracted)
assert names == ["notes.txt", "paper.md"]
assert (out / "notes.txt").read_bytes() == b"hello"
assert result.skipped == []
def test_zip_slip_is_defused(tmp_path: Path) -> None:
# Member names that try to escape via traversal or absolute paths.
src = _make_zip(
tmp_path / "evil.zip",
[("../../escape.txt", b"x"), ("/abs/secret.txt", b"y"), ("a/b/deep.txt", b"z")],
)
out = tmp_path / "out"
result = safe_extract_zip(src, out, allowed_extensions=DOC_EXTS)
# Everything is flattened into the target; nothing escapes it.
for p in result.extracted:
assert out.resolve() in p.resolve().parents
assert not (tmp_path / "escape.txt").exists()
assert not Path("/abs/secret.txt").exists()
assert sorted(p.name for p in result.extracted) == ["deep.txt", "escape.txt", "secret.txt"]
def test_disallowed_extension_is_skipped(tmp_path: Path) -> None:
src = _make_zip(tmp_path / "a.zip", [("ok.txt", b"x"), ("malware.exe", b"x"), ("run.sh", b"x")])
out = tmp_path / "out"
result = safe_extract_zip(src, out, allowed_extensions=DOC_EXTS)
assert [p.name for p in result.extracted] == ["ok.txt"]
skipped_names = {member for member, _ in result.skipped}
assert "malware.exe" in skipped_names
assert "run.sh" in skipped_names
def test_nested_zip_is_never_extracted(tmp_path: Path) -> None:
src = _make_zip(tmp_path / "a.zip", [("inner.zip", b"PK\x03\x04"), ("ok.txt", b"x")])
out = tmp_path / "out"
result = safe_extract_zip(src, out, allowed_extensions=DOC_EXTS)
assert [p.name for p in result.extracted] == ["ok.txt"]
assert any(member == "inner.zip" for member, _ in result.skipped)
def test_macosx_and_dotfiles_are_skipped(tmp_path: Path) -> None:
src = _make_zip(
tmp_path / "a.zip",
[("__MACOSX/._notes.txt", b"junk"), (".hidden.txt", b"junk"), ("real.txt", b"ok")],
)
out = tmp_path / "out"
result = safe_extract_zip(src, out, allowed_extensions=DOC_EXTS)
assert [p.name for p in result.extracted] == ["real.txt"]
def test_duplicate_basenames_after_flattening(tmp_path: Path) -> None:
src = _make_zip(tmp_path / "a.zip", [("chap1/notes.txt", b"one"), ("chap2/notes.txt", b"two")])
out = tmp_path / "out"
result = safe_extract_zip(src, out, allowed_extensions=DOC_EXTS)
assert [p.name for p in result.extracted] == ["notes.txt"]
assert (out / "notes.txt").read_bytes() == b"one" # first wins
assert any("duplicate" in reason for _, reason in result.skipped)
def test_per_entry_size_cap_raises(tmp_path: Path) -> None:
src = _make_zip(tmp_path / "a.zip", [("big.txt", b"x" * 100)])
out = tmp_path / "out"
with pytest.raises(ArchiveTooLargeError):
safe_extract_zip(
src, out, allowed_extensions=DOC_EXTS, limits=ZipExtractionLimits(max_entry_bytes=10)
)
def test_too_many_entries_raises(tmp_path: Path) -> None:
src = _make_zip(tmp_path / "a.zip", [(f"f{i}.txt", b"x") for i in range(5)])
out = tmp_path / "out"
with pytest.raises(ArchiveTooLargeError):
safe_extract_zip(
src, out, allowed_extensions=DOC_EXTS, limits=ZipExtractionLimits(max_entries=3)
)
def test_total_size_cap_raises(tmp_path: Path) -> None:
src = _make_zip(tmp_path / "a.zip", [("a.txt", b"x" * 60), ("b.txt", b"x" * 60)])
out = tmp_path / "out"
with pytest.raises(ArchiveTooLargeError):
safe_extract_zip(
src,
out,
allowed_extensions=DOC_EXTS,
limits=ZipExtractionLimits(max_total_bytes=100, max_entry_bytes=100),
)
def test_compression_ratio_guard_raises(tmp_path: Path) -> None:
# 50 KB of identical bytes compresses to a few hundred bytes -> very high ratio.
src = _make_zip(tmp_path / "bomb.zip", [("a.txt", b"A" * 50_000)], deflate=True)
out = tmp_path / "out"
with pytest.raises(ArchiveTooLargeError):
safe_extract_zip(
src,
out,
allowed_extensions=DOC_EXTS,
limits=ZipExtractionLimits(max_compression_ratio=10.0),
)
def test_bad_zip_raises(tmp_path: Path) -> None:
bogus = tmp_path / "not.zip"
bogus.write_bytes(b"this is not a zip")
with pytest.raises(zipfile.BadZipFile):
safe_extract_zip(bogus, tmp_path / "out", allowed_extensions=DOC_EXTS)