Files
cmautobuy/client/src/update_service.py
T

511 lines
19 KiB
Python

"""Client 在线更新的检查、下载、校验和安全暂存。
本模块不访问 Qt,也不替换正在运行的程序。Launcher 只在下次启动时应用这里
准备好的 ``data/update/app.new``。
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import re
import shutil
import stat
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from typing import Callable, Optional
from .db import data_dir
from .version import __version__
MAX_MANIFEST_BYTES = 1024 * 1024
MAX_UPDATE_BYTES = 500 * 1024 * 1024
MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024
UPDATE_MANIFEST_SETTING = "update.manifest_url"
UPDATE_USERNAME_SETTING = "update.username"
DEFAULT_UPDATE_MANIFEST_URL = "http://cm.xiapi.com/autobuy_manifest.json"
LEGACY_DEFAULT_UPDATE_MANIFEST_URL = "http://cm.xiapi.com/autobuy——manifest.json"
DEFAULT_UPDATE_USERNAME = "admin"
_ALLOWED_HTTP_UPDATE_HOST = "cm.xiapi.com"
_VERSION_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$")
_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
class UpdateError(RuntimeError):
"""在线更新失败。"""
class UpdateConfigurationError(UpdateError):
"""更新地址或清单配置不合法。"""
class UpdateNetworkError(UpdateError):
"""更新服务器访问失败。"""
class UpdateIntegrityError(UpdateError):
"""更新文件大小、哈希或版本不一致。"""
class UnsafeUpdateArchiveError(UpdateError):
"""更新压缩包包含不安全路径或内容。"""
class UpdateCancelled(UpdateError):
"""用户关闭页面后取消继续处理更新。"""
@dataclass(frozen=True)
class UpdateCredentials:
"""仅在内存中使用的更新服务器 Basic Authentication 凭据。"""
username: str
password: str = field(repr=False)
@dataclass(frozen=True)
class UpdateInfo:
"""清单中一份可下载更新的信息。"""
version: str
manifest_url: str
update_url: str
file_name: str
size: int
sha256: str
@dataclass(frozen=True)
class UpdateCheckResult:
"""检查更新结果。"""
current_version: str
latest_version: str
available: bool
update: Optional[UpdateInfo] = None
def parse_version(version: str) -> tuple[int, int, int, int]:
"""把三段或四段数字版本转换为可比较元组。"""
match = _VERSION_PATTERN.fullmatch(version.strip())
if match is None:
raise UpdateConfigurationError(
f"版本号格式无效:{version!r};应为 1.2.3 或 1.2.3.4"
)
numbers = [int(part) for part in version.strip().split(".")]
while len(numbers) < 4:
numbers.append(0)
return tuple(numbers) # type: ignore[return-value]
def validate_manifest_url(url: str) -> str:
"""验证更新地址;HTTP 只对白名单发布主机开放。"""
normalized = url.strip()
parsed = urllib.parse.urlsplit(normalized)
scheme = parsed.scheme.lower()
try:
port = parsed.port
except ValueError as exc:
raise UpdateConfigurationError("更新清单地址端口无效") from exc
is_https = scheme == "https" and bool(parsed.hostname)
is_allowed_http = (
scheme == "http"
and (parsed.hostname or "").lower() == _ALLOWED_HTTP_UPDATE_HOST
and port in {None, 80}
)
if not is_https and not is_allowed_http:
raise UpdateConfigurationError(
"更新清单地址必须使用 HTTPS;HTTP 只允许固定发布服务器"
)
if parsed.username is not None or parsed.password is not None:
raise UpdateConfigurationError("更新清单地址不能包含账号或密码")
if parsed.query:
raise UpdateConfigurationError("更新清单地址不能包含查询参数,避免把凭据写入本地")
if parsed.fragment:
raise UpdateConfigurationError("更新清单地址不能包含 # 片段")
return normalized
def _origin(url: str) -> tuple[str, str, int]:
parsed = urllib.parse.urlsplit(url)
scheme = parsed.scheme.lower()
return (
scheme,
(parsed.hostname or "").lower(),
parsed.port or (443 if scheme == "https" else 80),
)
def _ascii_request_url(url: str) -> str:
"""只编码 URL 路径中的非 ASCII 字符,避免重复编码已有百分号。"""
parsed = urllib.parse.urlsplit(url)
encoded_path = urllib.parse.quote(parsed.path, safe="/%")
return urllib.parse.urlunsplit(
(parsed.scheme, parsed.netloc, encoded_path, parsed.query, parsed.fragment)
)
class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
"""在发送认证头之前拒绝跨源重定向。"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
validated_url = validate_manifest_url(newurl)
if _origin(req.full_url) != _origin(validated_url):
raise UpdateConfigurationError("更新请求不允许跨服务器重定向")
return super().redirect_request(
req,
fp,
code,
msg,
headers,
_ascii_request_url(validated_url),
)
class UpdateService:
"""检查并把更新安全暂存到 ``data/update``。"""
def __init__(
self,
update_directory: Optional[Path] = None,
urlopen: Optional[Callable] = None,
timeout_seconds: float = 10.0,
):
self._update_directory = update_directory or (data_dir() / "update")
self._urlopen = urlopen or urllib.request.build_opener(
_SameOriginRedirectHandler()
).open
self._timeout_seconds = timeout_seconds
@property
def update_directory(self) -> Path:
return self._update_directory
def check(
self,
manifest_url: str,
current_version: str = __version__,
is_cancelled: Optional[Callable[[], bool]] = None,
credentials: Optional[UpdateCredentials] = None,
) -> UpdateCheckResult:
"""下载并解析清单,返回是否存在新版本。"""
configured_url = validate_manifest_url(manifest_url)
parse_version(current_version)
content, final_manifest_url = self._read_url(
configured_url,
MAX_MANIFEST_BYTES,
is_cancelled,
credentials,
)
try:
manifest = json.loads(content.decode("utf-8-sig"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise UpdateConfigurationError("更新清单不是有效的 UTF-8 JSON") from exc
info = self._parse_manifest(manifest, final_manifest_url)
available = parse_version(info.version) > parse_version(current_version)
return UpdateCheckResult(
current_version=current_version,
latest_version=info.version,
available=available,
update=info if available else None,
)
def download_and_stage(
self,
info: UpdateInfo,
is_cancelled: Optional[Callable[[], bool]] = None,
on_progress: Optional[Callable[[int], None]] = None,
credentials: Optional[UpdateCredentials] = None,
) -> Path:
"""下载、校验并安全解压更新,返回暂存的 ``app.new``。"""
self._raise_if_cancelled(is_cancelled)
update_directory = self._update_directory
update_directory.mkdir(parents=True, exist_ok=True)
download_path = update_directory / "download.tmp"
extracting_directory = update_directory / "extracting"
staged_app = update_directory / "app.new"
self._remove_path(download_path)
self._remove_path(extracting_directory)
digest = hashlib.sha256()
bytes_written = 0
try:
request = self._make_request(info.update_url, credentials)
with self._open(request) as response:
final_url = validate_manifest_url(response.geturl())
if _origin(final_url) != _origin(info.manifest_url):
raise UpdateConfigurationError("更新包必须与更新清单来自同一服务器")
declared_size = self._content_length(response)
if declared_size is not None and declared_size != info.size:
raise UpdateIntegrityError("更新包服务器大小与清单不一致")
with download_path.open("wb") as output:
while True:
self._raise_if_cancelled(is_cancelled)
block = response.read(1024 * 1024)
if not block:
break
bytes_written += len(block)
if bytes_written > MAX_UPDATE_BYTES or bytes_written > info.size:
raise UpdateIntegrityError("更新包大小超过清单或安全限制")
output.write(block)
digest.update(block)
if on_progress is not None and info.size:
on_progress(min(100, bytes_written * 100 // info.size))
if bytes_written != info.size:
raise UpdateIntegrityError("更新包实际大小与清单不一致")
if digest.hexdigest() != info.sha256:
raise UpdateIntegrityError("更新包 SHA256 与清单不一致")
extracted_app = self._extract_safely(
download_path,
extracting_directory,
info.version,
is_cancelled,
)
self._remove_path(staged_app)
os.replace(str(extracted_app), str(staged_app))
self._write_json_atomic(
update_directory / "pending.json",
{
"schema_version": 1,
"state": "ready",
"version": info.version,
"sha256": info.sha256,
"file": info.file_name,
},
)
if on_progress is not None:
on_progress(100)
return staged_app
except UpdateError:
raise
except (OSError, zipfile.BadZipFile) as exc:
raise UpdateError(f"无法暂存更新:{exc}") from exc
finally:
self._remove_path(download_path)
self._remove_path(extracting_directory)
def _parse_manifest(self, manifest, manifest_url: str) -> UpdateInfo:
if not isinstance(manifest, dict):
raise UpdateConfigurationError("更新清单根节点必须是对象")
if manifest.get("schema_version") != 1:
raise UpdateConfigurationError("不支持的更新清单版本")
if manifest.get("product") != "CMAutoBuy":
raise UpdateConfigurationError("更新清单不属于 CMAutoBuy")
version = manifest.get("version")
update = manifest.get("update")
if not isinstance(version, str) or not isinstance(update, dict):
raise UpdateConfigurationError("更新清单缺少版本或更新包信息")
parse_version(version)
file_name = update.get("file")
size = update.get("size")
sha256 = update.get("sha256")
if (
not isinstance(file_name, str)
or not file_name
or PurePosixPath(file_name).name != file_name
or "\\" in file_name
):
raise UpdateConfigurationError("更新包文件名无效")
if not isinstance(size, int) or isinstance(size, bool) or not 0 < size <= MAX_UPDATE_BYTES:
raise UpdateConfigurationError("更新包大小无效或超过安全限制")
if not isinstance(sha256, str) or not _SHA256_PATTERN.fullmatch(sha256):
raise UpdateConfigurationError("更新包 SHA256 格式无效")
update_url = urllib.parse.urljoin(
manifest_url,
urllib.parse.quote(file_name),
)
validate_manifest_url(update_url)
if _origin(update_url) != _origin(manifest_url):
raise UpdateConfigurationError("更新包必须与更新清单来自同一服务器")
return UpdateInfo(
version=version,
manifest_url=manifest_url,
update_url=update_url,
file_name=file_name,
size=size,
sha256=sha256,
)
def _read_url(
self,
url: str,
maximum_bytes: int,
is_cancelled: Optional[Callable[[], bool]],
credentials: Optional[UpdateCredentials],
) -> tuple[bytes, str]:
request = self._make_request(url, credentials)
with self._open(request) as response:
final_url = validate_manifest_url(response.geturl())
declared_size = self._content_length(response)
if declared_size is not None and declared_size > maximum_bytes:
raise UpdateConfigurationError("更新清单超过安全大小限制")
chunks = []
total = 0
while True:
self._raise_if_cancelled(is_cancelled)
block = response.read(64 * 1024)
if not block:
break
total += len(block)
if total > maximum_bytes:
raise UpdateConfigurationError("更新清单超过安全大小限制")
chunks.append(block)
return b"".join(chunks), final_url
@staticmethod
def _make_request(
url: str,
credentials: Optional[UpdateCredentials],
) -> urllib.request.Request:
headers = {"User-Agent": f"CMAutoBuy/{__version__}"}
if credentials is not None:
username = credentials.username.strip()
if not username or not credentials.password:
raise UpdateConfigurationError("更新账号和密码不能为空")
raw = f"{username}:{credentials.password}".encode("utf-8")
headers["Authorization"] = "Basic " + base64.b64encode(raw).decode(
"ascii"
)
return urllib.request.Request(_ascii_request_url(url), headers=headers)
def _open(self, request):
try:
return self._urlopen(request, timeout=self._timeout_seconds)
except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError) as exc:
raise UpdateNetworkError(f"无法连接更新服务器:{exc}") from exc
@staticmethod
def _content_length(response) -> Optional[int]:
value = response.headers.get("Content-Length")
if value is None:
return None
try:
size = int(value)
except (TypeError, ValueError) as exc:
raise UpdateConfigurationError("服务器返回了无效的文件大小") from exc
if size < 0:
raise UpdateConfigurationError("服务器返回了无效的文件大小")
return size
def _extract_safely(
self,
archive_path: Path,
destination: Path,
expected_version: str,
is_cancelled: Optional[Callable[[], bool]],
) -> Path:
destination.mkdir(parents=True, exist_ok=False)
destination_resolved = destination.resolve()
total_size = 0
with zipfile.ZipFile(archive_path) as archive:
members = archive.infolist()
if not members:
raise UnsafeUpdateArchiveError("更新压缩包为空")
for member in members:
self._raise_if_cancelled(is_cancelled)
if "\\" in member.filename:
raise UnsafeUpdateArchiveError("更新压缩包包含非法路径分隔符")
relative = PurePosixPath(member.filename)
if (
relative.is_absolute()
or not relative.parts
or relative.parts[0] != "app"
or any(part in {"", ".", ".."} for part in relative.parts)
):
raise UnsafeUpdateArchiveError("更新压缩包只能包含安全的 app/ 内容")
file_type = (member.external_attr >> 16) & 0o170000
if file_type == stat.S_IFLNK:
raise UnsafeUpdateArchiveError("更新压缩包不能包含符号链接")
total_size += member.file_size
if total_size > MAX_EXTRACTED_BYTES:
raise UnsafeUpdateArchiveError("更新解压后超过安全大小限制")
target = destination.joinpath(*relative.parts)
if not target.resolve().is_relative_to(destination_resolved):
raise UnsafeUpdateArchiveError("更新压缩包路径越界")
if member.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with archive.open(member) as source, target.open("wb") as output:
while True:
self._raise_if_cancelled(is_cancelled)
block = source.read(1024 * 1024)
if not block:
break
output.write(block)
app_directory = destination / "app"
executable = app_directory / "CMAutoBuy.exe"
version_file = app_directory / "version.txt"
if not executable.is_file() or not version_file.is_file():
raise UnsafeUpdateArchiveError("更新包缺少主程序或 version.txt")
try:
packaged_version = version_file.read_text(encoding="utf-8-sig").strip()
except (OSError, UnicodeDecodeError) as exc:
raise UpdateIntegrityError("无法读取更新包版本") from exc
if packaged_version != expected_version:
raise UpdateIntegrityError("更新包版本与清单不一致")
return app_directory
@staticmethod
def _raise_if_cancelled(is_cancelled: Optional[Callable[[], bool]]) -> None:
if is_cancelled is not None and is_cancelled():
raise UpdateCancelled("更新操作已取消")
def _remove_path(self, path: Path) -> None:
if not path.exists():
return
resolved = path.resolve()
root = self._update_directory.resolve()
if resolved == root or not resolved.is_relative_to(root):
raise UpdateError(f"拒绝清理更新目录之外的路径:{resolved}")
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
@staticmethod
def _write_json_atomic(path: Path, value: dict) -> None:
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
os.replace(str(temporary), str(path))
def mark_current_version_healthy(update_directory: Optional[Path] = None) -> None:
"""主窗口成功创建后写健康标记,供 Launcher 判断新版本能否启动。"""
directory = update_directory or (data_dir() / "update")
pending = directory / "pending.json"
if not pending.is_file():
return
directory.mkdir(parents=True, exist_ok=True)
UpdateService._write_json_atomic(
directory / "healthy.json",
{"schema_version": 1, "version": __version__},
)