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
pyencode/__init__.py
ADDED
pyencode/__main__.py
ADDED
pyencode/builder.py
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
"""Build encrypted module trees and their portable Python runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import ast
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import marshal
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import tokenize
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import date, datetime, timezone
|
|
18
|
+
from pathlib import Path, PurePosixPath
|
|
19
|
+
from typing import Sequence
|
|
20
|
+
|
|
21
|
+
from cryptography.hazmat.primitives import serialization
|
|
22
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
23
|
+
|
|
24
|
+
from . import __version__
|
|
25
|
+
from .container import canonical_json_bytes
|
|
26
|
+
from .code_hardening import harden_code
|
|
27
|
+
from .crypto import derive_bound_master_key
|
|
28
|
+
from .discovery import ProjectLayout, discover_project
|
|
29
|
+
from .errors import PyEncodeError
|
|
30
|
+
from .integrity import (
|
|
31
|
+
BOUND_FILES_ALGORITHM,
|
|
32
|
+
BOUND_FILES_FORMAT,
|
|
33
|
+
bound_files_commitment,
|
|
34
|
+
)
|
|
35
|
+
from .inventory import (
|
|
36
|
+
discover_inventory_files,
|
|
37
|
+
hash_inventory_files,
|
|
38
|
+
validate_inventory_path,
|
|
39
|
+
)
|
|
40
|
+
from .opaque import (
|
|
41
|
+
OpaqueModuleContext,
|
|
42
|
+
derive_module_root_key,
|
|
43
|
+
encrypt_module_index,
|
|
44
|
+
module_index_commitment,
|
|
45
|
+
pack_opaque_artifact,
|
|
46
|
+
)
|
|
47
|
+
from .source import prepare_source
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
MANIFEST_NAME = ".pyencode-manifest.json"
|
|
51
|
+
RUNTIME_TEMPLATE_FILES = (
|
|
52
|
+
"__init__.py",
|
|
53
|
+
"_mp_main.py",
|
|
54
|
+
"_runtime.py",
|
|
55
|
+
)
|
|
56
|
+
RESERVED_TOP_LEVEL_MODULES = frozenset(
|
|
57
|
+
{
|
|
58
|
+
"cryptography",
|
|
59
|
+
"__main__",
|
|
60
|
+
"pyencode_runtime",
|
|
61
|
+
*(name.partition(".")[0].casefold() for name in sys.builtin_module_names),
|
|
62
|
+
*(name.partition(".")[0].casefold() for name in sys.stdlib_module_names),
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
DEFAULT_LAUNCHER = """import sys
|
|
67
|
+
sys.dont_write_bytecode = True
|
|
68
|
+
|
|
69
|
+
import os
|
|
70
|
+
|
|
71
|
+
_runtime_directory = os.path.join(os.path.dirname(os.path.realpath(__file__)), "pyencode_runtime")
|
|
72
|
+
_expected_runtime_files = {"__init__.py", "_mp_main.py", "_runtime.py", "_build.py"}
|
|
73
|
+
try:
|
|
74
|
+
with os.scandir(_runtime_directory) as _runtime_iterator:
|
|
75
|
+
_runtime_entries = tuple(_runtime_iterator)
|
|
76
|
+
except OSError as exc:
|
|
77
|
+
raise ImportError("pyencode runtime directory is missing or unreadable") from exc
|
|
78
|
+
_runtime_names = {entry.name for entry in _runtime_entries}
|
|
79
|
+
_runtime_problems = []
|
|
80
|
+
if _expected_runtime_files - _runtime_names:
|
|
81
|
+
_runtime_problems.append("missing " + ", ".join(sorted(_expected_runtime_files - _runtime_names)))
|
|
82
|
+
if _runtime_names - _expected_runtime_files:
|
|
83
|
+
_runtime_problems.append("unexpected " + ", ".join(sorted(_runtime_names - _expected_runtime_files)))
|
|
84
|
+
_non_files = sorted(
|
|
85
|
+
entry.name
|
|
86
|
+
for entry in _runtime_entries
|
|
87
|
+
if entry.is_symlink() or not entry.is_file(follow_symlinks=False)
|
|
88
|
+
)
|
|
89
|
+
if _non_files:
|
|
90
|
+
_runtime_problems.append("not regular files " + ", ".join(_non_files))
|
|
91
|
+
if _runtime_problems:
|
|
92
|
+
raise ImportError(
|
|
93
|
+
"pyencode runtime directory contains unsigned files or directories ("
|
|
94
|
+
+ "; ".join(_runtime_problems)
|
|
95
|
+
+ ")"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
from pyencode_runtime import run
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
run()
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True, slots=True)
|
|
106
|
+
class BuildOptions:
|
|
107
|
+
source: Path
|
|
108
|
+
output: Path
|
|
109
|
+
entry_module: str | None = None
|
|
110
|
+
excludes: tuple[str, ...] = ()
|
|
111
|
+
include_resources: bool = True
|
|
112
|
+
strip_docstrings: bool = True
|
|
113
|
+
optimize: int = 0
|
|
114
|
+
expires: date | None = None
|
|
115
|
+
launcher: Path | None = None
|
|
116
|
+
rename_locals: bool = False
|
|
117
|
+
support: tuple[tuple[Path, str], ...] = ()
|
|
118
|
+
allow_extra_data: bool = False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class BuildResult:
|
|
123
|
+
output: Path
|
|
124
|
+
entry_module: str
|
|
125
|
+
build_id: str
|
|
126
|
+
python_tag: str
|
|
127
|
+
module_count: int
|
|
128
|
+
resource_count: int
|
|
129
|
+
support_count: int
|
|
130
|
+
launcher_name: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _python_tag() -> str:
|
|
134
|
+
return f"cp{sys.version_info.major}{sys.version_info.minor}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _validate_build_interpreter() -> None:
|
|
138
|
+
version = (sys.version_info.major, sys.version_info.minor)
|
|
139
|
+
if getattr(sys.implementation, "name", None) != "cpython" or version < (3, 10):
|
|
140
|
+
raise PyEncodeError("Builds require standard CPython 3.10 or newer")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _validate_entry(layout: ProjectLayout, requested: str | None) -> str:
|
|
144
|
+
entry = requested or layout.inferred_entry
|
|
145
|
+
if entry is None:
|
|
146
|
+
raise PyEncodeError(
|
|
147
|
+
"Could not infer an entry module; pass --entry (for example --entry myapp.__main__)"
|
|
148
|
+
)
|
|
149
|
+
names = {module.name for module in layout.modules}
|
|
150
|
+
if entry not in names:
|
|
151
|
+
raise PyEncodeError(f"Entry module {entry!r} was not found in the protected source tree")
|
|
152
|
+
return entry
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _validate_reserved_modules(layout: ProjectLayout) -> None:
|
|
156
|
+
conflicts = sorted(
|
|
157
|
+
module.name
|
|
158
|
+
for module in layout.modules
|
|
159
|
+
if module.name.partition(".")[0].casefold() in RESERVED_TOP_LEVEL_MODULES
|
|
160
|
+
)
|
|
161
|
+
if conflicts:
|
|
162
|
+
raise PyEncodeError(
|
|
163
|
+
"Protected source uses a reserved runtime namespace: "
|
|
164
|
+
+ ", ".join(conflicts)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _validate_module_hierarchy(layout: ProjectLayout) -> None:
|
|
169
|
+
package_flags = {module.name: module.is_package for module in layout.modules}
|
|
170
|
+
for module in layout.modules:
|
|
171
|
+
parts = module.name.split(".")
|
|
172
|
+
for end in range(1, len(parts)):
|
|
173
|
+
ancestor = ".".join(parts[:end])
|
|
174
|
+
if ancestor in package_flags and not package_flags[ancestor]:
|
|
175
|
+
raise PyEncodeError(
|
|
176
|
+
f"Protected module {ancestor!r} is not a package but has "
|
|
177
|
+
f"protected child {module.name!r}"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _validate_launcher(launcher: Path | None) -> tuple[Path | None, str]:
|
|
182
|
+
if launcher is None:
|
|
183
|
+
return None, "run.py"
|
|
184
|
+
source = launcher.expanduser().resolve()
|
|
185
|
+
if not source.is_file():
|
|
186
|
+
raise PyEncodeError(f"Launcher does not exist or is not a file: {source}")
|
|
187
|
+
name = source.name
|
|
188
|
+
if source.suffix.lower() != ".py" or not source.stem.isidentifier():
|
|
189
|
+
raise PyEncodeError("Launcher must be a single valid Python filename")
|
|
190
|
+
if name.casefold() == "pyencode_runtime.py":
|
|
191
|
+
raise PyEncodeError("Launcher name would shadow the pyencode_runtime package")
|
|
192
|
+
_validate_custom_launcher_bootstrap(source)
|
|
193
|
+
return source, name
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _validate_custom_launcher_bootstrap(source: Path) -> None:
|
|
197
|
+
try:
|
|
198
|
+
with tokenize.open(source) as stream:
|
|
199
|
+
tree = ast.parse(stream.read(), filename=str(source))
|
|
200
|
+
except (OSError, SyntaxError, UnicodeError) as exc:
|
|
201
|
+
raise PyEncodeError(f"Custom launcher is not valid Python: {source}") from exc
|
|
202
|
+
|
|
203
|
+
sys_aliases: set[str] = set()
|
|
204
|
+
protected = False
|
|
205
|
+
for statement in tree.body:
|
|
206
|
+
if (
|
|
207
|
+
isinstance(statement, ast.Expr)
|
|
208
|
+
and isinstance(statement.value, ast.Constant)
|
|
209
|
+
and isinstance(statement.value.value, str)
|
|
210
|
+
):
|
|
211
|
+
continue
|
|
212
|
+
if isinstance(statement, ast.ImportFrom) and statement.module == "__future__":
|
|
213
|
+
continue
|
|
214
|
+
if isinstance(statement, ast.Import):
|
|
215
|
+
imported = {alias.name for alias in statement.names}
|
|
216
|
+
if not protected and not imported.issubset({"sys", "os"}):
|
|
217
|
+
break
|
|
218
|
+
for alias in statement.names:
|
|
219
|
+
if alias.name == "sys":
|
|
220
|
+
sys_aliases.add(alias.asname or "sys")
|
|
221
|
+
continue
|
|
222
|
+
if isinstance(statement, (ast.Assign, ast.AnnAssign)):
|
|
223
|
+
targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target]
|
|
224
|
+
value = statement.value
|
|
225
|
+
if any(
|
|
226
|
+
isinstance(target, ast.Attribute)
|
|
227
|
+
and target.attr == "dont_write_bytecode"
|
|
228
|
+
and isinstance(target.value, ast.Name)
|
|
229
|
+
and target.value.id in sys_aliases
|
|
230
|
+
for target in targets
|
|
231
|
+
) and isinstance(value, ast.Constant) and value.value is True:
|
|
232
|
+
protected = True
|
|
233
|
+
break
|
|
234
|
+
if not protected:
|
|
235
|
+
break
|
|
236
|
+
if not protected:
|
|
237
|
+
raise PyEncodeError(
|
|
238
|
+
"Custom launcher must set sys.dont_write_bytecode = True before "
|
|
239
|
+
"importing any non-bootstrap module"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _validate_output(source: Path, output: Path) -> Path:
|
|
244
|
+
output = output.expanduser().resolve()
|
|
245
|
+
if output == source or output in source.parents:
|
|
246
|
+
raise PyEncodeError("Output must not replace the source or one of its parent directories")
|
|
247
|
+
if output.exists():
|
|
248
|
+
if not output.is_dir():
|
|
249
|
+
raise PyEncodeError(f"Output exists and is not a directory: {output}")
|
|
250
|
+
if any(output.iterdir()):
|
|
251
|
+
raise PyEncodeError(f"Output directory is not empty: {output}")
|
|
252
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
253
|
+
return output
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _compile_module(path: Path, module_name: str, options: BuildOptions) -> bytes:
|
|
257
|
+
with tokenize.open(path) as source_file:
|
|
258
|
+
text = source_file.read()
|
|
259
|
+
synthetic_filename = f"<pyencode:{module_name}>"
|
|
260
|
+
prepared = prepare_source(text, synthetic_filename, strip_docstrings=options.strip_docstrings)
|
|
261
|
+
code = compile(
|
|
262
|
+
prepared,
|
|
263
|
+
synthetic_filename,
|
|
264
|
+
"exec",
|
|
265
|
+
dont_inherit=True,
|
|
266
|
+
optimize=options.optimize,
|
|
267
|
+
)
|
|
268
|
+
if options.rename_locals:
|
|
269
|
+
code = harden_code(code, module_name=module_name)
|
|
270
|
+
return marshal.dumps(code)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _mask_master_key(master_key: bytes, build_id: str) -> tuple[list[str], list[int]]:
|
|
274
|
+
mask = hashlib.sha256(b"pyencode-key-mask\0" + bytes.fromhex(build_id)).digest()
|
|
275
|
+
masked = bytes(value ^ mask[index] for index, value in enumerate(master_key))
|
|
276
|
+
widths = (5, 7, 4, 8, 8)
|
|
277
|
+
chunks: list[bytes] = []
|
|
278
|
+
offset = 0
|
|
279
|
+
for width in widths:
|
|
280
|
+
chunks.append(masked[offset : offset + width])
|
|
281
|
+
offset += width
|
|
282
|
+
|
|
283
|
+
shuffled_indexes = list(range(len(chunks)))
|
|
284
|
+
secrets.SystemRandom().shuffle(shuffled_indexes)
|
|
285
|
+
stored = [base64.b85encode(chunks[index]).decode("ascii") for index in shuffled_indexes]
|
|
286
|
+
restore_order = [shuffled_indexes.index(index) for index in range(len(chunks))]
|
|
287
|
+
return stored, restore_order
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _write_runtime_config(
|
|
291
|
+
runtime_directory: Path,
|
|
292
|
+
*,
|
|
293
|
+
build_id: str,
|
|
294
|
+
python_tag: str,
|
|
295
|
+
public_key: bytes,
|
|
296
|
+
master_key: bytes,
|
|
297
|
+
launcher_name: str,
|
|
298
|
+
bootstrap_paths: tuple[str, ...],
|
|
299
|
+
) -> None:
|
|
300
|
+
parts, order = _mask_master_key(master_key, build_id)
|
|
301
|
+
content = "\n".join(
|
|
302
|
+
(
|
|
303
|
+
"# Generated by pyencode. Do not edit.",
|
|
304
|
+
f"BUILD_ID = {build_id!r}",
|
|
305
|
+
f"PYTHON_TAG = {python_tag!r}",
|
|
306
|
+
f"PUBLIC_KEY_B64 = {base64.b64encode(public_key).decode('ascii')!r}",
|
|
307
|
+
f"KEY_PARTS = {parts!r}",
|
|
308
|
+
f"KEY_ORDER = {order!r}",
|
|
309
|
+
f"MANIFEST_NAME = {MANIFEST_NAME!r}",
|
|
310
|
+
f"LAUNCHER_NAME = {launcher_name!r}",
|
|
311
|
+
f"BOOTSTRAP_PATHS = {list(bootstrap_paths)!r}",
|
|
312
|
+
"",
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
(runtime_directory / "_build.py").write_text(content, encoding="utf-8", newline="\n")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _copy_runtime(destination: Path) -> None:
|
|
319
|
+
template = Path(__file__).with_name("runtime_template")
|
|
320
|
+
if not template.is_dir():
|
|
321
|
+
raise PyEncodeError(f"Runtime template is missing: {template}")
|
|
322
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
323
|
+
for filename in RUNTIME_TEMPLATE_FILES:
|
|
324
|
+
source = template / filename
|
|
325
|
+
if not source.is_file():
|
|
326
|
+
raise PyEncodeError(f"Runtime template file is missing: {source}")
|
|
327
|
+
shutil.copy2(source, destination / filename)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _write_launcher(destination: Path, source: Path | None, name: str) -> None:
|
|
331
|
+
target = destination / name
|
|
332
|
+
if source is not None:
|
|
333
|
+
shutil.copyfile(source, target)
|
|
334
|
+
else:
|
|
335
|
+
target.write_text(DEFAULT_LAUNCHER, encoding="utf-8", newline="\n")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _write_requirements(destination: Path) -> None:
|
|
339
|
+
(destination / "requirements.txt").write_text(
|
|
340
|
+
"cryptography>=42\n", encoding="utf-8", newline="\n"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _copy_regular_file(source: Path, target: Path, destination: Path) -> None:
|
|
345
|
+
if source.is_symlink():
|
|
346
|
+
raise PyEncodeError(f"Refusing to copy symlink into protected output: {source}")
|
|
347
|
+
if not source.is_file():
|
|
348
|
+
raise PyEncodeError(f"Support source is not a regular file: {source}")
|
|
349
|
+
if target.exists():
|
|
350
|
+
try:
|
|
351
|
+
relative = target.relative_to(destination).as_posix()
|
|
352
|
+
except ValueError:
|
|
353
|
+
relative = str(target)
|
|
354
|
+
raise PyEncodeError(f"Support output path collides with an existing file: {relative}")
|
|
355
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
356
|
+
shutil.copy2(source, target)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _copy_support_inputs(
|
|
360
|
+
destination: Path,
|
|
361
|
+
support: Sequence[tuple[Path, str]],
|
|
362
|
+
launcher_name: str,
|
|
363
|
+
) -> int:
|
|
364
|
+
copied = 0
|
|
365
|
+
for raw_source, raw_target in support:
|
|
366
|
+
target_text = validate_inventory_path(raw_target)
|
|
367
|
+
_validate_generated_path_conflict(target_text, launcher_name)
|
|
368
|
+
source = Path(raw_source).expanduser()
|
|
369
|
+
if source.is_symlink():
|
|
370
|
+
raise PyEncodeError(f"Refusing to copy symlink support input: {source}")
|
|
371
|
+
try:
|
|
372
|
+
source = source.resolve(strict=True)
|
|
373
|
+
except OSError as exc:
|
|
374
|
+
raise PyEncodeError(f"Support input is missing or unreadable: {source}") from exc
|
|
375
|
+
target_root = destination / Path(*target_text.split("/"))
|
|
376
|
+
if source.is_file():
|
|
377
|
+
_copy_regular_file(source, target_root, destination)
|
|
378
|
+
copied += 1
|
|
379
|
+
continue
|
|
380
|
+
if not source.is_dir():
|
|
381
|
+
raise PyEncodeError(f"Support input is not a regular file or directory: {source}")
|
|
382
|
+
for relative_text in discover_inventory_files(source):
|
|
383
|
+
relative = Path(*relative_text.split("/"))
|
|
384
|
+
_copy_regular_file(source / relative, target_root / relative, destination)
|
|
385
|
+
copied += 1
|
|
386
|
+
return copied
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _validate_generated_path_conflict(relative: str, launcher_name: str) -> None:
|
|
390
|
+
parts = relative.split("/")
|
|
391
|
+
if parts[0].casefold() == "pyencode_runtime":
|
|
392
|
+
raise PyEncodeError(
|
|
393
|
+
"Output path uses the reserved pyencode_runtime subtree: " + relative
|
|
394
|
+
)
|
|
395
|
+
if relative.casefold() == MANIFEST_NAME.casefold():
|
|
396
|
+
raise PyEncodeError(f"Output path is reserved for the signed manifest: {relative}")
|
|
397
|
+
if relative.casefold() == launcher_name.casefold():
|
|
398
|
+
raise PyEncodeError(f"Output path collides with the generated launcher: {relative}")
|
|
399
|
+
if PurePosixPath(relative).suffix.casefold() == ".pye":
|
|
400
|
+
raise PyEncodeError(f"Output path uses the reserved .pye artifact suffix: {relative}")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _opaque_artifact_path(module_name: str, is_package: bool, token: str) -> Path:
|
|
404
|
+
parts = module_name.split(".")
|
|
405
|
+
directory_parts = parts if is_package else parts[:-1]
|
|
406
|
+
return Path(*directory_parts, f"{token}.pye")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _public_key_bytes(private_key: Ed25519PrivateKey) -> bytes:
|
|
410
|
+
return private_key.public_key().public_bytes(
|
|
411
|
+
encoding=serialization.Encoding.Raw,
|
|
412
|
+
format=serialization.PublicFormat.Raw,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _signed_manifest(
|
|
417
|
+
payload: dict[str, object], private_key: Ed25519PrivateKey
|
|
418
|
+
) -> dict[str, object]:
|
|
419
|
+
payload_bytes = canonical_json_bytes(payload)
|
|
420
|
+
signature = private_key.sign(payload_bytes)
|
|
421
|
+
wrapper: dict[str, object] = {
|
|
422
|
+
"manifest": payload,
|
|
423
|
+
"signature": base64.b64encode(signature).decode("ascii"),
|
|
424
|
+
}
|
|
425
|
+
return wrapper
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _build_into(
|
|
429
|
+
layout: ProjectLayout,
|
|
430
|
+
destination: Path,
|
|
431
|
+
options: BuildOptions,
|
|
432
|
+
entry_module: str,
|
|
433
|
+
) -> BuildResult:
|
|
434
|
+
build_id = secrets.token_hex(16)
|
|
435
|
+
python_tag = _python_tag()
|
|
436
|
+
master_key = os.urandom(32)
|
|
437
|
+
signing_key = Ed25519PrivateKey.generate()
|
|
438
|
+
public_key = _public_key_bytes(signing_key)
|
|
439
|
+
launcher_source, launcher_name = _validate_launcher(options.launcher)
|
|
440
|
+
module_index_entries: dict[str, dict[str, object]] = {}
|
|
441
|
+
module_tokens: dict[str, str] = {}
|
|
442
|
+
|
|
443
|
+
runtime_directory = destination / "pyencode_runtime"
|
|
444
|
+
_copy_runtime(runtime_directory)
|
|
445
|
+
_write_launcher(destination, launcher_source, launcher_name)
|
|
446
|
+
|
|
447
|
+
for resource in layout.resources:
|
|
448
|
+
_validate_generated_path_conflict(
|
|
449
|
+
resource.output_path.as_posix(), launcher_name
|
|
450
|
+
)
|
|
451
|
+
output_path = destination / resource.output_path
|
|
452
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
453
|
+
if resource.source_path.is_symlink():
|
|
454
|
+
raise PyEncodeError(
|
|
455
|
+
f"Refusing to copy symlink resource: {resource.source_path}"
|
|
456
|
+
)
|
|
457
|
+
shutil.copy2(resource.source_path, output_path)
|
|
458
|
+
# Keep the generated runtime dependency authoritative even when a
|
|
459
|
+
# project-root source tree also contains a requirements.txt resource.
|
|
460
|
+
_write_requirements(destination)
|
|
461
|
+
support_count = _copy_support_inputs(destination, options.support, launcher_name)
|
|
462
|
+
|
|
463
|
+
_write_runtime_config(
|
|
464
|
+
runtime_directory,
|
|
465
|
+
build_id=build_id,
|
|
466
|
+
python_tag=python_tag,
|
|
467
|
+
public_key=public_key,
|
|
468
|
+
master_key=master_key,
|
|
469
|
+
launcher_name=launcher_name,
|
|
470
|
+
bootstrap_paths=tuple(
|
|
471
|
+
sorted(
|
|
472
|
+
{
|
|
473
|
+
"_vendor"
|
|
474
|
+
for _source, target in options.support
|
|
475
|
+
if validate_inventory_path(target) == "_vendor"
|
|
476
|
+
}
|
|
477
|
+
)
|
|
478
|
+
),
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
bound_paths = discover_inventory_files(destination)
|
|
482
|
+
bound_file_digests = hash_inventory_files(destination, bound_paths)
|
|
483
|
+
commitment = bound_files_commitment(bound_file_digests)
|
|
484
|
+
support_root_key = derive_bound_master_key(master_key, build_id, commitment)
|
|
485
|
+
|
|
486
|
+
used_tokens: set[str] = set()
|
|
487
|
+
for module in layout.modules:
|
|
488
|
+
while True:
|
|
489
|
+
token = secrets.token_hex(16)
|
|
490
|
+
if token not in used_tokens:
|
|
491
|
+
break
|
|
492
|
+
used_tokens.add(token)
|
|
493
|
+
module_tokens[module.name] = token
|
|
494
|
+
module_index_entries[module.name] = {
|
|
495
|
+
"artifact": token,
|
|
496
|
+
"package": module.is_package,
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
encrypted_index = encrypt_module_index(
|
|
500
|
+
{
|
|
501
|
+
"format": 1,
|
|
502
|
+
"entry_module": entry_module,
|
|
503
|
+
"modules": module_index_entries,
|
|
504
|
+
},
|
|
505
|
+
support_root_key,
|
|
506
|
+
build_id=build_id,
|
|
507
|
+
python_tag=python_tag,
|
|
508
|
+
)
|
|
509
|
+
index_commitment = module_index_commitment(
|
|
510
|
+
encrypted_index,
|
|
511
|
+
build_id=build_id,
|
|
512
|
+
python_tag=python_tag,
|
|
513
|
+
)
|
|
514
|
+
module_root_key = derive_module_root_key(
|
|
515
|
+
support_root_key,
|
|
516
|
+
build_id,
|
|
517
|
+
index_commitment,
|
|
518
|
+
)
|
|
519
|
+
artifacts_manifest: dict[str, str] = {}
|
|
520
|
+
|
|
521
|
+
for module in layout.modules:
|
|
522
|
+
marshalled = _compile_module(module.source_path, module.name, options)
|
|
523
|
+
token = module_tokens[module.name]
|
|
524
|
+
context = OpaqueModuleContext(
|
|
525
|
+
build_id=build_id,
|
|
526
|
+
python_tag=python_tag,
|
|
527
|
+
module=module.name,
|
|
528
|
+
package=module.is_package,
|
|
529
|
+
token=token,
|
|
530
|
+
marshal_version=marshal.version,
|
|
531
|
+
)
|
|
532
|
+
protected = pack_opaque_artifact(marshalled, module_root_key, context)
|
|
533
|
+
relative_output = _opaque_artifact_path(module.name, module.is_package, token)
|
|
534
|
+
output_path = destination / relative_output
|
|
535
|
+
if output_path.exists():
|
|
536
|
+
raise PyEncodeError(
|
|
537
|
+
f"Opaque artifact path collides with a resource: {relative_output.as_posix()}"
|
|
538
|
+
)
|
|
539
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
540
|
+
output_path.write_bytes(protected)
|
|
541
|
+
artifacts_manifest[token] = hashlib.sha256(protected).hexdigest()
|
|
542
|
+
|
|
543
|
+
# A final cross-platform scan catches case-fold collisions introduced by
|
|
544
|
+
# logical package directories and opaque artifact paths before publishing.
|
|
545
|
+
discover_inventory_files(destination)
|
|
546
|
+
|
|
547
|
+
policy: dict[str, object] = {}
|
|
548
|
+
if options.expires is not None:
|
|
549
|
+
policy["expires"] = options.expires.isoformat()
|
|
550
|
+
if options.allow_extra_data:
|
|
551
|
+
policy["allow_extra_data"] = True
|
|
552
|
+
manifest_payload: dict[str, object] = {
|
|
553
|
+
"format": 3,
|
|
554
|
+
"tool_version": __version__,
|
|
555
|
+
"build_id": build_id,
|
|
556
|
+
"python_tag": python_tag,
|
|
557
|
+
"created_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
558
|
+
"module_index": {
|
|
559
|
+
"format": 1,
|
|
560
|
+
"cipher": "aes-256-gcm",
|
|
561
|
+
"compression": "zlib",
|
|
562
|
+
"nonce_b64": base64.b64encode(encrypted_index.nonce).decode("ascii"),
|
|
563
|
+
"ciphertext_b64": base64.b64encode(encrypted_index.ciphertext).decode("ascii"),
|
|
564
|
+
"commitment_sha256": index_commitment.hex(),
|
|
565
|
+
},
|
|
566
|
+
"artifacts": dict(sorted(artifacts_manifest.items())),
|
|
567
|
+
"policy": policy,
|
|
568
|
+
"integrity": {
|
|
569
|
+
"format": BOUND_FILES_FORMAT,
|
|
570
|
+
"algorithm": BOUND_FILES_ALGORITHM,
|
|
571
|
+
"files": bound_file_digests,
|
|
572
|
+
"commitment_sha256": commitment.hex(),
|
|
573
|
+
},
|
|
574
|
+
}
|
|
575
|
+
wrapper = _signed_manifest(manifest_payload, signing_key)
|
|
576
|
+
(destination / MANIFEST_NAME).write_text(
|
|
577
|
+
json.dumps(wrapper, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
|
|
578
|
+
encoding="utf-8",
|
|
579
|
+
newline="\n",
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
return BuildResult(
|
|
583
|
+
output=destination,
|
|
584
|
+
entry_module=entry_module,
|
|
585
|
+
build_id=build_id,
|
|
586
|
+
python_tag=python_tag,
|
|
587
|
+
module_count=len(layout.modules),
|
|
588
|
+
resource_count=len(layout.resources),
|
|
589
|
+
support_count=support_count,
|
|
590
|
+
launcher_name=launcher_name,
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def build(options: BuildOptions) -> BuildResult:
|
|
595
|
+
_validate_build_interpreter()
|
|
596
|
+
if options.optimize not in (0, 1, 2):
|
|
597
|
+
raise PyEncodeError("Optimization level must be 0, 1, or 2")
|
|
598
|
+
layout = discover_project(
|
|
599
|
+
options.source,
|
|
600
|
+
excludes=options.excludes,
|
|
601
|
+
include_resources=options.include_resources,
|
|
602
|
+
)
|
|
603
|
+
_validate_reserved_modules(layout)
|
|
604
|
+
_validate_module_hierarchy(layout)
|
|
605
|
+
entry_module = _validate_entry(layout, options.entry_module)
|
|
606
|
+
output = _validate_output(layout.source, options.output)
|
|
607
|
+
|
|
608
|
+
temporary = Path(
|
|
609
|
+
tempfile.mkdtemp(prefix=f".{output.name}.pyencode-", dir=str(output.parent))
|
|
610
|
+
)
|
|
611
|
+
try:
|
|
612
|
+
temporary_result = _build_into(layout, temporary, options, entry_module)
|
|
613
|
+
if output.exists():
|
|
614
|
+
output.rmdir()
|
|
615
|
+
temporary.replace(output)
|
|
616
|
+
return BuildResult(
|
|
617
|
+
output=output,
|
|
618
|
+
entry_module=temporary_result.entry_module,
|
|
619
|
+
build_id=temporary_result.build_id,
|
|
620
|
+
python_tag=temporary_result.python_tag,
|
|
621
|
+
module_count=temporary_result.module_count,
|
|
622
|
+
resource_count=temporary_result.resource_count,
|
|
623
|
+
support_count=temporary_result.support_count,
|
|
624
|
+
launcher_name=temporary_result.launcher_name,
|
|
625
|
+
)
|
|
626
|
+
except Exception:
|
|
627
|
+
shutil.rmtree(temporary, ignore_errors=True)
|
|
628
|
+
raise
|