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/crypto.py ADDED
@@ -0,0 +1,319 @@
1
+ """Small, auditable cryptographic primitives used by pyencode.
2
+
3
+ No custom cipher is implemented here. Module keys are domain-separated with
4
+ HKDF-SHA256 and payloads are authenticated with AES-256-GCM.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ from typing import NamedTuple, TypeAlias
12
+
13
+ from cryptography.exceptions import InvalidSignature, InvalidTag
14
+ from cryptography.hazmat.primitives import hashes, serialization
15
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
16
+ Ed25519PrivateKey,
17
+ Ed25519PublicKey,
18
+ )
19
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
20
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
21
+
22
+ from .errors import CryptoError, IntegrityError, InvalidKeyError, SignatureError
23
+
24
+
25
+ NONCE_SIZE = 12
26
+ KEY_SIZE = 32
27
+ TAG_SIZE = 16
28
+ BOUND_COMMITMENT_SIZE = 32
29
+ _BUILD_ID_RE = re.compile(r"[0-9a-fA-F]+\Z")
30
+
31
+ BytesLike: TypeAlias = bytes | bytearray | memoryview
32
+ PrivateKeyLike: TypeAlias = Ed25519PrivateKey | BytesLike
33
+ PublicKeyLike: TypeAlias = Ed25519PublicKey | BytesLike
34
+
35
+
36
+ class EncryptedPayload(NamedTuple):
37
+ """The nonce and AES-GCM ciphertext (with its tag appended)."""
38
+
39
+ nonce: bytes
40
+ ciphertext: bytes
41
+
42
+
43
+ class Ed25519KeyPair(NamedTuple):
44
+ """Raw 32-byte Ed25519 private and public keys."""
45
+
46
+ private_key: bytes
47
+ public_key: bytes
48
+
49
+
50
+ def _as_bytes(value: BytesLike, *, name: str) -> bytes:
51
+ if not isinstance(value, (bytes, bytearray, memoryview)):
52
+ raise CryptoError(f"{name} must be bytes-like")
53
+ return bytes(value)
54
+
55
+
56
+ def _build_id_bytes(build_id: str) -> bytes:
57
+ if not isinstance(build_id, str):
58
+ raise CryptoError("build_id must be a string")
59
+ if (
60
+ not build_id
61
+ or len(build_id) % 2
62
+ or _BUILD_ID_RE.fullmatch(build_id) is None
63
+ ):
64
+ raise CryptoError("build_id must be a non-empty, even-length hexadecimal string")
65
+ # Keep this conversion explicit: the container format defines the HKDF salt
66
+ # as bytes.fromhex(build_id), not as the UTF-8 spelling of the identifier.
67
+ return bytes.fromhex(build_id)
68
+
69
+
70
+ def derive_module_key(master_key: BytesLike, build_id: str, module: str) -> bytes:
71
+ """Derive a 32-byte key for one module using HKDF-SHA256.
72
+
73
+ The derivation is exactly::
74
+
75
+ salt = bytes.fromhex(build_id)
76
+ info = b"pyencode-module\\0" + module.encode("utf-8")
77
+ """
78
+
79
+ key_material = _as_bytes(master_key, name="master_key")
80
+ if not key_material:
81
+ raise InvalidKeyError("master_key must not be empty")
82
+ if not isinstance(module, str):
83
+ raise CryptoError("module must be a string")
84
+ if not module:
85
+ raise CryptoError("module must not be empty")
86
+ try:
87
+ module_bytes = module.encode("utf-8")
88
+ except UnicodeEncodeError as exc:
89
+ raise CryptoError("module is not valid UTF-8 text") from exc
90
+
91
+ return HKDF(
92
+ algorithm=hashes.SHA256(),
93
+ length=KEY_SIZE,
94
+ salt=_build_id_bytes(build_id),
95
+ info=b"pyencode-module\0" + module_bytes,
96
+ ).derive(key_material)
97
+
98
+
99
+ def derive_bound_master_key(
100
+ master_key: BytesLike,
101
+ build_id: str,
102
+ commitment: BytesLike,
103
+ ) -> bytes:
104
+ """Bind a build's root key to the exact staged runtime/support files.
105
+
106
+ ``commitment`` is the 32-byte digest produced by
107
+ :func:`pyencode.integrity.bound_files_commitment`. The returned key, not
108
+ the recoverable root key stored by the generated runtime, is used as the
109
+ master input for per-module key derivation.
110
+ """
111
+
112
+ key_material = _as_bytes(master_key, name="master_key")
113
+ binding = _as_bytes(commitment, name="commitment")
114
+ if not key_material:
115
+ raise InvalidKeyError("master_key must not be empty")
116
+ if len(binding) != BOUND_COMMITMENT_SIZE:
117
+ raise CryptoError("bound-files commitment must be exactly 32 bytes")
118
+ return HKDF(
119
+ algorithm=hashes.SHA256(),
120
+ length=KEY_SIZE,
121
+ salt=_build_id_bytes(build_id),
122
+ info=b"pyencode-root-key-v2\0" + binding,
123
+ ).derive(key_material)
124
+
125
+
126
+ def encrypt_payload(
127
+ payload: BytesLike,
128
+ key: BytesLike,
129
+ *,
130
+ aad: BytesLike = b"",
131
+ nonce: BytesLike | None = None,
132
+ ) -> EncryptedPayload:
133
+ """Encrypt and authenticate a payload with AES-256-GCM.
134
+
135
+ ``ciphertext`` includes the 16-byte GCM authentication tag. Supplying a
136
+ nonce is useful for deterministic test vectors; production callers should
137
+ leave it unset so a fresh random 96-bit nonce is generated.
138
+ """
139
+
140
+ plaintext = _as_bytes(payload, name="payload")
141
+ aes_key = _as_bytes(key, name="key")
142
+ associated_data = _as_bytes(aad, name="aad")
143
+ if len(aes_key) != KEY_SIZE:
144
+ raise InvalidKeyError("AES-256-GCM key must be exactly 32 bytes")
145
+
146
+ if nonce is None:
147
+ nonce_bytes = os.urandom(NONCE_SIZE)
148
+ else:
149
+ nonce_bytes = _as_bytes(nonce, name="nonce")
150
+ if len(nonce_bytes) != NONCE_SIZE:
151
+ raise CryptoError("AES-GCM nonce must be exactly 12 bytes")
152
+
153
+ try:
154
+ ciphertext = AESGCM(aes_key).encrypt(
155
+ nonce_bytes, plaintext, associated_data
156
+ )
157
+ except (TypeError, ValueError) as exc:
158
+ raise CryptoError("could not encrypt payload") from exc
159
+ return EncryptedPayload(nonce_bytes, ciphertext)
160
+
161
+
162
+ def decrypt_payload(
163
+ ciphertext: BytesLike,
164
+ key: BytesLike,
165
+ nonce: BytesLike,
166
+ *,
167
+ aad: BytesLike = b"",
168
+ ) -> bytes:
169
+ """Authenticate and decrypt an AES-256-GCM payload.
170
+
171
+ ``IntegrityError`` intentionally does not distinguish a wrong key from
172
+ modified data.
173
+ """
174
+
175
+ encrypted = _as_bytes(ciphertext, name="ciphertext")
176
+ aes_key = _as_bytes(key, name="key")
177
+ nonce_bytes = _as_bytes(nonce, name="nonce")
178
+ associated_data = _as_bytes(aad, name="aad")
179
+ if len(aes_key) != KEY_SIZE:
180
+ raise InvalidKeyError("AES-256-GCM key must be exactly 32 bytes")
181
+ if len(nonce_bytes) != NONCE_SIZE:
182
+ raise CryptoError("AES-GCM nonce must be exactly 12 bytes")
183
+ if len(encrypted) < TAG_SIZE:
184
+ raise IntegrityError("encrypted payload is shorter than the GCM tag")
185
+
186
+ try:
187
+ return AESGCM(aes_key).decrypt(
188
+ nonce_bytes, encrypted, associated_data
189
+ )
190
+ except InvalidTag as exc:
191
+ raise IntegrityError("payload authentication failed") from exc
192
+ except (TypeError, ValueError) as exc:
193
+ raise CryptoError("could not decrypt payload") from exc
194
+
195
+
196
+ def load_private_key(key: PrivateKeyLike) -> Ed25519PrivateKey:
197
+ """Load an Ed25519 private key object from an object or 32 raw bytes."""
198
+
199
+ if isinstance(key, Ed25519PrivateKey):
200
+ return key
201
+ try:
202
+ raw = _as_bytes(key, name="private_key")
203
+ if len(raw) != KEY_SIZE:
204
+ raise InvalidKeyError("Ed25519 private key must be exactly 32 raw bytes")
205
+ return Ed25519PrivateKey.from_private_bytes(raw)
206
+ except InvalidKeyError:
207
+ raise
208
+ except (TypeError, ValueError) as exc:
209
+ raise InvalidKeyError("invalid Ed25519 private key") from exc
210
+
211
+
212
+ def load_public_key(key: PublicKeyLike) -> Ed25519PublicKey:
213
+ """Load an Ed25519 public key object from an object or 32 raw bytes."""
214
+
215
+ if isinstance(key, Ed25519PublicKey):
216
+ return key
217
+ try:
218
+ raw = _as_bytes(key, name="public_key")
219
+ if len(raw) != KEY_SIZE:
220
+ raise InvalidKeyError("Ed25519 public key must be exactly 32 raw bytes")
221
+ return Ed25519PublicKey.from_public_bytes(raw)
222
+ except InvalidKeyError:
223
+ raise
224
+ except (TypeError, ValueError) as exc:
225
+ raise InvalidKeyError("invalid Ed25519 public key") from exc
226
+
227
+
228
+ def private_key_bytes(key: PrivateKeyLike) -> bytes:
229
+ """Return a private key in its portable 32-byte raw encoding."""
230
+
231
+ return load_private_key(key).private_bytes(
232
+ encoding=serialization.Encoding.Raw,
233
+ format=serialization.PrivateFormat.Raw,
234
+ encryption_algorithm=serialization.NoEncryption(),
235
+ )
236
+
237
+
238
+ def public_key_bytes(key: PublicKeyLike | PrivateKeyLike) -> bytes:
239
+ """Return a public key in its portable 32-byte raw encoding."""
240
+
241
+ if isinstance(key, Ed25519PrivateKey):
242
+ public_key = key.public_key()
243
+ else:
244
+ try:
245
+ public_key = load_public_key(key) # type: ignore[arg-type]
246
+ except InvalidKeyError:
247
+ # Raw private and public keys are both 32 bytes, so bytes cannot be
248
+ # disambiguated. Bytes are intentionally interpreted as public.
249
+ raise
250
+ return public_key.public_bytes(
251
+ encoding=serialization.Encoding.Raw,
252
+ format=serialization.PublicFormat.Raw,
253
+ )
254
+
255
+
256
+ def generate_ed25519_keypair() -> Ed25519KeyPair:
257
+ """Generate a new Ed25519 keypair in raw, cross-platform encodings."""
258
+
259
+ private = Ed25519PrivateKey.generate()
260
+ return Ed25519KeyPair(
261
+ private_key_bytes(private),
262
+ private.public_key().public_bytes(
263
+ encoding=serialization.Encoding.Raw,
264
+ format=serialization.PublicFormat.Raw,
265
+ ),
266
+ )
267
+
268
+
269
+ def sign_bytes(data: BytesLike, private_key: PrivateKeyLike) -> bytes:
270
+ """Return an Ed25519 signature for arbitrary bytes."""
271
+
272
+ message = _as_bytes(data, name="data")
273
+ try:
274
+ return load_private_key(private_key).sign(message)
275
+ except InvalidKeyError:
276
+ raise
277
+ except (TypeError, ValueError) as exc:
278
+ raise CryptoError("could not sign data") from exc
279
+
280
+
281
+ def verify_bytes(
282
+ data: BytesLike,
283
+ signature: BytesLike,
284
+ public_key: PublicKeyLike,
285
+ ) -> bool:
286
+ """Verify an Ed25519 signature, returning ``True`` or raising on failure."""
287
+
288
+ message = _as_bytes(data, name="data")
289
+ signature_bytes = _as_bytes(signature, name="signature")
290
+ try:
291
+ load_public_key(public_key).verify(signature_bytes, message)
292
+ except InvalidSignature as exc:
293
+ raise SignatureError("signature verification failed") from exc
294
+ except InvalidKeyError:
295
+ raise
296
+ except (TypeError, ValueError) as exc:
297
+ raise SignatureError("signature is malformed") from exc
298
+ return True
299
+
300
+
301
+ __all__ = [
302
+ "BOUND_COMMITMENT_SIZE",
303
+ "Ed25519KeyPair",
304
+ "EncryptedPayload",
305
+ "KEY_SIZE",
306
+ "NONCE_SIZE",
307
+ "TAG_SIZE",
308
+ "decrypt_payload",
309
+ "derive_bound_master_key",
310
+ "derive_module_key",
311
+ "encrypt_payload",
312
+ "generate_ed25519_keypair",
313
+ "load_private_key",
314
+ "load_public_key",
315
+ "private_key_bytes",
316
+ "public_key_bytes",
317
+ "sign_bytes",
318
+ "verify_bytes",
319
+ ]
pyencode/discovery.py ADDED
@@ -0,0 +1,168 @@
1
+ """Discover Python modules and resource files in an input tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from fnmatch import fnmatch
7
+ from pathlib import Path
8
+ from typing import Iterable, Sequence
9
+
10
+ from .errors import PyEncodeError
11
+
12
+
13
+ DEFAULT_EXCLUDED_DIRECTORIES = frozenset(
14
+ {
15
+ ".git",
16
+ ".hg",
17
+ ".svn",
18
+ ".venv",
19
+ "venv",
20
+ "env",
21
+ "__pycache__",
22
+ ".mypy_cache",
23
+ ".pytest_cache",
24
+ ".ruff_cache",
25
+ "build",
26
+ "dist",
27
+ }
28
+ )
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class SourceModule:
33
+ name: str
34
+ source_path: Path
35
+ output_path: Path
36
+ is_package: bool
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class ResourceFile:
41
+ source_path: Path
42
+ output_path: Path
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class ProjectLayout:
47
+ source: Path
48
+ base_directory: Path
49
+ modules: tuple[SourceModule, ...]
50
+ resources: tuple[ResourceFile, ...]
51
+ inferred_entry: str | None
52
+
53
+
54
+ def _relative_module_name(relative_path: Path) -> tuple[str, bool]:
55
+ is_package = relative_path.name == "__init__.py"
56
+ module_path = relative_path.parent if is_package else relative_path.with_suffix("")
57
+ parts = module_path.parts
58
+ if not parts:
59
+ raise PyEncodeError("A package __init__.py must have a package directory name")
60
+ invalid = [part for part in parts if not part.isidentifier()]
61
+ if invalid:
62
+ joined = ", ".join(repr(part) for part in invalid)
63
+ raise PyEncodeError(f"Invalid Python module path component(s): {joined}")
64
+ return ".".join(parts), is_package
65
+
66
+
67
+ def _protected_output_path(relative_path: Path) -> Path:
68
+ if relative_path.name == "__init__.py":
69
+ return relative_path.with_name("__init__.pye")
70
+ return relative_path.with_suffix(".pye")
71
+
72
+
73
+ def _matches_user_exclude(relative_path: Path, patterns: Sequence[str]) -> bool:
74
+ value = relative_path.as_posix()
75
+ return any(fnmatch(value, pattern) or fnmatch(relative_path.name, pattern) for pattern in patterns)
76
+
77
+
78
+ def _is_default_excluded(relative_path: Path) -> bool:
79
+ if any(part in DEFAULT_EXCLUDED_DIRECTORIES for part in relative_path.parts):
80
+ return True
81
+ return relative_path.suffix.lower() in {".pyc", ".pyo", ".pye"}
82
+
83
+
84
+ def _iter_files(scope: Path) -> Iterable[Path]:
85
+ if scope.is_file():
86
+ yield scope
87
+ return
88
+ for path in sorted(scope.rglob("*")):
89
+ if path.is_file():
90
+ yield path
91
+
92
+
93
+ def discover_project(
94
+ source: Path,
95
+ *,
96
+ excludes: Sequence[str] = (),
97
+ include_resources: bool = True,
98
+ ) -> ProjectLayout:
99
+ """Return protected modules and copied resources for *source*.
100
+
101
+ A directory containing ``__init__.py`` is treated as one package and keeps
102
+ its top-level package name. Other directories are treated as project roots.
103
+ """
104
+
105
+ source = source.expanduser().resolve()
106
+ if not source.exists():
107
+ raise PyEncodeError(f"Source does not exist: {source}")
108
+ if source.is_file() and source.suffix.lower() != ".py":
109
+ raise PyEncodeError("A source file must have the .py extension")
110
+
111
+ if source.is_file():
112
+ base_directory = source.parent
113
+ scope = source
114
+ package_input = False
115
+ else:
116
+ package_input = (source / "__init__.py").is_file()
117
+ base_directory = source.parent if package_input else source
118
+ scope = source
119
+
120
+ modules: list[SourceModule] = []
121
+ resources: list[ResourceFile] = []
122
+ seen_modules: set[str] = set()
123
+
124
+ for path in _iter_files(scope):
125
+ relative = path.relative_to(base_directory)
126
+ if _is_default_excluded(relative) or _matches_user_exclude(relative, excludes):
127
+ continue
128
+
129
+ if path.suffix.lower() == ".py":
130
+ module_name, is_package = _relative_module_name(relative)
131
+ if module_name in seen_modules:
132
+ raise PyEncodeError(f"Duplicate module discovered: {module_name}")
133
+ seen_modules.add(module_name)
134
+ modules.append(
135
+ SourceModule(
136
+ name=module_name,
137
+ source_path=path,
138
+ output_path=_protected_output_path(relative),
139
+ is_package=is_package,
140
+ )
141
+ )
142
+ elif include_resources:
143
+ resources.append(ResourceFile(source_path=path, output_path=relative))
144
+
145
+ if not modules:
146
+ raise PyEncodeError(f"No Python modules found in {source}")
147
+
148
+ inferred_entry: str | None = None
149
+ names = {module.name for module in modules}
150
+ if source.is_file():
151
+ inferred_entry = modules[0].name
152
+ elif package_input:
153
+ candidate = f"{source.name}.__main__"
154
+ if candidate in names:
155
+ inferred_entry = candidate
156
+ elif "main" in names:
157
+ inferred_entry = "main"
158
+ elif len(modules) == 1 and not modules[0].is_package:
159
+ inferred_entry = modules[0].name
160
+
161
+ return ProjectLayout(
162
+ source=source,
163
+ base_directory=base_directory,
164
+ modules=tuple(modules),
165
+ resources=tuple(resources),
166
+ inferred_entry=inferred_entry,
167
+ )
168
+
pyencode/errors.py ADDED
@@ -0,0 +1,59 @@
1
+ """Project-specific exceptions raised by :mod:`pyencode`.
2
+
3
+ The public exception hierarchy deliberately separates malformed input from
4
+ cryptographic integrity failures. Applications can catch ``PyEncodeError``
5
+ for a user-facing error while still treating ``IntegrityError`` as a likely
6
+ tamper/wrong-key condition.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class PyEncodeError(Exception):
13
+ """Base class for all expected pyencode failures."""
14
+
15
+
16
+ class ContainerError(PyEncodeError):
17
+ """A protected module container is malformed or unsupported."""
18
+
19
+
20
+ class UnsupportedFormatError(ContainerError):
21
+ """The container uses a format version this runtime cannot read."""
22
+
23
+
24
+ class CryptoError(PyEncodeError):
25
+ """A cryptographic operation could not be completed."""
26
+
27
+
28
+ class InvalidKeyError(CryptoError):
29
+ """A key has an invalid type, encoding, or size."""
30
+
31
+
32
+ class IntegrityError(CryptoError):
33
+ """Authenticated data is corrupt, was modified, or used the wrong key."""
34
+
35
+
36
+ class ManifestError(PyEncodeError):
37
+ """A manifest is malformed or cannot be serialized canonically."""
38
+
39
+
40
+ class SignatureError(ManifestError, IntegrityError):
41
+ """A manifest signature is missing, malformed, or invalid."""
42
+
43
+
44
+ # A compatibility spelling for callers that do not capitalize the product
45
+ # name as two words. New code should use ``PyEncodeError``.
46
+ PyencodeError = PyEncodeError
47
+
48
+
49
+ __all__ = [
50
+ "ContainerError",
51
+ "CryptoError",
52
+ "IntegrityError",
53
+ "InvalidKeyError",
54
+ "ManifestError",
55
+ "PyEncodeError",
56
+ "PyencodeError",
57
+ "SignatureError",
58
+ "UnsupportedFormatError",
59
+ ]
pyencode/integrity.py ADDED
@@ -0,0 +1,88 @@
1
+ """Deterministic commitments for plaintext runtime and launcher files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path, PurePosixPath
7
+ from typing import Mapping, Sequence
8
+
9
+ from .container import canonical_json_bytes
10
+ from .errors import PyEncodeError
11
+
12
+
13
+ BOUND_FILES_FORMAT = 1
14
+ BOUND_FILES_ALGORITHM = "sha256"
15
+ BOUND_FILES_DOMAIN = b"pyencode-bound-files-v1\0"
16
+
17
+
18
+ def validate_bound_path(value: str) -> str:
19
+ """Validate a platform-neutral relative path and return its canonical form."""
20
+
21
+ if not isinstance(value, str) or not value or "\\" in value:
22
+ raise PyEncodeError("bound file paths must be non-empty POSIX relative paths")
23
+ path = PurePosixPath(value)
24
+ if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
25
+ raise PyEncodeError(f"unsafe bound file path: {value!r}")
26
+ canonical = path.as_posix()
27
+ if canonical != value:
28
+ raise PyEncodeError(f"bound file path is not canonical: {value!r}")
29
+ return canonical
30
+
31
+
32
+ def normalize_file_digests(file_digests: Mapping[str, str]) -> dict[str, str]:
33
+ """Validate a path-to-SHA256 mapping and return it sorted by path."""
34
+
35
+ if not isinstance(file_digests, Mapping) or not file_digests:
36
+ raise PyEncodeError("bound file digest mapping must not be empty")
37
+ normalized: dict[str, str] = {}
38
+ for raw_path, raw_digest in file_digests.items():
39
+ path = validate_bound_path(raw_path)
40
+ if not isinstance(raw_digest, str) or len(raw_digest) != 64:
41
+ raise PyEncodeError(f"invalid SHA-256 digest for bound file {path!r}")
42
+ try:
43
+ decoded = bytes.fromhex(raw_digest)
44
+ except ValueError as exc:
45
+ raise PyEncodeError(f"invalid SHA-256 digest for bound file {path!r}") from exc
46
+ if len(decoded) != 32 or raw_digest != raw_digest.lower():
47
+ raise PyEncodeError(f"invalid SHA-256 digest for bound file {path!r}")
48
+ normalized[path] = raw_digest
49
+ return dict(sorted(normalized.items()))
50
+
51
+
52
+ def bound_files_commitment(file_digests: Mapping[str, str]) -> bytes:
53
+ """Commit to exact file paths and raw-byte SHA-256 digests."""
54
+
55
+ descriptor = {
56
+ "format": BOUND_FILES_FORMAT,
57
+ "files": normalize_file_digests(file_digests),
58
+ }
59
+ return hashlib.sha256(BOUND_FILES_DOMAIN + canonical_json_bytes(descriptor)).digest()
60
+
61
+
62
+ def hash_bound_files(root: Path, relative_paths: Sequence[str]) -> dict[str, str]:
63
+ """Hash exact bytes below *root*, rejecting missing or escaping paths."""
64
+
65
+ root = root.resolve()
66
+ result: dict[str, str] = {}
67
+ for raw_path in relative_paths:
68
+ relative = validate_bound_path(raw_path)
69
+ candidate = (root / Path(*PurePosixPath(relative).parts)).resolve()
70
+ try:
71
+ candidate.relative_to(root)
72
+ except ValueError as exc:
73
+ raise PyEncodeError(f"bound file escapes output root: {relative!r}") from exc
74
+ if not candidate.is_file():
75
+ raise PyEncodeError(f"bound file is missing: {relative}")
76
+ result[relative] = hashlib.sha256(candidate.read_bytes()).hexdigest()
77
+ return normalize_file_digests(result)
78
+
79
+
80
+ __all__ = [
81
+ "BOUND_FILES_ALGORITHM",
82
+ "BOUND_FILES_DOMAIN",
83
+ "BOUND_FILES_FORMAT",
84
+ "bound_files_commitment",
85
+ "hash_bound_files",
86
+ "normalize_file_digests",
87
+ "validate_bound_path",
88
+ ]