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,363 @@
1
+ """What each violation means for a person: verdicts, explanations, per-file outcomes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from collections import Counter
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING
9
+
10
+ from uploadshield.core.violations import Code
11
+
12
+ if TYPE_CHECKING:
13
+ from uploadshield.core.result import ValidationResult
14
+ from uploadshield.core.violations import Violation
15
+
16
+ __all__ = ["CODE_INFO", "CONDITIONS", "CodeInfo", "Outcome", "Summary", "Verdict", "judge"]
17
+
18
+
19
+ class Verdict(enum.StrEnum):
20
+ """Ordered from best to worst: a file takes the worst verdict of its violations."""
21
+
22
+ CLEAN = "clean"
23
+ REFUSED = "refused"
24
+ NOT_VERIFIED = "not_verified"
25
+ DANGEROUS = "dangerous"
26
+
27
+ @property
28
+ def rank(self) -> int:
29
+ return list(Verdict).index(self)
30
+
31
+ @property
32
+ def label(self) -> str:
33
+ return self.value.replace("_", " ")
34
+
35
+
36
+ D, U, R = Verdict.DANGEROUS, Verdict.NOT_VERIFIED, Verdict.REFUSED
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class CodeInfo:
41
+ verdict: Verdict
42
+ title: str
43
+ meaning: str
44
+ cause: str
45
+ relax: str
46
+
47
+
48
+ CODE_INFO: dict[Code, CodeInfo] = {
49
+ Code.FILE_EMPTY: CodeInfo(
50
+ R,
51
+ "empty file",
52
+ "The file has no content.",
53
+ "An empty upload, or a client that failed to read the file.",
54
+ "Policy(allow_empty=True)",
55
+ ),
56
+ Code.FILE_TOO_LARGE: CodeInfo(
57
+ R,
58
+ "file too large",
59
+ "The file is larger than the policy's max_size.",
60
+ "A large file, or one meant to fill the disk.",
61
+ "Policy(max_size=...), or max_size=None for no limit",
62
+ ),
63
+ Code.FILENAME_INVALID: CodeInfo(
64
+ D,
65
+ "invalid file name",
66
+ "The name contains path separators, '..', control or bidi characters, or "
67
+ "characters Windows forbids.",
68
+ "Path traversal (../../etc/cron.d/x), NUL bytes, or a U+202E override that shows "
69
+ "'gpj.exe' as 'exe.jpg'.",
70
+ "FilenameCheck(allow_leading_hyphen=True, allow_consecutive_dots=True) for those "
71
+ "two; SafeFilename() or RandomizeFilename() replace names instead",
72
+ ),
73
+ Code.FILENAME_TOO_LONG: CodeInfo(
74
+ R,
75
+ "file name too long",
76
+ "The name is longer than FilenameCheck.max_length bytes (UTF-8).",
77
+ "Very long names break filesystems and databases.",
78
+ "FilenameCheck(max_length=...)",
79
+ ),
80
+ Code.FILENAME_HIDDEN: CodeInfo(
81
+ R,
82
+ "hidden file name",
83
+ "The name starts with a dot.",
84
+ "Dotfiles are hidden and often read as configuration.",
85
+ "FilenameCheck(allow_hidden=True)",
86
+ ),
87
+ Code.FILENAME_RESERVED: CodeInfo(
88
+ D,
89
+ "reserved file name",
90
+ "The name is a Windows device name (CON, NUL, COM1, ...), a server configuration "
91
+ "file (.htaccess, web.config) or an 8.3 short name (PROGRA~1).",
92
+ "Configuration files change how a web server treats the upload directory.",
93
+ "FilenameCheck(forbidden_names=..., allow_short_names=True)",
94
+ ),
95
+ Code.FILENAME_DANGEROUS_EXTENSION: CodeInfo(
96
+ D,
97
+ "dangerous extension",
98
+ "An extension in the name makes servers or clients run the file (.php, .jsp, .exe, "
99
+ ".bat, ...), wherever it appears (shell.php.png).",
100
+ "A web shell or executable named to be run where it is stored.",
101
+ "FilenameCheck(forbidden_extensions=...)",
102
+ ),
103
+ Code.FILENAME_MISSING_EXTENSION: CodeInfo(
104
+ R,
105
+ "no extension",
106
+ "The name has no extension.",
107
+ "Files without an extension are served with a guessed type.",
108
+ "FilenameCheck(require_extension=False)",
109
+ ),
110
+ Code.TYPE_UNKNOWN: CodeInfo(
111
+ U,
112
+ "unknown format",
113
+ "The content matches no file format uploadshield knows.",
114
+ "A format it does not know, or random or encrypted data.",
115
+ "register_type(FileType(...)) for your format, or Policy(sniffer=MagicSniffer())",
116
+ ),
117
+ Code.TYPE_NOT_ALLOWED: CodeInfo(
118
+ R,
119
+ "format not allowed",
120
+ "The format detected from the content is not in the policy's allowed_types.",
121
+ "The content is not what the name says (a PHP script named .png), or the format "
122
+ "is simply not accepted here.",
123
+ "policy.extend(allowed_types={'<mime type>'})",
124
+ ),
125
+ Code.EXTENSION_MISMATCH: CodeInfo(
126
+ R,
127
+ "extension does not match the content",
128
+ "The name's extension does not belong to the format detected from the content.",
129
+ "A renamed file, e.g. a JPEG named .png - or a disguise.",
130
+ "Policy(match_extension=False) with SafeFilename() or RandomizeFilename(), which "
131
+ "give the file the right extension",
132
+ ),
133
+ Code.CONTENT_MISMATCH: CodeInfo(
134
+ D,
135
+ "content is a different format",
136
+ "Magika's model identifies the content as a forbidden format.",
137
+ "Code or an executable inside a file of an allowed format.",
138
+ "MagikaCheck(forbidden=..., min_score=...)",
139
+ ),
140
+ Code.DECLARED_TYPE_MISMATCH: CodeInfo(
141
+ R,
142
+ "declared type does not match",
143
+ "The client's Content-Type header contradicts the content.",
144
+ "A client that guessed wrong, or one that lies.",
145
+ "Policy(match_declared_type=False), the default",
146
+ ),
147
+ Code.IMAGE_CORRUPT: CodeInfo(
148
+ R,
149
+ "corrupt image",
150
+ "Pillow cannot decode the image completely.",
151
+ "A truncated or damaged file, or one crafted against image libraries.",
152
+ "none: re-save the image",
153
+ ),
154
+ Code.IMAGE_TOO_LARGE: CodeInfo(
155
+ D,
156
+ "decompression bomb",
157
+ "The image's dimensions, frame count or total pixels exceed the limits.",
158
+ "A small file that needs gigabytes of memory to decode.",
159
+ "ImageIntegrity(max_pixels=..., max_frames=..., max_total_pixels=...)",
160
+ ),
161
+ Code.PDF_ACTIVE_CONTENT: CodeInfo(
162
+ D,
163
+ "active PDF content",
164
+ "The PDF contains JavaScript, launch actions, embedded files or forms that submit "
165
+ "data - or it is encrypted, or stores objects in a form that cannot be checked.",
166
+ "Malicious PDFs run scripts or drop files when opened.",
167
+ "PdfActiveContent(forbidden=..., allow_encrypted=True)",
168
+ ),
169
+ Code.ARCHIVE_CORRUPT: CodeInfo(
170
+ R,
171
+ "corrupt archive",
172
+ "The ZIP container cannot be read consistently.",
173
+ "A damaged file, or one built so that tools disagree about its contents.",
174
+ "none",
175
+ ),
176
+ Code.ARCHIVE_UNSAFE_PATH: CodeInfo(
177
+ D,
178
+ "unsafe archive path (zip slip)",
179
+ "A member would be extracted outside the target directory (../, absolute paths, "
180
+ "drive letters) or is a symbolic link.",
181
+ "Extracting it overwrites files elsewhere.",
182
+ "none",
183
+ ),
184
+ Code.ARCHIVE_BOMB: CodeInfo(
185
+ D,
186
+ "archive bomb",
187
+ "The archive expands far beyond its size, has too many members, overlapping "
188
+ "entries or too deep nesting.",
189
+ "A small file that fills the disk or memory when extracted.",
190
+ "ArchiveSafety(max_ratio=..., max_uncompressed_bytes=..., max_entries=..., max_depth=...)",
191
+ ),
192
+ Code.ARCHIVE_ENCRYPTED: CodeInfo(
193
+ U,
194
+ "encrypted archive",
195
+ "Encrypted members cannot be inspected.",
196
+ "Encryption hides malware from scanners.",
197
+ "ArchiveSafety(allow_encrypted=True)",
198
+ ),
199
+ Code.ARCHIVE_DANGEROUS_MEMBER: CodeInfo(
200
+ D,
201
+ "dangerous archive member",
202
+ "A member has a dangerous extension or a server configuration name (shell.php, "
203
+ ".htaccess, ...).",
204
+ "Archives that are extracted on the server deliver what the upload check refused.",
205
+ "ArchiveSafety(check_member_names=False)",
206
+ ),
207
+ Code.OFFICE_MACROS: CodeInfo(
208
+ D,
209
+ "macros",
210
+ "The document contains macros, ActiveX controls or DDE fields that run commands.",
211
+ "The classic malicious-attachment payload.",
212
+ "remove OfficeMacros() from content_checks (not recommended)",
213
+ ),
214
+ Code.OFFICE_EXTERNAL_REFERENCE: CodeInfo(
215
+ D,
216
+ "remote template",
217
+ "The document loads a remote template or object when it is opened.",
218
+ "Used to fetch macros later and to leak Windows credentials.",
219
+ "OfficeMacros(block_external_references=False)",
220
+ ),
221
+ Code.SVG_ACTIVE_CONTENT: CodeInfo(
222
+ D,
223
+ "scripted SVG",
224
+ "The SVG contains scripts, event handlers, javascript: links, foreignObject, "
225
+ "entities or external references.",
226
+ "Served from your domain, it runs script in your users' browsers (stored XSS).",
227
+ "none: rasterize SVG uploads instead",
228
+ ),
229
+ Code.XML_DTD: CodeInfo(
230
+ D,
231
+ "XML DTD",
232
+ "The XML declares a DTD, which enables entity expansion and external entities.",
233
+ "Billion-laughs denial of service and XXE file disclosure.",
234
+ "none",
235
+ ),
236
+ Code.EMBEDDED_CODE: CodeInfo(
237
+ D,
238
+ "embedded code",
239
+ "Code (PHP, script tags, JSP, SSI) is hidden inside the file.",
240
+ "A web shell in image metadata, or PHP tags in a text file that include() runs.",
241
+ "EmbeddedContent(categories=..., scan_text=False)",
242
+ ),
243
+ Code.POLYGLOT: CodeInfo(
244
+ D,
245
+ "polyglot",
246
+ "The file is also a valid ZIP archive or PDF document.",
247
+ "One file that different programs read as different formats (GIFAR).",
248
+ "EmbeddedContent(categories=...)",
249
+ ),
250
+ Code.MALWARE_DETECTED: CodeInfo(
251
+ D,
252
+ "malware",
253
+ "ClamAV found a malware signature.",
254
+ "Known malware.",
255
+ "none",
256
+ ),
257
+ Code.SIGNATURE_MATCH: CodeInfo(
258
+ D,
259
+ "YARA match",
260
+ "One of your YARA rules matched.",
261
+ "Whatever the rule describes.",
262
+ "adjust the rules",
263
+ ),
264
+ Code.SCANNER_UNAVAILABLE: CodeInfo(
265
+ U,
266
+ "scanner unavailable",
267
+ "An external scanner could not be reached or refused the file.",
268
+ "clamd is down, or the file exceeds clamd's StreamMaxLength.",
269
+ "ClamAV(fail_open=True) (not recommended)",
270
+ ),
271
+ Code.SANITIZATION_FAILED: CodeInfo(
272
+ U,
273
+ "sanitizing failed",
274
+ "A sanitizer could not process the file, or what it produced failed the checks.",
275
+ "Pillow is missing or cannot re-encode the image.",
276
+ "uv add 'uploadshield[images]'",
277
+ ),
278
+ Code.CHECK_FAILED: CodeInfo(
279
+ U,
280
+ "check failed",
281
+ "A check could not finish: the file needs more memory than it may use, or the "
282
+ "check crashed.",
283
+ "A very large file - or a bug, which you are welcome to report.",
284
+ "raise the check's limit, e.g. PdfActiveContent(max_bytes=...)",
285
+ ),
286
+ }
287
+
288
+ #: Codes whose verdict depends on the file (see :func:`verdict_for`).
289
+ CONDITIONS: dict[Code, str] = {
290
+ Code.TYPE_NOT_ALLOWED: "dangerous when the content is an executable or HTML; in a scan, "
291
+ "not verified for archives it cannot open",
292
+ Code.PDF_ACTIVE_CONTENT: "not verified when the PDF is encrypted or cannot be decoded",
293
+ }
294
+
295
+ #: Formats that are dangerous to accept as uploads whatever their content.
296
+ _ACTIVE_TYPES = frozenset({"text/html"})
297
+
298
+
299
+ def verdict_for(violation: Violation, result: ValidationResult, *, scan: bool) -> Verdict:
300
+ code = violation.code
301
+ if scan and code == Code.TYPE_UNKNOWN and result.size == 0:
302
+ return Verdict.CLEAN # nothing to inspect (its name is still judged)
303
+ if code == Code.TYPE_NOT_ALLOWED:
304
+ detected = result.file_type
305
+ if detected is not None and (detected.executable or detected.mime in _ACTIVE_TYPES):
306
+ return D
307
+ return U if scan else R # in a scan: a format it cannot look into
308
+ if code == Code.PDF_ACTIVE_CONTENT and violation.details.get("reason") in (
309
+ "encrypted",
310
+ "uncheckable",
311
+ ):
312
+ return U
313
+ info = CODE_INFO.get(code) if isinstance(code, Code) else None
314
+ return info.verdict if info is not None else R
315
+
316
+
317
+ def judge(result: ValidationResult, *, scan: bool) -> tuple[Verdict, tuple[Verdict, ...]]:
318
+ """The verdict of each violation and of the file (the worst one)."""
319
+ each = tuple(verdict_for(v, result, scan=scan) for v in result.violations)
320
+ return max(each, key=lambda v: v.rank, default=Verdict.CLEAN), each
321
+
322
+
323
+ @dataclass(slots=True)
324
+ class Outcome:
325
+ """What happened to one path."""
326
+
327
+ display: str
328
+ result: ValidationResult | None = None
329
+ verdict: Verdict = Verdict.CLEAN
330
+ verdicts: tuple[Verdict, ...] = ()
331
+ sha256: str | None = None
332
+ error: str | None = None # could not be read: counts as not verified
333
+ skipped: str | None = None # deliberately not inspected (symbolic link, device)
334
+ saved: str | None = None
335
+ save_error: str | None = None
336
+ #: Where a file found in a directory is shown: under ``root``, as ``label``.
337
+ root: str | None = None
338
+ label: str = ""
339
+
340
+
341
+ @dataclass(slots=True)
342
+ class Summary:
343
+ counts: Counter[Verdict] = field(default_factory=Counter)
344
+ skipped: Counter[str] = field(default_factory=Counter)
345
+ save_errors: int = 0
346
+ seconds: float = 0.0
347
+
348
+ def add(self, outcome: Outcome) -> None:
349
+ if outcome.skipped is not None:
350
+ self.skipped[outcome.skipped] += 1
351
+ return
352
+ self.counts[outcome.verdict] += 1
353
+ self.save_errors += outcome.save_error is not None
354
+
355
+ @property
356
+ def files(self) -> int:
357
+ return sum(self.counts.values())
358
+
359
+ @property
360
+ def exit_code(self) -> int:
361
+ if self.save_errors:
362
+ return 2
363
+ return 0 if self.files == self.counts[Verdict.CLEAN] else 1
@@ -0,0 +1,159 @@
1
+ """Turn command-line paths into files to inspect, without surprises.
2
+
3
+ * Directories are walked in sorted order, so output is stable.
4
+ * Symbolic links named on the command line are followed; links found while walking
5
+ are skipped unless asked for, and directory cycles are cut.
6
+ * Only regular files are read. A FIFO would block forever and ``/dev/zero`` never
7
+ ends, so files are opened non-blocking and checked with ``fstat`` first.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import dataclasses
14
+ import fnmatch
15
+ import os
16
+ import stat
17
+ import sys
18
+ import tempfile
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import IO, TYPE_CHECKING
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Iterable, Iterator
25
+
26
+ __all__ = ["STDIN", "Target", "iter_targets", "missing_paths", "open_target"]
27
+
28
+ STDIN = "-"
29
+ _CHUNK = 1 << 20
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class Target:
34
+ """One thing to inspect. ``path`` is ``None`` for standard input.
35
+
36
+ ``display`` is the full path as given; files found in a directory also have a
37
+ ``root`` (that directory, as given) and a ``label`` relative to it.
38
+ """
39
+
40
+ display: str
41
+ name: str
42
+ path: Path | None = None
43
+ follow: bool = True # may the final component be a symbolic link?
44
+ error: str | None = None
45
+ skipped: str | None = None
46
+ root: str | None = None
47
+ label: str = ""
48
+
49
+
50
+ def missing_paths(paths: Iterable[str]) -> list[str]:
51
+ return [p for p in paths if p != STDIN and not os.path.lexists(p)]
52
+
53
+
54
+ def iter_targets(
55
+ paths: Iterable[str], *, exclude: Iterable[str] = (), follow_symlinks: bool = False
56
+ ) -> Iterator[Target]:
57
+ patterns = tuple(exclude)
58
+ for arg in paths:
59
+ if arg == STDIN:
60
+ yield Target(display="<stdin>", name="", label="<stdin>")
61
+ continue
62
+ path = Path(arg)
63
+ if path.is_dir(): # follows a link named on the command line, like find -H
64
+ for target in _walk(path, patterns, follow_symlinks=follow_symlinks):
65
+ label = Path(target.display).relative_to(path).as_posix()
66
+ yield dataclasses.replace(target, root=arg, label=label)
67
+ else:
68
+ yield Target(display=arg, name=path.name, path=path, label=arg)
69
+
70
+
71
+ def _excluded(name: str, relative: str, patterns: tuple[str, ...]) -> bool:
72
+ return any(fnmatch.fnmatch(name, p) or fnmatch.fnmatch(relative, p) for p in patterns)
73
+
74
+
75
+ def _walk(root: Path, patterns: tuple[str, ...], *, follow_symlinks: bool) -> Iterator[Target]:
76
+ seen: set[tuple[int, int]] = set()
77
+ try:
78
+ info = root.stat()
79
+ seen.add((info.st_dev, info.st_ino))
80
+ except OSError:
81
+ pass
82
+ stack = [root]
83
+ while stack:
84
+ directory = stack.pop()
85
+ try:
86
+ with os.scandir(directory) as scan:
87
+ entries = sorted(scan, key=lambda e: e.name)
88
+ except OSError as exc:
89
+ yield Target(str(directory), directory.name, directory, error=_reason(exc))
90
+ continue
91
+ subdirectories: list[Path] = []
92
+ for entry in entries:
93
+ path = Path(entry.path)
94
+ relative = path.relative_to(root).as_posix()
95
+ if _excluded(entry.name, relative, patterns):
96
+ continue
97
+ link = entry.is_symlink()
98
+ if link and not follow_symlinks:
99
+ yield Target(str(path), entry.name, path, skipped="symbolic link")
100
+ continue
101
+ try:
102
+ is_dir = entry.is_dir(follow_symlinks=follow_symlinks)
103
+ except OSError as exc:
104
+ yield Target(str(path), entry.name, path, error=_reason(exc))
105
+ continue
106
+ if is_dir:
107
+ try:
108
+ info = entry.stat(follow_symlinks=True)
109
+ except OSError as exc:
110
+ yield Target(str(path), entry.name, path, error=_reason(exc))
111
+ continue
112
+ if (key := (info.st_dev, info.st_ino)) in seen:
113
+ yield Target(str(path), entry.name, path, skipped="directory cycle")
114
+ continue
115
+ seen.add(key)
116
+ subdirectories.append(path)
117
+ else:
118
+ yield Target(str(path), entry.name, path, follow=follow_symlinks)
119
+ stack.extend(reversed(subdirectories)) # depth first, in sorted order
120
+
121
+
122
+ def open_target(target: Target) -> tuple[IO[bytes], int] | str:
123
+ """An open binary stream and its size, or why the target is not a regular file.
124
+
125
+ Raises :class:`OSError` when the file cannot be read (reported as not verified).
126
+ """
127
+ if target.path is None:
128
+ return _spool_stdin()
129
+ flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0)
130
+ if not target.follow:
131
+ flags |= getattr(os, "O_NOFOLLOW", 0) # it was not a link a moment ago
132
+ fd = os.open(target.path, flags)
133
+ try:
134
+ info = os.fstat(fd)
135
+ if not stat.S_ISREG(info.st_mode):
136
+ os.close(fd)
137
+ return "not a regular file"
138
+ return os.fdopen(fd, "rb"), info.st_size
139
+ except BaseException:
140
+ with contextlib.suppress(OSError):
141
+ os.close(fd)
142
+ raise
143
+
144
+
145
+ def _spool_stdin() -> tuple[IO[bytes], int]:
146
+ # A copy of our own: the engine closes the streams it copies, and --save needs
147
+ # the bytes afterwards.
148
+ spool = tempfile.SpooledTemporaryFile(max_size=16 * _CHUNK) # noqa: SIM115
149
+ size = 0
150
+ source = sys.stdin.buffer
151
+ while chunk := source.read(_CHUNK):
152
+ spool.write(chunk)
153
+ size += len(chunk)
154
+ spool.seek(0)
155
+ return spool, size
156
+
157
+
158
+ def _reason(exc: OSError) -> str:
159
+ return exc.strerror or type(exc).__name__
@@ -0,0 +1,4 @@
1
+ """Framework-agnostic building blocks: types, sniffing, checks API and results.
2
+
3
+ Nothing in this package imports Django; this is enforced by the test-suite.
4
+ """
@@ -0,0 +1,39 @@
1
+ """Support for Django's migration serializer without importing Django.
2
+
3
+ Django serialises model-field validators into migration files by calling
4
+ ``deconstruct()`` on them. Policies and checks are frozen dataclasses, so their
5
+ constructor arguments are exactly their fields; emitting the non-default ones keeps
6
+ migrations short and stable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import dataclasses
12
+ from typing import Any
13
+
14
+ __all__ = ["Deconstructible"]
15
+
16
+
17
+ class Deconstructible:
18
+ """Mixin for frozen dataclasses: ``deconstruct() -> (path, args, kwargs)``."""
19
+
20
+ __slots__ = ()
21
+
22
+ #: Public import path prefix used in migrations instead of the private module.
23
+ _deconstruct_module: str | None = None
24
+
25
+ def deconstruct(self) -> tuple[str, tuple[Any, ...], dict[str, Any]]:
26
+ if not dataclasses.is_dataclass(self): # pragma: no cover - programming error
27
+ raise TypeError(f"{type(self).__name__} must be a dataclass")
28
+ kwargs: dict[str, Any] = {}
29
+ for f in dataclasses.fields(self):
30
+ if not f.init or not f.repr:
31
+ continue
32
+ value = getattr(self, f.name)
33
+ if f.default is not dataclasses.MISSING and value == f.default:
34
+ continue
35
+ if f.default_factory is not dataclasses.MISSING and value == f.default_factory():
36
+ continue
37
+ kwargs[f.name] = value
38
+ module = self._deconstruct_module or type(self).__module__
39
+ return f"{module}.{type(self).__qualname__}", (), kwargs
@@ -0,0 +1,79 @@
1
+ """Extension points: validation checks and sanitizers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, Any, ClassVar, Literal
8
+
9
+ from ._deconstruct import Deconstructible
10
+ from .violations import Violation
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Iterable
14
+
15
+ from .upload import InspectedFile
16
+
17
+ __all__ = ["Check", "Replacement", "Sanitizer", "Stage"]
18
+
19
+ #: What a check needs to see: the name only, the byte count, the first 8 KiB, or
20
+ #: the complete content. Streaming integrations run the early stages while the
21
+ #: upload is still arriving.
22
+ Stage = Literal["name", "size", "type", "content"]
23
+
24
+
25
+ class _Component(Deconstructible):
26
+ __slots__ = ()
27
+
28
+ #: Short identifier used in violations, logs and ``checks_run``.
29
+ name: ClassVar[str] = "component"
30
+ #: Importable modules this component needs (e.g. ``("PIL",)``).
31
+ requires: ClassVar[tuple[str, ...]] = ()
32
+ #: The earliest point at which the component can run (see :data:`Stage`).
33
+ stage: ClassVar[Stage] = "content"
34
+
35
+ def applies(self, file: InspectedFile) -> bool:
36
+ """Whether this component is relevant for ``file`` (e.g. only for PDFs)."""
37
+ return True
38
+
39
+ @classmethod
40
+ def missing_dependencies(cls) -> tuple[str, ...]:
41
+ return tuple(m for m in cls.requires if importlib.util.find_spec(m) is None)
42
+
43
+
44
+ class Check(_Component):
45
+ """A single, focused validation rule.
46
+
47
+ Subclasses are frozen dataclasses whose fields are the rule's parameters. ``run``
48
+ yields zero or more :class:`Violation` objects and must not modify the file.
49
+ Exceptions raised by a check are treated as a failed verification (fail closed).
50
+ """
51
+
52
+ __slots__ = ()
53
+
54
+ def run(self, file: InspectedFile) -> Iterable[Violation]:
55
+ raise NotImplementedError
56
+
57
+ def violation(self, code: str, message: str, **details: Any) -> Violation:
58
+ return Violation(code=code, message=message, check=self.name, details=details)
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class Replacement:
63
+ """What a sanitizer changed. ``None`` fields are left as they were."""
64
+
65
+ data: bytes | None = None
66
+ name: str | None = None
67
+
68
+
69
+ class Sanitizer(_Component):
70
+ """Transforms an already *validated* file into a safer equivalent.
71
+
72
+ Examples: re-encoding images to drop metadata and trailing payloads, or replacing
73
+ the client-chosen filename. Sanitizers run only after every check has passed.
74
+ """
75
+
76
+ __slots__ = ()
77
+
78
+ def sanitize(self, file: InspectedFile) -> Replacement:
79
+ raise NotImplementedError