diff --git a/app/task/MaaEnd/manager.py b/app/task/MaaEnd/manager.py index 0ddb67f681..51034ffd5f 100644 --- a/app/task/MaaEnd/manager.py +++ b/app/task/MaaEnd/manager.py @@ -24,7 +24,11 @@ from app.config import ConfigCollection, config_manager from app.core import Config, EmulatorManager, Notify, Publisher from app.core.notify.task_content import screenshot_entries as screenshot_entries -from app.core.script_update import ScriptUpdateRequest, run_script_update +from app.core.script_update import ( + ScriptUpdateRequest, + auto_recover_script_install, + run_script_update, +) from app.core.ws import protocol from app.models.config import MaaEndConfig, MaaEndUserConfig, UserConfigType from app.models.emulator import DeviceProvider @@ -52,6 +56,7 @@ from .resource_loader import MaaEndResourceLoader, load_maaend_controller_protocol from .ScriptConfig import ScriptConfigTask, maaend_config_base from .tools import script_update +from .tools.managed_recovery import MaaEndInstallAdapter from .tools.backup_archive import archive_native_backup from .tools.update import ensure_game_updated from .Update import EndfieldUpdateTask @@ -335,6 +340,11 @@ async def progress(downloaded: int, total: int, speed: float) -> None: ) else: await push_dispatch_log(self.script_info, result) + await self._refresh_after_program_install(root) + self._program_update_prepared = True + + async def _refresh_after_program_install(self, root: Path) -> None: + """不重复安装,但必须照常校验资源并清理过时的 MXU 缓存包。""" await asyncio.to_thread( MaaEndResourceLoader.get_cached, root, force_reload=True ) @@ -349,7 +359,6 @@ async def progress(downloaded: int, total: int, speed: float) -> None: self.script_info, f"已移除 {len(removed)} 个过期 MXU 更新包;MXU 启动时将清除对应的待安装记录", ) - self._program_update_prepared = True async def _restore_script_config_from_temp(self) -> None: """恢复任务开始前的 MaaEnd working 配置。""" @@ -485,17 +494,82 @@ async def main_task(self): if (script_install.workspace(root) / "runtime-interface.json").exists(): await asyncio.to_thread(script_update.assert_idle, root) script_update.recover_runtime_interface(root) - await script_install.run_worker( - script_install.recover_install, + journal = script_install.workspace(root) / "transaction.json" + if journal.is_symlink(): + raise script_install.InstallUnsafeError("MaaEnd 原生事务路径不安全") + adapter = MaaEndInstallAdapter( root, - lambda: script_update.assert_idle(root), + config, + takeover_enabled=config.Update.TakeoverEnabled, + allow_damaged_repair=( + self.execution_mode == "AutoProxy" and config.Update.TakeoverEnabled + ), ) + request = ScriptUpdateRequest(str(self.script_info.script_id), "MaaEnd") + recovered_automatically = False + try: + # 原生事务恢复不能以旧 MAS 收据校验作为前置门槛; + # MXU 独立更新后的收据可能旧,但本体仍可正确修复。 + await adapter.recover_native() + except script_install.InstallUnsafeError: + if not journal.is_file(): + raise + if journal.is_file(): + # 只有无可回滚目录、原生安装完整且身份可信时,公共层 + # 才允许清理过期 journal;否则重新尝试原生恢复。 + outcome = await auto_recover_script_install( + request, + install_root=root, + inspect=adapter.inspect, + acknowledge=adapter.acknowledge, + reinstall=adapter.retry_native, + ) + if outcome not in {"verified", "reinstalled"}: + raise script_install.InstallUnsafeError( + "MaaEnd 原生事务未完成,拒绝启动" + ) + await self.script_info.set_log(f"MaaEnd 原生事务自动恢复:{outcome}") + recovered_automatically = True + # 关闭接管或配置会话不主动覆盖用户自行维护的 MaaEnd。 + # 仅在已开启托管且存在可信安装身份时才自动修复损坏的发行包。 + identity = script_install.record_path(root, "program-identity.json") + if ( + self.execution_mode == "AutoProxy" + and config.Update.TakeoverEnabled + and identity.is_file() + and not identity.is_symlink() + ): + state = await adapter.inspect() + if not state.healthy: + if state.pending: + raise script_install.InstallUnsafeError( + "MaaEnd 尚有未完成原生事务,不能重新安装" + ) + outcome = await auto_recover_script_install( + request, + install_root=root, + inspect=adapter.inspect_repair, + acknowledge=adapter.acknowledge, + reinstall=adapter.repair_program, + ) + if outcome != "reinstalled": + raise script_install.InstallUnsafeError( + "MaaEnd 完整包自动修复尚未确认成功" + ) + await self.script_info.set_log( + f"MaaEnd 官方完整包自动修复:{outcome}" + ) + recovered_automatically = True if self.execution_mode == "AutoProxy" and config.Update.TakeoverEnabled: self.maaend_config_dir = root / "config" self.temp_path = Path.cwd() / f"data/{self.script_info.script_id}/Temp" self._recover_previous_run() # 修复在原有资源预检之前,损坏的旧资源不能堵死恢复入口。 - await self._prepare_program_update(root) + if recovered_automatically: + await self._refresh_after_program_install(root) + self._program_update_prepared = True + else: + await self._prepare_program_update(root) elif script_install.record_path(root, "installed.json").exists(): # 非接管模式沿用 MXU 自行更新的安装。恢复事务仍须完成, # 但旧 MAS 回执不应阻断后续配置或运行,也不重算哈希冒充可信。 diff --git a/app/task/MaaEnd/tools/managed_recovery.py b/app/task/MaaEnd/tools/managed_recovery.py new file mode 100644 index 0000000000..9e52b04420 --- /dev/null +++ b/app/task/MaaEnd/tools/managed_recovery.py @@ -0,0 +1,213 @@ +# Copyright © 2025-2026 AUTO-MAS Team +# SPDX-License-Identifier: AGPL-3.0-or-later +"""MaaEnd 安装状态与修复适配器。公共 #1916 负责决定恢复策略。""" + +import asyncio +import shutil +import threading +from pathlib import Path +from uuid import uuid4 + +from app.core.script_update_recovery import InstallInspection +from app.services import script_install + +from . import script_update + +_MAX_TRANSACTION_BYTES = 128 * 1024 * 1024 + + +def discard_uncommitted_staging( + root: Path, + takeover_enabled: bool, + allow_damaged_repair: bool, + cancelled: threading.Event, +) -> bool: + """只清理尚未写入事务日志的安装暂存,不接触已开始切换的目录。""" + directory = script_install.workspace(root) + if not directory.exists() and not directory.is_symlink(): + return False + script_install._reject_link(directory) + journal = directory / "transaction.json" + previous = directory / "previous" + ready = directory / "ready" + package = directory / "package" + for path in (journal, previous, ready, package): + if path.exists() or path.is_symlink(): + script_install._reject_link(path) + if journal.exists() or previous.exists() or not ready.exists(): + return False + if not ready.is_dir(): + raise script_install.InstallUnsafeError("MaaEnd 暂存目录类型异常") + script_install._check_cancel(cancelled) + script_update.assert_idle(root) + script_install._reject_link(root) + # 优先核验健康旧版;损坏本体或旧收据失效时只能在已授权的 + # AutoProxy 托管修复路径里凭 MAS 自有可信身份清理未提交暂存。 + # 清理不代表安装已健康;manager 后续仍须调用公共入口重装并验证。 + try: + script_update.read_install(root) + receipt = script_install.record_path(root, "installed.json") + if takeover_enabled and receipt.exists(): + if not script_install.verify_installed_files(root, cancelled): + raise script_install.InstallUnsafeError("MaaEnd 原安装收据校验未通过") + except (OSError, ValueError, script_install.InstallUnsafeError) as error: + if not (takeover_enabled and allow_damaged_repair): + raise + identity = script_install.record_path(root, "program-identity.json") + if identity.is_symlink() or not identity.is_file(): + raise script_install.InstallUnsafeError( + "MaaEnd 安装损坏且缺少可信 MAS 身份,保留未提交暂存" + ) from error + snapshot = identity.read_bytes() + script_update.verify_repair_identity(root, snapshot) + script_install._check_cancel(cancelled) + # 删暂存前再检查一次:确认没有任何外部写入开始目录切换。 + script_install._check_cancel(cancelled) + script_update.assert_idle(root) + if journal.exists() or previous.exists(): + raise script_install.InstallUnsafeError("MaaEnd 事务状态已变化,保留暂存") + for path in (ready, package): + if path.exists() or path.is_symlink(): + script_install._reject_link(path) + if not path.is_dir(): + raise script_install.InstallUnsafeError("MaaEnd 更新暂存类型异常") + script_install._check_cancel(cancelled) + shutil.rmtree(path) + return True + + +class MaaEndInstallAdapter: + def __init__( + self, + root: Path, + config, + *, + takeover_enabled: bool, + allow_damaged_repair: bool = False, + ) -> None: + self.root = root + self.config = config + self.takeover_enabled = takeover_enabled + self.allow_damaged_repair = allow_damaged_repair + + async def ensure_idle(self) -> None: + await asyncio.to_thread(script_update.assert_idle, self.root) + + async def inspect(self) -> InstallInspection: + await self.ensure_idle() + directory = script_install.workspace(self.root) + marker = directory / "transaction.json" + previous = directory / "previous" + ready = directory / "ready" + if any(p.is_symlink() for p in (directory, marker, previous, ready)): + raise script_install.InstallUnsafeError("MaaEnd 原生安装事务路径不安全") + pending = marker.exists() + if previous.exists() or ready.exists(): + return InstallInspection(pending=True, healthy=False) + try: + version, architecture = await asyncio.to_thread( + script_update.read_install, self.root + ) + healthy = architecture in {"x86_64", "aarch64"} + receipt = script_install.record_path(self.root, "installed.json") + if healthy and self.takeover_enabled and receipt.is_file(): + healthy = await script_install.run_worker( + script_install.verify_installed_files, self.root + ) + except (OSError, ValueError, script_install.InstallUnsafeError): + return InstallInspection(pending=pending, healthy=False) + return InstallInspection( + pending=pending, + healthy=healthy, + installed_version=str(version), + # 原安装记录存在不等于新版本已完成,必须再次检查回执。 + updated_confirmed=healthy and pending, + previous_confirmed=healthy and not pending, + ) + + async def acknowledge(self) -> None: + """只归档无原版备份、版本和安装清单仍完整的遗留 journal。""" + state = await self.inspect() + if not (state.pending and state.healthy and state.updated_confirmed): + raise script_install.InstallUnsafeError( + "MaaEnd 遗留事务尚未完成,不允许清除记录" + ) + directory = script_install.workspace(self.root) + marker = directory / "transaction.json" + snapshot = marker.read_bytes() + if not snapshot or len(snapshot) > _MAX_TRANSACTION_BYTES: + raise script_install.InstallUnsafeError("原生事务标记无效") + await self.ensure_idle() + if marker.is_symlink() or marker.read_bytes() != snapshot: + raise script_install.InstallUnsafeError("事务内容在恢复时发生变化") + if any( + p.exists() or p.is_symlink() + for p in (directory / "previous", directory / "ready") + ): + raise script_install.InstallUnsafeError("仍有可恢复的安装目录") + archive = directory / f"maaend-transaction-ack-{uuid4().hex}.json" + marker.replace(archive) + try: + await script_update.confirm_current_install( + self.root, verify_receipt=self.takeover_enabled + ) + except BaseException: + if archive.exists() and not marker.exists(): + archive.replace(marker) + raise + + async def recover_native(self) -> None: + """仅恢复原生目录事务;安装健康验证留给后续公共恢复入口。""" + await script_install.run_worker( + script_install.recover_install, + self.root, + lambda: script_update.assert_idle(self.root), + ) + # 安装器在 ready 复制后、写 journal 前取消时,原安装未切换; + # 仅清理这种未提交状态。任何 previous/journal 均不可触碰。 + await script_install.run_worker( + discard_uncommitted_staging, + self.root, + self.takeover_enabled, + self.allow_damaged_repair, + ) + + async def retry_native(self) -> None: + await self.recover_native() + await script_update.confirm_current_install( + self.root, verify_receipt=self.takeover_enabled + ) + + async def inspect_repair(self) -> InstallInspection: + """将无 journal、但有可信身份的损坏安装表达为公共待恢复态。""" + state = await self.inspect() + if state.pending or state.healthy: + return state + return InstallInspection( + pending=True, + healthy=False, + installed_version=state.installed_version, + previous_confirmed=False, + updated_confirmed=False, + ) + + async def repair_program(self) -> None: + identity = script_install.record_path(self.root, "program-identity.json") + if identity.is_symlink() or not identity.is_file(): + raise script_install.InstallUnsafeError( + "当前 MaaEnd 安装损坏且缺少可信架构身份,不能自动重装" + ) + snapshot = identity.read_bytes() + script_update.verify_repair_identity(self.root, snapshot) + # 可识别的 MXU 原生升级不应被当作损坏 EXE,更不能降低版本。 + # 仅在原生安装身份本身无法读取时走带身份绑定的强制修复; + # 旧 MAS 回执失效时保留正常更新器的精确版本官方包复核。 + try: + await asyncio.to_thread(script_update.read_install, self.root) + except (OSError, ValueError): + await script_update.update_before_run( + self.root, self.config, recovery_identity=snapshot + ) + else: + await script_update.update_before_run(self.root, self.config) + await script_update.confirm_current_install(self.root) diff --git a/app/task/MaaEnd/tools/script_update.py b/app/task/MaaEnd/tools/script_update.py index 516a3c35b7..cc83e809ad 100644 --- a/app/task/MaaEnd/tools/script_update.py +++ b/app/task/MaaEnd/tools/script_update.py @@ -363,13 +363,60 @@ def validate(path: Path) -> None: discard_update(candidate) +def verify_repair_identity(root: Path, snapshot: bytes) -> None: + """只对用户授权的同一份既有 MAS 安装身份执行修复,拒绝换根/换架构。""" + path = script_install.record_path(root, "program-identity.json") + if ( + path.is_symlink() + or not path.is_file() + or not snapshot + or len(snapshot) > 16 * 1024 + or path.read_bytes() != snapshot + ): + raise script_install.InstallUnsafeError( + "MaaEnd 安装身份记录已变化,不允许继续旧修复请求" + ) + try: + record = json.loads(snapshot) + if record.get("arch") not in ("x86_64", "aarch64"): + raise ValueError("untrusted architecture") + except (ValueError, TypeError, AttributeError) as error: + raise script_install.InstallUnsafeError( + "MaaEnd 安装身份缺少可信架构,必须由官方安装器手动修复" + ) from error + + async def update_before_run( - root: Path, config: MaaEndConfig, *, progress: DownloadProgress | None = None + root: Path, + config: MaaEndConfig, + *, + progress: DownloadProgress | None = None, + recovery_identity: bytes | None = None, ) -> str: """手动与无预取路径仍在安装锁下完整等待更新。""" await asyncio.to_thread(assert_idle, root) + if recovery_identity is not None: + verify_repair_identity(root, recovery_identity) + try: + await asyncio.to_thread(read_install, root) + except (OSError, ValueError): + pass + else: + raise script_install.InstallUnsafeError( + "MaaEnd 已由外部安装器修复,请重新检查,不再沿用旧请求" + ) candidate = await prepare_update(root, config, progress=progress) try: + if recovery_identity is not None: + verify_repair_identity(root, recovery_identity) + try: + await asyncio.to_thread(read_install, root) + except (OSError, ValueError): + pass + else: + raise script_install.InstallUnsafeError( + "MaaEnd 安装在下载期间已变化,请重新检查" + ) return await apply_update(root, candidate) finally: discard_update(candidate) diff --git a/changelog.d/maaend-transaction-recovery.fix.md b/changelog.d/maaend-transaction-recovery.fix.md new file mode 100644 index 0000000000..aee320393d --- /dev/null +++ b/changelog.d/maaend-transaction-recovery.fix.md @@ -0,0 +1,2 @@ +project: maaend +MaaEnd 安装异常时统一恢复事务,并安全修复可信身份的损坏本体 diff --git a/tests/task/test_maaend_program_update.py b/tests/task/test_maaend_program_update.py index 9e7f70632d..07741b5c99 100644 --- a/tests/task/test_maaend_program_update.py +++ b/tests/task/test_maaend_program_update.py @@ -936,3 +936,291 @@ async def test_no_update_prefetch_is_rechecked_at_script_start( await scheduler._prepare_program_update(root) check.assert_awaited_once() apply.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_maaend_stale_journal_verified_by_public_native_adapter(installation): + from app.task.MaaEnd.tools.managed_recovery import MaaEndInstallAdapter + + root, config = installation + work = script_install.workspace(root) + work.mkdir(parents=True, exist_ok=True) + marker = work / "transaction.json" + marker.write_bytes(b"stale-journal") + adapter = MaaEndInstallAdapter(root, config, takeover_enabled=False) + state = await adapter.inspect() + assert state.pending and state.healthy and state.updated_confirmed + await adapter.acknowledge() + assert not marker.exists() + assert list(work.glob("maaend-transaction-ack-*.json")) + + +@pytest.mark.asyncio +async def test_maaend_old_backup_prevents_journal_bypass(installation): + from app.task.MaaEnd.tools.managed_recovery import MaaEndInstallAdapter + + root, config = installation + work = script_install.workspace(root) + work.mkdir(parents=True, exist_ok=True) + marker = work / "transaction.json" + marker.write_bytes(b"stale-journal") + (work / "previous").mkdir() + adapter = MaaEndInstallAdapter(root, config, takeover_enabled=True) + state = await adapter.inspect() + assert state.pending and not state.healthy + with pytest.raises(script_install.InstallUnsafeError): + await adapter.acknowledge() + assert marker.exists() + + +@pytest.mark.asyncio +async def test_maaend_bad_exe_stays_blocked_without_frontend(installation): + from app.task.MaaEnd.tools.managed_recovery import MaaEndInstallAdapter + + root, config = installation + marker = script_install.workspace(root) / "transaction.json" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_bytes(b"stale-journal") + (root / "MaaEnd.exe").write_bytes(b"damaged") + adapter = MaaEndInstallAdapter(root, config, takeover_enabled=False) + state = await adapter.inspect() + assert state.pending and not state.healthy + assert marker.exists() + + +@pytest.mark.asyncio +async def test_maaend_repaired_native_build_can_ignore_stale_receipt_without_takeover( + installation, +): + from app.task.MaaEnd.tools.managed_recovery import MaaEndInstallAdapter + + root, config = installation + await update.update_before_run(root, config) + (root / "interface.json").write_text( + json.dumps({"name": "MaaEnd", "version": "v2.3.0", "mirrorchyan_rid": "MaaEnd"}) + ) + marker = script_install.workspace(root) / "transaction.json" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_bytes(b"stale-journal") + adapter = MaaEndInstallAdapter(root, config, takeover_enabled=False) + state = await adapter.inspect() + assert state.pending and state.healthy + await adapter.acknowledge() + assert not marker.exists() + + +async def _interrupt_native_install_after_ready(root: Path, monkeypatch): + """调用真实原生安装器,在写入 transaction.json 前故障注入。""" + archive = root.parent / "official.zip" + original_copytree = script_install.shutil.copytree + + def copy_then_interrupt(source, destination, *args, **kwargs): + result = original_copytree(source, destination, *args, **kwargs) + if Path(destination).name == "ready": + raise OSError("injected failure after ready copy") + return result + + with monkeypatch.context() as patch: + patch.setattr(script_install.shutil, "copytree", copy_then_interrupt) + with pytest.raises(OSError, match="after ready copy"): + await script_install.run_worker( + script_install.install_package, + root, + archive, + archive.stat().st_size, + hashlib.sha256(archive.read_bytes()).hexdigest(), + update.PROTECTED, + lambda path: None, + lambda: None, + ) + workspace = script_install.workspace(root) + assert (workspace / "ready").is_dir() + assert (workspace / "package").is_dir() + assert not (workspace / "transaction.json").exists() + assert not (workspace / "previous").exists() + + +async def _start_real_maaend_manager_for_repair(root: Path, monkeypatch): + """保留真实 main_task / adapter / #1916 公共恢复层,只截断游戏任务。""" + from app.models.config import MaaEndConfig + + home = root.parent / "mas-home" + home.mkdir(exist_ok=True) + monkeypatch.chdir(home) + config = MaaEndConfig.model_validate( + {"Info": {"Path": str(root)}, "Update": {"TakeoverEnabled": True}} + ) + await config.activate() + script_id = config.uid + info = SimpleNamespace( + task_info=SimpleNamespace(mode="AutoProxy", task_id="test"), + script_id=str(script_id), + user_list=[], + status="等待", + log="", + set_log=AsyncMock(), + ) + instance = manager.MaaEndManager(info) + instance.check = AsyncMock(return_value="test stop before game startup") + lock, unlock = AsyncMock(return_value="ticket"), AsyncMock() + monkeypatch.setattr(MaaEndConfig, "try_lock_x", lock) + monkeypatch.setattr(MaaEndConfig, "unlock", unlock) + monkeypatch.setattr(manager.Config, "ScriptConfig", {script_id: config}) + monkeypatch.setattr(manager.MaaEndResourceLoader, "get_cached", Mock()) + monkeypatch.setattr( + manager, "load_maaend_controller_protocol", lambda *args: "Win32" + ) + monkeypatch.setattr(manager, "push_dispatch_log", AsyncMock()) + monkeypatch.setattr(manager.Publisher, "send", AsyncMock()) + return instance, lock, unlock + + +@pytest.mark.asyncio +async def test_manager_repairs_damaged_exe_with_no_native_journal_via_common_entry( + installation, monkeypatch +): + root, existing_config = installation + await update.update_before_run(root, existing_config) + (root / "MaaEnd.exe").write_bytes(b"damaged executable") + await _interrupt_native_install_after_ready(root, monkeypatch) + instance, lock, unlock = await _start_real_maaend_manager_for_repair( + root, monkeypatch + ) + await instance.main_task() + assert update.read_install(root) == (Version("2.2.0"), "x86_64") + assert script_install.verify_installed_files(root, threading.Event()) + assert (root / "config/mxu-MaaEnd.json").read_bytes() == b'{"user-data": true}' + assert not (script_install.workspace(root) / "ready").exists() + assert any( + "reinstalled" in str(call) + for call in instance.script_info.set_log.await_args_list + ) + lock.assert_awaited_once() + await instance.final_task() + unlock.assert_awaited_once_with("ticket") + + +@pytest.mark.asyncio +async def test_manager_repairs_native_upgrade_receipt_at_exact_newer_version( + installation, tmp_path, monkeypatch +): + root, existing_config = installation + await update.update_before_run(root, existing_config) + native_tag = "v2.3.0-beta.1" + (root / "interface.json").write_text( + json.dumps( + {"name": "MaaEnd", "version": native_tag, "mirrorchyan_rid": "MaaEnd"} + ) + ) + archive, release = _official_release_for_test(tmp_path, native_tag) + exact = AsyncMock(return_value=release) + monkeypatch.setattr(update, "request_release_for_version", exact) + + async def download_exact(_url, destination, **_kwargs): + shutil.copyfile(archive, destination) + + monkeypatch.setattr(update, "download_file", AsyncMock(side_effect=download_exact)) + await _interrupt_native_install_after_ready(root, monkeypatch) + instance, _, _ = await _start_real_maaend_manager_for_repair(root, monkeypatch) + await instance.main_task() + assert not (script_install.workspace(root) / "ready").exists() + assert update.read_install(root) == (Version(native_tag), "x86_64") + assert script_install.verify_installed_files(root, threading.Event()) + exact.assert_awaited_once_with(Version(native_tag), native_tag) + await instance.final_task() + + +@pytest.mark.asyncio +async def test_manager_recovers_ready_left_before_native_journal( + installation, monkeypatch +): + """真实安装器完成 ready 复制后异常,下一轮真实 manager 可安全清理重跑。""" + root, config = installation + await update.update_before_run(root, config) + original_version = update.read_install(root) + archive = root.parent / "official.zip" + candidate = update.PreparedUpdate( + root.resolve(), + original_version, + original_version[0], + original_version[1], + archive, + archive.stat().st_size, + hashlib.sha256(archive.read_bytes()).hexdigest(), + ) + original_copytree = script_install.shutil.copytree + + def fail_after_ready(*args, **kwargs): + result = original_copytree(*args, **kwargs) + if Path(args[1]).name == "ready": + raise OSError("interrupted after ready copy, before transaction") + return result + + with monkeypatch.context() as patch: + patch.setattr(script_install.shutil, "copytree", fail_after_ready) + with pytest.raises(OSError, match="before transaction"): + await update.apply_update(root, candidate) + + work = script_install.workspace(root) + assert (work / "ready").is_dir() + assert (work / "package").is_dir() + assert not (work / "transaction.json").exists() + assert not (work / "previous").exists() + assert update.read_install(root) == original_version + assert script_install.verify_installed_files(root, threading.Event()) + instance, _, unlock = await _start_real_maaend_manager_for_repair(root, monkeypatch) + await instance.main_task() + assert not (work / "ready").exists() + assert not (work / "package").exists() + assert update.read_install(root) == original_version + assert script_install.verify_installed_files(root, threading.Event()) + await instance.final_task() + unlock.assert_awaited_once_with("ticket") + + +@pytest.mark.asyncio +async def test_maaend_precommit_cleanup_preserves_previous_and_invalid_original( + installation, +): + from app.task.MaaEnd.tools.managed_recovery import MaaEndInstallAdapter + + root, config = installation + await update.update_before_run(root, config) + work = script_install.workspace(root) + work.mkdir(parents=True, exist_ok=True) + (work / "ready").mkdir() + (work / "previous").mkdir() + adapter = MaaEndInstallAdapter(root, config, takeover_enabled=True) + with pytest.raises(script_install.InstallUnsafeError, match="备份存在"): + await adapter.recover_native() + assert (work / "ready").exists() and (work / "previous").exists() + + (work / "previous").rmdir() + (root / "MaaEnd.exe").write_bytes(b"damaged") + with pytest.raises((OSError, ValueError, script_install.InstallUnsafeError)): + await adapter.recover_native() + assert (work / "ready").exists() + assert not (work / "transaction.json").exists() + + +@pytest.mark.asyncio +async def test_precommit_staging_repair_requires_trusted_identity( + installation, monkeypatch +): + from app.task.MaaEnd.tools.managed_recovery import MaaEndInstallAdapter + + root, config = installation + await update.update_before_run(root, config) + (root / "MaaEnd.exe").write_bytes(b"damaged") + await _interrupt_native_install_after_ready(root, monkeypatch) + script_install.record_path(root, "program-identity.json").unlink() + adapter = MaaEndInstallAdapter( + root, + config, + takeover_enabled=True, + allow_damaged_repair=True, + ) + with pytest.raises(script_install.InstallUnsafeError, match="可信 MAS 身份"): + await adapter.recover_native() + assert (script_install.workspace(root) / "ready").exists() + assert (root / "MaaEnd.exe").read_bytes() == b"damaged"