Files
cmshoppe/tests/test_update_installer.py
T

305 lines
13 KiB
Python
Raw Normal View History

import hashlib
import io
import json
import tempfile
import unittest
import urllib.request
import zipfile
from pathlib import Path
from types import SimpleNamespace
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):
@staticmethod
def public_resolver(_host, _port):
return ["93.184.216.34"]
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")
(package / "_internal" / "empty.marker").write_bytes(b"")
(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)
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)
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,
resolver=self.public_resolver,
opener=opener,
)
reused = update_installer.prepare_update(
self.metadata(payload),
install_root,
resolver=self.public_resolver,
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)
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):
with tempfile.TemporaryDirectory() as temp_dir:
payload = b"zip"
root = Path(temp_dir)
cases = (
self.metadata(payload, sha256=""),
self.metadata(payload, size_bytes=0),
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"),
)
for metadata in cases:
with self.subTest(metadata=metadata):
with self.assertRaises(update_installer.UpdateInstallError):
update_installer.download_package(
metadata,
root,
resolver=self.public_resolver,
opener=FakeOpener(payload),
)
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()
)
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,
resolver=self.public_resolver,
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()