bedrockplus 0.1.0.dev1__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. bedrockplus/__init__.py +95 -0
  2. bedrockplus/__main__.py +4 -0
  3. bedrockplus/_assets/Initialize-BedrockPlusServer.ps1 +395 -0
  4. bedrockplus/_assets/README.md +85 -0
  5. bedrockplus/_assets/SHA256SUMS +20 -0
  6. bedrockplus/_assets/Start-BedrockPlus.ps1 +125 -0
  7. bedrockplus/_assets/THIRD_PARTY_NOTICES.md +26 -0
  8. bedrockplus/_assets/bedrockplus.exe +0 -0
  9. bedrockplus/_assets/bedrockplus_bootstrap.dll +0 -0
  10. bedrockplus/_assets/bedrockplus_plugin_host.dll +0 -0
  11. bedrockplus/_assets/bedrockplus_plugin_worker.dll +0 -0
  12. bedrockplus/_assets/bedrockplus_runtime.dll +0 -0
  13. bedrockplus/_assets/bin/bedrockplus.ps1 +192 -0
  14. bedrockplus/_assets/compatibility/bds/1.26.51.1/windows-x64.json +24 -0
  15. bedrockplus/_assets/compatibility/bds/supported-versions.json +14 -0
  16. bedrockplus/_assets/licenses/microsoft-detours-MIT.txt +23 -0
  17. bedrockplus/_assets/plugins/README.txt +11 -0
  18. bedrockplus/_assets/release-manifest.json +34 -0
  19. bedrockplus/_assets/server/README.txt +10 -0
  20. bedrockplus/_assets/tools/bppack.py +1865 -0
  21. bedrockplus/_assets/tools/init_python_plugin.py +140 -0
  22. bedrockplus/_assets/tools/pack_plugin.py +142 -0
  23. bedrockplus/_assets/tools/world_packs.py +530 -0
  24. bedrockplus/api.py +83 -0
  25. bedrockplus/cli.py +121 -0
  26. bedrockplus/commands.py +50 -0
  27. bedrockplus/events.py +138 -0
  28. bedrockplus/objects.py +413 -0
  29. bedrockplus/py.typed +1 -0
  30. bedrockplus-0.1.0.dev1.dist-info/METADATA +41 -0
  31. bedrockplus-0.1.0.dev1.dist-info/RECORD +36 -0
  32. bedrockplus-0.1.0.dev1.dist-info/WHEEL +4 -0
  33. bedrockplus-0.1.0.dev1.dist-info/entry_points.txt +2 -0
  34. bedrockplus-0.1.0.dev1.dist-info/licenses/THIRD_PARTY_NOTICES.md +26 -0
  35. bedrockplus-0.1.0.dev1.dist-info/licenses/microsoft-detours-MIT.txt +23 -0
  36. bedrockplus-0.1.0.dev1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1865 @@
1
+ #!/usr/bin/env python3
2
+ """Build and validate deterministic BedrockPlus .bppack archives.
3
+
4
+ This tool intentionally uses only the Python standard library. It is the
5
+ reference implementation of the package-level checks described by the v1
6
+ format specification; loading native or Python code is a separate runtime
7
+ responsibility.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ from contextlib import contextmanager
14
+ import hashlib
15
+ import json
16
+ import os
17
+ from pathlib import Path
18
+ import re
19
+ import shutil
20
+ import stat
21
+ import sys
22
+ import tempfile
23
+ from dataclasses import dataclass
24
+ from datetime import datetime, timezone
25
+ import uuid
26
+ from typing import Any, BinaryIO, Iterable, Iterator
27
+ import unicodedata
28
+ import zipfile
29
+
30
+ if os.name == "nt":
31
+ import msvcrt
32
+ else:
33
+ import fcntl
34
+
35
+
36
+ FORMAT_VERSION = 1
37
+ INTEGRITY_PATH = "integrity.json"
38
+ MANIFEST_PATH = "manifest.json"
39
+ CHUNK_SIZE = 1024 * 1024
40
+ FIXED_ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
41
+ MAX_JSON_DEPTH = 64
42
+ CACHE_LEASE_FILENAME = ".bedrockplus-runtime.lock"
43
+
44
+ SEMVER_NUMBER = r"(?:0|[1-9][0-9]*)"
45
+ SEMVER_PRERELEASE_IDENTIFIER = (
46
+ r"(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)"
47
+ )
48
+ SEMVER_PATTERN = (
49
+ rf"{SEMVER_NUMBER}\.{SEMVER_NUMBER}\.{SEMVER_NUMBER}"
50
+ rf"(?:-{SEMVER_PRERELEASE_IDENTIFIER}"
51
+ rf"(?:\.{SEMVER_PRERELEASE_IDENTIFIER})*)?"
52
+ r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
53
+ )
54
+ SEMVER_RE = re.compile(rf"^{SEMVER_PATTERN}$")
55
+ DEPENDENCY_RANGE_RE = re.compile(
56
+ rf"^(?:\*|(?:>=|<=|>|<|=)?{SEMVER_PATTERN}"
57
+ rf"(?:,(?:>=|<=|>|<|=)?{SEMVER_PATTERN})*)$"
58
+ )
59
+ PLUGIN_ID_RE = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
60
+ PYTHON_CLASS_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$")
61
+ SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
62
+
63
+ WINDOWS_RESERVED_NAMES = {
64
+ "con",
65
+ "prn",
66
+ "aux",
67
+ "nul",
68
+ "conin$",
69
+ "conout$",
70
+ *(f"com{number}" for number in range(1, 10)),
71
+ *(f"lpt{number}" for number in range(1, 10)),
72
+ # Win32 also treats the ISO-8859-1 superscript digits as DOS-device
73
+ # suffixes. They remain reserved even though the package format permits
74
+ # Unicode paths.
75
+ *(f"com{number}" for number in ("¹", "²", "³")),
76
+ *(f"lpt{number}" for number in ("¹", "²", "³")),
77
+ }
78
+ REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class PackLimits:
83
+ max_archive_size: int = 512 * 1024 * 1024
84
+ max_files: int = 4096
85
+ max_file_size: int = 256 * 1024 * 1024
86
+ max_total_size: int = 512 * 1024 * 1024
87
+ max_manifest_size: int = 128 * 1024
88
+ max_integrity_size: int = 2 * 1024 * 1024
89
+ max_compression_ratio: int = 200
90
+ ratio_check_min_size: int = 1024 * 1024
91
+ max_path_bytes: int = 1024
92
+ max_segment_bytes: int = 255
93
+
94
+
95
+ DEFAULT_LIMITS = PackLimits()
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class SourceSnapshot:
100
+ path: Path
101
+ device: int
102
+ inode: int
103
+ mode: int
104
+ size: int
105
+ modified_ns: int
106
+ file_attributes: int
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class ArchiveInspection:
111
+ result: dict[str, Any]
112
+ manifest: dict[str, Any]
113
+ entries: dict[str, zipfile.ZipInfo]
114
+ expected_digests: dict[str, str]
115
+
116
+
117
+ class PackValidationError(ValueError):
118
+ """A validation failure with a stable code suitable for structured logs."""
119
+
120
+ def __init__(self, code: str, message: str) -> None:
121
+ super().__init__(message)
122
+ self.code = code
123
+
124
+ def as_dict(self) -> dict[str, str | bool]:
125
+ return {"valid": False, "code": self.code, "message": str(self)}
126
+
127
+
128
+ def _fail(code: str, message: str) -> None:
129
+ raise PackValidationError(code, message)
130
+
131
+
132
+ def _is_plain_int(value: object) -> bool:
133
+ return isinstance(value, int) and not isinstance(value, bool)
134
+
135
+
136
+ def _object_without_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
137
+ result: dict[str, Any] = {}
138
+ for key, value in pairs:
139
+ if key in result:
140
+ _fail("JSON_DUPLICATE_KEY", f"JSON object contains duplicate key {key!r}")
141
+ result[key] = value
142
+ return result
143
+
144
+
145
+ def _parse_json_integer(raw_value: str) -> int:
146
+ if len(raw_value.lstrip("-")) > 19:
147
+ _fail("JSON_NUMBER_RANGE", "JSON integer exceeds the signed 64-bit range")
148
+ value = int(raw_value)
149
+ if not -(2**63) <= value <= 2**63 - 1:
150
+ _fail("JSON_NUMBER_RANGE", "JSON integer exceeds the signed 64-bit range")
151
+ return value
152
+
153
+
154
+ def _reject_json_float(raw_value: str) -> None:
155
+ _fail("JSON_NUMBER_TYPE", f"Floating-point JSON number is not allowed: {raw_value}")
156
+
157
+
158
+ def _validate_decoded_json(value: Any, label: str) -> None:
159
+ pending: list[tuple[Any, int]] = [(value, 0)]
160
+ while pending:
161
+ current, depth = pending.pop()
162
+ if depth > MAX_JSON_DEPTH:
163
+ _fail("JSON_NESTING", f"{label} exceeds the maximum JSON nesting depth")
164
+ if isinstance(current, str):
165
+ try:
166
+ current.encode("utf-8")
167
+ except UnicodeEncodeError:
168
+ _fail("JSON_INVALID_UNICODE", f"{label} contains an invalid Unicode string")
169
+ if any(ord(character) < 0x20 or ord(character) == 0x7F for character in current):
170
+ _fail("JSON_CONTROL_CHARACTER", f"{label} contains a control character")
171
+ elif isinstance(current, list):
172
+ pending.extend((item, depth + 1) for item in current)
173
+ elif isinstance(current, dict):
174
+ for key, item in current.items():
175
+ pending.append((key, depth + 1))
176
+ pending.append((item, depth + 1))
177
+
178
+
179
+ def _decode_json(data: bytes, label: str) -> Any:
180
+ try:
181
+ text = data.decode("utf-8")
182
+ except UnicodeDecodeError as error:
183
+ _fail("JSON_NOT_UTF8", f"{label} is not valid UTF-8: {error}")
184
+
185
+ try:
186
+ value = json.loads(
187
+ text,
188
+ object_pairs_hook=_object_without_duplicate_keys,
189
+ parse_int=_parse_json_integer,
190
+ parse_float=_reject_json_float,
191
+ parse_constant=lambda constant: _fail(
192
+ "JSON_INVALID", f"{label} contains non-standard value {constant}"
193
+ ),
194
+ )
195
+ _validate_decoded_json(value, label)
196
+ return value
197
+ except PackValidationError:
198
+ raise
199
+ except json.JSONDecodeError as error:
200
+ _fail(
201
+ "JSON_INVALID",
202
+ f"{label} is not valid JSON at line {error.lineno}, column {error.colno}",
203
+ )
204
+ except (ValueError, RecursionError) as error:
205
+ _fail("JSON_INVALID", f"{label} could not be parsed safely: {error}")
206
+
207
+
208
+ def _canonical_json(value: Any) -> bytes:
209
+ return (
210
+ json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
211
+ ).encode("utf-8")
212
+
213
+
214
+ def _check_object_keys(
215
+ value: object,
216
+ *,
217
+ required: set[str],
218
+ optional: set[str],
219
+ label: str,
220
+ ) -> dict[str, Any]:
221
+ if not isinstance(value, dict):
222
+ _fail("MANIFEST_TYPE", f"{label} must be a JSON object")
223
+ missing = sorted(required - value.keys())
224
+ if missing:
225
+ _fail("MANIFEST_REQUIRED_FIELD", f"{label} is missing: {', '.join(missing)}")
226
+ unknown = sorted(value.keys() - required - optional)
227
+ if unknown:
228
+ _fail("MANIFEST_UNKNOWN_FIELD", f"{label} has unknown field(s): {', '.join(unknown)}")
229
+ return value
230
+
231
+
232
+ def _require_string(
233
+ value: object,
234
+ label: str,
235
+ *,
236
+ minimum: int = 1,
237
+ maximum: int,
238
+ ) -> str:
239
+ if not isinstance(value, str) or not minimum <= len(value) <= maximum:
240
+ _fail(
241
+ "MANIFEST_TYPE",
242
+ f"{label} must be a string with {minimum}..{maximum} characters",
243
+ )
244
+ try:
245
+ value.encode("utf-8")
246
+ except UnicodeEncodeError:
247
+ _fail("MANIFEST_INVALID_UNICODE", f"{label} contains invalid Unicode")
248
+ if any(ord(character) < 0x20 or ord(character) == 0x7F for character in value):
249
+ _fail("MANIFEST_CONTROL_CHARACTER", f"{label} contains a control character")
250
+ return value
251
+
252
+
253
+ def validate_package_path(path: str, limits: PackLimits = DEFAULT_LIMITS) -> str:
254
+ """Validate one canonical, relative POSIX path stored in a package."""
255
+
256
+ if not isinstance(path, str) or not path:
257
+ _fail("PATH_EMPTY", "Package path must be a non-empty string")
258
+ if "\\" in path:
259
+ _fail("PATH_BACKSLASH", f"Package path must use '/' separators: {path!r}")
260
+ if path.startswith("/") or re.match(r"^[A-Za-z]:", path):
261
+ _fail("PATH_ABSOLUTE", f"Absolute or drive-qualified path is forbidden: {path!r}")
262
+ try:
263
+ encoded_path = path.encode("utf-8")
264
+ except UnicodeEncodeError:
265
+ _fail("PATH_INVALID_UNICODE", f"Package path contains invalid Unicode: {path!r}")
266
+ if len(encoded_path) > limits.max_path_bytes:
267
+ _fail("PATH_TOO_LONG", f"Package path is too long: {path!r}")
268
+
269
+ parts = path.split("/")
270
+ for part in parts:
271
+ if part in {"", ".", ".."}:
272
+ _fail("PATH_TRAVERSAL", f"Empty, '.' or '..' path segment is forbidden: {path!r}")
273
+ if unicodedata.normalize("NFC", part) != part:
274
+ _fail("PATH_NOT_NFC", f"Package path must use Unicode NFC form: {path!r}")
275
+ # The complete-path encoding above already proves every segment can be
276
+ # represented as strict UTF-8.
277
+ if len(part.encode("utf-8")) > limits.max_segment_bytes:
278
+ _fail("PATH_SEGMENT_TOO_LONG", f"Package path segment is too long: {part!r}")
279
+ if any(ord(character) < 0x20 or ord(character) == 0x7F for character in part):
280
+ _fail("PATH_CONTROL_CHARACTER", f"Control character in package path: {path!r}")
281
+ if ":" in part:
282
+ _fail("PATH_ALTERNATE_DATA_STREAM", f"':' is forbidden in package paths: {path!r}")
283
+ if any(character in '<>"|?*' for character in part):
284
+ _fail(
285
+ "PATH_WINDOWS_ILLEGAL_CHARACTER",
286
+ f"Windows-illegal character in package path: {path!r}",
287
+ )
288
+ if part.endswith((" ", ".")):
289
+ _fail("PATH_WINDOWS_AMBIGUOUS", f"Trailing dot or space is forbidden: {path!r}")
290
+ base_name = part.split(".", 1)[0].casefold()
291
+ if base_name in WINDOWS_RESERVED_NAMES:
292
+ _fail("PATH_WINDOWS_RESERVED", f"Reserved Windows name in package path: {path!r}")
293
+
294
+ return path
295
+
296
+
297
+ def _collision_key(path: str) -> str:
298
+ return unicodedata.normalize("NFC", path).casefold()
299
+
300
+
301
+ def _plugin_id_uses_reserved_windows_name(plugin_id: str) -> bool:
302
+ """IDs become directory names, so apply Windows basename semantics."""
303
+
304
+ return plugin_id.split(".", 1)[0].casefold() in WINDOWS_RESERVED_NAMES
305
+
306
+
307
+ def _validate_manifest(manifest: object, package_paths: set[str]) -> dict[str, Any]:
308
+ value = _check_object_keys(
309
+ manifest,
310
+ required={"format_version", "id", "name", "version", "api_version", "runtime"},
311
+ optional={"description", "authors", "license", "dependencies", "packs"},
312
+ label="manifest",
313
+ )
314
+
315
+ if not _is_plain_int(value["format_version"]) or value["format_version"] != FORMAT_VERSION:
316
+ _fail("MANIFEST_FORMAT_VERSION", f"format_version must be {FORMAT_VERSION}")
317
+
318
+ plugin_id = _require_string(value["id"], "id", maximum=64)
319
+ if not PLUGIN_ID_RE.fullmatch(plugin_id):
320
+ _fail(
321
+ "MANIFEST_PLUGIN_ID",
322
+ "id must be lowercase and contain only letters, digits, '.', '_' or '-' separators",
323
+ )
324
+ if _plugin_id_uses_reserved_windows_name(plugin_id):
325
+ _fail(
326
+ "MANIFEST_PLUGIN_ID_RESERVED",
327
+ f"Plugin id cannot become a reserved Windows directory name: {plugin_id!r}",
328
+ )
329
+ _require_string(value["name"], "name", maximum=80)
330
+
331
+ version = _require_string(value["version"], "version", maximum=128)
332
+ if not SEMVER_RE.fullmatch(version):
333
+ _fail("MANIFEST_VERSION", "version must be a complete Semantic Version")
334
+
335
+ if not _is_plain_int(value["api_version"]) or value["api_version"] != 1:
336
+ _fail("MANIFEST_API_VERSION", "api_version must be 1")
337
+
338
+ if "description" in value:
339
+ _require_string(value["description"], "description", minimum=0, maximum=500)
340
+ if "license" in value:
341
+ _require_string(value["license"], "license", maximum=100)
342
+ if "authors" in value:
343
+ authors = value["authors"]
344
+ if not isinstance(authors, list) or not 1 <= len(authors) <= 32:
345
+ _fail("MANIFEST_AUTHORS", "authors must contain 1..32 strings")
346
+ seen_authors: set[str] = set()
347
+ for index, author in enumerate(authors):
348
+ checked_author = _require_string(author, f"authors[{index}]", maximum=100)
349
+ if checked_author in seen_authors:
350
+ _fail("MANIFEST_AUTHORS", f"Duplicate author: {checked_author}")
351
+ seen_authors.add(checked_author)
352
+
353
+ runtime = _check_object_keys(
354
+ value["runtime"],
355
+ required={"type", "entrypoint"},
356
+ optional={"entry_class", "python", "platform", "abi"},
357
+ label="runtime",
358
+ )
359
+ runtime_type = runtime["type"]
360
+ if runtime_type == "python":
361
+ expected = {"type", "entrypoint", "entry_class", "python"}
362
+ if set(runtime) != expected:
363
+ _fail(
364
+ "MANIFEST_RUNTIME_FIELDS",
365
+ "Python runtime requires exactly: type, entrypoint, entry_class, python",
366
+ )
367
+ entrypoint = _require_string(runtime["entrypoint"], "runtime.entrypoint", maximum=240)
368
+ validate_package_path(entrypoint)
369
+ if not entrypoint.startswith("python/") or not entrypoint.endswith(".py"):
370
+ _fail(
371
+ "MANIFEST_ENTRYPOINT",
372
+ "Python entrypoint must be a .py file below python/",
373
+ )
374
+ entry_class = _require_string(runtime["entry_class"], "runtime.entry_class", maximum=200)
375
+ if not PYTHON_CLASS_RE.fullmatch(entry_class):
376
+ _fail("MANIFEST_ENTRY_CLASS", "entry_class must be a dotted Python identifier")
377
+ if runtime["python"] != "3.13":
378
+ _fail("MANIFEST_PYTHON_VERSION", "The v1 Python runtime must be '3.13'")
379
+ elif runtime_type == "cpp":
380
+ expected = {"type", "entrypoint", "platform", "abi"}
381
+ if set(runtime) != expected:
382
+ _fail(
383
+ "MANIFEST_RUNTIME_FIELDS",
384
+ "C++ runtime requires exactly: type, entrypoint, platform, abi",
385
+ )
386
+ entrypoint = _require_string(runtime["entrypoint"], "runtime.entrypoint", maximum=240)
387
+ validate_package_path(entrypoint)
388
+ if not entrypoint.startswith("native/windows-x64/") or not entrypoint.endswith(".dll"):
389
+ _fail(
390
+ "MANIFEST_ENTRYPOINT",
391
+ "C++ entrypoint must be a .dll below native/windows-x64/",
392
+ )
393
+ if runtime["platform"] != "windows-x64":
394
+ _fail("MANIFEST_PLATFORM", "The v1 native platform must be 'windows-x64'")
395
+ if not _is_plain_int(runtime["abi"]) or runtime["abi"] != 1:
396
+ _fail("MANIFEST_ABI", "The v1 native ABI must be 1")
397
+ else:
398
+ _fail("MANIFEST_RUNTIME_TYPE", "runtime.type must be 'python' or 'cpp'")
399
+
400
+ if entrypoint not in package_paths:
401
+ _fail("MANIFEST_ENTRYPOINT_MISSING", f"Runtime entrypoint is absent: {entrypoint}")
402
+
403
+ dependencies = value.get("dependencies", [])
404
+ if not isinstance(dependencies, list) or len(dependencies) > 128:
405
+ _fail("MANIFEST_DEPENDENCIES", "dependencies must be an array of at most 128 entries")
406
+ dependency_ids: set[str] = set()
407
+ for index, dependency_value in enumerate(dependencies):
408
+ dependency = _check_object_keys(
409
+ dependency_value,
410
+ required={"id", "version"},
411
+ optional={"optional"},
412
+ label=f"dependencies[{index}]",
413
+ )
414
+ dependency_id = _require_string(
415
+ dependency["id"], f"dependencies[{index}].id", maximum=64
416
+ )
417
+ if not PLUGIN_ID_RE.fullmatch(dependency_id):
418
+ _fail("MANIFEST_DEPENDENCY_ID", f"Invalid dependency id: {dependency_id!r}")
419
+ if _plugin_id_uses_reserved_windows_name(dependency_id):
420
+ _fail(
421
+ "MANIFEST_DEPENDENCY_ID",
422
+ f"Dependency id uses a reserved Windows directory name: {dependency_id!r}",
423
+ )
424
+ if dependency_id == plugin_id:
425
+ _fail("MANIFEST_DEPENDENCY_SELF", "A plugin cannot depend on itself")
426
+ if dependency_id in dependency_ids:
427
+ _fail("MANIFEST_DEPENDENCY_DUPLICATE", f"Duplicate dependency: {dependency_id}")
428
+ dependency_ids.add(dependency_id)
429
+ dependency_range = _require_string(
430
+ dependency["version"], f"dependencies[{index}].version", maximum=300
431
+ )
432
+ if not DEPENDENCY_RANGE_RE.fullmatch(dependency_range):
433
+ _fail(
434
+ "MANIFEST_DEPENDENCY_VERSION",
435
+ "Dependency version must be '*' or comma-separated SemVer comparators",
436
+ )
437
+ if "optional" in dependency and not isinstance(dependency["optional"], bool):
438
+ _fail("MANIFEST_DEPENDENCY_OPTIONAL", "dependency.optional must be boolean")
439
+
440
+ packs = value.get("packs", [])
441
+ if not isinstance(packs, list) or len(packs) > 128:
442
+ _fail("MANIFEST_PACKS", "packs must be an array of at most 128 entries")
443
+ pack_roots: list[str] = []
444
+ pack_keys: set[str] = set()
445
+ for index, pack_value in enumerate(packs):
446
+ pack = _check_object_keys(
447
+ pack_value,
448
+ required={"type", "path"},
449
+ optional=set(),
450
+ label=f"packs[{index}]",
451
+ )
452
+ pack_type = pack["type"]
453
+ if pack_type not in {"behavior", "resource"}:
454
+ _fail("MANIFEST_PACK_TYPE", "pack.type must be 'behavior' or 'resource'")
455
+ pack_path = _require_string(pack["path"], f"packs[{index}].path", maximum=240)
456
+ validate_package_path(pack_path)
457
+ prefix = f"packs/{pack_type}/"
458
+ if not pack_path.startswith(prefix) or pack_path == prefix[:-1]:
459
+ _fail(
460
+ "MANIFEST_PACK_PATH",
461
+ f"{pack_type} pack path must be below {prefix}",
462
+ )
463
+ key = _collision_key(pack_path)
464
+ if key in pack_keys:
465
+ _fail("MANIFEST_PACK_DUPLICATE", f"Duplicate pack path: {pack_path}")
466
+ if any(
467
+ key.startswith(f"{existing}/") or existing.startswith(f"{key}/")
468
+ for existing in pack_roots
469
+ ):
470
+ _fail(
471
+ "MANIFEST_PACK_OVERLAP",
472
+ f"Bundled pack roots must not overlap: {pack_path}",
473
+ )
474
+ pack_keys.add(key)
475
+ pack_roots.append(key)
476
+ pack_manifest = f"{pack_path}/manifest.json"
477
+ if pack_manifest not in package_paths:
478
+ _fail("MANIFEST_PACK_MISSING", f"Bundled pack manifest is absent: {pack_manifest}")
479
+
480
+ for path in package_paths:
481
+ path_key = _collision_key(path)
482
+ if (path_key == "packs" or path_key.startswith("packs/")) and not any(
483
+ path_key == root or path_key.startswith(f"{root}/")
484
+ for root in pack_roots
485
+ ):
486
+ _fail("MANIFEST_PACK_UNDECLARED", f"File belongs to no declared pack: {path}")
487
+
488
+ return value
489
+
490
+
491
+ def _read_entry_limited(
492
+ archive: zipfile.ZipFile,
493
+ info: zipfile.ZipInfo,
494
+ maximum: int,
495
+ ) -> bytes:
496
+ if info.file_size > maximum:
497
+ _fail("ENTRY_TOO_LARGE", f"{info.filename} exceeds its size limit")
498
+ try:
499
+ with archive.open(info, "r") as stream:
500
+ data = stream.read(maximum + 1)
501
+ if len(data) > maximum:
502
+ _fail("ENTRY_TOO_LARGE", f"{info.filename} exceeds its size limit")
503
+ return data
504
+ except (OSError, RuntimeError, zipfile.BadZipFile) as error:
505
+ _fail("ZIP_READ_FAILED", f"Cannot read {info.filename}: {error}")
506
+
507
+
508
+ def _hash_archive_entry(archive: zipfile.ZipFile, info: zipfile.ZipInfo) -> str:
509
+ digest = hashlib.sha256()
510
+ actual_size = 0
511
+ try:
512
+ with archive.open(info, "r") as stream:
513
+ while True:
514
+ block = stream.read(CHUNK_SIZE)
515
+ if not block:
516
+ break
517
+ actual_size += len(block)
518
+ if actual_size > info.file_size:
519
+ _fail("ZIP_SIZE_MISMATCH", f"Expanded size exceeds metadata: {info.filename}")
520
+ digest.update(block)
521
+ except (OSError, RuntimeError, zipfile.BadZipFile) as error:
522
+ _fail("ZIP_READ_FAILED", f"Cannot verify {info.filename}: {error}")
523
+ if actual_size != info.file_size:
524
+ _fail("ZIP_SIZE_MISMATCH", f"Expanded size differs from metadata: {info.filename}")
525
+ return digest.hexdigest()
526
+
527
+
528
+ def _validate_integrity(value: object, expected_paths: set[str]) -> dict[str, str]:
529
+ integrity = _check_object_keys(
530
+ value,
531
+ required={"format_version", "algorithm", "files"},
532
+ optional=set(),
533
+ label="integrity",
534
+ )
535
+ if not _is_plain_int(integrity["format_version"]) or integrity["format_version"] != 1:
536
+ _fail("INTEGRITY_FORMAT_VERSION", "integrity format_version must be 1")
537
+ if integrity["algorithm"] != "sha256":
538
+ _fail("INTEGRITY_ALGORITHM", "integrity algorithm must be 'sha256'")
539
+ files = integrity["files"]
540
+ if not isinstance(files, dict):
541
+ _fail("INTEGRITY_TYPE", "integrity.files must be an object")
542
+
543
+ actual_paths = set(files)
544
+ if actual_paths != expected_paths:
545
+ missing = sorted(expected_paths - actual_paths)
546
+ extra = sorted(actual_paths - expected_paths)
547
+ details: list[str] = []
548
+ if missing:
549
+ details.append(f"missing {missing}")
550
+ if extra:
551
+ details.append(f"extra {extra}")
552
+ _fail("INTEGRITY_FILE_SET", "Integrity file set differs from archive: " + "; ".join(details))
553
+
554
+ result: dict[str, str] = {}
555
+ for path, digest in files.items():
556
+ validate_package_path(path)
557
+ if not isinstance(digest, str) or not SHA256_RE.fullmatch(digest):
558
+ _fail("INTEGRITY_DIGEST", f"Invalid SHA-256 for {path}")
559
+ result[path] = digest
560
+ return result
561
+
562
+
563
+ def _inspect_archive(
564
+ archive: zipfile.ZipFile,
565
+ archive_sha256: str,
566
+ limits: PackLimits,
567
+ ) -> ArchiveInspection:
568
+ if archive.comment:
569
+ _fail("ZIP_COMMENT", "Archive comments are not allowed")
570
+ infos = archive.infolist()
571
+ if not infos:
572
+ _fail("ZIP_EMPTY", "Package archive is empty")
573
+ if len(infos) > limits.max_files:
574
+ _fail("ZIP_FILE_COUNT", f"Package contains more than {limits.max_files} files")
575
+
576
+ by_name: dict[str, zipfile.ZipInfo] = {}
577
+ collision_names: dict[str, str] = {}
578
+ total_size = 0
579
+ for info in infos:
580
+ path = validate_package_path(info.filename, limits)
581
+ if info.is_dir() or path.endswith("/"):
582
+ _fail("ZIP_DIRECTORY_ENTRY", f"Directory entries are forbidden: {path}")
583
+ if path in by_name:
584
+ _fail("ZIP_DUPLICATE_PATH", f"Duplicate archive path: {path}")
585
+ collision = _collision_key(path)
586
+ if collision in collision_names:
587
+ _fail(
588
+ "ZIP_CASE_COLLISION",
589
+ f"Windows path collision: {collision_names[collision]!r} and {path!r}",
590
+ )
591
+ collision_names[collision] = path
592
+ by_name[path] = info
593
+
594
+ if any(ord(character) >= 128 for character in path) and not (info.flag_bits & 0x800):
595
+ _fail("ZIP_FILENAME_ENCODING", f"Non-ASCII path is not marked UTF-8: {path}")
596
+ if info.flag_bits & 0x1:
597
+ _fail("ZIP_ENCRYPTED", f"Encrypted archive entry is forbidden: {path}")
598
+ if info.compress_type not in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}:
599
+ _fail("ZIP_COMPRESSION", f"Unsupported compression method for {path}")
600
+
601
+ unix_mode = (info.external_attr >> 16) & 0xFFFF
602
+ file_type = stat.S_IFMT(unix_mode)
603
+ if file_type not in {0, stat.S_IFREG}:
604
+ _fail("ZIP_SPECIAL_FILE", f"Links and special files are forbidden: {path}")
605
+ if info.file_size > limits.max_file_size:
606
+ _fail("ZIP_FILE_TOO_LARGE", f"Expanded file exceeds limit: {path}")
607
+ total_size += info.file_size
608
+ if total_size > limits.max_total_size:
609
+ _fail("ZIP_TOTAL_TOO_LARGE", "Total expanded package size exceeds limit")
610
+ if info.file_size >= limits.ratio_check_min_size:
611
+ if info.compress_size == 0:
612
+ _fail("ZIP_COMPRESSION_RATIO", f"Invalid compression ratio: {path}")
613
+ ratio = info.file_size / info.compress_size
614
+ if ratio > limits.max_compression_ratio:
615
+ _fail(
616
+ "ZIP_COMPRESSION_RATIO",
617
+ f"Compression ratio exceeds {limits.max_compression_ratio}:1: {path}",
618
+ )
619
+
620
+ if MANIFEST_PATH not in by_name:
621
+ _fail("MANIFEST_MISSING", "Package has no root manifest.json")
622
+ if INTEGRITY_PATH not in by_name:
623
+ _fail("INTEGRITY_MISSING", "Package has no root integrity.json")
624
+
625
+ manifest = _decode_json(
626
+ _read_entry_limited(archive, by_name[MANIFEST_PATH], limits.max_manifest_size),
627
+ MANIFEST_PATH,
628
+ )
629
+ package_paths = set(by_name) - {INTEGRITY_PATH}
630
+ validated_manifest = _validate_manifest(manifest, package_paths)
631
+ integrity = _decode_json(
632
+ _read_entry_limited(archive, by_name[INTEGRITY_PATH], limits.max_integrity_size),
633
+ INTEGRITY_PATH,
634
+ )
635
+ expected_digests = _validate_integrity(integrity, package_paths)
636
+ for path in sorted(package_paths):
637
+ actual_digest = _hash_archive_entry(archive, by_name[path])
638
+ if actual_digest != expected_digests[path]:
639
+ _fail("INTEGRITY_MISMATCH", f"SHA-256 mismatch for {path}")
640
+
641
+ return ArchiveInspection(
642
+ result={
643
+ "valid": True,
644
+ "format_version": FORMAT_VERSION,
645
+ "id": validated_manifest["id"],
646
+ "name": validated_manifest["name"],
647
+ "version": validated_manifest["version"],
648
+ "runtime": validated_manifest["runtime"]["type"],
649
+ "files": len(by_name),
650
+ "expanded_size": total_size,
651
+ "sha256": archive_sha256,
652
+ },
653
+ manifest=validated_manifest,
654
+ entries=by_name,
655
+ expected_digests=expected_digests,
656
+ )
657
+
658
+
659
+ def _same_open_file(before: os.stat_result, opened: os.stat_result) -> bool:
660
+ return (
661
+ before.st_dev == opened.st_dev
662
+ and before.st_ino == opened.st_ino
663
+ and before.st_size == opened.st_size
664
+ and before.st_mtime_ns == opened.st_mtime_ns
665
+ and getattr(before, "st_file_attributes", 0)
666
+ == getattr(opened, "st_file_attributes", 0)
667
+ )
668
+
669
+
670
+ @contextmanager
671
+ def _open_package_archive(
672
+ package: Path,
673
+ limits: PackLimits,
674
+ ) -> Iterator[tuple[zipfile.ZipFile, ArchiveInspection]]:
675
+ if package.suffix.casefold() != ".bppack":
676
+ _fail("PACKAGE_EXTENSION", "Package filename must end in .bppack")
677
+ try:
678
+ before = package.lstat()
679
+ except OSError as error:
680
+ _fail("PACKAGE_NOT_READABLE", f"Cannot inspect package: {error}")
681
+ if not stat.S_ISREG(before.st_mode) or stat.S_ISLNK(before.st_mode) or _is_reparse_point(before):
682
+ _fail("PACKAGE_LINK", "Package must be a regular, non-reparse file")
683
+ if before.st_size > limits.max_archive_size:
684
+ _fail("PACKAGE_TOO_LARGE", "Compressed package exceeds the archive size limit")
685
+
686
+ flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
687
+ try:
688
+ descriptor = os.open(package, flags)
689
+ stream = os.fdopen(descriptor, "rb")
690
+ except OSError as error:
691
+ _fail("PACKAGE_NOT_READABLE", f"Cannot open package: {error}")
692
+ try:
693
+ opened = os.fstat(stream.fileno())
694
+ if not stat.S_ISREG(opened.st_mode) or _is_reparse_point(opened) or not _same_open_file(before, opened):
695
+ _fail("PACKAGE_CHANGED", "Package changed or became a reparse point while opening")
696
+ archive_sha256 = _hash_stream(stream)
697
+ stream.seek(0)
698
+ with zipfile.ZipFile(stream, "r") as archive:
699
+ inspection = _inspect_archive(archive, archive_sha256, limits)
700
+ yield archive, inspection
701
+ after = os.fstat(stream.fileno())
702
+ if not _same_open_file(opened, after):
703
+ _fail("PACKAGE_CHANGED", "Package changed while it was being read")
704
+ except PackValidationError:
705
+ raise
706
+ except (OSError, RuntimeError, zipfile.BadZipFile, zipfile.LargeZipFile) as error:
707
+ _fail("ZIP_INVALID", f"Invalid ZIP archive: {error}")
708
+ finally:
709
+ stream.close()
710
+
711
+
712
+ def validate_package(
713
+ package_path: str | os.PathLike[str],
714
+ limits: PackLimits = DEFAULT_LIMITS,
715
+ ) -> dict[str, Any]:
716
+ """Fully validate and hash one held, non-reparse .bppack file."""
717
+
718
+ with _open_package_archive(Path(package_path), limits) as (_, inspection):
719
+ return dict(inspection.result)
720
+
721
+
722
+ def _is_reparse_point(file_stat: os.stat_result) -> bool:
723
+ return bool(getattr(file_stat, "st_file_attributes", 0) & REPARSE_POINT)
724
+
725
+
726
+ def _source_snapshot(path: Path, file_stat: os.stat_result) -> SourceSnapshot:
727
+ return SourceSnapshot(
728
+ path=path,
729
+ device=file_stat.st_dev,
730
+ inode=file_stat.st_ino,
731
+ mode=file_stat.st_mode,
732
+ size=file_stat.st_size,
733
+ modified_ns=file_stat.st_mtime_ns,
734
+ file_attributes=getattr(file_stat, "st_file_attributes", 0),
735
+ )
736
+
737
+
738
+ def _snapshot_matches(snapshot: SourceSnapshot, file_stat: os.stat_result) -> bool:
739
+ return (
740
+ snapshot.device == file_stat.st_dev
741
+ and snapshot.inode == file_stat.st_ino
742
+ and snapshot.mode == file_stat.st_mode
743
+ and snapshot.size == file_stat.st_size
744
+ and snapshot.modified_ns == file_stat.st_mtime_ns
745
+ and snapshot.file_attributes == getattr(file_stat, "st_file_attributes", 0)
746
+ )
747
+
748
+
749
+ def _scan_source(
750
+ root: Path,
751
+ limits: PackLimits,
752
+ ) -> dict[str, SourceSnapshot]:
753
+ files: dict[str, SourceSnapshot] = {}
754
+ collisions: dict[str, str] = {}
755
+ total_size = 0
756
+ scanned_entries = 0
757
+
758
+ try:
759
+ root_stat = root.lstat()
760
+ except OSError as error:
761
+ _fail("SOURCE_READ_FAILED", f"Cannot inspect source root {root}: {error}")
762
+ if not stat.S_ISDIR(root_stat.st_mode) or _is_reparse_point(root_stat):
763
+ _fail("SOURCE_LINK", "The package source root must be a regular directory")
764
+
765
+ def visit(
766
+ directory: Path,
767
+ relative_parts: tuple[str, ...],
768
+ expected_directory: SourceSnapshot,
769
+ ) -> None:
770
+ nonlocal total_size, scanned_entries
771
+ try:
772
+ before = directory.lstat()
773
+ if not _snapshot_matches(expected_directory, before):
774
+ _fail("SOURCE_CHANGED", f"Source directory changed: {directory}")
775
+ if not stat.S_ISDIR(before.st_mode) or _is_reparse_point(before):
776
+ _fail("SOURCE_LINK", f"Source directory became a reparse point: {directory}")
777
+ entries = []
778
+ with os.scandir(directory) as scan:
779
+ for item in scan:
780
+ scanned_entries += 1
781
+ if scanned_entries > limits.max_files * 2:
782
+ _fail("SOURCE_ENTRY_COUNT", "Source contains too many files and directories")
783
+ entries.append(item)
784
+ entries.sort(key=lambda item: item.name.casefold())
785
+ except OSError as error:
786
+ _fail("SOURCE_READ_FAILED", f"Cannot scan {directory}: {error}")
787
+ try:
788
+ for entry in entries:
789
+ relative = "/".join((*relative_parts, entry.name))
790
+ validate_package_path(relative, limits)
791
+ try:
792
+ entry_path = Path(entry.path)
793
+ entry_stat = entry_path.lstat()
794
+ except OSError as error:
795
+ _fail("SOURCE_READ_FAILED", f"Cannot inspect {entry.path}: {error}")
796
+ if stat.S_ISLNK(entry_stat.st_mode) or _is_reparse_point(entry_stat):
797
+ _fail("SOURCE_LINK", f"Links and reparse points are forbidden: {relative}")
798
+ if stat.S_ISDIR(entry_stat.st_mode):
799
+ # Python creates these beside sources during local testing.
800
+ # They are interpreter-specific generated artifacts, never
801
+ # plugin source, and can otherwise carry stale code into a
802
+ # package after its manifest version changes.
803
+ if entry.name == "__pycache__":
804
+ continue
805
+ visit(
806
+ entry_path,
807
+ (*relative_parts, entry.name),
808
+ _source_snapshot(entry_path, entry_stat),
809
+ )
810
+ continue
811
+ if not stat.S_ISREG(entry_stat.st_mode):
812
+ _fail("SOURCE_SPECIAL_FILE", f"Special source file is forbidden: {relative}")
813
+ if entry.name.endswith((".pyc", ".pyo")):
814
+ continue
815
+ if entry_stat.st_nlink != 1:
816
+ _fail("SOURCE_LINK", f"Multiply linked source file is forbidden: {relative}")
817
+ if relative == INTEGRITY_PATH:
818
+ continue # Generated from the current payload on every build.
819
+ collision = _collision_key(relative)
820
+ if collision == _collision_key(INTEGRITY_PATH):
821
+ _fail(
822
+ "SOURCE_RESERVED_PATH",
823
+ f"Path collides with generated {INTEGRITY_PATH}: {relative}",
824
+ )
825
+ if collision in collisions:
826
+ _fail(
827
+ "SOURCE_CASE_COLLISION",
828
+ f"Windows path collision: {collisions[collision]!r} and {relative!r}",
829
+ )
830
+ collisions[collision] = relative
831
+ if entry_stat.st_size > limits.max_file_size:
832
+ _fail("SOURCE_FILE_TOO_LARGE", f"Source file exceeds limit: {relative}")
833
+ total_size += entry_stat.st_size
834
+ if total_size > limits.max_total_size:
835
+ _fail("SOURCE_TOTAL_TOO_LARGE", "Source tree exceeds total size limit")
836
+ files[relative] = _source_snapshot(entry_path, entry_stat)
837
+ if len(files) + 1 > limits.max_files: # + generated integrity.json
838
+ _fail("SOURCE_FILE_COUNT", f"Source contains more than {limits.max_files - 1} files")
839
+ finally:
840
+ entries.clear()
841
+ try:
842
+ after = directory.lstat()
843
+ except OSError as error:
844
+ _fail("SOURCE_CHANGED", f"Source directory disappeared: {directory}: {error}")
845
+ if not _snapshot_matches(expected_directory, after):
846
+ _fail("SOURCE_CHANGED", f"Source directory changed while scanning: {directory}")
847
+
848
+ visit(root, (), _source_snapshot(root, root_stat))
849
+ return files
850
+
851
+
852
+ def _hash_stream(stream: BinaryIO) -> str:
853
+ digest = hashlib.sha256()
854
+ while True:
855
+ block = stream.read(CHUNK_SIZE)
856
+ if not block:
857
+ break
858
+ digest.update(block)
859
+ return digest.hexdigest()
860
+
861
+
862
+ @contextmanager
863
+ def _open_verified_source(source: SourceSnapshot) -> Iterator[tuple[BinaryIO, int]]:
864
+ """Open one regular source file without accepting a link-swap race."""
865
+
866
+ path = source.path
867
+ try:
868
+ before = path.lstat()
869
+ except OSError as error:
870
+ _fail("SOURCE_READ_FAILED", f"Cannot inspect {path}: {error}")
871
+ if stat.S_ISLNK(before.st_mode) or _is_reparse_point(before):
872
+ _fail("SOURCE_LINK", f"Source became a link or reparse point: {path}")
873
+ if not stat.S_ISREG(before.st_mode):
874
+ _fail("SOURCE_SPECIAL_FILE", f"Source is not a regular file: {path}")
875
+ if not _snapshot_matches(source, before):
876
+ _fail("SOURCE_CHANGED", f"Source changed since it was scanned: {path}")
877
+
878
+ flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
879
+ flags |= getattr(os, "O_NOFOLLOW", 0)
880
+ try:
881
+ descriptor = os.open(path, flags)
882
+ except OSError as error:
883
+ _fail("SOURCE_READ_FAILED", f"Cannot open {path}: {error}")
884
+
885
+ stream = os.fdopen(descriptor, "rb")
886
+ try:
887
+ opened = os.fstat(stream.fileno())
888
+ if not stat.S_ISREG(opened.st_mode) or _is_reparse_point(opened):
889
+ _fail("SOURCE_SPECIAL_FILE", f"Opened source is not a regular file: {path}")
890
+ if (before.st_dev, before.st_ino) != (opened.st_dev, opened.st_ino):
891
+ _fail("SOURCE_CHANGED", f"Source changed while it was opened: {path}")
892
+ if (before.st_size, before.st_mtime_ns) != (opened.st_size, opened.st_mtime_ns):
893
+ _fail("SOURCE_CHANGED", f"Source changed while it was opened: {path}")
894
+ yield stream, opened.st_size
895
+ after = os.fstat(stream.fileno())
896
+ if (opened.st_size, opened.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
897
+ _fail("SOURCE_CHANGED", f"Source changed while it was being read: {path}")
898
+ finally:
899
+ stream.close()
900
+
901
+
902
+ def _read_source_limited(source: SourceSnapshot, maximum: int) -> bytes:
903
+ with _open_verified_source(source) as (stream, expected_size):
904
+ if expected_size > maximum:
905
+ _fail("SOURCE_FILE_TOO_LARGE", f"Source file exceeds limit: {source.path}")
906
+ data = stream.read(maximum + 1)
907
+ if len(data) != expected_size or len(data) > maximum:
908
+ _fail("SOURCE_CHANGED", f"Source size changed while reading: {source.path}")
909
+ return data
910
+
911
+
912
+ def _hash_source_path(source: SourceSnapshot, maximum: int) -> str:
913
+ with _open_verified_source(source) as (stream, expected_size):
914
+ if expected_size > maximum:
915
+ _fail("SOURCE_FILE_TOO_LARGE", f"Source file exceeds limit: {source.path}")
916
+ digest = hashlib.sha256()
917
+ actual_size = 0
918
+ while True:
919
+ block = stream.read(CHUNK_SIZE)
920
+ if not block:
921
+ break
922
+ actual_size += len(block)
923
+ if actual_size > maximum:
924
+ _fail("SOURCE_FILE_TOO_LARGE", f"Source file exceeds limit: {source.path}")
925
+ digest.update(block)
926
+ if actual_size != expected_size:
927
+ _fail("SOURCE_CHANGED", f"Source size changed while hashing: {source.path}")
928
+ return digest.hexdigest()
929
+
930
+
931
+ def _hash_path(path: Path) -> str:
932
+ try:
933
+ with path.open("rb") as stream:
934
+ return _hash_stream(stream)
935
+ except OSError as error:
936
+ _fail("FILE_READ_FAILED", f"Cannot hash {path}: {error}")
937
+
938
+
939
+ def _zip_info(path: str) -> zipfile.ZipInfo:
940
+ info = zipfile.ZipInfo(path, date_time=FIXED_ZIP_TIMESTAMP)
941
+ info.compress_type = zipfile.ZIP_DEFLATED
942
+ info.create_system = 3
943
+ info.external_attr = (stat.S_IFREG | 0o644) << 16
944
+ info.internal_attr = 0
945
+ return info
946
+
947
+
948
+ def _write_bytes(archive: zipfile.ZipFile, path: str, data: bytes) -> None:
949
+ with archive.open(_zip_info(path), "w") as destination:
950
+ destination.write(data)
951
+
952
+
953
+ def _write_file(
954
+ archive: zipfile.ZipFile,
955
+ path: str,
956
+ source: SourceSnapshot,
957
+ maximum: int,
958
+ ) -> None:
959
+ try:
960
+ with _open_verified_source(source) as (input_stream, expected_size), archive.open(
961
+ _zip_info(path), "w"
962
+ ) as output_stream:
963
+ if expected_size > maximum:
964
+ _fail("SOURCE_FILE_TOO_LARGE", f"Source file exceeds limit: {path}")
965
+ actual_size = 0
966
+ while True:
967
+ block = input_stream.read(CHUNK_SIZE)
968
+ if not block:
969
+ break
970
+ actual_size += len(block)
971
+ if actual_size > maximum:
972
+ _fail("SOURCE_FILE_TOO_LARGE", f"Source file exceeds limit: {path}")
973
+ output_stream.write(block)
974
+ if actual_size != expected_size:
975
+ _fail("SOURCE_CHANGED", f"Source size changed while packaging: {path}")
976
+ except OSError as error:
977
+ _fail("SOURCE_READ_FAILED", f"Cannot package {source.path}: {error}")
978
+
979
+
980
+ def _path_is_within(path: Path, directory: Path) -> bool:
981
+ try:
982
+ path.relative_to(directory)
983
+ return True
984
+ except ValueError:
985
+ return False
986
+
987
+
988
+ def _ensure_private_directory_tree(path: Path) -> Path:
989
+ """Create a local directory tree without accepting reparse-point components."""
990
+
991
+ absolute = path.absolute()
992
+ parts = absolute.parts
993
+ if not parts:
994
+ _fail("INSTALL_CACHE_PATH", "Install cache path is empty")
995
+ current = Path(parts[0])
996
+ for part in parts[1:]:
997
+ current /= part
998
+ try:
999
+ current_stat = current.lstat()
1000
+ except FileNotFoundError:
1001
+ try:
1002
+ current.mkdir()
1003
+ current_stat = current.lstat()
1004
+ except OSError as error:
1005
+ _fail("INSTALL_CACHE_CREATE", f"Cannot create cache directory {current}: {error}")
1006
+ except OSError as error:
1007
+ _fail("INSTALL_CACHE_INSPECT", f"Cannot inspect cache directory {current}: {error}")
1008
+ if not stat.S_ISDIR(current_stat.st_mode) or _is_reparse_point(current_stat):
1009
+ _fail(
1010
+ "INSTALL_CACHE_REPARSE",
1011
+ f"Install cache component must be a regular directory: {current}",
1012
+ )
1013
+ return absolute
1014
+
1015
+
1016
+ def _remove_staging_directory(staging: Path, cache_root: Path) -> None:
1017
+ if not staging.exists():
1018
+ return
1019
+ if staging.parent != cache_root or not staging.name.startswith(".staging-"):
1020
+ _fail("INSTALL_STAGING_BOUNDARY", "Refusing to remove staging outside the cache root")
1021
+ try:
1022
+ staging_stat = staging.lstat()
1023
+ if not stat.S_ISDIR(staging_stat.st_mode) or _is_reparse_point(staging_stat):
1024
+ _fail("INSTALL_STAGING_REPARSE", "Refusing to remove a reparse staging path")
1025
+ shutil.rmtree(staging)
1026
+ except PackValidationError:
1027
+ raise
1028
+ except OSError as error:
1029
+ _fail("INSTALL_STAGING_CLEANUP", f"Cannot remove install staging directory: {error}")
1030
+
1031
+
1032
+ def _prune_inactive_plugin_sets(
1033
+ sets_root: Path,
1034
+ active_set: Path,
1035
+ emit: Any,
1036
+ warnings: list[dict[str, Any]],
1037
+ ) -> list[str]:
1038
+ """Remove inactive immutable run sets without touching persistent plugin data."""
1039
+
1040
+ removed: list[str] = []
1041
+ for candidate in sorted(sets_root.iterdir(), key=lambda path: path.name):
1042
+ if candidate == active_set:
1043
+ continue
1044
+ try:
1045
+ metadata = candidate.lstat()
1046
+ if (
1047
+ not SHA256_RE.fullmatch(candidate.name)
1048
+ or not stat.S_ISDIR(metadata.st_mode)
1049
+ or candidate.is_symlink()
1050
+ or _is_reparse_point(metadata)
1051
+ ):
1052
+ warning = {"code": "BPP-INBOX-CACHE-IGNORED", "path": str(candidate)}
1053
+ warnings.append(warning)
1054
+ emit("warning", warning["code"],
1055
+ "Unknown or unsafe cache entry was not removed", path=str(candidate))
1056
+ continue
1057
+ try:
1058
+ # A running server owns this per-set lease. Once an inactive
1059
+ # set can be leased, no cooperating runtime can newly select it
1060
+ # because current.json already points at active_set.
1061
+ with _cache_install_lease(candidate):
1062
+ pass
1063
+ except PackValidationError as error:
1064
+ warning = {"code": "BPP-INBOX-CACHE-IN-USE", "path": str(candidate)}
1065
+ warnings.append(warning)
1066
+ emit("warning", warning["code"],
1067
+ "Inactive plugin cache is still in use and was preserved",
1068
+ path=str(candidate), error=str(error))
1069
+ continue
1070
+ safe = True
1071
+ pending = [candidate]
1072
+ while pending and safe:
1073
+ directory = pending.pop()
1074
+ with os.scandir(directory) as entries:
1075
+ for entry in entries:
1076
+ entry_stat = entry.stat(follow_symlinks=False)
1077
+ if entry.is_symlink() or _is_reparse_point(entry_stat):
1078
+ safe = False
1079
+ break
1080
+ if stat.S_ISDIR(entry_stat.st_mode):
1081
+ pending.append(Path(entry.path))
1082
+ elif not stat.S_ISREG(entry_stat.st_mode):
1083
+ safe = False
1084
+ break
1085
+ if not safe:
1086
+ warning = {"code": "BPP-INBOX-CACHE-UNSAFE", "path": str(candidate)}
1087
+ warnings.append(warning)
1088
+ emit("warning", warning["code"],
1089
+ "Unsafe inactive cache was not removed", path=str(candidate))
1090
+ continue
1091
+ shutil.rmtree(candidate)
1092
+ removed.append(candidate.name)
1093
+ emit("info", "BPP-INBOX-CACHE-PRUNED",
1094
+ "Removed inactive plugin cache", set_id=candidate.name)
1095
+ except OSError as error:
1096
+ warning = {"code": "BPP-INBOX-CACHE-PRUNE-FAILED", "path": str(candidate)}
1097
+ warnings.append(warning)
1098
+ emit("warning", warning["code"],
1099
+ "Inactive plugin cache could not be removed", path=str(candidate), error=str(error))
1100
+ return removed
1101
+
1102
+
1103
+ @contextmanager
1104
+ def _cache_install_lease(cache_root: Path) -> Iterator[None]:
1105
+ """Exclude a running BedrockPlus runtime and concurrent installers."""
1106
+
1107
+ path = cache_root / CACHE_LEASE_FILENAME
1108
+ flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOINHERIT", 0)
1109
+ try:
1110
+ descriptor = os.open(path, flags, 0o600)
1111
+ stream = os.fdopen(descriptor, "r+b", buffering=0)
1112
+ except OSError as error:
1113
+ _fail("INSTALL_CACHE_IN_USE", f"Cannot open package-cache lease: {error}")
1114
+
1115
+ locked = False
1116
+ try:
1117
+ before = path.lstat()
1118
+ opened = os.fstat(stream.fileno())
1119
+ if (
1120
+ not stat.S_ISREG(before.st_mode)
1121
+ or stat.S_ISLNK(before.st_mode)
1122
+ or _is_reparse_point(before)
1123
+ or before.st_nlink != 1
1124
+ or not stat.S_ISREG(opened.st_mode)
1125
+ or _is_reparse_point(opened)
1126
+ or opened.st_nlink != 1
1127
+ or not _same_open_file(before, opened)
1128
+ ):
1129
+ _fail("INSTALL_CACHE_LEASE", "Package-cache lease is not a safe regular file")
1130
+ stream.seek(0)
1131
+ try:
1132
+ if os.name == "nt":
1133
+ msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
1134
+ else:
1135
+ fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
1136
+ locked = True
1137
+ except OSError as error:
1138
+ _fail(
1139
+ "INSTALL_CACHE_IN_USE",
1140
+ f"Package cache is in use by another BedrockPlus runtime or installer: {error}",
1141
+ )
1142
+ yield
1143
+ finally:
1144
+ if locked:
1145
+ try:
1146
+ stream.seek(0)
1147
+ if os.name == "nt":
1148
+ msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
1149
+ else:
1150
+ fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
1151
+ except OSError:
1152
+ pass
1153
+ stream.close()
1154
+
1155
+
1156
+ def _extract_verified_entry(
1157
+ archive: zipfile.ZipFile,
1158
+ info: zipfile.ZipInfo,
1159
+ destination: Path,
1160
+ expected_digest: str | None,
1161
+ ) -> None:
1162
+ try:
1163
+ destination.parent.mkdir(parents=True, exist_ok=True)
1164
+ parent_stat = destination.parent.lstat()
1165
+ if not stat.S_ISDIR(parent_stat.st_mode) or _is_reparse_point(parent_stat):
1166
+ _fail("INSTALL_PARENT_REPARSE", f"Unsafe extraction parent: {destination.parent}")
1167
+ flags = (
1168
+ os.O_WRONLY
1169
+ | os.O_CREAT
1170
+ | os.O_EXCL
1171
+ | getattr(os, "O_BINARY", 0)
1172
+ | getattr(os, "O_NOFOLLOW", 0)
1173
+ )
1174
+ descriptor = os.open(destination, flags, 0o600)
1175
+ digest = hashlib.sha256()
1176
+ actual_size = 0
1177
+ with os.fdopen(descriptor, "wb") as output, archive.open(info, "r") as source:
1178
+ while True:
1179
+ block = source.read(CHUNK_SIZE)
1180
+ if not block:
1181
+ break
1182
+ actual_size += len(block)
1183
+ if actual_size > info.file_size:
1184
+ _fail("ZIP_SIZE_MISMATCH", f"Expanded size exceeds metadata: {info.filename}")
1185
+ output.write(block)
1186
+ digest.update(block)
1187
+ output.flush()
1188
+ os.fsync(output.fileno())
1189
+ if actual_size != info.file_size:
1190
+ _fail("ZIP_SIZE_MISMATCH", f"Expanded size differs from metadata: {info.filename}")
1191
+ if expected_digest is not None and digest.hexdigest() != expected_digest:
1192
+ _fail("INTEGRITY_MISMATCH", f"SHA-256 changed while extracting {info.filename}")
1193
+ extracted_stat = destination.lstat()
1194
+ if not stat.S_ISREG(extracted_stat.st_mode) or _is_reparse_point(extracted_stat):
1195
+ _fail("INSTALL_OUTPUT_TYPE", f"Extracted path is not a regular file: {info.filename}")
1196
+ except PackValidationError:
1197
+ raise
1198
+ except (OSError, RuntimeError, zipfile.BadZipFile) as error:
1199
+ _fail("INSTALL_EXTRACT", f"Cannot extract {info.filename}: {error}")
1200
+
1201
+
1202
+ @contextmanager
1203
+ def _open_verified_installed_file(path: Path) -> Iterator[tuple[BinaryIO, int]]:
1204
+ """Open one cache file without accepting a final link/reparse swap."""
1205
+
1206
+ try:
1207
+ before = path.lstat()
1208
+ except OSError as error:
1209
+ _fail("INSTALL_TARGET_TAMPERED", f"Cannot inspect installed file {path}: {error}")
1210
+ if (
1211
+ not stat.S_ISREG(before.st_mode)
1212
+ or stat.S_ISLNK(before.st_mode)
1213
+ or _is_reparse_point(before)
1214
+ or before.st_nlink != 1
1215
+ ):
1216
+ _fail("INSTALL_TARGET_TAMPERED", f"Installed path is not a regular file: {path}")
1217
+ flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
1218
+ try:
1219
+ descriptor = os.open(path, flags)
1220
+ stream = os.fdopen(descriptor, "rb")
1221
+ except OSError as error:
1222
+ _fail("INSTALL_TARGET_TAMPERED", f"Cannot open installed file {path}: {error}")
1223
+ try:
1224
+ opened = os.fstat(stream.fileno())
1225
+ if (
1226
+ not stat.S_ISREG(opened.st_mode)
1227
+ or _is_reparse_point(opened)
1228
+ or opened.st_nlink != 1
1229
+ or not _same_open_file(before, opened)
1230
+ ):
1231
+ _fail("INSTALL_TARGET_TAMPERED", f"Installed file changed while opening: {path}")
1232
+ yield stream, opened.st_size
1233
+ after = os.fstat(stream.fileno())
1234
+ if after.st_nlink != 1 or not _same_open_file(opened, after):
1235
+ _fail("INSTALL_TARGET_TAMPERED", f"Installed file changed while reading: {path}")
1236
+ finally:
1237
+ stream.close()
1238
+
1239
+
1240
+ def _read_verified_installed_file(path: Path, maximum: int) -> bytes:
1241
+ with _open_verified_installed_file(path) as (stream, expected_size):
1242
+ if expected_size > maximum:
1243
+ _fail("INSTALL_TARGET_TAMPERED", f"Installed metadata is too large: {path}")
1244
+ data = stream.read(maximum + 1)
1245
+ if len(data) != expected_size or len(data) > maximum:
1246
+ _fail("INSTALL_TARGET_TAMPERED", f"Installed metadata changed: {path}")
1247
+ return data
1248
+
1249
+
1250
+ def _hash_verified_installed_file(path: Path) -> str:
1251
+ with _open_verified_installed_file(path) as (stream, expected_size):
1252
+ digest = hashlib.sha256()
1253
+ actual_size = 0
1254
+ while True:
1255
+ block = stream.read(CHUNK_SIZE)
1256
+ if not block:
1257
+ break
1258
+ actual_size += len(block)
1259
+ if actual_size > expected_size:
1260
+ _fail("INSTALL_TARGET_TAMPERED", f"Installed file grew while hashing: {path}")
1261
+ digest.update(block)
1262
+ if actual_size != expected_size:
1263
+ _fail("INSTALL_TARGET_TAMPERED", f"Installed file size changed: {path}")
1264
+ return digest.hexdigest()
1265
+
1266
+
1267
+ def _read_install_record(target: Path) -> tuple[dict[str, Any], bytes] | None:
1268
+ record_path = target / "install.json"
1269
+ try:
1270
+ record_bytes = _read_verified_installed_file(record_path, 2 * 1024 * 1024)
1271
+ record = _decode_json(record_bytes, "install.json")
1272
+ return (record, record_bytes) if isinstance(record, dict) else None
1273
+ except (OSError, PackValidationError):
1274
+ return None
1275
+
1276
+
1277
+ def _verify_existing_install(
1278
+ target: Path,
1279
+ expected_record: dict[str, Any],
1280
+ existing_record: dict[str, Any],
1281
+ existing_record_bytes: bytes,
1282
+ max_integrity_size: int,
1283
+ ) -> None:
1284
+ """Revalidate all immutable cache content before reporting reuse."""
1285
+
1286
+ try:
1287
+ target_stat = target.lstat()
1288
+ except OSError as error:
1289
+ _fail("INSTALL_TARGET_TAMPERED", f"Cannot inspect install target {target}: {error}")
1290
+ if not stat.S_ISDIR(target_stat.st_mode) or _is_reparse_point(target_stat):
1291
+ _fail("INSTALL_TARGET_TAMPERED", f"Install target is not a regular directory: {target}")
1292
+
1293
+ canonical_record = _canonical_json(expected_record)
1294
+ if existing_record != expected_record or existing_record_bytes != canonical_record:
1295
+ _fail("INSTALL_TARGET_TAMPERED", "Existing install record differs from package metadata")
1296
+
1297
+ archive_files = expected_record.get("files")
1298
+ integrity = expected_record.get("integrity")
1299
+ if not isinstance(archive_files, list) or not isinstance(integrity, dict):
1300
+ _fail("INSTALL_TARGET_TAMPERED", "Existing install record has an invalid file index")
1301
+ if set(archive_files) != {*integrity, INTEGRITY_PATH}:
1302
+ _fail("INSTALL_TARGET_TAMPERED", "Existing install record has an inconsistent integrity index")
1303
+
1304
+ expected_files = {"install.json", *(f"payload/{path}" for path in archive_files)}
1305
+ expected_directories = {"payload"}
1306
+ for relative in expected_files:
1307
+ parts = relative.split("/")
1308
+ for length in range(1, len(parts)):
1309
+ expected_directories.add("/".join(parts[:length]))
1310
+
1311
+ actual_files: set[str] = set()
1312
+ actual_directories: set[str] = set()
1313
+
1314
+ def visit(directory: Path, relative: str) -> None:
1315
+ try:
1316
+ directory_stat = directory.lstat()
1317
+ if not stat.S_ISDIR(directory_stat.st_mode) or _is_reparse_point(directory_stat):
1318
+ _fail("INSTALL_TARGET_TAMPERED", f"Unsafe installed directory: {directory}")
1319
+ entries = list(os.scandir(directory))
1320
+ except PackValidationError:
1321
+ raise
1322
+ except OSError as error:
1323
+ _fail("INSTALL_TARGET_TAMPERED", f"Cannot scan installed directory {directory}: {error}")
1324
+ for entry in entries:
1325
+ child_relative = f"{relative}/{entry.name}" if relative else entry.name
1326
+ child = Path(entry.path)
1327
+ try:
1328
+ child_stat = child.lstat()
1329
+ except OSError as error:
1330
+ _fail("INSTALL_TARGET_TAMPERED", f"Cannot inspect installed path {child}: {error}")
1331
+ if stat.S_ISLNK(child_stat.st_mode) or _is_reparse_point(child_stat):
1332
+ _fail("INSTALL_TARGET_TAMPERED", f"Installed cache contains a link: {child}")
1333
+ if stat.S_ISDIR(child_stat.st_mode):
1334
+ actual_directories.add(child_relative)
1335
+ visit(child, child_relative)
1336
+ elif stat.S_ISREG(child_stat.st_mode):
1337
+ actual_files.add(child_relative)
1338
+ else:
1339
+ _fail("INSTALL_TARGET_TAMPERED", f"Installed cache contains a special file: {child}")
1340
+
1341
+ visit(target, "")
1342
+ if actual_files != expected_files or actual_directories != expected_directories:
1343
+ _fail("INSTALL_TARGET_TAMPERED", "Existing install contains missing or unexpected paths")
1344
+
1345
+ payload = target / "payload"
1346
+ for relative, expected_digest in integrity.items():
1347
+ path = payload.joinpath(*relative.split("/"))
1348
+ if _hash_verified_installed_file(path) != expected_digest:
1349
+ _fail("INSTALL_TARGET_TAMPERED", f"Installed payload hash differs: {relative}")
1350
+ expected_integrity = _canonical_json(
1351
+ {
1352
+ "format_version": 1,
1353
+ "algorithm": "sha256",
1354
+ "files": dict(sorted(integrity.items())),
1355
+ }
1356
+ )
1357
+ actual_integrity = _read_verified_installed_file(
1358
+ payload / INTEGRITY_PATH,
1359
+ max_integrity_size,
1360
+ )
1361
+ if actual_integrity != expected_integrity:
1362
+ _fail("INSTALL_TARGET_TAMPERED", "Installed integrity.json differs from package metadata")
1363
+ if _read_verified_installed_file(target / "install.json", 2 * 1024 * 1024) != canonical_record:
1364
+ _fail("INSTALL_TARGET_TAMPERED", "Install record changed during cache verification")
1365
+
1366
+
1367
+ def _install_package_locked(
1368
+ package_path: str | os.PathLike[str],
1369
+ cache_root: Path,
1370
+ *,
1371
+ limits: PackLimits = DEFAULT_LIMITS,
1372
+ ) -> dict[str, Any]:
1373
+ """Validate, stage, and atomically activate one immutable package payload."""
1374
+
1375
+ package = Path(package_path)
1376
+ try:
1377
+ staging = Path(tempfile.mkdtemp(prefix=".staging-", dir=cache_root))
1378
+ except OSError as error:
1379
+ _fail("INSTALL_STAGING_CREATE", f"Cannot create install staging directory: {error}")
1380
+
1381
+ activated = False
1382
+ try:
1383
+ payload = staging / "payload"
1384
+ payload.mkdir()
1385
+ with _open_package_archive(package, limits) as (archive, inspection):
1386
+ for archive_path in sorted(inspection.entries):
1387
+ info = inspection.entries[archive_path]
1388
+ destination = payload.joinpath(*archive_path.split("/"))
1389
+ if not _path_is_within(destination, payload):
1390
+ _fail("INSTALL_PATH_ESCAPE", f"Extraction path escaped staging: {archive_path}")
1391
+ _extract_verified_entry(
1392
+ archive,
1393
+ info,
1394
+ destination,
1395
+ inspection.expected_digests.get(archive_path),
1396
+ )
1397
+
1398
+ runtime = inspection.manifest["runtime"]
1399
+ record = {
1400
+ "format_version": 1,
1401
+ "id": inspection.result["id"],
1402
+ "name": inspection.result["name"],
1403
+ "version": inspection.result["version"],
1404
+ "api_version": inspection.manifest["api_version"],
1405
+ "runtime": runtime,
1406
+ "dependencies": inspection.manifest.get("dependencies", []),
1407
+ "packs": inspection.manifest.get("packs", []),
1408
+ "package_sha256": inspection.result["sha256"],
1409
+ "files": sorted(inspection.entries),
1410
+ "integrity": dict(sorted(inspection.expected_digests.items())),
1411
+ }
1412
+ record_path = staging / "install.json"
1413
+ with record_path.open("xb") as record_stream:
1414
+ record_stream.write(_canonical_json(record))
1415
+ record_stream.flush()
1416
+ os.fsync(record_stream.fileno())
1417
+
1418
+ installed_root = _ensure_private_directory_tree(cache_root / "installed")
1419
+ plugin_root = _ensure_private_directory_tree(installed_root / record["id"])
1420
+ target = plugin_root / f"{record['version']}-{record['package_sha256']}"
1421
+ reused = False
1422
+ try:
1423
+ os.rename(staging, target)
1424
+ activated = True
1425
+ except FileExistsError:
1426
+ existing = _read_install_record(target)
1427
+ if existing is None or existing[0].get("package_sha256") != record["package_sha256"]:
1428
+ _fail("INSTALL_TARGET_CONFLICT", f"Install target already exists with other content: {target}")
1429
+ _verify_existing_install(
1430
+ target,
1431
+ record,
1432
+ existing[0],
1433
+ existing[1],
1434
+ limits.max_integrity_size,
1435
+ )
1436
+ reused = True
1437
+
1438
+ result = dict(inspection.result)
1439
+ result.update(
1440
+ {
1441
+ "installed": True,
1442
+ "reused": reused,
1443
+ "installed_path": str(target),
1444
+ "entrypoint": str(target / "payload" / Path(runtime["entrypoint"])),
1445
+ }
1446
+ )
1447
+ return result
1448
+ finally:
1449
+ if not activated and staging.exists():
1450
+ _remove_staging_directory(staging, cache_root)
1451
+
1452
+
1453
+ def install_package(
1454
+ package_path: str | os.PathLike[str],
1455
+ cache_directory: str | os.PathLike[str],
1456
+ *,
1457
+ limits: PackLimits = DEFAULT_LIMITS,
1458
+ ) -> dict[str, Any]:
1459
+ """Validate and atomically install one package under an exclusive cache lease."""
1460
+
1461
+ cache_root = _ensure_private_directory_tree(Path(cache_directory))
1462
+ with _cache_install_lease(cache_root):
1463
+ return _install_package_locked(package_path, cache_root, limits=limits)
1464
+
1465
+
1466
+ def sync_inbox(
1467
+ inbox_directory: str | os.PathLike[str],
1468
+ cache_directory: str | os.PathLike[str],
1469
+ *,
1470
+ limits: PackLimits = DEFAULT_LIMITS,
1471
+ maximum_plugins: int = 128,
1472
+ maximum_expanded_bytes: int = 2 * 1024 * 1024 * 1024,
1473
+ ) -> dict[str, Any]:
1474
+ """Prepare one complete plugin set; never run package code or alter the inbox.
1475
+
1476
+ The returned cache_path is directly consumable by PluginOrchestrator.
1477
+ current.json is published only after the entire set has been validated and
1478
+ installed. Old run sets remain available for an already-running server.
1479
+ """
1480
+ inbox = Path(os.path.abspath(inbox_directory))
1481
+ cache = Path(os.path.abspath(cache_directory))
1482
+ if _path_is_within(cache, inbox) or _path_is_within(inbox, cache):
1483
+ _fail("INBOX_CACHE_OVERLAP", "Inbox and internal cache must be separate directory trees")
1484
+ if maximum_plugins < 1 or maximum_plugins > 128 or maximum_expanded_bytes < 1:
1485
+ _fail("INBOX_LIMITS", "Invalid inbox limits")
1486
+ cache = _ensure_private_directory_tree(cache)
1487
+ now = datetime.now(timezone.utc)
1488
+ log_root = _ensure_private_directory_tree(cache / "logs" / now.strftime("%Y-%m-%d"))
1489
+ log_path = log_root / f"inbox-{now.strftime('%H-%M-%S')}-{uuid.uuid4().hex}.jsonl"
1490
+ warnings: list[dict[str, Any]] = []
1491
+ with log_path.open("x", encoding="utf-8", newline="\n") as log:
1492
+ source_staging: Path | None = None
1493
+ def emit(level: str, code: str, message: str, **fields: Any) -> None:
1494
+ record = {"timestamp": datetime.now(timezone.utc).isoformat(),
1495
+ "level": level, "code": code, "message": message, **fields}
1496
+ log.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
1497
+ log.flush()
1498
+
1499
+ try:
1500
+ emit("info", "BPP-INBOX-START", "Preparing plugin inbox", path=str(inbox))
1501
+ # Inspect every existing parent without creating anything in the inbox.
1502
+ for directory in [*reversed(inbox.parents), inbox]:
1503
+ metadata = directory.lstat()
1504
+ if not stat.S_ISDIR(metadata.st_mode) or _is_reparse_point(metadata) or directory.is_symlink():
1505
+ _fail("INBOX_UNSAFE_PATH", f"Inbox path is not a regular directory: {directory}")
1506
+ packages: list[Path] = []
1507
+ names: set[str] = set()
1508
+ entries: list[Path] = []
1509
+ for entry in inbox.iterdir():
1510
+ entries.append(entry)
1511
+ if len(entries) > 4096:
1512
+ _fail("INBOX_TOO_MANY_ENTRIES", "Inbox exceeds the directory-entry limit")
1513
+ for entry in sorted(entries, key=lambda path: path.name.casefold()):
1514
+ metadata = entry.lstat()
1515
+ if _is_reparse_point(metadata) or entry.is_symlink():
1516
+ _fail("INBOX_UNSAFE_PACKAGE", f"Inbox entries cannot be links: {entry}")
1517
+ folder = stat.S_ISDIR(metadata.st_mode)
1518
+ if (folder and not (entry / MANIFEST_PATH).exists()) or (
1519
+ not folder and entry.suffix.casefold() != ".bppack"):
1520
+ warning = {"code": "BPP-INBOX-IGNORED", "path": str(entry)}
1521
+ warnings.append(warning)
1522
+ emit("warning", warning["code"], "Expected .bppack or a plugin folder with manifest.json", path=str(entry))
1523
+ continue
1524
+ if not folder and (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1):
1525
+ _fail("INBOX_UNSAFE_PACKAGE", f"Package must be a regular single-link file: {entry}")
1526
+ if entry.name.casefold() in names:
1527
+ _fail("INBOX_NAME_COLLISION", "Package filenames collide ignoring case")
1528
+ names.add(entry.name.casefold())
1529
+ packages.append(entry)
1530
+ if len(packages) > maximum_plugins:
1531
+ _fail("INBOX_TOO_MANY_PLUGINS", "Inbox exceeds the plugin-count limit")
1532
+
1533
+ with _cache_install_lease(cache):
1534
+ package_sources: dict[str, Path] = {}
1535
+ selected: list[dict[str, Any]] = []
1536
+ ids: set[str] = set()
1537
+ total = 0
1538
+ for package in packages:
1539
+ if package.is_dir():
1540
+ if source_staging is None:
1541
+ source_staging = Path(tempfile.mkdtemp(prefix=".staging-sources-", dir=cache))
1542
+ archive = source_staging / f"{len(package_sources)}.bppack"
1543
+ validated = build_package(package, archive, limits=limits)
1544
+ package_sources[package.name] = archive
1545
+ emit("info", "BPP-INBOX-FOLDER", "Plugin folder snapshotted without executing code", path=str(package))
1546
+ else:
1547
+ validated = validate_package(package, limits)
1548
+ package_sources[package.name] = package
1549
+ if validated["id"] in ids:
1550
+ _fail("INBOX_DUPLICATE_ID", f"More than one package declares {validated['id']}")
1551
+ ids.add(validated["id"])
1552
+ total += validated["expanded_size"]
1553
+ if total > maximum_expanded_bytes:
1554
+ _fail("INBOX_TOTAL_SIZE", "Inbox exceeds the expanded-size limit")
1555
+ selected.append({"id": validated["id"], "version": validated["version"],
1556
+ "sha256": validated["sha256"], "package": package.name})
1557
+ emit("info", "BPP-INBOX-VALIDATED", "Plugin package validated",
1558
+ plugin_id=validated["id"], version=validated["version"], sha256=validated["sha256"])
1559
+ selected.sort(key=lambda item: item["id"])
1560
+ # Format 2 run sets retain exact package snapshots for automatic
1561
+ # bundled-pack binding. The changed identity migrates safely from
1562
+ # older immutable sets instead of editing them in place.
1563
+ selection = {"format_version": 2, "plugins": selected}
1564
+ selection_bytes = _canonical_json(selection)
1565
+ set_id = hashlib.sha256(selection_bytes).hexdigest()
1566
+ sets = _ensure_private_directory_tree(cache / "sets")
1567
+ target = sets / set_id
1568
+ reused = target.exists()
1569
+ staging: Path | None = None
1570
+ try:
1571
+ if reused:
1572
+ _ensure_private_directory_tree(target)
1573
+ if _read_verified_installed_file(target / "selection.json", 128 * 1024) != selection_bytes:
1574
+ _fail("INBOX_SET_TAMPERED", "Cached plugin selection differs from its identity")
1575
+ # Always prepare in a new tree. A source package changing
1576
+ # during this operation must never mutate a published set.
1577
+ staging = Path(tempfile.mkdtemp(prefix=".staging-inbox-", dir=cache))
1578
+ prepared = staging
1579
+ with _cache_install_lease(prepared):
1580
+ installed = _ensure_private_directory_tree(prepared / "installed")
1581
+ snapshots = _ensure_private_directory_tree(prepared / "packages")
1582
+ for expected in selected:
1583
+ # Retain the exact validated archive in the immutable
1584
+ # run set. Folder-form plugins therefore have the same
1585
+ # downstream pack-preparation input as .bppack files.
1586
+ snapshot = snapshots / f"{expected['id']}.bppack"
1587
+ shutil.copyfile(package_sources[expected["package"]], snapshot)
1588
+ snapshotted = validate_package(snapshot, limits)
1589
+ if any(snapshotted[key] != expected[key] for key in ("id", "version", "sha256")):
1590
+ _fail("INBOX_CHANGED", "A package changed while its run-set snapshot was created")
1591
+ result = _install_package_locked(snapshot, prepared, limits=limits)
1592
+ if any(result[key] != expected[key] for key in ("id", "version", "sha256")):
1593
+ _fail("INBOX_CHANGED", "A package changed between validation and installation")
1594
+ source_after = validate_package(package_sources[expected["package"]], limits)
1595
+ if any(source_after[key] != expected[key] for key in ("id", "version", "sha256")):
1596
+ _fail("INBOX_CHANGED", "A package changed while the prepared set was built")
1597
+ plugin_root = installed / expected["id"]
1598
+ if len(list(plugin_root.iterdir())) != 1:
1599
+ _fail("INBOX_SET_TAMPERED", "A plugin has unexpected cached versions")
1600
+ emit("info", "BPP-INBOX-INSTALLED", "Plugin ready in prepared set", plugin_id=expected["id"])
1601
+ with (prepared / "selection.json").open("xb") as output:
1602
+ output.write(selection_bytes)
1603
+ output.flush()
1604
+ os.fsync(output.fileno())
1605
+ if reused:
1606
+ # Compare against records derived from the fresh archive,
1607
+ # not against self-reported hashes in the old cache.
1608
+ # Use the same lease held by the plugin runtime.
1609
+ with _cache_install_lease(target):
1610
+ old_installed = target / "installed"
1611
+ old_snapshots = target / "packages"
1612
+ if not old_installed.is_dir() or not old_snapshots.is_dir():
1613
+ _fail("INBOX_SET_TAMPERED", "Cached installed or package-snapshot directory is missing")
1614
+ _ensure_private_directory_tree(old_installed)
1615
+ _ensure_private_directory_tree(old_snapshots)
1616
+ if {path.name for path in old_installed.iterdir()} != ids:
1617
+ _fail("INBOX_SET_TAMPERED", "Cached plugin set has missing or unexpected plugin IDs")
1618
+ if {path.name for path in old_snapshots.iterdir()} != {f"{plugin_id}.bppack" for plugin_id in ids}:
1619
+ _fail("INBOX_SET_TAMPERED", "Cached package snapshots are missing or unexpected")
1620
+ for expected in selected:
1621
+ old_plugin = old_installed / expected["id"]
1622
+ _ensure_private_directory_tree(old_plugin)
1623
+ version_name = f"{expected['version']}-{expected['sha256']}"
1624
+ if {path.name for path in old_plugin.iterdir()} != {version_name}:
1625
+ _fail("INBOX_SET_TAMPERED", "Cached plugin versions differ from selection")
1626
+ fresh = _read_install_record(installed / expected["id"] / version_name)
1627
+ old = _read_install_record(old_plugin / version_name)
1628
+ if fresh is None or old is None:
1629
+ _fail("INBOX_SET_TAMPERED", "Cannot verify cached install record")
1630
+ _verify_existing_install(old_plugin / version_name, fresh[0], old[0], old[1], limits.max_integrity_size)
1631
+ cached_package = validate_package(old_snapshots / f"{expected['id']}.bppack", limits)
1632
+ if any(cached_package[key] != expected[key] for key in ("id", "version", "sha256")):
1633
+ _fail("INBOX_SET_TAMPERED", "Cached package snapshot identity differs")
1634
+ else:
1635
+ os.rename(staging, target)
1636
+ staging = None
1637
+
1638
+ active = {"format_version": 1, "set_id": set_id, "relative_cache": f"sets/{set_id}"}
1639
+ current = cache / "current.json"
1640
+ if current.exists() or current.is_symlink():
1641
+ # Never replace a reparse point or a multiply linked file.
1642
+ _read_verified_installed_file(current, 4096)
1643
+ temporary = cache / f".current-{uuid.uuid4().hex}.json"
1644
+ try:
1645
+ with temporary.open("xb") as output:
1646
+ output.write(_canonical_json(active))
1647
+ output.flush()
1648
+ os.fsync(output.fileno())
1649
+ os.replace(temporary, current)
1650
+ finally:
1651
+ if temporary.exists():
1652
+ temporary.unlink()
1653
+ pruned_sets = _prune_inactive_plugin_sets(sets, target, emit, warnings)
1654
+ emit("info", "BPP-INBOX-READY", "Complete plugin set published",
1655
+ set_id=set_id, plugin_count=len(selected), pruned_sets=len(pruned_sets))
1656
+ return {"ready": True, "set_id": set_id, "cache_path": str(target),
1657
+ "package_paths": [str(target / "packages" / f"{item['id']}.bppack") for item in selected],
1658
+ "plugins": selected, "reused": reused, "warnings": warnings,
1659
+ "pruned_sets": pruned_sets, "log_path": str(log_path)}
1660
+ finally:
1661
+ if staging is not None:
1662
+ _remove_staging_directory(staging, cache)
1663
+ except PackValidationError as error:
1664
+ emit("error", error.code, str(error))
1665
+ raise
1666
+ except OSError as error:
1667
+ emit("error", "INBOX_IO", str(error))
1668
+ _fail("INBOX_IO", f"Could not prepare plugin inbox: {error}")
1669
+ finally:
1670
+ if source_staging is not None:
1671
+ _remove_staging_directory(source_staging, cache)
1672
+
1673
+
1674
+ def build_package(
1675
+ source_directory: str | os.PathLike[str],
1676
+ output_path: str | os.PathLike[str],
1677
+ *,
1678
+ overwrite: bool = False,
1679
+ limits: PackLimits = DEFAULT_LIMITS,
1680
+ ) -> dict[str, Any]:
1681
+ """Build, atomically publish, and revalidate a deterministic .bppack."""
1682
+
1683
+ source_argument = Path(source_directory).absolute()
1684
+ try:
1685
+ source_stat = source_argument.lstat()
1686
+ except OSError as error:
1687
+ _fail("SOURCE_NOT_DIRECTORY", f"Cannot inspect package source: {error}")
1688
+ if stat.S_ISLNK(source_stat.st_mode) or _is_reparse_point(source_stat):
1689
+ _fail("SOURCE_LINK", "The package source root cannot be a link or reparse point")
1690
+ source = source_argument.resolve()
1691
+ output = Path(output_path).resolve()
1692
+ if not source.is_dir():
1693
+ _fail("SOURCE_NOT_DIRECTORY", f"Package source is not a directory: {source}")
1694
+ if output.suffix.casefold() != ".bppack":
1695
+ _fail("PACKAGE_EXTENSION", "Output filename must end in .bppack")
1696
+ if _path_is_within(output, source):
1697
+ _fail("OUTPUT_INSIDE_SOURCE", "Output package must not be placed inside its source tree")
1698
+ if output.exists() and not overwrite:
1699
+ _fail("OUTPUT_EXISTS", f"Output already exists (use --force): {output}")
1700
+
1701
+ files = _scan_source(source, limits)
1702
+ if MANIFEST_PATH not in files:
1703
+ _fail("MANIFEST_MISSING", "Package source has no root manifest.json")
1704
+ manifest_bytes = _read_source_limited(
1705
+ files[MANIFEST_PATH], limits.max_manifest_size
1706
+ )
1707
+ manifest = _decode_json(manifest_bytes, MANIFEST_PATH)
1708
+ _validate_manifest(manifest, set(files))
1709
+ canonical_manifest = _canonical_json(manifest)
1710
+ if len(canonical_manifest) > limits.max_manifest_size:
1711
+ _fail("ENTRY_TOO_LARGE", "Canonical manifest.json exceeds its size limit")
1712
+
1713
+ digests: dict[str, str] = {}
1714
+ for path in sorted(files):
1715
+ if path == MANIFEST_PATH:
1716
+ digests[path] = hashlib.sha256(canonical_manifest).hexdigest()
1717
+ else:
1718
+ digests[path] = _hash_source_path(files[path], limits.max_file_size)
1719
+ integrity = {
1720
+ "format_version": 1,
1721
+ "algorithm": "sha256",
1722
+ "files": digests,
1723
+ }
1724
+ integrity_bytes = _canonical_json(integrity)
1725
+ if len(integrity_bytes) > limits.max_integrity_size:
1726
+ _fail("ENTRY_TOO_LARGE", "Generated integrity.json exceeds its size limit")
1727
+
1728
+ try:
1729
+ output.parent.mkdir(parents=True, exist_ok=True)
1730
+ descriptor, temporary_name = tempfile.mkstemp(
1731
+ prefix=f".{output.name}.", suffix=".bppack", dir=output.parent
1732
+ )
1733
+ os.close(descriptor)
1734
+ except OSError as error:
1735
+ _fail("OUTPUT_CREATE_FAILED", f"Cannot create output staging file: {error}")
1736
+
1737
+ temporary = Path(temporary_name)
1738
+ try:
1739
+ try:
1740
+ with zipfile.ZipFile(
1741
+ temporary,
1742
+ "w",
1743
+ compression=zipfile.ZIP_DEFLATED,
1744
+ compresslevel=9,
1745
+ allowZip64=False,
1746
+ ) as archive:
1747
+ for path in sorted((*files.keys(), INTEGRITY_PATH)):
1748
+ if path == MANIFEST_PATH:
1749
+ _write_bytes(archive, path, canonical_manifest)
1750
+ elif path == INTEGRITY_PATH:
1751
+ _write_bytes(archive, path, integrity_bytes)
1752
+ else:
1753
+ _write_file(
1754
+ archive,
1755
+ path,
1756
+ files[path],
1757
+ limits.max_file_size,
1758
+ )
1759
+ except (OSError, zipfile.LargeZipFile) as error:
1760
+ _fail("OUTPUT_WRITE_FAILED", f"Cannot write package: {error}")
1761
+
1762
+ if _scan_source(source, limits) != files:
1763
+ _fail("SOURCE_CHANGED", "Package source changed while the archive was built")
1764
+ result = validate_package(temporary, limits)
1765
+ try:
1766
+ if output.exists() and not overwrite:
1767
+ _fail("OUTPUT_EXISTS", f"Output appeared while building: {output}")
1768
+ os.replace(temporary, output)
1769
+ except OSError as error:
1770
+ _fail("OUTPUT_PUBLISH_FAILED", f"Cannot publish package: {error}")
1771
+ finally:
1772
+ if temporary.exists():
1773
+ try:
1774
+ temporary.unlink()
1775
+ except OSError:
1776
+ pass
1777
+
1778
+ result["path"] = str(output)
1779
+ return result
1780
+
1781
+
1782
+ def _human_size(value: int) -> str:
1783
+ size = float(value)
1784
+ for unit in ("B", "KiB", "MiB", "GiB"):
1785
+ if size < 1024 or unit == "GiB":
1786
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
1787
+ size /= 1024
1788
+ return f"{value} B"
1789
+
1790
+
1791
+ def _create_parser() -> argparse.ArgumentParser:
1792
+ parser = argparse.ArgumentParser(
1793
+ prog="bppack",
1794
+ description="Build and validate BedrockPlus plugin packages",
1795
+ )
1796
+ subparsers = parser.add_subparsers(dest="command", required=True)
1797
+
1798
+ build = subparsers.add_parser("build", help="build a deterministic package")
1799
+ build.add_argument("source", help="package payload directory")
1800
+ build.add_argument("output", help="output .bppack path")
1801
+ build.add_argument("--force", action="store_true", help="replace an existing output")
1802
+ build.add_argument("--json", action="store_true", help="print structured JSON")
1803
+
1804
+ validate = subparsers.add_parser("validate", help="validate an existing package")
1805
+ validate.add_argument("package", help="input .bppack path")
1806
+ validate.add_argument("--json", action="store_true", help="print structured JSON")
1807
+
1808
+ install = subparsers.add_parser(
1809
+ "install", help="validate and atomically install a package into an internal cache"
1810
+ )
1811
+ install.add_argument("package", help="input .bppack path")
1812
+ install.add_argument("cache", help="private BedrockPlus package-cache directory")
1813
+ install.add_argument("--json", action="store_true", help="print structured JSON")
1814
+ sync = subparsers.add_parser("sync", help="prepare a complete plugins/ inbox for the native/Python runtime")
1815
+ sync.add_argument("inbox", help="directory where the operator drops .bppack files")
1816
+ sync.add_argument("cache", help="private BedrockPlus run-set cache (outside the inbox)")
1817
+ sync.add_argument("--json", action="store_true", help="print structured JSON")
1818
+ return parser
1819
+
1820
+
1821
+ def main(argv: Iterable[str] | None = None) -> int:
1822
+ arguments = _create_parser().parse_args(list(argv) if argv is not None else None)
1823
+ try:
1824
+ if arguments.command == "build":
1825
+ result = build_package(arguments.source, arguments.output, overwrite=arguments.force)
1826
+ elif arguments.command == "validate":
1827
+ result = validate_package(arguments.package)
1828
+ elif arguments.command == "sync":
1829
+ result = sync_inbox(arguments.inbox, arguments.cache)
1830
+ else:
1831
+ result = install_package(arguments.package, arguments.cache)
1832
+ except PackValidationError as error:
1833
+ if getattr(arguments, "json", False):
1834
+ print(json.dumps(error.as_dict(), ensure_ascii=False, sort_keys=True))
1835
+ else:
1836
+ print(f"ERROR [{error.code}] {error}", file=sys.stderr)
1837
+ return 2
1838
+
1839
+ if arguments.json:
1840
+ print(json.dumps(result, ensure_ascii=False, sort_keys=True))
1841
+ elif arguments.command == "build":
1842
+ print(
1843
+ f"Built {result['id']} {result['version']} -> {result['path']} "
1844
+ f"({result['sha256']})"
1845
+ )
1846
+ elif arguments.command == "validate":
1847
+ print(
1848
+ f"VALID {result['id']} {result['version']} "
1849
+ f"({result['runtime']}, {result['files']} files, "
1850
+ f"{_human_size(result['expanded_size'])})"
1851
+ )
1852
+ elif arguments.command == "sync":
1853
+ print(f"Ready: {len(result['plugins'])} plugins -> {result['cache_path']}")
1854
+ print(f"Validation log: {result['log_path']}")
1855
+ else:
1856
+ action = "Reused" if result["reused"] else "Installed"
1857
+ print(
1858
+ f"{action} {result['id']} {result['version']} -> "
1859
+ f"{result['installed_path']}"
1860
+ )
1861
+ return 0
1862
+
1863
+
1864
+ if __name__ == "__main__":
1865
+ raise SystemExit(main())