874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
290 lines
17 KiB
Python
290 lines
17 KiB
Python
import hashlib
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import struct
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
import zipfile
|
|
|
|
from tools import check_public_tree, lab
|
|
|
|
|
|
def minimal_class(source="Example.java"):
|
|
# A valid class-file structure with no fields/methods and one SourceFile attribute.
|
|
def utf8(value):
|
|
data = value.encode()
|
|
return b"\x01" + struct.pack(">H", len(data)) + data
|
|
return (b"\xca\xfe\xba\xbe" + struct.pack(">HHH", 0, 69, 5)
|
|
+ utf8("SourceFile") + utf8(source) + utf8("Example") + b"\x07\x00\x03"
|
|
+ struct.pack(">HHHHHHHHIH", 1, 4, 0, 0, 0, 0, 1, 1, 2, 2))
|
|
|
|
|
|
class ArtifactTests(unittest.TestCase):
|
|
def test_download_rejects_corruption_without_overwriting_existing_file(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
destination = Path(temp) / "game.jar"
|
|
destination.write_bytes(b"existing")
|
|
with mock.patch("urllib.request.urlopen", return_value=io.BytesIO(b"corrupt")):
|
|
with self.assertRaisesRegex(ValueError, "Checksum mismatch"):
|
|
lab.download("https://example.invalid/game.jar", destination, "0" * 40)
|
|
self.assertEqual(destination.read_bytes(), b"existing")
|
|
self.assertEqual([p.name for p in Path(temp).iterdir()], ["game.jar"])
|
|
|
|
def test_cached_download_is_verified(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
destination = Path(temp) / "game.jar"
|
|
destination.write_bytes(b"verified")
|
|
with mock.patch("urllib.request.urlopen") as request:
|
|
lab.download("https://example.invalid", destination, hashlib.sha1(b"verified").hexdigest())
|
|
request.assert_not_called()
|
|
|
|
def test_import_copies_only_the_pinned_file_from_the_users_install(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
source, destination = Path(temp) / "own.jar", Path(temp) / "cache" / "client.jar"
|
|
source.write_bytes(b"modified")
|
|
with self.assertRaisesRegex(ValueError, "not the pinned file"):
|
|
lab.import_copy(source, destination, hashlib.sha1(b"vanilla").hexdigest())
|
|
self.assertFalse(destination.exists())
|
|
source.write_bytes(b"vanilla")
|
|
lab.import_copy(source, destination, hashlib.sha1(b"vanilla").hexdigest())
|
|
self.assertEqual(destination.read_bytes(), b"vanilla")
|
|
with self.assertRaisesRegex(FileNotFoundError, "official launcher"):
|
|
lab.import_copy(Path(temp) / "absent.jar", Path(temp) / "other.jar", "0" * 40,
|
|
hint=lab.LAUNCHER_HINT.format(version="26.3"))
|
|
|
|
def test_game_files_come_from_the_launcher_install_without_network(self):
|
|
client, server = b"client", b"server"
|
|
lock = {"minecraft": {"version": "26.3", "manifest_sha1": "0" * 40, "java_major": 25,
|
|
"client_sha1": hashlib.sha1(client).hexdigest(),
|
|
"server_sha1": hashlib.sha1(server).hexdigest()}}
|
|
manifest = {"id": "26.3", "javaVersion": {"majorVersion": 25},
|
|
"downloads": {"client": {"sha1": lock["minecraft"]["client_sha1"]},
|
|
"server": {"sha1": lock["minecraft"]["server_sha1"]}}}
|
|
with tempfile.TemporaryDirectory() as temp, mock.patch("urllib.request.urlopen") as request, \
|
|
mock.patch.dict("os.environ", {}, clear=False) as environment:
|
|
environment.pop("HORIZON_SERVER_JAR", None)
|
|
launcher, cache = Path(temp) / "minecraft", Path(temp) / "cache"
|
|
version = launcher / "versions" / "26.3"
|
|
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"):
|
|
lab.import_server(lock, cache)
|
|
(Path(temp) / "server.jar").write_bytes(server)
|
|
self.assertEqual(lab.import_server(lock, cache, Path(temp) / "server.jar").read_bytes(), server)
|
|
self.assertEqual(lab.import_server(lock, cache).read_bytes(), server)
|
|
request.assert_not_called()
|
|
|
|
def test_launcher_manifest_for_another_release_is_rejected(self):
|
|
lock = {"minecraft": {"version": "26.3", "manifest_sha1": "0" * 40, "java_major": 25,
|
|
"client_sha1": "1" * 40, "server_sha1": "2" * 40}}
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
version = Path(temp) / "versions" / "26.3"
|
|
version.mkdir(parents=True)
|
|
(version / "26.3.json").write_text(json.dumps({"id": "26.3", "downloads": {
|
|
"client": {"sha1": "3" * 40}, "server": {"sha1": "2" * 40}}}))
|
|
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"
|
|
with zipfile.ZipFile(path, "w") as archive:
|
|
archive.writestr("demo/Example.class", minimal_class())
|
|
archive.writestr("demo/Example$Inner.class", minimal_class())
|
|
result = lab.inventory(path)
|
|
self.assertEqual(result["class_count"], 2)
|
|
self.assertEqual(result["expected_source_files"], ["demo/Example.java"])
|
|
|
|
def test_invalid_class_is_rejected(self):
|
|
for data in [b"oops", b"\xca\xfe\xba\xbe"]:
|
|
with self.assertRaises(ValueError):
|
|
lab.ClassReader(data).metadata()
|
|
|
|
def test_server_bundler_extracts_actual_game_and_checks_checksum(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
directory = Path(temp)
|
|
path = directory / "server.jar"
|
|
game = b"inner game jar"
|
|
library = b"dependency"
|
|
with zipfile.ZipFile(path, "w") as archive:
|
|
archive.writestr("META-INF/versions.list", f"{hashlib.sha256(game).hexdigest()}\t26.3\t26.3/game.jar\n")
|
|
archive.writestr("META-INF/versions/26.3/game.jar", game)
|
|
archive.writestr("META-INF/libraries.list", f"{hashlib.sha256(library).hexdigest()}\tdemo:lib:1\tdemo/lib.jar\n")
|
|
archive.writestr("META-INF/libraries/demo/lib.jar", library)
|
|
extracted, libraries = lab.unpack_server(path, directory, "26.3")
|
|
self.assertEqual(extracted.read_bytes(), game)
|
|
self.assertEqual(libraries[0].read_bytes(), library)
|
|
with self.assertRaises(ValueError):
|
|
lab.unpack_server(path, directory, "wrong-version")
|
|
|
|
def test_server_bundler_rejects_path_traversal(self):
|
|
for relative in ["../../escape.jar", "/escape.jar", "..\\escape.jar"]:
|
|
with zipfile.ZipFile(io.BytesIO(), "w") as archive:
|
|
archive.writestr("entries", f"{'0' * 64}\tdemo\t{relative}")
|
|
with self.assertRaisesRegex(ValueError, "Unsafe bundled path"):
|
|
list(lab.bundle_entries(archive, "entries"))
|
|
|
|
def test_coverage_reports_missing_files_and_failed_method_markers(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
directory = Path(temp)
|
|
lab.write_json(directory / "reports" / "client-inventory.json", {
|
|
"class_count": 3, "expected_source_files": ["demo/Example.java", "demo/Missing.java"]
|
|
})
|
|
sources = directory / "sources" / "client" / "demo"
|
|
sources.mkdir(parents=True)
|
|
(sources / "Example.java").write_text("// $VF: Couldn't be decompiled\n")
|
|
report = lab.coverage(directory, "client")
|
|
self.assertEqual(report["missing_source_files"], ["demo/Missing.java"])
|
|
self.assertEqual(report["files_with_decompiler_error_markers"], ["demo/Example.java"])
|
|
|
|
|
|
class PublicationTests(unittest.TestCase):
|
|
def test_private_paths_rejected(self):
|
|
for path in [".local/minecraft/source.java", "decompiled/Foo.txt", "game.JAR", "capture.jfr", "source/Foo.java"]:
|
|
self.assertTrue(check_public_tree.prohibited(path), path)
|
|
for path in ["tools/lab.py", "crates/horizon-core/src/lib.rs", "Cargo.lock", "docs/analysis.md",
|
|
"mods/horizon/src/main/java/dev/horizonlab/HorizonLab.java",
|
|
"mods/horizon-lab/src/main/java/dev/horizonlab/HorizonLab.java"]:
|
|
self.assertFalse(check_public_tree.prohibited(path), path)
|
|
self.assertTrue(check_public_tree.prohibited("mods/horizon/src/main/java/net/minecraft/Client.java"))
|
|
for path in ["tools/JfrSummary.java", "tests/java/JfrFixture.java"]:
|
|
self.assertFalse(check_public_tree.prohibited(path), path)
|
|
for path in ["tools/Minecraft.java", "tests/java/Client.java", ".local/tools/JfrSummary.java"]:
|
|
self.assertTrue(check_public_tree.prohibited(path), path)
|
|
|
|
def test_force_added_ignored_file_is_detected_in_index_and_history(self):
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
directory = Path(temp)
|
|
real_check_output = subprocess.check_output
|
|
def git(*args):
|
|
return real_check_output(["git", *args], cwd=directory, stderr=subprocess.DEVNULL)
|
|
git("init", "-b", "main")
|
|
(directory / ".gitignore").write_text("/.local/\n")
|
|
(directory / ".local").mkdir()
|
|
(directory / ".local" / "artifact.txt").write_text("private fixture")
|
|
git("add", ".gitignore")
|
|
git("add", "-f", ".local/artifact.txt")
|
|
with mock.patch("tools.check_public_tree.subprocess.check_output",
|
|
side_effect=lambda command: git(*command[1:])):
|
|
self.assertIn(".local/artifact.txt", check_public_tree.paths_in_repository())
|
|
git("-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "-m", "fixture")
|
|
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()
|