bedrockplus 0.1.0.dev1__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. bedrockplus/__init__.py +95 -0
  2. bedrockplus/__main__.py +4 -0
  3. bedrockplus/_assets/Initialize-BedrockPlusServer.ps1 +395 -0
  4. bedrockplus/_assets/README.md +85 -0
  5. bedrockplus/_assets/SHA256SUMS +20 -0
  6. bedrockplus/_assets/Start-BedrockPlus.ps1 +125 -0
  7. bedrockplus/_assets/THIRD_PARTY_NOTICES.md +26 -0
  8. bedrockplus/_assets/bedrockplus.exe +0 -0
  9. bedrockplus/_assets/bedrockplus_bootstrap.dll +0 -0
  10. bedrockplus/_assets/bedrockplus_plugin_host.dll +0 -0
  11. bedrockplus/_assets/bedrockplus_plugin_worker.dll +0 -0
  12. bedrockplus/_assets/bedrockplus_runtime.dll +0 -0
  13. bedrockplus/_assets/bin/bedrockplus.ps1 +192 -0
  14. bedrockplus/_assets/compatibility/bds/1.26.51.1/windows-x64.json +24 -0
  15. bedrockplus/_assets/compatibility/bds/supported-versions.json +14 -0
  16. bedrockplus/_assets/licenses/microsoft-detours-MIT.txt +23 -0
  17. bedrockplus/_assets/plugins/README.txt +11 -0
  18. bedrockplus/_assets/release-manifest.json +34 -0
  19. bedrockplus/_assets/server/README.txt +10 -0
  20. bedrockplus/_assets/tools/bppack.py +1865 -0
  21. bedrockplus/_assets/tools/init_python_plugin.py +140 -0
  22. bedrockplus/_assets/tools/pack_plugin.py +142 -0
  23. bedrockplus/_assets/tools/world_packs.py +530 -0
  24. bedrockplus/api.py +83 -0
  25. bedrockplus/cli.py +121 -0
  26. bedrockplus/commands.py +50 -0
  27. bedrockplus/events.py +138 -0
  28. bedrockplus/objects.py +413 -0
  29. bedrockplus/py.typed +1 -0
  30. bedrockplus-0.1.0.dev1.dist-info/METADATA +41 -0
  31. bedrockplus-0.1.0.dev1.dist-info/RECORD +36 -0
  32. bedrockplus-0.1.0.dev1.dist-info/WHEEL +4 -0
  33. bedrockplus-0.1.0.dev1.dist-info/entry_points.txt +2 -0
  34. bedrockplus-0.1.0.dev1.dist-info/licenses/THIRD_PARTY_NOTICES.md +26 -0
  35. bedrockplus-0.1.0.dev1.dist-info/licenses/microsoft-detours-MIT.txt +23 -0
  36. bedrockplus-0.1.0.dev1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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: ...