511 lines
19 KiB
Python
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__},
|
|
)
|