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.
- bedrockplus/__init__.py +95 -0
- bedrockplus/__main__.py +4 -0
- bedrockplus/_assets/Initialize-BedrockPlusServer.ps1 +395 -0
- bedrockplus/_assets/README.md +85 -0
- bedrockplus/_assets/SHA256SUMS +20 -0
- bedrockplus/_assets/Start-BedrockPlus.ps1 +125 -0
- bedrockplus/_assets/THIRD_PARTY_NOTICES.md +26 -0
- bedrockplus/_assets/bedrockplus.exe +0 -0
- bedrockplus/_assets/bedrockplus_bootstrap.dll +0 -0
- bedrockplus/_assets/bedrockplus_plugin_host.dll +0 -0
- bedrockplus/_assets/bedrockplus_plugin_worker.dll +0 -0
- bedrockplus/_assets/bedrockplus_runtime.dll +0 -0
- bedrockplus/_assets/bin/bedrockplus.ps1 +192 -0
- bedrockplus/_assets/compatibility/bds/1.26.51.1/windows-x64.json +24 -0
- bedrockplus/_assets/compatibility/bds/supported-versions.json +14 -0
- bedrockplus/_assets/licenses/microsoft-detours-MIT.txt +23 -0
- bedrockplus/_assets/plugins/README.txt +11 -0
- bedrockplus/_assets/release-manifest.json +34 -0
- bedrockplus/_assets/server/README.txt +10 -0
- bedrockplus/_assets/tools/bppack.py +1865 -0
- bedrockplus/_assets/tools/init_python_plugin.py +140 -0
- bedrockplus/_assets/tools/pack_plugin.py +142 -0
- bedrockplus/_assets/tools/world_packs.py +530 -0
- bedrockplus/api.py +83 -0
- bedrockplus/cli.py +121 -0
- bedrockplus/commands.py +50 -0
- bedrockplus/events.py +138 -0
- bedrockplus/objects.py +413 -0
- bedrockplus/py.typed +1 -0
- bedrockplus-0.1.0.dev1.dist-info/METADATA +41 -0
- bedrockplus-0.1.0.dev1.dist-info/RECORD +36 -0
- bedrockplus-0.1.0.dev1.dist-info/WHEEL +4 -0
- bedrockplus-0.1.0.dev1.dist-info/entry_points.txt +2 -0
- bedrockplus-0.1.0.dev1.dist-info/licenses/THIRD_PARTY_NOTICES.md +26 -0
- bedrockplus-0.1.0.dev1.dist-info/licenses/microsoft-detours-MIT.txt +23 -0
- 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())
|