zipwire 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zipwire/__init__.py +33 -0
- zipwire/__main__.py +74 -0
- zipwire/_async.py +173 -0
- zipwire/_constants.py +126 -0
- zipwire/_decompress.py +146 -0
- zipwire/_errors.py +40 -0
- zipwire/_parser.py +273 -0
- zipwire/_sync.py +163 -0
- zipwire/_types.py +57 -0
- zipwire/_version.py +24 -0
- zipwire/_zipinfo.py +93 -0
- zipwire/backends/__init__.py +42 -0
- zipwire/backends/_aiohttp.py +55 -0
- zipwire/backends/_httpx2_async.py +55 -0
- zipwire/backends/_httpx2_sync.py +54 -0
- zipwire/backends/_requests.py +54 -0
- zipwire/backends/_urllib3.py +59 -0
- zipwire/py.typed +0 -0
- zipwire-0.0.1.dist-info/METADATA +134 -0
- zipwire-0.0.1.dist-info/RECORD +22 -0
- zipwire-0.0.1.dist-info/WHEEL +4 -0
- zipwire-0.0.1.dist-info/licenses/LICENSE +177 -0
zipwire/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""zipwire - Read and extract files from remote ZIP archives over HTTP range requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from zipwire._async import AsyncRemoteZip
|
|
6
|
+
from zipwire._constants import CompressionMethod
|
|
7
|
+
from zipwire._errors import (
|
|
8
|
+
BadZipFile,
|
|
9
|
+
CRCMismatch,
|
|
10
|
+
FileNotFoundInZip,
|
|
11
|
+
RangeRequestUnsupported,
|
|
12
|
+
UnsupportedCompression,
|
|
13
|
+
ZipwireError,
|
|
14
|
+
)
|
|
15
|
+
from zipwire._sync import SyncRemoteZip
|
|
16
|
+
from zipwire._types import AsyncReader, SyncReader, Writable
|
|
17
|
+
from zipwire._zipinfo import RemoteZipInfo
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"AsyncReader",
|
|
21
|
+
"AsyncRemoteZip",
|
|
22
|
+
"BadZipFile",
|
|
23
|
+
"CRCMismatch",
|
|
24
|
+
"CompressionMethod",
|
|
25
|
+
"FileNotFoundInZip",
|
|
26
|
+
"RangeRequestUnsupported",
|
|
27
|
+
"RemoteZipInfo",
|
|
28
|
+
"SyncReader",
|
|
29
|
+
"SyncRemoteZip",
|
|
30
|
+
"UnsupportedCompression",
|
|
31
|
+
"Writable",
|
|
32
|
+
"ZipwireError",
|
|
33
|
+
]
|
zipwire/__main__.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""CLI entry-point: ``python -m zipwire <url>``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from zipwire import AsyncRemoteZip, SyncRemoteZip, ZipwireError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _print_table(infos: list) -> None:
|
|
13
|
+
print(f"{'Size':>10} {'Date':>10} {'Time':>8} Name")
|
|
14
|
+
print(f"{'----':>10} {'----':>10} {'----':>8} ----")
|
|
15
|
+
total_size = 0
|
|
16
|
+
for info in infos:
|
|
17
|
+
y, mo, d, h, mi, s = info.date_time
|
|
18
|
+
date_str = f"{y:04d}-{mo:02d}-{d:02d}"
|
|
19
|
+
time_str = f"{h:02d}:{mi:02d}:{s:02d}"
|
|
20
|
+
print(f"{info.file_size:>10} {date_str:>10} {time_str:>8} {info.filename}")
|
|
21
|
+
total_size += info.file_size
|
|
22
|
+
print(f"{'----':>10} {' ':>10} {' ':>8} ----")
|
|
23
|
+
print(f"{total_size:>10} {' ':>10} {' ':>8} {len(infos)} entries")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _run_sync(url: str, backend: str) -> None:
|
|
27
|
+
from zipwire import backends
|
|
28
|
+
|
|
29
|
+
reader_cls = {
|
|
30
|
+
"urllib3": backends.Urllib3Reader,
|
|
31
|
+
"requests": backends.RequestsReader,
|
|
32
|
+
}[backend]
|
|
33
|
+
with SyncRemoteZip(reader_cls(url)) as rz:
|
|
34
|
+
_print_table(rz.infolist())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _run_async(url: str, backend: str) -> None:
|
|
38
|
+
from zipwire import backends
|
|
39
|
+
|
|
40
|
+
reader_cls = {
|
|
41
|
+
"httpx2": backends.Httpx2AsyncReader,
|
|
42
|
+
"aiohttp": backends.AiohttpReader,
|
|
43
|
+
}[backend]
|
|
44
|
+
async with AsyncRemoteZip(reader_cls(url)) as rz:
|
|
45
|
+
_print_table(await rz.infolist())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main(argv: list[str] | None = None) -> None:
|
|
49
|
+
parser = argparse.ArgumentParser(
|
|
50
|
+
prog="python -m zipwire",
|
|
51
|
+
description="List files in a remote ZIP archive.",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument("url", help="URL of the remote ZIP archive")
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"-b",
|
|
56
|
+
"--backend",
|
|
57
|
+
choices=["urllib3", "requests", "httpx2", "aiohttp"],
|
|
58
|
+
default="urllib3",
|
|
59
|
+
help="HTTP backend to use (default: urllib3)",
|
|
60
|
+
)
|
|
61
|
+
args = parser.parse_args(argv)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
if args.backend in ("urllib3", "requests"):
|
|
65
|
+
_run_sync(args.url, args.backend)
|
|
66
|
+
else:
|
|
67
|
+
asyncio.run(_run_async(args.url, args.backend))
|
|
68
|
+
except ZipwireError as exc:
|
|
69
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
main()
|
zipwire/_async.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Asynchronous remote ZIP reader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typing
|
|
6
|
+
|
|
7
|
+
from zipwire._constants import LOCAL_FILE_HEADER_SIZE
|
|
8
|
+
from zipwire._decompress import StreamingDecompressor, decompress
|
|
9
|
+
from zipwire._errors import FileNotFoundInZip
|
|
10
|
+
from zipwire._parser import (
|
|
11
|
+
eocd_search_length,
|
|
12
|
+
find_eocd,
|
|
13
|
+
parse_central_directory,
|
|
14
|
+
parse_local_file_header,
|
|
15
|
+
)
|
|
16
|
+
from zipwire._zipinfo import RemoteZipInfo
|
|
17
|
+
|
|
18
|
+
if typing.TYPE_CHECKING:
|
|
19
|
+
from zipwire._types import AsyncReader, Writable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AsyncRemoteZip:
|
|
23
|
+
"""Read files from a remote ZIP archive using async HTTP range requests.
|
|
24
|
+
|
|
25
|
+
Usage::
|
|
26
|
+
|
|
27
|
+
async with AsyncRemoteZip(reader) as rz:
|
|
28
|
+
for info in await rz.infolist():
|
|
29
|
+
print(info.filename)
|
|
30
|
+
data = await rz.read("path/to/file.txt")
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, reader: AsyncReader) -> None:
|
|
34
|
+
self._reader = reader
|
|
35
|
+
self._entries: list[RemoteZipInfo] | None = None
|
|
36
|
+
self._name_index: dict[str, RemoteZipInfo] | None = None
|
|
37
|
+
self._file_size: int | None = None
|
|
38
|
+
|
|
39
|
+
async def __aenter__(self) -> AsyncRemoteZip:
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
43
|
+
await self.close()
|
|
44
|
+
|
|
45
|
+
async def close(self) -> None:
|
|
46
|
+
"""Close the underlying reader."""
|
|
47
|
+
await self._reader.close()
|
|
48
|
+
|
|
49
|
+
async def _ensure_loaded(self) -> None:
|
|
50
|
+
"""Fetch and parse the central directory if not already done."""
|
|
51
|
+
if self._entries is not None:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
# Step 1: Get file size
|
|
55
|
+
self._file_size = await self._reader.get_content_length()
|
|
56
|
+
|
|
57
|
+
# Step 2: Fetch the tail for EOCD search
|
|
58
|
+
search_len = eocd_search_length(self._file_size)
|
|
59
|
+
tail_offset = self._file_size - search_len
|
|
60
|
+
tail = await self._reader.read_range(tail_offset, search_len)
|
|
61
|
+
|
|
62
|
+
# Step 3: Parse EOCD
|
|
63
|
+
eocd = find_eocd(tail, self._file_size)
|
|
64
|
+
|
|
65
|
+
# Step 4: Fetch and parse central directory
|
|
66
|
+
cd_data = await self._reader.read_range(eocd.cd_offset, eocd.cd_size)
|
|
67
|
+
raw_entries = parse_central_directory(cd_data, eocd.cd_entry_count)
|
|
68
|
+
|
|
69
|
+
# Step 5: Convert to RemoteZipInfo objects
|
|
70
|
+
self._entries = [RemoteZipInfo._from_central_dir_entry(e) for e in raw_entries]
|
|
71
|
+
self._name_index = {info.filename: info for info in self._entries}
|
|
72
|
+
|
|
73
|
+
async def infolist(self) -> list[RemoteZipInfo]:
|
|
74
|
+
"""Return a list of RemoteZipInfo objects for all files in the archive."""
|
|
75
|
+
await self._ensure_loaded()
|
|
76
|
+
assert self._entries is not None
|
|
77
|
+
return list(self._entries)
|
|
78
|
+
|
|
79
|
+
async def namelist(self) -> list[str]:
|
|
80
|
+
"""Return a list of filenames in the archive."""
|
|
81
|
+
await self._ensure_loaded()
|
|
82
|
+
assert self._entries is not None
|
|
83
|
+
return [info.filename for info in self._entries]
|
|
84
|
+
|
|
85
|
+
async def getinfo(self, name: str) -> RemoteZipInfo:
|
|
86
|
+
"""Return the RemoteZipInfo for the given filename.
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
FileNotFoundInZip: If the name is not in the archive.
|
|
90
|
+
"""
|
|
91
|
+
await self._ensure_loaded()
|
|
92
|
+
assert self._name_index is not None
|
|
93
|
+
try:
|
|
94
|
+
return self._name_index[name]
|
|
95
|
+
except KeyError:
|
|
96
|
+
raise FileNotFoundInZip(name) from None
|
|
97
|
+
|
|
98
|
+
async def read(self, name: str | RemoteZipInfo) -> bytes:
|
|
99
|
+
"""Read and decompress a file from the archive.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
name: Filename string or RemoteZipInfo object.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
The decompressed file contents.
|
|
106
|
+
"""
|
|
107
|
+
if isinstance(name, RemoteZipInfo):
|
|
108
|
+
info = name
|
|
109
|
+
else:
|
|
110
|
+
info = await self.getinfo(name)
|
|
111
|
+
|
|
112
|
+
# Directories have no data
|
|
113
|
+
if info.is_dir():
|
|
114
|
+
return b""
|
|
115
|
+
|
|
116
|
+
# Step 1: Read the local file header to get the actual data offset
|
|
117
|
+
local_header_data = await self._reader.read_range(
|
|
118
|
+
info.header_offset, LOCAL_FILE_HEADER_SIZE
|
|
119
|
+
)
|
|
120
|
+
local_header = parse_local_file_header(local_header_data)
|
|
121
|
+
|
|
122
|
+
# Step 2: Calculate the data offset
|
|
123
|
+
data_offset = (
|
|
124
|
+
info.header_offset + LOCAL_FILE_HEADER_SIZE + local_header.data_offset_past_header
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Step 3: Fetch the compressed data
|
|
128
|
+
compressed_data = await self._reader.read_range(data_offset, info.compress_size)
|
|
129
|
+
|
|
130
|
+
# Step 4: Decompress and verify CRC
|
|
131
|
+
return decompress(
|
|
132
|
+
compressed_data,
|
|
133
|
+
info.compress_type,
|
|
134
|
+
info.file_size,
|
|
135
|
+
info.CRC,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def read_into(self, name: str | RemoteZipInfo, dest: Writable) -> None:
|
|
139
|
+
"""Decompress a file from the archive into a writable destination.
|
|
140
|
+
|
|
141
|
+
Unlike :meth:`read`, this truly streams: the compressed payload is
|
|
142
|
+
fetched in chunks via :meth:`stream_range` and each chunk is
|
|
143
|
+
decompressed and written immediately, keeping peak memory low.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
name: Filename string or RemoteZipInfo object.
|
|
147
|
+
dest: A writable file-like object (e.g. ``io.BytesIO``, open file).
|
|
148
|
+
"""
|
|
149
|
+
if isinstance(name, RemoteZipInfo):
|
|
150
|
+
info = name
|
|
151
|
+
else:
|
|
152
|
+
info = await self.getinfo(name)
|
|
153
|
+
|
|
154
|
+
# Directories have no data
|
|
155
|
+
if info.is_dir():
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
# Step 1: Read the local file header to get the actual data offset
|
|
159
|
+
local_header_data = await self._reader.read_range(
|
|
160
|
+
info.header_offset, LOCAL_FILE_HEADER_SIZE
|
|
161
|
+
)
|
|
162
|
+
local_header = parse_local_file_header(local_header_data)
|
|
163
|
+
|
|
164
|
+
# Step 2: Calculate the data offset
|
|
165
|
+
data_offset = (
|
|
166
|
+
info.header_offset + LOCAL_FILE_HEADER_SIZE + local_header.data_offset_past_header
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Step 3: Stream compressed data and decompress into dest
|
|
170
|
+
sd = StreamingDecompressor(info.compress_type, info.CRC, dest)
|
|
171
|
+
async for chunk in self._reader.stream_range(data_offset, info.compress_size):
|
|
172
|
+
sd.feed(chunk)
|
|
173
|
+
sd.finish()
|
zipwire/_constants.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""ZIP format constants: signatures, struct formats, and compression methods."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import struct
|
|
6
|
+
from enum import IntEnum
|
|
7
|
+
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
# Signatures
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
EOCD_SIGNATURE = b"PK\x05\x06" # zipfile.stringEndArchive
|
|
12
|
+
ZIP64_EOCD_LOCATOR_SIGNATURE = b"PK\x06\x07" # zipfile.stringEndArchive64Locator
|
|
13
|
+
ZIP64_EOCD_SIGNATURE = b"PK\x06\x06" # zipfile.stringEndArchive64
|
|
14
|
+
CENTRAL_DIR_SIGNATURE = b"PK\x01\x02" # zipfile.stringCentralDir
|
|
15
|
+
LOCAL_FILE_HEADER_SIGNATURE = b"PK\x03\x04" # zipfile.stringFileHeader
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Fixed-part sizes
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
EOCD_SIZE = 22 # zipfile.sizeEndCentDir
|
|
21
|
+
ZIP64_EOCD_LOCATOR_SIZE = 20 # zipfile.sizeEndCentDir64Locator
|
|
22
|
+
ZIP64_EOCD_SIZE = 56 # zipfile.sizeEndCentDir64
|
|
23
|
+
CENTRAL_DIR_SIZE = 46 # zipfile.sizeCentralDir
|
|
24
|
+
LOCAL_FILE_HEADER_SIZE = 30 # zipfile.sizeFileHeader
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Compression methods
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CompressionMethod(IntEnum):
|
|
32
|
+
"""ZIP compression method identifiers."""
|
|
33
|
+
|
|
34
|
+
STORED = 0 # zipfile.ZIP_STORED
|
|
35
|
+
DEFLATED = 8 # zipfile.ZIP_DEFLATED
|
|
36
|
+
BZIP2 = 12 # zipfile.ZIP_BZIP2
|
|
37
|
+
LZMA = 14 # zipfile.ZIP_LZMA
|
|
38
|
+
ZSTANDARD = 93 # zipfile.ZIP_ZSTANDARD (3.14+)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Struct formats
|
|
43
|
+
#
|
|
44
|
+
# The stdlib defines struct formats too (e.g. zipfile.structCentralDir), but
|
|
45
|
+
# they use different field groupings that shift unpack indices. We define
|
|
46
|
+
# our own for cleaner parser code.
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
# End of Central Directory Record (22 bytes)
|
|
50
|
+
EOCD_STRUCT = struct.Struct(
|
|
51
|
+
"<4s" # [0] signature
|
|
52
|
+
"H" # [1] disk_num
|
|
53
|
+
"H" # [2] disk_cd_start
|
|
54
|
+
"H" # [3] entries_this_disk
|
|
55
|
+
"H" # [4] entries_total
|
|
56
|
+
"I" # [5] cd_size
|
|
57
|
+
"I" # [6] cd_offset
|
|
58
|
+
"H" # [7] comment_length
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# ZIP64 End of Central Directory Locator (20 bytes)
|
|
62
|
+
ZIP64_EOCD_LOCATOR_STRUCT = struct.Struct(
|
|
63
|
+
"<4s" # [0] signature
|
|
64
|
+
"I" # [1] disk_with_zip64_eocd
|
|
65
|
+
"Q" # [2] zip64_eocd_offset
|
|
66
|
+
"I" # [3] total_disks
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# ZIP64 End of Central Directory Record (56 bytes)
|
|
70
|
+
ZIP64_EOCD_STRUCT = struct.Struct(
|
|
71
|
+
"<4s" # [0] signature
|
|
72
|
+
"Q" # [1] record_size
|
|
73
|
+
"H" # [2] version_made
|
|
74
|
+
"H" # [3] version_needed
|
|
75
|
+
"I" # [4] disk_num
|
|
76
|
+
"I" # [5] disk_cd_start
|
|
77
|
+
"Q" # [6] entries_this_disk
|
|
78
|
+
"Q" # [7] entries_total
|
|
79
|
+
"Q" # [8] cd_size
|
|
80
|
+
"Q" # [9] cd_offset
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Central Directory File Header (46 bytes)
|
|
84
|
+
CENTRAL_DIR_STRUCT = struct.Struct(
|
|
85
|
+
"<4s" # [0] signature
|
|
86
|
+
"H" # [1] version_made
|
|
87
|
+
"H" # [2] version_needed
|
|
88
|
+
"H" # [3] flags
|
|
89
|
+
"H" # [4] compression
|
|
90
|
+
"H" # [5] mod_time
|
|
91
|
+
"H" # [6] mod_date
|
|
92
|
+
"I" # [7] crc32
|
|
93
|
+
"I" # [8] compressed_size
|
|
94
|
+
"I" # [9] uncompressed_size
|
|
95
|
+
"H" # [10] filename_len
|
|
96
|
+
"H" # [11] extra_len
|
|
97
|
+
"H" # [12] comment_len
|
|
98
|
+
"H" # [13] disk_start
|
|
99
|
+
"H" # [14] internal_attr
|
|
100
|
+
"I" # [15] external_attr
|
|
101
|
+
"I" # [16] header_offset
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Local File Header (30 bytes)
|
|
105
|
+
LOCAL_FILE_HEADER_STRUCT = struct.Struct(
|
|
106
|
+
"<4s" # [0] signature
|
|
107
|
+
"H" # [1] version_needed
|
|
108
|
+
"H" # [2] flags
|
|
109
|
+
"H" # [3] compression
|
|
110
|
+
"H" # [4] mod_time
|
|
111
|
+
"H" # [5] mod_date
|
|
112
|
+
"I" # [6] crc32
|
|
113
|
+
"I" # [7] compressed_size
|
|
114
|
+
"I" # [8] uncompressed_size
|
|
115
|
+
"H" # [9] filename_len
|
|
116
|
+
"H" # [10] extra_len
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Derived / misc
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
MAX_EOCD_SEARCH = EOCD_SIZE + 65535 # max ZIP comment is 65 535 bytes
|
|
123
|
+
|
|
124
|
+
ZIP64_EXTRA_FIELD_ID = 0x0001
|
|
125
|
+
|
|
126
|
+
STREAM_CHUNK_SIZE = 2 * 1024 * 1024 # 2 MiB - default for stream_range backends
|
zipwire/_decompress.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Decompression and CRC verification for ZIP entries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typing
|
|
6
|
+
import zlib
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
from zipwire._constants import CompressionMethod
|
|
10
|
+
from zipwire._errors import CRCMismatch, UnsupportedCompression
|
|
11
|
+
|
|
12
|
+
if typing.TYPE_CHECKING:
|
|
13
|
+
from zipwire._types import Writable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _DecompressMode(Enum):
|
|
17
|
+
"""Internal mode tags for StreamingDecompressor."""
|
|
18
|
+
|
|
19
|
+
STORED = "stored"
|
|
20
|
+
DEFLATED = "deflated"
|
|
21
|
+
INCREMENTAL = "incremental"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def decompress(
|
|
25
|
+
data: bytes,
|
|
26
|
+
method: int,
|
|
27
|
+
expected_size: int,
|
|
28
|
+
expected_crc: int,
|
|
29
|
+
) -> bytes:
|
|
30
|
+
"""Decompress data and verify CRC-32.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
data: The raw (possibly compressed) file data.
|
|
34
|
+
method: ZIP compression method (0=stored, 8=deflated, 12=bzip2, 14=lzma, 93=zstandard).
|
|
35
|
+
expected_size: Expected uncompressed size.
|
|
36
|
+
expected_crc: Expected CRC-32 of the uncompressed data.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The decompressed bytes.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
UnsupportedCompression: If the compression method is not supported.
|
|
43
|
+
CRCMismatch: If the CRC-32 check fails.
|
|
44
|
+
"""
|
|
45
|
+
match method:
|
|
46
|
+
case CompressionMethod.STORED:
|
|
47
|
+
result = data
|
|
48
|
+
case CompressionMethod.DEFLATED:
|
|
49
|
+
# -15 = raw deflate (no zlib/gzip header)
|
|
50
|
+
result = zlib.decompress(data, -15)
|
|
51
|
+
case CompressionMethod.BZIP2:
|
|
52
|
+
try:
|
|
53
|
+
import bz2
|
|
54
|
+
except ImportError:
|
|
55
|
+
raise UnsupportedCompression(method) from None
|
|
56
|
+
|
|
57
|
+
result = bz2.decompress(data)
|
|
58
|
+
case CompressionMethod.LZMA:
|
|
59
|
+
try:
|
|
60
|
+
import lzma
|
|
61
|
+
except ImportError:
|
|
62
|
+
raise UnsupportedCompression(method) from None
|
|
63
|
+
|
|
64
|
+
result = lzma.decompress(data)
|
|
65
|
+
case CompressionMethod.ZSTANDARD:
|
|
66
|
+
try:
|
|
67
|
+
import zstandard # ty: ignore[unresolved-import]
|
|
68
|
+
except ImportError:
|
|
69
|
+
raise UnsupportedCompression(method) from None
|
|
70
|
+
|
|
71
|
+
result = zstandard.decompress(data)
|
|
72
|
+
case _:
|
|
73
|
+
raise UnsupportedCompression(method)
|
|
74
|
+
|
|
75
|
+
actual_crc = zlib.crc32(result) & 0xFFFFFFFF
|
|
76
|
+
if actual_crc != expected_crc:
|
|
77
|
+
raise CRCMismatch(expected_crc, actual_crc)
|
|
78
|
+
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class StreamingDecompressor:
|
|
83
|
+
"""Feed compressed chunks via :meth:`feed`, then call :meth:`finish` to verify CRC-32."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, method: int, expected_crc: int, dest: Writable) -> None:
|
|
86
|
+
self._expected_crc = expected_crc
|
|
87
|
+
self._dest = dest
|
|
88
|
+
self._crc: int = 0
|
|
89
|
+
self._dobj: typing.Any = None
|
|
90
|
+
|
|
91
|
+
match method:
|
|
92
|
+
case CompressionMethod.STORED:
|
|
93
|
+
self._mode = _DecompressMode.STORED
|
|
94
|
+
case CompressionMethod.DEFLATED:
|
|
95
|
+
self._mode = _DecompressMode.DEFLATED
|
|
96
|
+
self._dobj = zlib.decompressobj(-15)
|
|
97
|
+
case CompressionMethod.BZIP2:
|
|
98
|
+
try:
|
|
99
|
+
import bz2
|
|
100
|
+
except ImportError:
|
|
101
|
+
raise UnsupportedCompression(method) from None
|
|
102
|
+
|
|
103
|
+
self._mode = _DecompressMode.INCREMENTAL
|
|
104
|
+
self._dobj = bz2.BZ2Decompressor()
|
|
105
|
+
case CompressionMethod.LZMA:
|
|
106
|
+
try:
|
|
107
|
+
import lzma
|
|
108
|
+
except ImportError:
|
|
109
|
+
raise UnsupportedCompression(method) from None
|
|
110
|
+
|
|
111
|
+
self._mode = _DecompressMode.INCREMENTAL
|
|
112
|
+
self._dobj = lzma.LZMADecompressor()
|
|
113
|
+
case CompressionMethod.ZSTANDARD:
|
|
114
|
+
try:
|
|
115
|
+
import zstandard # ty: ignore[unresolved-import]
|
|
116
|
+
except ImportError:
|
|
117
|
+
raise UnsupportedCompression(method) from None
|
|
118
|
+
|
|
119
|
+
self._mode = _DecompressMode.INCREMENTAL
|
|
120
|
+
self._dobj = zstandard.ZstdDecompressor().decompressobj()
|
|
121
|
+
case _:
|
|
122
|
+
raise UnsupportedCompression(method)
|
|
123
|
+
|
|
124
|
+
def feed(self, data: bytes) -> None:
|
|
125
|
+
"""Decompress *data*, write output to dest, update CRC."""
|
|
126
|
+
if self._mode is _DecompressMode.STORED:
|
|
127
|
+
self._crc = zlib.crc32(data, self._crc)
|
|
128
|
+
self._dest.write(data)
|
|
129
|
+
else:
|
|
130
|
+
# deflate, bz2, lzma, zstandard - all expose .decompress()
|
|
131
|
+
chunk = self._dobj.decompress(data)
|
|
132
|
+
if chunk:
|
|
133
|
+
self._crc = zlib.crc32(chunk, self._crc)
|
|
134
|
+
self._dest.write(chunk)
|
|
135
|
+
|
|
136
|
+
def finish(self) -> None:
|
|
137
|
+
"""Flush remaining data (deflate only) and verify CRC-32."""
|
|
138
|
+
if self._mode is _DecompressMode.DEFLATED:
|
|
139
|
+
chunk = self._dobj.flush()
|
|
140
|
+
if chunk:
|
|
141
|
+
self._crc = zlib.crc32(chunk, self._crc)
|
|
142
|
+
self._dest.write(chunk)
|
|
143
|
+
|
|
144
|
+
actual_crc = self._crc & 0xFFFFFFFF
|
|
145
|
+
if actual_crc != self._expected_crc:
|
|
146
|
+
raise CRCMismatch(self._expected_crc, actual_crc)
|
zipwire/_errors.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Exception hierarchy for zipwire."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ZipwireError(Exception):
|
|
7
|
+
"""Base exception for all zipwire errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BadZipFile(ZipwireError):
|
|
11
|
+
"""The data does not appear to be a valid ZIP archive."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UnsupportedCompression(ZipwireError):
|
|
15
|
+
"""The file uses an unsupported compression method."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, method: int) -> None:
|
|
18
|
+
self.method = method
|
|
19
|
+
super().__init__(f"Unsupported compression method: {method}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CRCMismatch(ZipwireError):
|
|
23
|
+
"""CRC-32 check failed after decompression."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, expected: int, actual: int) -> None:
|
|
26
|
+
self.expected = expected
|
|
27
|
+
self.actual = actual
|
|
28
|
+
super().__init__(f"CRC-32 mismatch: expected {expected:#010x}, got {actual:#010x}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RangeRequestUnsupported(ZipwireError):
|
|
32
|
+
"""The HTTP server does not support range requests."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FileNotFoundInZip(ZipwireError, KeyError):
|
|
36
|
+
"""The requested file was not found in the ZIP archive."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, filename: str) -> None:
|
|
39
|
+
self.filename = filename
|
|
40
|
+
super().__init__(f"File not found in archive: {filename!r}")
|