uploadshield 2.0.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.
Files changed (62) hide show
  1. uploadshield/__init__.py +49 -0
  2. uploadshield/__main__.py +17 -0
  3. uploadshield/checks/__init__.py +65 -0
  4. uploadshield/checks/archive.py +316 -0
  5. uploadshield/checks/basic.py +476 -0
  6. uploadshield/checks/image.py +107 -0
  7. uploadshield/checks/magika.py +129 -0
  8. uploadshield/checks/markup.py +181 -0
  9. uploadshield/checks/office.py +174 -0
  10. uploadshield/checks/pdf.py +277 -0
  11. uploadshield/checks/scanners.py +205 -0
  12. uploadshield/checks/xml.py +123 -0
  13. uploadshield/cli/__init__.py +276 -0
  14. uploadshield/cli/_commands.py +401 -0
  15. uploadshield/cli/_json.py +80 -0
  16. uploadshield/cli/_render.py +319 -0
  17. uploadshield/cli/_scan.py +106 -0
  18. uploadshield/cli/_style.py +95 -0
  19. uploadshield/cli/_verdicts.py +363 -0
  20. uploadshield/cli/_walk.py +159 -0
  21. uploadshield/core/__init__.py +4 -0
  22. uploadshield/core/_deconstruct.py +39 -0
  23. uploadshield/core/check.py +79 -0
  24. uploadshield/core/containers.py +131 -0
  25. uploadshield/core/result.py +63 -0
  26. uploadshield/core/sniffing.py +432 -0
  27. uploadshield/core/types.py +319 -0
  28. uploadshield/core/upload.py +192 -0
  29. uploadshield/core/violations.py +79 -0
  30. uploadshield/django/__init__.py +36 -0
  31. uploadshield/django/apps.py +13 -0
  32. uploadshield/django/checks.py +148 -0
  33. uploadshield/django/cli.py +36 -0
  34. uploadshield/django/conf.py +189 -0
  35. uploadshield/django/decorators.py +204 -0
  36. uploadshield/django/exceptions.py +39 -0
  37. uploadshield/django/forms.py +40 -0
  38. uploadshield/django/handlers.py +322 -0
  39. uploadshield/django/responses.py +92 -0
  40. uploadshield/django/service.py +473 -0
  41. uploadshield/django/signals.py +16 -0
  42. uploadshield/django/storage.py +204 -0
  43. uploadshield/django/templates/uploadshield/rejected.html +15 -0
  44. uploadshield/django/validators.py +48 -0
  45. uploadshield/django/views.py +40 -0
  46. uploadshield/engine.py +230 -0
  47. uploadshield/fastapi/__init__.py +23 -0
  48. uploadshield/fastapi/shield.py +299 -0
  49. uploadshield/flask/__init__.py +42 -0
  50. uploadshield/flask/core.py +359 -0
  51. uploadshield/flask/decorators.py +69 -0
  52. uploadshield/flask/wtforms.py +58 -0
  53. uploadshield/policy.py +137 -0
  54. uploadshield/presets.py +65 -0
  55. uploadshield/py.typed +0 -0
  56. uploadshield/sanitizers.py +132 -0
  57. uploadshield/web.py +218 -0
  58. uploadshield-2.0.0.dist-info/METADATA +239 -0
  59. uploadshield-2.0.0.dist-info/RECORD +62 -0
  60. uploadshield-2.0.0.dist-info/WHEEL +4 -0
  61. uploadshield-2.0.0.dist-info/entry_points.txt +3 -0
  62. uploadshield-2.0.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,131 @@
1
+ """Shared, cached access to container formats (ZIP) used by several checks.
2
+
3
+ The central directory is parsed once per upload and kept in ``InspectedFile.cache``
4
+ together with an index of member offsets. Every helper below then costs only the
5
+ member it reads: documents with thousands of parts are walked in linear time.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import bisect
11
+ import struct
12
+ import zipfile
13
+ import zlib
14
+ from typing import IO, TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ import re
18
+
19
+ from .upload import InspectedFile
20
+
21
+ __all__ = ["ZIP_ERRORS", "zip_entries", "zip_head", "zip_member", "zip_read", "zip_search"]
22
+
23
+ ZIP_ERRORS = (
24
+ zipfile.BadZipFile,
25
+ zipfile.LargeZipFile,
26
+ OSError,
27
+ ValueError,
28
+ EOFError,
29
+ NotImplementedError,
30
+ KeyError,
31
+ RuntimeError,
32
+ zlib.error,
33
+ struct.error,
34
+ )
35
+
36
+ _LOCAL_HEADER = 30
37
+ _CHUNK = 1024 * 1024
38
+
39
+
40
+ class _Archive:
41
+ """An open ZIP over the upload's stream, its members by name and an offset index."""
42
+
43
+ def __init__(self, stream: IO[bytes]) -> None:
44
+ self.zip = zipfile.ZipFile(stream)
45
+ self.entries = self.zip.infolist()
46
+ self.by_name = {e.filename: e for e in self.entries}
47
+ self.offsets = sorted(e.header_offset for e in self.entries)
48
+
49
+ def overlaps(self, info: zipfile.ZipInfo) -> bool:
50
+ """Whether another member starts inside ``info``'s data (overlapping-entry bombs)."""
51
+ end = info.header_offset + _LOCAL_HEADER + len(info.filename.encode()) + info.compress_size
52
+ index = bisect.bisect_right(self.offsets, info.header_offset)
53
+ duplicate_start = index >= 2 and self.offsets[index - 2] == info.header_offset
54
+ return duplicate_start or (index < len(self.offsets) and self.offsets[index] < end)
55
+
56
+
57
+ def _archive(file: InspectedFile) -> _Archive | None:
58
+ key = "zip.archive"
59
+ if key not in file.cache:
60
+ try:
61
+ with file.open() as fh:
62
+ file.cache[key] = _Archive(fh)
63
+ except ZIP_ERRORS:
64
+ file.cache[key] = None
65
+ return file.cache[key]
66
+
67
+
68
+ def zip_entries(file: InspectedFile) -> list[zipfile.ZipInfo] | None:
69
+ """The central-directory listing, or ``None`` if the file is not a readable ZIP."""
70
+ archive = _archive(file)
71
+ return archive.entries if archive is not None else None
72
+
73
+
74
+ def _member(file: InspectedFile, member: str) -> tuple[_Archive, zipfile.ZipInfo] | None:
75
+ archive = _archive(file)
76
+ if archive is None or (info := archive.by_name.get(member)) is None:
77
+ return None
78
+ return None if archive.overlaps(info) else (archive, info)
79
+
80
+
81
+ def zip_member(file: InspectedFile, member: str) -> zipfile.ZipInfo | None:
82
+ """A member that can be read safely (exists, does not overlap another one)."""
83
+ found = _member(file, member)
84
+ return found[1] if found is not None else None
85
+
86
+
87
+ def _read(file: InspectedFile, member: str, length: int) -> bytes | None:
88
+ if (found := _member(file, member)) is None:
89
+ return None
90
+ archive, info = found
91
+ try:
92
+ with file.open(), archive.zip.open(info) as stream:
93
+ return stream.read(length)
94
+ except ZIP_ERRORS:
95
+ return None
96
+
97
+
98
+ def zip_head(file: InspectedFile, member: str, length: int) -> bytes | None:
99
+ """The first ``length`` uncompressed bytes of a member (``None`` if unreadable)."""
100
+ return _read(file, member, length)
101
+
102
+
103
+ def zip_search(
104
+ file: InspectedFile, member: str, pattern: re.Pattern[bytes], *, overlap: int = 4096
105
+ ) -> bool:
106
+ """Whether ``pattern`` occurs in a member, streamed in chunks (members may be large)."""
107
+ if (found := _member(file, member)) is None:
108
+ return False
109
+ archive, info = found
110
+ carry = b""
111
+ try:
112
+ with file.open(), archive.zip.open(info) as stream:
113
+ while chunk := stream.read(_CHUNK):
114
+ window = carry + chunk
115
+ if pattern.search(window):
116
+ return True
117
+ carry = window[-overlap:]
118
+ except ZIP_ERRORS:
119
+ return False
120
+ return False
121
+
122
+
123
+ def zip_read(file: InspectedFile, member: str, limit: int) -> bytes | None:
124
+ """Read one member, decompressing at most ``limit`` bytes.
125
+
126
+ Returns ``None`` if the member is missing, unreadable, larger than ``limit``, or
127
+ overlaps another entry (such archives are refused by ``ArchiveSafety``; reading
128
+ them would also make :mod:`zipfile` emit warnings).
129
+ """
130
+ data = _read(file, member, limit + 1)
131
+ return None if data is None or len(data) > limit else data
@@ -0,0 +1,63 @@
1
+ """The outcome of validating one file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ if TYPE_CHECKING:
9
+ from .types import FileType
10
+ from .violations import Violation
11
+
12
+ __all__ = ["SanitizedFile", "ValidationResult"]
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class SanitizedFile:
17
+ """The sanitized replacement for an upload.
18
+
19
+ ``data`` is ``None`` when only metadata (e.g. the name) changed and the original
20
+ content can be kept as-is.
21
+ """
22
+
23
+ name: str
24
+ content_type: str
25
+ data: bytes | None
26
+ applied: tuple[str, ...]
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ValidationResult:
31
+ name: str
32
+ field: str | None
33
+ size: int
34
+ file_type: FileType | None
35
+ declared_type: str | None
36
+ violations: tuple[Violation, ...]
37
+ checks_run: tuple[str, ...]
38
+ sanitized: SanitizedFile | None = None
39
+
40
+ @property
41
+ def ok(self) -> bool:
42
+ return not self.violations
43
+
44
+ @property
45
+ def codes(self) -> tuple[str, ...]:
46
+ return tuple(str(v.code) for v in self.violations)
47
+
48
+ def as_dict(self, *, include_details: bool = False) -> dict[str, Any]:
49
+ data: dict[str, Any] = {
50
+ "field": self.field,
51
+ "name": self.name,
52
+ "ok": self.ok,
53
+ "errors": [v.as_dict(include_details=include_details) for v in self.violations],
54
+ }
55
+ if include_details:
56
+ data |= {
57
+ "size": self.size,
58
+ "detected_type": self.file_type.mime if self.file_type else None,
59
+ "declared_type": self.declared_type,
60
+ "checks_run": list(self.checks_run),
61
+ "sanitized": list(self.sanitized.applied) if self.sanitized else [],
62
+ }
63
+ return data
@@ -0,0 +1,432 @@
1
+ """Content-based file type detection ("sniffing").
2
+
3
+ The only trustworthy evidence about an upload is its bytes. A :class:`Sniffer` maps
4
+ content to a registered :class:`~uploadshield.core.types.FileType` or returns
5
+ ``None`` when the content cannot be positively identified - unidentified content is
6
+ never guessed from the client-supplied name or ``Content-Type``.
7
+
8
+ Short magic numbers (``BM``, ``MZ``, ...) are verified with additional header fields so
9
+ that ordinary text does not get misclassified as a binary format.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import struct
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass
18
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
19
+
20
+ from ._deconstruct import Deconstructible
21
+ from .containers import zip_entries, zip_read
22
+ from .types import FileType, get_type
23
+
24
+ if TYPE_CHECKING:
25
+ from .upload import InspectedFile
26
+
27
+ __all__ = ["BuiltinSniffer", "ChainedSniffer", "MagicSniffer", "Sniffer", "is_binary"]
28
+
29
+
30
+ @runtime_checkable
31
+ class Sniffer(Protocol):
32
+ """Anything that can identify a file's type from its content."""
33
+
34
+ def sniff(self, file: InspectedFile) -> FileType | None: ...
35
+
36
+
37
+ # --------------------------------------------------------------------------- #
38
+ # Text / binary classification (after the WHATWG MIME Sniffing Standard)
39
+ # --------------------------------------------------------------------------- #
40
+
41
+ #: Bytes that never occur in text (WHATWG "binary data byte").
42
+ _BINARY_BYTES = bytes([*range(0x00, 0x09), 0x0B, *range(0x0E, 0x1B), *range(0x1C, 0x20)])
43
+ _TEXT_BYTES = bytes(b for b in range(256) if b not in _BINARY_BYTES)
44
+
45
+ #: Tag openings that make browsers render a resource as HTML.
46
+ _HTML_OPENERS = re.compile(
47
+ rb"^(?:<!doctype\s+html|<html|<head|<script|<iframe|<h1|<div|<font|<table|<a|<style"
48
+ rb"|<title|<b|<body|<br|<p|<!--)(?:[\s>]|$)",
49
+ )
50
+ _WHITESPACE = b" \t\r\n\f"
51
+ _ELEMENT_NAME = re.compile(rb"<([a-z_][a-z0-9_.:-]*)")
52
+ _DOCTYPE_NAME = re.compile(rb"<!doctype\s+([a-z_][a-z0-9_.:-]*)")
53
+
54
+
55
+ def _root_element(text: bytes) -> tuple[bytes | None, bytes | None]:
56
+ """Return ``(doctype_name, root_element_name)`` of a markup document head."""
57
+ doctype: bytes | None = None
58
+ rest = text.lstrip(_WHITESPACE)
59
+ while rest.startswith(b"<?") or rest.startswith(b"<!"):
60
+ if rest.startswith(b"<!--"):
61
+ end = rest.find(b"-->")
62
+ skip = end + 3
63
+ elif rest.startswith(b"<?"):
64
+ end = rest.find(b"?>")
65
+ skip = end + 2
66
+ else:
67
+ if match := _DOCTYPE_NAME.match(rest):
68
+ doctype = match.group(1)
69
+ end = rest.find(b">")
70
+ skip = end + 1
71
+ if end == -1:
72
+ return doctype, None
73
+ rest = rest[skip:].lstrip(_WHITESPACE)
74
+ match = _ELEMENT_NAME.match(rest)
75
+ root = match.group(1).rsplit(b":", 1)[-1] if match else None
76
+ return doctype, root
77
+
78
+
79
+ def is_binary(data: bytes) -> bool:
80
+ """Whether ``data`` contains bytes that never appear in text."""
81
+ return bool(data.translate(None, _TEXT_BYTES))
82
+
83
+
84
+ def _decode_text_head(head: bytes) -> bytes | None:
85
+ """Return a lowercase, BOM-free, ASCII-compatible view of a text head or ``None``."""
86
+ if head.startswith((b"\xff\xfe", b"\xfe\xff")):
87
+ codec = "utf-16-le" if head.startswith(b"\xff\xfe") else "utf-16-be"
88
+ body = head[2:]
89
+ body = body[: len(body) - (len(body) % 2)]
90
+ try:
91
+ text = body.decode(codec)
92
+ except UnicodeDecodeError:
93
+ return None
94
+ encoded = text.encode("utf-8", "replace")
95
+ return None if is_binary(encoded) else encoded.lower()
96
+ head = head.removeprefix(b"\xef\xbb\xbf")
97
+ return None if is_binary(head) else head.lower()
98
+
99
+
100
+ def _classify_text(head: bytes) -> str | None:
101
+ text = _decode_text_head(head)
102
+ if text is None:
103
+ return None
104
+ start = text.lstrip(_WHITESPACE)
105
+ if start.startswith((b"<?php", b"<?=")):
106
+ return "application/x-httpd-php"
107
+ if start.startswith(b"#!"):
108
+ return "application/x-sh"
109
+ if not start.startswith(b"<"):
110
+ return "text/plain"
111
+ doctype, root = _root_element(start)
112
+ if b"svg" in (doctype, root):
113
+ return "image/svg+xml"
114
+ if b"html" in (doctype, root) or _HTML_OPENERS.match(start):
115
+ return "text/html"
116
+ if start.startswith(b"<?xml"):
117
+ return "application/xml"
118
+ return "text/plain"
119
+
120
+
121
+ # --------------------------------------------------------------------------- #
122
+ # Binary signatures
123
+ # --------------------------------------------------------------------------- #
124
+
125
+ _PREFIXES: tuple[tuple[bytes, str], ...] = (
126
+ (b"\xff\xd8\xff", "image/jpeg"),
127
+ (b"\x89PNG\r\n\x1a\n", "image/png"),
128
+ (b"GIF87a", "image/gif"),
129
+ (b"GIF89a", "image/gif"),
130
+ (b"II*\x00", "image/tiff"),
131
+ (b"MM\x00*", "image/tiff"),
132
+ (b"%PDF-", "application/pdf"),
133
+ (b"{\\rtf", "application/rtf"),
134
+ (b"%!PS", "application/postscript"),
135
+ (b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", "application/x-ole-storage"),
136
+ (b"\x1f\x8b\x08", "application/gzip"),
137
+ (b"\xfd7zXZ\x00", "application/x-xz"),
138
+ (b"7z\xbc\xaf\x27\x1c", "application/x-7z-compressed"),
139
+ (b"Rar!\x1a\x07", "application/vnd.rar"),
140
+ (b"fLaC", "audio/flac"),
141
+ (b"OggS\x00", "audio/ogg"),
142
+ (b"\x7fELF", "application/x-elf"),
143
+ (b"\x00asm", "application/wasm"),
144
+ (b"SQLite format 3\x00", "application/vnd.sqlite3"),
145
+ (b"\xfe\xed\xfa\xce", "application/x-mach-binary"),
146
+ (b"\xfe\xed\xfa\xcf", "application/x-mach-binary"),
147
+ (b"\xce\xfa\xed\xfe", "application/x-mach-binary"),
148
+ (b"\xcf\xfa\xed\xfe", "application/x-mach-binary"),
149
+ (b"\x00\x00\x01\xba", "video/mpeg"),
150
+ (b"\x00\x00\x01\xb3", "video/mpeg"),
151
+ )
152
+
153
+ _RIFF_FORMS = {b"WEBP": "image/webp", b"WAVE": "audio/wav", b"AVI ": "video/x-msvideo"}
154
+
155
+ _FTYP_BRANDS = {
156
+ b"avif": "image/avif",
157
+ b"avis": "image/avif",
158
+ b"heic": "image/heic",
159
+ b"heix": "image/heic",
160
+ b"heim": "image/heic",
161
+ b"heis": "image/heic",
162
+ b"hevc": "image/heic",
163
+ b"hevx": "image/heic",
164
+ b"M4A ": "audio/mp4",
165
+ b"M4B ": "audio/mp4",
166
+ b"qt ": "video/quicktime",
167
+ b"3gp4": "video/3gpp",
168
+ b"3gp5": "video/3gpp",
169
+ b"3gp6": "video/3gpp",
170
+ b"3g2a": "video/3gpp",
171
+ b"isom": "video/mp4",
172
+ b"iso2": "video/mp4",
173
+ b"iso4": "video/mp4",
174
+ b"iso5": "video/mp4",
175
+ b"iso6": "video/mp4",
176
+ b"mp41": "video/mp4",
177
+ b"mp42": "video/mp4",
178
+ b"avc1": "video/mp4",
179
+ b"dash": "video/mp4",
180
+ b"M4V ": "video/mp4",
181
+ b"MSNV": "video/mp4",
182
+ b"f4v ": "video/mp4",
183
+ }
184
+
185
+ _BMP_DIB_HEADER_SIZES = {12, 40, 52, 56, 64, 108, 124}
186
+
187
+
188
+ def _sniff_bmp(head: bytes) -> str | None:
189
+ if len(head) < 18 or head[:2] != b"BM":
190
+ return None
191
+ reserved = head[6:10]
192
+ (dib_size,) = struct.unpack_from("<I", head, 14)
193
+ return "image/bmp" if reserved == b"\x00" * 4 and dib_size in _BMP_DIB_HEADER_SIZES else None
194
+
195
+
196
+ def _sniff_ico(head: bytes) -> str | None:
197
+ if len(head) < 22 or head[:4] != b"\x00\x00\x01\x00":
198
+ return None
199
+ (count,) = struct.unpack_from("<H", head, 4)
200
+ reserved = head[9]
201
+ (planes,) = struct.unpack_from("<H", head, 10)
202
+ size, offset = struct.unpack_from("<II", head, 14)
203
+ if count and reserved == 0 and planes in (0, 1) and size and offset >= 6 + 16 * count:
204
+ return "image/vnd.microsoft.icon"
205
+ return None
206
+
207
+
208
+ def _sniff_riff(head: bytes) -> str | None:
209
+ if head[:4] != b"RIFF" or len(head) < 12:
210
+ return None
211
+ return _RIFF_FORMS.get(head[8:12])
212
+
213
+
214
+ def _sniff_ftyp(head: bytes) -> str | None:
215
+ if len(head) < 12 or head[4:8] != b"ftyp":
216
+ return None
217
+ (box_size,) = struct.unpack_from(">I", head, 0)
218
+ brands = [head[8:12]]
219
+ end = min(box_size, len(head))
220
+ brands += [head[i : i + 4] for i in range(16, end - 3, 4)]
221
+ # AVIF/HEIC files often use the generic 'mif1' major brand; look at compatibles.
222
+ for brand in brands:
223
+ if brand in (b"avif", b"avis"):
224
+ return "image/avif"
225
+ for brand in brands:
226
+ if brand in _FTYP_BRANDS:
227
+ return _FTYP_BRANDS[brand]
228
+ return None
229
+
230
+
231
+ def _sniff_ebml(head: bytes) -> str | None:
232
+ if head[:4] != b"\x1a\x45\xdf\xa3":
233
+ return None
234
+ window = head[:4096]
235
+ if b"webm" in window:
236
+ return "video/webm"
237
+ if b"matroska" in window:
238
+ return "video/x-matroska"
239
+ return None
240
+
241
+
242
+ def _sniff_tar(head: bytes) -> str | None:
243
+ return "application/x-tar" if head[257:262] == b"ustar" else None
244
+
245
+
246
+ def _sniff_bzip2(head: bytes) -> str | None:
247
+ if head[:3] == b"BZh" and head[3:4].isdigit() and head[3:4] != b"0":
248
+ block = head[4:10]
249
+ if block in (b"\x31\x41\x59\x26\x53\x59", b"\x17\x72\x45\x38\x50\x90"):
250
+ return "application/x-bzip2"
251
+ return None
252
+
253
+
254
+ def _sniff_id3_or_mpeg_audio(head: bytes) -> str | None:
255
+ if head[:3] == b"ID3" and len(head) >= 10 and head[3] in (2, 3, 4) and head[4] != 0xFF:
256
+ return "audio/mpeg"
257
+ if len(head) < 4 or head[0] != 0xFF or (head[1] & 0xE0) != 0xE0:
258
+ return None
259
+ version = (head[1] >> 3) & 0x03
260
+ layer = (head[1] >> 1) & 0x03
261
+ bitrate = head[2] >> 4
262
+ sample_rate = (head[2] >> 2) & 0x03
263
+ if layer == 0 and (head[1] & 0xF6) == 0xF0: # ADTS (AAC) shares the sync word
264
+ return "audio/aac"
265
+ if version == 1 or layer == 0 or bitrate in (0, 0x0F) or sample_rate == 0x03:
266
+ return None
267
+ return "audio/mpeg"
268
+
269
+
270
+ def _sniff_cafebabe(head: bytes) -> str | None:
271
+ if head[:4] != b"\xca\xfe\xba\xbe" or len(head) < 8:
272
+ return None
273
+ (value,) = struct.unpack_from(">I", head, 4)
274
+ # Mach-O universal binaries store a small architecture count here; Java class
275
+ # files store (minor << 16 | major) where major >= 45.
276
+ return "application/x-mach-binary" if value < 45 else "application/java-vm"
277
+
278
+
279
+ def _sniff_mz(head: bytes, file: InspectedFile) -> str | None:
280
+ if head[:2] != b"MZ":
281
+ return None
282
+ if len(head) >= 0x40:
283
+ (pe_offset,) = struct.unpack_from("<I", head, 0x3C)
284
+ if pe_offset + 4 <= min(file.size, 1 << 20):
285
+ window = file.head(pe_offset + 4)
286
+ if window[pe_offset : pe_offset + 4] == b"PE\x00\x00":
287
+ return "application/vnd.microsoft.portable-executable"
288
+ # A DOS "MZ" executable without a PE header is still an executable, unless the
289
+ # content is plainly text that merely starts with the letters "MZ".
290
+ return "application/vnd.microsoft.portable-executable" if is_binary(head) else None
291
+
292
+
293
+ _ZIP_SIGNATURES = (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")
294
+
295
+ _ODF_MIMETYPES = {
296
+ mime.encode(): mime
297
+ for mime in (
298
+ "application/vnd.oasis.opendocument.text",
299
+ "application/vnd.oasis.opendocument.spreadsheet",
300
+ "application/vnd.oasis.opendocument.presentation",
301
+ "application/epub+zip",
302
+ )
303
+ }
304
+
305
+ _OOXML_MAIN_PARTS = (
306
+ (
307
+ b"application/vnd.ms-word.document.macroenabled.main+xml",
308
+ "application/vnd.ms-word.document.macroenabled.12",
309
+ ),
310
+ (
311
+ b"application/vnd.ms-excel.sheet.macroenabled.main+xml",
312
+ "application/vnd.ms-excel.sheet.macroenabled.12",
313
+ ),
314
+ (
315
+ b"application/vnd.ms-powerpoint.presentation.macroenabled.main+xml",
316
+ "application/vnd.ms-powerpoint.presentation.macroenabled.12",
317
+ ),
318
+ (
319
+ b"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
320
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
321
+ ),
322
+ (
323
+ b"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml",
324
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
325
+ ),
326
+ (
327
+ b"application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml",
328
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
329
+ ),
330
+ )
331
+
332
+
333
+ def _sniff_zip(head: bytes, file: InspectedFile) -> str | None:
334
+ if head[:4] not in _ZIP_SIGNATURES:
335
+ return None
336
+ entries = zip_entries(file)
337
+ if not entries:
338
+ return "application/zip"
339
+ names = {e.filename for e in entries}
340
+ if entries[0].filename == "mimetype":
341
+ declared = (zip_read(file, "mimetype", 256) or b"").strip()
342
+ if declared in _ODF_MIMETYPES:
343
+ return _ODF_MIMETYPES[declared]
344
+ if "[Content_Types].xml" in names:
345
+ content_types = (zip_read(file, "[Content_Types].xml", 1 << 20) or b"").lower()
346
+ for marker, mime in _OOXML_MAIN_PARTS:
347
+ if marker in content_types:
348
+ return mime
349
+ if "AndroidManifest.xml" in names and "classes.dex" in names:
350
+ return "application/vnd.android.package-archive"
351
+ if "META-INF/MANIFEST.MF" in names:
352
+ return "application/java-archive"
353
+ return "application/zip"
354
+
355
+
356
+ _HeadDetector = Callable[[bytes], "str | None"]
357
+ _FileDetector = Callable[[bytes, "InspectedFile"], "str | None"]
358
+
359
+ _HEAD_DETECTORS: tuple[_HeadDetector, ...] = (
360
+ _sniff_riff,
361
+ _sniff_ftyp,
362
+ _sniff_ebml,
363
+ _sniff_bmp,
364
+ _sniff_ico,
365
+ _sniff_bzip2,
366
+ _sniff_id3_or_mpeg_audio,
367
+ _sniff_cafebabe,
368
+ _sniff_tar,
369
+ )
370
+ _FILE_DETECTORS: tuple[_FileDetector, ...] = (_sniff_zip, _sniff_mz)
371
+
372
+
373
+ @dataclass(frozen=True, slots=True)
374
+ class BuiltinSniffer(Deconstructible):
375
+ """Pure-Python signature sniffer covering the formats in the type registry."""
376
+
377
+ _deconstruct_module = "uploadshield.core.sniffing"
378
+
379
+ def sniff(self, file: InspectedFile) -> FileType | None:
380
+ head = file.head()
381
+ if not head:
382
+ return None
383
+ mime = self._sniff_mime(head, file)
384
+ return get_type(mime) if mime else None
385
+
386
+ @staticmethod
387
+ def _sniff_mime(head: bytes, file: InspectedFile) -> str | None:
388
+ for prefix, mime in _PREFIXES:
389
+ if head.startswith(prefix):
390
+ return mime
391
+ # A UTF-16 BOM (FF FE) doubles as an MPEG audio frame sync; text wins.
392
+ if head.startswith((b"\xff\xfe", b"\xfe\xff")) and (text := _classify_text(head)):
393
+ return text
394
+ for file_detector in _FILE_DETECTORS:
395
+ if mime := file_detector(head, file):
396
+ return mime
397
+ for detector in _HEAD_DETECTORS:
398
+ if mime := detector(head):
399
+ return mime
400
+ return _classify_text(head)
401
+
402
+
403
+ @dataclass(frozen=True, slots=True)
404
+ class MagicSniffer(Deconstructible):
405
+ """Identify types with libmagic (``uv add "uploadshield[magic]"``).
406
+
407
+ Best used as a fallback after :class:`BuiltinSniffer` via :class:`ChainedSniffer`.
408
+ Results that do not map to a registered type are treated as unidentified.
409
+ """
410
+
411
+ _deconstruct_module = "uploadshield.core.sniffing"
412
+ head_size: int = 64 * 1024
413
+
414
+ def sniff(self, file: InspectedFile) -> FileType | None:
415
+ import magic
416
+
417
+ mime = magic.from_buffer(file.head(self.head_size), mime=True)
418
+ return get_type(mime) if isinstance(mime, str) else None
419
+
420
+
421
+ @dataclass(frozen=True, slots=True)
422
+ class ChainedSniffer(Deconstructible):
423
+ """Try several sniffers in order; the first positive identification wins."""
424
+
425
+ _deconstruct_module = "uploadshield.core.sniffing"
426
+ sniffers: tuple[Sniffer, ...]
427
+
428
+ def sniff(self, file: InspectedFile) -> FileType | None:
429
+ for sniffer in self.sniffers:
430
+ if (found := sniffer.sniff(file)) is not None:
431
+ return found
432
+ return None