zipcheck 0.1.0__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.
- zipcheck/__init__.py +21 -0
- zipcheck/__main__.py +4 -0
- zipcheck/cli.py +333 -0
- zipcheck/detect.py +138 -0
- zipcheck/engine.py +968 -0
- zipcheck/formats/__init__.py +1 -0
- zipcheck/formats/bzip2fmt.py +95 -0
- zipcheck/formats/gzipfmt.py +143 -0
- zipcheck/formats/probe.py +62 -0
- zipcheck/formats/rarfmt.py +280 -0
- zipcheck/formats/sevenzipfmt.py +382 -0
- zipcheck/formats/tarfmt.py +271 -0
- zipcheck/formats/xzfmt.py +170 -0
- zipcheck/formats/zipfmt.py +1034 -0
- zipcheck/formats/zstdfmt.py +80 -0
- zipcheck/limits.py +62 -0
- zipcheck/model.py +216 -0
- zipcheck/py.typed +0 -0
- zipcheck/reader.py +169 -0
- zipcheck/verify.py +816 -0
- zipcheck-0.1.0.dist-info/METADATA +676 -0
- zipcheck-0.1.0.dist-info/RECORD +26 -0
- zipcheck-0.1.0.dist-info/WHEEL +5 -0
- zipcheck-0.1.0.dist-info/entry_points.txt +2 -0
- zipcheck-0.1.0.dist-info/licenses/LICENSE +21 -0
- zipcheck-0.1.0.dist-info/top_level.txt +1 -0
zipcheck/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""zipcheck: static archive-bomb detection without extraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .engine import scan_bytes, scan_file
|
|
6
|
+
from .engine import scan_file as scan
|
|
7
|
+
from .limits import Limits
|
|
8
|
+
from .model import EntryInfo, Finding, ScanResult, Severity
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"EntryInfo",
|
|
12
|
+
"Finding",
|
|
13
|
+
"Limits",
|
|
14
|
+
"ScanResult",
|
|
15
|
+
"Severity",
|
|
16
|
+
"scan",
|
|
17
|
+
"scan_bytes",
|
|
18
|
+
"scan_file",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
zipcheck/__main__.py
ADDED
zipcheck/cli.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Command-line interface for zipcheck."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .detect import extension_suggests_archive
|
|
14
|
+
from .engine import scan_file
|
|
15
|
+
from .limits import Limits
|
|
16
|
+
from .model import ScanResult, Severity
|
|
17
|
+
from .reader import human_ratio, human_size
|
|
18
|
+
|
|
19
|
+
VERDICT_COLORS = {
|
|
20
|
+
"clean": "\x1b[32m",
|
|
21
|
+
"suspicious": "\x1b[33m",
|
|
22
|
+
"dangerous": "\x1b[31m",
|
|
23
|
+
"unknown": "\x1b[90m",
|
|
24
|
+
}
|
|
25
|
+
SEVERITY_COLORS = {
|
|
26
|
+
Severity.INFO: "\x1b[90m",
|
|
27
|
+
Severity.LOW: "\x1b[36m",
|
|
28
|
+
Severity.MEDIUM: "\x1b[33m",
|
|
29
|
+
Severity.HIGH: "\x1b[31m",
|
|
30
|
+
Severity.CRITICAL: "\x1b[1;31m",
|
|
31
|
+
}
|
|
32
|
+
RESET = "\x1b[0m"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_size(value: str) -> int:
|
|
36
|
+
text = value.strip().lower().replace("_", "")
|
|
37
|
+
if not text:
|
|
38
|
+
raise argparse.ArgumentTypeError("empty size")
|
|
39
|
+
suffixes = {
|
|
40
|
+
"k": 1024,
|
|
41
|
+
"kb": 1024,
|
|
42
|
+
"kib": 1024,
|
|
43
|
+
"m": 1024**2,
|
|
44
|
+
"mb": 1024**2,
|
|
45
|
+
"mib": 1024**2,
|
|
46
|
+
"g": 1024**3,
|
|
47
|
+
"gb": 1024**3,
|
|
48
|
+
"gib": 1024**3,
|
|
49
|
+
"t": 1024**4,
|
|
50
|
+
"tb": 1024**4,
|
|
51
|
+
"tib": 1024**4,
|
|
52
|
+
}
|
|
53
|
+
for suffix in sorted(suffixes, key=len, reverse=True):
|
|
54
|
+
if text.endswith(suffix):
|
|
55
|
+
number = text[: -len(suffix)].strip()
|
|
56
|
+
try:
|
|
57
|
+
return int(float(number) * suffixes[suffix])
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
raise argparse.ArgumentTypeError(f"invalid size: {value}") from exc
|
|
60
|
+
try:
|
|
61
|
+
return int(text)
|
|
62
|
+
except ValueError as exc:
|
|
63
|
+
raise argparse.ArgumentTypeError(f"invalid size: {value}") from exc
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
67
|
+
parser = argparse.ArgumentParser(
|
|
68
|
+
prog="zipcheck",
|
|
69
|
+
description=(
|
|
70
|
+
"Static zip-bomb / archive-bomb detector. Reads only archive metadata; "
|
|
71
|
+
"it never extracts files and never writes member data."
|
|
72
|
+
),
|
|
73
|
+
epilog=(
|
|
74
|
+
"Exit codes: 0 clean (or below --fail-on), 1 suspicious, 2 dangerous, "
|
|
75
|
+
"3 unknown/unscannable, 4 invocation or I/O error."
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument("paths", nargs="+", metavar="PATH", help="archive file(s) to scan")
|
|
79
|
+
parser.add_argument("-r", "--recursive", action="store_true", help="recurse into directories")
|
|
80
|
+
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--fail-on",
|
|
83
|
+
choices=("none", "suspicious", "dangerous"),
|
|
84
|
+
default="suspicious",
|
|
85
|
+
help="lowest verdict that causes a non-zero exit (default: suspicious)",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument("--depth", type=int, default=6, help="maximum nested archive depth")
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--timeout", type=float, default=60.0, help="per-file wall-clock limit in seconds"
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--no-probe",
|
|
93
|
+
dest="probe",
|
|
94
|
+
action="store_false",
|
|
95
|
+
help="do not run bounded decompression on bzip2 / size-less formats",
|
|
96
|
+
)
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"--probe-limit",
|
|
99
|
+
type=_parse_size,
|
|
100
|
+
default=64 * 1024 * 1024,
|
|
101
|
+
help="maximum bytes produced by a bounded probe (default: 64MiB)",
|
|
102
|
+
)
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--verify",
|
|
105
|
+
action="store_true",
|
|
106
|
+
help="decompress content into a counting sink to detect forged metadata",
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--verify-limit",
|
|
110
|
+
type=_parse_size,
|
|
111
|
+
default=512 * 1024 * 1024,
|
|
112
|
+
help="maximum bytes produced by content verification (default: 512MiB)",
|
|
113
|
+
)
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--require-verified",
|
|
116
|
+
action="store_true",
|
|
117
|
+
help="fail closed (unknown) when content cannot be verified; implies --verify",
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"--production",
|
|
121
|
+
action="store_true",
|
|
122
|
+
help=(
|
|
123
|
+
"fail-closed preset for user uploads: verify content and reject "
|
|
124
|
+
"archives that cannot be fully verified (implies --verify "
|
|
125
|
+
"--require-verified)"
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
parser.add_argument(
|
|
129
|
+
"--ratio",
|
|
130
|
+
type=float,
|
|
131
|
+
default=100.0,
|
|
132
|
+
help="expansion ratio considered suspicious (default: 100)",
|
|
133
|
+
)
|
|
134
|
+
parser.add_argument(
|
|
135
|
+
"--dangerous-ratio",
|
|
136
|
+
type=float,
|
|
137
|
+
default=1000.0,
|
|
138
|
+
help="expansion ratio considered dangerous (default: 1000)",
|
|
139
|
+
)
|
|
140
|
+
parser.add_argument(
|
|
141
|
+
"--min-size",
|
|
142
|
+
type=_parse_size,
|
|
143
|
+
default=16 * 1024 * 1024,
|
|
144
|
+
help="ignore expansion ratios below this declared size (default: 16MiB)",
|
|
145
|
+
)
|
|
146
|
+
parser.add_argument(
|
|
147
|
+
"--entries",
|
|
148
|
+
type=int,
|
|
149
|
+
default=10,
|
|
150
|
+
help="number of archive members to show in human output (default: 10)",
|
|
151
|
+
)
|
|
152
|
+
parser.add_argument("--quiet", action="store_true", help="one line per file")
|
|
153
|
+
parser.add_argument("--no-color", action="store_true", help="disable ANSI colors")
|
|
154
|
+
parser.add_argument(
|
|
155
|
+
"-v", "--verbose", action="store_true", help="show notes and member samples"
|
|
156
|
+
)
|
|
157
|
+
parser.add_argument("--version", action="version", version=f"zipcheck {__version__}")
|
|
158
|
+
return parser
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _collect_paths(paths: Sequence[str], recursive: bool) -> List[str]:
|
|
162
|
+
collected: List[str] = []
|
|
163
|
+
for path in paths:
|
|
164
|
+
if os.path.isdir(path):
|
|
165
|
+
if not recursive:
|
|
166
|
+
raise IsADirectoryError(
|
|
167
|
+
f"{path!r} is a directory (use --recursive to scan its archives)"
|
|
168
|
+
)
|
|
169
|
+
for root, dirs, files in os.walk(path):
|
|
170
|
+
dirs.sort()
|
|
171
|
+
for name in sorted(files):
|
|
172
|
+
candidate = os.path.join(root, name)
|
|
173
|
+
if extension_suggests_archive(candidate):
|
|
174
|
+
collected.append(candidate)
|
|
175
|
+
else:
|
|
176
|
+
collected.append(path)
|
|
177
|
+
return collected
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _colorize(text: str, color: str, enabled: bool) -> str:
|
|
181
|
+
return f"{color}{text}{RESET}" if enabled else text
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _print_result(result: ScanResult, args: argparse.Namespace, color: bool) -> None:
|
|
185
|
+
indent = " " * (result.depth + 1)
|
|
186
|
+
verdict = result.verdict.upper()
|
|
187
|
+
colored_verdict = _colorize(verdict, VERDICT_COLORS.get(result.verdict, ""), color)
|
|
188
|
+
print(f"{indent}{colored_verdict} {result.path}")
|
|
189
|
+
print(f"{indent} format: {result.format} ({result.detail or 'n/a'})")
|
|
190
|
+
print(f"{indent} on disk: {human_size(result.size)}")
|
|
191
|
+
if result.declared_uncompressed is not None:
|
|
192
|
+
ratio = result.ratio
|
|
193
|
+
print(
|
|
194
|
+
f"{indent} declared: {human_size(result.declared_uncompressed)}"
|
|
195
|
+
f" ({human_ratio(ratio)})"
|
|
196
|
+
)
|
|
197
|
+
if result.entry_count is not None:
|
|
198
|
+
print(f"{indent} members: {result.entry_count:,}")
|
|
199
|
+
if result.verified is not None:
|
|
200
|
+
state = "verified" if result.verified else "unverified"
|
|
201
|
+
if result.verification_capped:
|
|
202
|
+
state += " (cap reached)"
|
|
203
|
+
print(
|
|
204
|
+
f"{indent} verify: {state}, "
|
|
205
|
+
f"{human_size(result.verified_uncompressed)} produced - {result.verification_detail}"
|
|
206
|
+
)
|
|
207
|
+
if result.error:
|
|
208
|
+
print(f"{indent} error: {result.error}")
|
|
209
|
+
|
|
210
|
+
for finding in result.findings:
|
|
211
|
+
color_code = SEVERITY_COLORS.get(finding.severity, "")
|
|
212
|
+
severity = _colorize(finding.severity.label.upper(), color_code, color)
|
|
213
|
+
print(f"{indent} [{severity}] {finding.code}: {finding.title}")
|
|
214
|
+
if args.verbose or finding.severity >= Severity.MEDIUM:
|
|
215
|
+
print(f"{indent} {finding.detail}")
|
|
216
|
+
|
|
217
|
+
if args.verbose and result.notes:
|
|
218
|
+
for note in result.notes:
|
|
219
|
+
print(f"{indent} note: {note}")
|
|
220
|
+
|
|
221
|
+
if args.verbose and result.entries:
|
|
222
|
+
print(f"{indent} members (sample):")
|
|
223
|
+
for entry in result.entries[: args.entries]:
|
|
224
|
+
kind = "dir" if entry.is_dir else entry.method or "?"
|
|
225
|
+
print(
|
|
226
|
+
f"{indent} {entry.name!r} {human_size(entry.usize)} "
|
|
227
|
+
f"({kind}, {human_ratio(entry.ratio)})"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
for index, child in enumerate(result.nested):
|
|
231
|
+
if args.verbose or index == 0:
|
|
232
|
+
_print_result(child, args, color)
|
|
233
|
+
else:
|
|
234
|
+
child_verdict = _colorize(
|
|
235
|
+
child.verdict.upper(), VERDICT_COLORS.get(child.verdict, ""), color
|
|
236
|
+
)
|
|
237
|
+
print(f"{indent} nested: {child_verdict} {child.path}")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _summary(results: Sequence[ScanResult]) -> Dict[str, int]:
|
|
241
|
+
summary = {"clean": 0, "suspicious": 0, "dangerous": 0, "unknown": 0}
|
|
242
|
+
for result in results:
|
|
243
|
+
summary[result.verdict] += 1
|
|
244
|
+
return summary
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _exit_code(results: Sequence[ScanResult], fail_on: str, io_error: bool) -> int:
|
|
248
|
+
if io_error:
|
|
249
|
+
return 4
|
|
250
|
+
if fail_on == "none":
|
|
251
|
+
return 0
|
|
252
|
+
summary = _summary(results)
|
|
253
|
+
if summary["dangerous"]:
|
|
254
|
+
return 2
|
|
255
|
+
if fail_on == "suspicious" and summary["suspicious"]:
|
|
256
|
+
return 1
|
|
257
|
+
if summary["unknown"]:
|
|
258
|
+
return 3
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
263
|
+
parser = build_parser()
|
|
264
|
+
args = parser.parse_args(argv)
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
paths = _collect_paths(args.paths, args.recursive)
|
|
268
|
+
except (IsADirectoryError, OSError) as exc:
|
|
269
|
+
print(f"zipcheck: {exc}", file=sys.stderr)
|
|
270
|
+
return 4
|
|
271
|
+
if not paths:
|
|
272
|
+
print("zipcheck: no archive files found", file=sys.stderr)
|
|
273
|
+
return 4
|
|
274
|
+
|
|
275
|
+
limits = Limits(
|
|
276
|
+
max_depth=max(0, args.depth),
|
|
277
|
+
timeout=max(0.0, args.timeout),
|
|
278
|
+
probe=args.probe,
|
|
279
|
+
probe_limit=args.probe_limit,
|
|
280
|
+
verify=args.verify or args.require_verified or args.production,
|
|
281
|
+
verify_limit=args.verify_limit,
|
|
282
|
+
require_verified=args.require_verified or args.production,
|
|
283
|
+
min_expansion_bytes=args.min_size,
|
|
284
|
+
suspicious_ratio=args.ratio,
|
|
285
|
+
dangerous_ratio=max(args.dangerous_ratio, args.ratio),
|
|
286
|
+
critical_ratio=max(args.dangerous_ratio * 100.0, args.ratio * 100.0),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
color = (
|
|
290
|
+
sys.stdout.isatty()
|
|
291
|
+
and not args.no_color
|
|
292
|
+
and os.environ.get("NO_COLOR") is None
|
|
293
|
+
)
|
|
294
|
+
started = time.monotonic()
|
|
295
|
+
results: List[ScanResult] = []
|
|
296
|
+
io_error = False
|
|
297
|
+
for path in paths:
|
|
298
|
+
try:
|
|
299
|
+
result = scan_file(path, limits)
|
|
300
|
+
except Exception as exc: # defensive: a parser bug must not abort the run
|
|
301
|
+
result = ScanResult(path=path, size=0, error=f"internal error: {exc}", unknown=True)
|
|
302
|
+
io_error = True
|
|
303
|
+
results.append(result)
|
|
304
|
+
if not args.json and not args.quiet:
|
|
305
|
+
_print_result(result, args, color)
|
|
306
|
+
print()
|
|
307
|
+
|
|
308
|
+
elapsed = time.monotonic() - started
|
|
309
|
+
summary = _summary(results)
|
|
310
|
+
|
|
311
|
+
if args.json:
|
|
312
|
+
payload: Dict[str, Any] = {
|
|
313
|
+
"tool": "zipcheck",
|
|
314
|
+
"version": __version__,
|
|
315
|
+
"elapsed_ms": round(elapsed * 1000, 3),
|
|
316
|
+
"summary": summary,
|
|
317
|
+
"results": [result.to_dict(sample_entries=max(args.entries, 25)) for result in results],
|
|
318
|
+
}
|
|
319
|
+
json.dump(payload, sys.stdout, indent=2)
|
|
320
|
+
sys.stdout.write("\n")
|
|
321
|
+
elif args.quiet:
|
|
322
|
+
for result in results:
|
|
323
|
+
print(f"{result.verdict.upper():10} {result.path}")
|
|
324
|
+
else:
|
|
325
|
+
print(
|
|
326
|
+
f"{len(results)} file(s): {summary['dangerous']} dangerous, "
|
|
327
|
+
f"{summary['suspicious']} suspicious, {summary['clean']} clean, "
|
|
328
|
+
f"{summary['unknown']} unknown ({elapsed:.2f}s)"
|
|
329
|
+
)
|
|
330
|
+
if not args.verbose:
|
|
331
|
+
print("use -v for notes and member samples; --json for machine output")
|
|
332
|
+
|
|
333
|
+
return _exit_code(results, args.fail_on, io_error)
|
zipcheck/detect.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Format identification.
|
|
2
|
+
|
|
3
|
+
Magic bytes identify the *container format*, not whether it is a bomb. There
|
|
4
|
+
is no universal "zip bomb signature": every bomb is a structurally valid
|
|
5
|
+
archive whose declared expansion is enormous. This module therefore only
|
|
6
|
+
routes a file to the right metadata parser. Extensions are used as a fallback
|
|
7
|
+
for formats with weak or optional magic (notably tar and bare LZMA).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import Optional, Tuple
|
|
14
|
+
|
|
15
|
+
ZIP_MAGICS = (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")
|
|
16
|
+
RAR4_MAGIC = b"Rar!\x1a\x07\x00"
|
|
17
|
+
RAR5_MAGIC = b"Rar!\x1a\x07\x01\x00"
|
|
18
|
+
SEVENZIP_MAGIC = b"7z\xbc\xaf\x27\x1c"
|
|
19
|
+
GZIP_MAGIC = b"\x1f\x8b"
|
|
20
|
+
BZIP2_MAGIC = b"BZh"
|
|
21
|
+
XZ_MAGIC = b"\xfd7zXZ\x00"
|
|
22
|
+
LZMA_MAGIC = b"\x5d\x00\x00"
|
|
23
|
+
ZSTD_MAGIC = b"\x28\xb5\x2f\xfd"
|
|
24
|
+
ZSTD_SKIPPABLE_MASK = 0xFFFFFFF0
|
|
25
|
+
ZSTD_SKIPPABLE_BASE = 0x184D2A50
|
|
26
|
+
|
|
27
|
+
ZIP_EXTENSIONS = {
|
|
28
|
+
".zip",
|
|
29
|
+
".zipx",
|
|
30
|
+
".jar",
|
|
31
|
+
".war",
|
|
32
|
+
".apk",
|
|
33
|
+
".epub",
|
|
34
|
+
".whl",
|
|
35
|
+
".xpi",
|
|
36
|
+
".crx",
|
|
37
|
+
".cbz",
|
|
38
|
+
".docx",
|
|
39
|
+
".xlsx",
|
|
40
|
+
".pptx",
|
|
41
|
+
".odt",
|
|
42
|
+
".ods",
|
|
43
|
+
".odp",
|
|
44
|
+
}
|
|
45
|
+
TAR_EXTENSIONS = {".tar", ".tarx", ".pax", ".cpio"}
|
|
46
|
+
SEVENZIP_EXTENSIONS = {".7z"}
|
|
47
|
+
RAR_EXTENSIONS = {".rar", ".rev"}
|
|
48
|
+
GZIP_EXTENSIONS = {".gz", ".gzip", ".tgz", ".z"}
|
|
49
|
+
BZIP2_EXTENSIONS = {".bz2", ".bzip2", ".tbz2", ".tbz"}
|
|
50
|
+
XZ_EXTENSIONS = {".xz", ".txz"}
|
|
51
|
+
LZMA_EXTENSIONS = {".lzma"}
|
|
52
|
+
ZSTD_EXTENSIONS = {".zst", ".zstd", ".tzst"}
|
|
53
|
+
|
|
54
|
+
ARCHIVE_EXTENSIONS = (
|
|
55
|
+
ZIP_EXTENSIONS
|
|
56
|
+
| TAR_EXTENSIONS
|
|
57
|
+
| SEVENZIP_EXTENSIONS
|
|
58
|
+
| RAR_EXTENSIONS
|
|
59
|
+
| GZIP_EXTENSIONS
|
|
60
|
+
| BZIP2_EXTENSIONS
|
|
61
|
+
| XZ_EXTENSIONS
|
|
62
|
+
| LZMA_EXTENSIONS
|
|
63
|
+
| ZSTD_EXTENSIONS
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _extension(path: str) -> str:
|
|
68
|
+
base = os.path.basename(path).lower()
|
|
69
|
+
# .tar.gz, .tar.bz2, ... -> outer format is what we can inspect first.
|
|
70
|
+
if base.endswith(".tar.gz"):
|
|
71
|
+
return ".gz"
|
|
72
|
+
if base.endswith(".tar.bz2"):
|
|
73
|
+
return ".bz2"
|
|
74
|
+
if base.endswith(".tar.xz"):
|
|
75
|
+
return ".xz"
|
|
76
|
+
if base.endswith(".tar.zst"):
|
|
77
|
+
return ".zst"
|
|
78
|
+
return os.path.splitext(base)[1]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def extension_suggests_archive(path: str) -> bool:
|
|
82
|
+
return _extension(path) in ARCHIVE_EXTENSIONS
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def sniff(head: bytes, path: str = "") -> Tuple[Optional[str], str]:
|
|
86
|
+
"""Return ``(format_key, why)`` or ``(None, "")``.
|
|
87
|
+
|
|
88
|
+
``head`` needs only the first ~512 bytes plus (for ZIP) the extension.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
ext = _extension(path)
|
|
92
|
+
|
|
93
|
+
if head.startswith(ZIP_MAGICS):
|
|
94
|
+
return "zip", "ZIP local header magic"
|
|
95
|
+
if head.startswith(RAR5_MAGIC):
|
|
96
|
+
return "rar5", "RAR 5 signature"
|
|
97
|
+
if head.startswith(RAR4_MAGIC):
|
|
98
|
+
return "rar4", "RAR 4 signature"
|
|
99
|
+
if head.startswith(SEVENZIP_MAGIC):
|
|
100
|
+
return "7z", "7-Zip signature"
|
|
101
|
+
if head.startswith(GZIP_MAGIC) and len(head) >= 3 and head[2] == 0x08:
|
|
102
|
+
return "gzip", "gzip magic"
|
|
103
|
+
if head.startswith(BZIP2_MAGIC) and len(head) >= 4 and 0x31 <= head[3] <= 0x39:
|
|
104
|
+
return "bzip2", "bzip2 magic"
|
|
105
|
+
if head.startswith(XZ_MAGIC):
|
|
106
|
+
return "xz", "xz magic"
|
|
107
|
+
if head.startswith(ZSTD_MAGIC):
|
|
108
|
+
return "zstd", "Zstandard frame magic"
|
|
109
|
+
if len(head) >= 512 and head[257:262] == b"ustar":
|
|
110
|
+
return "tar", "ustar header at offset 257"
|
|
111
|
+
|
|
112
|
+
# Fallbacks for weak/optional magic.
|
|
113
|
+
if ext in ZIP_EXTENSIONS:
|
|
114
|
+
return "zip", "ZIP extension"
|
|
115
|
+
if ext in TAR_EXTENSIONS:
|
|
116
|
+
return "tar", "tar extension"
|
|
117
|
+
if ext in SEVENZIP_EXTENSIONS:
|
|
118
|
+
return "7z", "7-Zip extension"
|
|
119
|
+
if ext in RAR_EXTENSIONS:
|
|
120
|
+
return "rar5" if head.startswith(RAR5_MAGIC) else "rar4", "RAR extension"
|
|
121
|
+
if ext in GZIP_EXTENSIONS:
|
|
122
|
+
return "gzip", "gzip extension"
|
|
123
|
+
if ext in BZIP2_EXTENSIONS:
|
|
124
|
+
return "bzip2", "bzip2 extension"
|
|
125
|
+
if ext in XZ_EXTENSIONS:
|
|
126
|
+
return "xz", "xz extension"
|
|
127
|
+
if ext in LZMA_EXTENSIONS:
|
|
128
|
+
return "lzma", "LZMA extension"
|
|
129
|
+
if ext in ZSTD_EXTENSIONS:
|
|
130
|
+
return "zstd", "Zstandard extension"
|
|
131
|
+
if len(head) >= 3 and head.startswith(LZMA_MAGIC):
|
|
132
|
+
return "lzma", "LZMA-alone magic"
|
|
133
|
+
return None, ""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def looks_like_archive(head: bytes, path: str = "") -> bool:
|
|
137
|
+
fmt, _ = sniff(head, path)
|
|
138
|
+
return fmt is not None
|