feat(update): securely stage verified packages

This commit is contained in:
chengma
2026-07-13 12:02:47 +08:00
parent 567d4e7b68
commit 3ad9ffd239
8 changed files with 616 additions and 3 deletions
+24
View File
@@ -27,6 +27,10 @@ class UpdateInfo:
force_update: bool = False
download_url: str = ""
sha256: str = ""
size_bytes: int = 0
package_format: str = ""
updater_protocol: int = 0
min_updater_protocol: int = 0
message: str = ""
@@ -39,6 +43,10 @@ class UpdateCheckResult:
min_supported_version: str = ""
download_url: str = ""
sha256: str = ""
size_bytes: int = 0
package_format: str = ""
updater_protocol: int = 0
min_updater_protocol: int = 0
message: str = ""
error: str = ""
@@ -111,6 +119,18 @@ def parse_update_info(payload) -> UpdateInfo:
force_update=_as_bool(payload.get("force_update", release.get("force_update"))),
download_url=str(payload.get("download_url") or release.get("download_url") or "").strip(),
sha256=str(payload.get("sha256") or release.get("sha256") or "").strip(),
size_bytes=int(payload.get("size_bytes") or release.get("size_bytes") or 0),
package_format=str(
payload.get("package_format") or release.get("package_format") or ""
).strip(),
updater_protocol=int(
payload.get("updater_protocol") or release.get("updater_protocol") or 0
),
min_updater_protocol=int(
payload.get("min_updater_protocol")
or release.get("min_updater_protocol")
or 0
),
message=str(
payload.get("message")
or payload.get("release_notes")
@@ -178,6 +198,10 @@ def check_for_update(
min_supported_version=info.min_supported_version,
download_url=info.download_url,
sha256=info.sha256,
size_bytes=info.size_bytes,
package_format=info.package_format,
updater_protocol=info.updater_protocol,
min_updater_protocol=info.min_updater_protocol,
message=info.message,
)
except Exception as exc:
+398
View File
@@ -0,0 +1,398 @@
"""自动升级包的安全下载、解压和暂存校验。"""
from __future__ import annotations
import hashlib
import ipaddress
import json
import os
import re
import shutil
import stat
import urllib.parse
import urllib.request
import uuid
import zipfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from .release_manifest import (
ALLOWED_ROOTS,
ENTRY_POINT,
MANIFEST_FILENAME,
PACKAGE_FORMAT,
UPDATER_PROTOCOL,
sha256_file,
)
MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024
MAX_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024
MAX_FILE_COUNT = 20000
MAX_COMPRESSION_RATIO = 200
DEFAULT_TRUSTED_HOSTS = frozenset({"cm.833729.com"})
RESERVED_NAMES = {
"CON",
"PRN",
"AUX",
"NUL",
*("COM%d" % number for number in range(1, 10)),
*("LPT%d" % number for number in range(1, 10)),
}
class UpdateInstallError(RuntimeError):
"""升级包不能安全下载或暂存。"""
class UpdateCancelled(UpdateInstallError):
"""用户取消升级包下载。"""
@dataclass(frozen=True)
class UpdatePackageMetadata:
version: str
download_url: str
sha256: str
size_bytes: int
package_format: str
updater_protocol: int
min_updater_protocol: int = 1
@dataclass(frozen=True)
class StagedUpdate:
version: str
zip_path: Path
staging_dir: Path
pending_path: Path
sha256: str
def metadata_from_update_info(info):
metadata = UpdatePackageMetadata(
version=str(getattr(info, "latest_version", "") or "").strip(),
download_url=str(getattr(info, "download_url", "") or "").strip(),
sha256=str(getattr(info, "sha256", "") or "").strip().lower(),
size_bytes=int(getattr(info, "size_bytes", 0) or 0),
package_format=str(getattr(info, "package_format", "") or "").strip(),
updater_protocol=int(getattr(info, "updater_protocol", 0) or 0),
min_updater_protocol=int(getattr(info, "min_updater_protocol", 0) or 1),
)
validate_metadata(metadata)
return metadata
def validate_metadata(metadata, trusted_hosts=DEFAULT_TRUSTED_HOSTS):
if not re.fullmatch(r"\d+(?:\.\d+)*", metadata.version):
raise UpdateInstallError("新版版本号格式不正确")
validate_download_url(metadata.download_url, trusted_hosts)
if not re.fullmatch(r"[0-9a-fA-F]{64}", metadata.sha256):
raise UpdateInstallError("新版安装包校验值缺失或格式不正确")
if metadata.size_bytes <= 0 or metadata.size_bytes > MAX_DOWNLOAD_BYTES:
raise UpdateInstallError("新版安装包大小不正确或超过限制")
if metadata.package_format != PACKAGE_FORMAT:
raise UpdateInstallError("新版安装包格式不受支持")
if metadata.updater_protocol != UPDATER_PROTOCOL:
raise UpdateInstallError("新版安装包更新协议不受支持")
if metadata.min_updater_protocol > UPDATER_PROTOCOL:
raise UpdateInstallError("当前更新器版本过低")
def validate_download_url(url, trusted_hosts=DEFAULT_TRUSTED_HOSTS):
parsed = urllib.parse.urlsplit(str(url or ""))
host = (parsed.hostname or "").lower().rstrip(".")
trusted = {value.lower().rstrip(".") for value in trusted_hosts}
if parsed.scheme.lower() != "https" or not host:
raise UpdateInstallError("新版下载地址必须使用 HTTPS")
if parsed.username or parsed.password:
raise UpdateInstallError("新版下载地址不能包含账号信息")
try:
address = ipaddress.ip_address(host)
except ValueError:
address = None
if address is not None and not address.is_global:
raise UpdateInstallError("新版下载地址不能指向内网")
if host not in trusted:
raise UpdateInstallError("新版下载地址不在受信任域名内")
return parsed
class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, trusted_hosts):
super().__init__()
self.trusted_hosts = trusted_hosts
def redirect_request(self, req, fp, code, msg, headers, newurl):
validate_download_url(newurl, self.trusted_hosts)
return super().redirect_request(req, fp, code, msg, headers, newurl)
def _update_root(install_root):
return Path(install_root).resolve() / ".cmshopee-update"
def _safe_unlink(path):
try:
Path(path).unlink()
except FileNotFoundError:
pass
def download_package(
metadata,
install_root,
*,
trusted_hosts=DEFAULT_TRUSTED_HOSTS,
opener=None,
cancelled=None,
progress=None,
timeout=66,
):
validate_metadata(metadata, trusted_hosts)
validate_download_url(metadata.download_url, trusted_hosts)
downloads = _update_root(install_root) / "downloads"
downloads.mkdir(parents=True, exist_ok=True)
part_path = downloads / (metadata.version + ".zip.part")
zip_path = downloads / (metadata.version + ".zip")
_safe_unlink(part_path)
if shutil.disk_usage(str(downloads)).free < metadata.size_bytes:
raise UpdateInstallError("磁盘空间不足,无法下载新版")
request = urllib.request.Request(
metadata.download_url,
headers={"Accept": "application/zip", "User-Agent": "cmshopee-updater/1"},
)
if opener is None:
opener = urllib.request.build_opener(_SafeRedirectHandler(trusted_hosts))
digest = hashlib.sha256()
downloaded = 0
try:
with opener.open(request, timeout=timeout) as response, part_path.open("wb") as output:
validate_download_url(response.geturl(), trusted_hosts)
while True:
if cancelled and cancelled():
raise UpdateCancelled("已取消下载新版")
chunk = response.read(1024 * 1024)
if not chunk:
break
downloaded += len(chunk)
if downloaded > metadata.size_bytes or downloaded > MAX_DOWNLOAD_BYTES:
raise UpdateInstallError("新版安装包大小超过接口声明")
output.write(chunk)
digest.update(chunk)
if progress:
progress(downloaded, metadata.size_bytes)
if downloaded != metadata.size_bytes:
raise UpdateInstallError("新版安装包大小校验失败")
if digest.hexdigest().lower() != metadata.sha256.lower():
raise UpdateInstallError("新版安装包完整性校验失败")
os.replace(str(part_path), str(zip_path))
return zip_path
except UpdateInstallError:
_safe_unlink(part_path)
raise
except Exception as exc:
_safe_unlink(part_path)
raise UpdateInstallError("下载新版失败,请检查网络后重试") from exc
def _validate_member_name(name):
if not name or "\\" in name or name.startswith(("/", "\\")):
raise UpdateInstallError("新版安装包包含不安全路径")
if re.match(r"^[A-Za-z]:", name) or ":" in name:
raise UpdateInstallError("新版安装包包含不安全路径")
path = PurePosixPath(name)
if any(part in {"", ".", ".."} for part in path.parts):
raise UpdateInstallError("新版安装包包含路径穿越")
for part in path.parts:
if part.endswith((".", " ")):
raise UpdateInstallError("新版安装包包含 Windows 不支持的路径")
stem = part.split(".", 1)[0].upper()
if stem in RESERVED_NAMES:
raise UpdateInstallError("新版安装包包含 Windows 保留名称")
return path
def _is_zip_symlink(info):
mode = (info.external_attr >> 16) & 0xFFFF
return stat.S_ISLNK(mode)
def safe_extract(zip_path, target_dir):
target_dir = Path(target_dir)
seen = set()
total_size = 0
with zipfile.ZipFile(str(zip_path), "r") as archive:
infos = archive.infolist()
if len(infos) > MAX_FILE_COUNT:
raise UpdateInstallError("新版安装包文件数量超过限制")
validated = []
for info in infos:
path = _validate_member_name(info.filename.rstrip("/") if info.is_dir() else info.filename)
key = path.as_posix().casefold()
if key in seen:
raise UpdateInstallError("新版安装包包含重复路径")
seen.add(key)
if _is_zip_symlink(info):
raise UpdateInstallError("新版安装包不允许符号链接")
total_size += info.file_size
if total_size > MAX_EXTRACTED_BYTES:
raise UpdateInstallError("新版安装包解压大小超过限制")
if info.file_size and (
info.compress_size == 0
or info.file_size / max(info.compress_size, 1) > MAX_COMPRESSION_RATIO
):
raise UpdateInstallError("新版安装包压缩比异常")
validated.append((info, path))
target_dir.mkdir(parents=True, exist_ok=False)
for info, relative in validated:
destination = target_dir.joinpath(*relative.parts)
if info.is_dir():
destination.mkdir(parents=True, exist_ok=True)
continue
destination.parent.mkdir(parents=True, exist_ok=True)
with archive.open(info, "r") as source, destination.open("wb") as output:
shutil.copyfileobj(source, output, length=1024 * 1024)
return target_dir
def validate_staging(staging_dir, metadata):
staging_dir = Path(staging_dir)
for name in (ENTRY_POINT, "_internal", "version.txt", MANIFEST_FILENAME):
if not (staging_dir / name).exists():
raise UpdateInstallError("新版安装包缺少必要程序文件")
if not (staging_dir / "_internal").is_dir():
raise UpdateInstallError("新版安装包依赖目录无效")
if (staging_dir / "version.txt").read_text(encoding="utf-8-sig").strip() != metadata.version:
raise UpdateInstallError("新版安装包版本不一致")
try:
manifest = json.loads((staging_dir / MANIFEST_FILENAME).read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise UpdateInstallError("新版安装包清单无效") from exc
if (
manifest.get("app_version") != metadata.version
or manifest.get("package_format") != metadata.package_format
or manifest.get("entry_point") != ENTRY_POINT
or int(manifest.get("updater_protocol") or 0) != metadata.updater_protocol
or int(manifest.get("min_updater_protocol") or 0) > UPDATER_PROTOCOL
):
raise UpdateInstallError("新版安装包清单与发布信息不一致")
replace_roots = manifest.get("replace_roots")
if not isinstance(replace_roots, list) or not replace_roots:
raise UpdateInstallError("新版安装包替换范围无效")
if any(str(root) not in ALLOWED_ROOTS for root in replace_roots):
raise UpdateInstallError("新版安装包包含未授权替换范围")
declared = {}
for item in manifest.get("files") or []:
if not isinstance(item, dict):
raise UpdateInstallError("新版安装包清单文件项无效")
relative = _validate_member_name(str(item.get("path") or ""))
if relative.parts[0] not in ALLOWED_ROOTS or relative.name == MANIFEST_FILENAME:
raise UpdateInstallError("新版安装包清单包含未授权文件")
key = relative.as_posix().casefold()
if key in declared:
raise UpdateInstallError("新版安装包清单包含重复路径")
declared[key] = (relative, int(item.get("size_bytes") or -1), str(item.get("sha256") or ""))
actual = {}
for path in staging_dir.rglob("*"):
if path.is_symlink():
raise UpdateInstallError("新版暂存目录不允许符号链接")
if not path.is_file() or path.name == MANIFEST_FILENAME:
continue
relative = PurePosixPath(path.relative_to(staging_dir).as_posix())
actual[relative.as_posix().casefold()] = relative
if set(actual) != set(declared):
raise UpdateInstallError("新版安装包文件与清单不一致")
for key, (relative, size_bytes, expected_hash) in declared.items():
path = staging_dir.joinpath(*relative.parts)
if path.stat().st_size != size_bytes or sha256_file(path).lower() != expected_hash.lower():
raise UpdateInstallError("新版安装包文件校验失败")
return manifest
def _write_pending(update_root, metadata, staging_dir, zip_path):
pending_path = update_root / "pending.json"
payload = {
"schema_version": 1,
"stage": "verified",
"version": metadata.version,
"install_root": str(update_root.parent),
"staging_dir": str(Path(staging_dir).relative_to(update_root).as_posix()),
"zip_path": str(Path(zip_path).relative_to(update_root).as_posix()),
"sha256": metadata.sha256,
"package_format": metadata.package_format,
"updater_protocol": metadata.updater_protocol,
}
temporary = pending_path.with_suffix(".json.tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(str(temporary), str(pending_path))
return pending_path
def _discard_pending(update_root, payload=None):
if isinstance(payload, dict):
relative = payload.get("staging_dir")
if relative:
try:
staging_dir = (update_root / relative).resolve()
if update_root in staging_dir.parents and staging_dir.exists():
shutil.rmtree(str(staging_dir), ignore_errors=True)
except (OSError, ValueError):
pass
_safe_unlink(update_root / "pending.json")
def load_verified_pending(install_root, metadata):
update_root = _update_root(install_root)
pending_path = update_root / "pending.json"
payload = None
try:
payload = json.loads(pending_path.read_text(encoding="utf-8"))
if (
payload.get("stage") != "verified"
or payload.get("version") != metadata.version
or payload.get("sha256") != metadata.sha256
):
raise UpdateInstallError("待安装更新记录已失效")
staging_dir = (update_root / payload["staging_dir"]).resolve()
zip_path = (update_root / payload["zip_path"]).resolve()
if update_root not in staging_dir.parents or update_root not in zip_path.parents:
raise UpdateInstallError("待安装更新路径无效")
validate_staging(staging_dir, metadata)
if not zip_path.is_file() or sha256_file(zip_path) != metadata.sha256:
raise UpdateInstallError("待安装更新压缩包已失效")
return StagedUpdate(metadata.version, zip_path, staging_dir, pending_path, metadata.sha256)
except FileNotFoundError:
return None
except (OSError, ValueError, KeyError, UpdateInstallError):
_discard_pending(update_root, payload)
return None
def prepare_update(metadata, install_root, **download_kwargs):
validate_metadata(metadata, download_kwargs.get("trusted_hosts", DEFAULT_TRUSTED_HOSTS))
reusable = load_verified_pending(install_root, metadata)
if reusable is not None:
return reusable
update_root = _update_root(install_root)
zip_path = download_package(metadata, install_root, **download_kwargs)
staging_parent = update_root / "staging"
staging_parent.mkdir(parents=True, exist_ok=True)
staging_dir = staging_parent / (metadata.version + "-" + uuid.uuid4().hex[:12])
try:
safe_extract(zip_path, staging_dir)
validate_staging(staging_dir, metadata)
pending_path = _write_pending(update_root, metadata, staging_dir, zip_path)
except Exception:
if staging_dir.exists():
shutil.rmtree(str(staging_dir), ignore_errors=True)
raise
return StagedUpdate(metadata.version, zip_path, staging_dir, pending_path, metadata.sha256)
+2
View File
@@ -499,6 +499,7 @@ cmshopee/
│ ├── appconfig.py / db.py / excel.py / config.py / accounts.py / chrome.py
│ ├── editor.py / ai.py / prompts.py / gui.py / workers.py
│ ├── release_manifest.py # 发布包文件清单、zip哈希与服务端元数据模板
│ ├── update_installer.py # 自动升级安全下载、解压、manifest校验与同盘暂存
├── main.py # GUI 启动入口:from app.gui import main
├── shopee待处理任务模板.xlsx # 标准空 Excel 模板,可提交;业务填写后的副本不提交
├── data/ # 用户本地数据根(整体 gitignore;打包更新时保留)
@@ -527,6 +528,7 @@ cmshopee/
- 发布包格式固定为 `cmshopee-portable-v1`,入口为 `cmshopee.exe`,程序依赖集中在 `_internal/`。manifest 覆盖除自身外的所有程序文件,并记录规范化相对路径、字节数和 SHA-256。
- 发布包只能包含程序根项目,`data/` 和 `.cmshopee-update/` 永远在替换边界之外。第一阶段预留签名字段,但 SHA-256 只负责传输完整性,不等同于发布者身份认证。
- T-615 只提供可验证发布契约;启动门禁仍保持 T-544 的人工下载行为,直到后续下载、独立更新器、事务替换和失败熔断任务全部接入。
- T-616 的下载暂存根固定为安装目录下 `.cmshopee-update/`,与 `data/` 完全隔离。远程zip必须经过HTTPS/受信任域名、声明大小、整包SHA-256、安全zip路径和包内manifest逐文件校验,才写 `pending.json`;此阶段不替换任何运行中程序文件。
- CDP 交互事实变化同步第七节。
- 正式代码只放 `app/` 包;根目录只保留 `main.py`、配置/数据目录、文档和原型目录,不新增正式业务模块。
+2
View File
@@ -176,6 +176,8 @@ T-544 第一版目标是**启动时检查是否必须升级**,但仍不做自
T-615 不改变客户端行为:T-544 仍只打开浏览器下载。客户端下载、解压校验、独立进程替换、回滚和重启分别由 T-616 至 T-619 实现。清单预留签名字段,但当前只验证完整性,不宣称已验证发布者身份。
T-616 提供无Qt依赖的安全下载暂存层 `app/update_installer.py`。它只接受受信任域名的HTTPS,下载到 `<安装目录>/.cmshopee-update/downloads/`,完成大小与zip SHA-256校验后安全解压到同盘 `staging/`;路径穿越、符号链接、大小写重复、Windows保留名、ADS、异常压缩比、文件数/解压总量超限,以及manifest缺失或逐文件hash不一致都会拒绝。完整验证后才原子写 `pending.json`,当前程序目录和 `data/` 不变。
## 四、绝不打包的本地数据
发布包里不能包含以下本地数据、密钥、业务数据或登录态:
+4 -2
View File
@@ -3,7 +3,7 @@ id: T-616
title: 自动升级安全下载校验与同盘暂存
phase: 8
deps: [T-615]
status: TODO
status: DONE
created: 2026-07-13
---
@@ -44,4 +44,6 @@ created: 2026-07-13
## 执行记录
(完成后记录实现、验证命令与结果。)
- 2026-07-13:新增无Qt依赖的 `app/update_installer.py`,实现受信任HTTPS下载、同盘part暂存、大小/hash校验、安全zip解压、manifest逐文件复核及可复用 `pending.json`。
- 2026-07-13:补齐版本接口自动安装元数据透传;所有失败均发生在当前程序替换前,取消与无效pending会清理不完整状态,`data/` 不参与更新。
- 2026-07-13:干净worktree验证通过:ruff、compileall、完整unittest(400项)和 `git diff --check`。
+3 -1
View File
@@ -15,7 +15,7 @@
## 二、客户端读取的字段(`update_check.parse_update_info`)
响应是一个 JSON 对象。以下字段**顶层 payload 或 `release` 子对象里都认**(客户端按 `payload.get(x) or release.get(x)` 取,顶层优先):
响应是一个 JSON 对象。T-544 兼容字段仍可位于顶层或 `release` 子对象;自动安装元数据由 `app/update_installer.py` 做第二层严格校验,生产服务应把同一版本的全部字段放进同一个 `release` 对象。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
@@ -47,6 +47,8 @@
- **非强制**:**不弹任何提示**,直接进主界面(当前无"温和可跳过提示"分支;如需另立任务)。
- **失败放行**:接口断网、超时、返回非法 JSON、缺 `latest_version`/`min_supported_version` 时,客户端记诊断日志(`data/logs/cmshopee.log`,`step=startup_update_check`「已允许继续使用」)并**放行**,不因服务器故障导致全员打不开。
T-616 已实现但尚未接入GUI的安全暂存层:仅接受受信任域名的 HTTPS 地址,流式下载到安装目录 `.cmshopee-update/`,校验zip大小和SHA-256,安全解压后再按包内manifest逐文件校验。任一步失败都不修改当前程序或 `data/`;GUI接入由T-618完成。
## 五、发版约定(服务端据此控制)
自动安装使用的全部字段必须放在同一个 `release` 对象内,不得把版本取自一个对象、hash 取自另一个对象。构建脚本生成的 `release/release-metadata.json` 是服务端录入模板;发布人员只补 HTTPS `download_url`、强制策略和中文发布说明,不得手工改写 hash、大小、包格式或协议版本。
+5
View File
@@ -43,6 +43,9 @@ class UpdateCheckTests(unittest.TestCase):
"force_update": True,
"download_url": "https://example.test/cmshopee.zip",
"sha256": "abc",
"size_bytes": 123,
"package_format": "cmshopee-portable-v1",
"updater_protocol": 1,
"message": "请升级后继续使用",
}
@@ -57,6 +60,8 @@ class UpdateCheckTests(unittest.TestCase):
self.assertFalse(result.can_enter)
self.assertEqual("1.2.0", result.latest_version)
self.assertEqual("https://example.test/cmshopee.zip", result.download_url)
self.assertEqual(123, result.size_bytes)
self.assertEqual("cmshopee-portable-v1", result.package_format)
def test_check_for_update_accepts_release_wrapper_response(self):
def fetcher(_url, _timeout):
+178
View File
@@ -0,0 +1,178 @@
import hashlib
import io
import json
import tempfile
import unittest
import zipfile
from pathlib import Path
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):
trusted_hosts = {"updates.example.test"}
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 / "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_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,
trusted_hosts=self.trusted_hosts,
opener=opener,
)
reused = update_installer.prepare_update(
self.metadata(payload),
install_root,
trusted_hosts=self.trusted_hosts,
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_download_rejects_hash_size_http_and_untrusted_host(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="http://updates.example.test/app.zip"),
self.metadata(payload, download_url="https://other.example.test/app.zip"),
)
for metadata in cases:
with self.subTest(metadata=metadata):
with self.assertRaises(update_installer.UpdateInstallError):
update_installer.download_package(
metadata,
root,
trusted_hosts=self.trusted_hosts,
opener=FakeOpener(payload),
)
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,
trusted_hosts=self.trusted_hosts,
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()