Land Explorer in HomeCamp with boot loading, journal UI, and CDN publish tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
import uuid
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location("publish_cdn", REPO / "Tools/publish_cdn.py")
|
||||
publish = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(publish)
|
||||
|
||||
|
||||
class PublishTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
(REPO / "Temp").mkdir(exist_ok=True)
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="cdn-publish-tests-", dir=REPO / "Temp")
|
||||
self.directory = Path(self.temp.name)
|
||||
self.stage = self.directory / "stage"
|
||||
self.root = self.directory / "CDN website with spaces"
|
||||
self.local = self.directory / "localappdata"
|
||||
self.local.mkdir()
|
||||
self.env = dict(os.environ, LOCALAPPDATA=str(self.local))
|
||||
self.old = self.create_stage(b"old index", b"old bundle")
|
||||
self.root.mkdir()
|
||||
for item in self.old["files"]:
|
||||
target = self.root / item["path"]
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(item["source"], target)
|
||||
self.old_version = (self.root / "cdn/version.json").read_bytes()
|
||||
self.plan = self.create_stage(b"new index", b"new bundle")
|
||||
self.request = publish.request_for({"remote_root": str(self.root)}, self.plan, uuid.uuid4().hex)
|
||||
self.request["expectedVersionSha256"] = hashlib.sha256(self.old_version).hexdigest()
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def create_stage(self, index, bundle):
|
||||
directory = self.stage / "1.0.0/DefaultPackage"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
for file in directory.iterdir():
|
||||
file.unlink()
|
||||
md5 = hashlib.md5(index).hexdigest()
|
||||
(directory / f"DefaultPackage_{md5}.bytes").write_bytes(index)
|
||||
(directory / f"DefaultPackage_{md5}.hash").write_text(zlib.crc32(index).to_bytes(4, "little").hex())
|
||||
(directory / "DefaultPackage.version").write_text(md5)
|
||||
(directory / f"payload_{hashlib.md5(bundle).hexdigest()}.bundle").write_bytes(bundle)
|
||||
version = {"appVersionMin": "1.0.0", "appVersionMax": "1.0.0", "packVersion": md5,
|
||||
"resVersion": "", "cdnRoot": "https://www.xworld.ren/game/Friends/webgl/yoo/DefaultPackage",
|
||||
"packages": ["DefaultPackage"], "notice": "", "forceUpdate": False}
|
||||
publish.write_json(self.stage / "version.prod.json", version)
|
||||
return publish.make_plan(self.stage, self.directory / "plan.json")
|
||||
|
||||
def invoke(self, action, request=None, success=True):
|
||||
if not shutil.which("powershell.exe"):
|
||||
self.skipTest("Windows PowerShell required for remote transaction tests")
|
||||
payload = base64.b64encode(json.dumps(request or self.request).encode()).decode()
|
||||
result = subprocess.run(["powershell.exe", "-NoProfile", "-NonInteractive", "-File", str(publish.REMOTE_SCRIPT),
|
||||
"-Action", action, "-RequestBase64", payload, "-StateRoot", str(self.local / "AIGameCdnPublish")], env=self.env,
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30)
|
||||
if success:
|
||||
self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
|
||||
return json.loads(result.stdout)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
return result.stderr
|
||||
|
||||
def prepare(self):
|
||||
comparison = self.invoke("Plan")
|
||||
prepared = self.invoke("Prepare")
|
||||
publish.make_archive(self.plan, comparison["changed"], Path(prepared["archive"]))
|
||||
return Path(prepared["archive"])
|
||||
|
||||
def test_local_manifest_crc_and_md5(self):
|
||||
directory = self.stage / "1.0.0/DefaultPackage"
|
||||
next(directory.glob("*.bytes")).write_bytes(b"corrupt")
|
||||
with self.assertRaisesRegex(ValueError, "MD5"):
|
||||
publish.make_plan(self.stage, self.directory / "bad-plan.json")
|
||||
|
||||
def test_local_crc_failure(self):
|
||||
next((self.stage / "1.0.0/DefaultPackage").glob("*.hash")).write_text("00000000")
|
||||
with self.assertRaisesRegex(ValueError, "hash marker"):
|
||||
publish.make_plan(self.stage, self.directory / "bad-plan.json")
|
||||
|
||||
def test_archive_rejects_changed_local_payload(self):
|
||||
entry = next(f for f in self.plan["files"] if f["path"].endswith(".bundle"))
|
||||
Path(entry["source"]).write_bytes(b"changed after planning")
|
||||
with self.assertRaisesRegex(ValueError, "changed since planning"):
|
||||
publish.make_archive(self.plan, [entry["path"]], self.directory / "bad.zip")
|
||||
|
||||
def test_reject_traversal_and_unplanned_file(self):
|
||||
for path in ("../version.json", "yoo/Art/..", "yoo/Art/../../evil", "cdn/secret.txt"):
|
||||
with self.assertRaises(ValueError):
|
||||
publish.validate_path(path)
|
||||
with self.assertRaises(ValueError):
|
||||
publish.make_archive(self.plan, ["unknown"], self.directory / "bad.zip")
|
||||
|
||||
def test_plan_is_read_only(self):
|
||||
result = self.invoke("Plan")
|
||||
self.assertIn("cdn/version.json", result["changed"])
|
||||
self.assertFalse((self.local / "AIGameCdnPublish").exists())
|
||||
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
|
||||
|
||||
def startup_export(self):
|
||||
export = self.directory / "export"
|
||||
(export / "minigame").mkdir(parents=True)
|
||||
(export / "webgl").mkdir()
|
||||
data = b"new Unity shell startup data"
|
||||
tag = hashlib.md5(data).hexdigest()[8:24]
|
||||
name = tag + ".webgl.data.unityweb.bin.txt"
|
||||
(export / "webgl" / name).write_bytes(data)
|
||||
(export / "minigame/game.js").write_text(
|
||||
f"const config={{DATA_FILE_MD5:'{tag}',DATA_FILE_SIZE:'{len(data)}',"
|
||||
"DATA_CDN:'https://www.xworld.ren/game/Friends/webgl/'};", encoding="utf-8")
|
||||
return export, name, data
|
||||
|
||||
def test_startup_export_transaction(self):
|
||||
export, name, data = self.startup_export()
|
||||
self.plan = publish.make_plan(self.stage, self.directory / "startup-plan.json", export)
|
||||
self.request = publish.request_for({"remote_root": str(self.root)}, self.plan, uuid.uuid4().hex)
|
||||
self.request["expectedVersionSha256"] = hashlib.sha256(self.old_version).hexdigest()
|
||||
self.prepare()
|
||||
self.assertTrue(self.invoke("Publish")["published"])
|
||||
self.assertEqual((self.root / name).read_bytes(), data)
|
||||
self.assertTrue(self.invoke("Rollback")["rolledBack"])
|
||||
self.assertEqual((self.root / name).read_bytes(), data)
|
||||
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
|
||||
|
||||
def test_startup_export_rejects_corruption(self):
|
||||
export, name, data = self.startup_export()
|
||||
(export / "webgl" / name).write_bytes(b"x" * len(data))
|
||||
with self.assertRaisesRegex(ValueError, "size or MD5"):
|
||||
publish.make_plan(self.stage, self.directory / "bad-startup.json", export)
|
||||
|
||||
def test_startup_export_rejects_unknown_and_unsafe_files(self):
|
||||
for name in ("webgl.data", "game.js", "../1234567890abcdef.webgl.data.unityweb.bin.txt"):
|
||||
with self.assertRaises(ValueError):
|
||||
publish.validate_path(name)
|
||||
request = dict(self.request)
|
||||
request["files"] = [*self.request["files"], {"path": name,"size": 1,"sha256": "0" * 64}]
|
||||
self.invoke("Plan", request=request, success=False)
|
||||
|
||||
def test_publish_noop_and_rollback(self):
|
||||
self.prepare()
|
||||
receipt = self.invoke("Publish")
|
||||
self.assertTrue(receipt["published"])
|
||||
self.assertEqual(json.loads((self.root / "cdn/version.json").read_bytes()), self.plan["version"])
|
||||
self.assertEqual(self.invoke("Plan")["changed"], [])
|
||||
old_index = self.old["version"]["packVersion"]
|
||||
self.assertTrue((self.root / f"yoo/DefaultPackage/DefaultPackage_{old_index}.bytes").exists())
|
||||
self.assertTrue(self.invoke("Rollback")["rolledBack"])
|
||||
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
|
||||
|
||||
def test_corrupt_upload_keeps_live_version(self):
|
||||
archive = self.prepare()
|
||||
with zipfile.ZipFile(archive, "w") as stream:
|
||||
item = self.plan["files"][0]
|
||||
stream.writestr(item["path"], b"x" * item["size"])
|
||||
self.invoke("Publish", success=False)
|
||||
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
|
||||
|
||||
def test_incomplete_upload_keeps_live_version(self):
|
||||
archive = self.prepare()
|
||||
with zipfile.ZipFile(archive, "w"):
|
||||
pass
|
||||
self.invoke("Publish", success=False)
|
||||
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
|
||||
|
||||
def test_stale_version_keeps_live_version(self):
|
||||
self.prepare()
|
||||
self.request["expectedVersionSha256"] = "0" * 64
|
||||
self.invoke("Publish", success=False)
|
||||
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
|
||||
|
||||
def test_zip_traversal_keeps_live_version(self):
|
||||
archive = self.prepare()
|
||||
with zipfile.ZipFile(archive, "w") as stream:
|
||||
stream.writestr("../evil", b"bad")
|
||||
self.invoke("Publish", success=False)
|
||||
self.assertFalse((archive.parent / "evil").exists())
|
||||
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
|
||||
|
||||
def test_rollback_refuses_later_release(self):
|
||||
self.prepare()
|
||||
self.invoke("Publish")
|
||||
(self.root / "cdn/version.json").write_bytes(b"later release")
|
||||
self.invoke("Rollback", success=False)
|
||||
self.assertEqual((self.root / "cdn/version.json").read_bytes(), b"later release")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user