pyencode-protector 0.3.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.
- pyencode/__init__.py +5 -0
- pyencode/__main__.py +8 -0
- pyencode/builder.py +628 -0
- pyencode/cli.py +136 -0
- pyencode/code_hardening.py +185 -0
- pyencode/container.py +319 -0
- pyencode/crypto.py +319 -0
- pyencode/discovery.py +168 -0
- pyencode/errors.py +59 -0
- pyencode/integrity.py +88 -0
- pyencode/inventory.py +356 -0
- pyencode/manifest.py +196 -0
- pyencode/opaque.py +574 -0
- pyencode/runtime_template/__init__.py +99 -0
- pyencode/runtime_template/_build.py +15 -0
- pyencode/runtime_template/_mp_main.py +7 -0
- pyencode/runtime_template/_runtime.py +1488 -0
- pyencode/source.py +50 -0
- pyencode_protector-0.3.0.dist-info/METADATA +276 -0
- pyencode_protector-0.3.0.dist-info/RECORD +23 -0
- pyencode_protector-0.3.0.dist-info/WHEEL +5 -0
- pyencode_protector-0.3.0.dist-info/entry_points.txt +2 -0
- pyencode_protector-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1488 @@
|
|
|
1
|
+
"""Portable in-memory runtime for PyEncode PYE2 distributions.
|
|
2
|
+
|
|
3
|
+
The runtime intentionally remains pure Python so one generated runtime source
|
|
4
|
+
works on Windows and Linux and across supported CPython releases. Marshalled
|
|
5
|
+
payloads are still tied to the CPython minor version recorded by the build.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import binascii
|
|
12
|
+
import hashlib
|
|
13
|
+
import importlib
|
|
14
|
+
import importlib.abc
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import marshal
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import stat
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
import types
|
|
24
|
+
import zlib
|
|
25
|
+
from datetime import date, datetime, time, timezone
|
|
26
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
27
|
+
from typing import Any, Iterator, Mapping, NoReturn
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from importlib.resources.abc import ResourceReader as _ResourceReaderABC
|
|
31
|
+
except ImportError: # Python 3.10
|
|
32
|
+
from importlib.abc import ResourceReader as _ResourceReaderABC
|
|
33
|
+
|
|
34
|
+
from . import _build
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_MAGIC = b"PYE2"
|
|
38
|
+
_NONCE_SIZE = 12
|
|
39
|
+
_TAG_SIZE = 16
|
|
40
|
+
_KEY_SIZE = 32
|
|
41
|
+
_MAX_INDEX_SIZE = 16 * 1024 * 1024
|
|
42
|
+
_MAX_MARSHALLED_SIZE = 256 * 1024 * 1024
|
|
43
|
+
_MASK_PREFIX = b"pyencode-key-mask\0"
|
|
44
|
+
_ROOT_KEY_INFO = b"pyencode-root-key-v2\0"
|
|
45
|
+
_INDEX_KEY_INFO = b"pyencode-module-index-v1\0"
|
|
46
|
+
_INDEX_COMMITMENT_DOMAIN = b"pyencode-module-index-commitment-v1\0"
|
|
47
|
+
_MODULE_ROOT_INFO = b"pyencode-module-root-v2\0"
|
|
48
|
+
_ARTIFACT_KEY_INFO = b"pyencode-artifact-v2\0"
|
|
49
|
+
_BOUND_FILES_DOMAIN = b"pyencode-bound-files-v1\0"
|
|
50
|
+
_MANIFEST_NAME = ".pyencode-manifest.json"
|
|
51
|
+
_TOKEN_RE = re.compile(r"[0-9a-f]{32}\Z")
|
|
52
|
+
_DIGEST_RE = re.compile(r"[0-9a-f]{64}\Z")
|
|
53
|
+
_PYTHON_TAG_RE = re.compile(r"cp[0-9]{2,}\Z")
|
|
54
|
+
_WINDOWS_FORBIDDEN_CHARACTERS = frozenset('<>:"|?*')
|
|
55
|
+
_WINDOWS_RESERVED_BASENAMES = frozenset(
|
|
56
|
+
{
|
|
57
|
+
"aux",
|
|
58
|
+
"con",
|
|
59
|
+
"conin$",
|
|
60
|
+
"conout$",
|
|
61
|
+
"nul",
|
|
62
|
+
"prn",
|
|
63
|
+
*(f"com{index}" for index in range(1, 10)),
|
|
64
|
+
*(f"lpt{index}" for index in range(1, 10)),
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
_CODE_SUFFIXES = frozenset(
|
|
68
|
+
{".py", ".pyc", ".pyo", ".pyd", ".so", ".dll", ".dylib"}
|
|
69
|
+
)
|
|
70
|
+
_RUNTIME_PATHS = frozenset(
|
|
71
|
+
{
|
|
72
|
+
"pyencode_runtime/__init__.py",
|
|
73
|
+
"pyencode_runtime/_mp_main.py",
|
|
74
|
+
"pyencode_runtime/_runtime.py",
|
|
75
|
+
"pyencode_runtime/_build.py",
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PyEncodeRuntimeError(ImportError):
|
|
81
|
+
"""Raised when protected output is invalid, incompatible, or expired."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class IntegrityError(PyEncodeRuntimeError):
|
|
85
|
+
"""Raised when signed or authenticated bytes do not match."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CompatibilityError(PyEncodeRuntimeError):
|
|
89
|
+
"""Raised when a distribution targets another Python runtime."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PolicyError(PyEncodeRuntimeError):
|
|
93
|
+
"""Raised when a signed policy prevents execution."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _fail(message: str, *, cause: BaseException | None = None) -> NoReturn:
|
|
97
|
+
error = PyEncodeRuntimeError(f"pyencode runtime: {message}")
|
|
98
|
+
if cause is None:
|
|
99
|
+
raise error
|
|
100
|
+
raise error from cause
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _canonical_json(value: Any) -> bytes:
|
|
104
|
+
try:
|
|
105
|
+
return json.dumps(
|
|
106
|
+
value,
|
|
107
|
+
sort_keys=True,
|
|
108
|
+
separators=(",", ":"),
|
|
109
|
+
ensure_ascii=False,
|
|
110
|
+
allow_nan=False,
|
|
111
|
+
).encode("utf-8")
|
|
112
|
+
except (TypeError, ValueError, UnicodeEncodeError) as exc:
|
|
113
|
+
_fail("metadata cannot be encoded as canonical JSON", cause=exc)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _decode_json(raw: bytes, description: str) -> Any:
|
|
117
|
+
try:
|
|
118
|
+
return json.loads(raw.decode("utf-8"))
|
|
119
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
120
|
+
_fail(f"invalid {description} JSON", cause=exc)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _decode_b64(value: object, description: str, *, minimum: int = 1) -> bytes:
|
|
124
|
+
if not isinstance(value, str) or not value:
|
|
125
|
+
_fail(f"{description} must be a non-empty base64 string")
|
|
126
|
+
try:
|
|
127
|
+
decoded = base64.b64decode(value, validate=True)
|
|
128
|
+
except (ValueError, binascii.Error) as exc:
|
|
129
|
+
_fail(f"{description} is not valid base64", cause=exc)
|
|
130
|
+
if len(decoded) < minimum:
|
|
131
|
+
_fail(f"{description} is too short")
|
|
132
|
+
return decoded
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _decode_hex(value: object, description: str, *, size: int) -> bytes:
|
|
136
|
+
expected_length = size * 2
|
|
137
|
+
if (
|
|
138
|
+
not isinstance(value, str)
|
|
139
|
+
or len(value) != expected_length
|
|
140
|
+
or value != value.lower()
|
|
141
|
+
):
|
|
142
|
+
_fail(
|
|
143
|
+
f"{description} must be exactly {expected_length} lowercase hexadecimal characters"
|
|
144
|
+
)
|
|
145
|
+
try:
|
|
146
|
+
decoded = bytes.fromhex(value)
|
|
147
|
+
except ValueError as exc:
|
|
148
|
+
_fail(f"{description} is not valid hexadecimal", cause=exc)
|
|
149
|
+
if len(decoded) != size:
|
|
150
|
+
_fail(f"{description} must decode to {size} bytes")
|
|
151
|
+
return decoded
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _runtime_python_tag() -> str:
|
|
155
|
+
if getattr(sys.implementation, "name", None) != "cpython":
|
|
156
|
+
raise CompatibilityError("pyencode runtime: protected modules require CPython")
|
|
157
|
+
return f"cp{sys.version_info.major}{sys.version_info.minor}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _validate_build_config() -> bytes:
|
|
161
|
+
required_strings = {
|
|
162
|
+
"BUILD_ID": _build.BUILD_ID,
|
|
163
|
+
"PYTHON_TAG": _build.PYTHON_TAG,
|
|
164
|
+
"PUBLIC_KEY_B64": _build.PUBLIC_KEY_B64,
|
|
165
|
+
"MANIFEST_NAME": _build.MANIFEST_NAME,
|
|
166
|
+
"LAUNCHER_NAME": _build.LAUNCHER_NAME,
|
|
167
|
+
}
|
|
168
|
+
for name, value in required_strings.items():
|
|
169
|
+
if not isinstance(value, str) or not value:
|
|
170
|
+
_fail(f"runtime build configuration {name} is missing")
|
|
171
|
+
build_id = _decode_hex(_build.BUILD_ID, "BUILD_ID", size=16)
|
|
172
|
+
if _PYTHON_TAG_RE.fullmatch(_build.PYTHON_TAG) is None:
|
|
173
|
+
_fail("runtime build configuration PYTHON_TAG is invalid")
|
|
174
|
+
if _build.MANIFEST_NAME != _MANIFEST_NAME:
|
|
175
|
+
_fail("runtime build configuration MANIFEST_NAME is invalid")
|
|
176
|
+
actual_tag = _runtime_python_tag()
|
|
177
|
+
if _build.PYTHON_TAG != actual_tag:
|
|
178
|
+
raise CompatibilityError(
|
|
179
|
+
"pyencode runtime: protected output targets "
|
|
180
|
+
f"{_build.PYTHON_TAG}, but this interpreter is {actual_tag}"
|
|
181
|
+
)
|
|
182
|
+
if not isinstance(_build.KEY_PARTS, (tuple, list)) or not _build.KEY_PARTS:
|
|
183
|
+
_fail("runtime build configuration KEY_PARTS is missing")
|
|
184
|
+
if not isinstance(_build.KEY_ORDER, (tuple, list)) or not _build.KEY_ORDER:
|
|
185
|
+
_fail("runtime build configuration KEY_ORDER is missing")
|
|
186
|
+
if (
|
|
187
|
+
not isinstance(_build.BOOTSTRAP_PATHS, (tuple, list))
|
|
188
|
+
or any(path != "_vendor" for path in _build.BOOTSTRAP_PATHS)
|
|
189
|
+
or len(set(_build.BOOTSTRAP_PATHS)) != len(_build.BOOTSTRAP_PATHS)
|
|
190
|
+
):
|
|
191
|
+
_fail("runtime build configuration BOOTSTRAP_PATHS is invalid")
|
|
192
|
+
launcher = PurePosixPath(_build.LAUNCHER_NAME)
|
|
193
|
+
if (
|
|
194
|
+
len(launcher.parts) != 1
|
|
195
|
+
or launcher.suffix.lower() != ".py"
|
|
196
|
+
or not launcher.stem.isidentifier()
|
|
197
|
+
):
|
|
198
|
+
_fail("runtime build configuration LAUNCHER_NAME is invalid")
|
|
199
|
+
return build_id
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _embedded_master_key(build_id: bytes) -> bytearray:
|
|
203
|
+
parts = _build.KEY_PARTS
|
|
204
|
+
order = _build.KEY_ORDER
|
|
205
|
+
if any(isinstance(index, bool) or not isinstance(index, int) for index in order):
|
|
206
|
+
_fail("KEY_ORDER entries must be integers")
|
|
207
|
+
if len(order) != len(parts) or sorted(order) != list(range(len(parts))):
|
|
208
|
+
_fail("KEY_ORDER must be a permutation of all KEY_PARTS indexes")
|
|
209
|
+
decoded_parts: list[bytes] = []
|
|
210
|
+
for index in order:
|
|
211
|
+
part = parts[index]
|
|
212
|
+
if not isinstance(part, (str, bytes)) or not part:
|
|
213
|
+
_fail("KEY_PARTS entries must be non-empty base85 strings")
|
|
214
|
+
try:
|
|
215
|
+
decoded_parts.append(base64.b85decode(part))
|
|
216
|
+
except (ValueError, binascii.Error) as exc:
|
|
217
|
+
_fail("a KEY_PARTS entry is not valid base85", cause=exc)
|
|
218
|
+
material = b"".join(decoded_parts)
|
|
219
|
+
if len(material) != _KEY_SIZE:
|
|
220
|
+
_fail(f"decoded KEY_PARTS must contain exactly {_KEY_SIZE} bytes")
|
|
221
|
+
mask = hashlib.sha256(_MASK_PREFIX + build_id).digest()
|
|
222
|
+
return bytearray(left ^ right for left, right in zip(material, mask))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _hkdf(root_key: bytes | bytearray, build_id: bytes, info: bytes) -> bytes:
|
|
226
|
+
try:
|
|
227
|
+
from cryptography.hazmat.primitives import hashes
|
|
228
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
229
|
+
except ImportError as exc: # pragma: no cover - installation dependent
|
|
230
|
+
_fail("the 'cryptography' package is required", cause=exc)
|
|
231
|
+
return HKDF(
|
|
232
|
+
algorithm=hashes.SHA256(),
|
|
233
|
+
length=_KEY_SIZE,
|
|
234
|
+
salt=build_id,
|
|
235
|
+
info=info,
|
|
236
|
+
).derive(bytes(root_key))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _derive_support_root(
|
|
240
|
+
master_key: bytes | bytearray, build_id: bytes, commitment: bytes
|
|
241
|
+
) -> bytes:
|
|
242
|
+
if len(commitment) != 32:
|
|
243
|
+
_fail("bound-files commitment must be exactly 32 bytes")
|
|
244
|
+
return _hkdf(master_key, build_id, _ROOT_KEY_INFO + commitment)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _index_aad(build_id_text: str, python_tag: str) -> bytes:
|
|
248
|
+
return _canonical_json(
|
|
249
|
+
{
|
|
250
|
+
"build_id": build_id_text,
|
|
251
|
+
"cipher": "aes-256-gcm",
|
|
252
|
+
"compression": "zlib",
|
|
253
|
+
"format": 1,
|
|
254
|
+
"python_tag": python_tag,
|
|
255
|
+
}
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _index_commitment(
|
|
260
|
+
nonce: bytes, ciphertext: bytes, build_id_text: str, python_tag: str
|
|
261
|
+
) -> bytes:
|
|
262
|
+
return hashlib.sha256(
|
|
263
|
+
_INDEX_COMMITMENT_DOMAIN
|
|
264
|
+
+ _index_aad(build_id_text, python_tag)
|
|
265
|
+
+ nonce
|
|
266
|
+
+ ciphertext
|
|
267
|
+
).digest()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _derive_index_key(support_root: bytes, build_id: bytes) -> bytes:
|
|
271
|
+
return _hkdf(support_root, build_id, _INDEX_KEY_INFO)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _derive_module_root(
|
|
275
|
+
support_root: bytes, build_id: bytes, index_commitment: bytes
|
|
276
|
+
) -> bytes:
|
|
277
|
+
if len(index_commitment) != 32:
|
|
278
|
+
_fail("module-index commitment must be exactly 32 bytes")
|
|
279
|
+
return _hkdf(support_root, build_id, _MODULE_ROOT_INFO + index_commitment)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _derive_artifact_key(
|
|
283
|
+
module_root: bytes, build_id: bytes, token: str, fullname: str
|
|
284
|
+
) -> bytes:
|
|
285
|
+
return _hkdf(
|
|
286
|
+
module_root,
|
|
287
|
+
build_id,
|
|
288
|
+
_ARTIFACT_KEY_INFO
|
|
289
|
+
+ bytes.fromhex(token)
|
|
290
|
+
+ b"\0"
|
|
291
|
+
+ fullname.encode("utf-8"),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _artifact_aad(
|
|
296
|
+
*, fullname: str, package: bool, token: str, build_id: str, python_tag: str
|
|
297
|
+
) -> bytes:
|
|
298
|
+
return _canonical_json(
|
|
299
|
+
{
|
|
300
|
+
"artifact": token,
|
|
301
|
+
"build_id": build_id,
|
|
302
|
+
"compression": "zlib",
|
|
303
|
+
"format": 2,
|
|
304
|
+
"marshal_version": marshal.version,
|
|
305
|
+
"module": fullname,
|
|
306
|
+
"package": package,
|
|
307
|
+
"python_tag": python_tag,
|
|
308
|
+
}
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _manifest_path() -> Path:
|
|
313
|
+
package_dir = Path(__file__).resolve().parent
|
|
314
|
+
return package_dir.parent / _MANIFEST_NAME
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _read_manifest() -> tuple[dict[str, Any], Path]:
|
|
318
|
+
path = _manifest_path()
|
|
319
|
+
try:
|
|
320
|
+
raw = path.read_bytes()
|
|
321
|
+
except OSError as exc:
|
|
322
|
+
_fail(f"cannot read manifest {path}", cause=exc)
|
|
323
|
+
wrapper = _decode_json(raw, "manifest wrapper")
|
|
324
|
+
if not isinstance(wrapper, dict) or set(wrapper) != {"manifest", "signature"}:
|
|
325
|
+
_fail("manifest wrapper must contain only 'manifest' and 'signature'")
|
|
326
|
+
payload = wrapper["manifest"]
|
|
327
|
+
if not isinstance(payload, dict):
|
|
328
|
+
_fail("signed manifest payload must be an object")
|
|
329
|
+
signature = _decode_b64(wrapper["signature"], "manifest signature", minimum=64)
|
|
330
|
+
public_key = _decode_b64(_build.PUBLIC_KEY_B64, "manifest public key", minimum=32)
|
|
331
|
+
if len(signature) != 64 or len(public_key) != 32:
|
|
332
|
+
_fail("manifest signature or public key has an invalid length")
|
|
333
|
+
try:
|
|
334
|
+
from cryptography.exceptions import InvalidSignature
|
|
335
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
336
|
+
except ImportError as exc: # pragma: no cover - installation dependent
|
|
337
|
+
_fail("the 'cryptography' package is required", cause=exc)
|
|
338
|
+
try:
|
|
339
|
+
Ed25519PublicKey.from_public_bytes(public_key).verify(
|
|
340
|
+
signature, _canonical_json(payload)
|
|
341
|
+
)
|
|
342
|
+
except InvalidSignature as exc:
|
|
343
|
+
raise IntegrityError("pyencode runtime: manifest signature is invalid") from exc
|
|
344
|
+
except ValueError as exc:
|
|
345
|
+
_fail("invalid Ed25519 public key or signature", cause=exc)
|
|
346
|
+
return payload, path.parent.resolve()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _validate_module_name(value: object) -> str:
|
|
350
|
+
if not isinstance(value, str) or not value:
|
|
351
|
+
_fail("module name must be a non-empty string")
|
|
352
|
+
if any(not part or not part.isidentifier() for part in value.split(".")):
|
|
353
|
+
_fail(f"invalid protected module name {value!r}")
|
|
354
|
+
return value
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _validate_inventory_path(value: object, description: str) -> str:
|
|
358
|
+
if (
|
|
359
|
+
not isinstance(value, str)
|
|
360
|
+
or not value
|
|
361
|
+
or "\0" in value
|
|
362
|
+
or "\\" in value
|
|
363
|
+
):
|
|
364
|
+
_fail(f"{description} must be a canonical POSIX relative path")
|
|
365
|
+
posix = PurePosixPath(value)
|
|
366
|
+
windows = PureWindowsPath(value)
|
|
367
|
+
if (
|
|
368
|
+
posix.is_absolute()
|
|
369
|
+
or windows.is_absolute()
|
|
370
|
+
or bool(windows.drive)
|
|
371
|
+
or any(
|
|
372
|
+
part in ("", ".", "..")
|
|
373
|
+
or part.endswith((".", " "))
|
|
374
|
+
or any(
|
|
375
|
+
ord(character) < 32 or character in _WINDOWS_FORBIDDEN_CHARACTERS
|
|
376
|
+
for character in part
|
|
377
|
+
)
|
|
378
|
+
or part.partition(".")[0].casefold() in _WINDOWS_RESERVED_BASENAMES
|
|
379
|
+
for part in posix.parts
|
|
380
|
+
)
|
|
381
|
+
or posix.as_posix() != value
|
|
382
|
+
):
|
|
383
|
+
_fail(f"unsafe {description}: {value!r}")
|
|
384
|
+
return value
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _is_code_path(path: str) -> bool:
|
|
388
|
+
name = PurePosixPath(path).name.casefold()
|
|
389
|
+
return any(name.endswith(suffix) for suffix in _CODE_SUFFIXES) or ".so." in name
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _is_link_or_reparse(metadata: os.stat_result) -> bool:
|
|
393
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
394
|
+
return True
|
|
395
|
+
flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
|
|
396
|
+
attributes = getattr(metadata, "st_file_attributes", 0)
|
|
397
|
+
return bool(flag and attributes & flag)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _walk_files(
|
|
401
|
+
directory: Path,
|
|
402
|
+
root: Path,
|
|
403
|
+
prefix: tuple[str, ...] = (),
|
|
404
|
+
directories: set[str] | None = None,
|
|
405
|
+
) -> Iterator[str]:
|
|
406
|
+
try:
|
|
407
|
+
with os.scandir(directory) as iterator:
|
|
408
|
+
entries = sorted(iterator, key=lambda item: item.name)
|
|
409
|
+
except OSError as exc:
|
|
410
|
+
raise IntegrityError(
|
|
411
|
+
f"pyencode runtime: cannot scan distribution directory {'/'.join(prefix) or '.'!r}"
|
|
412
|
+
) from exc
|
|
413
|
+
folded: dict[str, str] = {}
|
|
414
|
+
for entry in entries:
|
|
415
|
+
previous = folded.get(entry.name.casefold())
|
|
416
|
+
if previous is not None and previous != entry.name:
|
|
417
|
+
raise IntegrityError(
|
|
418
|
+
"pyencode runtime: case-folding file collision: "
|
|
419
|
+
f"{previous!r} and {entry.name!r}"
|
|
420
|
+
)
|
|
421
|
+
folded[entry.name.casefold()] = entry.name
|
|
422
|
+
for entry in entries:
|
|
423
|
+
relative = "/".join((*prefix, entry.name))
|
|
424
|
+
_validate_inventory_path(relative, "distribution path")
|
|
425
|
+
try:
|
|
426
|
+
metadata = entry.stat(follow_symlinks=False)
|
|
427
|
+
except OSError as exc:
|
|
428
|
+
raise IntegrityError(
|
|
429
|
+
f"pyencode runtime: cannot stat distribution path {relative!r}"
|
|
430
|
+
) from exc
|
|
431
|
+
if _is_link_or_reparse(metadata):
|
|
432
|
+
raise IntegrityError(
|
|
433
|
+
f"pyencode runtime: distribution contains a link or reparse point: {relative!r}"
|
|
434
|
+
)
|
|
435
|
+
if stat.S_ISDIR(metadata.st_mode):
|
|
436
|
+
if directories is not None:
|
|
437
|
+
directories.add(relative)
|
|
438
|
+
yield from _walk_files(
|
|
439
|
+
Path(entry.path), root, (*prefix, entry.name), directories
|
|
440
|
+
)
|
|
441
|
+
elif stat.S_ISREG(metadata.st_mode):
|
|
442
|
+
try:
|
|
443
|
+
Path(entry.path).resolve(strict=True).relative_to(root)
|
|
444
|
+
except (OSError, ValueError) as exc:
|
|
445
|
+
raise IntegrityError(
|
|
446
|
+
f"pyencode runtime: distribution file escapes its root: {relative!r}"
|
|
447
|
+
) from exc
|
|
448
|
+
yield relative
|
|
449
|
+
else:
|
|
450
|
+
raise IntegrityError(
|
|
451
|
+
f"pyencode runtime: distribution contains a non-regular entry: {relative!r}"
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _scan_distribution(root: Path) -> tuple[dict[str, Path], frozenset[str]]:
|
|
456
|
+
"""Scan the tree once and retain exact, already-validated file paths."""
|
|
457
|
+
|
|
458
|
+
directories: set[str] = set()
|
|
459
|
+
files = {
|
|
460
|
+
relative: root.joinpath(*PurePosixPath(relative).parts)
|
|
461
|
+
for relative in _walk_files(root, root, directories=directories)
|
|
462
|
+
}
|
|
463
|
+
return files, frozenset(directories)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _exact_file(root: Path, relative: str) -> Path:
|
|
467
|
+
parts = PurePosixPath(relative).parts
|
|
468
|
+
current = root
|
|
469
|
+
for index, part in enumerate(parts):
|
|
470
|
+
try:
|
|
471
|
+
with os.scandir(current) as iterator:
|
|
472
|
+
entries = tuple(iterator)
|
|
473
|
+
except OSError as exc:
|
|
474
|
+
raise IntegrityError(
|
|
475
|
+
f"pyencode runtime: signed file {relative!r} is missing or unreadable"
|
|
476
|
+
) from exc
|
|
477
|
+
exact = next((entry for entry in entries if entry.name == part), None)
|
|
478
|
+
if exact is None:
|
|
479
|
+
folded = next(
|
|
480
|
+
(entry.name for entry in entries if entry.name.casefold() == part.casefold()),
|
|
481
|
+
None,
|
|
482
|
+
)
|
|
483
|
+
if folded is not None:
|
|
484
|
+
raise IntegrityError(
|
|
485
|
+
f"pyencode runtime: signed file {relative!r} has mismatched path casing"
|
|
486
|
+
)
|
|
487
|
+
raise IntegrityError(f"pyencode runtime: signed file {relative!r} is missing")
|
|
488
|
+
try:
|
|
489
|
+
metadata = exact.stat(follow_symlinks=False)
|
|
490
|
+
except OSError as exc:
|
|
491
|
+
raise IntegrityError(
|
|
492
|
+
f"pyencode runtime: cannot inspect signed file {relative!r}"
|
|
493
|
+
) from exc
|
|
494
|
+
if _is_link_or_reparse(metadata):
|
|
495
|
+
raise IntegrityError(
|
|
496
|
+
f"pyencode runtime: signed file {relative!r} uses a link or reparse point"
|
|
497
|
+
)
|
|
498
|
+
last = index == len(parts) - 1
|
|
499
|
+
if last and not stat.S_ISREG(metadata.st_mode):
|
|
500
|
+
raise IntegrityError(
|
|
501
|
+
f"pyencode runtime: signed file {relative!r} is not a regular file"
|
|
502
|
+
)
|
|
503
|
+
if not last and not stat.S_ISDIR(metadata.st_mode):
|
|
504
|
+
raise IntegrityError(
|
|
505
|
+
f"pyencode runtime: parent of signed file {relative!r} is not a directory"
|
|
506
|
+
)
|
|
507
|
+
current = Path(exact.path)
|
|
508
|
+
try:
|
|
509
|
+
resolved = current.resolve(strict=True)
|
|
510
|
+
resolved.relative_to(root)
|
|
511
|
+
except (OSError, ValueError) as exc:
|
|
512
|
+
raise IntegrityError(
|
|
513
|
+
f"pyencode runtime: signed file {relative!r} escapes the distribution"
|
|
514
|
+
) from exc
|
|
515
|
+
return resolved
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _bound_files_commitment(file_digests: Mapping[str, str]) -> bytes:
|
|
519
|
+
descriptor = {"format": 1, "files": dict(sorted(file_digests.items()))}
|
|
520
|
+
return hashlib.sha256(_BOUND_FILES_DOMAIN + _canonical_json(descriptor)).digest()
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _validate_bound_files(
|
|
524
|
+
payload: Mapping[str, Any], root: Path, inventory: Mapping[str, Path]
|
|
525
|
+
) -> tuple[bytes, frozenset[str]]:
|
|
526
|
+
integrity = payload.get("integrity")
|
|
527
|
+
if not isinstance(integrity, dict) or set(integrity) != {
|
|
528
|
+
"format",
|
|
529
|
+
"algorithm",
|
|
530
|
+
"files",
|
|
531
|
+
"commitment_sha256",
|
|
532
|
+
}:
|
|
533
|
+
_fail("manifest integrity metadata is missing or has invalid fields")
|
|
534
|
+
if type(integrity["format"]) is not int or integrity["format"] != 1:
|
|
535
|
+
_fail("unsupported manifest integrity format")
|
|
536
|
+
if integrity["algorithm"] != "sha256":
|
|
537
|
+
_fail("unsupported manifest integrity algorithm")
|
|
538
|
+
raw_files = integrity["files"]
|
|
539
|
+
if not isinstance(raw_files, dict) or not raw_files:
|
|
540
|
+
_fail("manifest integrity files must be a non-empty object")
|
|
541
|
+
|
|
542
|
+
expected_mandatory = set(_RUNTIME_PATHS)
|
|
543
|
+
expected_mandatory.add(_build.LAUNCHER_NAME)
|
|
544
|
+
if not expected_mandatory.issubset(raw_files):
|
|
545
|
+
missing = sorted(expected_mandatory.difference(raw_files))
|
|
546
|
+
_fail("manifest integrity file set is missing " + ", ".join(missing))
|
|
547
|
+
|
|
548
|
+
signed: dict[str, str] = {}
|
|
549
|
+
folded_paths: dict[str, str] = {}
|
|
550
|
+
for raw_path, raw_digest in raw_files.items():
|
|
551
|
+
path = _validate_inventory_path(raw_path, "signed support file path")
|
|
552
|
+
previous = folded_paths.get(path.casefold())
|
|
553
|
+
if previous is not None and previous != path:
|
|
554
|
+
_fail(f"case-folding signed path collision: {previous!r} and {path!r}")
|
|
555
|
+
folded_paths[path.casefold()] = path
|
|
556
|
+
if not isinstance(raw_digest, str) or _DIGEST_RE.fullmatch(raw_digest) is None:
|
|
557
|
+
_fail(f"invalid sha256 for signed support file {path!r}")
|
|
558
|
+
signed[path] = raw_digest
|
|
559
|
+
|
|
560
|
+
actual: dict[str, str] = {}
|
|
561
|
+
for relative, expected_digest in sorted(signed.items()):
|
|
562
|
+
path = inventory.get(relative)
|
|
563
|
+
if path is None:
|
|
564
|
+
raise IntegrityError(
|
|
565
|
+
f"pyencode runtime: signed support file {relative!r} is missing"
|
|
566
|
+
)
|
|
567
|
+
try:
|
|
568
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
569
|
+
except OSError as exc:
|
|
570
|
+
raise IntegrityError(
|
|
571
|
+
f"pyencode runtime: cannot read signed support file {relative!r}"
|
|
572
|
+
) from exc
|
|
573
|
+
if digest != expected_digest:
|
|
574
|
+
raise IntegrityError(
|
|
575
|
+
f"pyencode runtime: signed support file {relative!r} failed its hash check"
|
|
576
|
+
)
|
|
577
|
+
actual[relative] = digest
|
|
578
|
+
|
|
579
|
+
commitment = _bound_files_commitment(actual)
|
|
580
|
+
expected_commitment = _decode_hex(
|
|
581
|
+
integrity["commitment_sha256"], "bound-files commitment", size=32
|
|
582
|
+
)
|
|
583
|
+
if commitment != expected_commitment:
|
|
584
|
+
raise IntegrityError("pyencode runtime: bound-files commitment is invalid")
|
|
585
|
+
|
|
586
|
+
actual_code = {path for path in inventory if _is_code_path(path)}
|
|
587
|
+
allowed_code = {path for path in signed if _is_code_path(path)}
|
|
588
|
+
unexpected = sorted(actual_code.difference(allowed_code))
|
|
589
|
+
if unexpected:
|
|
590
|
+
raise IntegrityError(
|
|
591
|
+
"pyencode runtime: distribution contains unsigned executable file(s): "
|
|
592
|
+
+ ", ".join(unexpected)
|
|
593
|
+
)
|
|
594
|
+
return commitment, frozenset(signed)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _expiry_value(payload: Mapping[str, Any]) -> object | None:
|
|
598
|
+
policy = payload.get("policy")
|
|
599
|
+
if not isinstance(policy, dict):
|
|
600
|
+
_fail("manifest policy must be an object")
|
|
601
|
+
for name in ("expiry", "expires_at", "expires"):
|
|
602
|
+
if name in policy and policy[name] is not None:
|
|
603
|
+
return policy[name]
|
|
604
|
+
return None
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _parse_expiry(value: object) -> datetime:
|
|
608
|
+
if isinstance(value, bool):
|
|
609
|
+
_fail("policy expiry must be an ISO-8601 string or Unix timestamp")
|
|
610
|
+
if isinstance(value, (int, float)):
|
|
611
|
+
try:
|
|
612
|
+
return datetime.fromtimestamp(value, timezone.utc)
|
|
613
|
+
except (OverflowError, OSError, ValueError) as exc:
|
|
614
|
+
_fail("policy expiry timestamp is invalid", cause=exc)
|
|
615
|
+
if not isinstance(value, str) or not value:
|
|
616
|
+
_fail("policy expiry must be an ISO-8601 string or Unix timestamp")
|
|
617
|
+
try:
|
|
618
|
+
if len(value) == 10:
|
|
619
|
+
return datetime.combine(date.fromisoformat(value), time.max, timezone.utc)
|
|
620
|
+
normalized = value[:-1] + "+00:00" if value.endswith(("Z", "z")) else value
|
|
621
|
+
parsed = datetime.fromisoformat(normalized)
|
|
622
|
+
except ValueError as exc:
|
|
623
|
+
_fail("policy expiry is not valid ISO-8601", cause=exc)
|
|
624
|
+
if parsed.tzinfo is None:
|
|
625
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
626
|
+
return parsed.astimezone(timezone.utc)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _check_policy(payload: Mapping[str, Any]) -> None:
|
|
630
|
+
value = _expiry_value(payload)
|
|
631
|
+
if value is None:
|
|
632
|
+
return
|
|
633
|
+
expiry = _parse_expiry(value)
|
|
634
|
+
if datetime.now(timezone.utc) > expiry:
|
|
635
|
+
raise PolicyError(
|
|
636
|
+
"pyencode runtime: this protected build expired at "
|
|
637
|
+
f"{expiry.isoformat().replace('+00:00', 'Z')}"
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _allow_extra_data(payload: Mapping[str, Any]) -> bool:
|
|
642
|
+
policy = payload.get("policy")
|
|
643
|
+
if not isinstance(policy, dict):
|
|
644
|
+
_fail("manifest policy must be an object")
|
|
645
|
+
value = policy.get("allow_extra_data", False)
|
|
646
|
+
if type(value) is not bool:
|
|
647
|
+
_fail("manifest allow_extra_data policy must be boolean")
|
|
648
|
+
return value
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _normalize_artifacts(value: object) -> dict[str, str]:
|
|
652
|
+
if not isinstance(value, dict) or not value:
|
|
653
|
+
_fail("manifest artifacts must be a non-empty object")
|
|
654
|
+
result: dict[str, str] = {}
|
|
655
|
+
for token, digest in value.items():
|
|
656
|
+
if not isinstance(token, str) or _TOKEN_RE.fullmatch(token) is None:
|
|
657
|
+
_fail("artifact tokens must be 32 lowercase hexadecimal characters")
|
|
658
|
+
if not isinstance(digest, str) or _DIGEST_RE.fullmatch(digest) is None:
|
|
659
|
+
_fail(f"sha256 for artifact {token!r} is invalid")
|
|
660
|
+
result[token] = digest
|
|
661
|
+
return dict(sorted(result.items()))
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _normalize_index_envelope(value: object) -> tuple[bytes, bytes, bytes]:
|
|
665
|
+
if not isinstance(value, dict) or set(value) != {
|
|
666
|
+
"format",
|
|
667
|
+
"cipher",
|
|
668
|
+
"compression",
|
|
669
|
+
"nonce_b64",
|
|
670
|
+
"ciphertext_b64",
|
|
671
|
+
"commitment_sha256",
|
|
672
|
+
}:
|
|
673
|
+
_fail("manifest module_index metadata has invalid fields")
|
|
674
|
+
if type(value["format"]) is not int or value["format"] != 1:
|
|
675
|
+
_fail("unsupported module-index format")
|
|
676
|
+
if value["cipher"] != "aes-256-gcm" or value["compression"] != "zlib":
|
|
677
|
+
_fail("unsupported module-index cipher or compression")
|
|
678
|
+
nonce = _decode_b64(value["nonce_b64"], "module-index nonce")
|
|
679
|
+
ciphertext = _decode_b64(
|
|
680
|
+
value["ciphertext_b64"], "module-index ciphertext", minimum=_TAG_SIZE
|
|
681
|
+
)
|
|
682
|
+
if len(nonce) != _NONCE_SIZE:
|
|
683
|
+
_fail(f"module-index nonce must decode to {_NONCE_SIZE} bytes")
|
|
684
|
+
commitment = _decode_hex(
|
|
685
|
+
value["commitment_sha256"], "module-index commitment", size=32
|
|
686
|
+
)
|
|
687
|
+
return nonce, ciphertext, commitment
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _validate_manifest(
|
|
691
|
+
payload: dict[str, Any], build_id: bytes
|
|
692
|
+
) -> tuple[dict[str, str], bytes, bytes, bytes]:
|
|
693
|
+
required = {
|
|
694
|
+
"format",
|
|
695
|
+
"tool_version",
|
|
696
|
+
"build_id",
|
|
697
|
+
"python_tag",
|
|
698
|
+
"created_utc",
|
|
699
|
+
"module_index",
|
|
700
|
+
"artifacts",
|
|
701
|
+
"policy",
|
|
702
|
+
"integrity",
|
|
703
|
+
}
|
|
704
|
+
if set(payload) != required:
|
|
705
|
+
_fail("manifest format-3 fields are incomplete or unknown")
|
|
706
|
+
if type(payload["format"]) is not int or payload["format"] != 3:
|
|
707
|
+
_fail("unsupported manifest format")
|
|
708
|
+
if payload["build_id"] != _build.BUILD_ID:
|
|
709
|
+
raise IntegrityError("pyencode runtime: manifest build_id does not match runtime")
|
|
710
|
+
if payload["python_tag"] != _build.PYTHON_TAG:
|
|
711
|
+
raise CompatibilityError(
|
|
712
|
+
"pyencode runtime: manifest Python tag does not match runtime configuration"
|
|
713
|
+
)
|
|
714
|
+
if _decode_hex(payload["build_id"], "manifest build_id", size=16) != build_id:
|
|
715
|
+
_fail("manifest build_id is malformed")
|
|
716
|
+
_check_policy(payload)
|
|
717
|
+
artifacts = _normalize_artifacts(payload["artifacts"])
|
|
718
|
+
nonce, ciphertext, commitment = _normalize_index_envelope(payload["module_index"])
|
|
719
|
+
actual_commitment = _index_commitment(
|
|
720
|
+
nonce, ciphertext, _build.BUILD_ID, _build.PYTHON_TAG
|
|
721
|
+
)
|
|
722
|
+
if commitment != actual_commitment:
|
|
723
|
+
raise IntegrityError("pyencode runtime: module-index commitment is invalid")
|
|
724
|
+
return artifacts, nonce, ciphertext, commitment
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _decompress_strict(compressed: bytes, maximum: int, description: str) -> bytes:
|
|
728
|
+
decompressor = zlib.decompressobj()
|
|
729
|
+
try:
|
|
730
|
+
value = decompressor.decompress(compressed, maximum + 1)
|
|
731
|
+
if len(value) > maximum or decompressor.unconsumed_tail:
|
|
732
|
+
_fail(f"{description} exceeds the runtime safety limit")
|
|
733
|
+
value += decompressor.flush()
|
|
734
|
+
except zlib.error as exc:
|
|
735
|
+
_fail(f"{description} is not valid zlib data", cause=exc)
|
|
736
|
+
if len(value) > maximum:
|
|
737
|
+
_fail(f"{description} exceeds the runtime safety limit")
|
|
738
|
+
if not decompressor.eof or decompressor.unused_data:
|
|
739
|
+
_fail(f"{description} contains incomplete or trailing compressed data")
|
|
740
|
+
return value
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _decrypt_index(
|
|
744
|
+
nonce: bytes,
|
|
745
|
+
ciphertext: bytes,
|
|
746
|
+
support_root: bytes,
|
|
747
|
+
build_id: bytes,
|
|
748
|
+
) -> tuple[str, dict[str, dict[str, Any]]]:
|
|
749
|
+
key = _derive_index_key(support_root, build_id)
|
|
750
|
+
try:
|
|
751
|
+
from cryptography.exceptions import InvalidTag
|
|
752
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
753
|
+
except ImportError as exc: # pragma: no cover - installation dependent
|
|
754
|
+
_fail("the 'cryptography' package is required", cause=exc)
|
|
755
|
+
try:
|
|
756
|
+
compressed = AESGCM(key).decrypt(
|
|
757
|
+
nonce,
|
|
758
|
+
ciphertext,
|
|
759
|
+
_index_aad(_build.BUILD_ID, _build.PYTHON_TAG),
|
|
760
|
+
)
|
|
761
|
+
except InvalidTag as exc:
|
|
762
|
+
raise IntegrityError("pyencode runtime: module-index authentication failed") from exc
|
|
763
|
+
raw = _decompress_strict(compressed, _MAX_INDEX_SIZE, "module index")
|
|
764
|
+
index = _decode_json(raw, "module index")
|
|
765
|
+
if not isinstance(index, dict) or _canonical_json(index) != raw:
|
|
766
|
+
_fail("module-index JSON is not canonical")
|
|
767
|
+
if set(index) != {"format", "entry_module", "modules"}:
|
|
768
|
+
_fail("module index has invalid fields")
|
|
769
|
+
if type(index["format"]) is not int or index["format"] != 1:
|
|
770
|
+
_fail("unsupported decrypted module-index format")
|
|
771
|
+
entry = _validate_module_name(index["entry_module"])
|
|
772
|
+
raw_modules = index["modules"]
|
|
773
|
+
if not isinstance(raw_modules, dict) or not raw_modules:
|
|
774
|
+
_fail("module index modules must be a non-empty object")
|
|
775
|
+
modules: dict[str, dict[str, Any]] = {}
|
|
776
|
+
tokens: set[str] = set()
|
|
777
|
+
for raw_name, metadata in raw_modules.items():
|
|
778
|
+
name = _validate_module_name(raw_name)
|
|
779
|
+
if not isinstance(metadata, dict) or set(metadata) != {"artifact", "package"}:
|
|
780
|
+
_fail(f"module-index metadata for {name!r} is invalid")
|
|
781
|
+
token = metadata["artifact"]
|
|
782
|
+
package = metadata["package"]
|
|
783
|
+
if not isinstance(token, str) or _TOKEN_RE.fullmatch(token) is None:
|
|
784
|
+
_fail(f"artifact token for module {name!r} is invalid")
|
|
785
|
+
if token in tokens:
|
|
786
|
+
_fail(f"duplicate artifact token {token!r}")
|
|
787
|
+
if type(package) is not bool:
|
|
788
|
+
_fail(f"package flag for module {name!r} must be boolean")
|
|
789
|
+
tokens.add(token)
|
|
790
|
+
modules[name] = {"artifact": token, "package": package}
|
|
791
|
+
if entry not in modules:
|
|
792
|
+
_fail("module index does not contain its configured entry module")
|
|
793
|
+
return entry, dict(sorted(modules.items()))
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _artifact_relative_path(fullname: str, package: bool, token: str) -> str:
|
|
797
|
+
parts = fullname.split(".")
|
|
798
|
+
directories = parts if package else parts[:-1]
|
|
799
|
+
return "/".join((*directories, f"{token}.pye"))
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _validate_artifacts(
|
|
803
|
+
root: Path,
|
|
804
|
+
modules: dict[str, dict[str, Any]],
|
|
805
|
+
artifacts: Mapping[str, str],
|
|
806
|
+
inventory: Mapping[str, Path],
|
|
807
|
+
directories: frozenset[str],
|
|
808
|
+
signed_paths: frozenset[str],
|
|
809
|
+
allow_extra_data: bool,
|
|
810
|
+
) -> None:
|
|
811
|
+
index_tokens = {metadata["artifact"] for metadata in modules.values()}
|
|
812
|
+
if index_tokens != set(artifacts):
|
|
813
|
+
missing = sorted(index_tokens.difference(artifacts))
|
|
814
|
+
extra = sorted(set(artifacts).difference(index_tokens))
|
|
815
|
+
details: list[str] = []
|
|
816
|
+
if missing:
|
|
817
|
+
details.append("missing " + ", ".join(missing))
|
|
818
|
+
if extra:
|
|
819
|
+
details.append("unexpected " + ", ".join(extra))
|
|
820
|
+
_fail("artifact token set does not match module index (" + "; ".join(details) + ")")
|
|
821
|
+
|
|
822
|
+
expected_paths: dict[str, tuple[str, str]] = {}
|
|
823
|
+
for fullname, metadata in modules.items():
|
|
824
|
+
token = metadata["artifact"]
|
|
825
|
+
relative = _artifact_relative_path(fullname, metadata["package"], token)
|
|
826
|
+
if relative in expected_paths:
|
|
827
|
+
_fail(f"multiple protected modules map to artifact path {relative!r}")
|
|
828
|
+
expected_paths[relative] = (token, fullname)
|
|
829
|
+
|
|
830
|
+
actual_pye = {path for path in inventory if path.casefold().endswith(".pye")}
|
|
831
|
+
if actual_pye != set(expected_paths):
|
|
832
|
+
missing = sorted(set(expected_paths).difference(actual_pye))
|
|
833
|
+
extra = sorted(actual_pye.difference(expected_paths))
|
|
834
|
+
details = []
|
|
835
|
+
if missing:
|
|
836
|
+
details.append("missing " + ", ".join(missing))
|
|
837
|
+
if extra:
|
|
838
|
+
details.append("unexpected " + ", ".join(extra))
|
|
839
|
+
raise IntegrityError(
|
|
840
|
+
"pyencode runtime: protected artifact file set failed integrity check ("
|
|
841
|
+
+ "; ".join(details)
|
|
842
|
+
+ ")"
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
for relative, (token, fullname) in sorted(expected_paths.items()):
|
|
846
|
+
path = inventory[relative]
|
|
847
|
+
try:
|
|
848
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
849
|
+
except OSError as exc:
|
|
850
|
+
_fail(f"cannot read protected module {fullname!r}", cause=exc)
|
|
851
|
+
if digest != artifacts[token]:
|
|
852
|
+
raise IntegrityError(
|
|
853
|
+
f"pyencode runtime: protected module {fullname!r} failed its hash check"
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
expected_files = signed_paths | set(expected_paths) | {_build.MANIFEST_NAME}
|
|
857
|
+
actual_files = set(inventory)
|
|
858
|
+
missing_files = sorted(expected_files.difference(actual_files))
|
|
859
|
+
unexpected_files = (
|
|
860
|
+
[]
|
|
861
|
+
if allow_extra_data
|
|
862
|
+
else sorted(actual_files.difference(expected_files))
|
|
863
|
+
)
|
|
864
|
+
if missing_files or unexpected_files:
|
|
865
|
+
details = []
|
|
866
|
+
if missing_files:
|
|
867
|
+
details.append("missing " + ", ".join(missing_files))
|
|
868
|
+
if unexpected_files:
|
|
869
|
+
details.append("unexpected " + ", ".join(unexpected_files))
|
|
870
|
+
raise IntegrityError(
|
|
871
|
+
"pyencode runtime: immutable distribution file set changed ("
|
|
872
|
+
+ "; ".join(details)
|
|
873
|
+
+ ")"
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
expected_directories: set[str] = set()
|
|
877
|
+
for relative in expected_files:
|
|
878
|
+
parts = PurePosixPath(relative).parts
|
|
879
|
+
for end in range(1, len(parts)):
|
|
880
|
+
expected_directories.add("/".join(parts[:end]))
|
|
881
|
+
missing = sorted(expected_directories.difference(directories))
|
|
882
|
+
extra = (
|
|
883
|
+
[]
|
|
884
|
+
if allow_extra_data
|
|
885
|
+
else sorted(set(directories).difference(expected_directories))
|
|
886
|
+
)
|
|
887
|
+
if missing or extra:
|
|
888
|
+
details = []
|
|
889
|
+
if missing:
|
|
890
|
+
details.append("missing " + ", ".join(missing))
|
|
891
|
+
if extra:
|
|
892
|
+
details.append("unexpected " + ", ".join(extra))
|
|
893
|
+
raise IntegrityError(
|
|
894
|
+
"pyencode runtime: immutable distribution directory set changed ("
|
|
895
|
+
+ "; ".join(details)
|
|
896
|
+
+ ")"
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
class _ResourceReader(_ResourceReaderABC):
|
|
901
|
+
def __init__(self, directory: Path, root: Path) -> None:
|
|
902
|
+
self._directory = directory
|
|
903
|
+
self._root = root
|
|
904
|
+
|
|
905
|
+
def _resource(self, resource: str) -> Path:
|
|
906
|
+
if (
|
|
907
|
+
not isinstance(resource, str)
|
|
908
|
+
or not resource
|
|
909
|
+
or resource in (".", "..")
|
|
910
|
+
or "/" in resource
|
|
911
|
+
or "\\" in resource
|
|
912
|
+
):
|
|
913
|
+
raise FileNotFoundError(resource)
|
|
914
|
+
path = (self._directory / resource).resolve()
|
|
915
|
+
try:
|
|
916
|
+
path.relative_to(self._root)
|
|
917
|
+
except ValueError as exc:
|
|
918
|
+
raise FileNotFoundError(resource) from exc
|
|
919
|
+
return path
|
|
920
|
+
|
|
921
|
+
def open_resource(self, resource: str):
|
|
922
|
+
return self._resource(resource).open("rb")
|
|
923
|
+
|
|
924
|
+
def resource_path(self, resource: str) -> str:
|
|
925
|
+
path = self._resource(resource)
|
|
926
|
+
if not path.is_file():
|
|
927
|
+
raise FileNotFoundError(resource)
|
|
928
|
+
return str(path)
|
|
929
|
+
|
|
930
|
+
def is_resource(self, name: str) -> bool:
|
|
931
|
+
try:
|
|
932
|
+
return self._resource(name).is_file()
|
|
933
|
+
except (FileNotFoundError, OSError):
|
|
934
|
+
return False
|
|
935
|
+
|
|
936
|
+
def contents(self) -> Iterator[str]:
|
|
937
|
+
try:
|
|
938
|
+
return (item.name for item in self._directory.iterdir())
|
|
939
|
+
except OSError:
|
|
940
|
+
return iter(())
|
|
941
|
+
|
|
942
|
+
def files(self):
|
|
943
|
+
"""Expose a Traversable root for nested importlib.resources paths."""
|
|
944
|
+
|
|
945
|
+
return self._directory
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
class _ProtectedLoader(importlib.abc.Loader):
|
|
949
|
+
def __init__(self, finder: "_ProtectedFinder", fullname: str) -> None:
|
|
950
|
+
self._finder = finder
|
|
951
|
+
self._fullname = fullname
|
|
952
|
+
|
|
953
|
+
def create_module(self, spec):
|
|
954
|
+
return None
|
|
955
|
+
|
|
956
|
+
def exec_module(self, module: types.ModuleType) -> None:
|
|
957
|
+
self._finder._execute(self._fullname, module.__dict__)
|
|
958
|
+
|
|
959
|
+
def get_code(self, fullname: str) -> types.CodeType:
|
|
960
|
+
"""Return only a tiny runpy trampoline, never decrypted application code."""
|
|
961
|
+
|
|
962
|
+
if fullname != self._fullname:
|
|
963
|
+
raise ImportError(f"loader for {self._fullname!r} cannot load {fullname!r}")
|
|
964
|
+
source = (
|
|
965
|
+
"from pyencode_runtime._runtime import _exec_runpy as __pye_exec\n"
|
|
966
|
+
f"__pye_exec({fullname!r}, globals())\n"
|
|
967
|
+
"del __pye_exec\n"
|
|
968
|
+
)
|
|
969
|
+
return compile(
|
|
970
|
+
source,
|
|
971
|
+
f"<pyencode-runpy:{fullname}>",
|
|
972
|
+
"exec",
|
|
973
|
+
dont_inherit=True,
|
|
974
|
+
optimize=2,
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
def get_source(self, fullname: str) -> None:
|
|
978
|
+
if fullname != self._fullname:
|
|
979
|
+
raise ImportError(f"loader for {self._fullname!r} cannot load {fullname!r}")
|
|
980
|
+
return None
|
|
981
|
+
|
|
982
|
+
def is_package(self, fullname: str) -> bool:
|
|
983
|
+
return bool(self._finder._metadata(fullname)["package"])
|
|
984
|
+
|
|
985
|
+
def get_filename(self, fullname: str) -> str:
|
|
986
|
+
return str(self._finder._artifact_path(fullname))
|
|
987
|
+
|
|
988
|
+
def get_data(self, path: str) -> bytes:
|
|
989
|
+
candidate = Path(path).resolve()
|
|
990
|
+
try:
|
|
991
|
+
candidate.relative_to(self._finder._root)
|
|
992
|
+
except ValueError as exc:
|
|
993
|
+
raise OSError(f"resource is outside the protected distribution: {path}") from exc
|
|
994
|
+
return candidate.read_bytes()
|
|
995
|
+
|
|
996
|
+
def get_resource_reader(self, fullname: str):
|
|
997
|
+
if fullname != self._fullname or not self.is_package(fullname):
|
|
998
|
+
return None
|
|
999
|
+
return _ResourceReader(
|
|
1000
|
+
self._finder._artifact_path(fullname).parent,
|
|
1001
|
+
self._finder._root,
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
class _ProtectedFinder(importlib.abc.MetaPathFinder):
|
|
1006
|
+
def __init__(
|
|
1007
|
+
self,
|
|
1008
|
+
payload: dict[str, Any],
|
|
1009
|
+
entry: str,
|
|
1010
|
+
modules: dict[str, dict[str, Any]],
|
|
1011
|
+
artifacts: dict[str, str],
|
|
1012
|
+
root: Path,
|
|
1013
|
+
build_id: bytes,
|
|
1014
|
+
support_commitment: bytes,
|
|
1015
|
+
index_commitment: bytes,
|
|
1016
|
+
) -> None:
|
|
1017
|
+
self._payload = payload
|
|
1018
|
+
self._entry = entry
|
|
1019
|
+
self._modules = modules
|
|
1020
|
+
self._artifacts = artifacts
|
|
1021
|
+
self._root = root
|
|
1022
|
+
self._build_id = build_id
|
|
1023
|
+
self._support_commitment = support_commitment
|
|
1024
|
+
self._index_commitment = index_commitment
|
|
1025
|
+
|
|
1026
|
+
def _parent_path_represented(self, fullname: str, path: object) -> bool:
|
|
1027
|
+
if "." not in fullname:
|
|
1028
|
+
return True
|
|
1029
|
+
if path is None:
|
|
1030
|
+
return False
|
|
1031
|
+
logical_parent = self._root.joinpath(*fullname.split(".")[:-1]).resolve()
|
|
1032
|
+
try:
|
|
1033
|
+
locations = iter(path)
|
|
1034
|
+
except TypeError:
|
|
1035
|
+
return False
|
|
1036
|
+
for raw_location in locations:
|
|
1037
|
+
try:
|
|
1038
|
+
if Path(raw_location).resolve() == logical_parent:
|
|
1039
|
+
return True
|
|
1040
|
+
except (OSError, TypeError, ValueError):
|
|
1041
|
+
continue
|
|
1042
|
+
return False
|
|
1043
|
+
|
|
1044
|
+
def find_spec(self, fullname: str, path=None, target=None):
|
|
1045
|
+
metadata = self._modules.get(fullname)
|
|
1046
|
+
if metadata is None:
|
|
1047
|
+
return None
|
|
1048
|
+
if not self._parent_path_represented(fullname, path):
|
|
1049
|
+
return None
|
|
1050
|
+
loader = _ProtectedLoader(self, fullname)
|
|
1051
|
+
artifact = self._artifact_path(fullname)
|
|
1052
|
+
spec = importlib.util.spec_from_loader(
|
|
1053
|
+
fullname,
|
|
1054
|
+
loader,
|
|
1055
|
+
origin=str(artifact),
|
|
1056
|
+
is_package=bool(metadata["package"]),
|
|
1057
|
+
)
|
|
1058
|
+
spec.has_location = True
|
|
1059
|
+
if spec.submodule_search_locations is not None:
|
|
1060
|
+
location = str(artifact.parent)
|
|
1061
|
+
if location not in spec.submodule_search_locations:
|
|
1062
|
+
spec.submodule_search_locations.append(location)
|
|
1063
|
+
return spec
|
|
1064
|
+
|
|
1065
|
+
def invalidate_caches(self) -> None:
|
|
1066
|
+
return None
|
|
1067
|
+
|
|
1068
|
+
def _metadata(self, fullname: str) -> dict[str, Any]:
|
|
1069
|
+
try:
|
|
1070
|
+
return self._modules[fullname]
|
|
1071
|
+
except KeyError as exc:
|
|
1072
|
+
raise ImportError(f"unknown protected module {fullname!r}") from exc
|
|
1073
|
+
|
|
1074
|
+
def _artifact_path(self, fullname: str) -> Path:
|
|
1075
|
+
metadata = self._metadata(fullname)
|
|
1076
|
+
relative = _artifact_relative_path(
|
|
1077
|
+
fullname, metadata["package"], metadata["artifact"]
|
|
1078
|
+
)
|
|
1079
|
+
return _exact_file(self._root, relative)
|
|
1080
|
+
|
|
1081
|
+
def _execute(self, fullname: str, namespace: dict[str, Any]) -> None:
|
|
1082
|
+
_check_policy(self._payload)
|
|
1083
|
+
metadata = self._metadata(fullname)
|
|
1084
|
+
path = self._artifact_path(fullname)
|
|
1085
|
+
try:
|
|
1086
|
+
artifact = path.read_bytes()
|
|
1087
|
+
except OSError as exc:
|
|
1088
|
+
_fail(f"cannot read protected module {fullname!r}", cause=exc)
|
|
1089
|
+
token = metadata["artifact"]
|
|
1090
|
+
if hashlib.sha256(artifact).hexdigest() != self._artifacts[token]:
|
|
1091
|
+
raise IntegrityError(
|
|
1092
|
+
f"pyencode runtime: protected module {fullname!r} failed its hash check"
|
|
1093
|
+
)
|
|
1094
|
+
minimum = len(_MAGIC) + _NONCE_SIZE + _TAG_SIZE
|
|
1095
|
+
if len(artifact) < minimum or artifact[: len(_MAGIC)] != _MAGIC:
|
|
1096
|
+
_fail(f"protected module {fullname!r} has invalid PYE2 framing")
|
|
1097
|
+
nonce_start = len(_MAGIC)
|
|
1098
|
+
ciphertext_start = nonce_start + _NONCE_SIZE
|
|
1099
|
+
nonce = artifact[nonce_start:ciphertext_start]
|
|
1100
|
+
ciphertext = artifact[ciphertext_start:]
|
|
1101
|
+
embedded = _embedded_master_key(self._build_id)
|
|
1102
|
+
try:
|
|
1103
|
+
support_root = _derive_support_root(
|
|
1104
|
+
embedded, self._build_id, self._support_commitment
|
|
1105
|
+
)
|
|
1106
|
+
module_root = _derive_module_root(
|
|
1107
|
+
support_root, self._build_id, self._index_commitment
|
|
1108
|
+
)
|
|
1109
|
+
finally:
|
|
1110
|
+
for index in range(len(embedded)):
|
|
1111
|
+
embedded[index] = 0
|
|
1112
|
+
key = _derive_artifact_key(module_root, self._build_id, token, fullname)
|
|
1113
|
+
aad = _artifact_aad(
|
|
1114
|
+
fullname=fullname,
|
|
1115
|
+
package=metadata["package"],
|
|
1116
|
+
token=token,
|
|
1117
|
+
build_id=_build.BUILD_ID,
|
|
1118
|
+
python_tag=_build.PYTHON_TAG,
|
|
1119
|
+
)
|
|
1120
|
+
try:
|
|
1121
|
+
from cryptography.exceptions import InvalidTag
|
|
1122
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
1123
|
+
except ImportError as exc: # pragma: no cover - installation dependent
|
|
1124
|
+
_fail("the 'cryptography' package is required", cause=exc)
|
|
1125
|
+
try:
|
|
1126
|
+
compressed = AESGCM(key).decrypt(nonce, ciphertext, aad)
|
|
1127
|
+
except InvalidTag as exc:
|
|
1128
|
+
raise IntegrityError(
|
|
1129
|
+
f"pyencode runtime: authentication failed for module {fullname!r}"
|
|
1130
|
+
) from exc
|
|
1131
|
+
marshalled = _decompress_strict(
|
|
1132
|
+
compressed, _MAX_MARSHALLED_SIZE, f"module payload for {fullname!r}"
|
|
1133
|
+
)
|
|
1134
|
+
try:
|
|
1135
|
+
code = marshal.loads(marshalled)
|
|
1136
|
+
except (EOFError, TypeError, ValueError) as exc:
|
|
1137
|
+
_fail(f"invalid marshalled code for module {fullname!r}", cause=exc)
|
|
1138
|
+
if not isinstance(code, types.CodeType):
|
|
1139
|
+
_fail(f"payload for module {fullname!r} is not a Python code object")
|
|
1140
|
+
exec(code, namespace)
|
|
1141
|
+
|
|
1142
|
+
def _resolved_entry(self) -> tuple[str, dict[str, Any]]:
|
|
1143
|
+
entry = self._entry
|
|
1144
|
+
metadata = self._metadata(entry)
|
|
1145
|
+
if metadata["package"]:
|
|
1146
|
+
candidate = f"{entry}.__main__"
|
|
1147
|
+
if candidate not in self._modules:
|
|
1148
|
+
_fail(f"entry package {entry!r} has no protected __main__ module")
|
|
1149
|
+
entry = candidate
|
|
1150
|
+
metadata = self._metadata(entry)
|
|
1151
|
+
return entry, metadata
|
|
1152
|
+
|
|
1153
|
+
def _protected_entry_names(self) -> frozenset[str]:
|
|
1154
|
+
entry, _metadata = self._resolved_entry()
|
|
1155
|
+
return frozenset({self._entry, entry})
|
|
1156
|
+
|
|
1157
|
+
def _prepare_parent(self, entry: str) -> str:
|
|
1158
|
+
parent = entry.rpartition(".")[0]
|
|
1159
|
+
if not parent:
|
|
1160
|
+
return ""
|
|
1161
|
+
parts = parent.split(".")
|
|
1162
|
+
for end in range(1, len(parts) + 1):
|
|
1163
|
+
name = ".".join(parts[:end])
|
|
1164
|
+
child_name = name + ".__pyencode_child__"
|
|
1165
|
+
existing = sys.modules.get(name)
|
|
1166
|
+
if existing is not None:
|
|
1167
|
+
locations = getattr(existing, "__path__", None)
|
|
1168
|
+
if not self._parent_path_represented(child_name, locations):
|
|
1169
|
+
raise IntegrityError(
|
|
1170
|
+
f"pyencode runtime: parent package {name!r} for {entry!r} "
|
|
1171
|
+
"does not include the protected distribution path"
|
|
1172
|
+
)
|
|
1173
|
+
continue
|
|
1174
|
+
|
|
1175
|
+
try:
|
|
1176
|
+
spec = importlib.util.find_spec(name)
|
|
1177
|
+
except (ImportError, AttributeError, ValueError) as exc:
|
|
1178
|
+
raise IntegrityError(
|
|
1179
|
+
f"pyencode runtime: cannot resolve parent package {name!r} "
|
|
1180
|
+
f"for {entry!r}"
|
|
1181
|
+
) from exc
|
|
1182
|
+
locations = None if spec is None else spec.submodule_search_locations
|
|
1183
|
+
if not self._parent_path_represented(child_name, locations):
|
|
1184
|
+
raise IntegrityError(
|
|
1185
|
+
f"pyencode runtime: parent package {name!r} for {entry!r} "
|
|
1186
|
+
"does not include the protected distribution path"
|
|
1187
|
+
)
|
|
1188
|
+
importlib.import_module(name)
|
|
1189
|
+
return parent
|
|
1190
|
+
|
|
1191
|
+
def _multiprocessing_main(self, namespace: dict[str, Any]) -> None:
|
|
1192
|
+
entry, metadata = self._resolved_entry()
|
|
1193
|
+
parent = self._prepare_parent(entry)
|
|
1194
|
+
loader = _ProtectedLoader(self, entry)
|
|
1195
|
+
artifact = self._artifact_path(entry)
|
|
1196
|
+
spec = importlib.util.spec_from_loader(
|
|
1197
|
+
entry,
|
|
1198
|
+
loader,
|
|
1199
|
+
origin=str(artifact),
|
|
1200
|
+
is_package=bool(metadata["package"]),
|
|
1201
|
+
)
|
|
1202
|
+
spec.has_location = True
|
|
1203
|
+
namespace.update(
|
|
1204
|
+
{
|
|
1205
|
+
"__file__": str(artifact),
|
|
1206
|
+
"__cached__": None,
|
|
1207
|
+
"__loader__": loader,
|
|
1208
|
+
"__package__": entry if metadata["package"] else parent,
|
|
1209
|
+
"__spec__": spec,
|
|
1210
|
+
}
|
|
1211
|
+
)
|
|
1212
|
+
self._execute(entry, namespace)
|
|
1213
|
+
|
|
1214
|
+
def _run_entry(self) -> dict[str, Any]:
|
|
1215
|
+
entry, metadata = self._resolved_entry()
|
|
1216
|
+
parent = self._prepare_parent(entry)
|
|
1217
|
+
loader = _ProtectedLoader(self, entry)
|
|
1218
|
+
artifact = self._artifact_path(entry)
|
|
1219
|
+
spec = importlib.util.spec_from_loader(
|
|
1220
|
+
entry,
|
|
1221
|
+
loader,
|
|
1222
|
+
origin=str(artifact),
|
|
1223
|
+
is_package=bool(metadata["package"]),
|
|
1224
|
+
)
|
|
1225
|
+
spec.has_location = True
|
|
1226
|
+
namespace: dict[str, Any] = {
|
|
1227
|
+
"__name__": "__main__",
|
|
1228
|
+
"__file__": str(artifact),
|
|
1229
|
+
"__cached__": None,
|
|
1230
|
+
"__loader__": loader,
|
|
1231
|
+
"__package__": entry if metadata["package"] else parent,
|
|
1232
|
+
"__spec__": spec,
|
|
1233
|
+
}
|
|
1234
|
+
main_module = types.ModuleType("__main__")
|
|
1235
|
+
main_module.__dict__.update(namespace)
|
|
1236
|
+
previous_main = sys.modules.get("__main__")
|
|
1237
|
+
previous_argv0 = sys.argv[0] if sys.argv else None
|
|
1238
|
+
sys.modules["__main__"] = main_module
|
|
1239
|
+
if sys.argv:
|
|
1240
|
+
sys.argv[0] = str(artifact)
|
|
1241
|
+
try:
|
|
1242
|
+
self._execute(entry, main_module.__dict__)
|
|
1243
|
+
return main_module.__dict__
|
|
1244
|
+
finally:
|
|
1245
|
+
if previous_main is None:
|
|
1246
|
+
sys.modules.pop("__main__", None)
|
|
1247
|
+
else:
|
|
1248
|
+
sys.modules["__main__"] = previous_main
|
|
1249
|
+
if sys.argv and previous_argv0 is not None:
|
|
1250
|
+
sys.argv[0] = previous_argv0
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
_install_lock = threading.RLock()
|
|
1254
|
+
_installed_finder: _ProtectedFinder | None = None
|
|
1255
|
+
_multiprocessing_patch_installed = False
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _exec_runpy(fullname: str, namespace: dict[str, Any]) -> None:
|
|
1259
|
+
"""Execute protected code into runpy's namespace without returning it."""
|
|
1260
|
+
|
|
1261
|
+
finder = _installed_finder
|
|
1262
|
+
if finder is None:
|
|
1263
|
+
_fail("runpy requested a protected module before runtime installation")
|
|
1264
|
+
finder._execute(fullname, namespace)
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def _multiprocessing_bootstrap(namespace: dict[str, Any]) -> None:
|
|
1268
|
+
"""Populate multiprocessing's ``__mp_main__`` with the protected entry."""
|
|
1269
|
+
|
|
1270
|
+
install()
|
|
1271
|
+
finder = _installed_finder
|
|
1272
|
+
if finder is None: # pragma: no cover - defensive invariant
|
|
1273
|
+
_fail("multiprocessing bootstrap could not install the runtime")
|
|
1274
|
+
finder._multiprocessing_main(namespace)
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _validate_preloaded_modules(
|
|
1278
|
+
modules: Mapping[str, Mapping[str, Any]], root: Path, finder: _ProtectedFinder | None
|
|
1279
|
+
) -> None:
|
|
1280
|
+
missing = object()
|
|
1281
|
+
protected_names = set(modules)
|
|
1282
|
+
parent_names: set[str] = set()
|
|
1283
|
+
for fullname in protected_names:
|
|
1284
|
+
parts = fullname.split(".")
|
|
1285
|
+
parent_names.update(".".join(parts[:end]) for end in range(1, len(parts)))
|
|
1286
|
+
|
|
1287
|
+
for fullname in sorted(protected_names):
|
|
1288
|
+
existing = sys.modules.get(fullname, missing)
|
|
1289
|
+
if existing is missing:
|
|
1290
|
+
continue
|
|
1291
|
+
spec = getattr(existing, "__spec__", None)
|
|
1292
|
+
loader = getattr(spec, "loader", None)
|
|
1293
|
+
if (
|
|
1294
|
+
finder is not None
|
|
1295
|
+
and isinstance(loader, _ProtectedLoader)
|
|
1296
|
+
and loader._finder is finder
|
|
1297
|
+
and loader._fullname == fullname
|
|
1298
|
+
and getattr(spec, "name", None) == fullname
|
|
1299
|
+
and getattr(existing, "__loader__", None) is loader
|
|
1300
|
+
and getattr(spec, "origin", None) == str(finder._artifact_path(fullname))
|
|
1301
|
+
):
|
|
1302
|
+
continue
|
|
1303
|
+
raise IntegrityError(
|
|
1304
|
+
f"pyencode runtime: protected module {fullname!r} was preloaded "
|
|
1305
|
+
"outside the verified finder"
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
for parent in sorted(parent_names.difference(protected_names)):
|
|
1309
|
+
existing = sys.modules.get(parent, missing)
|
|
1310
|
+
if existing is missing:
|
|
1311
|
+
continue
|
|
1312
|
+
expected = root.joinpath(*parent.split(".")).resolve()
|
|
1313
|
+
locations = getattr(existing, "__path__", None)
|
|
1314
|
+
represented = False
|
|
1315
|
+
if locations is not None:
|
|
1316
|
+
for raw_location in locations:
|
|
1317
|
+
try:
|
|
1318
|
+
if Path(raw_location).resolve() == expected:
|
|
1319
|
+
represented = True
|
|
1320
|
+
break
|
|
1321
|
+
except (OSError, TypeError, ValueError):
|
|
1322
|
+
continue
|
|
1323
|
+
if not represented:
|
|
1324
|
+
raise IntegrityError(
|
|
1325
|
+
f"pyencode runtime: protected parent namespace {parent!r} was "
|
|
1326
|
+
"preloaded outside the verified distribution"
|
|
1327
|
+
)
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def _enable_multiprocessing_bootstrap() -> None:
|
|
1331
|
+
global _multiprocessing_patch_installed
|
|
1332
|
+
if _multiprocessing_patch_installed:
|
|
1333
|
+
return
|
|
1334
|
+
try:
|
|
1335
|
+
import multiprocessing.spawn as spawn
|
|
1336
|
+
except ImportError: # pragma: no cover - CPython always ships multiprocessing
|
|
1337
|
+
return
|
|
1338
|
+
original = spawn.get_preparation_data
|
|
1339
|
+
|
|
1340
|
+
def protected_preparation_data(name):
|
|
1341
|
+
data = original(name)
|
|
1342
|
+
finder = _installed_finder
|
|
1343
|
+
initial_name = data.get("init_main_from_name")
|
|
1344
|
+
if finder is not None and initial_name in finder._protected_entry_names():
|
|
1345
|
+
data.pop("init_main_from_path", None)
|
|
1346
|
+
data["init_main_from_name"] = "pyencode_runtime._mp_main"
|
|
1347
|
+
return data
|
|
1348
|
+
|
|
1349
|
+
spawn.get_preparation_data = protected_preparation_data
|
|
1350
|
+
# This is consumed when a fresh spawn interpreter starts, before it imports
|
|
1351
|
+
# the signed runtime package and could otherwise create an unsigned cache.
|
|
1352
|
+
os.environ["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
1353
|
+
_multiprocessing_patch_installed = True
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def _verified_build_state() -> tuple[
|
|
1357
|
+
dict[str, Any],
|
|
1358
|
+
str,
|
|
1359
|
+
dict[str, dict[str, Any]],
|
|
1360
|
+
dict[str, str],
|
|
1361
|
+
Path,
|
|
1362
|
+
bytes,
|
|
1363
|
+
bytes,
|
|
1364
|
+
bytes,
|
|
1365
|
+
]:
|
|
1366
|
+
build_id = _validate_build_config()
|
|
1367
|
+
payload, root = _read_manifest()
|
|
1368
|
+
inventory, directories = _scan_distribution(root)
|
|
1369
|
+
artifacts, nonce, ciphertext, index_commitment = _validate_manifest(
|
|
1370
|
+
payload, build_id
|
|
1371
|
+
)
|
|
1372
|
+
allow_extra_data = _allow_extra_data(payload)
|
|
1373
|
+
support_commitment, signed_paths = _validate_bound_files(payload, root, inventory)
|
|
1374
|
+
embedded = _embedded_master_key(build_id)
|
|
1375
|
+
try:
|
|
1376
|
+
support_root = _derive_support_root(embedded, build_id, support_commitment)
|
|
1377
|
+
entry, modules = _decrypt_index(nonce, ciphertext, support_root, build_id)
|
|
1378
|
+
finally:
|
|
1379
|
+
for index in range(len(embedded)):
|
|
1380
|
+
embedded[index] = 0
|
|
1381
|
+
if {metadata["artifact"] for metadata in modules.values()} != set(artifacts):
|
|
1382
|
+
_fail("artifact token set does not match decrypted module index")
|
|
1383
|
+
_validate_artifacts(
|
|
1384
|
+
root,
|
|
1385
|
+
modules,
|
|
1386
|
+
artifacts,
|
|
1387
|
+
inventory,
|
|
1388
|
+
directories,
|
|
1389
|
+
signed_paths,
|
|
1390
|
+
allow_extra_data,
|
|
1391
|
+
)
|
|
1392
|
+
return (
|
|
1393
|
+
payload,
|
|
1394
|
+
entry,
|
|
1395
|
+
modules,
|
|
1396
|
+
artifacts,
|
|
1397
|
+
root,
|
|
1398
|
+
build_id,
|
|
1399
|
+
support_commitment,
|
|
1400
|
+
index_commitment,
|
|
1401
|
+
)
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def install() -> None:
|
|
1405
|
+
"""Verify this distribution and install its exec-only import hook.
|
|
1406
|
+
|
|
1407
|
+
Repeated calls fully revalidate signed support files and encrypted metadata.
|
|
1408
|
+
The function intentionally returns no finder or key-bearing state.
|
|
1409
|
+
"""
|
|
1410
|
+
|
|
1411
|
+
global _installed_finder
|
|
1412
|
+
with _install_lock:
|
|
1413
|
+
(
|
|
1414
|
+
payload,
|
|
1415
|
+
entry,
|
|
1416
|
+
modules,
|
|
1417
|
+
artifacts,
|
|
1418
|
+
root,
|
|
1419
|
+
build_id,
|
|
1420
|
+
support_commitment,
|
|
1421
|
+
index_commitment,
|
|
1422
|
+
) = _verified_build_state()
|
|
1423
|
+
_validate_preloaded_modules(modules, root, _installed_finder)
|
|
1424
|
+
if _installed_finder is not None:
|
|
1425
|
+
finder = _installed_finder
|
|
1426
|
+
stable = (
|
|
1427
|
+
finder._entry == entry
|
|
1428
|
+
and finder._modules == modules
|
|
1429
|
+
and finder._artifacts == artifacts
|
|
1430
|
+
and finder._root == root
|
|
1431
|
+
and finder._build_id == build_id
|
|
1432
|
+
and finder._support_commitment == support_commitment
|
|
1433
|
+
and finder._index_commitment == index_commitment
|
|
1434
|
+
)
|
|
1435
|
+
if not stable:
|
|
1436
|
+
raise IntegrityError(
|
|
1437
|
+
"pyencode runtime: verified build state changed after installation"
|
|
1438
|
+
)
|
|
1439
|
+
finder._payload = payload
|
|
1440
|
+
if finder not in sys.meta_path:
|
|
1441
|
+
sys.meta_path.insert(0, finder)
|
|
1442
|
+
_enable_multiprocessing_bootstrap()
|
|
1443
|
+
return None
|
|
1444
|
+
finder = _ProtectedFinder(
|
|
1445
|
+
payload,
|
|
1446
|
+
entry,
|
|
1447
|
+
modules,
|
|
1448
|
+
artifacts,
|
|
1449
|
+
root,
|
|
1450
|
+
build_id,
|
|
1451
|
+
support_commitment,
|
|
1452
|
+
index_commitment,
|
|
1453
|
+
)
|
|
1454
|
+
sys.meta_path.insert(0, finder)
|
|
1455
|
+
_installed_finder = finder
|
|
1456
|
+
_enable_multiprocessing_bootstrap()
|
|
1457
|
+
return None
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def run() -> dict[str, Any]:
|
|
1461
|
+
"""Verify, install, and execute the encrypted entry as ``__main__``."""
|
|
1462
|
+
|
|
1463
|
+
install()
|
|
1464
|
+
finder = _installed_finder
|
|
1465
|
+
if finder is None: # pragma: no cover - defensive invariant
|
|
1466
|
+
_fail("protected-module finder was not installed")
|
|
1467
|
+
return finder._run_entry()
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def load_entry() -> types.ModuleType:
|
|
1471
|
+
"""Verify, install, and import the encrypted entry for an embedding host."""
|
|
1472
|
+
|
|
1473
|
+
install()
|
|
1474
|
+
finder = _installed_finder
|
|
1475
|
+
if finder is None: # pragma: no cover - defensive invariant
|
|
1476
|
+
_fail("protected-module finder was not installed")
|
|
1477
|
+
return importlib.import_module(finder._entry)
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
__all__ = [
|
|
1481
|
+
"CompatibilityError",
|
|
1482
|
+
"IntegrityError",
|
|
1483
|
+
"PolicyError",
|
|
1484
|
+
"PyEncodeRuntimeError",
|
|
1485
|
+
"install",
|
|
1486
|
+
"load_entry",
|
|
1487
|
+
"run",
|
|
1488
|
+
]
|