Release govoplan-files v0.1.26: speed archive workflows and unify file tools
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-09-08 01:32:41 +02:00
parent 2baa8f2657
commit ff84812f7f
35 changed files with 4331 additions and 275 deletions
+208 -69
View File
@@ -6,10 +6,11 @@ import stat
import tarfile
import zipfile
from dataclasses import dataclass
from contextlib import closing
from io import BytesIO
from os import PathLike
from pathlib import Path, PurePosixPath
from typing import Any, BinaryIO, Iterable, Literal
from typing import Any, BinaryIO, Callable, Iterable, Iterator, Literal
import pyzipper
from sqlalchemy.orm import Session
@@ -25,6 +26,7 @@ from govoplan_files.backend.storage.common import (
UploadedStoredFile,
)
from govoplan_files.backend.storage.files import (
archive_storage_backend_scope,
create_file_asset,
current_versions_and_blobs,
)
@@ -33,11 +35,17 @@ from govoplan_files.backend.storage.paths import (
normalize_folder,
normalize_logical_path,
)
from govoplan_files.backend.storage.native_zip import (
native_zip_library,
read_native_zip_members,
)
_ARCHIVE_READ_CHUNK_SIZE = 1024 * 1024
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:")
ARCHIVE_UPLOAD_MAX_ENTRIES = 10_000
ARCHIVE_MAX_PATH_BYTES = 4096
ARCHIVE_MAX_PATH_DEPTH = 128
# Kept for callers and documentation using the former ZIP-specific name.
ZIP_UPLOAD_MAX_FILES = ARCHIVE_UPLOAD_MAX_ENTRIES
SUPPORTED_ARCHIVE_SUFFIXES = (
@@ -51,6 +59,8 @@ SUPPORTED_ARCHIVE_SUFFIXES = (
".zip",
)
ArchiveProgress = Callable[[str, int, int, int, int], None]
class ArchivePasswordError(FileStorageError):
pass
@@ -141,7 +151,13 @@ def inspect_archive(
password=password,
)
else:
entries = _inspect_tar(archive_data)
entries = _inspect_tar(
archive_data,
compressed_size=compressed_size,
max_entries=max_entries,
max_expanded_bytes=max_expanded_bytes,
max_expansion_ratio=max_expansion_ratio,
)
requires_password = False
password_verified = True
_validate_archive_limits(
@@ -151,14 +167,12 @@ def inspect_archive(
max_expanded_bytes=max_expanded_bytes,
max_expansion_ratio=max_expansion_ratio,
)
complete_entries = _with_derived_directories(entries)
complete_entries = _with_derived_directories(entries, max_entries=max_entries)
return ArchiveInspection(
archive_format=archive_format,
entries=tuple(complete_entries),
file_count=sum(entry.kind == "file" for entry in complete_entries),
directory_count=sum(
entry.kind == "directory" for entry in complete_entries
),
directory_count=sum(entry.kind == "directory" for entry in complete_entries),
expanded_size_bytes=sum(
entry.size_bytes for entry in entries if entry.kind == "file"
),
@@ -190,7 +204,10 @@ def extract_archive_upload(
max_file_bytes: int = 50 * 1024 * 1024,
max_expanded_bytes: int = 2 * 1024 * 1024 * 1024,
max_expansion_ratio: int = 100,
progress: ArchiveProgress | None = None,
) -> list[UploadedStoredFile]:
if progress:
progress("inspecting", 0, 0, 0, 0)
inspection = inspect_archive(
archive_data,
filename=filename,
@@ -204,6 +221,9 @@ def extract_archive_upload(
selected_files = _selected_file_paths(inspection.entries, selected_paths)
if not selected_files:
raise FileStorageError("Select at least one archive file to import")
selected_total_bytes = sum(
entry.size_bytes for entry in inspection.entries if entry.path in selected_files
)
actual_total_limit = min(
max_expanded_bytes,
inspection.compressed_size_bytes * max_expansion_ratio,
@@ -215,6 +235,8 @@ def extract_archive_upload(
password=password,
max_file_bytes=max_file_bytes,
max_total_bytes=actual_total_limit,
progress=progress,
total_bytes=selected_total_bytes,
)
else:
members = _read_selected_tar_members(
@@ -222,22 +244,30 @@ def extract_archive_upload(
selected_files=selected_files,
max_file_bytes=max_file_bytes,
max_total_bytes=actual_total_limit,
progress=progress,
total_bytes=selected_total_bytes,
)
# Release Python/native archive handles immediately if storage or a
# callback fails while the member iterator is suspended at a yield.
with closing(members), archive_storage_backend_scope():
return _store_archive_members(
session,
members=members,
tenant_id=tenant_id,
owner_type=owner_type,
owner_id=owner_id,
user_id=user_id,
folder=folder,
campaign_id=campaign_id,
conflict_strategy=conflict_strategy,
conflict_resolutions=conflict_resolutions,
metadata=metadata,
is_admin=is_admin,
encryption_vault_id=encryption_vault_id,
progress=progress,
total_files=len(selected_files),
total_bytes=selected_total_bytes,
)
return _store_archive_members(
session,
members=members,
tenant_id=tenant_id,
owner_type=owner_type,
owner_id=owner_id,
user_id=user_id,
folder=folder,
campaign_id=campaign_id,
conflict_strategy=conflict_strategy,
conflict_resolutions=conflict_resolutions,
metadata=metadata,
is_admin=is_admin,
encryption_vault_id=encryption_vault_id,
)
def extract_zip_upload(
@@ -319,11 +349,23 @@ def _inspect_zip(
def _inspect_tar(
archive_data: bytes | str | PathLike[str],
*,
compressed_size: int,
max_entries: int,
max_expanded_bytes: int,
max_expansion_ratio: int,
) -> list[ArchiveEntry]:
try:
with _open_tar(archive_data) as archive:
entries: list[ArchiveEntry] = []
for member in archive.getmembers():
expanded_size = 0
for member in archive:
# Check each header before advancing across its payload. In a
# compressed TAR getmembers() would inflate everything first.
if len(entries) >= max_entries:
raise FileStorageError(
f"Archive contains too many entries (limit {max_entries})"
)
path = _safe_member_path(member.name)
if member.isdir():
entries.append(
@@ -334,6 +376,21 @@ def _inspect_tar(
raise FileStorageError(
f"Archive member {member.name!r} is not a regular file or directory"
)
if member.size < 0:
raise FileStorageError("Archive member has invalid size metadata")
expanded_size += member.size
if expanded_size > max_expanded_bytes:
raise FileStorageError(
"Archive is too large after extraction "
f"(limit {max_expanded_bytes} bytes)"
)
if expanded_size and (
compressed_size <= 0
or expanded_size > compressed_size * max_expansion_ratio
):
raise FileStorageError(
f"Archive expansion ratio exceeds {max_expansion_ratio}:1"
)
entries.append(
ArchiveEntry(
path=path,
@@ -352,21 +409,19 @@ def _zip_entry(info: zipfile.ZipInfo) -> ArchiveEntry:
path = _safe_member_path(info.filename)
unix_mode = (info.external_attr >> 16) & 0xFFFF
file_type = stat.S_IFMT(unix_mode)
if file_type and not (
stat.S_ISREG(unix_mode) or stat.S_ISDIR(unix_mode)
):
if file_type and not (stat.S_ISREG(unix_mode) or stat.S_ISDIR(unix_mode)):
raise FileStorageError(
f"Archive member {info.filename!r} is not a regular file or directory"
)
if info.file_size < 0 or info.compress_size < 0:
raise FileStorageError(f"Archive member {info.filename!r} has invalid size metadata")
raise FileStorageError(
f"Archive member {info.filename!r} has invalid size metadata"
)
return ArchiveEntry(
path=path,
kind="directory" if info.is_dir() else "file",
size_bytes=0 if info.is_dir() else int(info.file_size),
compressed_size_bytes=(
None if info.is_dir() else int(info.compress_size)
),
compressed_size_bytes=(None if info.is_dir() else int(info.compress_size)),
encrypted=bool(info.flag_bits & 0x1),
)
@@ -388,9 +443,7 @@ def _validate_archive_limits(
for entry in entries:
previous_kind = seen.get(entry.path)
if previous_kind is not None:
raise FileStorageError(
f"Archive contains duplicate path {entry.path!r}"
)
raise FileStorageError(f"Archive contains duplicate path {entry.path!r}")
seen[entry.path] = entry.kind
if entry.kind == "file":
expanded_size += entry.size_bytes
@@ -400,17 +453,17 @@ def _validate_archive_limits(
f"(limit {max_expanded_bytes} bytes)"
)
if expanded_size and (
compressed_size <= 0
or expanded_size > compressed_size * max_expansion_ratio
compressed_size <= 0 or expanded_size > compressed_size * max_expansion_ratio
):
raise FileStorageError(
"Archive expansion ratio exceeds "
f"{max_expansion_ratio}:1"
f"Archive expansion ratio exceeds {max_expansion_ratio}:1"
)
def _with_derived_directories(
entries: list[ArchiveEntry],
*,
max_entries: int,
) -> list[ArchiveEntry]:
by_path = {entry.path: entry for entry in entries}
for entry in entries:
@@ -422,10 +475,13 @@ def _with_derived_directories(
raise FileStorageError(
f"Archive path {path!r} is both a file and a directory"
)
by_path.setdefault(
path,
ArchiveEntry(path=path, kind="directory", size_bytes=0),
)
if path not in by_path:
if len(by_path) >= max_entries:
raise FileStorageError(
"Archive contains too many entries including parent "
f"directories (limit {max_entries})"
)
by_path[path] = ArchiveEntry(path=path, kind="directory", size_bytes=0)
parent = parent.parent
return sorted(
by_path.values(),
@@ -458,9 +514,7 @@ def _selected_file_paths(
continue
prefix = f"{path}/"
selected_files.update(
file_path
for file_path in file_paths
if file_path.startswith(prefix)
file_path for file_path in file_paths if file_path.startswith(prefix)
)
return selected_files
@@ -472,12 +526,45 @@ def _read_selected_zip_members(
password: str | None,
max_file_bytes: int,
max_total_bytes: int,
) -> list[tuple[str, bytes]]:
result: list[tuple[str, bytes]] = []
progress: ArchiveProgress | None = None,
total_bytes: int = 0,
) -> Iterator[tuple[str, bytes]]:
total = 0
completed = 0
try:
with pyzipper.AESZipFile(_archive_source(archive_data)) as archive:
for info in archive.infolist():
infos = archive.infolist()
# Accelerate only classic ZIPCrypto using established public C
# decoding APIs; AES and other formats retain their existing path.
native_compatible = (
bool(password)
and "\x00" not in password
and any(info.flag_bits & 1 for info in infos)
and all(
not getattr(info, "wz_aes_version", None)
and info.compress_type in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}
and (info.filename.isascii() or bool(info.flag_bits & 0x800))
for info in infos
)
)
native = native_zip_library() if native_compatible else None
if native is not None:
yield from read_native_zip_members(
native,
archive_data,
infos_by_path={
_safe_member_path(info.filename): info for info in infos
},
selected_files=selected_files,
normalize_name=_safe_member_path,
password=password,
max_file_bytes=max_file_bytes,
max_total_bytes=max_total_bytes,
progress=progress,
total_bytes=total_bytes,
)
return
for info in infos:
if info.is_dir():
continue
path = _safe_member_path(info.filename)
@@ -492,6 +579,17 @@ def _read_selected_zip_members(
max_file_bytes=max_file_bytes,
max_total_bytes=max_total_bytes,
current_total=total,
on_bytes=(
lambda count: progress(
"extracting",
completed,
len(selected_files),
count,
total_bytes,
)
)
if progress
else None,
)
except (RuntimeError, ValueError, zipfile.BadZipFile) as exc:
if info.flag_bits & 0x1:
@@ -499,12 +597,17 @@ def _read_selected_zip_members(
"Archive password is incorrect"
) from exc
raise
result.append((path, data))
completed += 1
if progress:
progress(
"extracting", completed, len(selected_files), total, total_bytes
)
yield path, data
del data
except (FileStorageError, ArchivePasswordError):
raise
except (OSError, ValueError, zipfile.BadZipFile) as exc:
raise FileStorageError("ZIP extraction failed") from exc
return result
def _read_selected_tar_members(
@@ -513,12 +616,16 @@ def _read_selected_tar_members(
selected_files: set[str],
max_file_bytes: int,
max_total_bytes: int,
) -> list[tuple[str, bytes]]:
result: list[tuple[str, bytes]] = []
progress: ArchiveProgress | None = None,
total_bytes: int = 0,
) -> Iterator[tuple[str, bytes]]:
total = 0
completed = 0
try:
with _open_tar(archive_data) as archive:
for member in archive.getmembers():
# Iterate as headers are read instead of inflating the entire TAR
# with getmembers(), then seeking backwards to inflate it again.
for member in archive:
if not member.isfile():
continue
path = _safe_member_path(member.name)
@@ -526,9 +633,7 @@ def _read_selected_tar_members(
continue
source = archive.extractfile(member)
if source is None:
raise FileStorageError(
f"Archive member {path!r} could not be read"
)
raise FileStorageError(f"Archive member {path!r} could not be read")
with source:
data, total = _read_member(
source,
@@ -536,13 +641,29 @@ def _read_selected_tar_members(
max_file_bytes=max_file_bytes,
max_total_bytes=max_total_bytes,
current_total=total,
on_bytes=(
lambda count: progress(
"extracting",
completed,
len(selected_files),
count,
total_bytes,
)
)
if progress
else None,
)
result.append((path, data))
completed += 1
if progress:
progress(
"extracting", completed, len(selected_files), total, total_bytes
)
yield path, data
del data
except FileStorageError:
raise
except (OSError, tarfile.TarError) as exc:
raise FileStorageError("TAR extraction failed") from exc
return result
def _read_member(
@@ -552,6 +673,7 @@ def _read_member(
max_file_bytes: int,
max_total_bytes: int,
current_total: int,
on_bytes: Callable[[int], None] | None = None,
) -> tuple[bytes, int]:
parts: list[bytes] = []
actual_size = 0
@@ -565,12 +687,12 @@ def _read_member(
break
actual_size += len(chunk)
if actual_size > max_file_bytes:
raise FileStorageError(
f"Archive member {path!r} exceeds per-file limit"
)
raise FileStorageError(f"Archive member {path!r} exceeds per-file limit")
if current_total + actual_size > max_total_bytes:
raise FileStorageError("Archive is too large after extraction")
parts.append(chunk)
if on_bytes:
on_bytes(current_total + actual_size)
return b"".join(parts), current_total + actual_size
@@ -589,13 +711,15 @@ def _store_archive_members(
metadata: dict[str, Any] | None,
is_admin: bool,
encryption_vault_id: str | None,
progress: ArchiveProgress | None = None,
total_files: int = 0,
total_bytes: int = 0,
) -> list[UploadedStoredFile]:
uploaded: list[UploadedStoredFile] = []
base_folder = normalize_folder(folder)
stored_bytes = 0
for inner_path, data in members:
target_path = (
f"{base_folder}/{inner_path}" if base_folder else inner_path
)
target_path = f"{base_folder}/{inner_path}" if base_folder else inner_path
uploaded.append(
create_file_asset(
session,
@@ -616,24 +740,39 @@ def _store_archive_members(
encryption_vault_id=encryption_vault_id,
)
)
stored_bytes += len(data)
if progress:
progress("storing", len(uploaded), total_files, stored_bytes, total_bytes)
del data
return uploaded
def _safe_member_path(value: str) -> str:
raw = str(value or "").replace("\\", "/").strip()
if (
not raw
or "\x00" in raw
or raw.startswith("/")
or _WINDOWS_DRIVE_RE.match(raw)
):
try:
oversized = (
len(raw) > ARCHIVE_MAX_PATH_BYTES
or len(raw.encode("utf-8")) > ARCHIVE_MAX_PATH_BYTES
)
except UnicodeEncodeError as exc:
raise FileStorageError("Archive member path is not valid Unicode") from exc
if oversized:
raise FileStorageError(
f"Archive member path exceeds {ARCHIVE_MAX_PATH_BYTES} UTF-8 bytes"
)
if not raw or "\x00" in raw or raw.startswith("/") or _WINDOWS_DRIVE_RE.match(raw):
raise FileStorageError(f"Unsafe archive member path {value!r}")
if any(part == ".." for part in raw.split("/")):
raise FileStorageError(f"Unsafe archive member path {value!r}")
try:
return normalize_logical_path(raw.rstrip("/"))
normalized = normalize_logical_path(raw.rstrip("/"))
except ValueError as exc:
raise FileStorageError(f"Unsafe archive member path {value!r}") from exc
if normalized.count("/") + 1 > ARCHIVE_MAX_PATH_DEPTH:
raise FileStorageError(
f"Archive member path exceeds {ARCHIVE_MAX_PATH_DEPTH} components"
)
return normalized
def _archive_size(archive_data: bytes | str | PathLike[str]) -> int:
+46 -8
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
import hashlib
import mimetypes
from contextlib import contextmanager
from contextvars import ContextVar
from datetime import datetime
from pathlib import PurePosixPath
from typing import Any, Iterable
@@ -15,6 +17,7 @@ from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, F
from govoplan_files.backend.runtime import get_registry, settings
from govoplan_files.backend.storage.access import ensure_owner_access, ensure_share_target_exists, user_group_ids
from govoplan_files.backend.storage.backends import (
StorageBackend,
StorageBackendError,
StorageObjectMissing,
get_storage_backend,
@@ -30,6 +33,39 @@ from govoplan_files.backend.storage.integrity import (
from govoplan_files.backend.storage.share_state import effective_file_share_clause
_ARCHIVE_STORAGE_BACKEND: ContextVar[dict[str, StorageBackend] | None] = ContextVar(
"govoplan.files.archive_storage_backend", default=None
)
@contextmanager
def archive_storage_backend_scope():
"""Reuse deployment transport only, never principals, policy or file data.
Lazy creation avoids opening storage for rejected or empty work. Recovery
effects retain the backend until post-commit verification is complete.
Nested archive work shares the same deployment transport, and finally
restores the caller context on success, failure or generator cancellation.
"""
if _ARCHIVE_STORAGE_BACKEND.get() is not None:
yield
return
token = _ARCHIVE_STORAGE_BACKEND.set({})
try:
yield
finally:
_ARCHIVE_STORAGE_BACKEND.reset(token)
def _archive_write_backend() -> StorageBackend:
scope = _ARCHIVE_STORAGE_BACKEND.get()
if scope is None:
return get_storage_backend()
if "backend" not in scope:
scope["backend"] = get_storage_backend()
return scope["backend"]
def _campaign_access_provider() -> CampaignAccessProvider:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_ACCESS):
@@ -88,7 +124,7 @@ def _get_or_create_blob(
.one_or_none()
)
if blob:
backend = get_storage_backend()
backend = _archive_write_backend()
repair_required = (
blob.integrity_status in QUARANTINED_BLOB_STATUSES
or blob.quarantined_at is not None
@@ -162,7 +198,7 @@ def _get_or_create_blob(
blob_id = str(uuid4())
storage_key = _storage_key(tenant_id=tenant_id, checksum=checksum)
backend = get_storage_backend()
backend = _archive_write_backend()
recovery = begin_blob_write_recovery(
session,
backend=backend,
@@ -214,7 +250,6 @@ def _get_or_create_blob(
integrity_checked_at=utcnow(),
)
session.add(blob)
session.flush()
return blob
@@ -265,7 +300,12 @@ def create_file_asset(
logical_path = _next_available_logical_path(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, desired_path=logical_path)
blob = _get_or_create_blob(session, tenant_id=tenant_id, data=data, filename=safe_filename, content_type=content_type, actor_id=user_id, encryption_vault_id=encryption_vault_id)
# Assign the immutable identities before the first flush. Publishing an
# incomplete asset and immediately updating its current version doubled
# change events and added a redundant UPDATE for every imported member.
version_id = str(uuid4())
asset = FileAsset(
id=str(uuid4()),
tenant_id=tenant_id,
owner_type=owner_type,
owner_user_id=owner_id if owner_type == "user" else None,
@@ -275,10 +315,10 @@ def create_file_asset(
description=description,
created_by_user_id=user_id,
metadata_=metadata or {},
current_version_id=version_id,
)
session.add(asset)
session.flush()
version = FileVersion(
id=version_id,
tenant_id=tenant_id,
file_asset_id=asset.id,
blob_id=blob.id,
@@ -290,10 +330,8 @@ def create_file_asset(
checksum_sha256=blob.checksum_sha256,
created_by_user_id=user_id,
)
session.add(version)
session.add_all((asset, version))
session.flush()
asset.current_version_id = version.id
session.add(asset)
if campaign_id:
share_file(session, tenant_id=tenant_id, asset=asset, target_type="campaign", target_id=campaign_id, permission="read", user_id=user_id)
return UploadedStoredFile(asset=asset, version=version, blob=blob)
@@ -0,0 +1,214 @@
"""Optional libarchive ZIPCrypto acceleration, without filesystem extraction.
Only public libarchive read APIs are bound. Callers must preflight the complete
ZIP directory with the ordinary archive validator. Missing libraries/symbols
select the Python path before decoding; native failures never retry a decoder.
Public ABI reference: https://github.com/libarchive/libarchive/blob/master/libarchive/archive.h
"""
from __future__ import annotations
from collections.abc import Callable, Iterator, Mapping
import ctypes
from ctypes.util import find_library
from functools import lru_cache
import os
from os import PathLike
import stat
from typing import Any
import zlib
from govoplan_files.backend.storage.common import FileStorageError
_READ_CHUNK_SIZE = 1024 * 1024
_ARCHIVE_OK = 0
_ARCHIVE_EOF = 1
@lru_cache(maxsize=1)
def native_zip_library() -> Any | None:
name = find_library("archive")
if not name:
return None
try:
library = ctypes.CDLL(name)
signatures = {
"archive_read_new": ([], ctypes.c_void_p),
"archive_read_support_filter_none": ([ctypes.c_void_p], ctypes.c_int),
"archive_read_support_format_zip": ([ctypes.c_void_p], ctypes.c_int),
"archive_read_set_format_option": (
[ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p],
ctypes.c_int,
),
"archive_read_add_passphrase": (
[ctypes.c_void_p, ctypes.c_char_p],
ctypes.c_int,
),
"archive_read_open_memory": (
[ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t],
ctypes.c_int,
),
"archive_read_open_filename": (
[ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t],
ctypes.c_int,
),
"archive_read_next_header": (
[ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)],
ctypes.c_int,
),
"archive_read_data": (
[ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t],
ctypes.c_ssize_t,
),
"archive_read_free": ([ctypes.c_void_p], ctypes.c_int),
"archive_entry_pathname_utf8": ([ctypes.c_void_p], ctypes.c_char_p),
"archive_entry_size": ([ctypes.c_void_p], ctypes.c_int64),
"archive_entry_filetype": ([ctypes.c_void_p], ctypes.c_uint),
}
for symbol, (arguments, result) in signatures.items():
function = getattr(library, symbol)
function.argtypes = arguments
function.restype = result
return library
except (OSError, AttributeError):
return None
def _require_ok(status: int) -> None:
if status != _ARCHIVE_OK:
# Do not expose native error strings: they can contain source paths.
raise FileStorageError(
"Native ZIP decoding failed: incorrect password, corrupt or unsupported archive"
)
def read_native_zip_members(
library: Any,
archive_data: bytes | str | PathLike[str],
*,
infos_by_path: Mapping[str, Any],
selected_files: set[str],
normalize_name: Callable[[str], str],
password: str,
max_file_bytes: int,
max_total_bytes: int,
progress: Callable[[str, int, int, int, int], None] | None = None,
total_bytes: int = 0,
) -> Iterator[tuple[str, bytes]]:
# Keep memory input, password and output buffers alive until reader free.
source_buffer = None
password_bytes = password.encode("utf-8")
buffer = ctypes.create_string_buffer(_READ_CHUNK_SIZE)
archive = library.archive_read_new()
if not archive:
raise FileStorageError("Native ZIP reader could not be allocated")
total = 0
completed = 0
seen: set[str] = set()
try:
_require_ok(library.archive_read_support_filter_none(archive))
_require_ok(library.archive_read_support_format_zip(archive))
_require_ok(
library.archive_read_set_format_option(
archive, b"zip", b"hdrcharset", b"UTF-8"
)
)
_require_ok(library.archive_read_add_passphrase(archive, password_bytes))
if isinstance(archive_data, bytes):
source_buffer = ctypes.create_string_buffer(archive_data)
_require_ok(
library.archive_read_open_memory(
archive, source_buffer, len(archive_data)
)
)
else:
_require_ok(
library.archive_read_open_filename(
archive, os.fsencode(archive_data), _READ_CHUNK_SIZE
)
)
entry = ctypes.c_void_p()
while True:
status = library.archive_read_next_header(archive, ctypes.byref(entry))
if status == _ARCHIVE_EOF:
break
_require_ok(status)
raw_path = library.archive_entry_pathname_utf8(entry)
if not raw_path:
raise FileStorageError("Native ZIP entry has no path")
try:
path = normalize_name(raw_path.decode("utf-8"))
except UnicodeDecodeError as exc:
raise FileStorageError(
"Native ZIP path does not match the inspected directory"
) from exc
info = infos_by_path.get(path)
if info is None or path in seen:
raise FileStorageError(
"Native ZIP path does not match the inspected directory"
)
seen.add(path)
directory = info.is_dir()
file_type = library.archive_entry_filetype(entry)
if not (stat.S_ISDIR(file_type) if directory else stat.S_ISREG(file_type)):
raise FileStorageError(
"Native ZIP entry type does not match the inspected directory"
)
if library.archive_entry_size(entry) != info.file_size:
raise FileStorageError(
"Native ZIP entry size does not match the inspected directory"
)
if directory or path not in selected_files:
continue
parts = []
size = 0
checksum = 0
while True:
read_size = min(
_READ_CHUNK_SIZE,
max_file_bytes + 1 - size,
max_total_bytes + 1 - total,
)
if read_size <= 0:
raise FileStorageError("Archive exceeds its extraction limits")
count = library.archive_read_data(archive, buffer, read_size)
if count < 0:
_require_ok(count)
if count == 0:
break
if count > read_size:
raise FileStorageError("Native ZIP reader returned an invalid byte count")
size += count
total += count
if size > max_file_bytes:
raise FileStorageError(
f"Archive member {path!r} exceeds per-file limit"
)
if total > max_total_bytes:
raise FileStorageError("Archive is too large after extraction")
chunk = ctypes.string_at(buffer, count)
checksum = zlib.crc32(chunk, checksum)
parts.append(chunk)
if progress:
progress(
"extracting", completed, len(selected_files), total, total_bytes
)
# Independent checks preserve the Python ZIP reader's CRC and size
# guarantees even when native decoding behavior changes upstream.
if size != info.file_size or checksum != info.CRC:
raise FileStorageError(
"Native ZIP member failed size or CRC verification"
)
completed += 1
if progress:
progress(
"extracting", completed, len(selected_files), total, total_bytes
)
yield path, b"".join(parts)
parts.clear()
if seen != set(infos_by_path) or completed != len(selected_files):
raise FileStorageError(
"Native ZIP directory does not match the inspected archive"
)
finally:
library.archive_read_free(archive)