2026-07-13 12:02:47 +08:00
|
|
|
import hashlib
|
|
|
|
|
import io
|
|
|
|
|
import json
|
|
|
|
|
import tempfile
|
|
|
|
|
import unittest
|
2026-07-13 16:19:03 +08:00
|
|
|
import urllib.request
|
2026-07-13 12:02:47 +08:00
|
|
|
import zipfile
|
|
|
|
|
from pathlib import Path
|
2026-07-13 17:17:25 +08:00
|
|
|
from types import SimpleNamespace
|
2026-07-13 12:02:47 +08:00
|
|
|
|
|
|
|
|
from app import release_manifest, update_installer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeResponse(io.BytesIO):
|
|
|
|
|
def __init__(self, payload, url):
|
|
|
|
|
super().__init__(payload)
|
|
|
|
|
self.url = url
|
|
|
|
|
|
|
|
|
|
def geturl(self):
|
|
|
|
|
return self.url
|
|
|
|
|
|
|
|
|
|
def __enter__(self):
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def __exit__(self, *_args):
|
|
|
|
|
self.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeOpener:
|
|
|
|
|
def __init__(self, payload, url="https://updates.example.test/app.zip"):
|
|
|
|
|
self.payload = payload
|
|
|
|
|
self.url = url
|
|
|
|
|
self.calls = 0
|
|
|
|
|
|
|
|
|
|
def open(self, _request, timeout=None):
|
|
|
|
|
assert timeout
|
|
|
|
|
self.calls += 1
|
|
|
|
|
return FakeResponse(self.payload, self.url)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UpdateInstallerTests(unittest.TestCase):
|
2026-07-13 16:19:03 +08:00
|
|
|
@staticmethod
|
|
|
|
|
def public_resolver(_host, _port):
|
|
|
|
|
return ["93.184.216.34"]
|
2026-07-13 12:02:47 +08:00
|
|
|
|
|
|
|
|
def make_zip(self, root, version="1.2.3"):
|
|
|
|
|
package = Path(root) / "package"
|
|
|
|
|
(package / "_internal").mkdir(parents=True)
|
|
|
|
|
(package / "cmshopee.exe").write_bytes(b"new-exe")
|
|
|
|
|
(package / "_internal" / "runtime.dll").write_bytes(b"runtime")
|
2026-07-13 12:23:46 +08:00
|
|
|
(package / "_internal" / "empty.marker").write_bytes(b"")
|
2026-07-13 12:02:47 +08:00
|
|
|
(package / "version.txt").write_text(version, encoding="ascii")
|
|
|
|
|
(package / "README.txt").write_text("说明", encoding="utf-8")
|
|
|
|
|
release_manifest.write_package_manifest(package, version)
|
|
|
|
|
zip_path = Path(root) / "package.zip"
|
|
|
|
|
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
|
|
|
for path in package.rglob("*"):
|
|
|
|
|
if path.is_file():
|
|
|
|
|
archive.write(path, path.relative_to(package).as_posix())
|
|
|
|
|
return zip_path.read_bytes()
|
|
|
|
|
|
|
|
|
|
def metadata(self, payload, **changes):
|
|
|
|
|
values = {
|
|
|
|
|
"version": "1.2.3",
|
|
|
|
|
"download_url": "https://updates.example.test/app.zip",
|
|
|
|
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
|
|
|
|
"size_bytes": len(payload),
|
|
|
|
|
"package_format": release_manifest.PACKAGE_FORMAT,
|
|
|
|
|
"updater_protocol": release_manifest.UPDATER_PROTOCOL,
|
|
|
|
|
"min_updater_protocol": 1,
|
|
|
|
|
}
|
|
|
|
|
values.update(changes)
|
|
|
|
|
return update_installer.UpdatePackageMetadata(**values)
|
|
|
|
|
|
2026-07-13 17:17:25 +08:00
|
|
|
def test_online_release_uses_internal_package_contract_defaults(self):
|
|
|
|
|
info = SimpleNamespace(
|
|
|
|
|
latest_version="0.1.6",
|
|
|
|
|
download_url="http://downloads.external.test/cmshopee.zip",
|
|
|
|
|
sha256="a" * 64,
|
|
|
|
|
size_bytes=53318076,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
metadata = update_installer.metadata_from_update_info(info)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(release_manifest.PACKAGE_FORMAT, metadata.package_format)
|
|
|
|
|
self.assertEqual(release_manifest.UPDATER_PROTOCOL, metadata.updater_protocol)
|
|
|
|
|
self.assertEqual(release_manifest.UPDATER_PROTOCOL, metadata.min_updater_protocol)
|
|
|
|
|
|
2026-07-13 12:02:47 +08:00
|
|
|
def test_prepare_update_verifies_and_reuses_pending(self):
|
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
|
|
payload = self.make_zip(temp_dir)
|
|
|
|
|
install_root = Path(temp_dir) / "installed"
|
|
|
|
|
install_root.mkdir()
|
|
|
|
|
(install_root / "cmshopee.exe").write_bytes(b"old-exe")
|
|
|
|
|
(install_root / "data").mkdir()
|
|
|
|
|
opener = FakeOpener(payload)
|
|
|
|
|
|
|
|
|
|
staged = update_installer.prepare_update(
|
|
|
|
|
self.metadata(payload),
|
|
|
|
|
install_root,
|
2026-07-13 16:19:03 +08:00
|
|
|
resolver=self.public_resolver,
|
2026-07-13 12:02:47 +08:00
|
|
|
opener=opener,
|
|
|
|
|
)
|
|
|
|
|
reused = update_installer.prepare_update(
|
|
|
|
|
self.metadata(payload),
|
|
|
|
|
install_root,
|
2026-07-13 16:19:03 +08:00
|
|
|
resolver=self.public_resolver,
|
2026-07-13 12:02:47 +08:00
|
|
|
opener=opener,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(1, opener.calls)
|
|
|
|
|
self.assertEqual(staged.staging_dir, reused.staging_dir)
|
|
|
|
|
self.assertEqual(b"old-exe", (install_root / "cmshopee.exe").read_bytes())
|
|
|
|
|
self.assertTrue((install_root / "data").is_dir())
|
|
|
|
|
pending = json.loads(staged.pending_path.read_text(encoding="utf-8"))
|
|
|
|
|
self.assertNotIn("download_url", pending)
|
|
|
|
|
|
2026-07-13 16:19:03 +08:00
|
|
|
def test_prepare_update_accepts_public_http_and_writes_verified_pending(self):
|
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
|
|
payload = self.make_zip(temp_dir)
|
|
|
|
|
install_root = Path(temp_dir) / "installed"
|
|
|
|
|
install_root.mkdir()
|
|
|
|
|
metadata = self.metadata(
|
|
|
|
|
payload,
|
|
|
|
|
download_url="http://downloads.external.test/releases/app.zip",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
staged = update_installer.prepare_update(
|
|
|
|
|
metadata,
|
|
|
|
|
install_root,
|
|
|
|
|
resolver=self.public_resolver,
|
|
|
|
|
opener=FakeOpener(payload, url=metadata.download_url),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
pending = json.loads(staged.pending_path.read_text(encoding="utf-8"))
|
|
|
|
|
self.assertEqual("verified", pending["stage"])
|
|
|
|
|
self.assertEqual(metadata.sha256, pending["sha256"])
|
|
|
|
|
|
|
|
|
|
def test_download_rejects_invalid_metadata_and_unsafe_urls(self):
|
2026-07-13 12:02:47 +08:00
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
|
|
payload = b"zip"
|
|
|
|
|
root = Path(temp_dir)
|
|
|
|
|
cases = (
|
|
|
|
|
self.metadata(payload, sha256=""),
|
|
|
|
|
self.metadata(payload, size_bytes=0),
|
2026-07-13 16:19:03 +08:00
|
|
|
self.metadata(payload, download_url="file:///tmp/app.zip"),
|
|
|
|
|
self.metadata(payload, download_url="http://user:secret@example.test/app.zip"),
|
|
|
|
|
self.metadata(payload, download_url="http://127.0.0.1/app.zip"),
|
|
|
|
|
self.metadata(payload, download_url="http://10.0.0.8/app.zip"),
|
|
|
|
|
self.metadata(payload, download_url="http://[::1]/app.zip"),
|
|
|
|
|
self.metadata(payload, download_url="http://localhost/app.zip"),
|
|
|
|
|
self.metadata(payload, download_url="http://[invalid/app.zip"),
|
2026-07-13 12:02:47 +08:00
|
|
|
)
|
|
|
|
|
for metadata in cases:
|
|
|
|
|
with self.subTest(metadata=metadata):
|
|
|
|
|
with self.assertRaises(update_installer.UpdateInstallError):
|
|
|
|
|
update_installer.download_package(
|
|
|
|
|
metadata,
|
|
|
|
|
root,
|
2026-07-13 16:19:03 +08:00
|
|
|
resolver=self.public_resolver,
|
2026-07-13 12:02:47 +08:00
|
|
|
opener=FakeOpener(payload),
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-13 16:19:03 +08:00
|
|
|
def test_download_allows_public_http_https_domains_and_public_ip(self):
|
|
|
|
|
payload = b"zip"
|
|
|
|
|
urls = (
|
|
|
|
|
"http://cdn-one.example.test:8080/releases/app.zip",
|
|
|
|
|
"https://storage.other.test/app.zip?token=hidden",
|
|
|
|
|
"http://8.8.8.8/app.zip",
|
|
|
|
|
)
|
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
|
|
for index, url in enumerate(urls):
|
|
|
|
|
with self.subTest(url=url):
|
|
|
|
|
root = Path(temp_dir) / str(index)
|
|
|
|
|
downloaded = update_installer.download_package(
|
|
|
|
|
self.metadata(payload, download_url=url),
|
|
|
|
|
root,
|
|
|
|
|
resolver=self.public_resolver,
|
|
|
|
|
opener=FakeOpener(payload, url=url),
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(payload, downloaded.read_bytes())
|
|
|
|
|
|
|
|
|
|
def test_download_rejects_domain_resolving_to_non_public_address(self):
|
|
|
|
|
payload = b"zip"
|
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
|
|
with self.assertRaisesRegex(update_installer.UpdateInstallError, "本机或内网"):
|
|
|
|
|
update_installer.download_package(
|
|
|
|
|
self.metadata(payload, download_url="http://files.example.test/app.zip"),
|
|
|
|
|
temp_dir,
|
|
|
|
|
resolver=lambda _host, _port: ["192.168.1.20"],
|
|
|
|
|
opener=FakeOpener(payload, url="http://files.example.test/app.zip"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_redirect_handler_allows_public_cross_domain_and_protocol_redirects(self):
|
|
|
|
|
handler = update_installer._SafeRedirectHandler(self.public_resolver)
|
|
|
|
|
request = urllib.request.Request("https://origin.example.test/app.zip")
|
|
|
|
|
for target in (
|
|
|
|
|
"http://cdn.example.test:8080/releases/app.zip",
|
|
|
|
|
"https://objects.other.test/app.zip",
|
|
|
|
|
):
|
|
|
|
|
with self.subTest(target=target):
|
|
|
|
|
redirected = handler.redirect_request(request, None, 302, "Found", {}, target)
|
|
|
|
|
self.assertEqual(target, redirected.full_url)
|
|
|
|
|
|
|
|
|
|
with self.assertRaisesRegex(update_installer.UpdateInstallError, "本机或内网"):
|
|
|
|
|
handler.redirect_request(
|
|
|
|
|
request,
|
|
|
|
|
None,
|
|
|
|
|
302,
|
|
|
|
|
"Found",
|
|
|
|
|
{},
|
|
|
|
|
"http://169.254.169.254/latest/meta-data",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_download_revalidates_final_response_url(self):
|
|
|
|
|
payload = b"zip"
|
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
|
|
with self.assertRaisesRegex(update_installer.UpdateInstallError, "本机或内网"):
|
|
|
|
|
update_installer.download_package(
|
|
|
|
|
self.metadata(payload, download_url="http://cdn.example.test/app.zip"),
|
|
|
|
|
temp_dir,
|
|
|
|
|
resolver=self.public_resolver,
|
|
|
|
|
opener=FakeOpener(payload, url="http://127.0.0.1/app.zip"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_http_download_still_requires_matching_hash(self):
|
|
|
|
|
payload = b"zip"
|
|
|
|
|
metadata = self.metadata(
|
|
|
|
|
payload,
|
|
|
|
|
download_url="http://cdn.example.test/app.zip",
|
|
|
|
|
sha256="0" * 64,
|
|
|
|
|
)
|
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
|
|
with self.assertRaisesRegex(update_installer.UpdateInstallError, "完整性校验失败"):
|
|
|
|
|
update_installer.download_package(
|
|
|
|
|
metadata,
|
|
|
|
|
temp_dir,
|
|
|
|
|
resolver=self.public_resolver,
|
|
|
|
|
opener=FakeOpener(payload, url=metadata.download_url),
|
|
|
|
|
)
|
|
|
|
|
self.assertFalse(
|
|
|
|
|
(Path(temp_dir) / ".cmshopee-update/downloads/1.2.3.zip").exists()
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-13 12:02:47 +08:00
|
|
|
def test_cancel_removes_partial_download(self):
|
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
|
|
payload = b"zip-content"
|
|
|
|
|
with self.assertRaises(update_installer.UpdateCancelled):
|
|
|
|
|
update_installer.download_package(
|
|
|
|
|
self.metadata(payload),
|
|
|
|
|
temp_dir,
|
2026-07-13 16:19:03 +08:00
|
|
|
resolver=self.public_resolver,
|
2026-07-13 12:02:47 +08:00
|
|
|
opener=FakeOpener(payload),
|
|
|
|
|
cancelled=lambda: True,
|
|
|
|
|
)
|
|
|
|
|
self.assertFalse(
|
|
|
|
|
(Path(temp_dir) / ".cmshopee-update/downloads/1.2.3.zip.part").exists()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_safe_extract_rejects_path_traversal_case_duplicates_and_symlink(self):
|
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
|
|
for index, entries in enumerate(
|
|
|
|
|
(
|
|
|
|
|
[("../escape.txt", b"x", None)],
|
|
|
|
|
[("same.txt", b"a", None), ("SAME.txt", b"b", None)],
|
|
|
|
|
[("link", b"target", (stat_mode := (0o120777 << 16)))],
|
|
|
|
|
)
|
|
|
|
|
):
|
|
|
|
|
zip_path = Path(temp_dir) / ("bad-%d.zip" % index)
|
|
|
|
|
with zipfile.ZipFile(zip_path, "w") as archive:
|
|
|
|
|
for name, content, external_attr in entries:
|
|
|
|
|
info = zipfile.ZipInfo(name)
|
|
|
|
|
if external_attr is not None:
|
|
|
|
|
info.create_system = 3
|
|
|
|
|
info.external_attr = stat_mode
|
|
|
|
|
archive.writestr(info, content)
|
|
|
|
|
with self.assertRaises(update_installer.UpdateInstallError):
|
|
|
|
|
update_installer.safe_extract(zip_path, Path(temp_dir) / ("out-%d" % index))
|
|
|
|
|
|
|
|
|
|
def test_staging_rejects_manifest_tampering_and_extra_files(self):
|
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
|
|
payload = self.make_zip(temp_dir)
|
|
|
|
|
metadata = self.metadata(payload)
|
|
|
|
|
package = Path(temp_dir) / "package"
|
|
|
|
|
(package / "cmshopee.exe").write_bytes(b"tampered")
|
|
|
|
|
with self.assertRaises(update_installer.UpdateInstallError):
|
|
|
|
|
update_installer.validate_staging(package, metadata)
|
|
|
|
|
|
|
|
|
|
(package / "cmshopee.exe").write_bytes(b"new-exe")
|
|
|
|
|
(package / "extra.bin").write_bytes(b"extra")
|
|
|
|
|
with self.assertRaises(update_installer.UpdateInstallError):
|
|
|
|
|
update_installer.validate_staging(package, metadata)
|
|
|
|
|
|
|
|
|
|
def test_zip_bomb_ratio_is_rejected(self):
|
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
|
|
zip_path = Path(temp_dir) / "bomb.zip"
|
|
|
|
|
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
|
|
|
archive.writestr("large.bin", b"0" * 1024 * 1024)
|
|
|
|
|
with self.assertRaises(update_installer.UpdateInstallError):
|
|
|
|
|
update_installer.safe_extract(zip_path, Path(temp_dir) / "out")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|