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,80 @@
1
+ """The ``--json`` document: one object with every file, a summary and the exit status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from uploadshield.web import json_safe
9
+
10
+ from ._verdicts import Verdict
11
+
12
+ if TYPE_CHECKING:
13
+ from ._verdicts import Outcome, Summary
14
+
15
+ __all__ = ["SCHEMA", "document", "dumps", "json_safe"]
16
+
17
+ #: Bumped when a field changes meaning or disappears; new fields do not bump it.
18
+ SCHEMA = 1
19
+
20
+
21
+ def file_entry(outcome: Outcome) -> dict[str, Any]:
22
+ entry: dict[str, Any] = {"path": outcome.display, "verdict": outcome.verdict.value}
23
+ if outcome.skipped is not None:
24
+ return {"path": outcome.display, "verdict": None, "skipped": outcome.skipped}
25
+ if outcome.error is not None:
26
+ entry["error"] = outcome.error
27
+ if outcome.result is not None:
28
+ entry["sha256"] = outcome.sha256
29
+ result = outcome.result
30
+ entry |= result.as_dict(include_details=True)
31
+ entry.pop("field", None) # always None outside a web request
32
+ for error, verdict in zip(entry["errors"], outcome.verdicts, strict=True):
33
+ error["verdict"] = verdict.value
34
+ if (sanitized := result.sanitized) is not None:
35
+ entry["sanitized_as"] = {
36
+ "name": sanitized.name,
37
+ "content_type": sanitized.content_type,
38
+ "size": len(sanitized.data) if sanitized.data is not None else result.size,
39
+ "content_changed": sanitized.data is not None,
40
+ }
41
+ if outcome.saved is not None:
42
+ entry["saved"] = outcome.saved
43
+ if outcome.save_error is not None:
44
+ entry["save_error"] = outcome.save_error
45
+ return entry
46
+
47
+
48
+ def document(
49
+ *,
50
+ command: str,
51
+ policy: str | None,
52
+ outcomes: list[Outcome],
53
+ summary: Summary,
54
+ warnings: list[str],
55
+ interrupted: bool,
56
+ exit_code: int,
57
+ ) -> dict[str, Any]:
58
+ from uploadshield import __version__
59
+
60
+ return {
61
+ "schema": SCHEMA,
62
+ "version": __version__,
63
+ "command": command,
64
+ "policy": policy,
65
+ "files": [file_entry(o) for o in outcomes],
66
+ "summary": {
67
+ "files": summary.files,
68
+ **{verdict.value: summary.counts[verdict] for verdict in Verdict},
69
+ "skipped": sum(summary.skipped.values()),
70
+ "seconds": round(summary.seconds, 3),
71
+ },
72
+ "warnings": warnings,
73
+ "interrupted": interrupted,
74
+ "exit_code": exit_code,
75
+ }
76
+
77
+
78
+ def dumps(data: dict[str, Any]) -> str:
79
+ # ASCII escapes keep undecodable names (lone surrogates) valid and printable.
80
+ return json.dumps(json_safe(data), indent=2, ensure_ascii=True)
@@ -0,0 +1,319 @@
1
+ """Human-readable output for ``scan`` and ``validate``, and the ``--json`` reporter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any, Protocol, TextIO
8
+
9
+ from uploadshield.core.types import format_size
10
+ from uploadshield.core.violations import Code
11
+
12
+ from ._json import document, dumps
13
+ from ._style import Style, safe
14
+ from ._verdicts import Verdict
15
+
16
+ if TYPE_CHECKING:
17
+ from uploadshield.core.check import Check, Sanitizer
18
+ from uploadshield.core.result import ValidationResult
19
+ from uploadshield.policy import Policy
20
+
21
+ from ._verdicts import Outcome, Summary
22
+
23
+ __all__ = ["HumanReporter", "JsonReporter", "Reporter", "missing_dependency"]
24
+
25
+ _COLORS = {
26
+ Verdict.CLEAN: "green",
27
+ Verdict.REFUSED: "yellow",
28
+ Verdict.NOT_VERIFIED: "magenta",
29
+ Verdict.DANGEROUS: "red",
30
+ }
31
+ #: Details worth a glance in a scan (``validate`` shows all of them).
32
+ _HIGHLIGHTS = (
33
+ "keywords",
34
+ "members",
35
+ "found",
36
+ "marker",
37
+ "signature",
38
+ "rules",
39
+ "magika_label",
40
+ "read_as",
41
+ )
42
+ #: Optional dependencies without which a component silently does nothing.
43
+ _NEEDS: dict[str, tuple[str, str]] = {
44
+ "ImageIntegrity": ("PIL", "images"),
45
+ "ReencodeImage": ("PIL", "images"),
46
+ "MagikaCheck": ("magika", "magika"),
47
+ "YaraRules": ("yara", "yara"),
48
+ }
49
+
50
+
51
+ def missing_dependency(component: Check | Sanitizer) -> str | None:
52
+ """The extra a check or sanitizer needs but cannot find (it is then inactive)."""
53
+ needed = _NEEDS.get(type(component).__name__)
54
+ if needed is None or importlib.util.find_spec(needed[0]) is not None:
55
+ return None
56
+ return needed[1]
57
+
58
+
59
+ class Reporter(Protocol):
60
+ def file(self, outcome: Outcome) -> None: ...
61
+ def warn(self, text: str) -> None: ...
62
+ def finish(self, summary: Summary, *, interrupted: bool, exit_code: int) -> None: ...
63
+
64
+
65
+ class JsonReporter:
66
+ """Collects everything and prints one document at the end."""
67
+
68
+ def __init__(self, out: TextIO, *, command: str, policy: str | None) -> None:
69
+ self.out = out
70
+ self.command = command
71
+ self.policy = policy
72
+ self.outcomes: list[Outcome] = []
73
+ self.warnings: list[str] = []
74
+
75
+ def file(self, outcome: Outcome) -> None:
76
+ self.outcomes.append(outcome)
77
+
78
+ def warn(self, text: str) -> None:
79
+ self.warnings.append(text)
80
+
81
+ def finish(self, summary: Summary, *, interrupted: bool, exit_code: int) -> None:
82
+ data = document(
83
+ command=self.command,
84
+ policy=self.policy,
85
+ outcomes=self.outcomes,
86
+ summary=summary,
87
+ warnings=self.warnings,
88
+ interrupted=interrupted,
89
+ exit_code=exit_code,
90
+ )
91
+ print(dumps(data), file=self.out)
92
+
93
+
94
+ class HumanReporter:
95
+ """Prints each file as it is inspected: a line per file (scan) or a trace (validate)."""
96
+
97
+ def __init__(
98
+ self,
99
+ out: TextIO,
100
+ err: TextIO,
101
+ style: Style,
102
+ *,
103
+ policy: Policy,
104
+ detailed: bool,
105
+ quiet: bool,
106
+ ) -> None:
107
+ self.out, self.err, self.s = out, err, style
108
+ self.policy = policy
109
+ self.detailed = detailed
110
+ self.quiet = quiet
111
+ self._root: str | None = None
112
+
113
+ # -- per file ---------------------------------------------------------------------
114
+
115
+ def file(self, outcome: Outcome) -> None:
116
+ if outcome.skipped is not None:
117
+ if not self.quiet:
118
+ self._heading(outcome)
119
+ line = f" - {safe(outcome.label)} skipped: {outcome.skipped}"
120
+ self._print(self.s.dim(line))
121
+ return
122
+ if self.quiet and outcome.verdict is Verdict.CLEAN and outcome.save_error is None:
123
+ return
124
+ self._heading(outcome)
125
+ if self.detailed:
126
+ self._trace(outcome)
127
+ else:
128
+ self._line(outcome)
129
+
130
+ def _heading(self, outcome: Outcome) -> None:
131
+ """Files found in a directory are listed under it, relative to it."""
132
+ if outcome.root != self._root:
133
+ self._root = outcome.root
134
+ if outcome.root is not None:
135
+ self._print(self.s.bold(safe(outcome.root.rstrip("/\\") + "/")))
136
+
137
+ def _glyph(self, verdict: Verdict) -> str:
138
+ glyph = {
139
+ Verdict.CLEAN: self.s.ok,
140
+ Verdict.REFUSED: "!",
141
+ Verdict.NOT_VERIFIED: "?",
142
+ Verdict.DANGEROUS: self.s.bad,
143
+ }[verdict]
144
+ return self.s.paint(glyph, _COLORS[verdict], "bold")
145
+
146
+ def _describe(self, result: ValidationResult) -> str:
147
+ kind = result.file_type.label if result.file_type is not None else "unknown format"
148
+ return f"{kind} {self.s.dot} {format_size(result.size)}"
149
+
150
+ def _line(self, outcome: Outcome) -> None:
151
+ name = self.s.bold(safe(outcome.label))
152
+ if outcome.result is None:
153
+ reason = outcome.error or "could not be read"
154
+ self._print(f" {self._glyph(outcome.verdict)} {name} not verified: {reason}")
155
+ return
156
+ result = outcome.result
157
+ self._print(
158
+ f" {self._glyph(outcome.verdict)} {name} {self.s.dim(self._describe(result))}"
159
+ )
160
+ for violation, verdict in zip(result.violations, outcome.verdicts, strict=True):
161
+ # A line per file shows what makes it this verdict; --json lists everything.
162
+ if verdict is not outcome.verdict:
163
+ continue
164
+ message = violation.message
165
+ if violation.code == Code.TYPE_NOT_ALLOWED and verdict is Verdict.NOT_VERIFIED:
166
+ message = (
167
+ "Its contents cannot be inspected: unpack it and scan the files, "
168
+ "or add --clamav."
169
+ )
170
+ extra = _highlights(violation.details, self.s.ellipsis)
171
+ code = self.s.paint(str(violation.code), _COLORS[verdict])
172
+ self._print(f" {code} {safe(message)}{self.s.dim(extra)}")
173
+
174
+ def _trace(self, outcome: Outcome) -> None:
175
+ s = self.s
176
+ name = s.bold(safe(outcome.label))
177
+ policy = f'"{self.policy.name or "custom"}"'
178
+ if outcome.result is None:
179
+ self._print(f"{self._glyph(outcome.verdict)} {name} not verified: {outcome.error}")
180
+ self._print("")
181
+ return
182
+ result = outcome.result
183
+ self._print(f"{self._glyph(outcome.verdict)} {name} {self._head(outcome, policy)}")
184
+ if result.name != Path(outcome.label).name:
185
+ self._row("as", safe(result.name))
186
+ detected = (
187
+ f"{result.file_type.label} ({result.file_type.mime}), {format_size(result.size)}"
188
+ if result.file_type is not None
189
+ else f"unknown format, {format_size(result.size)}"
190
+ )
191
+ self._row("detected", detected)
192
+ if result.declared_type:
193
+ declared = safe(result.declared_type)
194
+ if not self.policy.match_declared_type:
195
+ declared += s.dim(" (not compared: the policy has match_declared_type=False)")
196
+ self._row("declared", declared)
197
+ self._checks(result)
198
+ self._violations(outcome)
199
+ if result.sanitized is not None:
200
+ sanitized = result.sanitized
201
+ size = len(sanitized.data) if sanitized.data is not None else result.size
202
+ changed = "renamed only" if sanitized.data is None else "rewritten"
203
+ self._row(
204
+ "sanitized",
205
+ f"{', '.join(sanitized.applied)} {s.arrow} {safe(sanitized.name)} "
206
+ f"({sanitized.content_type}, {format_size(size)}, {changed})",
207
+ )
208
+ if outcome.saved is not None:
209
+ self._row("saved", safe(outcome.saved))
210
+ if outcome.save_error is not None:
211
+ self._row("not saved", s.paint(outcome.save_error, "red"))
212
+ if not result.ok:
213
+ codes = " ".join(dict.fromkeys(str(v.code) for v in result.violations))
214
+ self._row("explain", s.dim(f"uploadshield explain {codes}"))
215
+ self._print("")
216
+
217
+ def _head(self, outcome: Outcome, policy: str) -> str:
218
+ s = self.s
219
+ if outcome.verdict is Verdict.CLEAN:
220
+ return s.paint(f"accepted by {policy}", "green", "bold")
221
+ head = s.paint(f"refused by {policy}", _COLORS[outcome.verdict], "bold")
222
+ if outcome.verdict in (Verdict.DANGEROUS, Verdict.NOT_VERIFIED):
223
+ head += s.paint(f" ({outcome.verdict.label})", _COLORS[outcome.verdict])
224
+ return head
225
+
226
+ def _violations(self, outcome: Outcome) -> None:
227
+ s = self.s
228
+ violations = outcome.result.violations if outcome.result is not None else ()
229
+ for violation, verdict in zip(violations, outcome.verdicts, strict=True):
230
+ code = s.paint(f"{self._glyph(verdict)} {violation.code}", _COLORS[verdict])
231
+ self._print(f" {code} {safe(violation.message)}")
232
+ for key, value in violation.details.items():
233
+ if value not in (None, "", [], ()):
234
+ self._print(s.dim(f" {key}: {_format(value, s.ellipsis, limit=300)}"))
235
+
236
+ def _checks(self, result: ValidationResult) -> None:
237
+ s = self.s
238
+ failed = {v.check for v in result.violations}
239
+ ran: list[str] = []
240
+ idle: list[str] = []
241
+ not_run: list[str] = []
242
+ inactive: list[str] = []
243
+ stopped = False
244
+ for check in self.policy.pipeline:
245
+ if check.name in result.checks_run:
246
+ if check.name in failed:
247
+ ran.append(s.paint(f"{s.bad} {check.name}", "red"))
248
+ stopped = self.policy.fail_fast
249
+ else:
250
+ ran.append(s.paint(f"{s.ok} {check.name}", "green"))
251
+ elif stopped:
252
+ not_run.append(check.name)
253
+ elif (extra := missing_dependency(check)) is not None:
254
+ inactive.append(f"{check.name} (needs uploadshield[{extra}])")
255
+ else:
256
+ idle.append(check.name)
257
+ self._row("checks", " ".join(ran) or "none")
258
+ if idle:
259
+ self._row("", s.dim(f"not applicable: {', '.join(idle)}"))
260
+ if not_run:
261
+ self._row("", s.dim(f"not run after the first failure: {', '.join(not_run)}"))
262
+ self._row("", s.dim(" (--all-checks runs every check)"))
263
+ if inactive:
264
+ self._row("", s.paint(f"inactive: {', '.join(inactive)}", "yellow"))
265
+
266
+ # -- summary ----------------------------------------------------------------------
267
+
268
+ def warn(self, text: str) -> None:
269
+ print(self.s.paint(f"warning: {text}", "yellow"), file=self.err)
270
+
271
+ def finish(self, summary: Summary, *, interrupted: bool, exit_code: int) -> None:
272
+ s = self.s
273
+ parts = [
274
+ s.paint(f"{summary.counts[v]} {v.label}", _COLORS[v])
275
+ for v in (Verdict.CLEAN, Verdict.DANGEROUS, Verdict.REFUSED, Verdict.NOT_VERIFIED)
276
+ if summary.counts[v] or v is Verdict.CLEAN
277
+ ]
278
+ files = f"{summary.files} file{'' if summary.files == 1 else 's'}"
279
+ line = f"{files} in {summary.seconds:.1f} s: {', '.join(parts)}"
280
+ if skipped := sum(summary.skipped.values()):
281
+ reasons = ", ".join(f"{n} {why}" for why, n in sorted(summary.skipped.items()))
282
+ line += s.dim(f"; {skipped} skipped ({reasons})")
283
+ if not self.detailed or summary.files > 1 or interrupted:
284
+ self._print(("" if self.detailed else "\n") + s.bold(line + "."))
285
+ if interrupted:
286
+ self._print(
287
+ s.paint("Interrupted: the files above are all that was inspected.", "yellow")
288
+ )
289
+ elif exit_code == 1 and not self.detailed:
290
+ self._print(s.dim("What a code means: uploadshield explain <code>"))
291
+
292
+ # -- helpers ----------------------------------------------------------------------
293
+
294
+ def _row(self, label: str, value: str) -> None:
295
+ self._print(f" {self.s.dim(f'{label:<10}')} {value}")
296
+
297
+ def _print(self, text: str) -> None:
298
+ print(text, file=self.out)
299
+
300
+
301
+ def _highlights(details: Any, ellipsis: str) -> str:
302
+ shown = [
303
+ _format(details[key], ellipsis, limit=80)
304
+ for key in _HIGHLIGHTS
305
+ if details.get(key) not in (None, "", [], ())
306
+ ]
307
+ if details.get("in_sanitized_output"):
308
+ shown.append("in the sanitized output")
309
+ return f" {'; '.join(shown)}" if shown else ""
310
+
311
+
312
+ def _format(value: Any, ellipsis: str, *, limit: int) -> str:
313
+ if isinstance(value, (list, tuple, set, frozenset)):
314
+ items = sorted(value, key=str) if isinstance(value, (set, frozenset)) else value
315
+ text = ", ".join(str(v) for v in items)
316
+ else:
317
+ text = str(value)
318
+ text = safe(text)
319
+ return text if len(text) <= limit else text[: limit - 1] + ellipsis
@@ -0,0 +1,106 @@
1
+ """The policy behind ``uploadshield scan``, and the optional scanners."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import importlib.util
7
+ from typing import TYPE_CHECKING
8
+
9
+ from uploadshield.checks import (
10
+ DEFAULT_CONTENT_CHECKS,
11
+ ClamAV,
12
+ EmbeddedContent,
13
+ FilenameCheck,
14
+ MagikaCheck,
15
+ YaraRules,
16
+ )
17
+ from uploadshield.checks.scanners import clamd_ping
18
+ from uploadshield.core.types import all_types
19
+ from uploadshield.policy import Policy
20
+
21
+ if TYPE_CHECKING:
22
+ from uploadshield.core.check import Check
23
+
24
+ __all__ = ["SetupError", "scan_policy", "scanner_checks"]
25
+
26
+ #: Formats a scan refuses outright: dangerous to accept whatever they contain.
27
+ ACTIVE_TYPES = frozenset({"text/html"})
28
+ #: Archives whose members the checks cannot see (only ZIP containers are opened).
29
+ OPAQUE_ARCHIVES = frozenset(
30
+ {
31
+ "application/gzip",
32
+ "application/x-tar",
33
+ "application/x-bzip2",
34
+ "application/x-xz",
35
+ "application/x-7z-compressed",
36
+ "application/vnd.rar",
37
+ }
38
+ )
39
+
40
+
41
+ class SetupError(Exception):
42
+ """The command cannot run as asked (exit status 2)."""
43
+
44
+
45
+ def scan_policy(extra_checks: tuple[Check, ...] = ()) -> Policy:
46
+ """Accept every format uploadshield can inspect, and judge each one by its content.
47
+
48
+ Built when the command runs, so formats an application registered count too.
49
+ Executables and HTML are refused (they are dangerous as uploads whatever they
50
+ contain); archives it cannot open are reported as not verified. Names are judged
51
+ for what makes them dangerous - not for being hidden or lacking an extension - and
52
+ there is no size limit: files too large for a check are reported, not skipped.
53
+ """
54
+ allowed = {
55
+ t.mime
56
+ for t in all_types()
57
+ if not t.executable and t.mime not in ACTIVE_TYPES | OPAQUE_ARCHIVES
58
+ }
59
+ content_checks = tuple(
60
+ dataclasses.replace(check, max_scan_bytes=1 << 62)
61
+ if isinstance(check, EmbeddedContent)
62
+ else check
63
+ for check in DEFAULT_CONTENT_CHECKS
64
+ )
65
+ return Policy(
66
+ name="scan",
67
+ allowed_types=allowed,
68
+ max_size=None,
69
+ allow_empty=True,
70
+ filename=FilenameCheck(allow_hidden=True, require_extension=False),
71
+ content_checks=content_checks,
72
+ checks=extra_checks,
73
+ fail_fast=False,
74
+ )
75
+
76
+
77
+ def scanner_checks(
78
+ *, clamav: str | None, yara: list[str] | None, magika: bool
79
+ ) -> tuple[Check, ...]:
80
+ """The optional scanners, verified before the first file is read."""
81
+ checks: list[Check] = []
82
+ if clamav:
83
+ if not clamd_ping(clamav):
84
+ raise SetupError(f"clamd does not answer at {clamav} (is it running?)")
85
+ checks.append(ClamAV(address=clamav))
86
+ if yara:
87
+ _require("yara", "yara")
88
+ rules = YaraRules(rules=tuple(yara))
89
+ try:
90
+ rules.compile()
91
+ except Exception as exc:
92
+ raise SetupError(f"YARA rules do not compile: {exc}") from exc
93
+ checks.append(rules)
94
+ if magika:
95
+ _require("magika", "magika")
96
+ checks.append(MagikaCheck())
97
+ return tuple(checks)
98
+
99
+
100
+ def _require(module: str, extra: str) -> None:
101
+ if importlib.util.find_spec(module) is None:
102
+ raise SetupError(
103
+ f"--{extra} needs the {extra} extra: "
104
+ f'uvx --from "uploadshield[{extra}]" uploadshield ..., '
105
+ f"or uv add 'uploadshield[{extra}]' in your project"
106
+ )
@@ -0,0 +1,95 @@
1
+ """Terminal styling: colour decisions, glyphs, and escaping of untrusted names."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import unicodedata
8
+ from typing import TextIO
9
+
10
+ __all__ = ["Style", "color_enabled", "safe"]
11
+
12
+ _BIDI_CONTROLS = frozenset(
13
+ "\u061c\u200e\u200f\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069"
14
+ )
15
+ _CODES = {
16
+ "bold": "1",
17
+ "dim": "2",
18
+ "red": "31",
19
+ "green": "32",
20
+ "yellow": "33",
21
+ "blue": "34",
22
+ "magenta": "35",
23
+ "cyan": "36",
24
+ }
25
+
26
+
27
+ def safe(text: str) -> str:
28
+ """``text`` with everything that could act on a terminal made visible.
29
+
30
+ File names are attacker-controlled: an ESC byte would let a name rewrite the
31
+ screen, CR/LF would forge lines, bidi overrides would reverse what is shown, and
32
+ undecodable bytes (lone surrogates) would crash the output stream.
33
+ """
34
+ if text.isprintable() and not _BIDI_CONTROLS.intersection(text):
35
+ return text
36
+ out: list[str] = []
37
+ for char in text:
38
+ if char in _BIDI_CONTROLS or unicodedata.category(char) in ("Cc", "Cs"):
39
+ point = ord(char)
40
+ out.append(f"\\x{point:02x}" if point < 0x100 else f"\\u{point:04x}")
41
+ else:
42
+ out.append(char)
43
+ return "".join(out)
44
+
45
+
46
+ def color_enabled(choice: str, stream: TextIO) -> bool:
47
+ """``--color`` first, then ``NO_COLOR``, ``FORCE_COLOR``, and finally a TTY check."""
48
+ if choice != "auto":
49
+ return choice == "always"
50
+ if os.environ.get("NO_COLOR"):
51
+ return False
52
+ if os.environ.get("FORCE_COLOR", "0") not in ("", "0"):
53
+ return True
54
+ if os.environ.get("TERM") == "dumb" or not _isatty(stream):
55
+ return False
56
+ return sys.platform != "win32" or "WT_SESSION" in os.environ
57
+
58
+
59
+ def _isatty(stream: TextIO) -> bool:
60
+ try:
61
+ return stream.isatty()
62
+ except (AttributeError, ValueError):
63
+ return False
64
+
65
+
66
+ def _can_encode(stream: TextIO, text: str) -> bool:
67
+ try:
68
+ text.encode(getattr(stream, "encoding", None) or "ascii")
69
+ except (UnicodeEncodeError, LookupError):
70
+ return False
71
+ return True
72
+
73
+
74
+ class Style:
75
+ """Colours and glyphs for one output stream (plain text when colour is off)."""
76
+
77
+ def __init__(self, stream: TextIO, *, color: bool) -> None:
78
+ self.color = color
79
+ fancy = _can_encode(stream, "\u2713\u2717\u2192\u00b7\u2026\u2500")
80
+ self.ok, self.bad = ("\u2713", "\u2717") if fancy else ("OK", "X")
81
+ self.arrow = "\u2192" if fancy else "->"
82
+ self.dot = "\u00b7" if fancy else "-"
83
+ self.ellipsis = "\u2026" if fancy else "..."
84
+
85
+ def paint(self, text: str, *styles: str) -> str:
86
+ if not self.color or not styles or not text:
87
+ return text
88
+ codes = ";".join(_CODES[s] for s in styles)
89
+ return f"\x1b[{codes}m{text}\x1b[0m"
90
+
91
+ def bold(self, text: str) -> str:
92
+ return self.paint(text, "bold")
93
+
94
+ def dim(self, text: str) -> str:
95
+ return self.paint(text, "dim")