pentool 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.
Files changed (133) hide show
  1. pentool/__init__.py +4 -0
  2. pentool/__main__.py +16 -0
  3. pentool/api/__init__.py +41 -0
  4. pentool/api/base_api.py +48 -0
  5. pentool/api/comparer_api.py +19 -0
  6. pentool/api/decoder_api.py +24 -0
  7. pentool/api/intruder_api.py +171 -0
  8. pentool/api/proxy_api.py +210 -0
  9. pentool/api/repeater_api.py +57 -0
  10. pentool/api/scanner_api.py +266 -0
  11. pentool/api/sequencer_api.py +17 -0
  12. pentool/api/spider_api.py +106 -0
  13. pentool/api/target_api.py +87 -0
  14. pentool/cli/__init__.py +1 -0
  15. pentool/cli/main.py +98 -0
  16. pentool/cli/project.py +88 -0
  17. pentool/cli/proxy.py +173 -0
  18. pentool/cli/scan.py +115 -0
  19. pentool/core/__init__.py +1 -0
  20. pentool/core/config.py +171 -0
  21. pentool/core/crash_reporter.py +75 -0
  22. pentool/core/database.py +139 -0
  23. pentool/core/event_bus.py +207 -0
  24. pentool/core/events.py +160 -0
  25. pentool/core/features.py +373 -0
  26. pentool/core/license.py +274 -0
  27. pentool/core/logging.py +54 -0
  28. pentool/core/plugin_manager.py +312 -0
  29. pentool/core/project.py +31 -0
  30. pentool/core/storage_interface.py +307 -0
  31. pentool/core/updater.py +96 -0
  32. pentool/core/utils.py +36 -0
  33. pentool/modules/__init__.py +1 -0
  34. pentool/modules/comparer.py +177 -0
  35. pentool/modules/decoder.py +281 -0
  36. pentool/modules/intruder.py +428 -0
  37. pentool/modules/intruder_turbo.py +176 -0
  38. pentool/modules/match_replace.py +142 -0
  39. pentool/modules/proxy.py +797 -0
  40. pentool/modules/repeater.py +195 -0
  41. pentool/modules/sequencer.py +492 -0
  42. pentool/modules/spider.py +679 -0
  43. pentool/modules/target.py +185 -0
  44. pentool/modules/websocket_handler.py +249 -0
  45. pentool/plugins/__init__.py +1 -0
  46. pentool/plugins/builtin/__init__.py +1 -0
  47. pentool/plugins/builtin/payloads_pro.py +381 -0
  48. pentool/plugins/builtin/reports_pro.py +436 -0
  49. pentool/plugins/builtin/scanner_pro.py +291 -0
  50. pentool/plugins/example_plugin.py +39 -0
  51. pentool/services/__init__.py +1 -0
  52. pentool/services/base_service.py +60 -0
  53. pentool/services/intruder_service.py +94 -0
  54. pentool/services/proxy_service.py +178 -0
  55. pentool/services/repeater_service.py +85 -0
  56. pentool/services/scan_service.py +330 -0
  57. pentool/storage/__init__.py +0 -0
  58. pentool/storage/http_storage.py +506 -0
  59. pentool/storage/large_body_handler.py +50 -0
  60. pentool/storage/lru_cache.py +45 -0
  61. pentool/t.py +213 -0
  62. pentool/tui/__init__.py +1 -0
  63. pentool/tui/app.py +1429 -0
  64. pentool/tui/constants.py +79 -0
  65. pentool/tui/dialogs/__init__.py +0 -0
  66. pentool/tui/dialogs/cert_dialog.py +81 -0
  67. pentool/tui/dialogs/file_selector.py +226 -0
  68. pentool/tui/dialogs/load_from_proxy.py +125 -0
  69. pentool/tui/dialogs/match_replace_dialog.py +229 -0
  70. pentool/tui/dialogs/project_dialog.py +82 -0
  71. pentool/tui/dialogs/scope_dialog.py +224 -0
  72. pentool/tui/messages.py +103 -0
  73. pentool/tui/mixins/__init__.py +0 -0
  74. pentool/tui/mixins/app_mixin.py +59 -0
  75. pentool/tui/mixins/dialog_cancel.py +21 -0
  76. pentool/tui/mixins/request_context_menu.py +247 -0
  77. pentool/tui/mixins/tab_rename.py +89 -0
  78. pentool/tui/screens/__init__.py +31 -0
  79. pentool/tui/screens/comparer/__init__.py +3 -0
  80. pentool/tui/screens/comparer/screen.py +229 -0
  81. pentool/tui/screens/dashboard/__init__.py +2 -0
  82. pentool/tui/screens/dashboard/live_dashboard.py +730 -0
  83. pentool/tui/screens/dashboard/screen.py +771 -0
  84. pentool/tui/screens/decoder/__init__.py +3 -0
  85. pentool/tui/screens/decoder/screen.py +306 -0
  86. pentool/tui/screens/extensions/__init__.py +3 -0
  87. pentool/tui/screens/extensions/screen.py +24 -0
  88. pentool/tui/screens/intruder/__init__.py +3 -0
  89. pentool/tui/screens/intruder/screen.py +1356 -0
  90. pentool/tui/screens/proxy/__init__.py +3 -0
  91. pentool/tui/screens/proxy/screen.py +1554 -0
  92. pentool/tui/screens/repeater/__init__.py +3 -0
  93. pentool/tui/screens/repeater/screen.py +621 -0
  94. pentool/tui/screens/scanner/__init__.py +3 -0
  95. pentool/tui/screens/scanner/screen.py +1956 -0
  96. pentool/tui/screens/sequencer/__init__.py +3 -0
  97. pentool/tui/screens/sequencer/screen.py +516 -0
  98. pentool/tui/screens/settings/__init__.py +5 -0
  99. pentool/tui/screens/settings/hotkeys.py +134 -0
  100. pentool/tui/screens/settings/screen.py +540 -0
  101. pentool/tui/screens/spider/__init__.py +3 -0
  102. pentool/tui/screens/spider/screen.py +380 -0
  103. pentool/tui/screens/target/__init__.py +4 -0
  104. pentool/tui/screens/target/screen.py +358 -0
  105. pentool/tui/screens/terminal/__init__.py +5 -0
  106. pentool/tui/screens/terminal/screen.py +179 -0
  107. pentool/tui/widgets/__init__.py +1 -0
  108. pentool/tui/widgets/context_menu.py +148 -0
  109. pentool/tui/widgets/filter_bar.py +204 -0
  110. pentool/tui/widgets/inspector_panel.py +111 -0
  111. pentool/tui/widgets/menu.py +86 -0
  112. pentool/tui/widgets/menu_bar.py +238 -0
  113. pentool/tui/widgets/module_tabs.py +68 -0
  114. pentool/tui/widgets/payload_drop_zone.py +65 -0
  115. pentool/tui/widgets/request_editor.py +495 -0
  116. pentool/tui/widgets/resize_handle.py +116 -0
  117. pentool/tui/widgets/search_bar.py +112 -0
  118. pentool/tui/widgets/statusbar.py +96 -0
  119. pentool/tui/widgets/toolbar_button.py +68 -0
  120. pentool/utils/__init__.py +1 -0
  121. pentool/utils/cert.py +314 -0
  122. pentool/utils/coder.py +170 -0
  123. pentool/utils/copy_as.py +250 -0
  124. pentool/utils/diff.py +82 -0
  125. pentool/utils/http_client.py +145 -0
  126. pentool/utils/parser.py +227 -0
  127. pentool/utils/terminal_check.py +31 -0
  128. pentool-0.1.0.dist-info/METADATA +231 -0
  129. pentool-0.1.0.dist-info/RECORD +133 -0
  130. pentool-0.1.0.dist-info/WHEEL +5 -0
  131. pentool-0.1.0.dist-info/entry_points.txt +2 -0
  132. pentool-0.1.0.dist-info/licenses/LICENSE +34 -0
  133. pentool-0.1.0.dist-info/top_level.txt +1 -0
pentool/utils/cert.py ADDED
@@ -0,0 +1,314 @@
1
+ """Генерация и управление TLS-сертификатами для HTTPS-перехвата прокси."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import os
7
+ import ssl
8
+ import tempfile
9
+ from datetime import datetime, timedelta, timezone
10
+ from pathlib import Path
11
+
12
+ from cryptography import x509
13
+ from cryptography.hazmat.primitives import hashes, serialization
14
+ from cryptography.hazmat.primitives.asymmetric import rsa
15
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
16
+ from cryptography.x509 import Certificate
17
+ from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
18
+
19
+
20
+ def _generate_rsa_key(key_size: int = 2048) -> RSAPrivateKey:
21
+ return rsa.generate_private_key(public_exponent=65537, key_size=key_size)
22
+
23
+
24
+ def _key_to_pem(key: RSAPrivateKey) -> bytes:
25
+ """Сериализовать приватный ключ в PEM без пароля."""
26
+ return key.private_bytes(
27
+ encoding=serialization.Encoding.PEM,
28
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
29
+ encryption_algorithm=serialization.NoEncryption(),
30
+ )
31
+
32
+
33
+ def _cert_to_pem(cert: Certificate) -> bytes:
34
+ """Сериализовать сертификат в PEM."""
35
+ return cert.public_bytes(serialization.Encoding.PEM)
36
+
37
+
38
+ def generate_ca_cert(cert_dir: str) -> tuple[str, str]:
39
+ dir_path = Path(cert_dir)
40
+ dir_path.mkdir(parents=True, exist_ok=True)
41
+ # Устанавливаем права 700 для директории с сертификатами
42
+ dir_path.chmod(0o700)
43
+
44
+ cert_path = dir_path / "ca.crt"
45
+ key_path = dir_path / "ca.key"
46
+
47
+ if cert_path.exists() and key_path.exists():
48
+ return str(cert_path), str(key_path)
49
+
50
+ key = _generate_rsa_key(4096)
51
+ now = datetime.now(timezone.utc)
52
+
53
+ subject = issuer = x509.Name([
54
+ x509.NameAttribute(NameOID.COMMON_NAME, "Pentool CA"),
55
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Pentool"),
56
+ x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Security Testing"),
57
+ ])
58
+
59
+ cert = (
60
+ x509.CertificateBuilder()
61
+ .subject_name(subject)
62
+ .issuer_name(issuer)
63
+ .public_key(key.public_key())
64
+ .serial_number(x509.random_serial_number())
65
+ .not_valid_before(now)
66
+ .not_valid_after(now + timedelta(days=3650)) # 10 лет
67
+ .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
68
+ .add_extension(
69
+ x509.KeyUsage(
70
+ digital_signature=True,
71
+ content_commitment=False,
72
+ key_encipherment=False,
73
+ data_encipherment=False,
74
+ key_agreement=False,
75
+ key_cert_sign=True,
76
+ crl_sign=True,
77
+ encipher_only=False,
78
+ decipher_only=False,
79
+ ),
80
+ critical=True,
81
+ )
82
+ .add_extension(
83
+ x509.SubjectKeyIdentifier.from_public_key(key.public_key()),
84
+ critical=False,
85
+ )
86
+ .sign(key, hashes.SHA256())
87
+ )
88
+
89
+ cert_path.write_bytes(_cert_to_pem(cert))
90
+ cert_path.chmod(0o644)
91
+
92
+ key_path.write_bytes(_key_to_pem(key))
93
+ key_path.chmod(0o600)
94
+
95
+ return str(cert_path), str(key_path)
96
+
97
+
98
+ def load_or_create_ca(cert_dir: str) -> tuple[str, str]:
99
+ return generate_ca_cert(cert_dir)
100
+
101
+
102
+ def generate_domain_cert(
103
+ domain: str,
104
+ ca_cert_path: str,
105
+ ca_key_path: str,
106
+ ) -> tuple[bytes, bytes]:
107
+ # Загрузить CA
108
+ ca_cert = x509.load_pem_x509_certificate(Path(ca_cert_path).read_bytes())
109
+ ca_key_data = Path(ca_key_path).read_bytes()
110
+ ca_key = serialization.load_pem_private_key(ca_key_data, password=None)
111
+
112
+ key = _generate_rsa_key(2048)
113
+ now = datetime.now(timezone.utc)
114
+
115
+ subject = x509.Name([
116
+ x509.NameAttribute(NameOID.COMMON_NAME, domain),
117
+ ])
118
+
119
+ # Определить SAN: IP или DNS
120
+ san_list: list[x509.GeneralName] = []
121
+ try:
122
+ san_list.append(x509.IPAddress(ipaddress.ip_address(domain)))
123
+ except ValueError:
124
+ san_list.append(x509.DNSName(domain))
125
+ # Добавить wildcard для поддоменов
126
+ if "." in domain and not domain.startswith("*."):
127
+ san_list.append(x509.DNSName(f"*.{domain}"))
128
+
129
+ cert = (
130
+ x509.CertificateBuilder()
131
+ .subject_name(subject)
132
+ .issuer_name(ca_cert.subject)
133
+ .public_key(key.public_key())
134
+ .serial_number(x509.random_serial_number())
135
+ .not_valid_before(now)
136
+ .not_valid_after(now + timedelta(days=825)) # ~2 года 3 месяца
137
+ .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
138
+ .add_extension(x509.SubjectAlternativeName(san_list), critical=False)
139
+ .add_extension(
140
+ x509.KeyUsage(
141
+ digital_signature=True,
142
+ content_commitment=False,
143
+ key_encipherment=True,
144
+ data_encipherment=False,
145
+ key_agreement=False,
146
+ key_cert_sign=False,
147
+ crl_sign=False,
148
+ encipher_only=False,
149
+ decipher_only=False,
150
+ ),
151
+ critical=True,
152
+ )
153
+ .add_extension(
154
+ x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]),
155
+ critical=False,
156
+ )
157
+ .sign(ca_key, hashes.SHA256())
158
+ )
159
+
160
+ return _cert_to_pem(cert), _key_to_pem(key)
161
+
162
+
163
+ def create_ssl_context_for_domain(
164
+ domain: str,
165
+ ca_cert_path: str,
166
+ ca_key_path: str,
167
+ cert_dir: str | None = None,
168
+ ) -> ssl.SSLContext:
169
+ # ── Уровень 1: in-memory LRU ──────────────────────────────────────────────
170
+ cache_key = f"{domain}:{ca_cert_path}"
171
+ ctx = _ssl_ctx_cache.get(cache_key)
172
+ if ctx is not None:
173
+ return ctx
174
+
175
+ # ── Уровень 2: disk-cache ─────────────────────────────────────────────────
176
+ ctx = _load_ctx_from_disk(domain, ca_cert_path, cert_dir)
177
+ if ctx is not None:
178
+ _ssl_ctx_cache.put(cache_key, ctx)
179
+ return ctx
180
+
181
+ # ── Генерация нового сертификата ──────────────────────────────────────────
182
+ cert_pem, key_pem = generate_domain_cert(domain, ca_cert_path, ca_key_path)
183
+
184
+ # Сохранить на диск (если cert_dir задан)
185
+ _save_ctx_to_disk(domain, cert_pem, key_pem, cert_dir)
186
+
187
+ ctx = _build_ssl_ctx(cert_pem, key_pem)
188
+ _ssl_ctx_cache.put(cache_key, ctx)
189
+ return ctx
190
+
191
+
192
+ # ── Вспомогательные функции для disk-cache ────────────────────────────────────
193
+
194
+ def _domain_cache_path(domain: str, cert_dir: str) -> Path:
195
+ """Путь к кэш-файлу для домена: {cert_dir}/domains/{sha256(domain)[:16]}.pem"""
196
+ import hashlib
197
+ key = hashlib.sha256(domain.encode()).hexdigest()[:16]
198
+ return Path(cert_dir) / "domains" / f"{key}.pem"
199
+
200
+
201
+ def _load_ctx_from_disk(
202
+ domain: str, ca_cert_path: str, cert_dir: str | None
203
+ ) -> ssl.SSLContext | None:
204
+ """Попытаться загрузить сертификат из disk-cache.
205
+
206
+ Проверяет:
207
+ - файл существует
208
+ - CN сертификата совпадает с доменом
209
+ - срок действия не истекает в ближайшие 30 дней
210
+ """
211
+ if not cert_dir:
212
+ return None
213
+ path = _domain_cache_path(domain, cert_dir)
214
+ if not path.exists():
215
+ return None
216
+ try:
217
+ pem_data = path.read_bytes()
218
+ # Файл содержит cert_pem + key_pem, разделённые маркером
219
+ sep = b"-----BEGIN RSA PRIVATE KEY-----"
220
+ sep2 = b"-----BEGIN PRIVATE KEY-----"
221
+ if sep in pem_data:
222
+ cert_pem, key_pem = pem_data.split(sep, 1)
223
+ key_pem = sep + key_pem
224
+ elif sep2 in pem_data:
225
+ cert_pem, key_pem = pem_data.split(sep2, 1)
226
+ key_pem = sep2 + key_pem
227
+ else:
228
+ return None
229
+
230
+ # Проверить срок действия
231
+ cert = x509.load_pem_x509_certificate(cert_pem)
232
+ expires = cert.not_valid_after_utc
233
+ margin = timedelta(days=30)
234
+ if expires - datetime.now(timezone.utc) < margin:
235
+ path.unlink(missing_ok=True)
236
+ return None
237
+
238
+ return _build_ssl_ctx(cert_pem, key_pem)
239
+ except Exception:
240
+ # Повреждённый кэш — удалить и регенерировать
241
+ try:
242
+ path.unlink(missing_ok=True)
243
+ except Exception:
244
+ pass
245
+ return None
246
+
247
+
248
+ def _save_ctx_to_disk(
249
+ domain: str, cert_pem: bytes, key_pem: bytes, cert_dir: str | None
250
+ ) -> None:
251
+ if not cert_dir:
252
+ return
253
+ try:
254
+ path = _domain_cache_path(domain, cert_dir)
255
+ path.parent.mkdir(parents=True, exist_ok=True)
256
+ path.write_bytes(cert_pem + key_pem)
257
+ path.chmod(0o600)
258
+ except Exception:
259
+ pass # disk-cache не критичен — продолжаем без него
260
+
261
+
262
+ def _build_ssl_ctx(cert_pem: bytes, key_pem: bytes) -> ssl.SSLContext:
263
+ """Собрать SSLContext из PEM-байтов через временные файлы."""
264
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
265
+ ctx.check_hostname = False
266
+ ctx.set_alpn_protocols(["http/1.1"])
267
+
268
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".crt") as cf:
269
+ cf.write(cert_pem)
270
+ cert_tmp = cf.name
271
+
272
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".key") as kf:
273
+ kf.write(key_pem)
274
+ key_tmp = kf.name
275
+
276
+ try:
277
+ ctx.load_cert_chain(cert_tmp, key_tmp)
278
+ finally:
279
+ os.unlink(cert_tmp)
280
+ os.unlink(key_tmp)
281
+
282
+ return ctx
283
+
284
+
285
+ # ── In-memory LRU cache для SSL-контекстов (1000 доменов) ────────────────────
286
+
287
+ class _SslCtxLRU:
288
+ """Простой LRU-кэш для SSLContext объектов.
289
+
290
+ Ключ — строка "{domain}:{ca_cert_path}", значение — ssl.SSLContext.
291
+ Максимум 1000 записей — примерно 1–2 МБ памяти.
292
+ """
293
+ def __init__(self, max_size: int = 1000) -> None:
294
+ from collections import OrderedDict
295
+ self._data: "OrderedDict[str, ssl.SSLContext]" = OrderedDict()
296
+ self._max = max_size
297
+
298
+ def get(self, key: str) -> ssl.SSLContext | None:
299
+ if key not in self._data:
300
+ return None
301
+ self._data.move_to_end(key)
302
+ return self._data[key]
303
+
304
+ def put(self, key: str, ctx: ssl.SSLContext) -> None:
305
+ if key in self._data:
306
+ self._data.move_to_end(key)
307
+ self._data[key] = ctx
308
+ return
309
+ self._data[key] = ctx
310
+ if len(self._data) > self._max:
311
+ self._data.popitem(last=False)
312
+
313
+
314
+ _ssl_ctx_cache = _SslCtxLRU(max_size=1000)
pentool/utils/coder.py ADDED
@@ -0,0 +1,170 @@
1
+ """Кодирование, декодирование и хэширование строк."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import html
8
+ import urllib.parse
9
+ from typing import Callable
10
+
11
+
12
+ def url_encode(text: str) -> str:
13
+ """URL-кодировать строку (все символы, кроме букв/цифр/'-._~')."""
14
+ return urllib.parse.quote(text, safe="")
15
+
16
+
17
+ def url_decode(text: str) -> str:
18
+ """URL-декодировать строку."""
19
+ return urllib.parse.unquote(text)
20
+
21
+
22
+ def url_encode_all(text: str) -> str:
23
+ """URL-кодировать строку, включая пробелы как %20."""
24
+ return urllib.parse.quote(text, safe="")
25
+
26
+
27
+ def url_decode_plus(text: str) -> str:
28
+ """URL-декодировать строку, обрабатывая '+' как пробел."""
29
+ return urllib.parse.unquote_plus(text)
30
+
31
+
32
+ def base64_encode(text: str) -> str:
33
+ """Закодировать строку в стандартный Base64."""
34
+ return base64.b64encode(text.encode("utf-8")).decode("ascii")
35
+
36
+
37
+ def base64_decode(text: str) -> str:
38
+ """Декодировать строку из стандартного Base64.
39
+
40
+ Raises:
41
+ ValueError: Если строка не является корректным Base64.
42
+ """
43
+ try:
44
+ padded = text + "=" * (-len(text) % 4)
45
+ return base64.b64decode(padded).decode("utf-8", errors="replace")
46
+ except Exception as exc:
47
+ raise ValueError(f"Invalid base64: {exc}") from exc
48
+
49
+
50
+ def base64url_encode(text: str) -> str:
51
+ """Закодировать строку в URL-safe Base64 (без паддинга)."""
52
+ return base64.urlsafe_b64encode(text.encode("utf-8")).decode("ascii").rstrip("=")
53
+
54
+
55
+ def base64url_decode(text: str) -> str:
56
+ """Декодировать строку из URL-safe Base64.
57
+
58
+ Raises:
59
+ ValueError: Если строка не является корректным Base64url.
60
+ """
61
+ try:
62
+ padded = text + "=" * (-len(text) % 4)
63
+ return base64.urlsafe_b64decode(padded).decode("utf-8", errors="replace")
64
+ except Exception as exc:
65
+ raise ValueError(f"Invalid base64url: {exc}") from exc
66
+
67
+
68
+ def html_encode(text: str) -> str:
69
+ """HTML-кодировать строку (заменить спецсимволы на HTML-сущности)."""
70
+ return html.escape(text, quote=True)
71
+
72
+
73
+ def html_decode(text: str) -> str:
74
+ """HTML-декодировать строку (заменить HTML-сущности на символы)."""
75
+ return html.unescape(text)
76
+
77
+
78
+ def hex_encode(text: str) -> str:
79
+ """Закодировать строку в hex-представление байт (UTF-8)."""
80
+ return text.encode("utf-8").hex()
81
+
82
+
83
+ def hex_decode(text: str) -> str:
84
+ """Декодировать hex-строку обратно в текст.
85
+
86
+ Raises:
87
+ ValueError: Если строка не является корректным hex.
88
+ """
89
+ try:
90
+ clean = text.replace(" ", "").replace("\\x", "").replace("0x", "")
91
+ return bytes.fromhex(clean).decode("utf-8", errors="replace")
92
+ except Exception as exc:
93
+ raise ValueError(f"Invalid hex: {exc}") from exc
94
+
95
+
96
+ def unicode_escape(text: str) -> str:
97
+ """Закодировать строку в Unicode escape (\\uXXXX для не-ASCII)."""
98
+ result = []
99
+ for ch in text:
100
+ code = ord(ch)
101
+ if code > 127:
102
+ result.append(f"\\u{code:04x}")
103
+ else:
104
+ result.append(ch)
105
+ return "".join(result)
106
+
107
+
108
+ def unicode_unescape(text: str) -> str:
109
+ """Декодировать строку из Unicode escape."""
110
+ try:
111
+ return text.encode("utf-8").decode("unicode_escape")
112
+ except Exception:
113
+ import codecs
114
+ try:
115
+ return codecs.decode(text, "unicode_escape")
116
+ except Exception as exc:
117
+ raise ValueError(f"Invalid unicode escape: {exc}") from exc
118
+
119
+
120
+ def md5(text: str) -> str:
121
+ """Вычислить MD5-хэш строки (hex-дайджест)."""
122
+ return hashlib.md5(text.encode("utf-8")).hexdigest()
123
+
124
+
125
+ def sha1(text: str) -> str:
126
+ """Вычислить SHA1-хэш строки (hex-дайджест)."""
127
+ return hashlib.sha1(text.encode("utf-8")).hexdigest()
128
+
129
+
130
+ def sha256(text: str) -> str:
131
+ """Вычислить SHA256-хэш строки (hex-дайджест)."""
132
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
133
+
134
+
135
+ OPERATIONS: dict[str, Callable[[str], str]] = {
136
+ "url_encode": url_encode,
137
+ "url_decode": url_decode,
138
+ "base64_encode": base64_encode,
139
+ "base64_decode": base64_decode,
140
+ "base64url_encode": base64url_encode,
141
+ "base64url_decode": base64url_decode,
142
+ "html_encode": html_encode,
143
+ "html_decode": html_decode,
144
+ "hex_encode": hex_encode,
145
+ "hex_decode": hex_decode,
146
+ "unicode_escape": unicode_escape,
147
+ "unicode_unescape": unicode_unescape,
148
+ "md5": md5,
149
+ "sha1": sha1,
150
+ "sha256": sha256,
151
+ }
152
+
153
+
154
+ def apply_operation(operation: str, text: str) -> str:
155
+ """Применить именованную операцию к тексту.
156
+
157
+ Args:
158
+ operation: Имя операции из OPERATIONS.
159
+ text: Входная строка.
160
+
161
+ Returns:
162
+ Результат операции.
163
+
164
+ Raises:
165
+ ValueError: Если операция неизвестна.
166
+ """
167
+ func = OPERATIONS.get(operation)
168
+ if func is None:
169
+ raise ValueError(f"Unknown operation: {operation!r}. Available: {list(OPERATIONS)}")
170
+ return func(text)