Files
FabledCurator/backend/app/services/archive_extractor.py
T
bvandeusen 5bb25245a5
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
fix(archive): magic-byte archive detection so mis-named archives extract — #713 part 1
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>
2026-06-06 14:33:24 -04:00

94 lines
3.1 KiB
Python

"""Best-effort archive member extraction (filesystem-import aid).
zip/cbz via stdlib zipfile; rar via rarfile (needs an unrar/bsdtar
binary); 7z via py7zr (pure-Python). NEVER raises: on any failure the
context yields an empty list, and the caller still preserves the archive
file itself as a PostAttachment, so nothing is lost.
"""
import logging
import zipfile
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
log = logging.getLogger(__name__)
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:
return detect_archive_format(path) is not None
@contextmanager
def extract_archive(path: Path):
"""Context manager yielding a list of (member_name, extracted_path)
for every regular-file member. Temp dir is removed on exit. Members
are only ever under the temp dir (libs' safe extractall + rglob from
base). Fail-soft: any error → yields []."""
tmp = TemporaryDirectory(prefix="fc_arch_")
base = Path(tmp.name)
members: list[tuple[str, Path]] = []
try:
try:
fmt = detect_archive_format(path)
if fmt == "zip":
with zipfile.ZipFile(path) as zf:
zf.extractall(base)
elif fmt == "rar":
import rarfile
with rarfile.RarFile(path) as rf:
rf.extractall(base)
elif fmt == "7z":
import py7zr
with py7zr.SevenZipFile(path, "r") as zf:
zf.extractall(base)
for p in sorted(base.rglob("*")):
if p.is_file():
members.append((str(p.relative_to(base)), p))
except Exception as exc:
log.warning("archive extract failed for %s: %s", path, exc)
members = []
yield members
finally:
tmp.cleanup()