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,530 @@
|
|
|
1
|
+
"""Offline, recoverable world-pack preparation. Not yet a launcher integration.
|
|
2
|
+
|
|
3
|
+
Uses only stdlib and the project's package validator. Never executes plugin code
|
|
4
|
+
or deletes pack directories. An interrupted metadata commit requires recover.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import base64
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
import hashlib
|
|
13
|
+
import importlib.util
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import stat
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import uuid
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import bppack as bp
|
|
24
|
+
except ModuleNotFoundError:
|
|
25
|
+
# `python -I tools/world_packs.py` intentionally omits the script directory
|
|
26
|
+
# from sys.path; load the audited sibling explicitly without widening imports.
|
|
27
|
+
specification = importlib.util.spec_from_file_location(
|
|
28
|
+
"bedrockplus_world_packs_bppack", Path(__file__).resolve().with_name("bppack.py"))
|
|
29
|
+
if specification is None or specification.loader is None:
|
|
30
|
+
raise RuntimeError("Could not load the bundled bppack validator")
|
|
31
|
+
bp = importlib.util.module_from_spec(specification)
|
|
32
|
+
sys.modules[specification.name] = bp
|
|
33
|
+
specification.loader.exec_module(bp)
|
|
34
|
+
|
|
35
|
+
KINDS = ("behavior", "resource")
|
|
36
|
+
REGISTRATIONS = tuple(f"world_{kind}_packs.json" for kind in KINDS)
|
|
37
|
+
STATE = ".bedrockplus-packs/state.json"
|
|
38
|
+
JOURNAL = ".bedrockplus-packs/pending.json"
|
|
39
|
+
BINDING = ".bedrockplus-packs/binding.json"
|
|
40
|
+
TARGETS = (*REGISTRATIONS, STATE, BINDING)
|
|
41
|
+
MAX_METADATA = 4 * 1024 * 1024
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def reject(code, message):
|
|
45
|
+
raise bp.PackValidationError("WORLD_PACK_" + code, message)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def safe_path(path: Path) -> Path:
|
|
49
|
+
path = Path(os.path.abspath(path))
|
|
50
|
+
if os.name == "nt":
|
|
51
|
+
import ctypes
|
|
52
|
+
if len(path.drive) != 2 or path.drive[1] != ":" or ctypes.windll.kernel32.GetDriveTypeW(str(Path(path.anchor))) != 3:
|
|
53
|
+
reject("PATH", "Use a normal absolute path on a fixed local drive")
|
|
54
|
+
if any(part.casefold() == "referenceonly" for part in path.parts):
|
|
55
|
+
reject("REFERENCE", "referenceOnly is permanently read-only")
|
|
56
|
+
current = Path(path.anchor)
|
|
57
|
+
for part in path.parts[1:]:
|
|
58
|
+
if part.endswith((".", " ")) or ":" in part:
|
|
59
|
+
reject("PATH", "Ambiguous Windows path")
|
|
60
|
+
current /= part
|
|
61
|
+
if not os.path.lexists(current):
|
|
62
|
+
continue
|
|
63
|
+
info = current.lstat()
|
|
64
|
+
if stat.S_ISLNK(info.st_mode) or bp._is_reparse_point(info):
|
|
65
|
+
reject("LINK", f"Links/reparse points are forbidden: {current}")
|
|
66
|
+
if current != path and not stat.S_ISDIR(info.st_mode):
|
|
67
|
+
reject("PATH", f"Parent is not a directory: {current}")
|
|
68
|
+
return path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def read_optional(path):
|
|
72
|
+
safe_path(path)
|
|
73
|
+
if not os.path.lexists(path):
|
|
74
|
+
return None
|
|
75
|
+
return bp._read_verified_installed_file(path, MAX_METADATA)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def identifier(value):
|
|
79
|
+
try:
|
|
80
|
+
result = str(uuid.UUID(value))
|
|
81
|
+
except (ValueError, TypeError, AttributeError):
|
|
82
|
+
reject("UUID", "Pack UUID must be a canonical UUID string")
|
|
83
|
+
if result != value or result == str(uuid.UUID(int=0)):
|
|
84
|
+
reject("UUID", "Pack UUID must be canonical lowercase and nonzero")
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def version(value):
|
|
89
|
+
if not isinstance(value, list) or len(value) != 3 or any(type(n) is not int or not 0 <= n <= 2147483647 for n in value):
|
|
90
|
+
reject("VERSION", "Pack version must contain three nonnegative integers")
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def pack_manifest(raw, kind):
|
|
95
|
+
value = bp._decode_json(raw, "vanilla pack manifest")
|
|
96
|
+
if not isinstance(value, dict) or type(value.get("format_version")) is not int or value["format_version"] != 2:
|
|
97
|
+
reject("FORMAT", "Only vanilla manifest format 2 is supported")
|
|
98
|
+
header = value.get("header")
|
|
99
|
+
modules = value.get("modules")
|
|
100
|
+
if not isinstance(header, dict) or not isinstance(modules, list) or not 1 <= len(modules) <= 128:
|
|
101
|
+
reject("MANIFEST", "Pack requires a header and modules")
|
|
102
|
+
for field in ("name", "description"):
|
|
103
|
+
if not isinstance(header.get(field), str) or not header[field]:
|
|
104
|
+
reject("MANIFEST", f"Pack header requires {field}")
|
|
105
|
+
pack_id = identifier(header.get("uuid"))
|
|
106
|
+
result = {"pack_id": pack_id, "version": version(header.get("version"))}
|
|
107
|
+
if version(header.get("min_engine_version")) > [1, 26, 51]:
|
|
108
|
+
reject("ENGINE", "Pack requires a newer game version than the pinned BDS")
|
|
109
|
+
if header.get("pack_scope", "world") not in ("world", "any"):
|
|
110
|
+
reject("SCOPE", "Global-only resource packs cannot be registered to a world")
|
|
111
|
+
identities = {pack_id}
|
|
112
|
+
for module in modules:
|
|
113
|
+
if not isinstance(module, dict) or module.get("type") != {"behavior": "data", "resource": "resources"}[kind]:
|
|
114
|
+
reject("MODULE", "Initial preparation supports data/resources modules only, not scripts")
|
|
115
|
+
module_id = identifier(module.get("uuid"))
|
|
116
|
+
version(module.get("version"))
|
|
117
|
+
if module_id in identities:
|
|
118
|
+
reject("UUID_COLLISION", "Header/module UUIDs must be distinct")
|
|
119
|
+
identities.add(module_id)
|
|
120
|
+
dependencies = value.get("dependencies", [])
|
|
121
|
+
if not isinstance(dependencies, list) or len(dependencies) > 128:
|
|
122
|
+
reject("DEPENDENCY", "Invalid pack dependencies")
|
|
123
|
+
seen = set()
|
|
124
|
+
for dependency in dependencies:
|
|
125
|
+
if not isinstance(dependency, dict) or set(dependency) != {"uuid", "version"}:
|
|
126
|
+
reject("DEPENDENCY", "Only bundled UUID dependencies are supported")
|
|
127
|
+
dep_id = identifier(dependency["uuid"])
|
|
128
|
+
version(dependency["version"])
|
|
129
|
+
if dep_id in seen or dep_id == pack_id:
|
|
130
|
+
reject("DEPENDENCY", "Duplicate/self dependency")
|
|
131
|
+
seen.add(dep_id)
|
|
132
|
+
return result, sorted(identities), dependencies
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def registration(raw):
|
|
136
|
+
entries = [] if raw is None else bp._decode_json(raw, "world pack registrations")
|
|
137
|
+
if not isinstance(entries, list) or len(entries) > 1024:
|
|
138
|
+
reject("REGISTRATION", "World registrations must be a bounded array")
|
|
139
|
+
seen = set()
|
|
140
|
+
for entry in entries:
|
|
141
|
+
if not isinstance(entry, dict) or set(entry) != {"pack_id", "version"}:
|
|
142
|
+
reject("REGISTRATION", "Unsupported existing registration; preserve it manually")
|
|
143
|
+
key = identifier(entry["pack_id"])
|
|
144
|
+
version(entry["version"])
|
|
145
|
+
if key in seen:
|
|
146
|
+
reject("UUID_COLLISION", "Duplicate world pack UUID")
|
|
147
|
+
seen.add(key)
|
|
148
|
+
return entries
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def encode(raw):
|
|
152
|
+
return None if raw is None else base64.b64encode(raw).decode("ascii")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def decode(value):
|
|
156
|
+
if value is None:
|
|
157
|
+
return None
|
|
158
|
+
if not isinstance(value, str) or len(value) > MAX_METADATA * 2:
|
|
159
|
+
reject("STATE", "Invalid backup bytes")
|
|
160
|
+
try:
|
|
161
|
+
raw = base64.b64decode(value, validate=True)
|
|
162
|
+
except ValueError:
|
|
163
|
+
reject("STATE", "Invalid backup encoding")
|
|
164
|
+
if len(raw) > MAX_METADATA:
|
|
165
|
+
reject("STATE", "Backup exceeds limit")
|
|
166
|
+
return raw
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def plan(world, packages, binding=None):
|
|
170
|
+
world = safe_path(Path(world))
|
|
171
|
+
if not world.is_dir() or world == Path(world.anchor):
|
|
172
|
+
reject("WORLD", "An existing world directory is required")
|
|
173
|
+
if read_optional(world / JOURNAL) is not None:
|
|
174
|
+
reject("RECOVERY_REQUIRED", "Recover the pending transaction before preparing or starting BDS")
|
|
175
|
+
before = {name: read_optional(world / name) for name in TARGETS}
|
|
176
|
+
if before[STATE] is None and before[BINDING] is not None:
|
|
177
|
+
reject("STATE", "Pack binding exists without ownership state")
|
|
178
|
+
baseline = {name: before[name] for name in REGISTRATIONS}
|
|
179
|
+
if before[STATE] is not None:
|
|
180
|
+
state = bp._decode_json(before[STATE], "world pack state")
|
|
181
|
+
if not isinstance(state, dict) or set(state) != {"format", "baseline", "expected", "owned"} or state["format"] != 1:
|
|
182
|
+
reject("STATE", "Invalid ownership state")
|
|
183
|
+
if not isinstance(state["baseline"], dict) or not isinstance(state["expected"], dict) or set(state["baseline"]) != set(REGISTRATIONS) or set(state["expected"]) != set(REGISTRATIONS):
|
|
184
|
+
reject("STATE", "Invalid world metadata state")
|
|
185
|
+
for name in REGISTRATIONS:
|
|
186
|
+
if before[name] != decode(state["expected"][name]):
|
|
187
|
+
reject("CONFLICT", "World registrations were edited outside BedrockPlus; refusing overwrite")
|
|
188
|
+
baseline[name] = decode(state["baseline"][name])
|
|
189
|
+
base_entries = {name: registration(raw) for name, raw in baseline.items()}
|
|
190
|
+
known_ids = {item["pack_id"] for values in base_entries.values() for item in values}
|
|
191
|
+
owners, packs, package_plugins = set(), [], []
|
|
192
|
+
if len(packages) > 128:
|
|
193
|
+
reject("LIMIT", "Too many plugin packages")
|
|
194
|
+
for package in packages:
|
|
195
|
+
package = safe_path(Path(package))
|
|
196
|
+
with bp._open_package_archive(package, bp.DEFAULT_LIMITS) as (archive, info):
|
|
197
|
+
owner = info.manifest["id"]
|
|
198
|
+
if owner in owners:
|
|
199
|
+
reject("OWNER", "Multiple selected versions of one plugin")
|
|
200
|
+
owners.add(owner)
|
|
201
|
+
package_plugins.append({"id": owner, "version": info.manifest["version"],
|
|
202
|
+
"sha256": info.result["sha256"]})
|
|
203
|
+
for declaration in info.manifest.get("packs", []):
|
|
204
|
+
if len(packs) >= 128:
|
|
205
|
+
reject("LIMIT", "Too many bundled packs")
|
|
206
|
+
kind, root = declaration["type"], declaration["path"]
|
|
207
|
+
entry = info.entries[root + "/manifest.json"]
|
|
208
|
+
if entry.file_size > bp.DEFAULT_LIMITS.max_manifest_size:
|
|
209
|
+
reject("MANIFEST", "Vanilla pack manifest too large")
|
|
210
|
+
item, ids, dependencies = pack_manifest(archive.read(entry), kind)
|
|
211
|
+
if known_ids.intersection(ids):
|
|
212
|
+
reject("UUID_COLLISION", "Pack/module UUID conflicts with another pack or an existing registration")
|
|
213
|
+
known_ids.update(ids)
|
|
214
|
+
files = {name[len(root) + 1:]: digest for name, digest in info.expected_digests.items() if name.startswith(root + "/")}
|
|
215
|
+
if any(name.casefold() == "integrity.json" for name in files):
|
|
216
|
+
reject("RESERVED_FILE", "Top-level integrity.json inside a vanilla pack is reserved by the snapshot validator")
|
|
217
|
+
digest = hashlib.sha256(bp._canonical_json(files)).hexdigest()
|
|
218
|
+
packs.append({**item, "owner": owner, "kind": kind, "source": str(package),
|
|
219
|
+
"archive_sha256": info.result["sha256"], "root": root, "files": files,
|
|
220
|
+
"target": f"{kind}_packs/bedrockplus_{digest}", "dependencies": dependencies, "identities": ids})
|
|
221
|
+
by_id = {item["pack_id"]: item for item in packs}
|
|
222
|
+
by_identity = {key: item for item in packs for key in item["identities"]}
|
|
223
|
+
# BDS scans installed directories as well as registrations. Refuse duplicate
|
|
224
|
+
# UUIDs even when an existing copy is not currently registered. In-place
|
|
225
|
+
# upgrades need a later journalled directory-retirement implementation.
|
|
226
|
+
if packs:
|
|
227
|
+
server = world.parent.parent if world.parent.name.casefold() == "worlds" else None
|
|
228
|
+
scan_roots = [world] + ([server] if server else [])
|
|
229
|
+
inspected = 0
|
|
230
|
+
for root in scan_roots:
|
|
231
|
+
for kind in KINDS:
|
|
232
|
+
folder = safe_path(root / f"{kind}_packs")
|
|
233
|
+
if not folder.exists():
|
|
234
|
+
continue
|
|
235
|
+
for child in folder.iterdir():
|
|
236
|
+
inspected += 1
|
|
237
|
+
if inspected > 4096:
|
|
238
|
+
reject("LIMIT", "Too many installed pack folders")
|
|
239
|
+
safe_path(child)
|
|
240
|
+
if not child.is_dir():
|
|
241
|
+
continue
|
|
242
|
+
raw = read_optional(child / "manifest.json")
|
|
243
|
+
if raw is None:
|
|
244
|
+
continue
|
|
245
|
+
installed = bp._decode_json(raw, "installed vanilla manifest")
|
|
246
|
+
header = installed.get("header", {}) if isinstance(installed, dict) else {}
|
|
247
|
+
installed_ids = [header.get("uuid")] if isinstance(header, dict) else []
|
|
248
|
+
modules = installed.get("modules", []) if isinstance(installed, dict) else []
|
|
249
|
+
if isinstance(modules, list):
|
|
250
|
+
installed_ids.extend(module.get("uuid") for module in modules if isinstance(module, dict))
|
|
251
|
+
for installed_id in installed_ids:
|
|
252
|
+
if isinstance(installed_id, str) and installed_id.lower() in by_identity:
|
|
253
|
+
item = by_identity[installed_id.lower()]
|
|
254
|
+
if child != world / item["target"]:
|
|
255
|
+
reject("INSTALLED_COLLISION", "Pack/module UUID already exists in another installed directory; automatic replacement is not supported")
|
|
256
|
+
active, visited = set(), set()
|
|
257
|
+
def visit(item):
|
|
258
|
+
key = item["pack_id"]
|
|
259
|
+
if key in active:
|
|
260
|
+
reject("DEPENDENCY_CYCLE", "Bundled pack dependency cycle")
|
|
261
|
+
if key in visited:
|
|
262
|
+
return
|
|
263
|
+
active.add(key)
|
|
264
|
+
for dependency in item["dependencies"]:
|
|
265
|
+
required = by_id.get(dependency["uuid"])
|
|
266
|
+
if required is None or required["version"] != dependency["version"]:
|
|
267
|
+
reject("DEPENDENCY", "Pack dependency/version missing from selected bundles")
|
|
268
|
+
visit(required)
|
|
269
|
+
active.remove(key)
|
|
270
|
+
visited.add(key)
|
|
271
|
+
for item in packs:
|
|
272
|
+
visit(item)
|
|
273
|
+
after = {}
|
|
274
|
+
for kind, name in zip(KINDS, REGISTRATIONS):
|
|
275
|
+
additions = [{"pack_id": p["pack_id"], "version": p["version"]} for p in packs if p["kind"] == kind]
|
|
276
|
+
after[name] = bp._canonical_json(base_entries[name] + additions) if additions else baseline[name]
|
|
277
|
+
after[STATE] = bp._canonical_json({"format": 1, "baseline": {n: encode(baseline[n]) for n in REGISTRATIONS},
|
|
278
|
+
"expected": {n: encode(after[n]) for n in REGISTRATIONS},
|
|
279
|
+
"owned": [{k: p[k] for k in ("owner", "kind", "pack_id", "version", "target")} for p in packs]})
|
|
280
|
+
if binding is None:
|
|
281
|
+
after[BINDING] = None
|
|
282
|
+
else:
|
|
283
|
+
if (not isinstance(binding, dict) or set(binding) != {"set_id", "plugins"} or
|
|
284
|
+
not isinstance(binding.get("set_id"), str) or not bp.SHA256_RE.fullmatch(binding["set_id"]) or
|
|
285
|
+
binding.get("plugins") != package_plugins):
|
|
286
|
+
reject("BINDING", "Invalid prepared plugin-set identity")
|
|
287
|
+
after[BINDING] = bp._canonical_json({
|
|
288
|
+
"format": 1,
|
|
289
|
+
"set_id": binding["set_id"],
|
|
290
|
+
"plugins": binding["plugins"],
|
|
291
|
+
"packs": [{k: p[k] for k in ("owner", "archive_sha256", "kind", "root", "pack_id", "version", "target")} for p in packs],
|
|
292
|
+
"metadata_sha256": {name: None if after[name] is None else hashlib.sha256(after[name]).hexdigest()
|
|
293
|
+
for name in (*REGISTRATIONS, STATE)},
|
|
294
|
+
})
|
|
295
|
+
# Bound the encoded snapshots too: otherwise a successful write could leave
|
|
296
|
+
# a journal too large for this same tool to read during recovery.
|
|
297
|
+
if any(raw is not None and len(raw) > MAX_METADATA for raw in after.values()):
|
|
298
|
+
reject("LIMIT", "Encoded ownership metadata exceeds the recovery limit")
|
|
299
|
+
transaction_bytes(before, after)
|
|
300
|
+
return world, packs, before, after
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def transaction_bytes(before, after):
|
|
304
|
+
raw = bp._canonical_json({"format": 1, "before": {n: encode(before[n]) for n in TARGETS},
|
|
305
|
+
"after": {n: encode(after[n]) for n in TARGETS}})
|
|
306
|
+
if len(raw) > MAX_METADATA:
|
|
307
|
+
reject("LIMIT", "Transaction journal exceeds the recovery limit")
|
|
308
|
+
return raw
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@contextmanager
|
|
312
|
+
def offline_world(world, operation="exclusive-access"):
|
|
313
|
+
# The launcher holds the same lease for its entire run. Directly launched
|
|
314
|
+
# vanilla BDS does not participate, so --offline is also required.
|
|
315
|
+
world = safe_path(Path(world))
|
|
316
|
+
server = world.parent.parent
|
|
317
|
+
if world.parent.name.casefold() != "worlds" or not world.is_dir() or read_optional(server / "server.properties") is None:
|
|
318
|
+
reject("WORLD", "Expected an existing <server>/worlds/<world> with server.properties")
|
|
319
|
+
with bp._cache_install_lease(server):
|
|
320
|
+
now = datetime.now(timezone.utc)
|
|
321
|
+
directory = safe_path(world / ".bedrockplus-packs/logs" / now.strftime("%Y-%m-%d"))
|
|
322
|
+
bp._ensure_private_directory_tree(directory)
|
|
323
|
+
log_path = directory / f"{now.strftime('%H-%M-%S')}-{uuid.uuid4().hex}.jsonl"
|
|
324
|
+
with log_path.open("x", encoding="utf-8") as log:
|
|
325
|
+
def emit(event, **fields):
|
|
326
|
+
log.write(json.dumps({"timestamp": datetime.now(timezone.utc).isoformat(), "event": event,
|
|
327
|
+
"operation": operation, "world": str(world), **fields}) + "\n")
|
|
328
|
+
log.flush()
|
|
329
|
+
emit("BPP-WORLD-PACK-BEGIN")
|
|
330
|
+
try:
|
|
331
|
+
yield world
|
|
332
|
+
except Exception as error:
|
|
333
|
+
emit("BPP-WORLD-PACK-FAILED", code=getattr(error, "code", "WORLD_PACK_IO"), message=str(error))
|
|
334
|
+
raise
|
|
335
|
+
else:
|
|
336
|
+
emit("BPP-WORLD-PACK-COMPLETED")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def replace_bytes(path, raw):
|
|
340
|
+
safe_path(path)
|
|
341
|
+
read_optional(path) # reject links, hardlinks, special files before replacing
|
|
342
|
+
if raw is None:
|
|
343
|
+
if path.exists():
|
|
344
|
+
path.unlink() # exact transaction-owned metadata path, never a tree
|
|
345
|
+
return
|
|
346
|
+
bp._ensure_private_directory_tree(path.parent)
|
|
347
|
+
fd, temporary = tempfile.mkstemp(prefix=".bpp-write-", dir=path.parent)
|
|
348
|
+
with os.fdopen(fd, "wb") as stream:
|
|
349
|
+
stream.write(raw)
|
|
350
|
+
stream.flush()
|
|
351
|
+
os.fsync(stream.fileno())
|
|
352
|
+
safe_path(path)
|
|
353
|
+
os.replace(temporary, path)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def stage_pack(world, item):
|
|
357
|
+
target = safe_path(world / item["target"])
|
|
358
|
+
if not target.exists():
|
|
359
|
+
stage_root = bp._ensure_private_directory_tree(world / ".bedrockplus-packs/staging")
|
|
360
|
+
stage = Path(tempfile.mkdtemp(prefix="pack-", dir=stage_root))
|
|
361
|
+
with bp._open_package_archive(Path(item["source"]), bp.DEFAULT_LIMITS) as (archive, info):
|
|
362
|
+
if info.result["sha256"] != item["archive_sha256"]:
|
|
363
|
+
reject("CHANGED", "Package changed after planning")
|
|
364
|
+
for relative, digest in item["files"].items():
|
|
365
|
+
bp._extract_verified_entry(archive, info.entries[item["root"] + "/" + relative], stage / relative, digest)
|
|
366
|
+
bp._ensure_private_directory_tree(target.parent)
|
|
367
|
+
stage.rename(target) # never replace an existing immutable pack directory
|
|
368
|
+
if any(child.name.casefold() == "integrity.json" for child in target.iterdir()):
|
|
369
|
+
reject("TAMPERED", "Managed vanilla pack contains a reserved integrity.json entry")
|
|
370
|
+
files = bp._scan_source(target, bp.DEFAULT_LIMITS)
|
|
371
|
+
if set(files) != set(item["files"]):
|
|
372
|
+
reject("TAMPERED", "Managed pack file set differs")
|
|
373
|
+
for name, source in files.items():
|
|
374
|
+
if source.path.stat().st_nlink != 1 or bp._hash_source_path(source, bp.DEFAULT_LIMITS.max_file_size) != item["files"][name]:
|
|
375
|
+
reject("TAMPERED", "Managed pack content differs")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def apply(world, packages, *, offline=False, after_replace=None, binding=None):
|
|
379
|
+
if not offline:
|
|
380
|
+
reject("OFFLINE_REQUIRED", "Explicit offline acknowledgement required")
|
|
381
|
+
with offline_world(world, "apply") as world:
|
|
382
|
+
world, packs, before, after = plan(world, packages, binding=binding)
|
|
383
|
+
for item in packs:
|
|
384
|
+
stage_pack(world, item)
|
|
385
|
+
if any(read_optional(world / name) != before[name] for name in TARGETS):
|
|
386
|
+
reject("CONFLICT", "World metadata changed while staging")
|
|
387
|
+
if before == after:
|
|
388
|
+
return {"status": "unchanged", "packs": len(packs)}
|
|
389
|
+
replace_bytes(world / JOURNAL, transaction_bytes(before, after))
|
|
390
|
+
for name in TARGETS:
|
|
391
|
+
replace_bytes(world / name, after[name])
|
|
392
|
+
if after_replace:
|
|
393
|
+
after_replace(name) # deterministic interruption injection in tests
|
|
394
|
+
if any(read_optional(world / name) != after[name] for name in TARGETS):
|
|
395
|
+
reject("CONFLICT", "World metadata changed during commit; recover before launch")
|
|
396
|
+
(world / JOURNAL).unlink()
|
|
397
|
+
return {"status": "applied", "packs": len(packs), "retained_pack_directories": True}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def selected_world(server, *, allow_missing=False):
|
|
401
|
+
server = safe_path(Path(server))
|
|
402
|
+
raw = read_optional(server / "server.properties")
|
|
403
|
+
if raw is None or len(raw) > 1024 * 1024:
|
|
404
|
+
reject("SERVER_PROPERTIES", "A bounded server.properties file is required")
|
|
405
|
+
try:
|
|
406
|
+
text = raw.decode("utf-8-sig")
|
|
407
|
+
except UnicodeDecodeError:
|
|
408
|
+
reject("SERVER_PROPERTIES", "server.properties must be UTF-8")
|
|
409
|
+
names = []
|
|
410
|
+
for line in text.splitlines():
|
|
411
|
+
stripped = line.strip()
|
|
412
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
413
|
+
continue
|
|
414
|
+
key, value = stripped.split("=", 1)
|
|
415
|
+
if key.strip() == "level-name":
|
|
416
|
+
names.append(value.strip())
|
|
417
|
+
invalid_name = (len(names) != 1 or not names[0] or len(names[0]) > 255 or
|
|
418
|
+
names[0] in (".", "..") or any(c in names[0] for c in '/\\:<>"|?*\0') or
|
|
419
|
+
names[0].split(".", 1)[0].casefold() in bp.WINDOWS_RESERVED_NAMES)
|
|
420
|
+
if invalid_name:
|
|
421
|
+
reject("LEVEL_NAME", "server.properties must contain one safe level-name")
|
|
422
|
+
world = safe_path(server / "worlds" / names[0])
|
|
423
|
+
if world.parent != safe_path(server / "worlds"):
|
|
424
|
+
reject("WORLD", "The configured world path is invalid")
|
|
425
|
+
if not world.is_dir():
|
|
426
|
+
if allow_missing and not os.path.lexists(world):
|
|
427
|
+
return None
|
|
428
|
+
reject("WORLD", "The configured world directory does not exist")
|
|
429
|
+
return world
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def bind_cache(server, cache, *, offline=False):
|
|
433
|
+
cache = safe_path(Path(cache))
|
|
434
|
+
selection_raw = read_optional(cache / "selection.json")
|
|
435
|
+
if selection_raw is None or len(selection_raw) > 128 * 1024:
|
|
436
|
+
reject("BINDING", "Prepared plugin set has no bounded selection.json")
|
|
437
|
+
selection = bp._decode_json(selection_raw, "plugin selection")
|
|
438
|
+
if (not isinstance(selection, dict) or set(selection) != {"format_version", "plugins"} or
|
|
439
|
+
selection["format_version"] != 2 or not isinstance(selection["plugins"], list) or
|
|
440
|
+
len(selection["plugins"]) > 128):
|
|
441
|
+
reject("BINDING", "Prepared plugin selection has an invalid schema")
|
|
442
|
+
set_id = hashlib.sha256(selection_raw).hexdigest()
|
|
443
|
+
if cache.name != set_id:
|
|
444
|
+
reject("BINDING", "Prepared plugin set path does not match selection identity")
|
|
445
|
+
plugins, packages, seen = [], [], set()
|
|
446
|
+
has_packs = False
|
|
447
|
+
for item in selection["plugins"]:
|
|
448
|
+
if (not isinstance(item, dict) or set(item) != {"id", "version", "sha256", "package"} or
|
|
449
|
+
not isinstance(item["id"], str) or not bp.PLUGIN_ID_RE.fullmatch(item["id"]) or
|
|
450
|
+
bp._plugin_id_uses_reserved_windows_name(item["id"]) or item["id"] in seen or
|
|
451
|
+
not isinstance(item["version"], str) or not bp.SEMVER_RE.fullmatch(item["version"]) or
|
|
452
|
+
not isinstance(item["sha256"], str) or not bp.SHA256_RE.fullmatch(item["sha256"]) or
|
|
453
|
+
not isinstance(item["package"], str) or not item["package"] or
|
|
454
|
+
Path(item["package"]).name != item["package"]):
|
|
455
|
+
reject("BINDING", "Prepared plugin selection contains an invalid entry")
|
|
456
|
+
seen.add(item["id"])
|
|
457
|
+
package = safe_path(cache / "packages" / f"{item['id']}.bppack")
|
|
458
|
+
try:
|
|
459
|
+
validated = bp.validate_package(package)
|
|
460
|
+
except bp.PackValidationError as error:
|
|
461
|
+
reject("BINDING", f"Prepared package snapshot is invalid: {error}")
|
|
462
|
+
if any(validated[key] != item[key] for key in ("id", "version", "sha256")):
|
|
463
|
+
reject("BINDING", "Prepared package snapshot differs from plugin selection")
|
|
464
|
+
with bp._open_package_archive(package, bp.DEFAULT_LIMITS) as (_, inspection):
|
|
465
|
+
has_packs = has_packs or bool(inspection.manifest.get("packs"))
|
|
466
|
+
plugins.append({key: item[key] for key in ("id", "version", "sha256")})
|
|
467
|
+
packages.append(package)
|
|
468
|
+
world = selected_world(server, allow_missing=not has_packs)
|
|
469
|
+
if world is None:
|
|
470
|
+
return {"status": "no_bundled_packs", "packs": 0, "set_id": set_id,
|
|
471
|
+
"world": None, "binding_path": None}
|
|
472
|
+
result = apply(world, packages if has_packs else [], offline=offline,
|
|
473
|
+
binding={"set_id": set_id, "plugins": plugins} if has_packs else None)
|
|
474
|
+
return {**result, "set_id": set_id, "world": str(world),
|
|
475
|
+
"binding_path": str(world / BINDING) if has_packs else None}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def recover(world, *, offline=False):
|
|
479
|
+
if not offline:
|
|
480
|
+
reject("OFFLINE_REQUIRED", "Explicit offline acknowledgement required")
|
|
481
|
+
with offline_world(world, "recover") as world:
|
|
482
|
+
raw = read_optional(world / JOURNAL)
|
|
483
|
+
if raw is None:
|
|
484
|
+
return {"status": "no_pending_transaction"}
|
|
485
|
+
journal = bp._decode_json(raw, "pack transaction journal")
|
|
486
|
+
if not isinstance(journal, dict) or set(journal) != {"format", "before", "after"} or journal["format"] != 1:
|
|
487
|
+
reject("JOURNAL", "Invalid transaction journal")
|
|
488
|
+
if any(not isinstance(journal[k], dict) or set(journal[k]) != set(TARGETS) for k in ("before", "after")):
|
|
489
|
+
reject("JOURNAL", "Invalid transaction targets")
|
|
490
|
+
before = {n: decode(journal["before"][n]) for n in TARGETS}
|
|
491
|
+
after = {n: decode(journal["after"][n]) for n in TARGETS}
|
|
492
|
+
for name in TARGETS:
|
|
493
|
+
if read_optional(world / name) not in (before[name], after[name]):
|
|
494
|
+
reject("CONFLICT", "External metadata edit prevents automatic recovery")
|
|
495
|
+
for name in TARGETS:
|
|
496
|
+
replace_bytes(world / name, before[name])
|
|
497
|
+
(world / JOURNAL).unlink()
|
|
498
|
+
return {"status": "rolled_back", "retained_pack_directories": True}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def main():
|
|
502
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
503
|
+
parser.add_argument("operation", choices=("plan", "apply", "recover", "bind"))
|
|
504
|
+
parser.add_argument("world", type=Path)
|
|
505
|
+
parser.add_argument("packages", type=Path, nargs="*")
|
|
506
|
+
parser.add_argument("--offline", action="store_true", help="I have stopped BDS and will not start it during this operation")
|
|
507
|
+
args = parser.parse_args()
|
|
508
|
+
try:
|
|
509
|
+
if args.operation == "plan":
|
|
510
|
+
_, packs, _, _ = plan(args.world, args.packages)
|
|
511
|
+
result = {"status": "planned", "packs": [{k: p[k] for k in ("owner", "kind", "pack_id", "version", "target")} for p in packs]}
|
|
512
|
+
elif args.operation == "apply":
|
|
513
|
+
result = apply(args.world, args.packages, offline=args.offline)
|
|
514
|
+
elif args.operation == "recover":
|
|
515
|
+
if args.packages:
|
|
516
|
+
parser.error("recover does not accept packages")
|
|
517
|
+
result = recover(args.world, offline=args.offline)
|
|
518
|
+
else:
|
|
519
|
+
if len(args.packages) != 1:
|
|
520
|
+
parser.error("bind requires exactly one prepared plugin-set cache")
|
|
521
|
+
result = bind_cache(args.world, args.packages[0], offline=args.offline)
|
|
522
|
+
print(json.dumps(result))
|
|
523
|
+
return 0
|
|
524
|
+
except (bp.PackValidationError, OSError) as error:
|
|
525
|
+
print(json.dumps({"status": "error", "code": getattr(error, "code", "WORLD_PACK_IO"), "message": str(error)}), file=sys.stderr)
|
|
526
|
+
return 1
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
if __name__ == "__main__":
|
|
530
|
+
raise SystemExit(main())
|
bedrockplus/api.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Protocols implemented by the BedrockPlus embedded plugin host."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
4
|
+
from typing import Protocol, TypeAlias
|
|
5
|
+
|
|
6
|
+
from .commands import CommandCallback, CommandOverload
|
|
7
|
+
from .events import Event, EventName, EventPriority
|
|
8
|
+
|
|
9
|
+
TaskCallback: TypeAlias = Callable[[], None]
|
|
10
|
+
EventCallback: TypeAlias = Callable[[Event], None]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PluginHost(Protocol):
|
|
14
|
+
"""The object supplied to ``Plugin.on_load``."""
|
|
15
|
+
|
|
16
|
+
def log(
|
|
17
|
+
self,
|
|
18
|
+
level: str,
|
|
19
|
+
event_code: str,
|
|
20
|
+
message: str,
|
|
21
|
+
fields: Mapping[str, str] | None = None,
|
|
22
|
+
) -> None: ...
|
|
23
|
+
|
|
24
|
+
def data_directory(self) -> str: ...
|
|
25
|
+
|
|
26
|
+
def scheduler_available(self) -> bool: ...
|
|
27
|
+
|
|
28
|
+
def schedule(
|
|
29
|
+
self,
|
|
30
|
+
callback: TaskCallback,
|
|
31
|
+
delay_ms: int = 0,
|
|
32
|
+
interval_ms: int = 0,
|
|
33
|
+
runs: int = 1,
|
|
34
|
+
) -> int: ...
|
|
35
|
+
|
|
36
|
+
def cancel_task(self, task_id: int) -> bool: ...
|
|
37
|
+
|
|
38
|
+
def events_available(self) -> bool: ...
|
|
39
|
+
|
|
40
|
+
def subscribe_event(
|
|
41
|
+
self,
|
|
42
|
+
name: EventName,
|
|
43
|
+
callback: EventCallback,
|
|
44
|
+
priority: EventPriority | int = EventPriority.NORMAL,
|
|
45
|
+
) -> int: ...
|
|
46
|
+
|
|
47
|
+
def unsubscribe_event(self, subscription_id: int) -> bool: ...
|
|
48
|
+
|
|
49
|
+
def commands_available(self) -> bool: ...
|
|
50
|
+
|
|
51
|
+
def register_command(
|
|
52
|
+
self,
|
|
53
|
+
name: str,
|
|
54
|
+
callback: CommandCallback,
|
|
55
|
+
description: str = "",
|
|
56
|
+
permission: str = "",
|
|
57
|
+
aliases: Sequence[str] | None = None,
|
|
58
|
+
overloads: Sequence[CommandOverload] | None = None,
|
|
59
|
+
) -> None: ...
|
|
60
|
+
|
|
61
|
+
def unregister_command(self, name: str) -> bool: ...
|
|
62
|
+
|
|
63
|
+
def dispatch_server_command(self, command: str) -> bool: ...
|
|
64
|
+
|
|
65
|
+
def hide_vanilla_command(self, name: str) -> bool: ...
|
|
66
|
+
|
|
67
|
+
def restore_vanilla_command(self, name: str) -> bool: ...
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Plugin(Protocol):
|
|
71
|
+
"""Lifecycle surface expected from a Python plugin entry class."""
|
|
72
|
+
|
|
73
|
+
def on_load(self, host: PluginHost) -> None: ...
|
|
74
|
+
|
|
75
|
+
def on_enable(self) -> None: ...
|
|
76
|
+
|
|
77
|
+
def on_server_ready(self) -> None: ...
|
|
78
|
+
|
|
79
|
+
def on_server_stopping(self) -> None: ...
|
|
80
|
+
|
|
81
|
+
def on_disable(self) -> None: ...
|
|
82
|
+
|
|
83
|
+
def on_unload(self) -> None: ...
|