feat(update): securely stage verified packages
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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`、配置/数据目录、文档和原型目录,不新增正式业务模块。
|
||||
|
||||
@@ -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
@@ -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`。
|
||||
|
||||
@@ -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、大小、包格式或协议版本。
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user