fix(archive): magic-byte archive detection so mis-named archives extract — #713 part 1
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 2m59s

Patreon attachment downloads land with sanitized URL-blob filenames
(01_https___www.patreon.com_media-u_v3_<id>) whose Path.suffix is junk, never
.zip — so the extension-only is_archive() filed them as opaque PostAttachments
and never extracted them ("No images attached to this post").

Add archive_extractor.detect_archive_format() — extension first, then magic-byte
sniff (zipfile.is_zipfile + RAR/7z signatures). is_archive(), extract_archive(),
and safe_probe._inspect_archive() (the bomb-guard) all route through it, so a
mis-named/extension-less archive is now detected, bomb-guarded, integrity-tested,
AND extracted regardless of filename. Stops new ones; part 2 re-extracts the
already-imported backlog.

Tests: mis-named zip detected + extracted; non-archive dotted name not
misdetected; _inspect_archive on a mis-named zip; signature updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 14:33:24 -04:00
parent 911d535f56
commit 5bb25245a5
4 changed files with 83 additions and 15 deletions
+41 -5
View File
@@ -16,9 +16,45 @@ log = logging.getLogger(__name__)
ARCHIVE_EXTS = {".zip", ".cbz", ".rar", ".7z"} ARCHIVE_EXTS = {".zip", ".cbz", ".rar", ".7z"}
# Magic-byte signatures, so an archive with a mangled / extension-less filename
# is still recognised. Patreon attachment download URLs sanitize to names like
# `01_https___www.patreon.com_media-u_v3_131083093`, whose `Path.suffix` is junk
# (`.com_media-u_v3_131083093`), never `.zip` — an extension-only gate filed
# those as opaque PostAttachments and NEVER extracted them (operator-flagged
# 2026-06-06). Detection is by extension first (cheap), then header sniff.
_RAR_MAGIC = b"Rar!\x1a\x07"
_7Z_MAGIC = b"7z\xbc\xaf\x27\x1c"
def detect_archive_format(path: Path) -> str | None:
"""Return "zip" | "rar" | "7z" for an archive, else None.
Trusts a known extension first, then falls back to magic-byte sniffing so a
mis-named or extension-less archive is still handled. (zip covers .cbz too.)
"""
ext = Path(path).suffix.lower()
if ext in (".zip", ".cbz"):
return "zip"
if ext == ".rar":
return "rar"
if ext == ".7z":
return "7z"
try:
if zipfile.is_zipfile(path):
return "zip"
with open(path, "rb") as fh:
head = fh.read(8)
except OSError:
return None
if head.startswith(_RAR_MAGIC):
return "rar"
if head.startswith(_7Z_MAGIC):
return "7z"
return None
def is_archive(path: Path) -> bool: def is_archive(path: Path) -> bool:
return Path(path).suffix.lower() in ARCHIVE_EXTS return detect_archive_format(path) is not None
@contextmanager @contextmanager
@@ -32,16 +68,16 @@ def extract_archive(path: Path):
members: list[tuple[str, Path]] = [] members: list[tuple[str, Path]] = []
try: try:
try: try:
ext = Path(path).suffix.lower() fmt = detect_archive_format(path)
if ext in (".zip", ".cbz"): if fmt == "zip":
with zipfile.ZipFile(path) as zf: with zipfile.ZipFile(path) as zf:
zf.extractall(base) zf.extractall(base)
elif ext == ".rar": elif fmt == "rar":
import rarfile import rarfile
with rarfile.RarFile(path) as rf: with rarfile.RarFile(path) as rf:
rf.extractall(base) rf.extractall(base)
elif ext == ".7z": elif fmt == "7z":
import py7zr import py7zr
with py7zr.SevenZipFile(path, "r") as zf: with py7zr.SevenZipFile(path, "r") as zf:
+13 -9
View File
@@ -149,9 +149,8 @@ def _run_probe(path_str: str) -> tuple[str, str | None]:
call the same code path. call the same code path.
""" """
path = Path(path_str) path = Path(path_str)
ext = path.suffix.lower()
try: try:
total, test_bad = _inspect_archive(path, ext) total, test_bad = _inspect_archive(path)
except Exception as exc: # noqa: BLE001 — clean rejection except Exception as exc: # noqa: BLE001 — clean rejection
return ("error", f"{type(exc).__name__}: {exc}") return ("error", f"{type(exc).__name__}: {exc}")
if total is not None and total > MAX_ARCHIVE_UNCOMPRESSED_BYTES: if total is not None and total > MAX_ARCHIVE_UNCOMPRESSED_BYTES:
@@ -162,24 +161,29 @@ def _run_probe(path_str: str) -> tuple[str, str | None]:
return ("ok", None) return ("ok", None)
def _inspect_archive(path: Path, ext: str): def _inspect_archive(path: Path):
"""Return (total_uncompressed_bytes | None, first_bad_member | None) """Return (total_uncompressed_bytes | None, first_bad_member | None)
for the archive. Format-specific; raises on a structurally-broken for the archive. Format detected by extension OR magic bytes (so a
container (caught by the child as a clean rejection).""" mis-named archive is still bomb-guarded + integrity-tested, matching the
if ext in (".zip", ".cbz"): extractor's gate); raises on a structurally-broken container (caught by the
child as a clean rejection)."""
from ..services.archive_extractor import detect_archive_format
fmt = detect_archive_format(path)
if fmt == "zip":
import zipfile import zipfile
with zipfile.ZipFile(path) as zf: with zipfile.ZipFile(path) as zf:
total = sum(zi.file_size for zi in zf.infolist()) total = sum(zi.file_size for zi in zf.infolist())
return total, zf.testzip() return total, zf.testzip()
if ext == ".rar": if fmt == "rar":
import rarfile import rarfile
with rarfile.RarFile(path) as rf: with rarfile.RarFile(path) as rf:
total = sum(getattr(ri, "file_size", 0) for ri in rf.infolist()) total = sum(getattr(ri, "file_size", 0) for ri in rf.infolist())
rf.testrar() rf.testrar()
return total, None return total, None
if ext == ".7z": if fmt == "7z":
import py7zr import py7zr
with py7zr.SevenZipFile(path, "r") as zf: with py7zr.SevenZipFile(path, "r") as zf:
@@ -187,5 +191,5 @@ def _inspect_archive(path: Path, ext: str):
total = getattr(info, "uncompressed", None) total = getattr(info, "uncompressed", None)
ok = zf.test() # True / None when all members pass ok = zf.test() # True / None when all members pass
return total, (None if ok in (True, None) else "7z test reported corruption") return total, (None if ok in (True, None) else "7z test reported corruption")
# Unknown extension — nothing to test; treat as clean. # Not a recognised archive — nothing to test; treat as clean.
return None, None return None, None
+18
View File
@@ -39,6 +39,24 @@ def test_cbz_alias(tmp_path):
assert [n for n, _ in members] == ["p1.jpg"] assert [n for n, _ in members] == ["p1.jpg"]
def test_misnamed_zip_detected_and_extracted(tmp_path):
"""A real zip whose filename has no usable extension (Patreon attachment
URL-blob name) is detected by magic bytes and its members extracted —
previously it was filed as an opaque attachment and never opened."""
z = tmp_path / "01_https___www.patreon.com_media-u_v3_131083093"
_zip(z, {"a.jpg": b"img", "b.png": b"img2"})
assert is_archive(z)
with extract_archive(z) as members:
assert sorted(n for n, _ in members) == ["a.jpg", "b.png"]
def test_non_archive_with_dotted_name_is_not_archive(tmp_path):
"""A non-archive file with a dotted/extension-less name isn't misdetected."""
f = tmp_path / "01_https___www.patreon.com_media-u_v3_999"
f.write_bytes(b"\x89PNG\r\n\x1a\n not really but not a zip")
assert not is_archive(f)
def test_corrupt_archive_yields_nothing(tmp_path): def test_corrupt_archive_yields_nothing(tmp_path):
bad = tmp_path / "broken.zip" bad = tmp_path / "broken.zip"
bad.write_bytes(b"not a zip at all") bad.write_bytes(b"not a zip at all")
+11 -1
View File
@@ -40,11 +40,21 @@ def test_probe_archive_corrupt_zip_clean_rejection(tmp_path):
def test_inspect_archive_reports_size_and_clean_integrity(tmp_path): def test_inspect_archive_reports_size_and_clean_integrity(tmp_path):
z = tmp_path / "sized.zip" z = tmp_path / "sized.zip"
_zip(z, {"a.txt": b"x" * 100, "b.txt": b"y" * 50}) _zip(z, {"a.txt": b"x" * 100, "b.txt": b"y" * 50})
total, bad = safe_probe._inspect_archive(z, ".zip") total, bad = safe_probe._inspect_archive(z)
assert total == 150 assert total == 150
assert bad is None assert bad is None
def test_inspect_archive_detects_misnamed_zip(tmp_path):
"""A zip with a mangled / extension-less name (Patreon URL-blob attachment)
is still bomb-guarded + integrity-tested via magic-byte detection."""
z = tmp_path / "01_https___www.patreon.com_media-u_v3_131083093"
_zip(z, {"a.txt": b"x" * 100})
total, bad = safe_probe._inspect_archive(z)
assert total == 100
assert bad is None
def test_run_probe_bomb_guard(tmp_path, monkeypatch): def test_run_probe_bomb_guard(tmp_path, monkeypatch):
"""In-process call to _run_probe so the monkeypatched cap takes effect. """In-process call to _run_probe so the monkeypatched cap takes effect.
The public probe_archive runs in a subprocess which re-imports the The public probe_archive runs in a subprocess which re-imports the