mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Prepare: require only this platform's libraries; accept republished 26.3 manifests
lab.prepare imported every client library in the version JSON, ignoring its OS rules, so a Linux install failed on the macOS-only java-objc-bridge the official launcher never downloads there. The rule evaluation moves from runtime.py into lab.py and one selector, client_library_artifacts, now serves preparation, the runtime and the model baking harness: OS rules as the launcher applies them, and the natives for this architecture (classifier suffixes, plain natives being the x64 build only when an ARM variant is listed). Required libraries are still checksummed and a missing one still errors. Mojang republished 26.3.json twice (702fe591, then 11f1aefe, the current one) with only assetIndex changed: non-English translations, same jars and libraries. The lock pins the current file and accepts both earlier ones; preparation takes the launcher's copy so it matches the asset index installed with it, and an unknown republish errors clearly. Fixes #41 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
383afd81eb
commit
6d7f11feb2
6 files changed
+211
-56
No files matched your search
+6
-1
@@ -1,7 +1,12 @@
|
||||
{
|
||||
"minecraft": {
|
||||
"version": "26.3",
|
||||
"manifest_sha1": "4fe1aa1ef8da1cb95c5bad1fb98890ca56dd8ca3",
|
||||
"manifest_sha1": "11f1aefe473eefba0af62e3286c33b692807b2eb",
|
||||
"accepted_manifest_sha1": [
|
||||
"4fe1aa1ef8da1cb95c5bad1fb98890ca56dd8ca3",
|
||||
"702fe59163c6ee6578607daa85811d9bc9c7cc40"
|
||||
],
|
||||
"accepted_manifest_note": "Earlier 26.3 version JSONs Mojang republished: only assetIndex differs (non-English translations), jars and libraries are identical. A launcher may still hold any of them.",
|
||||
"client_sha1": "e877b6a07acd633fb3bb475002175cec036e7b87",
|
||||
"server_sha1": "33680f5f2ac32864d6d7cf5e56a705fdb3e05f4c",
|
||||
"java_major": 25
|
||||
|
||||
@@ -71,6 +71,7 @@ class ArtifactTests(unittest.TestCase):
|
||||
version.mkdir(parents=True)
|
||||
(version / "26.3.json").write_text(json.dumps(manifest))
|
||||
(version / "26.3.jar").write_bytes(client)
|
||||
lock["minecraft"]["manifest_sha1"] = lab.digest(version / "26.3.json", "sha1")
|
||||
loaded, jar = lab.import_client(lock, cache, launcher)
|
||||
self.assertEqual((loaded["id"], jar.read_bytes()), ("26.3", client))
|
||||
with self.assertRaisesRegex(FileNotFoundError, "server jar yourself"):
|
||||
@@ -91,6 +92,29 @@ class ArtifactTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "pinned 26.3 release"):
|
||||
lab.import_manifest(lock, Path(temp) / "cache", Path(temp))
|
||||
|
||||
def test_republished_launcher_manifests_are_accepted_and_unknown_ones_rejected(self):
|
||||
manifest = {"id": "26.3", "javaVersion": {"majorVersion": 25}, "assetIndex": {"sha1": "a"},
|
||||
"downloads": {"client": {"sha1": "1" * 40}, "server": {"sha1": "2" * 40}}}
|
||||
older = json.dumps({**manifest, "assetIndex": {"sha1": "b"}})
|
||||
lock = {"minecraft": {"version": "26.3", "java_major": 25, "client_sha1": "1" * 40, "server_sha1": "2" * 40,
|
||||
"manifest_sha1": hashlib.sha1(json.dumps(manifest).encode()).hexdigest(),
|
||||
"accepted_manifest_sha1": [hashlib.sha1(older.encode()).hexdigest()]}}
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
launcher, cache = Path(temp) / "minecraft", Path(temp) / "cache"
|
||||
source = launcher / "versions" / "26.3" / "26.3.json"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text(older)
|
||||
self.assertEqual(lab.import_manifest(lock, cache, launcher)["assetIndex"], {"sha1": "b"})
|
||||
# The launcher's newer copy replaces the cached one, keeping it in step with its asset index.
|
||||
source.write_text(json.dumps(manifest))
|
||||
self.assertEqual(lab.import_manifest(lock, cache, launcher)["assetIndex"], {"sha1": "a"})
|
||||
source.unlink()
|
||||
self.assertEqual(lab.import_manifest(lock, cache, launcher)["assetIndex"], {"sha1": "a"})
|
||||
source.write_text(json.dumps({**manifest, "assetIndex": {"sha1": "c"}}))
|
||||
with self.assertRaisesRegex(ValueError, "does not know yet"):
|
||||
lab.import_manifest(lock, cache, launcher)
|
||||
|
||||
|
||||
def test_sourcefile_metadata_covers_inner_classes(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "game.jar"
|
||||
@@ -177,5 +201,89 @@ class PublicationTests(unittest.TestCase):
|
||||
self.assertIn(".local/artifact.txt", check_public_tree.paths_in_repository(history=True))
|
||||
|
||||
|
||||
def library(name, os_name=None):
|
||||
entry = {"name": name, "downloads": {"artifact": {"path": name.replace(":", "/") + ".jar",
|
||||
"sha1": hashlib.sha1(name.encode()).hexdigest()}}}
|
||||
if os_name:
|
||||
entry["rules"] = [{"action": "allow", "os": {"name": os_name}}]
|
||||
return entry
|
||||
|
||||
|
||||
# The shapes of 26.3's libraries: OS-only rules, ARM and x64 natives told apart by classifier.
|
||||
PLATFORM_MANIFEST = {"libraries": [
|
||||
library("com.mojang:brigadier:1.3.10"),
|
||||
library("ca.weblite:java-objc-bridge:1.1", "osx"),
|
||||
library("org.lwjgl:lwjgl:3.4.3"),
|
||||
library("org.lwjgl:lwjgl:3.4.3:natives-linux", "linux"),
|
||||
library("org.lwjgl:lwjgl:3.4.3:natives-macos", "osx"),
|
||||
library("org.lwjgl:lwjgl:3.4.3:natives-macos-arm64", "osx"),
|
||||
library("org.lwjgl:lwjgl:3.4.3:natives-windows", "windows"),
|
||||
library("org.lwjgl:lwjgl:3.4.3:natives-windows-arm64", "windows"),
|
||||
library("io.netty:netty-transport-native-epoll:4.2.16.Final:linux-aarch_64", "linux"),
|
||||
library("io.netty:netty-transport-native-epoll:4.2.16.Final:linux-x86_64", "linux"),
|
||||
library("io.netty:netty-transport-native-kqueue:4.2.16.Final:osx-aarch_64", "osx"),
|
||||
library("io.netty:netty-transport-native-kqueue:4.2.16.Final:osx-x86_64", "osx"),
|
||||
{"name": "org.example:no-artifact:1.0", "downloads": {}},
|
||||
]}
|
||||
|
||||
|
||||
class PlatformLibraryTests(unittest.TestCase):
|
||||
def selected(self, system, architecture):
|
||||
names = {entry["downloads"]["artifact"]["path"]: entry["name"]
|
||||
for entry in PLATFORM_MANIFEST["libraries"] if entry["downloads"]}
|
||||
return {names[artifact["path"]]
|
||||
for artifact in lab.client_library_artifacts(PLATFORM_MANIFEST, system, architecture)}
|
||||
|
||||
def test_each_platform_requires_only_what_the_official_launcher_installs(self):
|
||||
common = {"com.mojang:brigadier:1.3.10", "org.lwjgl:lwjgl:3.4.3"}
|
||||
expected = {
|
||||
("linux", "x86_64"): {"org.lwjgl:lwjgl:3.4.3:natives-linux",
|
||||
"io.netty:netty-transport-native-epoll:4.2.16.Final:linux-x86_64"},
|
||||
# No ARM build of natives-linux is listed, so the plain one is the only one.
|
||||
("linux", "aarch64"): {"org.lwjgl:lwjgl:3.4.3:natives-linux",
|
||||
"io.netty:netty-transport-native-epoll:4.2.16.Final:linux-aarch_64"},
|
||||
("osx", "arm64"): {"ca.weblite:java-objc-bridge:1.1", "org.lwjgl:lwjgl:3.4.3:natives-macos-arm64",
|
||||
"io.netty:netty-transport-native-kqueue:4.2.16.Final:osx-aarch_64"},
|
||||
("osx", "x86_64"): {"ca.weblite:java-objc-bridge:1.1", "org.lwjgl:lwjgl:3.4.3:natives-macos",
|
||||
"io.netty:netty-transport-native-kqueue:4.2.16.Final:osx-x86_64"},
|
||||
("windows", "AMD64"): {"org.lwjgl:lwjgl:3.4.3:natives-windows"},
|
||||
("windows", "ARM64"): {"org.lwjgl:lwjgl:3.4.3:natives-windows-arm64"},
|
||||
}
|
||||
for (system, architecture), specific in expected.items():
|
||||
with self.subTest(system=system, architecture=architecture):
|
||||
self.assertEqual(self.selected(system, architecture), common | specific)
|
||||
|
||||
def test_rules_follow_the_launcher_last_match_and_arch_aliases(self):
|
||||
rules = [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx", "arch": "aarch64"}}]
|
||||
self.assertTrue(lab.library_allowed({"rules": rules}, "osx", "x86_64"))
|
||||
self.assertFalse(lab.library_allowed({"rules": rules}, "osx", "arm64"))
|
||||
self.assertFalse(lab.library_allowed({"rules": rules}, "osx", "aarch64"))
|
||||
self.assertTrue(lab.library_allowed({"rules": rules}, "linux", "aarch64"))
|
||||
self.assertTrue(lab.library_allowed({"rules": [{"action": "allow", "os": {"arch": "x86_64"}}]}, "windows", "AMD64"))
|
||||
self.assertFalse(lab.library_allowed({"rules": [{"action": "allow", "features": {"is_demo_user": True}}]}, "linux"))
|
||||
|
||||
def test_prepare_on_linux_skips_macos_libraries_and_still_checks_required_ones(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
launcher, cache = Path(temp) / "minecraft", Path(temp) / "cache"
|
||||
artifacts = lab.client_library_artifacts(PLATFORM_MANIFEST, "linux", "x86_64")
|
||||
# A Linux install: the launcher never fetched the macOS bridge. Each fixture
|
||||
# jar's content is its Maven name, which is what its sha1 covers.
|
||||
for entry in PLATFORM_MANIFEST["libraries"]:
|
||||
artifact = entry["downloads"].get("artifact")
|
||||
if artifact in artifacts:
|
||||
path = launcher / "libraries" / artifact["path"]
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(entry["name"].encode())
|
||||
imported = [lab.import_library(a, cache, launcher) for a in artifacts]
|
||||
self.assertFalse(any("objc" in str(path) for path in imported))
|
||||
required = next(a for a in artifacts if "natives-linux" in a["path"])
|
||||
(launcher / "libraries" / required["path"]).write_bytes(b"tampered")
|
||||
with self.assertRaisesRegex(ValueError, "not the pinned file"):
|
||||
lab.import_library(required, Path(temp) / "other", launcher)
|
||||
(launcher / "libraries" / required["path"]).unlink()
|
||||
with self.assertRaisesRegex(FileNotFoundError, "official launcher"):
|
||||
lab.import_library(required, Path(temp) / "other", launcher)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+87
-9
@@ -8,6 +8,7 @@ from collections import Counter
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
@@ -99,23 +100,37 @@ def import_copy(source, destination, checksum, algorithm="sha1", hint=""):
|
||||
return destination
|
||||
|
||||
|
||||
def manifest_accepted(lock, checksum):
|
||||
"""Mojang republishes a version JSON for asset-index changes; every known one is accepted."""
|
||||
game = lock["minecraft"]
|
||||
return checksum == game["manifest_sha1"] or checksum in game.get("accepted_manifest_sha1", [])
|
||||
|
||||
|
||||
def import_manifest(lock, directory, minecraft=None):
|
||||
"""The pinned version JSON, from the launcher's versions/ directory."""
|
||||
"""The pinned version JSON, from the launcher's versions/ directory.
|
||||
|
||||
The launcher's copy wins over the cached one so the manifest matches the asset index the
|
||||
launcher installed alongside it."""
|
||||
game = lock["minecraft"]
|
||||
version = game["version"]
|
||||
destination = directory / "manifest.json"
|
||||
source = minecraft_dir(minecraft) / "versions" / version / f"{version}.json"
|
||||
if not (destination.exists() and digest(destination, "sha1") == game["manifest_sha1"]):
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(f"Missing {source}. {LAUNCHER_HINT.format(version=version)}")
|
||||
if source.is_file():
|
||||
manifest = json.loads(source.read_text(encoding="utf-8"))
|
||||
downloads = manifest.get("downloads", {})
|
||||
if (manifest.get("id") != version
|
||||
or downloads.get("client", {}).get("sha1") != game["client_sha1"]
|
||||
or downloads.get("server", {}).get("sha1") != game["server_sha1"]):
|
||||
raise ValueError(f"{source} does not describe the pinned {version} release")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, destination)
|
||||
checksum = digest(source, "sha1")
|
||||
if not manifest_accepted(lock, checksum):
|
||||
raise ValueError(f"{source} is a {version} version file Horizon does not know yet (sha1 {checksum}); "
|
||||
f"Mojang may have republished it. Update Horizon, or report it if this is the latest release.")
|
||||
if not (destination.exists() and digest(destination, "sha1") == checksum):
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, destination)
|
||||
elif not (destination.exists() and manifest_accepted(lock, digest(destination, "sha1"))):
|
||||
raise FileNotFoundError(f"Missing {source}. {LAUNCHER_HINT.format(version=version)}")
|
||||
manifest = json.loads(destination.read_text(encoding="utf-8"))
|
||||
if manifest["javaVersion"]["majorVersion"] != game["java_major"]:
|
||||
raise ValueError("Manifest Java version does not match lock file")
|
||||
@@ -146,6 +161,71 @@ def import_server(lock, directory, server_jar=None):
|
||||
return import_copy(Path(source).expanduser(), destination, checksum, hint=hint)
|
||||
|
||||
|
||||
def rule_system(system=None):
|
||||
"""The launcher's os.name for this machine: linux, osx or windows."""
|
||||
return system or {"Darwin": "osx", "Linux": "linux", "Windows": "windows"}[platform.system()]
|
||||
|
||||
|
||||
ARCHITECTURE_ALIASES = [{"x86_64", "amd64", "x64"}, {"arm64", "aarch64"}]
|
||||
|
||||
|
||||
def rule_architectures(architecture=None):
|
||||
"""platform.machine() and its aliases: Windows reports AMD64/ARM64, Linux aarch64, macOS arm64."""
|
||||
architecture = (architecture or platform.machine()).lower()
|
||||
return next((names for names in ARCHITECTURE_ALIASES if architecture in names), {architecture})
|
||||
|
||||
|
||||
def library_allowed(library, system=None, architecture=None):
|
||||
"""The official launcher's rules: the last matching rule decides, no rules means allowed."""
|
||||
system, architectures = rule_system(system), rule_architectures(architecture)
|
||||
rules = library.get("rules", [])
|
||||
allowed = not rules
|
||||
for rule in rules:
|
||||
operating_system = rule.get("os", {})
|
||||
if operating_system.get("name", system) != system:
|
||||
continue
|
||||
if "arch" in operating_system and not any(re.fullmatch(operating_system["arch"], name)
|
||||
for name in architectures):
|
||||
continue
|
||||
if "version" in operating_system and not re.search(operating_system["version"], platform.release()):
|
||||
continue
|
||||
if rule.get("features"):
|
||||
continue
|
||||
allowed = rule["action"] == "allow"
|
||||
return allowed
|
||||
|
||||
|
||||
ARM_CLASSIFIER = re.compile(r"(?:^|-)(?:arm64|aarch64|aarch_64)$")
|
||||
X86_CLASSIFIER = re.compile(r"(?:^|-)(?:x86_64|x64)$")
|
||||
|
||||
|
||||
def native_library_matches(library, architecture=None, names=None):
|
||||
"""Modern manifests list x64 and ARM natives with OS-only rules; keep this machine's.
|
||||
|
||||
A plain natives-<os> jar is the x64 build when the manifest's library names also hold
|
||||
an ARM variant of it (assumed when names is None), and the only build otherwise."""
|
||||
arm = "arm64" in rule_architectures(architecture)
|
||||
parts = library["name"].split(":")
|
||||
classifier = parts[3] if len(parts) > 3 else ""
|
||||
if ARM_CLASSIFIER.search(classifier):
|
||||
return arm
|
||||
if X86_CLASSIFIER.search(classifier):
|
||||
return not arm
|
||||
if arm and classifier.startswith("natives-"):
|
||||
return names is not None and not any(f"{library['name']}-{suffix}" in names
|
||||
for suffix in ("arm64", "aarch64"))
|
||||
return True
|
||||
|
||||
|
||||
def client_library_artifacts(manifest, system=None, architecture=None):
|
||||
"""The client libraries the official launcher installs on this OS and architecture."""
|
||||
names = {library["name"] for library in manifest["libraries"]}
|
||||
return [library["downloads"]["artifact"] for library in manifest["libraries"]
|
||||
if "artifact" in library.get("downloads", {})
|
||||
and library_allowed(library, system, architecture)
|
||||
and native_library_matches(library, architecture, names)]
|
||||
|
||||
|
||||
def import_library(artifact, destination_root, minecraft=None):
|
||||
"""One library jar from the launcher's libraries/ directory."""
|
||||
relative = PurePosixPath(artifact["path"])
|
||||
@@ -307,10 +387,8 @@ def prepare(minecraft=None, server_jar=None):
|
||||
print(f"Imported client {manifest['id']} from {minecraft_dir(minecraft)}", flush=True)
|
||||
jars = {"client": client, "server": import_server(lock, directory, server_jar)}
|
||||
jars["server"], server_libraries = unpack_server(jars["server"], directory, manifest["id"])
|
||||
client_artifacts = [library["downloads"]["artifact"] for library in manifest["libraries"]
|
||||
if "artifact" in library.get("downloads", {})]
|
||||
client_libraries = [import_library(artifact, directory / "libraries" / "client", minecraft)
|
||||
for artifact in client_artifacts]
|
||||
for artifact in client_library_artifacts(manifest)]
|
||||
vf = lock["vineflower"]
|
||||
download(vf["url"], LOCAL / "tools" / f"vineflower-{vf['version']}.jar", vf["sha256"], "sha256")
|
||||
for side, jar in jars.items():
|
||||
|
||||
+3
-37
@@ -21,6 +21,8 @@ except ImportError:
|
||||
import lab
|
||||
|
||||
ROOT = lab.ROOT
|
||||
library_allowed = lab.library_allowed
|
||||
native_library_matches = lab.native_library_matches
|
||||
|
||||
|
||||
def install_original_mod(source, mod_dir, filename=None):
|
||||
@@ -74,25 +76,6 @@ def fabric_libraries():
|
||||
return jars
|
||||
|
||||
|
||||
def library_allowed(library, system=None, architecture=None):
|
||||
system = system or {"Darwin": "osx", "Linux": "linux", "Windows": "windows"}[platform.system()]
|
||||
architecture = (architecture or platform.machine()).lower()
|
||||
rules = library.get("rules", [])
|
||||
allowed = not rules
|
||||
for rule in rules:
|
||||
operating_system = rule.get("os", {})
|
||||
if operating_system.get("name", system) != system:
|
||||
continue
|
||||
if "arch" in operating_system and not re.fullmatch(operating_system["arch"], architecture):
|
||||
continue
|
||||
if "version" in operating_system and not re.search(operating_system["version"], platform.release()):
|
||||
continue
|
||||
if rule.get("features"):
|
||||
continue
|
||||
allowed = rule["action"] == "allow"
|
||||
return allowed
|
||||
|
||||
|
||||
def conditional_client_jvm_options(manifest, system=None, architecture=None):
|
||||
"""Use official OS/architecture flags alongside our managed JVM/classpath options."""
|
||||
options = []
|
||||
@@ -107,18 +90,6 @@ def conditional_client_jvm_options(manifest, system=None, architecture=None):
|
||||
return options
|
||||
|
||||
|
||||
def native_library_matches(library, architecture=None):
|
||||
"""Modern manifests list both x64 and ARM natives with OS-only rules."""
|
||||
architecture = (architecture or platform.machine()).lower()
|
||||
name = library["name"]
|
||||
if ":natives-" not in name:
|
||||
return True
|
||||
arm = architecture in {"arm64", "aarch64"}
|
||||
if "-arm64" in name or "-aarch64" in name:
|
||||
return arm
|
||||
return not arm
|
||||
|
||||
|
||||
def prepare_client(assets=True):
|
||||
"""Classpath and natives from the user's own launcher install; nothing of the game is downloaded."""
|
||||
lock, directory = lab.config()
|
||||
@@ -126,12 +97,7 @@ def prepare_client(assets=True):
|
||||
jars = [client]
|
||||
natives = CACHE / "natives"
|
||||
natives.mkdir(parents=True, exist_ok=True)
|
||||
for library in manifest["libraries"]:
|
||||
if not library_allowed(library) or not native_library_matches(library):
|
||||
continue
|
||||
artifact = library.get("downloads", {}).get("artifact")
|
||||
if not artifact:
|
||||
continue
|
||||
for artifact in lab.client_library_artifacts(manifest):
|
||||
jar = lab.import_library(artifact, directory / "libraries/client")
|
||||
jars.append(jar)
|
||||
with zipfile.ZipFile(jar) as archive:
|
||||
|
||||
@@ -59,14 +59,12 @@ def client_libraries():
|
||||
_, directory=lab.config()
|
||||
manifest=json.loads((directory/'manifest.json').read_text())
|
||||
libraries=[]
|
||||
for library in manifest['libraries']:
|
||||
artifact=library.get('downloads',{}).get('artifact')
|
||||
if artifact:
|
||||
relative=Path(artifact['path'])
|
||||
if relative.is_absolute() or '..' in relative.parts: raise ValueError('Unsafe client library path')
|
||||
path=directory/'libraries/client'/relative
|
||||
if lab.digest(path,'sha1')!=artifact['sha1']: raise ValueError('Client dependency checksum changed: '+str(relative))
|
||||
libraries.append(path)
|
||||
for artifact in lab.client_library_artifacts(manifest):
|
||||
relative=Path(artifact['path'])
|
||||
if relative.is_absolute() or '..' in relative.parts: raise ValueError('Unsafe client library path')
|
||||
path=directory/'libraries/client'/relative
|
||||
if lab.digest(path,'sha1')!=artifact['sha1']: raise ValueError('Client dependency checksum changed: '+str(relative))
|
||||
libraries.append(path)
|
||||
return libraries
|
||||
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ def verify_artifacts():
|
||||
"""Authenticate against the lock and the bundled checksums, not generated reports."""
|
||||
lock, directory = lab.config()
|
||||
manifest_path = directory / "manifest.json"
|
||||
if lab.digest(manifest_path, "sha1") != lock["minecraft"]["manifest_sha1"]:
|
||||
if not lab.manifest_accepted(lock, lab.digest(manifest_path, "sha1")):
|
||||
raise ValueError("Manifest checksum changed; run tools/lab.py prepare")
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
if manifest["id"] != lock["minecraft"]["version"]:
|
||||
|
||||
Reference in new issue
Block a user