diff --git a/app/task/BAAH/manager.py b/app/task/BAAH/manager.py index 64037a595f..08048a8db2 100644 --- a/app/task/BAAH/manager.py +++ b/app/task/BAAH/manager.py @@ -23,6 +23,8 @@ 按账号逐个拉起 BAAH 自动代理任务,结束时统一组装任务报告并推送。 """ +import asyncio +import shutil import uuid from contextlib import ExitStack, suppress from datetime import datetime @@ -30,7 +32,13 @@ import app.core.notify.report as _node_report from app.config import config_manager from app.core import Config, EmulatorManager, Notify, Publisher -from app.core.script_update import ScriptUpdateRequest, run_script_update +from app.core.script_update import ( + ScriptUpdateRequest, + auto_recover_script_install, + run_managed_script_install, + run_script_update_with_safe_fallback, +) +from app.core.script_update_recovery import DownloadRetryFailed from app.core.ws import protocol from app.models.config import BAAHConfig from app.models.emulator import DeviceBase, DeviceProvider @@ -45,6 +53,15 @@ from .AutoProxy import AutoProxyTask from .tools import program_update +from .tools.managed_recovery import ( + BAAHInstallAdapter, + acknowledge_stale_transaction, + inspect_stale_transaction, + snapshot_original_receipts, + cleanup_original_receipts, + receipt_backup_path, +) +from app.services.script_install_backup import ManagedInstallBackup logger = get_logger("BAAH 调度器") @@ -99,7 +116,8 @@ async def check(self) -> str: return "未填写 BAAH 主程序路径, 请检查托管配置中的主程序路径设置!" if not path.is_file(): directory = script_install.workspace(path.parent) - if not ((directory / "transaction.json").exists()): + receipt = script_install.record_path(path.parent, "installed.json") + if not (directory / "transaction.json").is_file() and not receipt.is_file(): return "BAAH 主程序不存在,且没有可信的中断安装记录,请检查安装路径" return "Pass" @@ -133,49 +151,142 @@ async def prepare(self): async def _prepare_program_update(self) -> None: root = self.script_config.Script.BAAHPath.parent self.check_result = "BAAH 更新准备未完成" - # 按真实安装路径排队,等待前一 BAAH 运行的全部账号及恢复动作结束; - # 跨 MAS 进程仍由文件锁保障,不改变不同安装目录的并行能力。 lock = update_files.directory_lock(root.resolve()) await lock.acquire() self._directory_lock = lock self._install_access.enter_context(script_install.installation_access(root)) + request = ScriptUpdateRequest(str(self.script_info.script_id), "BAAH") + backup = ManagedInstallBackup( + root, script_id=request.script_id, script_type="BAAH" + ) + # 原生事务未完成时不能通过清除 journal 绕过正在使用的备份。 + try: + await script_install.run_worker( + script_install.recover_install, + root, + lambda: program_update.assert_idle(root), + ) + except (script_install.InstallUnsafeError, OSError, ValueError): + marker = script_install.workspace(root) / "transaction.json" + if marker.is_symlink() or not marker.is_file(): + raise + + async def retry_native_rollback(): + await script_install.run_worker( + script_install.recover_install, + root, + lambda: program_update.assert_idle(root), + ) + + outcome = await auto_recover_script_install( + request, + install_root=root, + inspect=lambda: inspect_stale_transaction(root), + acknowledge=lambda: acknowledge_stale_transaction(root), + reinstall=retry_native_rollback, + ) + await self.script_info.set_log( + f"BAAH 原生安装事务由公共入口恢复:{outcome}" + ) + # 检查旧安装不写目录,可以保留 BAAH 内置的 adb server。 + await asyncio.to_thread( + program_update.assert_idle, root, allow_background_adb=True + ) + + if backup.read() is not None: + adapter = BAAHInstallAdapter(root, self.script_config) + result = await run_managed_script_install( + request, + install_root=root, + inspect=adapter.inspect, + acknowledge=adapter.acknowledge, + install=adapter.install, + ensure_idle=adapter.ensure_idle, + after_rollback=adapter.after_rollback, + ) + await script_install.run_worker(cleanup_original_receipts, root) + await self.script_info.set_log(f"BAAH 上次安装恢复:{result}") + self.check_result = "Pass" + return + + try: + await program_update.confirm_current_install(root) + if receipt_backup_path(root).exists(): + await script_install.run_worker(cleanup_original_receipts, root) + except (script_install.InstallUnsafeError, OSError, ValueError) as error: + # 损坏本体只能在已核验的 MAS 安装身份下自动完整重装。 + receipt = script_install.record_path(root, "installed.json") + if receipt.is_symlink() or not receipt.is_file(): + raise script_install.InstallUnsafeError( + "BAAH 原安装不完整且缺少可信回执,不能自动修复" + ) from error + snapshot = receipt.read_bytes() + program_update.verify_recovery_receipt(root, snapshot) + adapter = BAAHInstallAdapter( + root, self.script_config, recovery_receipt=snapshot + ) + result = await auto_recover_script_install( + request, + install_root=root, + inspect=adapter.inspect, + acknowledge=adapter.acknowledge, + reinstall=adapter.install, + ) + await self.script_info.set_log(f"BAAH 自动修复:{result}") + self.check_result = "Pass" + return + if self.script_config.Update.TakeoverEnabled: async def progress(downloaded: int, total: int, speed: float) -> None: await self.script_info.set_log( - f"BAAH 更新下载:{downloaded / 1024**2:.1f} / {total / 1024**2:.1f} MB" + f"BAAH 下载:{downloaded / 1024**2:.1f} / {total / 1024**2:.1f} MB" ) - try: - await self.script_info.set_log( - await run_script_update( - ScriptUpdateRequest(self.script_info.script_id, "BAAH"), - program_update.update_before_run, - root, - self.script_config, - progress=progress, - ) - ) - except (script_install.InstallUnsafeError, FluxdownCancelUnconfirmed): - raise - except Exception as error: - await program_update.confirm_current_install(root) - message = f"BAAH 更新准备失败,沿用当前安装继续任务:{error}" - await self.script_info.set_log(message) - logger.warning(message) - with suppress(Exception): - await Publisher.send( - id=self.task_info.task_id, - type=protocol.TASK_NOTICE, - data=WSTaskNoticeData(level="warning", message=message), + async def prepare(): + try: + return await program_update.prepare_update( + root, self.script_config, progress=progress ) - else: - # 即使关闭接管,上轮未确认安装也必须先恢复。 - await script_install.run_worker( - script_install.recover_install, + except (script_install.InstallUnsafeError, FluxdownCancelUnconfirmed): + raise + except Exception as error: + raise DownloadRetryFailed( + source="BAAH GitHub/MirrorChyan", + reason=f"BAAH 更新准备失败({type(error).__name__})", + ) from error + + candidate = await run_script_update_with_safe_fallback( + request, + prepare, root, - lambda: program_update.assert_idle(root), + BAAHInstallAdapter(root, self.script_config).inspect, ) + if isinstance(candidate, program_update.PreparedUpdate): + adapter = BAAHInstallAdapter( + root, self.script_config, prepared=candidate + ) + await script_install.run_worker(snapshot_original_receipts, root) + try: + result = await run_managed_script_install( + request, + install_root=root, + inspect=adapter.inspect, + acknowledge=adapter.acknowledge, + install=adapter.install, + ensure_idle=adapter.ensure_idle, + after_rollback=adapter.after_rollback, + ) + finally: + await adapter.ensure_idle() + if candidate.archive.parent.exists(): + shutil.rmtree(candidate.archive.parent) + if backup.read() is None: + await script_install.run_worker(cleanup_original_receipts, root) + else: + await program_update.confirm_current_install(root) + result = candidate or "BAAH 当前无需更新" + await self.script_info.set_log(str(result)) self.check_result = "Pass" async def main_task(self): diff --git a/app/task/BAAH/tools/managed_recovery.py b/app/task/BAAH/tools/managed_recovery.py new file mode 100644 index 0000000000..161cfb4e28 --- /dev/null +++ b/app/task/BAAH/tools/managed_recovery.py @@ -0,0 +1,281 @@ +# Copyright © 2025-2026 AUTO-MAS Team +# SPDX-License-Identifier: AGPL-3.0-or-later +"""BAAH 官方完整包恢复适配器;公共入口负责恢复策略和任务末尾报告。""" + +import asyncio +import base64 +import hashlib +import os +import threading +from pathlib import Path +from uuid import uuid4 + +from packaging.version import Version + +from app.core.script_update_recovery import InstallInspection +from app.services import script_install +from app.services.script_install_backup import ManagedInstallBackup +from app.utils import io as io_utils + +from . import program_update + + +class BAAHInstallAdapter: + def __init__( + self, + root: Path, + config, + *, + prepared: program_update.PreparedUpdate | None = None, + recovery_receipt: bytes | None = None, + ) -> None: + self.root = root + self.config = config + self.prepared = prepared + self.recovery_receipt = recovery_receipt + + async def ensure_idle(self) -> None: + await asyncio.to_thread(program_update.assert_idle, self.root) + + async def inspect(self) -> InstallInspection: + # 只读健康检查允许 BAAH 自带 adb server 持续运行。 + await asyncio.to_thread( + program_update.assert_idle, self.root, allow_background_adb=True + ) + pending_file = script_install.workspace(self.root) / "transaction.json" + if pending_file.is_symlink(): + raise script_install.InstallUnsafeError("BAAH 原生事务标记为链接") + pending = pending_file.exists() + version = None + healthy = False + try: + await program_update.confirm_current_install(self.root) + version = await asyncio.to_thread(program_update.read_version, self.root) + healthy = not pending + except (script_install.InstallUnsafeError, OSError, ValueError, KeyError): + healthy = False + target = self.prepared.version if self.prepared else None + previous = None + if self.prepared is None: + state = ManagedInstallBackup( + self.root, script_id="", script_type="BAAH" + ).read() + if state and state.get("version"): + previous = Version(state["version"]) + elif version is not None and not pending: + previous = version + else: + try: + previous = await asyncio.to_thread( + program_update.read_version, self.root + ) + except (OSError, ValueError, KeyError): + previous = None + return InstallInspection( + pending=pending or (not healthy and self.recovery_receipt is not None), + healthy=healthy, + installed_version=str(version) if version else None, + target_version=str(target) if target else None, + updated_confirmed=bool( + healthy + and ( + (target is not None and version == target) + or ( + previous is not None + and version is not None + and version > previous + ) + ) + ), + previous_confirmed=bool( + healthy and not pending and previous is not None and version == previous + ), + ) + + async def acknowledge(self) -> None: + await self.ensure_idle() + await script_install.run_worker( + script_install.recover_install, + self.root, + lambda: program_update.assert_idle(self.root), + ) + await program_update.confirm_current_install(self.root) + + async def install(self) -> None: + if self.prepared: + await program_update.apply_update(self.root, self.prepared) + else: + await program_update.update_before_run( + self.root, + self.config, + recovery_receipt=self.recovery_receipt, + ) + + async def after_rollback(self) -> None: + await self.ensure_idle() + # 注册到安装访问生命周期;取消不会让外置收据线程越过目录锁。 + await script_install.run_worker(restore_original_receipts, self.root) + # 回退前的原 MAS 收据已同步恢复,仍要重新验证目录哈希。 + await program_update.confirm_current_install(self.root) + + +async def inspect_stale_transaction(root: Path) -> InstallInspection: + """只接受原安装回执已验证、目录切换已结束的遗留事务。""" + await asyncio.to_thread(program_update.assert_idle, root) + directory = script_install.workspace(root) + marker = directory / "transaction.json" + previous = directory / "previous" + ready = directory / "ready" + if any(path.is_symlink() for path in (marker, previous, ready)): + raise script_install.InstallUnsafeError("BAAH 恢复目录包含符号链接") + pending = marker.exists() + if previous.exists() or ready.exists(): + return InstallInspection(pending=pending, healthy=False) + receipt = script_install.record_path(root, "installed.json") + if not receipt.is_file() or receipt.is_symlink(): + return InstallInspection(pending=pending, healthy=False) + try: + arch = await asyncio.to_thread( + script_install.read_pe_architecture, root / "BAAH.exe" + ) + version = await asyncio.to_thread(program_update.read_version, root) + verified = await script_install.run_worker( + script_install.verify_installed_files, root + ) + except (OSError, ValueError, KeyError): + return InstallInspection(pending=pending, healthy=False) + healthy = arch == "x86_64" and verified + return InstallInspection( + pending=pending, + healthy=healthy, + installed_version=str(version), + updated_confirmed=healthy, + previous_confirmed=healthy and not pending, + ) + + +async def acknowledge_stale_transaction(root: Path) -> None: + """经公共入口确认后归档旧 journal,不接受有 backup/ready 的回滚事务。""" + state = await inspect_stale_transaction(root) + if not (state.pending and state.healthy and state.updated_confirmed): + raise script_install.InstallUnsafeError("旧 BAAH 安装事务无法确认完整") + directory = script_install.workspace(root) + marker = directory / "transaction.json" + snapshot = marker.read_bytes() + if not snapshot or len(snapshot) > 128 * 1024 * 1024: + raise script_install.InstallUnsafeError("旧 BAAH 事务内容无效") + await asyncio.to_thread(program_update.assert_idle, root) + if marker.is_symlink() or marker.read_bytes() != snapshot: + raise script_install.InstallUnsafeError("BAAH 事务记录已变化") + if any( + path.exists() or path.is_symlink() + for path in (directory / "previous", directory / "ready") + ): + raise script_install.InstallUnsafeError("BAAH 仍有可恢复的旧版目录") + archive = marker.with_name(f"baah-transaction-ack-{uuid4().hex}.json") + marker.replace(archive) + try: + await program_update.confirm_current_install(root) + except BaseException: + if not marker.exists() and archive.exists(): + archive.replace(marker) + raise + + +_RECEIPT_NAMES = ("installed.json", "baah-version.json") +_RECEIPT_BACKUP = "baah-managed-receipts.json" + + +def receipt_backup_path(root: Path) -> Path: + return script_install.workspace(root) / _RECEIPT_BACKUP + + +def _read_receipt_backup(root: Path) -> dict: + marker = receipt_backup_path(root) + if marker.is_symlink() or not marker.is_file(): + raise script_install.InstallUnsafeError("BAAH 更新前外置收据备份缺失或被替换") + data = io_utils.read_dict_file(marker) + if ( + data.get("root") != str(root.resolve()) + or data.get("schema") != 1 + or not isinstance(data.get("receipts"), dict) + or set(data["receipts"]) != set(_RECEIPT_NAMES) + ): + raise script_install.InstallUnsafeError("BAAH 外置收据备份身份不匹配") + return data["receipts"] + + +def snapshot_original_receipts(root: Path, cancelled: threading.Event) -> None: + """公共备份创建前快照收据;文件线程归 installation_access 管理。""" + if cancelled.is_set(): + raise InterruptedError("BAAH 收据快照已取消") + marker = receipt_backup_path(root) + if marker.exists() or marker.is_symlink(): + raise script_install.InstallUnsafeError("BAAH 尚有未结算的外置收据备份") + receipts = {} + for name in _RECEIPT_NAMES: + path = script_install.record_path(root, name) + if path.is_symlink(): + raise script_install.InstallUnsafeError("BAAH 收据目录包含链接") + content = path.read_bytes() if path.is_file() else None + if content is not None and len(content) > 32 * 1024 * 1024: + raise script_install.InstallUnsafeError("BAAH 原安装收据异常大") + receipts[name] = ( + None + if content is None + else { + "data": base64.b64encode(content).decode("ascii"), + "sha256": hashlib.sha256(content).hexdigest(), + } + ) + if cancelled.is_set(): + raise InterruptedError("BAAH 收据快照已取消") + marker.parent.mkdir(parents=True, exist_ok=True) + io_utils.write_file( + marker, + { + "schema": 1, + "root": str(root.resolve()), + "receipts": receipts, + }, + ) + + +def restore_original_receipts(root: Path, cancelled: threading.Event) -> None: + """回滚时完整恢复外置收据;启动后即使取消也必须完成写盘。""" + # 线程会被 run_worker 登记;不在两张收据之间响应取消,避免半恢复。 + _ = cancelled + receipts = _read_receipt_backup(root) + for name in _RECEIPT_NAMES: + path = script_install.record_path(root, name) + if path.is_symlink(): + raise script_install.InstallUnsafeError("BAAH 收据目标是符号链接") + entry = receipts[name] + if entry is None: + path.unlink(missing_ok=True) + continue + try: + content = base64.b64decode(entry["data"], validate=True) + if hashlib.sha256(content).hexdigest() != entry["sha256"]: + raise ValueError("digest mismatch") + except (KeyError, TypeError, ValueError) as error: + raise script_install.InstallUnsafeError( + "BAAH 更新前原始收据校验失败,拒绝回滚" + ) from error + path.parent.mkdir(parents=True, exist_ok=True) + staged = path.with_name(f"{path.name}.restore-{os.getpid()}.tmp") + if staged.is_symlink(): + raise script_install.InstallUnsafeError("BAAH 收据暂存路径不安全") + staged.write_bytes(content) + os.replace(staged, path) + + +def cleanup_original_receipts(root: Path, cancelled: threading.Event) -> None: + marker = receipt_backup_path(root) + if marker.is_symlink(): + raise script_install.InstallUnsafeError("BAAH 备份文件被重定向") + if marker.exists(): + _read_receipt_backup(root) + if cancelled.is_set(): + raise InterruptedError("BAAH 收据备份清理已取消") + marker.unlink() diff --git a/app/task/BAAH/tools/program_update.py b/app/task/BAAH/tools/program_update.py index 9a0f510670..5fa028da19 100644 --- a/app/task/BAAH/tools/program_update.py +++ b/app/task/BAAH/tools/program_update.py @@ -32,7 +32,7 @@ @dataclass(frozen=True) class PreparedUpdate: root: Path - base_exe_sha256: str + base_exe_sha256: str | None version: Version archive: Path size: int @@ -89,7 +89,13 @@ async def confirm_current_install(root: Path) -> None: await asyncio.to_thread(assert_idle, root, allow_background_adb=True) if (script_install.workspace(root) / "transaction.json").exists(): raise script_install.InstallUnsafeError("BAAH 安装尚未确认,请开启更新托管修复") - if script_install.read_pe_architecture(root / "BAAH.exe") != "x86_64": + try: + architecture = script_install.read_pe_architecture(root / "BAAH.exe") + except (OSError, ValueError) as error: + raise script_install.InstallUnsafeError( + "BAAH 主程序缺失或架构无法识别,需要修复" + ) from error + if architecture != "x86_64": raise script_install.InstallUnsafeError( "BAAH 安装架构不可识别,请使用官方包修复" ) @@ -154,7 +160,11 @@ def verify_package( async def prepare_update( - root: Path, config, *, progress: DownloadProgress | None = None + root: Path, + config, + *, + progress: DownloadProgress | None = None, + recovery_receipt: bytes | None = None, ) -> PreparedUpdate | None: """只读当前版本,在 MAS 数据目录下载并校验;允许与上一轮 BAAH 运行并行。""" channel = config.Update.Channel @@ -164,17 +174,29 @@ async def prepare_update( ) proxy = Config.proxy root = root.resolve() - baseline = await asyncio.to_thread(exe_digest, root) - current = await asyncio.to_thread(read_version, root) + if recovery_receipt is not None: + verify_recovery_receipt(root, recovery_receipt) + try: + baseline = await asyncio.to_thread(exe_digest, root) + current = await asyncio.to_thread(read_version, root) + except (OSError, ValueError, KeyError, TypeError): + if recovery_receipt is None: + raise + baseline = ( + await asyncio.to_thread(exe_digest, root) + if (root / "BAAH.exe").is_file() + else None + ) + current = None release = await request_release(channel) target = Version(release["tag_name"].removeprefix("BAAH")) receipt = script_install.record_path(root, "installed.json") verified = not receipt.exists() or await script_install.run_worker( script_install.verify_installed_files, root ) - if target <= current and verified: + if recovery_receipt is None and target <= current and verified: return None - if target < current: + if current is not None and target < current: raise script_install.InstallUnsafeError( "当前 BAAH 安装未通过校验,不会自动降级,请手动修复" ) @@ -230,16 +252,29 @@ async def prepare_update( progress=progress, ) await script_install.run_worker(verify_package, destination, size, digest) + if recovery_receipt is not None: + verify_recovery_receipt(root, recovery_receipt) return PreparedUpdate(root, baseline, target, destination, size, digest) -async def apply_update(root: Path, prepared: PreparedUpdate) -> None: +async def apply_update( + root: Path, + prepared: PreparedUpdate, + *, + recovery_receipt: bytes | None = None, +) -> None: """调用者持安装锁;重新验证安装身份和包体后,才允许修改安装目录。""" - if ( - root.resolve() != prepared.root - or await asyncio.to_thread(exe_digest, root) != prepared.base_exe_sha256 - ): + if root.resolve() != prepared.root: + raise ValueError("BAAH 安装在预下载后发生变化,请重新检查更新") + if prepared.base_exe_sha256 is None: + if recovery_receipt is None or (root / "BAAH.exe").exists(): + raise script_install.InstallUnsafeError( + "BAAH 安装身份发生变化,拒绝覆盖新出现的程序" + ) + elif await asyncio.to_thread(exe_digest, root) != prepared.base_exe_sha256: raise ValueError("BAAH 安装在预下载后发生变化,请重新检查更新") + if recovery_receipt is not None: + verify_recovery_receipt(root, recovery_receipt) await asyncio.to_thread(assert_idle, root) await script_install.run_worker( verify_package, prepared.archive, prepared.size, prepared.digest @@ -266,15 +301,39 @@ def validate(path: Path) -> None: shutil.rmtree(prepared.archive.parent) +def verify_recovery_receipt(root: Path, snapshot: bytes) -> None: + """只允许用户确认的相同 MAS 安装回执触发损坏本体重装。""" + receipt = script_install.record_path(root, "installed.json") + if ( + receipt.is_symlink() + or not receipt.is_file() + or not snapshot + or len(snapshot) > 1024 * 1024 + or receipt.read_bytes() != snapshot + ): + raise script_install.InstallUnsafeError( + "BAAH 安装校验回执已改变,拒绝继续旧恢复请求" + ) + + async def update_before_run( - root: Path, config, *, progress: DownloadProgress | None = None + root: Path, + config, + *, + progress: DownloadProgress | None = None, + recovery_receipt: bytes | None = None, ) -> str: await script_install.run_worker( script_install.recover_install, root, lambda: assert_idle(root) ) - candidate = await prepare_update(root, config, progress=progress) + if recovery_receipt is not None: + verify_recovery_receipt(root, recovery_receipt) + candidate = await prepare_update( + root, config, progress=progress, recovery_receipt=recovery_receipt + ) if candidate is None: await confirm_current_install(root) return "BAAH 当前版本无需更新" - await apply_update(root, candidate) + await apply_update(root, candidate, recovery_receipt=recovery_receipt) + await confirm_current_install(root) return f"BAAH 已更新至 {candidate.version},完整性校验通过" diff --git a/changelog.d/baah-install-recovery.fix.md b/changelog.d/baah-install-recovery.fix.md new file mode 100644 index 0000000000..ed124c5688 --- /dev/null +++ b/changelog.d/baah-install-recovery.fix.md @@ -0,0 +1,2 @@ +project: baah +损坏的托管安装须由用户明确选择官方完整包修复,禁止绕过校验运行 diff --git a/tests/task/test_baah_program_update.py b/tests/task/test_baah_program_update.py index e3ac55a765..f7126e5e09 100644 --- a/tests/task/test_baah_program_update.py +++ b/tests/task/test_baah_program_update.py @@ -1,5 +1,6 @@ """BAAH 下载/安装分离及配置保护;网络与真实程序进程在边界隔离。""" +import asyncio import hashlib import json import shutil @@ -245,6 +246,9 @@ async def test_second_baah_config_waits_for_same_directory(installation, monkeyp Update=config.Update, ) instance.check_result = "Pass" + instance.script_info = SimpleNamespace( + script_id=str(uuid.uuid4()), set_log=AsyncMock() + ) # 第一个任务在整个用户运行期间独占目录;第二个任务要等待。 await one._prepare_program_update() @@ -278,3 +282,154 @@ def test_real_idle_whitelist_rejects_baah_but_allows_known_adb( ) with pytest.raises(script_install.InstallUnsafeError): REAL_ASSERT_IDLE(root, allow_background_adb=True) + + +@pytest.mark.asyncio +async def test_explicit_repair_can_reinstall_missing_baah_exe(installation): + root, config, _, _ = installation + original = await update.prepare_update(root, config) + await update.apply_update(root, original) + receipt = script_install.record_path(root, "installed.json") + assert receipt.is_file() + snapshot = receipt.read_bytes() + (root / "BAAH.exe").unlink() + with pytest.raises(script_install.InstallUnsafeError): + await update.confirm_current_install(root) + with pytest.raises(OSError): + await update.prepare_update(root, config) + result = await update.update_before_run(root, config, recovery_receipt=snapshot) + assert "校验通过" in result + assert (root / "BAAH.exe").read_bytes() == pe(b"new") + assert not (script_install.workspace(root) / "transaction.json").exists() + await update.confirm_current_install(root) + + +@pytest.mark.asyncio +async def test_repair_refuses_changed_receipt(installation): + root, config, _, _ = installation + candidate = await update.prepare_update(root, config) + await update.apply_update(root, candidate) + receipt = script_install.record_path(root, "installed.json") + previous = receipt.read_bytes() + receipt.write_text('{"files":{}}') + with pytest.raises(script_install.InstallUnsafeError, match="已改变"): + await update.update_before_run(root, config, recovery_receipt=previous) + assert (root / "BAAH.exe").is_file() + + +@pytest.mark.asyncio +async def test_manager_without_takeover_repairs_corrupt_receipt_via_common_entry( + installation, monkeypatch +): + root, config, _, _ = installation + prepared = await update.prepare_update(root, config) + await update.apply_update(root, prepared) + (root / "BAAH.exe").write_bytes(pe(b"corrupt")) + config.Update.TakeoverEnabled = False + + instance = object.__new__(manager.BAAHManager) + instance._install_access = ExitStack() + instance._directory_lock = None + instance.script_config = SimpleNamespace( + Script=SimpleNamespace(BAAHPath=root / "BAAH.exe"), + Update=config.Update, + ) + instance.script_info = SimpleNamespace( + script_id=str(uuid.uuid4()), name="BAAH A", set_log=AsyncMock() + ) + instance.task_info = SimpleNamespace(task_id="task", mode="AutoProxy") + common = AsyncMock(return_value="reinstalled") + monkeypatch.setattr(manager, "auto_recover_script_install", common) + await instance._prepare_program_update() + common.assert_awaited_once() + instance._install_access.close() + if instance._directory_lock is not None: + instance._directory_lock.release() + + +@pytest.mark.asyncio +async def test_baah_stale_journal_automatically_requires_verified_receipt( + installation, monkeypatch +): + from app.task.BAAH.tools.managed_recovery import inspect_stale_transaction + + root, config, _, _ = installation + candidate = await update.prepare_update(root, config) + await update.apply_update(root, candidate) + marker = script_install.workspace(root) / "transaction.json" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_bytes(b'{"state":"completed"}') + state = await inspect_stale_transaction(root) + assert state.pending and state.healthy and state.updated_confirmed + (root / "BAAH.exe").write_bytes(b"bad") + unhealthy = await inspect_stale_transaction(root) + assert unhealthy.pending and not unhealthy.healthy + + +@pytest.mark.asyncio +async def test_baah_corrupt_install_has_no_gui_dependency(installation, monkeypatch): + root, config, _, _ = installation + candidate = await update.prepare_update(root, config) + await update.apply_update(root, candidate) + (root / "BAAH.exe").unlink() + config.Update.TakeoverEnabled = False + manager_mock = object.__new__(manager.BAAHManager) + manager_mock._install_access = ExitStack() + manager_mock._directory_lock = None + manager_mock.script_config = SimpleNamespace( + Script=SimpleNamespace(BAAHPath=root / "BAAH.exe"), + Update=config.Update, + ) + manager_mock.script_info = SimpleNamespace( + script_id=str(uuid.uuid4()), name="BAAH", set_log=AsyncMock() + ) + manager_mock.task_info = SimpleNamespace(task_id="task", mode="AutoProxy") + monkeypatch.setattr( + manager, "auto_recover_script_install", AsyncMock(return_value="reinstalled") + ) + await manager_mock._prepare_program_update() + manager.auto_recover_script_install.assert_awaited_once() + manager_mock._install_access.close() + if manager_mock._directory_lock is not None: + manager_mock._directory_lock.release() + + +@pytest.mark.asyncio +async def test_receipt_cleanup_cancellation_retains_installation_lock( + installation, monkeypatch +): + from app.task.BAAH.tools import managed_recovery + + root, _, _, _ = installation + managed_recovery.snapshot_original_receipts(root, threading.Event()) + entered = threading.Event() + release = threading.Event() + original = managed_recovery.cleanup_original_receipts + + def slow_cleanup(install_root, cancelled): + entered.set() + if not release.wait(5): + raise RuntimeError("test cleanup did not resume") + return original(install_root, cancelled) + + with script_install.installation_access(root): + pending = asyncio.create_task(script_install.run_worker(slow_cleanup, root)) + await asyncio.wait_for(asyncio.to_thread(entered.wait, 2), 3) + pending.cancel() + # Second cancellation is possible while the first is waiting for + # worker termination. run_worker must still retain installation ownership. + pending.cancel() + await asyncio.sleep(0) + with pytest.raises(script_install.InstallUnsafeError): + with script_install.installation_access(root): + pass + release.set() + with pytest.raises(asyncio.CancelledError): + await pending + await asyncio.sleep(0) + assert managed_recovery.receipt_backup_path(root).exists() + with script_install.installation_access(root): + await script_install.run_worker( + managed_recovery.cleanup_original_receipts, root + ) + assert not managed_recovery.receipt_backup_path(root).exists()