feat: 增加认证更新配置与自动检查 (#94)

This commit is contained in:
chengma
2026-08-10 14:31:44 +08:00
parent 48bbe1cc2c
commit dc375ff2af
14 changed files with 727 additions and 107 deletions
+21 -1
View File
@@ -16,6 +16,7 @@ from PyQt5.QtWidgets import (
QFormLayout, QFormLayout,
QHeaderView, QHeaderView,
QHBoxLayout, QHBoxLayout,
QLineEdit,
QSizePolicy, QSizePolicy,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
@@ -210,6 +211,7 @@ class SettingsPage(QWidget):
admin_gateway=None, admin_gateway=None,
android_device_service=None, android_device_service=None,
update_service=None, update_service=None,
update_credential_store=None,
): ):
super().__init__(parent) super().__init__(parent)
self.setObjectName("settingsPage") self.setObjectName("settingsPage")
@@ -224,6 +226,7 @@ class SettingsPage(QWidget):
admin_gateway=admin_gateway, admin_gateway=admin_gateway,
android_device_service=android_device_service, android_device_service=android_device_service,
update_service=update_service, update_service=update_service,
update_credential_store=update_credential_store,
) )
def _build_ui(self) -> None: def _build_ui(self) -> None:
@@ -301,10 +304,20 @@ class SettingsPage(QWidget):
) )
self.updateManifestUrlInput.setClearButtonEnabled(True) self.updateManifestUrlInput.setClearButtonEnabled(True)
self.updateManifestUrlInput.setAccessibleName("在线更新清单地址") self.updateManifestUrlInput.setAccessibleName("在线更新清单地址")
self.updateUsernameInput = LineEdit(self)
self.updateUsernameInput.setClearButtonEnabled(True)
self.updateUsernameInput.setAccessibleName("在线更新账号")
self.updatePasswordInput = LineEdit(self)
self.updatePasswordInput.setEchoMode(QLineEdit.Password)
self.updatePasswordInput.setClearButtonEnabled(True)
self.updatePasswordInput.setAccessibleName("在线更新密码")
self.updatePasswordInput.setPlaceholderText("请输入密码")
self.updateSaveButton = PushButton(FIF.SAVE, "保存", self)
self.updateSaveButton.setAccessibleName("安全保存在线更新设置")
self.updateCheckButton = PushButton(FIF.UPDATE, "检查更新", self) self.updateCheckButton = PushButton(FIF.UPDATE, "检查更新", self)
self.updateCheckButton.setAccessibleName("检查并下载软件更新") self.updateCheckButton.setAccessibleName("检查并下载软件更新")
self.updateStatusLabel = CaptionLabel( self.updateStatusLabel = CaptionLabel(
"尚未检查;请填写 HTTPS 更新清单地址", self "尚未保存更新账号和密码", self
) )
self.updateStatusLabel.setAccessibleName("软件更新状态") self.updateStatusLabel.setAccessibleName("软件更新状态")
self.updateStatusLabel.setWordWrap(True) self.updateStatusLabel.setWordWrap(True)
@@ -424,13 +437,20 @@ class SettingsPage(QWidget):
form.setVerticalSpacing(12) form.setVerticalSpacing(12)
versionLabel = CaptionLabel("当前版本", card) versionLabel = CaptionLabel("当前版本", card)
manifestLabel = CaptionLabel("清单地址", card) manifestLabel = CaptionLabel("清单地址", card)
usernameLabel = CaptionLabel("账号", card)
passwordLabel = CaptionLabel("密码", card)
manifestLabel.setBuddy(self.updateManifestUrlInput) manifestLabel.setBuddy(self.updateManifestUrlInput)
usernameLabel.setBuddy(self.updateUsernameInput)
passwordLabel.setBuddy(self.updatePasswordInput)
form.addRow(versionLabel, self.currentVersionLabel) form.addRow(versionLabel, self.currentVersionLabel)
form.addRow(manifestLabel, self.updateManifestUrlInput) form.addRow(manifestLabel, self.updateManifestUrlInput)
form.addRow(usernameLabel, self.updateUsernameInput)
form.addRow(passwordLabel, self.updatePasswordInput)
layout.addLayout(form) layout.addLayout(form)
commandLayout = QHBoxLayout() commandLayout = QHBoxLayout()
commandLayout.addStretch(1) commandLayout.addStretch(1)
commandLayout.addWidget(self.updateSaveButton)
commandLayout.addWidget(self.updateCheckButton) commandLayout.addWidget(self.updateCheckButton)
layout.addLayout(commandLayout) layout.addLayout(commandLayout)
return card return card
+2
View File
@@ -310,6 +310,7 @@ class SettingsPageEventBinder(QObject):
admin_gateway: Optional[ClientRegistrationGateway] = None, admin_gateway: Optional[ClientRegistrationGateway] = None,
android_device_service: Optional[AndroidDeviceService] = None, android_device_service: Optional[AndroidDeviceService] = None,
update_service=None, update_service=None,
update_credential_store=None,
): ):
super().__init__(page) super().__init__(page)
self._page = page self._page = page
@@ -349,6 +350,7 @@ class SettingsPageEventBinder(QObject):
page, page,
repository, repository,
service=update_service, service=update_service,
credential_store=update_credential_store,
parent=self, parent=self,
) )
self._client_service = CurrentClientService(repository) self._client_service = CurrentClientService(repository)
+86 -16
View File
@@ -6,6 +6,7 @@
from __future__ import annotations from __future__ import annotations
import base64
import hashlib import hashlib
import json import json
import os import os
@@ -16,7 +17,7 @@ import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import zipfile import zipfile
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import Callable, Optional from typing import Callable, Optional
@@ -28,6 +29,10 @@ MAX_MANIFEST_BYTES = 1024 * 1024
MAX_UPDATE_BYTES = 500 * 1024 * 1024 MAX_UPDATE_BYTES = 500 * 1024 * 1024
MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024 MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024
UPDATE_MANIFEST_SETTING = "update.manifest_url" UPDATE_MANIFEST_SETTING = "update.manifest_url"
UPDATE_USERNAME_SETTING = "update.username"
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*))?$") _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}$") _SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
@@ -56,6 +61,14 @@ class UpdateCancelled(UpdateError):
"""用户关闭页面后取消继续处理更新。""" """用户关闭页面后取消继续处理更新。"""
@dataclass(frozen=True)
class UpdateCredentials:
"""仅在内存中使用的更新服务器 Basic Authentication 凭据。"""
username: str
password: str = field(repr=False)
@dataclass(frozen=True) @dataclass(frozen=True)
class UpdateInfo: class UpdateInfo:
"""清单中一份可下载更新的信息。""" """清单中一份可下载更新的信息。"""
@@ -93,12 +106,25 @@ def parse_version(version: str) -> tuple[int, int, int, int]:
def validate_manifest_url(url: str) -> str: def validate_manifest_url(url: str) -> str:
"""验证并返回只允许 HTTPS、且不含凭据的清单地址。""" """验证更新地址;HTTP 只对白名单发布主机开放。"""
normalized = url.strip() normalized = url.strip()
parsed = urllib.parse.urlsplit(normalized) parsed = urllib.parse.urlsplit(normalized)
if parsed.scheme.lower() != "https" or not parsed.hostname: scheme = parsed.scheme.lower()
raise UpdateConfigurationError("更新清单地址必须是有效的 HTTPS 地址") 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: if parsed.username is not None or parsed.password is not None:
raise UpdateConfigurationError("更新清单地址不能包含账号或密码") raise UpdateConfigurationError("更新清单地址不能包含账号或密码")
if parsed.query: if parsed.query:
@@ -110,24 +136,54 @@ def validate_manifest_url(url: str) -> str:
def _origin(url: str) -> tuple[str, str, int]: def _origin(url: str) -> tuple[str, str, int]:
parsed = urllib.parse.urlsplit(url) parsed = urllib.parse.urlsplit(url)
scheme = parsed.scheme.lower()
return ( return (
parsed.scheme.lower(), scheme,
(parsed.hostname or "").lower(), (parsed.hostname or "").lower(),
parsed.port or 443, 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: class UpdateService:
"""检查并把更新安全暂存到 ``data/update``。""" """检查并把更新安全暂存到 ``data/update``。"""
def __init__( def __init__(
self, self,
update_directory: Optional[Path] = None, update_directory: Optional[Path] = None,
urlopen: Callable = urllib.request.urlopen, urlopen: Optional[Callable] = None,
timeout_seconds: float = 10.0, timeout_seconds: float = 10.0,
): ):
self._update_directory = update_directory or (data_dir() / "update") self._update_directory = update_directory or (data_dir() / "update")
self._urlopen = urlopen self._urlopen = urlopen or urllib.request.build_opener(
_SameOriginRedirectHandler()
).open
self._timeout_seconds = timeout_seconds self._timeout_seconds = timeout_seconds
@property @property
@@ -139,6 +195,7 @@ class UpdateService:
manifest_url: str, manifest_url: str,
current_version: str = __version__, current_version: str = __version__,
is_cancelled: Optional[Callable[[], bool]] = None, is_cancelled: Optional[Callable[[], bool]] = None,
credentials: Optional[UpdateCredentials] = None,
) -> UpdateCheckResult: ) -> UpdateCheckResult:
"""下载并解析清单,返回是否存在新版本。""" """下载并解析清单,返回是否存在新版本。"""
@@ -148,6 +205,7 @@ class UpdateService:
configured_url, configured_url,
MAX_MANIFEST_BYTES, MAX_MANIFEST_BYTES,
is_cancelled, is_cancelled,
credentials,
) )
try: try:
manifest = json.loads(content.decode("utf-8-sig")) manifest = json.loads(content.decode("utf-8-sig"))
@@ -168,6 +226,7 @@ class UpdateService:
info: UpdateInfo, info: UpdateInfo,
is_cancelled: Optional[Callable[[], bool]] = None, is_cancelled: Optional[Callable[[], bool]] = None,
on_progress: Optional[Callable[[int], None]] = None, on_progress: Optional[Callable[[int], None]] = None,
credentials: Optional[UpdateCredentials] = None,
) -> Path: ) -> Path:
"""下载、校验并安全解压更新,返回暂存的 ``app.new``。""" """下载、校验并安全解压更新,返回暂存的 ``app.new``。"""
@@ -184,10 +243,7 @@ class UpdateService:
bytes_written = 0 bytes_written = 0
try: try:
request = urllib.request.Request( request = self._make_request(info.update_url, credentials)
info.update_url,
headers={"User-Agent": f"CMAutoBuy/{__version__}"},
)
with self._open(request) as response: with self._open(request) as response:
final_url = validate_manifest_url(response.geturl()) final_url = validate_manifest_url(response.geturl())
if _origin(final_url) != _origin(info.manifest_url): if _origin(final_url) != _origin(info.manifest_url):
@@ -294,11 +350,9 @@ class UpdateService:
url: str, url: str,
maximum_bytes: int, maximum_bytes: int,
is_cancelled: Optional[Callable[[], bool]], is_cancelled: Optional[Callable[[], bool]],
credentials: Optional[UpdateCredentials],
) -> tuple[bytes, str]: ) -> tuple[bytes, str]:
request = urllib.request.Request( request = self._make_request(url, credentials)
url,
headers={"User-Agent": f"CMAutoBuy/{__version__}"},
)
with self._open(request) as response: with self._open(request) as response:
final_url = validate_manifest_url(response.geturl()) final_url = validate_manifest_url(response.geturl())
declared_size = self._content_length(response) declared_size = self._content_length(response)
@@ -317,6 +371,22 @@ class UpdateService:
chunks.append(block) chunks.append(block)
return b"".join(chunks), final_url 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): def _open(self, request):
try: try:
return self._urlopen(request, timeout=self._timeout_seconds) return self._urlopen(request, timeout=self._timeout_seconds)
+185 -45
View File
@@ -1,25 +1,34 @@
"""设置页在线更新事件和 Qt 后台 Worker。""" """设置页在线更新配置、事件和 Qt 后台 Worker。"""
from __future__ import annotations from __future__ import annotations
import sqlite3
from typing import Optional from typing import Optional
from PyQt5.QtCore import QObject, QThread, pyqtSignal, pyqtSlot from PyQt5.QtCore import QObject, QThread, QTimer, pyqtSignal, pyqtSlot
from qfluentwidgets import MessageBox from qfluentwidgets import MessageBox
from .settings_repository import SettingsRepository from .settings_repository import SettingsRepository
from .update_service import ( from .update_service import (
DEFAULT_UPDATE_MANIFEST_URL,
DEFAULT_UPDATE_USERNAME,
UPDATE_MANIFEST_SETTING, UPDATE_MANIFEST_SETTING,
UPDATE_USERNAME_SETTING,
UpdateCancelled, UpdateCancelled,
UpdateCheckResult, UpdateCheckResult,
UpdateCredentials,
UpdateInfo, UpdateInfo,
UpdateService, UpdateService,
validate_manifest_url, validate_manifest_url,
) )
from .windows_credential_store import (
CredentialStoreError,
WindowsCredentialStore,
)
class UpdateCheckWorker(QObject): class UpdateCheckWorker(QObject):
"""在线程中保存清单地址并检查新版本。""" """在线程中使用内存凭据检查新版本。"""
succeeded = pyqtSignal(object) succeeded = pyqtSignal(object)
failed = pyqtSignal(str) failed = pyqtSignal(str)
@@ -28,13 +37,13 @@ class UpdateCheckWorker(QObject):
def __init__( def __init__(
self, self,
service: UpdateService, service: UpdateService,
repository: SettingsRepository,
manifest_url: str, manifest_url: str,
credentials: UpdateCredentials,
): ):
super().__init__() super().__init__()
self._service = service self._service = service
self._repository = repository
self._manifest_url = manifest_url self._manifest_url = manifest_url
self._credentials = credentials
self._cancelled = False self._cancelled = False
def cancel(self) -> None: def cancel(self) -> None:
@@ -43,13 +52,10 @@ class UpdateCheckWorker(QObject):
@pyqtSlot() @pyqtSlot()
def run(self) -> None: def run(self) -> None:
try: try:
normalized_url = validate_manifest_url(self._manifest_url)
if self._cancelled:
return
self._repository.set(UPDATE_MANIFEST_SETTING, normalized_url)
result = self._service.check( result = self._service.check(
normalized_url, self._manifest_url,
is_cancelled=lambda: self._cancelled, is_cancelled=lambda: self._cancelled,
credentials=self._credentials,
) )
if not self._cancelled: if not self._cancelled:
self.succeeded.emit(result) self.succeeded.emit(result)
@@ -59,21 +65,28 @@ class UpdateCheckWorker(QObject):
if not self._cancelled: if not self._cancelled:
self.failed.emit(str(exc) or "检查更新失败") self.failed.emit(str(exc) or "检查更新失败")
finally: finally:
self._credentials = None
self.completed.emit() self.completed.emit()
class UpdateDownloadWorker(QObject): class UpdateDownloadWorker(QObject):
"""在线程中下载、校验并安全暂存更新。""" """在线程中认证下载、校验并安全暂存更新。"""
progressChanged = pyqtSignal(int) progressChanged = pyqtSignal(int)
succeeded = pyqtSignal(str) succeeded = pyqtSignal(str)
failed = pyqtSignal(str) failed = pyqtSignal(str)
completed = pyqtSignal() completed = pyqtSignal()
def __init__(self, service: UpdateService, update: UpdateInfo): def __init__(
self,
service: UpdateService,
update: UpdateInfo,
credentials: UpdateCredentials,
):
super().__init__() super().__init__()
self._service = service self._service = service
self._update = update self._update = update
self._credentials = credentials
self._cancelled = False self._cancelled = False
def cancel(self) -> None: def cancel(self) -> None:
@@ -86,6 +99,7 @@ class UpdateDownloadWorker(QObject):
self._update, self._update,
is_cancelled=lambda: self._cancelled, is_cancelled=lambda: self._cancelled,
on_progress=self.progressChanged.emit, on_progress=self.progressChanged.emit,
credentials=self._credentials,
) )
if not self._cancelled: if not self._cancelled:
self.succeeded.emit(self._update.version) self.succeeded.emit(self._update.version)
@@ -95,56 +109,159 @@ class UpdateDownloadWorker(QObject):
if not self._cancelled: if not self._cancelled:
self.failed.emit(str(exc) or "下载更新失败") self.failed.emit(str(exc) or "下载更新失败")
finally: finally:
self._credentials = None
self.completed.emit() self.completed.emit()
class UpdateUiEventBinder(QObject): class UpdateUiEventBinder(QObject):
"""管理设置页更新按钮、反馈和两个后台线程。""" """管理更新配置、保存反馈和两个后台线程。"""
def __init__( def __init__(
self, self,
page, page,
repository: SettingsRepository, repository: SettingsRepository,
service: Optional[UpdateService] = None, service: Optional[UpdateService] = None,
credential_store=None,
parent=None, parent=None,
): ):
super().__init__(parent or page) super().__init__(parent or page)
self._page = page self._page = page
self._repository = repository self._repository = repository
self._service = service or UpdateService() self._service = service or UpdateService()
self._credential_store_override = credential_store
self._closing = False self._closing = False
self._check_thread: Optional[QThread] = None self._check_thread: Optional[QThread] = None
self._check_worker: Optional[UpdateCheckWorker] = None self._check_worker: Optional[UpdateCheckWorker] = None
self._download_thread: Optional[QThread] = None self._download_thread: Optional[QThread] = None
self._download_worker: Optional[UpdateDownloadWorker] = None self._download_worker: Optional[UpdateDownloadWorker] = None
saved_url = repository.get(UPDATE_MANIFEST_SETTING, "") saved_url_record = repository.get_record(UPDATE_MANIFEST_SETTING)
page.updateManifestUrlInput.setText( saved_username_record = repository.get_record(UPDATE_USERNAME_SETTING)
saved_url if isinstance(saved_url, str) else "" saved_url = (
saved_url_record.value
if saved_url_record is not None and isinstance(saved_url_record.value, str)
else DEFAULT_UPDATE_MANIFEST_URL
) )
saved_username = (
saved_username_record.value
if saved_username_record is not None
and isinstance(saved_username_record.value, str)
else DEFAULT_UPDATE_USERNAME
)
page.updateManifestUrlInput.setText(saved_url)
page.updateUsernameInput.setText(saved_username)
page.updatePasswordInput.clear()
self._saved_url = saved_url if saved_url_record is not None else ""
self._saved_username = (
saved_username if saved_username_record is not None else ""
)
credential_ready = self._set_password_placeholder(saved_url)
page.updateSaveButton.clicked.connect(self.request_save)
page.updateCheckButton.clicked.connect(self.request_check) page.updateCheckButton.clicked.connect(self.request_check)
self._sync_button() self._sync_controls()
if self._saved_url and self._saved_username and credential_ready:
QTimer.singleShot(0, self.request_check)
def _credential_store_for(self, manifest_url: str):
if self._credential_store_override is not None:
return self._credential_store_override
return WindowsCredentialStore.for_url(manifest_url)
def _set_password_placeholder(self, manifest_url: str) -> bool:
try:
saved = self._credential_store_for(manifest_url).read()
except CredentialStoreError as exc:
self._page.updatePasswordInput.setPlaceholderText("凭据管理器不可用")
self._page.set_update_status(str(exc))
return False
if saved is None:
self._page.updatePasswordInput.setPlaceholderText("请输入密码")
return False
self._page.updatePasswordInput.setPlaceholderText(
"密码已安全保存;留空表示不修改"
)
return True
@pyqtSlot()
def request_save(self) -> None:
"""显式保存非敏感设置和 Windows 系统凭据。"""
if self._closing or self._is_busy():
return
manifest_url = self._page.updateManifestUrlInput.text().strip()
username = self._page.updateUsernameInput.text().strip()
password = self._page.updatePasswordInput.text()
self._page.updateManifestUrlInput.setText(manifest_url)
self._page.updateUsernameInput.setText(username)
try:
normalized_url = validate_manifest_url(manifest_url)
except Exception as exc:
self._page.set_update_status(f"保存失败:{exc}")
self._page.updateManifestUrlInput.setFocus()
return
if not username:
self._page.set_update_status("保存失败:更新账号不能为空")
self._page.updateUsernameInput.setFocus()
return
try:
credential_store = self._credential_store_for(normalized_url)
existing = credential_store.read()
if password:
credential_store.save(username, password)
elif existing is None:
self._page.set_update_status("保存失败:请填写更新密码")
self._page.updatePasswordInput.setFocus()
return
elif existing[0] != username:
self._page.set_update_status("账号已修改,请重新填写密码后保存")
self._page.updatePasswordInput.setFocus()
return
self._repository.set_many(
{
UPDATE_MANIFEST_SETTING: normalized_url,
UPDATE_USERNAME_SETTING: username,
}
)
except (CredentialStoreError, OSError, sqlite3.Error, ValueError) as exc:
self._page.set_update_status(f"保存失败:{exc}")
return
self._saved_url = normalized_url
self._saved_username = username
self._page.updatePasswordInput.clear()
self._set_password_placeholder(normalized_url)
self._page.set_update_status("更新设置已安全保存")
@pyqtSlot() @pyqtSlot()
def request_check(self) -> None: def request_check(self) -> None:
if self._closing or self._check_thread is not None or self._download_thread is not None: if self._closing or self._is_busy():
return return
manifest_url = self._page.updateManifestUrlInput.text().strip() manifest_url = self._page.updateManifestUrlInput.text().strip()
self._page.updateManifestUrlInput.setText(manifest_url) username = self._page.updateUsernameInput.text().strip()
try: if not self._saved_url or not self._saved_username:
validate_manifest_url(manifest_url) self._page.set_update_status("尚未保存更新账号和密码,请填写后保存")
except Exception as exc: self._page.updatePasswordInput.setFocus()
self._page.set_update_status(f"无法检查:{exc}") return
self._page.updateManifestUrlInput.setFocus() if (
manifest_url != self._saved_url
or username != self._saved_username
or self._page.updatePasswordInput.text()
):
self._page.set_update_status("更新设置有修改,请先保存后再检查")
self._page.updateSaveButton.setFocus()
return
credentials = self._read_credentials(username)
if credentials is None:
return return
self._page.set_update_status("正在检查新版本…") self._page.set_update_status("正在检查新版本…")
thread = QThread(self) thread = QThread(self)
worker = UpdateCheckWorker( worker = UpdateCheckWorker(self._service, manifest_url, credentials)
self._service,
self._repository,
manifest_url,
)
worker.moveToThread(thread) worker.moveToThread(thread)
thread.started.connect(worker.run) thread.started.connect(worker.run)
worker.succeeded.connect(self._on_check_succeeded) worker.succeeded.connect(self._on_check_succeeded)
@@ -155,9 +272,26 @@ class UpdateUiEventBinder(QObject):
thread.finished.connect(thread.deleteLater) thread.finished.connect(thread.deleteLater)
self._check_thread = thread self._check_thread = thread
self._check_worker = worker self._check_worker = worker
self._sync_button() self._sync_controls()
thread.start() thread.start()
def _read_credentials(self, expected_username: str) -> Optional[UpdateCredentials]:
try:
saved = self._credential_store_for(self._saved_url).read()
except CredentialStoreError as exc:
self._page.set_update_status(f"无法读取更新密码:{exc}")
return None
if saved is None:
self._page.set_update_status("尚未保存更新密码,请填写后保存")
self._page.updatePasswordInput.setFocus()
return None
username, password = saved
if username != expected_username:
self._page.set_update_status("保存的账号不一致,请重新填写密码并保存")
self._page.updatePasswordInput.setFocus()
return None
return UpdateCredentials(username, password)
@pyqtSlot(object) @pyqtSlot(object)
def _on_check_succeeded(self, result: UpdateCheckResult) -> None: def _on_check_succeeded(self, result: UpdateCheckResult) -> None:
if self._closing: if self._closing:
@@ -181,9 +315,7 @@ class UpdateUiEventBinder(QObject):
dialog.cancelButton.setText("暂不下载") dialog.cancelButton.setText("暂不下载")
dialog.cancelButton.setFocus() dialog.cancelButton.setFocus()
if not dialog.exec(): if not dialog.exec():
self._page.set_update_status( self._page.set_update_status(f"发现新版本 {update.version},尚未下载")
f"发现新版本 {update.version},尚未下载"
)
return return
self._start_download(update) self._start_download(update)
@@ -191,7 +323,7 @@ class UpdateUiEventBinder(QObject):
def _on_check_failed(self, message: str) -> None: def _on_check_failed(self, message: str) -> None:
if not self._closing: if not self._closing:
self._page.set_update_status( self._page.set_update_status(
f"检查失败:{message};地址已保留,可以重试" f"检查失败:{message};设置已保留,可以重试"
) )
@pyqtSlot() @pyqtSlot()
@@ -199,14 +331,17 @@ class UpdateUiEventBinder(QObject):
self._check_worker = None self._check_worker = None
self._check_thread = None self._check_thread = None
if not self._closing: if not self._closing:
self._sync_button() self._sync_controls()
def _start_download(self, update: UpdateInfo) -> None: def _start_download(self, update: UpdateInfo) -> None:
if self._closing or self._download_thread is not None: if self._closing or self._download_thread is not None:
return return
credentials = self._read_credentials(self._saved_username)
if credentials is None:
return
self._page.set_update_status(f"正在下载版本 {update.version}(0%)…") self._page.set_update_status(f"正在下载版本 {update.version}(0%)…")
thread = QThread(self) thread = QThread(self)
worker = UpdateDownloadWorker(self._service, update) worker = UpdateDownloadWorker(self._service, update, credentials)
worker.moveToThread(thread) worker.moveToThread(thread)
thread.started.connect(worker.run) thread.started.connect(worker.run)
worker.progressChanged.connect(self._on_download_progress) worker.progressChanged.connect(self._on_download_progress)
@@ -218,7 +353,7 @@ class UpdateUiEventBinder(QObject):
thread.finished.connect(thread.deleteLater) thread.finished.connect(thread.deleteLater)
self._download_thread = thread self._download_thread = thread
self._download_worker = worker self._download_worker = worker
self._sync_button() self._sync_controls()
thread.start() thread.start()
@pyqtSlot(int) @pyqtSlot(int)
@@ -245,16 +380,22 @@ class UpdateUiEventBinder(QObject):
self._download_worker = None self._download_worker = None
self._download_thread = None self._download_thread = None
if not self._closing: if not self._closing:
self._sync_button() self._sync_controls()
def _sync_button(self) -> None: def _is_busy(self) -> bool:
busy = self._check_thread is not None or self._download_thread is not None return self._check_thread is not None or self._download_thread is not None
self._page.updateCheckButton.setEnabled(not self._closing and not busy)
self._page.updateManifestUrlInput.setEnabled(not self._closing and not busy) def _sync_controls(self) -> None:
enabled = not self._closing and not self._is_busy()
self._page.updateSaveButton.setEnabled(enabled)
self._page.updateCheckButton.setEnabled(enabled)
self._page.updateManifestUrlInput.setEnabled(enabled)
self._page.updateUsernameInput.setEnabled(enabled)
self._page.updatePasswordInput.setEnabled(enabled)
@pyqtSlot() @pyqtSlot()
def shutdown(self) -> None: def shutdown(self) -> None:
"""取消后续处理,断开业务结果并短暂等待线程退出。""" """取消后续处理,断开业务结果并等待线程安全退出。"""
if self._closing: if self._closing:
return return
@@ -290,6 +431,5 @@ class UpdateUiEventBinder(QObject):
pass pass
if thread is not None and thread.isRunning(): if thread is not None and thread.isRunning():
thread.quit() thread.quit()
# 网络读超时是 10 秒;多等 1 秒,避免关闭窗口时销毁仍在运行的 QThread。
thread.wait(11000) thread.wait(11000)
self._sync_button() self._sync_controls()
+1 -1
View File
@@ -3,4 +3,4 @@
发布脚本和程序界面需要版本号时都从这里读取,避免多个文件各写一份。 发布脚本和程序界面需要版本号时都从这里读取,避免多个文件各写一份。
""" """
__version__ = "0.2.0" __version__ = "0.2.1"
+168
View File
@@ -0,0 +1,168 @@
"""使用 Windows 凭据管理器保存在线更新密码。
密码只在调用期间存在于内存,不进入 SQLite、日志或普通错误信息。本模块只使用
Windows 自带 Credential API,不增加第三方依赖。
"""
from __future__ import annotations
import ctypes
import sys
from ctypes import wintypes
from typing import Optional
from urllib.parse import urlsplit
UPDATE_CREDENTIAL_TARGET_PREFIX = "CMAutoBuy/update"
_CRED_TYPE_GENERIC = 1
_CRED_PERSIST_LOCAL_MACHINE = 2
_ERROR_NOT_FOUND = 1168
class CredentialStoreError(RuntimeError):
"""Windows 凭据管理器操作失败。"""
class _CredentialW(ctypes.Structure):
_fields_ = [
("Flags", wintypes.DWORD),
("Type", wintypes.DWORD),
("TargetName", wintypes.LPWSTR),
("Comment", wintypes.LPWSTR),
("LastWritten", wintypes.FILETIME),
("CredentialBlobSize", wintypes.DWORD),
("CredentialBlob", ctypes.POINTER(ctypes.c_ubyte)),
("Persist", wintypes.DWORD),
("AttributeCount", wintypes.DWORD),
("Attributes", wintypes.LPVOID),
("TargetAlias", wintypes.LPWSTR),
("UserName", wintypes.LPWSTR),
]
class WindowsCredentialStore:
"""读写当前 Windows 用户的通用凭据。"""
def __init__(self, target: str):
self._target = target
if sys.platform != "win32":
self._api = None
return
api = ctypes.WinDLL("Advapi32.dll", use_last_error=True)
api.CredWriteW.argtypes = [ctypes.POINTER(_CredentialW), wintypes.DWORD]
api.CredWriteW.restype = wintypes.BOOL
api.CredReadW.argtypes = [
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.DWORD,
ctypes.POINTER(ctypes.POINTER(_CredentialW)),
]
api.CredReadW.restype = wintypes.BOOL
api.CredDeleteW.argtypes = [
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.DWORD,
]
api.CredDeleteW.restype = wintypes.BOOL
api.CredFree.argtypes = [wintypes.LPVOID]
api.CredFree.restype = None
self._api = api
@property
def target(self) -> str:
"""返回不含密码的系统凭据目标名。"""
return self._target
@classmethod
def for_url(cls, url: str) -> "WindowsCredentialStore":
"""按 URL 源创建独立凭据,避免把一个服务器密码发送给另一个服务器。"""
parsed = urlsplit(url)
scheme = parsed.scheme.lower()
host = (parsed.hostname or "").lower()
if not scheme or not host:
raise CredentialStoreError("无法为无效更新地址保存密码")
port = parsed.port or (443 if scheme == "https" else 80)
return cls(f"{UPDATE_CREDENTIAL_TARGET_PREFIX}/{scheme}/{host}/{port}")
def save(self, username: str, password: str) -> None:
"""保存账号和密码;密码为空时拒绝覆盖已有凭据。"""
api = self._require_windows()
normalized_username = username.strip()
if not normalized_username:
raise CredentialStoreError("更新账号不能为空")
if not password:
raise CredentialStoreError("更新密码不能为空")
password_bytes = password.encode("utf-16-le")
if len(password_bytes) > 2560:
raise CredentialStoreError("更新密码过长")
blob = ctypes.create_string_buffer(password_bytes)
credential = _CredentialW()
credential.Type = _CRED_TYPE_GENERIC
credential.TargetName = self._target
credential.CredentialBlobSize = len(password_bytes)
credential.CredentialBlob = ctypes.cast(
blob,
ctypes.POINTER(ctypes.c_ubyte),
)
credential.Persist = _CRED_PERSIST_LOCAL_MACHINE
credential.UserName = normalized_username
if not api.CredWriteW(ctypes.byref(credential), 0):
raise self._system_error("保存更新凭据失败")
def read(self) -> Optional[tuple[str, str]]:
"""返回保存的 ``(账号, 密码)``;不存在时返回 ``None``。"""
api = self._require_windows()
pointer = ctypes.POINTER(_CredentialW)()
if not api.CredReadW(
self._target,
_CRED_TYPE_GENERIC,
0,
ctypes.byref(pointer),
):
error_code = ctypes.get_last_error()
if error_code == _ERROR_NOT_FOUND:
return None
raise self._system_error("读取更新凭据失败", error_code)
try:
credential = pointer.contents
password_bytes = ctypes.string_at(
credential.CredentialBlob,
credential.CredentialBlobSize,
)
password = password_bytes.decode("utf-16-le")
return credential.UserName or "", password
except (UnicodeDecodeError, ValueError) as exc:
raise CredentialStoreError("保存的更新凭据无法读取,请重新保存") from exc
finally:
api.CredFree(pointer)
def exists(self) -> bool:
"""返回是否已经保存更新凭据。"""
return self.read() is not None
def delete(self) -> bool:
"""删除更新凭据;原本不存在时返回 ``False``。"""
api = self._require_windows()
if api.CredDeleteW(self._target, _CRED_TYPE_GENERIC, 0):
return True
error_code = ctypes.get_last_error()
if error_code == _ERROR_NOT_FOUND:
return False
raise self._system_error("删除更新凭据失败", error_code)
def _require_windows(self):
if self._api is None:
raise CredentialStoreError("更新密码只能保存到 Windows 凭据管理器")
return self._api
@staticmethod
def _system_error(message: str, error_code: Optional[int] = None):
code = ctypes.get_last_error() if error_code is None else error_code
return CredentialStoreError(f"{message}(Windows 错误 {code})")
+62 -1
View File
@@ -1,18 +1,23 @@
"""在线更新清单、下载校验和安全解压测试。""" """在线更新清单、下载校验和安全解压测试。"""
import base64
import hashlib import hashlib
import io import io
import json import json
import tempfile import tempfile
import unittest import unittest
import urllib.request
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from src.update_service import ( from src.update_service import (
DEFAULT_UPDATE_MANIFEST_URL,
UnsafeUpdateArchiveError, UnsafeUpdateArchiveError,
UpdateConfigurationError, UpdateConfigurationError,
UpdateIntegrityError, UpdateIntegrityError,
UpdateCredentials,
UpdateService, UpdateService,
_SameOriginRedirectHandler,
mark_current_version_healthy, mark_current_version_healthy,
parse_version, parse_version,
validate_manifest_url, validate_manifest_url,
@@ -41,9 +46,11 @@ class FakeUrlOpen:
def __init__(self, responses): def __init__(self, responses):
self.responses = responses self.responses = responses
self.requested_urls = [] self.requested_urls = []
self.requested_headers = []
def __call__(self, request, timeout): def __call__(self, request, timeout):
self.requested_urls.append((request.full_url, timeout)) self.requested_urls.append((request.full_url, timeout))
self.requested_headers.append(dict(request.header_items()))
content, final_url = self.responses[request.full_url] content, final_url = self.responses[request.full_url]
return FakeResponse(content, final_url, len(content)) return FakeResponse(content, final_url, len(content))
@@ -107,6 +114,10 @@ class UpdateServiceTest(unittest.TestCase):
validate_manifest_url(self.manifest_url), validate_manifest_url(self.manifest_url),
self.manifest_url, self.manifest_url,
) )
self.assertEqual(
validate_manifest_url(DEFAULT_UPDATE_MANIFEST_URL),
DEFAULT_UPDATE_MANIFEST_URL,
)
for invalid in ( for invalid in (
"http://updates.example.test/manifest.json", "http://updates.example.test/manifest.json",
"https://user:pass@updates.example.test/manifest.json", "https://user:pass@updates.example.test/manifest.json",
@@ -118,6 +129,56 @@ class UpdateServiceTest(unittest.TestCase):
): ):
validate_manifest_url(invalid) validate_manifest_url(invalid)
def test_basic_auth_is_used_for_manifest_and_update_without_repr_leak(self):
update_content = make_update_zip()
service, opener = self.service_with(update_content)
credentials = UpdateCredentials("release-reader", "unit-test-password")
result = service.check(
self.manifest_url,
"0.1.0",
credentials=credentials,
)
service.download_and_stage(result.update, credentials=credentials)
expected = "Basic " + base64.b64encode(
b"release-reader:unit-test-password"
).decode("ascii")
self.assertEqual(len(opener.requested_headers), 2)
self.assertTrue(
all(
headers.get("Authorization") == expected
for headers in opener.requested_headers
)
)
self.assertNotIn("unit-test-password", repr(credentials))
def test_default_unicode_manifest_path_is_encoded_for_http_request(self):
request = UpdateService._make_request(
DEFAULT_UPDATE_MANIFEST_URL,
UpdateCredentials("release-reader", "unit-test-password"),
)
self.assertNotIn("——", request.full_url)
self.assertIn("%E2%80%94%E2%80%94", request.full_url)
def test_authenticated_redirect_cannot_change_origin(self):
handler = _SameOriginRedirectHandler()
request = urllib.request.Request(
"https://updates.example.test/manifest.json",
headers={"Authorization": "Basic test"},
)
with self.assertRaises(UpdateConfigurationError):
handler.redirect_request(
request,
None,
302,
"Found",
{},
"https://other.example.test/manifest.json",
)
def test_check_reports_newer_and_current_versions(self): def test_check_reports_newer_and_current_versions(self):
update_content = make_update_zip() update_content = make_update_zip()
service, _opener = self.service_with(update_content) service, _opener = self.service_with(update_content)
@@ -196,7 +257,7 @@ class UpdateServiceTest(unittest.TestCase):
health = json.loads( health = json.loads(
(self.update_directory / "healthy.json").read_text(encoding="utf-8") (self.update_directory / "healthy.json").read_text(encoding="utf-8")
) )
self.assertEqual(health["version"], "0.2.0") self.assertEqual(health["version"], "0.2.1")
if __name__ == "__main__": if __name__ == "__main__":
+145 -28
View File
@@ -1,4 +1,4 @@
"""设置页在线更新 UI 和后台线程测试。""" """设置页认证更新配置、自动检查和后台线程测试。"""
import os import os
import tempfile import tempfile
@@ -11,38 +11,72 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt5.QtCore import QTimer from PyQt5.QtCore import QTimer
from PyQt5.QtTest import QTest from PyQt5.QtTest import QTest
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication, QLineEdit
from src.mock_admin_gateway import MockAdminGateway from src.mock_admin_gateway import MockAdminGateway
from src.settings_repository import SettingsRepository from src.settings_repository import SettingsRepository
from src.settings_ui import SettingsPage from src.settings_ui import SettingsPage
from src.update_service import ( from src.update_service import (
DEFAULT_UPDATE_MANIFEST_URL,
DEFAULT_UPDATE_USERNAME,
UPDATE_MANIFEST_SETTING, UPDATE_MANIFEST_SETTING,
UPDATE_USERNAME_SETTING,
UpdateCheckResult, UpdateCheckResult,
UpdateInfo, UpdateInfo,
) )
MANIFEST_URL = "https://updates.example.test/autobuy%E2%80%94%E2%80%94manifest.json" MANIFEST_URL = "https://updates.example.test/autobuy%E2%80%94%E2%80%94manifest.json"
TEST_USERNAME = "release-reader"
TEST_PASSWORD = "unit-test-password"
class FakeCredentialStore:
def __init__(self, saved=None):
self.saved = saved
self.save_count = 0
def read(self):
return self.saved
def save(self, username, password):
self.saved = (username, password)
self.save_count += 1
class FakeUpdateService: class FakeUpdateService:
def __init__(self, result=None, delay=0.0, download_error=None): def __init__(self, result=None, delay=0.0, download_error=None):
self.result = result or UpdateCheckResult("0.2.0", "0.2.0", False) self.result = result or UpdateCheckResult("0.2.1", "0.2.1", False)
self.delay = delay self.delay = delay
self.download_error = download_error self.download_error = download_error
self.check_count = 0 self.check_count = 0
self.download_count = 0 self.download_count = 0
self.check_credentials = None
self.download_credentials = None
def check(self, manifest_url, current_version="0.1.0", is_cancelled=None): def check(
self,
manifest_url,
current_version="0.2.1",
is_cancelled=None,
credentials=None,
):
self.check_count += 1 self.check_count += 1
self.manifest_url = manifest_url self.manifest_url = manifest_url
self.check_credentials = credentials
if self.delay: if self.delay:
time.sleep(self.delay) time.sleep(self.delay)
return self.result return self.result
def download_and_stage(self, update, is_cancelled=None, on_progress=None): def download_and_stage(
self,
update,
is_cancelled=None,
on_progress=None,
credentials=None,
):
self.download_count += 1 self.download_count += 1
self.download_credentials = credentials
if on_progress is not None: if on_progress is not None:
on_progress(50) on_progress(50)
if self.download_error is not None: if self.download_error is not None:
@@ -76,18 +110,19 @@ class UpdateUiEventTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.temporary_directory = tempfile.TemporaryDirectory() self.temporary_directory = tempfile.TemporaryDirectory()
self.repository = SettingsRepository( self.db_path = Path(self.temporary_directory.name) / "client.db"
Path(self.temporary_directory.name) / "client.db" self.repository = SettingsRepository(self.db_path)
) self.credential_store = FakeCredentialStore()
def tearDown(self): def tearDown(self):
self.temporary_directory.cleanup() self.temporary_directory.cleanup()
def _page(self, service): def _page(self, service, credential_store=None):
return SettingsPage( return SettingsPage(
settings_repository=self.repository, settings_repository=self.repository,
admin_gateway=MockAdminGateway(), admin_gateway=MockAdminGateway(),
update_service=service, update_service=service,
update_credential_store=credential_store or self.credential_store,
) )
def _wait_until(self, predicate, timeout_ms=2000): def _wait_until(self, predicate, timeout_ms=2000):
@@ -97,24 +132,86 @@ class UpdateUiEventTest(unittest.TestCase):
elapsed += 10 elapsed += 10
self.assertTrue(predicate(), "等待在线更新线程超时") self.assertTrue(predicate(), "等待在线更新线程超时")
def test_update_card_loads_saved_url_and_current_version(self): def _save_configuration(self, page):
self.repository.set(UPDATE_MANIFEST_SETTING, MANIFEST_URL) page.updateManifestUrlInput.setText(MANIFEST_URL)
page.updateUsernameInput.setText(TEST_USERNAME)
page.updatePasswordInput.setText(TEST_PASSWORD)
page.updateSaveButton.click()
def test_update_card_uses_safe_defaults_and_password_input(self):
page = self._page(FakeUpdateService()) page = self._page(FakeUpdateService())
self.assertEqual(page.currentVersionLabel.text(), "0.2.0") self.assertEqual(page.currentVersionLabel.text(), "0.2.1")
self.assertEqual(page.updateManifestUrlInput.text(), MANIFEST_URL) self.assertEqual(
self.assertTrue(page.updateCheckButton.isEnabled()) page.updateManifestUrlInput.text(), DEFAULT_UPDATE_MANIFEST_URL
)
self.assertEqual(page.updateUsernameInput.text(), DEFAULT_UPDATE_USERNAME)
self.assertEqual(page.updatePasswordInput.text(), "")
self.assertEqual(page.updatePasswordInput.echoMode(), QLineEdit.Password)
self.assertTrue(page.updateSaveButton.isEnabled())
page.eventBinder.shutdown() page.eventBinder.shutdown()
page.deleteLater() page.deleteLater()
def test_http_url_is_rejected_before_starting_worker(self): def test_save_uses_sqlite_for_non_secret_and_credential_store_for_password(self):
page = self._page(FakeUpdateService())
self._save_configuration(page)
self.assertEqual(
self.repository.get(UPDATE_MANIFEST_SETTING), MANIFEST_URL
)
self.assertEqual(self.repository.get(UPDATE_USERNAME_SETTING), TEST_USERNAME)
self.assertEqual(
self.credential_store.saved,
(TEST_USERNAME, TEST_PASSWORD),
)
self.assertNotIn(TEST_PASSWORD.encode(), self.db_path.read_bytes())
self.assertEqual(page.updatePasswordInput.text(), "")
self.assertIn("已安全保存", page.updatePasswordInput.placeholderText())
page.eventBinder.shutdown()
page.deleteLater()
def test_blank_password_preserves_existing_credential(self):
self.repository.set_many(
{
UPDATE_MANIFEST_SETTING: MANIFEST_URL,
UPDATE_USERNAME_SETTING: TEST_USERNAME,
}
)
store = FakeCredentialStore((TEST_USERNAME, TEST_PASSWORD))
page = self._page(FakeUpdateService(), store)
page.updateSaveButton.click()
self.assertEqual(store.save_count, 0)
self.assertEqual(store.saved, (TEST_USERNAME, TEST_PASSWORD))
self.assertIn("安全保存", page.updateStatusLabel.text())
page.eventBinder.shutdown()
page.deleteLater()
def test_missing_password_stops_save_and_check(self):
service = FakeUpdateService() service = FakeUpdateService()
page = self._page(service) page = self._page(service)
page.updateManifestUrlInput.setText("http://updates.example.test/manifest.json")
page.updateSaveButton.click()
page.updateCheckButton.click() page.updateCheckButton.click()
self.assertIn("必须是有效的 HTTPS", page.updateStatusLabel.text()) self.assertIn("密码", page.updateStatusLabel.text())
self.assertEqual(service.check_count, 0)
page.eventBinder.shutdown()
page.deleteLater()
def test_unapproved_http_url_is_rejected_when_saving(self):
service = FakeUpdateService()
page = self._page(service)
page.updateManifestUrlInput.setText(
"http://updates.example.test/manifest.json"
)
page.updatePasswordInput.setText(TEST_PASSWORD)
page.updateSaveButton.click()
self.assertIn("HTTP 只允许固定发布服务器", page.updateStatusLabel.text())
self.assertEqual(service.check_count, 0) self.assertEqual(service.check_count, 0)
page.eventBinder.shutdown() page.eventBinder.shutdown()
page.deleteLater() page.deleteLater()
@@ -122,7 +219,7 @@ class UpdateUiEventTest(unittest.TestCase):
def test_slow_check_does_not_block_or_start_twice(self): def test_slow_check_does_not_block_or_start_twice(self):
service = FakeUpdateService(delay=0.08) service = FakeUpdateService(delay=0.08)
page = self._page(service) page = self._page(service)
page.updateManifestUrlInput.setText(MANIFEST_URL) self._save_configuration(page)
timer_fired = [] timer_fired = []
QTimer.singleShot(10, lambda: timer_fired.append(True)) QTimer.singleShot(10, lambda: timer_fired.append(True))
@@ -134,27 +231,46 @@ class UpdateUiEventTest(unittest.TestCase):
) )
self.assertEqual(service.check_count, 1) self.assertEqual(service.check_count, 1)
self.assertEqual( self.assertEqual(service.check_credentials.username, TEST_USERNAME)
self.repository.get(UPDATE_MANIFEST_SETTING), MANIFEST_URL self.assertEqual(service.check_credentials.password, TEST_PASSWORD)
)
self.assertIn("当前已是最新版本", page.updateStatusLabel.text()) self.assertIn("当前已是最新版本", page.updateStatusLabel.text())
page.eventBinder.shutdown() page.eventBinder.shutdown()
page.deleteLater() page.deleteLater()
def test_confirmed_new_version_downloads_and_stages(self): def test_saved_configuration_checks_automatically_after_startup(self):
self.repository.set_many(
{
UPDATE_MANIFEST_SETTING: MANIFEST_URL,
UPDATE_USERNAME_SETTING: TEST_USERNAME,
}
)
store = FakeCredentialStore((TEST_USERNAME, TEST_PASSWORD))
service = FakeUpdateService()
page = self._page(service, store)
self._wait_until(lambda: service.check_count == 1)
self._wait_until(
lambda: page.eventBinder.updateEventBinder._check_thread is None
)
self.assertIn("当前已是最新版本", page.updateStatusLabel.text())
page.eventBinder.shutdown()
page.deleteLater()
def test_confirmed_new_version_downloads_with_saved_credentials(self):
update = UpdateInfo( update = UpdateInfo(
version="0.2.0", version="0.3.0",
manifest_url=MANIFEST_URL, manifest_url=MANIFEST_URL,
update_url="https://updates.example.test/CMAutoBuy-0.2.0-update.zip", update_url="https://updates.example.test/CMAutoBuy-0.3.0-update.zip",
file_name="CMAutoBuy-0.2.0-update.zip", file_name="CMAutoBuy-0.3.0-update.zip",
size=1024, size=1024,
sha256="0" * 64, sha256="0" * 64,
) )
service = FakeUpdateService( service = FakeUpdateService(
UpdateCheckResult("0.1.0", "0.2.0", True, update) UpdateCheckResult("0.2.1", "0.3.0", True, update)
) )
page = self._page(service) page = self._page(service)
page.updateManifestUrlInput.setText(MANIFEST_URL) self._save_configuration(page)
with patch("src.update_ui_event.MessageBox", AcceptDownloadMessageBox): with patch("src.update_ui_event.MessageBox", AcceptDownloadMessageBox):
page.updateCheckButton.click() page.updateCheckButton.click()
@@ -164,8 +280,9 @@ class UpdateUiEventTest(unittest.TestCase):
) )
self.assertEqual(service.download_count, 1) self.assertEqual(service.download_count, 1)
self.assertEqual(service.download_credentials.username, TEST_USERNAME)
self.assertEqual(service.download_credentials.password, TEST_PASSWORD)
self.assertIn("已准备好", page.updateStatusLabel.text()) self.assertIn("已准备好", page.updateStatusLabel.text())
self.assertIn("重新启动", page.updateStatusLabel.text())
page.eventBinder.shutdown() page.eventBinder.shutdown()
page.deleteLater() page.deleteLater()
@@ -0,0 +1,26 @@
"""Windows 更新凭据按服务器隔离的测试。"""
import unittest
from src.windows_credential_store import WindowsCredentialStore
class WindowsCredentialStoreTest(unittest.TestCase):
def test_different_origins_use_different_credential_targets(self):
http_store = WindowsCredentialStore.for_url(
"http://cm.xiapi.com/autobuy——manifest.json"
)
https_store = WindowsCredentialStore.for_url(
"https://cm.xiapi.com/autobuy——manifest.json"
)
other_store = WindowsCredentialStore.for_url(
"https://updates.example.test/manifest.json"
)
self.assertNotEqual(http_store.target, https_store.target)
self.assertNotEqual(https_store.target, other_store.target)
self.assertNotIn("@", http_store.target)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -84,6 +84,7 @@ Admin 想知道某台 Client 是不是卡死了,用"领取后超时重派"就
| 模态对话框 | 弹出来必须先处理它、否则动不了主窗口的窗口。**很打断人,只在必须做决定时用** | 高危确认 | | 模态对话框 | 弹出来必须先处理它、否则动不了主窗口的窗口。**很打断人,只在必须做决定时用** | 高危确认 |
| DPI / 显示缩放 | Windows 里"文字和应用的大小"那个百分比设置 | 需验证 100%~200% | | DPI / 显示缩放 | Windows 里"文字和应用的大小"那个百分比设置 | 需验证 100%~200% |
| 高对比度 | Windows 的一种无障碍主题,颜色极简 | 需验证界面不能只靠颜色区分状态 | | 高对比度 | Windows 的一种无障碍主题,颜色极简 | 需验证界面不能只靠颜色区分状态 |
| Windows 凭据管理器 | Windows 专门保存密码等秘密的系统存储,普通 SQLite 和日志不应代替它 | 保存在线更新密码,界面不回填明文 |
## 4. 自动化和安全 ## 4. 自动化和安全
+8 -6
View File
@@ -134,7 +134,7 @@ Client 应执行:
2. **Android 设备:**ADB 地址、拼多多包名、设备测试连接。 2. **Android 设备:**ADB 地址、拼多多包名、设备测试连接。
3. **自动化:**轮询周期、任务超时、最大重试次数和演练模式。 3. **自动化:**轮询周期、任务超时、最大重试次数和演练模式。
4. **安全与诊断:**价格允许偏差、最大购买数量、日志目录、截图/XML 保留周期。 4. **安全与诊断:**价格允许偏差、最大购买数量、日志目录、截图/XML 保留周期。
5. **软件更新:**当前版本、HTTPS 更新清单地址、检查按钮和稳定状态文字。 5. **软件更新:**当前版本、清单地址、账号、密码、保存/检查按钮和稳定状态文字。
密码和访问令牌不得以明文写入普通 SQLite 设置或日志。 密码和访问令牌不得以明文写入普通 SQLite 设置或日志。
@@ -214,13 +214,15 @@ CMAutoBuy/ 整个文件夹拷到任何机器都能用
`autobuy——manifest.json`。清单至少记录版本、文件名、字节大小和 SHA256;SHA256 `autobuy——manifest.json`。清单至少记录版本、文件名、字节大小和 SHA256;SHA256
用于发现下载损坏,不等同于发布者身份认证。 用于发现下载损坏,不等同于发布者身份认证。
**在线更新:**操作人员在设置页填写完整 HTTPS 清单地址并主动检查。发现新版本后 **在线更新:**设置页默认显示已确认的发布清单地址和账号,密码由操作人员首次
必须由用户确认下载;下载、校验和安全解压在后台线程完成,只写入 填写并保存到 Windows 凭据管理器;URL 和账号作为非敏感设置保存在 SQLite,密码
不回填明文。保存完整配置后,Client 启动时在后台检查一次,也可手动检查。发现
新版本后必须由用户确认下载;下载、校验和安全解压在后台线程完成,只写入
`data/update/`。程序不会强制退出,用户下次通过 `Launcher.exe` 启动时才替换 `data/update/`。程序不会强制退出,用户下次通过 `Launcher.exe` 启动时才替换
`app/`。Launcher 保留一份 `app.old/`,目录移动失败或新版本没有写入健康标记时 `app/`。Launcher 保留一份 `app.old/`,目录移动失败或新版本没有写入健康标记时
恢复旧版本。更新包必须与清单同源;重定向后仍必须是 HTTPS;不允许忽略 TLS 恢复旧版本。更新包必须与清单同源且重定向不得跨源。一般地址只允许 HTTPS;当前
证书错误,也不在更新地址中保存账号密码。 固定发布主机按已确认方案临时允许默认端口 HTTP 和 Basic Authentication。URL
更新地址还不得包含查询参数或片段,避免 token 一类凭据被持久化。 不得包含账号密码、查询参数或片段,认证密码不得写入源码、Git、SQLite 或日志。
`0.1.0` 的 Launcher 没有应用在线更新的能力,因此第一次升级到带在线更新的 `0.1.0` 的 Launcher 没有应用在线更新的能力,因此第一次升级到带在线更新的
`0.2.0` 仍需人工替换一次完整程序;从 `0.2.0` 开始才可使用上述流程。当前没有 `0.2.0` 仍需人工替换一次完整程序;从 `0.2.0` 开始才可使用上述流程。当前没有
+7 -5
View File
@@ -145,8 +145,9 @@ client/
- Repository 封装 SQLite,界面和自动化代码不得直接拼接业务 SQL。 - Repository 封装 SQLite,界面和自动化代码不得直接拼接业务 SQL。
- PDD Adapter 封装设备连接、页面识别、采集和采购。 - PDD Adapter 封装设备连接、页面识别、采集和采购。
- ArtifactStore 保存失败截图、无障碍 XML 和结构化诊断文件。 - ArtifactStore 保存失败截图、无障碍 XML 和结构化诊断文件。
- `UpdateService` 只负责 HTTPS 清单、下载校验和安全暂存;正在运行的主程序不替换 - `UpdateService` 负责认证清单、下载校验和安全暂存;URL/账号由 Repository 保存,
自身,目录替换与回退只由下一次启动的 `Launcher.exe` 执行。 密码只从 Windows 凭据管理器短暂读入内存。正在运行的主程序不替换自身,目录
替换与回退只由下一次启动的 `Launcher.exe` 执行。
## 5. 线程模型 ## 5. 线程模型
@@ -165,7 +166,7 @@ Qt 主线程
└── Outbox 重试,不重复执行 PDD 操作 └── Outbox 重试,不重复执行 PDD 操作
更新工作线程 更新工作线程
├── 检查 HTTPS 清单 ├── 读取系统凭据并检查认证清单
└── 下载、SHA256 校验和安全解压到 data/update/ └── 下载、SHA256 校验和安全解压到 data/update/
``` ```
@@ -293,14 +294,15 @@ def _on_finished(self, remote_task_id: str, result) -> None:
### 5.3 在线更新启动顺序 ### 5.3 在线更新启动顺序
```text ```text
主程序:HTTPS 清单 → 用户确认 → 下载并校验 → data/update/app.new + pending.json 主程序:系统凭据 → 认证清单 → 用户确认 → 下载并校验 → app.new + pending.json
Launcher:确认主程序未运行 → app 改名 app.old → app.new 改名 app → 启动主程序 Launcher:确认主程序未运行 → app 改名 app.old → app.new 改名 app → 启动主程序
主程序:窗口成功创建 → 写 healthy.json 主程序:窗口成功创建 → 写 healthy.json
Launcher:健康标记正确则完成;提前退出则恢复 app.old Launcher:健康标记正确则完成;提前退出则恢复 app.old
``` ```
更新 ZIP 只允许 `app/` 内容,拒绝绝对路径、`..`、反斜杠路径和符号链接。Launcher 更新 ZIP 只允许 `app/` 内容,拒绝绝对路径、`..`、反斜杠路径和符号链接。Launcher
不联网、不处理凭据,也不自更新。 不联网、不处理凭据,也不自更新。启动自动检查只在 URL、账号和系统密码都已保存时
执行,网络失败只更新设置页状态,不阻止主窗口使用。
## 6. 任务引擎状态 ## 6. 任务引擎状态
+7 -2
View File
@@ -299,8 +299,12 @@ class TaskTableModel(QAbstractTableModel):
### 软件更新 ### 软件更新
- 显示只读的当前版本; - 显示只读的当前版本;
- 清单地址使用带可见标签的单行输入框,只允许完整 HTTPS URL; - 清单地址、账号和密码分别使用带可见标签的单行输入框,密码框默认隐藏且不回填;
- “检查更新”同时保存已验证的非敏感清单地址;检查和下载期间按钮防重复; - “保存”把 URL/账号写入 SQLite,把密码写入 Windows 凭据管理器;已有密码时留空
表示不修改,修改账号时必须重新输入密码;
- “检查更新”只使用已经保存的配置;存在未保存修改时先提示保存;检查和下载期间
输入框及按钮禁用,防止并行任务和配置竞态;
- 已保存完整配置时启动后自动检查一次;没有新版或网络失败不弹模态框;
- 状态文字稳定显示未配置、检查中、已是最新、发现新版、下载进度、已准备、失败和 - 状态文字稳定显示未配置、检查中、已是最新、发现新版、下载进度、已准备、失败和
恢复建议; 恢复建议;
- 发现新版本时使用有明确“下载更新 / 暂不下载”的确认框;下载完成不强制重启, - 发现新版本时使用有明确“下载更新 / 暂不下载”的确认框;下载完成不强制重启,
@@ -322,6 +326,7 @@ class TaskTableModel(QAbstractTableModel):
| 多个订单候选 | 状态改为“需要人工处理”,打开详情决策 | | 多个订单候选 | 状态改为“需要人工处理”,打开详情决策 |
| 普通任务成功 | 更新行和状态区,不弹“成功”对话框 | | 普通任务成功 | 更新行和状态区,不弹“成功”对话框 |
| 更新检查或下载失败 | 软件更新卡片保留地址并显示原因和重试方式,当前程序不变 | | 更新检查或下载失败 | 软件更新卡片保留地址并显示原因和重试方式,当前程序不变 |
| 更新设置保存成功 | 状态区提示已安全保存,密码输入框清空并显示已保存占位文字 |
| 更新下载完成 | 软件更新卡片持续提示“下次启动生效”,不强制关闭程序 | | 更新下载完成 | 软件更新卡片持续提示“下次启动生效”,不强制关闭程序 |
### 9.1 错误提示模板(照抄即可) ### 9.1 错误提示模板(照抄即可)
+8 -2
View File
@@ -187,8 +187,14 @@ Artifact 写入前应脱敏,数据库只保存引用。保留周期由设置
### 8.1 在线更新安全 ### 8.1 在线更新安全
- 清单和更新包只允许 HTTPS,不提供忽略证书错误的开关;URL 不得包含账号密码、 - 一般清单和更新包只允许 HTTPS;按已确认部署方案,只有固定发布主机的默认端口
查询参数或片段,避免凭据随地址写入 SQLite 或错误提示。 临时允许 HTTP Basic Authentication,任何其他 HTTP 地址仍拒绝。
- URL 不得包含账号密码、查询参数或片段。URL 和账号可写 SQLite;密码只能写入
Windows 凭据管理器,不得进入源码、Git、SQLite、日志、异常文本、工单或文档。
- 不同协议、主机或端口使用不同的系统凭据目标;修改服务器地址后必须为新服务器
重新输入密码,不能把旧服务器密码自动发送到新地址。
- HTTP Basic Authentication 不能防止同网段监听或中间人攻击;主机白名单只限制
Client 误连范围,不能替代 TLS,发布服务器具备条件后应迁移到 HTTPS。
- 更新包必须与最终清单 URL 同源,并同时校验清单声明的字节大小和 SHA256。 - 更新包必须与最终清单 URL 同源,并同时校验清单声明的字节大小和 SHA256。
- 清单、压缩包和解压后总大小均有限制;ZIP 只能包含安全的 `app/` 内容,拒绝路径 - 清单、压缩包和解压后总大小均有限制;ZIP 只能包含安全的 `app/` 内容,拒绝路径
穿越、绝对路径、反斜杠路径和符号链接。 穿越、绝对路径、反斜杠路径和符号链接。