feat(update): add transactional external updater

This commit is contained in:
chengma
2026-07-13 12:07:59 +08:00
parent 3ad9ffd239
commit bab2609a85
9 changed files with 578 additions and 2 deletions
+364
View File
@@ -0,0 +1,364 @@
"""在主程序退出后执行程序根项目事务替换的独立更新器。"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import tempfile
import time
import uuid
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
PACKAGE_FORMAT = "cmshopee-portable-v1"
UPDATER_PROTOCOL = 1
ALLOWED_ROOTS = {
"cmshopee.exe",
"_internal",
"version.txt",
"README.txt",
"package-manifest.json",
"cmshopee-updater.exe",
}
PROTECTED_ROOTS = {"data", ".cmshopee-update"}
class UpdaterError(RuntimeError):
"""独立更新器拒绝计划或无法安全完成事务。"""
@dataclass(frozen=True)
class UpdatePlan:
parent_pid: int
install_root: Path
staging_root: Path
target_version: str
transaction_id: str
log_path: Path
def create_plan(install_root, staging_root, target_version, parent_pid):
install_root = Path(install_root).resolve()
transaction_id = uuid.uuid4().hex
update_root = install_root / ".cmshopee-update"
plan = UpdatePlan(
parent_pid=int(parent_pid),
install_root=install_root,
staging_root=Path(staging_root).resolve(),
target_version=str(target_version),
transaction_id=transaction_id,
log_path=(update_root / "logs" / (transaction_id + ".log")).resolve(),
)
validate_plan(plan)
plan_path = update_root / "plans" / (transaction_id + ".json")
_atomic_json(
plan_path,
{
"parent_pid": plan.parent_pid,
"install_root": str(plan.install_root),
"staging_root": str(plan.staging_root),
"target_version": plan.target_version,
"transaction_id": plan.transaction_id,
"log_path": str(plan.log_path),
},
)
return plan_path
def _is_child(path, parent):
try:
Path(path).resolve().relative_to(Path(parent).resolve())
return True
except ValueError:
return False
def _atomic_json(path, payload):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(str(temporary), str(path))
def _append_log(path, message):
Path(path).parent.mkdir(parents=True, exist_ok=True)
with Path(path).open("a", encoding="utf-8") as stream:
stream.write("%s %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), message))
def load_plan(plan_path):
plan_path = Path(plan_path).resolve()
try:
payload = json.loads(plan_path.read_text(encoding="utf-8"))
plan = UpdatePlan(
parent_pid=int(payload["parent_pid"]),
install_root=Path(payload["install_root"]).resolve(),
staging_root=Path(payload["staging_root"]).resolve(),
target_version=str(payload["target_version"]),
transaction_id=str(payload["transaction_id"]),
log_path=Path(payload["log_path"]).resolve(),
)
except (OSError, ValueError, KeyError, TypeError) as exc:
raise UpdaterError("更新计划无效") from exc
validate_plan(plan, plan_path)
return plan
def validate_plan(plan, plan_path=None):
if plan.parent_pid <= 0:
raise UpdaterError("更新计划缺少主程序进程编号")
if not plan.install_root.is_absolute() or not plan.install_root.is_dir():
raise UpdaterError("程序安装目录无效")
update_root = plan.install_root / ".cmshopee-update"
staging_parent = update_root / "staging"
if not plan.staging_root.is_dir() or plan.staging_root.parent != staging_parent.resolve():
raise UpdaterError("新版暂存目录越界")
if not re.fullmatch(r"\d+(?:\.\d+)*", plan.target_version):
raise UpdaterError("目标版本号格式不正确")
if not re.fullmatch(r"[A-Za-z0-9-]{8,64}", plan.transaction_id):
raise UpdaterError("更新事务编号无效")
if not _is_child(plan.log_path, update_root / "logs"):
raise UpdaterError("更新日志路径越界")
if plan_path is not None and not _is_child(plan_path, update_root):
raise UpdaterError("更新计划文件路径越界")
probe = update_root / (".write-probe-" + plan.transaction_id)
try:
probe.parent.mkdir(parents=True, exist_ok=True)
probe.write_bytes(b"")
probe.unlink()
except OSError as exc:
raise UpdaterError("程序安装目录不可写") from exc
def _sha256(path):
digest = hashlib.sha256()
with Path(path).open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def validate_staging(plan):
manifest_path = plan.staging_root / "package-manifest.json"
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise UpdaterError("新版程序清单无效") from exc
if (
manifest.get("package_format") != PACKAGE_FORMAT
or manifest.get("app_version") != plan.target_version
or manifest.get("entry_point") != "cmshopee.exe"
or int(manifest.get("updater_protocol") or 0) != UPDATER_PROTOCOL
):
raise UpdaterError("新版程序清单与更新计划不一致")
roots = manifest.get("replace_roots")
if not isinstance(roots, list) or not roots:
raise UpdaterError("新版程序替换范围无效")
roots = list(dict.fromkeys(str(root) for root in roots))
if any(root not in ALLOWED_ROOTS or root in PROTECTED_ROOTS for root in roots):
raise UpdaterError("新版程序包含未授权替换范围")
if "cmshopee.exe" not in roots or "_internal" not in roots:
raise UpdaterError("新版程序缺少必要替换项目")
declared = {}
for item in manifest.get("files") or []:
relative = PurePosixPath(str(item.get("path") or ""))
if not relative.parts or ".." in relative.parts or relative.parts[0] not in roots:
raise UpdaterError("新版程序清单包含越界文件")
key = relative.as_posix().casefold()
if key in declared:
raise UpdaterError("新版程序清单包含重复文件")
declared[key] = (relative, int(item.get("size_bytes") or -1), str(item.get("sha256") or ""))
actual = {}
for path in plan.staging_root.rglob("*"):
if path.is_symlink():
raise UpdaterError("新版程序不允许符号链接")
if path.is_file() and path.name != "package-manifest.json":
relative = PurePosixPath(path.relative_to(plan.staging_root).as_posix())
actual[relative.as_posix().casefold()] = relative
if set(actual) != set(declared):
raise UpdaterError("新版程序文件与清单不一致")
for key, (relative, size_bytes, expected_hash) in declared.items():
path = plan.staging_root.joinpath(*relative.parts)
if path.stat().st_size != size_bytes or _sha256(path).lower() != expected_hash.lower():
raise UpdaterError("新版程序文件校验失败")
if (plan.staging_root / "version.txt").read_text(encoding="utf-8-sig").strip() != plan.target_version:
raise UpdaterError("新版程序版本文件不一致")
return roots
def _pid_running(pid):
if pid <= 0:
return False
if os.name == "nt":
import ctypes
handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid)
if handle:
ctypes.windll.kernel32.CloseHandle(handle)
return True
return False
try:
os.kill(pid, 0)
return True
except OSError:
return False
def wait_for_parent_exit(pid, timeout=60, poll_seconds=0.2):
deadline = time.monotonic() + timeout
while _pid_running(pid):
if time.monotonic() >= deadline:
raise UpdaterError("等待旧版程序退出超时")
time.sleep(poll_seconds)
class TransactionLock:
def __init__(self, path):
self.path = Path(path)
self.fd = None
def __enter__(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
try:
self.fd = os.open(str(self.path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(self.fd, str(os.getpid()).encode("ascii"))
except FileExistsError as exc:
raise UpdaterError("已有更新程序正在处理当前安装目录") from exc
return self
def __exit__(self, *_args):
if self.fd is not None:
os.close(self.fd)
try:
self.path.unlink()
except FileNotFoundError:
pass
def _default_launch(executable, args):
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
return subprocess.Popen([str(executable), *args], close_fds=True, creationflags=creationflags)
def apply_update(plan, *, wait_parent=wait_for_parent_exit, move=os.replace, launcher=_default_launch):
validate_plan(plan)
roots = validate_staging(plan)
update_root = plan.install_root / ".cmshopee-update"
old_version_path = plan.install_root / "version.txt"
old_version = old_version_path.read_text(encoding="utf-8-sig").strip() if old_version_path.exists() else "unknown"
backup_root = update_root / "backup" / (old_version + "-" + plan.transaction_id)
failed_root = update_root / "failed" / plan.transaction_id
journal_path = update_root / "transactions" / (plan.transaction_id + ".json")
operations = []
def journal(status, error=""):
_atomic_json(
journal_path,
{
"transaction_id": plan.transaction_id,
"target_version": plan.target_version,
"status": status,
"operations": operations,
"error": error,
},
)
with TransactionLock(update_root / "update.lock"):
wait_parent(plan.parent_pid)
backup_root.mkdir(parents=True, exist_ok=False)
journal("moving_old")
try:
for root in roots:
source = plan.install_root / root
if source.exists():
destination = backup_root / root
destination.parent.mkdir(parents=True, exist_ok=True)
move(str(source), str(destination))
operations.append({"kind": "old_to_backup", "root": root})
journal("moving_old")
for root in roots:
source = plan.staging_root / root
if not source.exists():
raise UpdaterError("新版程序缺少替换项目:%s" % root)
destination = plan.install_root / root
move(str(source), str(destination))
operations.append({"kind": "new_to_install", "root": root})
journal("moving_new")
launcher(
plan.install_root / "cmshopee.exe",
["--update-transaction", plan.transaction_id, "--update-target-version", plan.target_version],
)
journal("launched")
_append_log(plan.log_path, "新版程序已替换并启动:%s" % plan.target_version)
return backup_root
except Exception as exc:
journal("rolling_back", str(exc))
failed_root.mkdir(parents=True, exist_ok=True)
rollback_errors = []
for operation in reversed(operations):
root = operation["root"]
try:
if operation["kind"] == "new_to_install":
source = plan.install_root / root
if source.exists():
destination = failed_root / root
destination.parent.mkdir(parents=True, exist_ok=True)
move(str(source), str(destination))
else:
source = backup_root / root
if source.exists():
move(str(source), str(plan.install_root / root))
except Exception as rollback_exc:
rollback_errors.append(str(rollback_exc))
status = "rollback_failed" if rollback_errors else "rolled_back"
journal(status, ";".join(rollback_errors) or str(exc))
_append_log(plan.log_path, "更新失败,已尝试恢复旧版:%s" % exc)
if rollback_errors:
raise UpdaterError("更新失败且旧版恢复不完整,请联系管理员") from exc
raise UpdaterError("更新失败,已恢复旧版程序") from exc
def copy_and_launch_updater(updater_path, plan_path):
updater_path = Path(updater_path).resolve()
if not updater_path.is_file():
raise UpdaterError("独立更新程序不存在")
temporary_dir = Path(tempfile.mkdtemp(prefix="cmshopee-update-"))
external_updater = temporary_dir / updater_path.name
shutil.copy2(str(updater_path), str(external_updater))
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) | getattr(
subprocess, "DETACHED_PROCESS", 0
)
return subprocess.Popen(
[str(external_updater), "--plan", str(Path(plan_path).resolve())],
close_fds=True,
creationflags=creationflags,
)
def main(argv=None):
parser = argparse.ArgumentParser(description="蝦皮圈优化助手独立更新程序")
parser.add_argument("--plan", required=True)
args = parser.parse_args(argv)
try:
plan = load_plan(args.plan)
apply_update(plan)
return 0
except Exception as exc:
try:
if "plan" in locals():
_append_log(plan.log_path, "自动更新失败:%s" % exc)
except OSError:
pass
return 1
if __name__ == "__main__":
raise SystemExit(main())
+28
View File
@@ -0,0 +1,28 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
["app/updater_entry.py"],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=["PySide6"],
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name="cmshopee-updater",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
)
+2
View File
@@ -500,6 +500,7 @@ cmshopee/
│ ├── editor.py / ai.py / prompts.py / gui.py / workers.py
│ ├── release_manifest.py # 发布包文件清单、zip哈希与服务端元数据模板
│ ├── update_installer.py # 自动升级安全下载、解压、manifest校验与同盘暂存
│ ├── updater_entry.py # 独立更新器入口、事务根项目切换、journal与回滚
├── main.py # GUI 启动入口:from app.gui import main
├── shopee待处理任务模板.xlsx # 标准空 Excel 模板,可提交;业务填写后的副本不提交
├── data/ # 用户本地数据根(整体 gitignore;打包更新时保留)
@@ -529,6 +530,7 @@ cmshopee/
- 发布包只能包含程序根项目,`data/` 和 `.cmshopee-update/` 永远在替换边界之外。第一阶段预留签名字段,但 SHA-256 只负责传输完整性,不等同于发布者身份认证。
- T-615 只提供可验证发布契约;启动门禁仍保持 T-544 的人工下载行为,直到后续下载、独立更新器、事务替换和失败熔断任务全部接入。
- T-616 的下载暂存根固定为安装目录下 `.cmshopee-update/`,与 `data/` 完全隔离。远程zip必须经过HTTPS/受信任域名、声明大小、整包SHA-256、安全zip路径和包内manifest逐文件校验,才写 `pending.json`;此阶段不替换任何运行中程序文件。
- T-617 的独立 `cmshopee-updater.exe` 必须先复制到系统临时目录运行,并等待主程序退出。替换粒度是manifest允许的程序根项目,旧根先整体移动到同盘backup,新根再整体移入;事务锁防止并发更新,journal记录每次移动,任一步失败逆序恢复。`data/`、更新管理目录与未知安装根项目永不进入替换清单。
- CDP 交互事实变化同步第七节。
- 正式代码只放 `app/` 包;根目录只保留 `main.py`、配置/数据目录、文档和原型目录,不新增正式业务模块。
+2
View File
@@ -178,6 +178,8 @@ T-615 不改变客户端行为:T-544 仍只打开浏览器下载。客户端
T-616 提供无Qt依赖的安全下载暂存层 `app/update_installer.py`。它只接受受信任域名的HTTPS,下载到 `<安装目录>/.cmshopee-update/downloads/`,完成大小与zip SHA-256校验后安全解压到同盘 `staging/`;路径穿越、符号链接、大小写重复、Windows保留名、ADS、异常压缩比、文件数/解压总量超限,以及manifest缺失或逐文件hash不一致都会拒绝。完整验证后才原子写 `pending.json`,当前程序目录和 `data/` 不变。
T-617 增加独立 `cmshopee-updater.exe`。构建脚本用 `cmshopee-updater.spec` 生成无控制台单文件更新器并放进release和manifest。执行更新前,主程序把它复制到系统临时目录;更新器有上限地等待主程序退出,再按manifest白名单将旧程序根项目整体移动到 `.cmshopee-update/backup/`,把已验证暂存根项目移入安装目录。每一步写事务journal,移动或新版启动失败时逆序恢复;`data/` 与安装根未知文件不扫描、不移动、不删除。
## 四、绝不打包的本地数据
发布包里不能包含以下本地数据、密钥、业务数据或登录态:
+4 -2
View File
@@ -3,7 +3,7 @@ id: T-617
title: 独立更新器事务替换与失败恢复
phase: 8
deps: [T-615]
status: TODO
status: DONE
created: 2026-07-13
---
@@ -45,4 +45,6 @@ Windows正在运行的 `cmshopee.exe` 和PyInstaller `_internal/` 可能被锁
## 执行记录
(完成后记录实现、验证命令与结果。)
- 2026-07-13:新增标准库独立更新器入口、结构化plan、父进程退出等待、安装目录事务锁、根项目整体切换、逐步journal、故障逆序回滚及临时目录隐藏启动能力。
- 2026-07-13:新增无控制台单文件 `cmshopee-updater.spec`,构建脚本将更新器纳入release与manifest;Windows 10 / Python 3.10 / PyInstaller 6.11.1实际构建成功,产物约6.7MB且使用windowed bootloader。
- 2026-07-13:干净worktree验证通过:ruff、compileall、完整unittest(408项)和 `git diff --check`。尚未在正式release上人工执行“锁住运行中主程序后自动退出、替换、重启”的端到端验收,该项留给T-618/T-619集成后执行。
+2
View File
@@ -49,6 +49,8 @@
T-616 已实现但尚未接入GUI的安全暂存层:仅接受受信任域名的 HTTPS 地址,流式下载到安装目录 `.cmshopee-update/`,校验zip大小和SHA-256,安全解压后再按包内manifest逐文件校验。任一步失败都不修改当前程序或 `data/`;GUI接入由T-618完成。
T-617 已提供独立无控制台更新器和事务回滚能力:更新器从系统临时目录运行,旧主程序退出后才切换程序根项目,并在新版进程无法创建时恢复旧版。启动检查弹窗尚未调用该流程,直到T-618完成GUI编排。
## 五、发版约定(服务端据此控制)
自动安装使用的全部字段必须放在同一个 `release` 对象内,不得把版本取自一个对象、hash 取自另一个对象。构建脚本生成的 `release/release-metadata.json` 是服务端录入模板;发布人员只补 HTTPS `download_url`、强制策略和中文发布说明,不得手工改写 hash、大小、包格式或协议版本。
+13
View File
@@ -120,6 +120,18 @@ if ($LASTEXITCODE -ne 0) {
throw "$(Decode-Utf8Base64 "UHlJbnN0YWxsZXIg5omT5YyF5aSx6LSl77yM6YCA5Ye656CB"): $LASTEXITCODE"
}
$updaterSpec = Join-Path $repoRoot "cmshopee-updater.spec"
$updaterDist = Join-Path $repoRoot "dist\updater"
$updaterWork = Join-Path $repoRoot "build\updater"
& $pythonExe @pythonArgs -m PyInstaller --noconfirm --clean --distpath $updaterDist --workpath $updaterWork $updaterSpec
if ($LASTEXITCODE -ne 0) {
throw "独立更新器打包失败,退出码: $LASTEXITCODE"
}
$updaterExe = Join-Path $updaterDist "cmshopee-updater.exe"
if (-not (Test-Path -LiteralPath $updaterExe)) {
throw "独立更新器产物不存在: $updaterExe"
}
$distDir = Join-Path $repoRoot "dist\cmshopee"
$exePath = Join-Path $distDir "cmshopee.exe"
if (-not (Test-Path -LiteralPath $exePath)) {
@@ -164,6 +176,7 @@ foreach ($path in @($releaseDir, $portableZip, $releaseMetadata)) {
New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null
Get-ChildItem -LiteralPath $distDir -Force | Copy-Item -Destination $releaseDir -Recurse -Force
Copy-Item -LiteralPath $updaterExe -Destination (Join-Path $releaseDir "cmshopee-updater.exe") -Force
Set-Content -LiteralPath (Join-Path $releaseDir "version.txt") -Value $appVersion -Encoding ascii -NoNewline
$readme = @(
+11
View File
@@ -96,6 +96,17 @@ class PackagingTests(unittest.TestCase):
self.assertIn('-m app.release_manifest manifest', script)
self.assertIn('-m app.release_manifest metadata', script)
self.assertIn('release-metadata.json', script)
self.assertIn('cmshopee-updater.spec', script)
self.assertIn('cmshopee-updater.exe', script)
def test_updater_spec_builds_a_windowed_standalone_executable(self):
spec = self.read_text("cmshopee-updater.spec")
normalized = "".join(spec.split())
self.assertIn('app/updater_entry.py', spec)
self.assertIn('name="cmshopee-updater"', normalized)
self.assertIn('console=False', normalized)
self.assertNotIn("COLLECT(", spec)
def make_release_dir(self, root, app_version="1.2.3"):
release_dir = Path(root) / "release"
+152
View File
@@ -0,0 +1,152 @@
import hashlib
import json
import os
import tempfile
import unittest
from pathlib import Path
from app import release_manifest, updater_entry
class UpdaterEntryTests(unittest.TestCase):
def make_trees(self, root):
install = Path(root) / "install"
staging = install / ".cmshopee-update" / "staging" / "2.0.0-test"
install.mkdir()
(install / "_internal").mkdir()
(install / "cmshopee.exe").write_bytes(b"old-exe")
(install / "_internal" / "old.dll").write_bytes(b"old")
(install / "version.txt").write_text("1.0.0", encoding="ascii")
(install / "README.txt").write_text("旧说明", encoding="utf-8")
(install / "package-manifest.json").write_text("{}", encoding="utf-8")
(install / "cmshopee-updater.exe").write_bytes(b"old-updater")
(install / "data").mkdir()
(install / "data" / "cmshopee.db").write_bytes(b"business-data")
(install / "operator-note.txt").write_text("保留", encoding="utf-8")
(staging / "_internal").mkdir(parents=True)
(staging / "cmshopee.exe").write_bytes(b"new-exe")
(staging / "_internal" / "new.dll").write_bytes(b"new")
(staging / "version.txt").write_text("2.0.0", encoding="ascii")
(staging / "README.txt").write_text("新说明", encoding="utf-8")
(staging / "cmshopee-updater.exe").write_bytes(b"new-updater")
release_manifest.write_package_manifest(staging, "2.0.0")
plan = updater_entry.UpdatePlan(
parent_pid=12345,
install_root=install.resolve(),
staging_root=staging.resolve(),
target_version="2.0.0",
transaction_id="transaction-1234",
log_path=(install / ".cmshopee-update/logs/update.log").resolve(),
)
return install, staging, plan
def test_transaction_replaces_roots_and_preserves_data_and_unknown_files(self):
with tempfile.TemporaryDirectory() as temp_dir:
install, _staging, plan = self.make_trees(temp_dir)
before_hash = hashlib.sha256((install / "data/cmshopee.db").read_bytes()).hexdigest()
launched = []
backup = updater_entry.apply_update(
plan,
wait_parent=lambda _pid: None,
launcher=lambda executable, args: launched.append((executable, args)),
)
self.assertEqual(b"new-exe", (install / "cmshopee.exe").read_bytes())
self.assertTrue((install / "_internal/new.dll").is_file())
self.assertFalse((install / "_internal/old.dll").exists())
self.assertEqual("保留", (install / "operator-note.txt").read_text(encoding="utf-8"))
self.assertEqual(
before_hash,
hashlib.sha256((install / "data/cmshopee.db").read_bytes()).hexdigest(),
)
self.assertTrue((backup / "cmshopee.exe").is_file())
self.assertEqual(1, len(launched))
def test_move_failure_rolls_back_old_program(self):
with tempfile.TemporaryDirectory() as temp_dir:
install, _staging, plan = self.make_trees(temp_dir)
calls = {"count": 0}
def failing_move(source, destination):
calls["count"] += 1
if calls["count"] == 8:
raise OSError("injected move failure")
return os.replace(source, destination)
with self.assertRaisesRegex(updater_entry.UpdaterError, "已恢复旧版"):
updater_entry.apply_update(
plan,
wait_parent=lambda _pid: None,
move=failing_move,
launcher=lambda *_args: None,
)
self.assertEqual(b"old-exe", (install / "cmshopee.exe").read_bytes())
self.assertTrue((install / "_internal/old.dll").is_file())
self.assertEqual(b"business-data", (install / "data/cmshopee.db").read_bytes())
def test_parent_timeout_keeps_install_unchanged(self):
with tempfile.TemporaryDirectory() as temp_dir:
install, _staging, plan = self.make_trees(temp_dir)
def timeout(_pid):
raise updater_entry.UpdaterError("等待旧版程序退出超时")
with self.assertRaisesRegex(updater_entry.UpdaterError, "退出超时"):
updater_entry.apply_update(plan, wait_parent=timeout)
self.assertEqual(b"old-exe", (install / "cmshopee.exe").read_bytes())
def test_new_process_launch_failure_rolls_back(self):
with tempfile.TemporaryDirectory() as temp_dir:
install, _staging, plan = self.make_trees(temp_dir)
def fail_launch(*_args):
raise OSError("injected launch failure")
with self.assertRaisesRegex(updater_entry.UpdaterError, "已恢复旧版"):
updater_entry.apply_update(
plan,
wait_parent=lambda _pid: None,
launcher=fail_launch,
)
self.assertEqual(b"old-exe", (install / "cmshopee.exe").read_bytes())
self.assertTrue((install / "_internal/old.dll").is_file())
def test_lock_blocks_concurrent_transaction(self):
with tempfile.TemporaryDirectory() as temp_dir:
install, _staging, plan = self.make_trees(temp_dir)
lock_path = install / ".cmshopee-update/update.lock"
with updater_entry.TransactionLock(lock_path):
with self.assertRaisesRegex(updater_entry.UpdaterError, "已有更新程序"):
updater_entry.apply_update(plan, wait_parent=lambda _pid: None)
def test_plan_path_and_staging_must_stay_inside_update_root(self):
with tempfile.TemporaryDirectory() as temp_dir:
install, _staging, plan = self.make_trees(temp_dir)
escaped = updater_entry.UpdatePlan(
parent_pid=plan.parent_pid,
install_root=install,
staging_root=Path(temp_dir) / "outside",
target_version=plan.target_version,
transaction_id=plan.transaction_id,
log_path=plan.log_path,
)
escaped.staging_root.mkdir()
with self.assertRaisesRegex(updater_entry.UpdaterError, "暂存目录越界"):
updater_entry.validate_plan(escaped)
def test_create_and_load_plan_round_trip(self):
with tempfile.TemporaryDirectory() as temp_dir:
install, staging, _plan = self.make_trees(temp_dir)
plan_path = updater_entry.create_plan(install, staging, "2.0.0", 4321)
loaded = updater_entry.load_plan(plan_path)
payload = json.loads(plan_path.read_text(encoding="utf-8"))
self.assertEqual("2.0.0", loaded.target_version)
self.assertEqual(4321, payload["parent_pid"])
self.assertNotIn("data", payload)
if __name__ == "__main__":
unittest.main()