From 77fbe24ec04cf73d8d2553be111a90377c1f4f11 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Sun, 26 Jul 2026 21:43:44 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(hsr):=20=E6=96=B0=E5=A2=9E=E6=89=98?= =?UTF-8?q?=E7=AE=A1=E6=9B=B4=E6=96=B0=E4=BA=8B=E5=8A=A1=E5=86=85=E6=A0=B8?= =?UTF-8?q?=E4=B8=8E=E9=80=82=E9=85=8D=E5=99=A8=E5=AE=89=E8=A3=85=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 managed_update 事务内核:SHA-256 校验、安全解压、跨进程锁、journal 与崩溃恢复 - SRA/M7A 新增 installation.py 托管布局声明与配置访问模块 - 扩展 contracts/schema/registry 支持养成目标契约与权威运行时 Co-Authored-By: Claude Fable 5 --- .../src/automas_hsr_adapter_m7a/catalog.py | 2 +- .../src/automas_hsr_adapter_m7a/config.py | 51 +- .../src/automas_hsr_adapter_m7a/control.py | 44 +- .../automas_hsr_adapter_m7a/installation.py | 150 + .../src/automas_hsr_adapter_m7a/plugin.py | 3 + .../src/automas_hsr_adapter_sra/catalog.py | 2 +- .../src/automas_hsr_adapter_sra/control.py | 9 +- .../automas_hsr_adapter_sra/installation.py | 194 + .../src/automas_hsr_adapter_sra/plugin.py | 3 + .../src/automas_hsr_adapter_sra/runner.py | 15 +- .../src/automas_script_hsr/__init__.py | 30 + .../src/automas_script_hsr/adapter_plugin.py | 14 +- .../src/automas_script_hsr/contracts.py | 150 +- .../src/automas_script_hsr/managed_update.py | 3237 +++++++++++++++++ .../src/automas_script_hsr/plugin.py | 12 +- .../src/automas_script_hsr/registry.py | 25 + .../automas_script_hsr/runtime/autoproxy.py | 57 +- .../runtime/config_access.py | 123 + .../src/automas_script_hsr/runtime/game.py | 9 +- .../automas_script_hsr/runtime/log_detect.py | 9 +- .../src/automas_script_hsr/runtime/manager.py | 27 +- .../runtime/manual_review.py | 4 +- .../src/automas_script_hsr/runtime/notify.py | 17 +- .../src/automas_script_hsr/schema.py | 35 +- 24 files changed, 4165 insertions(+), 57 deletions(-) create mode 100644 packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/installation.py create mode 100644 packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/installation.py create mode 100644 packages/automas_script_hsr/src/automas_script_hsr/managed_update.py create mode 100644 packages/automas_script_hsr/src/automas_script_hsr/runtime/config_access.py diff --git a/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/catalog.py b/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/catalog.py index 4ba6f2f..e879034 100644 --- a/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/catalog.py +++ b/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/catalog.py @@ -49,7 +49,7 @@ M7A_DESCRIPTOR = HSRAdapterDescriptor( engine="M7A", display_name="March7thAssistant", - version="2026.4.27", + version="2026.6.8", tasks=M7A_TASKS, supported_modes=("AutoProxy",), capabilities=frozenset({"stage_catalog"}), diff --git a/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/config.py b/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/config.py index 490f1aa..9d4e55c 100644 --- a/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/config.py +++ b/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/config.py @@ -128,6 +128,9 @@ def _echo_of_war_weekday_to_iso(weekday: object) -> int: "echo_of_war_enable", "echo_of_war_timestamp", "build_target_enable", + "build_target_scheme", + "build_target_ornament_weekly_count", + "build_target_use_user_instance_when_only_erosion_and_ornament", "instance_type", "instance_names", "instance_names_challenge_count", @@ -146,9 +149,20 @@ def build_m7a_daily_patch( daily_eow_enabled: bool, main_stage: tuple[str, str] | None = None, eow_name: str | None = None, + cultivation_target: dict | None = None, ) -> dict: - """构造 M7A routine 的运行配置 patch。""" + """构造 M7A routine 的运行配置 patch。 + + ``cultivation_target`` 为公共配置 ``HSRCultivationTargetConfig`` 序列化 + 后的字典(或 None)。当 ``Enabled`` 为 true 时,MAS 接管四个 M7A 原生 + ``build_target_*`` 键;为 false 或缺省时显式写入 + ``build_target_enable=False`` 以避免上一轮脏状态。 + """ + eow_enabled = bool(daily_eow_enabled) + cultivation_enabled = bool( + cultivation_target is not None and cultivation_target.get("Enabled") + ) # 配置不完整时直接报错,避免刷错副本。 if eow_enabled and eow_name is None: @@ -199,12 +213,12 @@ def build_m7a_daily_patch( new_instance_names[main_type] = main_name new_instance_counts[main_type] = count_max patch["power_enable"] = True - elif not eow_enabled: - patch["power_enable"] = False - else: + elif cultivation_enabled or eow_enabled: patch["power_enable"] = True patch["instance_type"] = M7A_NO_OP_INSTANCE_TYPE new_instance_names[M7A_NO_OP_INSTANCE_TYPE] = M7A_NO_OP_INSTANCE_NAME + else: + patch["power_enable"] = False if eow_enabled: assert eow_name is not None, "前置校验保证启用历战余响时 eow_name 不为 None" @@ -219,6 +233,35 @@ def build_m7a_daily_patch( if new_instance_counts: patch["instance_names_challenge_count"] = new_instance_counts + # 培养目标接管:MAS 完全控制四个 build_target_* 键,显式写入以避免上一轮脏状态。 + if cultivation_enabled: + assert cultivation_target is not None + raw_scheme = cultivation_target.get("M7ARecognitionScheme") + if raw_scheme is None or raw_scheme == "": + scheme = "instance" + elif raw_scheme in ("instance", "drop"): + scheme = raw_scheme + else: + raise ValueError(f"build_target_scheme 非法: {raw_scheme!r}") + ornament_count = cultivation_target.get("M7AOrnamentWeeklyCount") + if ( + not isinstance(ornament_count, int) + or isinstance(ornament_count, bool) + or ornament_count < 0 + or ornament_count > 7 + ): + raise ValueError( + f"build_target_ornament_weekly_count 越界: {ornament_count!r}" + ) + patch["build_target_enable"] = True + patch["build_target_scheme"] = scheme + patch["build_target_ornament_weekly_count"] = ornament_count + patch["build_target_use_user_instance_when_only_erosion_and_ornament"] = bool( + cultivation_target.get("M7AUseUserStageWhenOnlyRelics") + ) + else: + patch["build_target_enable"] = False + return patch diff --git a/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/control.py b/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/control.py index 270af00..d3af146 100644 --- a/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/control.py +++ b/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/control.py @@ -41,6 +41,31 @@ logger = get_logger("HSR M7A 控制") +def _read_cultivation_target(script_config: Any) -> dict | None: + """从 script_config 提取 CultivationTarget 段,返回原始字段字典。 + + 与 ``script_config.get("M7A", "Path")`` 风格一致:缺省段返回 None, + 其余字段保留原始值(可能为 None),由 ``build_m7a_daily_patch`` 负责 + 默认值填充与边界校验(fail closed)。 + """ + + enabled = script_config.get("CultivationTarget", "Enabled") + if enabled is None: + return None + return { + "Enabled": bool(enabled), + "M7ARecognitionScheme": script_config.get( + "CultivationTarget", "M7ARecognitionScheme" + ), + "M7AOrnamentWeeklyCount": script_config.get( + "CultivationTarget", "M7AOrnamentWeeklyCount" + ), + "M7AUseUserStageWhenOnlyRelics": script_config.get( + "CultivationTarget", "M7AUseUserStageWhenOnlyRelics" + ), + } + + class HSRM7AControl: """M7A 执行项创建与 config.yaml patch 控制。""" @@ -144,6 +169,7 @@ async def execute_m7a_daily( daily_eow_enabled=daily_eow_enabled, main_stage=main_stage, eow_name=resolve_m7a_eow_stage(user_cfg), + cultivation_target=_read_cultivation_target(self.script_config), ) self.write_m7a_patch(m7a_config_path, daily_patch) if not module.m7a_tasks: @@ -175,6 +201,7 @@ def create_patched_item( *, user_name: str, module: HSRTaskModule, + timeout_seconds: int, m7a_path: str, m7a_runner: M7ARunner, patch: dict, @@ -229,9 +256,24 @@ def create_module_item( if module.key == "Daily": daily_main_stage = resolve_m7a_main_stage(user_cfg) - if daily_main_stage is None and not daily_eow_enabled: + cultivation_enabled = bool( + self.script_config.get("CultivationTarget", "Enabled") + ) + if ( + daily_main_stage is None + and not daily_eow_enabled + and not cultivation_enabled + ): self._append_log(f"用户「{user_name}」体力模块无可执行副本,跳过") return None + if ( + daily_main_stage is None + and not daily_eow_enabled + and cultivation_enabled + ): + self._append_log( + f"用户「{user_name}」培养目标已启用,由 M7A 原生识别流程决定副本" + ) async def run_m7a_daily(): return await self.execute_m7a_daily( diff --git a/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/installation.py b/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/installation.py new file mode 100644 index 0000000..b3abaa3 --- /dev/null +++ b/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/installation.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from automas_script_hsr import ( + HSRInstallationSnapshot, + HSRManagedUpdateLayout, + HSRPreservationRule, + HSRTrustedArtifact, + HSRTrustedRelease, + HSRUpdateDescriptor, +) + + +M7A_EXECUTABLE = "March7th Assistant.exe" +M7A_VERSION_PATH = Path("assets") / "config" / "version.txt" + +M7A_MANAGED_LAYOUT = HSRManagedUpdateLayout( + engine="M7A", + required_paths=( + M7A_EXECUTABLE, + M7A_VERSION_PATH.as_posix(), + "assets/app/images/sponsor.jpg", + "module/config/__init__.py", + ), + preserved_paths=( + HSRPreservationRule("config.yaml"), + HSRPreservationRule("config"), + HSRPreservationRule("warp.json"), + HSRPreservationRule("logs"), + HSRPreservationRule("screenshots"), + HSRPreservationRule("settings"), + HSRPreservationRule("smtp_temp.bin"), + ), +) + +M7A_TRUSTED_RELEASE = HSRTrustedRelease( + engine="M7A", + release_id=336015716, + tag="v2026.6.8", + version="2026.6.8", + channel="stable", + published_at="2026-06-08T14:48:28Z", + upstream_immutable=False, + artifacts=( + HSRTrustedArtifact( + asset_id=441830058, + name="March7thAssistant_full.zip", + download_url=( + "https://github.com/moesnow/March7thAssistant/releases/" + "download/v2026.6.8/March7thAssistant_full.zip" + ), + size=747724694, + sha256=( + "6bb1f9a599f94603cddfbc01e0cdd7d4" + "705ba19516027c950bf8067627fcabd9" + ), + ), + ), +) + +M7A_UPDATE_DESCRIPTOR = HSRUpdateDescriptor( + engine="M7A", + repository_url="https://github.com/moesnow/March7thAssistant", + release_check_enabled=False, + managed_enabled=False, + managed_block_reason=( + "插件已具备固定资产、事务内核与 sponsor/checker 成对布局,但宿主" + "鉴权下载/进度接口及真实 Windows M7A 迁移与回滚手测尚未闭合" + ), + trusted_asset_metadata=True, + trusted_releases=(M7A_TRUSTED_RELEASE,), +) + + +def _configured_root(script_config: Any) -> str: + if isinstance(script_config, dict): + group = script_config.get("M7A") + return str((group.get("Path") or "") if isinstance(group, dict) else "") + try: + return str(script_config.get("M7A", "Path") or "") + except (AttributeError, TypeError): + group = getattr(script_config, "M7A", None) + return str(getattr(group, "Path", "") or "") + + +def _read_version(version_path: Path) -> str | None: + try: + lines = version_path.read_text(encoding="utf-8-sig").splitlines() + except (OSError, UnicodeError): + return None + for line in lines: + version = line.strip() + if version: + return version[:128] + return None + + +class M7AUpdateProvider: + descriptor = M7A_UPDATE_DESCRIPTOR + managed_layout = M7A_MANAGED_LAYOUT + + def inspect(self, script_config: Any) -> HSRInstallationSnapshot: + raw_root = _configured_root(script_config).strip() + if not raw_root: + return HSRInstallationSnapshot( + engine="M7A", + installed=False, + ready=False, + root_path="", + executable_path="", + version=None, + version_source=None, + message="未配置三月七助手安装目录", + ) + + root = Path(raw_root).expanduser() + executable = root / M7A_EXECUTABLE + if not executable.is_file(): + return HSRInstallationSnapshot( + engine="M7A", + installed=False, + ready=False, + root_path=str(root), + executable_path=str(executable), + version=None, + version_source=None, + message=f"未找到 {M7A_EXECUTABLE}", + ) + + version = _read_version(root / M7A_VERSION_PATH) + return HSRInstallationSnapshot( + engine="M7A", + installed=True, + ready=True, + root_path=str(root), + executable_path=str(executable), + version=version, + version_source=( + M7A_VERSION_PATH.as_posix() + if version + else None + ), + message=( + "已检测到三月七助手安装" + if version + else "已检测到三月七助手安装,但无法读取版本文件" + ), + ) diff --git a/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/plugin.py b/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/plugin.py index 0b026d1..a2f8227 100644 --- a/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/plugin.py +++ b/packages/automas_hsr_adapter_m7a/src/automas_hsr_adapter_m7a/plugin.py @@ -4,6 +4,7 @@ from .catalog import M7ATaskCatalog from .controller import M7AController +from .installation import M7AUpdateProvider DEFAULT_INSTANCE = { @@ -27,6 +28,8 @@ class Plugin(HSRAdapterPlugin): provides = ["hsr.task_catalog.m7a.v1", "hsr.controller.m7a.v1"] task_catalog_factory = M7ATaskCatalog controller_factory = M7AController + update_provider_factory = M7AUpdateProvider task_catalog_service = "hsr.task_catalog.m7a.v1" controller_service = "hsr.controller.m7a.v1" + update_service = "hsr.update.m7a.v1" display_name = "M7A HSR adapter" diff --git a/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/catalog.py b/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/catalog.py index f667565..9ae282f 100644 --- a/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/catalog.py +++ b/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/catalog.py @@ -49,7 +49,7 @@ SRA_DESCRIPTOR = HSRAdapterDescriptor( engine="SRA", display_name="StarRailAssistant", - version="2.16.1", + version="2.18.3", tasks=SRA_TASKS, supported_modes=("AutoProxy", "ManualReview"), capabilities=frozenset({"account_login", "manual_review", "stage_catalog"}), diff --git a/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/control.py b/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/control.py index 11c1ce7..e1ca691 100644 --- a/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/control.py +++ b/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/control.py @@ -175,10 +175,17 @@ def create_module_item( user_cfg, daily_eow_enabled=daily_eow_enabled, ) + cultivation_enabled = bool( + self.script_config.get("CultivationTarget", "Enabled") + ) tasklist = cfg.get("trailblazePower", {}).get("tasklist") or [] - if not tasklist: + if not tasklist and not cultivation_enabled: self._append_log(f"用户「{user_name}」体力模块无可执行副本,跳过") return None + if not tasklist and cultivation_enabled: + self._append_log( + f"用户「{user_name}」培养目标已启用,由 SRA 原生识别流程决定副本" + ) else: cfg = build_sra_module_config(module, self.script_config, user_cfg) diff --git a/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/installation.py b/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/installation.py new file mode 100644 index 0000000..c6d939e --- /dev/null +++ b/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/installation.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import ctypes +import os +from ctypes import wintypes +from pathlib import Path +from typing import Any + +from automas_script_hsr import ( + HSRInstallationSnapshot, + HSRManagedUpdateLayout, + HSRTrustedArtifact, + HSRTrustedRelease, + HSRUpdateDescriptor, +) + + +SRA_EXECUTABLE = "SRA-cli.exe" + +SRA_MANAGED_LAYOUT = HSRManagedUpdateLayout( + engine="SRA", + required_paths=( + SRA_EXECUTABLE, + "SRA.exe", + ), +) + +SRA_TRUSTED_RELEASE = HSRTrustedRelease( + engine="SRA", + release_id=356763543, + tag="v2.18.3", + version="2.18.3", + channel="stable", + published_at="2026-07-20T14:04:49Z", + upstream_immutable=False, + artifacts=( + HSRTrustedArtifact( + asset_id=483523868, + name="StarRailAssistant_Full_v2.18.3.zip", + download_url=( + "https://github.com/Shasnow/StarRailAssistant/releases/" + "download/v2.18.3/StarRailAssistant_Full_v2.18.3.zip" + ), + size=174872014, + sha256=( + "4ee5a9c407e418c0c5fc5b0b6fbba80" + "c7c0c28ea0e0051ea31e7eddd85e4dd86" + ), + ), + ), +) + +SRA_UPDATE_DESCRIPTOR = HSRUpdateDescriptor( + engine="SRA", + repository_url="https://github.com/Shasnow/StarRailAssistant", + release_check_enabled=False, + managed_enabled=False, + managed_block_reason=( + "插件已具备固定资产与事务安装内核,但宿主鉴权下载/进度接口及真实" + "Windows SRA 安装、占用与回滚手测尚未闭合" + ), + trusted_asset_metadata=True, + trusted_releases=(SRA_TRUSTED_RELEASE,), +) + + +class _VSFixedFileInfo(ctypes.Structure): + _fields_ = [ + ("signature", wintypes.DWORD), + ("structure_version", wintypes.DWORD), + ("file_version_ms", wintypes.DWORD), + ("file_version_ls", wintypes.DWORD), + ("product_version_ms", wintypes.DWORD), + ("product_version_ls", wintypes.DWORD), + ("file_flags_mask", wintypes.DWORD), + ("file_flags", wintypes.DWORD), + ("file_os", wintypes.DWORD), + ("file_type", wintypes.DWORD), + ("file_subtype", wintypes.DWORD), + ("file_date_ms", wintypes.DWORD), + ("file_date_ls", wintypes.DWORD), + ] + + +def _configured_root(script_config: Any) -> str: + if isinstance(script_config, dict): + group = script_config.get("SRA") + return str((group.get("Path") or "") if isinstance(group, dict) else "") + try: + return str(script_config.get("SRA", "Path") or "") + except (AttributeError, TypeError): + group = getattr(script_config, "SRA", None) + return str(getattr(group, "Path", "") or "") + + +def _read_windows_file_version(executable: Path) -> str | None: + """Read PE fixed-file version without introducing a pywin32 dependency.""" + + if os.name != "nt": + return None + + version_api = ctypes.windll.version + handle = wintypes.DWORD() + size = version_api.GetFileVersionInfoSizeW( + str(executable), + ctypes.byref(handle), + ) + if not size: + return None + + buffer = ctypes.create_string_buffer(size) + if not version_api.GetFileVersionInfoW( + str(executable), + 0, + size, + buffer, + ): + return None + + value_pointer = ctypes.c_void_p() + value_length = wintypes.UINT() + if not version_api.VerQueryValueW( + buffer, + "\\", + ctypes.byref(value_pointer), + ctypes.byref(value_length), + ): + return None + if value_length.value < ctypes.sizeof(_VSFixedFileInfo): + return None + + info = ctypes.cast( + value_pointer, + ctypes.POINTER(_VSFixedFileInfo), + ).contents + if info.signature != 0xFEEF04BD: + return None + + parts = ( + info.file_version_ms >> 16, + info.file_version_ms & 0xFFFF, + info.file_version_ls >> 16, + info.file_version_ls & 0xFFFF, + ) + return ".".join(str(part) for part in parts) + + +class SRAUpdateProvider: + descriptor = SRA_UPDATE_DESCRIPTOR + managed_layout = SRA_MANAGED_LAYOUT + + def inspect(self, script_config: Any) -> HSRInstallationSnapshot: + raw_root = _configured_root(script_config).strip() + if not raw_root: + return HSRInstallationSnapshot( + engine="SRA", + installed=False, + ready=False, + root_path="", + executable_path="", + version=None, + version_source=None, + message="未配置 SRA 安装目录", + ) + + root = Path(raw_root).expanduser() + executable = root / SRA_EXECUTABLE + if not executable.is_file(): + return HSRInstallationSnapshot( + engine="SRA", + installed=False, + ready=False, + root_path=str(root), + executable_path=str(executable), + version=None, + version_source=None, + message=f"未找到 {SRA_EXECUTABLE}", + ) + + version = _read_windows_file_version(executable) + return HSRInstallationSnapshot( + engine="SRA", + installed=True, + ready=True, + root_path=str(root), + executable_path=str(executable), + version=version, + version_source="pe.fixed_file_info" if version else None, + message=( + "已检测到 SRA 安装" + if version + else "已检测到 SRA 安装,但无法读取 PE 版本信息" + ), + ) diff --git a/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/plugin.py b/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/plugin.py index da7839f..a56646e 100644 --- a/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/plugin.py +++ b/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/plugin.py @@ -4,6 +4,7 @@ from .catalog import SRATaskCatalog from .controller import SRAController +from .installation import SRAUpdateProvider DEFAULT_INSTANCE = { @@ -27,6 +28,8 @@ class Plugin(HSRAdapterPlugin): provides = ["hsr.task_catalog.sra.v1", "hsr.controller.sra.v1"] task_catalog_factory = SRATaskCatalog controller_factory = SRAController + update_provider_factory = SRAUpdateProvider task_catalog_service = "hsr.task_catalog.sra.v1" controller_service = "hsr.controller.sra.v1" + update_service = "hsr.update.sra.v1" display_name = "SRA HSR adapter" diff --git a/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/runner.py b/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/runner.py index 7cf9c03..37200c3 100644 --- a/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/runner.py +++ b/packages/automas_hsr_adapter_sra/src/automas_hsr_adapter_sra/runner.py @@ -189,9 +189,20 @@ def build_sra_module_config( target[keys[-1]] = value if module.key == "Daily": - config["trailblazePower"]["tasklist"] = _build_sra_trailblaze_tasklist( - user_config, eow_enabled=daily_eow_enabled + cultivation_enabled = bool( + script_config.get("CultivationTarget", "Enabled") ) + if cultivation_enabled: + # 培养目标模式:由 SRA 原生识别流程统一决定副本, + # 不向 tasklist 注入手动副本(含 EOW),避免与原生识别冲突。 + config["trailblazePower"]["useBuildTarget"] = True + config["trailblazePower"]["tasklist"] = [] + else: + # 手动副本模式:严格保持现有行为,显式重置避免上一轮脏状态。 + config["trailblazePower"]["useBuildTarget"] = False + config["trailblazePower"]["tasklist"] = _build_sra_trailblaze_tasklist( + user_config, eow_enabled=daily_eow_enabled + ) config["trailblazePower"]["replenish.enabled"] = False config["trailblazePower"]["replenish.way"] = 0 config["trailblazePower"]["replenish.times"] = 0 diff --git a/packages/automas_script_hsr/src/automas_script_hsr/__init__.py b/packages/automas_script_hsr/src/automas_script_hsr/__init__.py index 5cdd961..a046900 100644 --- a/packages/automas_script_hsr/src/automas_script_hsr/__init__.py +++ b/packages/automas_script_hsr/src/automas_script_hsr/__init__.py @@ -7,6 +7,7 @@ HSRController, HSRControllerSession, HSREngine, + HSRInstallationSnapshot, HSRNativeRunPlan, HSRNativeRunResult, HSRRunRequest, @@ -15,6 +16,21 @@ HSRStageOption, HSRTaskCatalogProvider, HSRTaskDescriptor, + HSRTrustedArtifact, + HSRTrustedRelease, + HSRUpdateChannel, + HSRUpdateDescriptor, + HSRUpdateMode, + HSRUpdateProvider, +) +from .managed_update import ( + HSRManagedUpdateCrash, + HSRManagedUpdateError, + HSRManagedUpdateLayout, + HSRManagedUpdateResult, + HSRManagedUpdater, + HSRPreservationRule, + safe_extract_hsr_archive, ) from .registry import HSRRegistryService @@ -25,6 +41,12 @@ "HSRController", "HSRControllerSession", "HSREngine", + "HSRInstallationSnapshot", + "HSRManagedUpdateCrash", + "HSRManagedUpdateError", + "HSRManagedUpdateLayout", + "HSRManagedUpdateResult", + "HSRManagedUpdater", "HSRNativeRunPlan", "HSRNativeRunResult", "HSRRegistryService", @@ -34,4 +56,12 @@ "HSRStageOption", "HSRTaskCatalogProvider", "HSRTaskDescriptor", + "HSRPreservationRule", + "HSRTrustedArtifact", + "HSRTrustedRelease", + "HSRUpdateChannel", + "HSRUpdateDescriptor", + "HSRUpdateMode", + "HSRUpdateProvider", + "safe_extract_hsr_archive", ] diff --git a/packages/automas_script_hsr/src/automas_script_hsr/adapter_plugin.py b/packages/automas_script_hsr/src/automas_script_hsr/adapter_plugin.py index 6f85dad..10248d7 100644 --- a/packages/automas_script_hsr/src/automas_script_hsr/adapter_plugin.py +++ b/packages/automas_script_hsr/src/automas_script_hsr/adapter_plugin.py @@ -3,7 +3,7 @@ from collections.abc import Callable from typing import Any -from .contracts import HSRController, HSRTaskCatalogProvider +from .contracts import HSRController, HSRTaskCatalogProvider, HSRUpdateProvider class HSRAdapterPlugin: @@ -12,14 +12,21 @@ class HSRAdapterPlugin: needs = ["hsr.registry.v1"] task_catalog_factory: Callable[[], HSRTaskCatalogProvider] controller_factory: Callable[[], HSRController] + update_provider_factory: Callable[[], HSRUpdateProvider] | None = None task_catalog_service: str controller_service: str + update_service: str | None = None display_name: str def __init__(self, ctx: Any) -> None: self.ctx = ctx self.catalog = self.task_catalog_factory() self.controller = self.controller_factory() + self.update_provider = ( + self.update_provider_factory() + if self.update_provider_factory is not None + else None + ) async def on_start(self) -> None: registry = self.ctx.get("hsr.registry.v1") @@ -32,10 +39,13 @@ async def on_start(self) -> None: owner=self.ctx.instance_id, task_catalog=self.catalog, controller=self.controller, + update_provider=self.update_provider, ) registered = True self.ctx.set(self.task_catalog_service, self.catalog) self.ctx.set(self.controller_service, self.controller) + if self.update_service is not None: + self.ctx.set(self.update_service, self.update_provider) except Exception: if registered: registry.unregister_owner(self.ctx.instance_id) @@ -67,3 +77,5 @@ async def _stop(self, *, reason: str, final: bool) -> None: def _clear_services(self) -> None: self.ctx.set(self.task_catalog_service, None) self.ctx.set(self.controller_service, None) + if self.update_service is not None: + self.ctx.set(self.update_service, None) diff --git a/packages/automas_script_hsr/src/automas_script_hsr/contracts.py b/packages/automas_script_hsr/src/automas_script_hsr/contracts.py index db79f7a..1b72bdf 100644 --- a/packages/automas_script_hsr/src/automas_script_hsr/contracts.py +++ b/packages/automas_script_hsr/src/automas_script_hsr/contracts.py @@ -1,12 +1,25 @@ from __future__ import annotations from dataclasses import asdict, dataclass, field -from typing import Any, Awaitable, Callable, Literal, Protocol, runtime_checkable +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Literal, + Protocol, + runtime_checkable, +) + +if TYPE_CHECKING: + from .managed_update import HSRManagedUpdateLayout HSREngine = Literal["SRA", "M7A"] HSRPhase = Literal["daily", "weekly"] HSRRunStatus = Literal["completed", "failed", "incomplete", "skipped"] +HSRUpdateMode = Literal["external", "managed", "custom-fork"] +HSRUpdateChannel = Literal["stable", "prerelease"] HSRLogCallback = Callable[[str], None] @@ -43,6 +56,129 @@ def asdict(self) -> dict[str, Any]: return data +@dataclass(frozen=True, slots=True) +class HSRTrustedArtifact: + """One release asset pinned by an AUTO-MAS plugin release.""" + + asset_id: int + name: str + download_url: str + size: int + sha256: str + content_type: str = "application/zip" + + def __post_init__(self) -> None: + if self.asset_id <= 0: + raise ValueError("asset_id 必须为正整数") + if not self.name.strip(): + raise ValueError("可信资产名称不能为空") + if not self.download_url.startswith("https://"): + raise ValueError("可信资产下载地址必须使用 HTTPS") + if self.size <= 0: + raise ValueError("可信资产大小必须为正整数") + digest = self.sha256.strip().lower() + if len(digest) != 64 or any( + character not in "0123456789abcdef" for character in digest + ): + raise ValueError("可信资产 SHA-256 必须是 64 位十六进制字符串") + object.__setattr__(self, "sha256", digest) + + def asdict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True, slots=True) +class HSRTrustedRelease: + """Release metadata pinned in the signed/versioned plugin distribution.""" + + engine: HSREngine + release_id: int + tag: str + version: str + channel: HSRUpdateChannel + published_at: str + upstream_immutable: bool + artifacts: tuple[HSRTrustedArtifact, ...] + + def __post_init__(self) -> None: + if self.release_id <= 0: + raise ValueError("release_id 必须为正整数") + if not self.tag.strip() or not self.version.strip(): + raise ValueError("可信 release 的 tag/version 不能为空") + if not self.published_at.strip(): + raise ValueError("可信 release 的发布时间不能为空") + if not self.artifacts: + raise ValueError("可信 release 至少需要一个固定资产") + asset_ids = [artifact.asset_id for artifact in self.artifacts] + if len(asset_ids) != len(set(asset_ids)): + raise ValueError("可信 release 不能包含重复 asset_id") + + def asdict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True, slots=True) +class HSRUpdateDescriptor: + """Adapter-owned update policy exposed without performing network I/O.""" + + engine: HSREngine + repository_url: str + default_mode: HSRUpdateMode = "external" + available_modes: tuple[HSRUpdateMode, ...] = ( + "external", + "custom-fork", + "managed", + ) + default_channel: HSRUpdateChannel = "stable" + prerelease_opt_in: bool = True + release_check_enabled: bool = False + managed_enabled: bool = False + managed_block_reason: str | None = None + trusted_asset_metadata: bool = False + trusted_releases: tuple[HSRTrustedRelease, ...] = () + + def __post_init__(self) -> None: + if self.default_mode not in self.available_modes: + raise ValueError("default_mode 必须包含在 available_modes 中") + if self.trusted_asset_metadata and not self.trusted_releases: + raise ValueError("声明可信资产元数据时必须提供固定 release 清单") + if self.trusted_releases and not self.trusted_asset_metadata: + raise ValueError("固定 release 清单必须显式声明可信资产元数据") + release_ids = [release.release_id for release in self.trusted_releases] + if len(release_ids) != len(set(release_ids)): + raise ValueError("可信 release 清单不能包含重复 release_id") + if any( + release.engine != self.engine for release in self.trusted_releases + ): + raise ValueError("可信 release 与更新描述器的引擎不一致") + if self.managed_enabled and not self.trusted_asset_metadata: + raise ValueError("启用 Managed 更新前必须具备可信资产元数据") + if not self.managed_enabled and not str( + self.managed_block_reason or "" + ).strip(): + raise ValueError("禁用 Managed 更新时必须声明阻断原因") + + def asdict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True, slots=True) +class HSRInstallationSnapshot: + """Read-only snapshot of one configured external script installation.""" + + engine: HSREngine + installed: bool + ready: bool + root_path: str + executable_path: str + version: str | None + version_source: str | None + message: str + + def asdict(self) -> dict[str, Any]: + return asdict(self) + + @dataclass(frozen=True, slots=True) class HSRStageOption: """统一阶段选项。""" @@ -163,6 +299,17 @@ def list_stage_options( slot: str, ) -> tuple[HSRStageCategory, ...] | Awaitable[tuple[HSRStageCategory, ...]]: ... + +@runtime_checkable +class HSRUpdateProvider(Protocol): + """Read-only installation inspection and update-policy contract.""" + + descriptor: HSRUpdateDescriptor + managed_layout: HSRManagedUpdateLayout + + def inspect(self, script_config: Any) -> HSRInstallationSnapshot: ... + + @runtime_checkable class HSRControllerSession(Protocol): """单次控制器运行会话。""" @@ -201,6 +348,7 @@ class HSRAdapterGroup: owner: str task_catalog: HSRTaskCatalogProvider controller: HSRController + update_provider: HSRUpdateProvider | None = None @property def descriptor(self) -> HSRAdapterDescriptor: diff --git a/packages/automas_script_hsr/src/automas_script_hsr/managed_update.py b/packages/automas_script_hsr/src/automas_script_hsr/managed_update.py new file mode 100644 index 0000000..13b4eb0 --- /dev/null +++ b/packages/automas_script_hsr/src/automas_script_hsr/managed_update.py @@ -0,0 +1,3237 @@ +from __future__ import annotations + +import fnmatch +import hashlib +import json +import os +import shutil +import stat +import zipfile +from collections.abc import Callable +from dataclasses import asdict, dataclass +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any, Literal +from uuid import uuid4 + +from packaging.version import InvalidVersion, Version + +from .contracts import ( + HSREngine, + HSRTrustedArtifact, + HSRTrustedRelease, +) + + +HSRManagedUpdateState = Literal[ + "committed", + "failed", + "rolled_back", + "rollback_failed", + "recovered_aborted", +] +HSRDownloadCallback = Callable[[HSRTrustedArtifact, Path], None] +HSRProcessGuard = Callable[[Path], bool] +HSRInstallValidator = Callable[[Path, HSRTrustedRelease], None] +HSRPhaseHook = Callable[[str], None] + +_TERMINAL_PHASES = { + "committed", + "failed", + "rolled_back", + "recovered_aborted", +} +_PRE_SWITCH_PHASES = { + "created", + "cache_publish_intent", + "verified", + "staged", + "ready", +} +_UPDATE_INSTALL_PHASES = { + "switch_new_intent", + "installed", + "commit_intent", + "current_published", +} +_UPDATE_ROLLBACK_PHASES = { + "failure_isolate_intent", + "failure_candidate_isolated", + "failure_restore_intent", + "rollback_applied", + "rollback_failed", +} +_MANUAL_ROLLBACK_PHASES = { + "manual_rollback_move_current_intent", + "manual_rollback_current_moved", + "manual_rollback_restore_intent", + "manual_rollback_restored", + "manual_rollback_pointer_intent", +} +_CURRENT_POINTER_NAME = "CURRENT.json" +_CURRENT_POINTER_KEYS = { + "schema_version", + "generation", + "state", + "active_transaction", + "engine", + "release_id", + "version", + "install_root", +} +_TREE_IDENTITY_KEYS = { + "schema_version", + "device", + "inode", +} +_WINDOWS_RESERVED_NAMES = { + "CON", + "PRN", + "AUX", + "NUL", + *(f"COM{index}" for index in range(1, 10)), + *(f"LPT{index}" for index in range(1, 10)), +} + + +class HSRManagedUpdateError(RuntimeError): + """Fail-closed managed update error with a stable machine code.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +class HSRManagedUpdateCrash(BaseException): + """Test hook that models process death without running rollback code.""" + + +@dataclass(frozen=True, slots=True) +class HSRPreservationRule: + """One user-owned relative path copied into the staged release.""" + + path: str + patterns: tuple[str, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "path", _validate_relative_path(self.path)) + if any( + not str(pattern).strip() + or PureWindowsPath(str(pattern)).drive + or ".." in PurePosixPath(str(pattern).replace("\\", "/")).parts + for pattern in self.patterns + ): + raise ValueError("保留规则 pattern 必须是安全的相对 glob") + + def asdict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True, slots=True) +class HSRManagedUpdateLayout: + """Adapter-owned release layout and user-data preservation boundary.""" + + engine: HSREngine + required_paths: tuple[str, ...] + preserved_paths: tuple[HSRPreservationRule, ...] = () + + def __post_init__(self) -> None: + if not self.required_paths: + raise ValueError("Managed 更新布局至少需要一个必需路径") + normalized_required = tuple( + _validate_relative_path(path) for path in self.required_paths + ) + required_keys = tuple(path.casefold() for path in normalized_required) + if len(required_keys) != len(set(required_keys)): + raise ValueError("Managed 更新布局不能包含重复必需路径") + normalized_preserved = tuple(rule.path for rule in self.preserved_paths) + preserved_keys = tuple(path.casefold() for path in normalized_preserved) + if len(preserved_keys) != len(set(preserved_keys)): + raise ValueError("Managed 更新布局不能包含重复保留路径") + for required in normalized_required: + required_parts = tuple( + part.casefold() for part in PurePosixPath(required).parts + ) + for preserved in normalized_preserved: + preserved_parts = tuple( + part.casefold() for part in PurePosixPath(preserved).parts + ) + shared = min(len(required_parts), len(preserved_parts)) + if required_parts[:shared] == preserved_parts[:shared]: + raise ValueError( + "Managed 更新布局的必需路径与保留路径" + f"不能重叠: {required!r} / {preserved!r}" + ) + object.__setattr__(self, "required_paths", normalized_required) + + def asdict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True, slots=True) +class HSRManagedUpdateResult: + transaction_id: str + engine: HSREngine + from_version: str | None + to_version: str + state: HSRManagedUpdateState + installation_path: str + journal_path: str + backup_path: str | None + error_code: str | None = None + error: str | None = None + + @property + def success(self) -> bool: + return self.state == "committed" + + @property + def rollback_available(self) -> bool: + return bool(self.backup_path) + + def asdict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(slots=True) +class _Journal: + path: Path + payload: dict[str, Any] + + @classmethod + def create( + cls, + path: Path, + *, + transaction_id: str, + install_root: Path, + release: HSRTrustedRelease, + artifact: HSRTrustedArtifact, + from_version: str | None, + had_existing: bool, + previous_current: dict[str, Any] | None, + ) -> "_Journal": + journal = cls( + path=path, + payload={ + "schema_version": 2, + "transaction_id": transaction_id, + "engine": release.engine, + "release_id": release.release_id, + "asset_id": artifact.asset_id, + "from_version": from_version, + "to_version": release.version, + "install_root": str(install_root), + "had_existing": had_existing, + "previous_current": previous_current, + "expected_generation": _current_generation(previous_current), + "phase": "created", + "events": [], + }, + ) + journal.transition("created") + return journal + + @classmethod + def load(cls, path: Path) -> "_Journal": + if _is_reparse_point(path) or not path.is_file(): + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "更新 journal 必须是非 reparse 的普通文件", + ) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + f"无法读取更新 journal: {exc}", + ) from exc + if ( + not isinstance(payload, dict) + or payload.get("schema_version") not in (1, 2) + ): + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "更新 journal 版本无效", + ) + return cls(path=path, payload=payload) + + @property + def phase(self) -> str: + return str(self.payload.get("phase") or "") + + def transition(self, phase: str, **details: Any) -> None: + event = {"phase": phase, **details} + events = self.payload.setdefault("events", []) + if not isinstance(events, list): + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "更新 journal events 无效", + ) + events.append(event) + self.payload.update(details) + self.payload["phase"] = phase + _atomic_write_json(self.path, self.payload) + + +class _TransactionLock: + """Cross-process lock whose OS lock is released automatically on crash.""" + + def __init__(self, path: Path, transaction_id: str) -> None: + self.path = path + self.transaction_id = transaction_id + self._file: Any = None + + def __enter__(self) -> "_TransactionLock": + self.path.parent.mkdir(parents=True, exist_ok=True) + if _path_lexically_exists(self.path) and _is_reparse_point(self.path): + raise HSRManagedUpdateError( + "UNSAFE_LOCK_PATH", + "更新锁文件不能是符号链接、junction 或其他 reparse point", + ) + handle = self.path.open("a+b") + try: + handle.seek(0, os.SEEK_END) + if handle.tell() == 0: + handle.write(b"\0") + handle.flush() + handle.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + if _is_reparse_point(self.path): + raise HSRManagedUpdateError( + "UNSAFE_LOCK_PATH", + "更新锁文件在打开期间变成了 reparse point", + ) + except (OSError, BlockingIOError) as exc: + handle.close() + raise HSRManagedUpdateError( + "LOCK_HELD", + "该脚本目录已有更新事务正在执行", + ) from exc + except Exception: + handle.close() + raise + handle.seek(0) + handle.truncate() + handle.write( + json.dumps( + { + "transaction_id": self.transaction_id, + "pid": os.getpid(), + }, + ensure_ascii=False, + ).encode("utf-8") + ) + handle.flush() + os.fsync(handle.fileno()) + handle.seek(0) + self._file = handle + return self + + def __exit__(self, *args: Any) -> None: + handle = self._file + self._file = None + if handle is None: + return + try: + handle.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + finally: + handle.close() + + +class HSRManagedUpdater: + """Transactional script installer; adapters own layout and validation.""" + + def __init__( + self, + *, + cache_root: Path, + download: HSRDownloadCallback, + process_guard: HSRProcessGuard, + validator: HSRInstallValidator | None = None, + phase_hook: HSRPhaseHook | None = None, + ) -> None: + if download is None: + raise ValueError("Managed 更新必须提供 downloader") + if process_guard is None: + raise ValueError("Managed 更新必须提供进程占用检查器") + self.cache_root = Path(cache_root) + self.download = download + self.process_guard = process_guard + self.validator = validator + self.phase_hook = phase_hook + + def execute( + self, + *, + install_root: Path, + layout: HSRManagedUpdateLayout, + release: HSRTrustedRelease, + artifact: HSRTrustedArtifact, + current_version: str | None, + allow_downgrade: bool = False, + ) -> HSRManagedUpdateResult: + install_root = _validate_install_root(install_root) + self._validate_release(layout, release, artifact) + had_existing = _path_lexically_exists(install_root) + self._validate_installation_state( + install_root=install_root, + had_existing=had_existing, + current_version=current_version, + ) + transaction_id = uuid4().hex + workspace = _workspace_root(install_root) + + with _TransactionLock(workspace / "active.lock", transaction_id): + locked_had_existing = _path_lexically_exists(install_root) + if locked_had_existing != had_existing: + raise HSRManagedUpdateError( + "INSTALLATION_CHANGED", + "获取更新锁前后安装目录状态发生变化", + ) + self._validate_installation_state( + install_root=install_root, + had_existing=had_existing, + current_version=current_version, + ) + previous_current = _read_current_pointer( + workspace, + install_root=install_root, + ) + if previous_current is None: + _assert_missing_current_is_pristine(workspace) + self._validate_current_binding( + previous_current=previous_current, + install_root=install_root, + current_version=current_version, + had_existing=had_existing, + ) + self._validate_version( + current_version, + release.version, + allow_downgrade=allow_downgrade, + ) + self._assert_process_stopped(install_root) + + tx_root = _transaction_root( + workspace, + transaction_id=transaction_id, + create_parent=True, + ) + tx_root.mkdir(parents=False, exist_ok=False) + journal = _Journal.create( + tx_root / "journal.json", + transaction_id=transaction_id, + install_root=install_root, + release=release, + artifact=artifact, + from_version=current_version, + had_existing=had_existing, + previous_current=previous_current, + ) + backup = tx_root / "previous" + failed_candidate = tx_root / "failed-candidate" + payload_root: Path | None = None + try: + archive = self._obtain_artifact( + artifact=artifact, + journal=journal, + ) + unpacked = tx_root / "unpacked" + safe_extract_hsr_archive(archive, unpacked) + payload_root = _select_payload_root(unpacked, layout) + journal.transition( + "staged", + payload_root=str(payload_root), + ) + self._call_hook("staged") + + if install_root.exists(): + _apply_preservation_rules( + install_root, + payload_root, + layout.preserved_paths, + ) + self._validate_candidate(payload_root, layout, release) + candidate_identity = _capture_tree_identity(payload_root) + journal.transition( + "ready", + candidate_identity=candidate_identity, + ) + self._call_hook("ready") + + self._assert_process_stopped(install_root) + _assert_current_pointer( + workspace, + install_root=install_root, + expected=previous_current, + ) + if had_existing: + backup_identity = _capture_tree_identity(install_root) + journal.transition( + "switch_old_intent", + backup_path=str(backup), + backup_identity=backup_identity, + ) + self._call_hook("switch_old_intent") + _rename(install_root, backup) + _assert_tree_identity( + backup, + backup_identity, + role="previous", + ) + self._call_hook("after_old_move_rename") + journal.transition( + "old_moved", + backup_path=str(backup), + ) + self._call_hook("old_moved") + + journal.transition( + "switch_new_intent", + payload_root=str(payload_root), + ) + self._call_hook("switch_new_intent") + _assert_tree_identity( + payload_root, + candidate_identity, + role="candidate", + ) + _rename(payload_root, install_root) + payload_root = None + _assert_tree_identity( + install_root, + candidate_identity, + role="active", + ) + self._call_hook("after_new_install_rename") + journal.transition("installed") + self._call_hook("installed") + + self._validate_candidate(install_root, layout, release) + current_pointer = _managed_current_pointer( + journal=journal, + install_root=install_root, + release=release, + ) + journal.transition( + "commit_intent", + current_pointer=current_pointer, + ) + self._call_hook("commit_intent") + _write_current_pointer( + workspace, + current_pointer, + install_root=install_root, + ) + self._call_hook("after_current_publish") + journal.transition("current_published") + self._call_hook("current_published") + journal.transition("committed") + self._call_hook("committed") + return self._result( + journal=journal, + layout=layout, + current_version=current_version, + release=release, + state="committed", + install_root=install_root, + backup=backup, + ) + except HSRManagedUpdateCrash: + raise + except Exception as exc: # noqa: BLE001 + error = _coerce_update_error(exc) + if self._finalize_published_commit_after_error( + install_root=install_root, + layout=layout, + release=release, + journal=journal, + workspace=workspace, + error=error, + ): + return self._result( + journal=journal, + layout=layout, + current_version=current_version, + release=release, + state="committed", + install_root=install_root, + backup=backup, + ) + try: + rolled_back = self._rollback_failed_update_locked( + install_root=install_root, + backup=backup, + failed_candidate=failed_candidate, + journal=journal, + workspace=workspace, + ) + except Exception as rollback_exc: # noqa: BLE001 + rollback_error = _coerce_update_error(rollback_exc) + journal.transition( + "rollback_failed", + error_code=rollback_error.code, + error=str(rollback_error), + original_error_code=error.code, + ) + return self._result( + journal=journal, + layout=layout, + current_version=current_version, + release=release, + state="rollback_failed", + install_root=install_root, + backup=backup, + error=error, + ) + + terminal_state: HSRManagedUpdateState = ( + "rolled_back" if rolled_back else "failed" + ) + journal.transition( + terminal_state, + error_code=error.code, + error=str(error), + ) + return self._result( + journal=journal, + layout=layout, + current_version=current_version, + release=release, + state=terminal_state, + install_root=install_root, + backup=backup, + error=error, + ) + + def recover( + self, + *, + install_root: Path, + layout: HSRManagedUpdateLayout, + transaction_id: str, + release: HSRTrustedRelease, + ) -> HSRManagedUpdateResult: + install_root = _validate_install_root(install_root) + transaction_id = _validate_transaction_id(transaction_id) + workspace = _workspace_root(install_root) + + with _TransactionLock(workspace / "active.lock", transaction_id): + tx_root = _transaction_root( + workspace, + transaction_id=transaction_id, + create_parent=False, + ) + journal = _Journal.load(tx_root / "journal.json") + self._validate_recovery_identity( + journal=journal, + install_root=install_root, + layout=layout, + release=release, + transaction_id=transaction_id, + ) + backup = tx_root / "previous" + failed_candidate = tx_root / "failed-candidate" + current_version = _optional_string( + journal.payload.get("from_version") + ) + phase = journal.phase + if phase in _TERMINAL_PHASES: + self._validate_terminal_recovery( + install_root=install_root, + layout=layout, + release=release, + journal=journal, + workspace=workspace, + ) + return self._result( + journal=journal, + layout=layout, + current_version=current_version, + release=release, + state=phase, # type: ignore[arg-type] + install_root=install_root, + backup=backup, + ) + + self._assert_process_stopped(install_root) + if phase in _MANUAL_ROLLBACK_PHASES: + return self._recover_manual_rollback_locked( + install_root=install_root, + layout=layout, + release=release, + journal=journal, + workspace=workspace, + backup=backup, + ) + + previous_current = _journal_previous_current(journal) + actual_current = _read_current_pointer( + workspace, + install_root=install_root, + ) + transaction_pointer = _managed_current_pointer( + journal=journal, + install_root=install_root, + release=release, + ) + if actual_current == transaction_pointer: + _assert_tree_identity( + install_root, + _journal_tree_identity( + journal, + "candidate_identity", + ), + role="active", + ) + self._validate_candidate(install_root, layout, release) + journal.transition("committed", recovered=True) + return self._result( + journal=journal, + layout=layout, + current_version=current_version, + release=release, + state="committed", + install_root=install_root, + backup=backup, + ) + if actual_current != previous_current: + raise HSRManagedUpdateError( + "STALE_TRANSACTION", + "CURRENT 已指向其他事务,拒绝旧事务覆盖较新安装", + ) + + if phase in _PRE_SWITCH_PHASES: + self._validate_pre_switch_state( + install_root=install_root, + journal=journal, + ) + journal.transition("recovered_aborted", recovered=True) + return self._result( + journal=journal, + layout=layout, + current_version=current_version, + release=release, + state="recovered_aborted", + install_root=install_root, + backup=backup, + ) + + if phase in {"switch_old_intent", "old_moved"}: + rolled_back = self._restore_previous_locked( + install_root=install_root, + backup=backup, + failed_candidate=failed_candidate, + journal=journal, + ) + state: HSRManagedUpdateState = ( + "rolled_back" if rolled_back else "recovered_aborted" + ) + journal.transition(state, recovered=True) + return self._result( + journal=journal, + layout=layout, + current_version=current_version, + release=release, + state=state, + install_root=install_root, + backup=backup, + ) + + if phase in _UPDATE_INSTALL_PHASES: + if _path_lexically_exists(install_root): + _assert_tree_identity( + install_root, + _journal_tree_identity( + journal, + "candidate_identity", + ), + role="active", + ) + try: + self._validate_candidate( + install_root, + layout, + release, + ) + except Exception: + rolled_back = self._restore_previous_locked( + install_root=install_root, + backup=backup, + failed_candidate=failed_candidate, + journal=journal, + ) + state = "rolled_back" if rolled_back else "failed" + journal.transition(state, recovered=True) + return self._result( + journal=journal, + layout=layout, + current_version=current_version, + release=release, + state=state, + install_root=install_root, + backup=backup, + ) + journal.transition( + "commit_intent", + current_pointer=transaction_pointer, + recovered=True, + ) + _write_current_pointer( + workspace, + transaction_pointer, + install_root=install_root, + ) + self._call_hook("after_current_publish") + journal.transition("current_published", recovered=True) + journal.transition("committed", recovered=True) + return self._result( + journal=journal, + layout=layout, + current_version=current_version, + release=release, + state="committed", + install_root=install_root, + backup=backup, + ) + rolled_back = self._restore_previous_locked( + install_root=install_root, + backup=backup, + failed_candidate=failed_candidate, + journal=journal, + ) + state = "rolled_back" if rolled_back else "failed" + journal.transition(state, recovered=True) + return self._result( + journal=journal, + layout=layout, + current_version=current_version, + release=release, + state=state, + install_root=install_root, + backup=backup, + ) + + if phase in _UPDATE_ROLLBACK_PHASES: + rolled_back = self._restore_previous_locked( + install_root=install_root, + backup=backup, + failed_candidate=failed_candidate, + journal=journal, + ) + state = "rolled_back" if rolled_back else "failed" + journal.transition(state, recovered=True) + return self._result( + journal=journal, + layout=layout, + current_version=current_version, + release=release, + state=state, + install_root=install_root, + backup=backup, + ) + + raise HSRManagedUpdateError( + "JOURNAL_PHASE_INVALID", + f"无法恢复未知事务阶段: {phase}", + ) + + def rollback( + self, + *, + install_root: Path, + layout: HSRManagedUpdateLayout, + transaction_id: str, + release: HSRTrustedRelease, + ) -> HSRManagedUpdateResult: + install_root = _validate_install_root(install_root) + transaction_id = _validate_transaction_id(transaction_id) + workspace = _workspace_root(install_root) + + with _TransactionLock(workspace / "active.lock", transaction_id): + tx_root = _transaction_root( + workspace, + transaction_id=transaction_id, + create_parent=False, + ) + journal = _Journal.load(tx_root / "journal.json") + self._validate_recovery_identity( + journal=journal, + install_root=install_root, + layout=layout, + release=release, + transaction_id=transaction_id, + ) + if journal.phase != "committed": + raise HSRManagedUpdateError( + "ROLLBACK_NOT_AVAILABLE", + f"仅 committed 事务可手动回滚,当前为 {journal.phase}", + ) + backup = tx_root / "previous" + had_existing = bool(journal.payload.get("had_existing")) + if ( + ( + had_existing + and ( + not backup.is_dir() + or _is_reparse_point(backup) + ) + ) + or not install_root.is_dir() + or _is_reparse_point(install_root) + ): + raise HSRManagedUpdateError( + "ROLLBACK_NOT_AVAILABLE", + "回滚所需的新旧安装目录不完整或不安全", + ) + transaction_pointer = _managed_current_pointer( + journal=journal, + install_root=install_root, + release=release, + ) + actual_current = _read_current_pointer( + workspace, + install_root=install_root, + ) + _assert_current_lineage( + actual_current, + transaction_pointer, + workspace=workspace, + install_root=install_root, + ) + candidate_identity = _journal_tree_identity( + journal, + "candidate_identity", + ) + _assert_tree_identity( + install_root, + candidate_identity, + role="active", + ) + if had_existing: + _assert_tree_identity( + backup, + _journal_tree_identity( + journal, + "backup_identity", + ), + role="previous", + ) + self._assert_process_stopped(install_root) + forward_backup = tx_root / "replaced-current" + rollback_pointer = _rollback_current_pointer( + journal=journal, + install_root=install_root, + active_current=actual_current, + ) + journal.transition( + "manual_rollback_move_current_intent", + replaced_current=str(forward_backup), + manual_expected_generation=_current_generation( + actual_current + ), + manual_active_pointer=actual_current, + rollback_pointer=rollback_pointer, + ) + self._call_hook("manual_rollback_move_current_intent") + return self._recover_manual_rollback_locked( + install_root=install_root, + layout=layout, + release=release, + journal=journal, + workspace=workspace, + backup=backup, + ) + + def _recover_manual_rollback_locked( + self, + *, + install_root: Path, + layout: HSRManagedUpdateLayout, + release: HSRTrustedRelease, + journal: _Journal, + workspace: Path, + backup: Path, + ) -> HSRManagedUpdateResult: + current_version = _optional_string(journal.payload.get("from_version")) + forward_backup = journal.path.parent / "replaced-current" + recorded_forward = journal.payload.get("replaced_current") + if ( + recorded_forward is not None + and str(recorded_forward) != str(forward_backup) + ): + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "手动回滚 replaced-current 路径不是规范事务子路径", + ) + _assert_transaction_child(forward_backup, journal.path.parent) + transaction_pointer = _managed_current_pointer( + journal=journal, + install_root=install_root, + release=release, + ) + expected_generation = journal.payload.get( + "manual_expected_generation" + ) + if ( + not isinstance(expected_generation, int) + or isinstance(expected_generation, bool) + or expected_generation < _current_generation(transaction_pointer) + ): + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "手动回滚 expected_generation 无效", + ) + expected_active = dict(transaction_pointer) + expected_active["generation"] = expected_generation + _validate_current_pointer_payload( + expected_active, + install_root=install_root, + ) + if journal.payload.get("manual_active_pointer") != expected_active: + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "手动回滚 active pointer 与可信 lineage 不一致", + ) + target_pointer = _rollback_current_pointer( + journal=journal, + install_root=install_root, + active_current=expected_active, + ) + if journal.payload.get("rollback_pointer") != target_pointer: + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "手动回滚 target pointer 与可信 lineage 不一致", + ) + _validate_current_pointer_payload( + target_pointer, + install_root=install_root, + ) + had_existing = bool(journal.payload.get("had_existing")) + candidate_identity = _journal_tree_identity( + journal, + "candidate_identity", + ) + backup_identity = ( + _journal_tree_identity(journal, "backup_identity") + if had_existing + else None + ) + if _path_lexically_exists(forward_backup): + _assert_tree_identity( + forward_backup, + candidate_identity, + role="replaced-current", + ) + if _path_lexically_exists(backup): + if backup_identity is None: + raise HSRManagedUpdateError( + "RECOVERY_STATE_MISMATCH", + "fresh install 事务不应存在 previous 安装目录", + ) + _assert_tree_identity( + backup, + backup_identity, + role="previous", + ) + actual_current = _read_current_pointer( + workspace, + install_root=install_root, + ) + if actual_current == target_pointer: + if target_pointer.get("state") == "absent": + if _path_lexically_exists(install_root): + raise HSRManagedUpdateError( + "RECOVERY_STATE_MISMATCH", + "absent CURRENT tombstone 与安装目录冲突", + ) + else: + if backup_identity is None: + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "非 absent 回滚缺少 previous 树身份", + ) + _assert_tree_identity( + install_root, + backup_identity, + role="restored", + ) + self._validate_required_files(install_root, layout) + journal.transition("rolled_back", manual=True, recovered=True) + return self._result( + journal=journal, + layout=layout, + current_version=current_version, + release=release, + state="rolled_back", + install_root=install_root, + backup=backup, + ) + if ( + actual_current != expected_active + or _current_generation(actual_current) != expected_generation + ): + raise HSRManagedUpdateError( + "STALE_TRANSACTION", + "CURRENT 已指向其他事务,拒绝旧事务执行手动回滚", + ) + + self._assert_process_stopped(install_root) + install_exists = _path_lexically_exists(install_root) + forward_exists = _path_lexically_exists(forward_backup) + backup_exists = _path_lexically_exists(backup) + restored_after_rename = ( + journal.phase + in { + "manual_rollback_restore_intent", + "manual_rollback_restored", + "manual_rollback_pointer_intent", + } + and install_exists + and forward_exists + and not backup_exists + ) + if install_exists and not restored_after_rename: + _assert_tree_identity( + install_root, + candidate_identity, + role="active", + ) + if forward_exists: + raise HSRManagedUpdateError( + "RECOVERY_STATE_MISMATCH", + "手动回滚的新版本与 replaced-current 同时存在", + ) + journal.transition( + "manual_rollback_move_current_intent", + replaced_current=str(forward_backup), + recovered=True, + ) + _rename(install_root, forward_backup) + _assert_tree_identity( + forward_backup, + candidate_identity, + role="replaced-current", + ) + self._call_hook("after_manual_current_move_rename") + journal.transition( + "manual_rollback_current_moved", + replaced_current=str(forward_backup), + recovered=True, + ) + self._call_hook("manual_rollback_current_moved") + elif not install_exists and not forward_exists: + raise HSRManagedUpdateError( + "RECOVERY_STATE_MISMATCH", + "手动回滚缺少当前版本和 replaced-current", + ) + + if _path_lexically_exists(backup): + journal.transition( + "manual_rollback_restore_intent", + replaced_current=str(forward_backup), + recovered=True, + ) + _rename(backup, install_root) + if backup_identity is None: + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "回滚恢复缺少 previous 树身份", + ) + _assert_tree_identity( + install_root, + backup_identity, + role="restored", + ) + self._call_hook("after_manual_restore_rename") + journal.transition( + "manual_rollback_restored", + replaced_current=str(forward_backup), + recovered=True, + ) + self._call_hook("manual_rollback_restored") + elif had_existing and not restored_after_rename: + raise HSRManagedUpdateError( + "RECOVERY_STATE_MISMATCH", + "手动回滚缺少 previous 安装目录", + ) + + if had_existing: + if backup_identity is None: + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "回滚恢复缺少 previous 树身份", + ) + _assert_tree_identity( + install_root, + backup_identity, + role="restored", + ) + self._validate_required_files(install_root, layout) + elif _path_lexically_exists(install_root): + raise HSRManagedUpdateError( + "RECOVERY_STATE_MISMATCH", + "fresh install 回滚后安装目录仍存在", + ) + journal.transition( + "manual_rollback_pointer_intent", + current_pointer=target_pointer, + replaced_current=str(forward_backup), + recovered=True, + ) + self._call_hook("manual_rollback_pointer_intent") + _write_current_pointer( + workspace, + target_pointer, + install_root=install_root, + ) + self._call_hook("after_manual_pointer_publish") + journal.transition( + "rolled_back", + manual=True, + replaced_current=str(forward_backup), + recovered=True, + ) + return self._result( + journal=journal, + layout=layout, + current_version=current_version, + release=release, + state="rolled_back", + install_root=install_root, + backup=backup, + ) + + def _obtain_artifact( + self, + *, + artifact: HSRTrustedArtifact, + journal: _Journal, + ) -> Path: + cache_root = _prepare_cache_root(self.cache_root) + cached = cache_root / f"{artifact.sha256}.zip" + if cached.is_file(): + _verify_artifact(cached, artifact) + journal.transition("verified", cache_hit=True) + self._call_hook("verified") + return cached + partial = cache_root / ( + f".{artifact.sha256}." + f"{journal.payload['transaction_id']}.download.part" + ) + if _path_lexically_exists(partial): + raise HSRManagedUpdateError( + "TRANSACTION_PATH_COLLISION", + "当前事务的 cache 下载临时文件已存在", + ) + self.download(artifact, partial) + _verify_artifact(partial, artifact) + if _path_lexically_exists(cached): + _verify_artifact(cached, artifact) + else: + journal.transition( + "cache_publish_intent", + cache_path=str(cached), + ) + self._call_hook("cache_publish_intent") + _rename(partial, cached) + self._call_hook("after_cache_publish_rename") + journal.transition("verified", cache_hit=False) + self._call_hook("verified") + return cached + + def _validate_candidate( + self, + root: Path, + layout: HSRManagedUpdateLayout, + release: HSRTrustedRelease, + ) -> None: + self._validate_required_files(root, layout) + if self.validator is not None: + self.validator(root, release) + + @staticmethod + def _validate_required_files( + root: Path, + layout: HSRManagedUpdateLayout, + ) -> None: + if ( + not root.is_dir() + or _is_reparse_point(root) + or not _path_lexically_exists(root) + ): + raise HSRManagedUpdateError( + "VALIDATION_FAILED", + "候选安装根不是安全的实体目录", + ) + for relative in layout.required_paths: + required = root / Path(relative) + if not _path_lexically_exists(required): + raise HSRManagedUpdateError( + "VALIDATION_FAILED", + f"候选安装缺少必需路径: {relative}", + ) + _assert_no_reparse_components( + required, + boundary=root, + code="REQUIRED_PATH_REPARSE_REJECTED", + ) + _assert_path_owned_by(required, root) + if not required.is_file(): + raise HSRManagedUpdateError( + "REQUIRED_PATH_TYPE_INVALID", + f"必需路径必须是普通文件: {relative}", + ) + + @staticmethod + def _validate_installation_state( + *, + install_root: Path, + had_existing: bool, + current_version: str | None, + ) -> None: + if had_existing: + _assert_no_reparse_components( + install_root, + code="UNSAFE_INSTALL_ROOT", + ) + if not install_root.is_dir() or _is_reparse_point(install_root): + raise HSRManagedUpdateError( + "UNSAFE_INSTALL_ROOT", + "现有安装路径必须是非 reparse 的实体目录", + ) + if current_version is None: + raise HSRManagedUpdateError( + "CURRENT_VERSION_REQUIRED", + "现有安装目录缺少 current_version,拒绝猜测或覆盖", + ) + return + if current_version is not None: + raise HSRManagedUpdateError( + "INSTALLATION_STATE_MISMATCH", + "安装目录不存在时 current_version 必须为空", + ) + + @staticmethod + def _validate_current_binding( + *, + previous_current: dict[str, Any] | None, + install_root: Path, + current_version: str | None, + had_existing: bool, + ) -> None: + if previous_current is None: + return + if not had_existing: + if previous_current.get("state") == "absent": + return + raise HSRManagedUpdateError( + "CURRENT_STATE_MISMATCH", + "非 absent CURRENT 指针存在但安装目录不存在", + ) + if previous_current.get("state") == "absent": + raise HSRManagedUpdateError( + "CURRENT_STATE_MISMATCH", + "absent CURRENT tombstone 与现有安装目录冲突", + ) + pointer_version = _optional_string(previous_current.get("version")) + if ( + pointer_version is not None + and current_version is not None + and _version_key(pointer_version) != _version_key(current_version) + ): + raise HSRManagedUpdateError( + "CURRENT_VERSION_MISMATCH", + "调用方 current_version 与 CURRENT 指针不一致", + ) + if previous_current.get("install_root") != str(install_root): + raise HSRManagedUpdateError( + "CURRENT_STATE_MISMATCH", + "CURRENT 指针属于其他安装目录", + ) + + def _assert_process_stopped(self, install_root: Path) -> None: + try: + active = bool(self.process_guard(install_root)) + except Exception as exc: # noqa: BLE001 + raise HSRManagedUpdateError( + "PROCESS_GUARD_FAILED", + f"无法确认脚本进程是否停止: {type(exc).__name__}: {exc}", + ) from exc + if active: + raise HSRManagedUpdateError( + "PROCESS_ACTIVE", + "SRA/M7A 仍在运行,已拒绝更新或回滚", + ) + + @staticmethod + def _validate_pre_switch_state( + *, + install_root: Path, + journal: _Journal, + ) -> None: + had_existing = bool(journal.payload.get("had_existing")) + exists = _path_lexically_exists(install_root) + if exists != had_existing: + raise HSRManagedUpdateError( + "RECOVERY_STATE_MISMATCH", + "切换前事务的安装目录状态与 journal 不一致", + ) + if exists and ( + not install_root.is_dir() or _is_reparse_point(install_root) + ): + raise HSRManagedUpdateError( + "UNSAFE_INSTALL_ROOT", + "恢复时发现安装目录不是安全实体目录", + ) + + def _validate_terminal_recovery( + self, + *, + install_root: Path, + layout: HSRManagedUpdateLayout, + release: HSRTrustedRelease, + journal: _Journal, + workspace: Path, + ) -> None: + phase = journal.phase + if phase == "committed": + expected = _managed_current_pointer( + journal=journal, + install_root=install_root, + release=release, + ) + actual = _read_current_pointer( + workspace, + install_root=install_root, + ) + _assert_current_lineage( + actual, + expected, + workspace=workspace, + install_root=install_root, + ) + _assert_tree_identity( + install_root, + _journal_tree_identity( + journal, + "candidate_identity", + ), + role="active", + ) + self._validate_candidate(install_root, layout, release) + return + + if phase == "rolled_back" and bool(journal.payload.get("manual")): + transaction_pointer = _managed_current_pointer( + journal=journal, + install_root=install_root, + release=release, + ) + expected_generation = journal.payload.get( + "manual_expected_generation" + ) + if ( + not isinstance(expected_generation, int) + or isinstance(expected_generation, bool) + or expected_generation < _current_generation( + transaction_pointer + ) + ): + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "手动回滚 journal expected_generation 无效", + ) + active = dict(transaction_pointer) + active["generation"] = expected_generation + expected = _rollback_current_pointer( + journal=journal, + install_root=install_root, + active_current=active, + ) + if ( + journal.payload.get("manual_active_pointer") != active + or journal.payload.get("rollback_pointer") != expected + or journal.payload.get("current_pointer") != expected + ): + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "手动回滚 journal 指针与可信 lineage 不一致", + ) + _validate_current_pointer_payload( + expected, + install_root=install_root, + ) + _assert_current_pointer( + workspace, + install_root=install_root, + expected=expected, + ) + if expected.get("state") == "absent": + if _path_lexically_exists(install_root): + raise HSRManagedUpdateError( + "RECOVERY_STATE_MISMATCH", + "absent CURRENT tombstone 与实体安装目录冲突", + ) + else: + _assert_tree_identity( + install_root, + _journal_tree_identity( + journal, + "backup_identity", + ), + role="restored", + ) + self._validate_required_files(install_root, layout) + forward = journal.path.parent / "replaced-current" + if _path_lexically_exists(forward): + _assert_tree_identity( + forward, + _journal_tree_identity( + journal, + "candidate_identity", + ), + role="replaced-current", + ) + return + + previous_current = _journal_previous_current(journal) + _assert_current_pointer( + workspace, + install_root=install_root, + expected=previous_current, + ) + had_existing = bool(journal.payload.get("had_existing")) + exists = _path_lexically_exists(install_root) + if exists != had_existing: + raise HSRManagedUpdateError( + "RECOVERY_STATE_MISMATCH", + f"{phase} 终态与安装目录存在性不一致", + ) + if exists: + if journal.payload.get("backup_identity") is not None: + _assert_tree_identity( + install_root, + _journal_tree_identity( + journal, + "backup_identity", + ), + role="restored", + ) + elif phase != "failed": + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "恢复终态缺少 previous 树身份", + ) + self._validate_required_files(install_root, layout) + + def _rollback_failed_update_locked( + self, + *, + install_root: Path, + backup: Path, + failed_candidate: Path, + journal: _Journal, + workspace: Path, + ) -> bool: + actual_current = _read_current_pointer( + workspace, + install_root=install_root, + ) + previous_current = _journal_previous_current(journal) + if actual_current != previous_current: + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "失败回滚前 CURRENT 已变化,保留事务供显式恢复", + ) + if journal.phase in _PRE_SWITCH_PHASES: + self._validate_pre_switch_state( + install_root=install_root, + journal=journal, + ) + return False + return self._restore_previous_locked( + install_root=install_root, + backup=backup, + failed_candidate=failed_candidate, + journal=journal, + ) + + def _finalize_published_commit_after_error( + self, + *, + install_root: Path, + layout: HSRManagedUpdateLayout, + release: HSRTrustedRelease, + journal: _Journal, + workspace: Path, + error: HSRManagedUpdateError, + ) -> bool: + """Keep the irreversible CURRENT outcome authoritative after an error.""" + try: + expected = _managed_current_pointer( + journal=journal, + install_root=install_root, + release=release, + ) + actual = _read_current_pointer( + workspace, + install_root=install_root, + ) + if actual != expected: + return False + _assert_tree_identity( + install_root, + _journal_tree_identity( + journal, + "candidate_identity", + ), + role="active", + ) + self._validate_candidate(install_root, layout, release) + except Exception: # noqa: BLE001 + return False + + try: + journal.transition( + "committed", + recovered=True, + post_commit_error_code=error.code, + ) + except Exception: # noqa: BLE001 + # CURRENT is the only persistent commit point. A later recover() + # can repair an older journal phase without rolling the install + # back over the already-published generation. + pass + return True + + def _restore_previous_locked( + self, + *, + install_root: Path, + backup: Path, + failed_candidate: Path, + journal: _Journal, + ) -> bool: + had_existing = bool(journal.payload.get("had_existing")) + install_exists = _path_lexically_exists(install_root) + backup_exists = _path_lexically_exists(backup) + failed_exists = _path_lexically_exists(failed_candidate) + candidate_identity = _journal_tree_identity( + journal, + "candidate_identity", + ) + backup_identity = ( + _journal_tree_identity(journal, "backup_identity") + if had_existing + else None + ) + + if backup_exists: + if backup_identity is None: + raise HSRManagedUpdateError( + "RECOVERY_STATE_MISMATCH", + "fresh install 事务不应存在 previous 安装目录", + ) + _assert_tree_identity( + backup, + backup_identity, + role="previous", + ) + if failed_exists: + _assert_tree_identity( + failed_candidate, + candidate_identity, + role="failed-candidate", + ) + + if had_existing and not backup_exists: + if install_exists and journal.phase == "switch_old_intent": + if backup_identity is None: + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "旧安装切换缺少 previous 树身份", + ) + _assert_tree_identity( + install_root, + backup_identity, + role="previous", + ) + return False + if ( + install_exists + and failed_exists + and journal.phase + in { + "failure_restore_intent", + "rollback_applied", + "rollback_failed", + } + ): + if backup_identity is None: + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "失败恢复缺少 previous 树身份", + ) + _assert_tree_identity( + install_root, + backup_identity, + role="restored", + ) + return True + raise HSRManagedUpdateError( + "RECOVERY_STATE_MISMATCH", + "需要回滚旧安装,但 previous 目录不存在", + ) + + self._assert_process_stopped(install_root) + if install_exists: + _assert_tree_identity( + install_root, + candidate_identity, + role="active", + ) + if failed_exists: + raise HSRManagedUpdateError( + "RECOVERY_STATE_MISMATCH", + "当前候选和 failed-candidate 同时存在", + ) + journal.transition( + "failure_isolate_intent", + failed_candidate=str(failed_candidate), + ) + self._call_hook("failure_isolate_intent") + _rename(install_root, failed_candidate) + _assert_tree_identity( + failed_candidate, + candidate_identity, + role="failed-candidate", + ) + self._call_hook("after_failure_isolate_rename") + journal.transition( + "failure_candidate_isolated", + failed_candidate=str(failed_candidate), + ) + self._call_hook("failure_candidate_isolated") + elif not failed_exists and not backup_exists: + if had_existing: + raise HSRManagedUpdateError( + "RECOVERY_STATE_MISMATCH", + "新旧安装及隔离候选均不存在", + ) + return False + + if not had_existing: + return False + if not _path_lexically_exists(backup): + raise HSRManagedUpdateError( + "RECOVERY_STATE_MISMATCH", + "回滚阶段缺少 previous 安装目录", + ) + self._assert_process_stopped(install_root) + journal.transition("failure_restore_intent") + self._call_hook("failure_restore_intent") + if backup_identity is None: + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "失败恢复缺少 previous 树身份", + ) + _rename(backup, install_root) + _assert_tree_identity( + install_root, + backup_identity, + role="restored", + ) + self._call_hook("after_failure_restore_rename") + journal.transition("rollback_applied") + self._call_hook("rollback_applied") + return True + + @staticmethod + def _validate_release( + layout: HSRManagedUpdateLayout, + release: HSRTrustedRelease, + artifact: HSRTrustedArtifact, + ) -> None: + if release.engine != layout.engine: + raise HSRManagedUpdateError( + "ENGINE_MISMATCH", + "release 与 Managed 更新布局的引擎不一致", + ) + if artifact not in release.artifacts: + raise HSRManagedUpdateError( + "UNTRUSTED_ARTIFACT", + "选择的资产不在插件固定 release 清单内", + ) + + @staticmethod + def _validate_version( + current: str | None, + target: str, + *, + allow_downgrade: bool, + ) -> None: + if not current: + return + current_key = _version_key(current) + target_key = _version_key(target) + if current_key == target_key: + raise HSRManagedUpdateError( + "SAME_VERSION", + f"目标版本与当前版本相同: {target}", + ) + if target_key < current_key and not allow_downgrade: + raise HSRManagedUpdateError( + "DOWNGRADE_REJECTED", + f"默认拒绝从 {current} 降级到 {target}", + ) + + @staticmethod + def _validate_recovery_identity( + *, + journal: _Journal, + install_root: Path, + layout: HSRManagedUpdateLayout, + release: HSRTrustedRelease, + transaction_id: str, + ) -> None: + if journal.payload.get("schema_version") != 2: + raise HSRManagedUpdateError( + "JOURNAL_MIGRATION_REQUIRED", + "旧版事务 journal 缺少 generation/WAI 信息,禁止自动恢复", + ) + expected = { + "transaction_id": transaction_id, + "engine": layout.engine, + "release_id": release.release_id, + "install_root": str(install_root), + } + for key, value in expected.items(): + if journal.payload.get(key) != value: + raise HSRManagedUpdateError( + "JOURNAL_IDENTITY_MISMATCH", + f"更新 journal 的 {key} 与当前请求不一致", + ) + previous_current = _journal_previous_current(journal) + if journal.payload.get("expected_generation") != _current_generation( + previous_current + ): + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "journal expected_generation 与 previous_current 不一致", + ) + if journal.payload.get("to_version") != release.version: + raise HSRManagedUpdateError( + "JOURNAL_IDENTITY_MISMATCH", + "更新 journal 的目标版本与 release 不一致", + ) + + @staticmethod + def _result( + *, + journal: _Journal, + layout: HSRManagedUpdateLayout, + current_version: str | None, + release: HSRTrustedRelease, + state: HSRManagedUpdateState, + install_root: Path, + backup: Path, + error: HSRManagedUpdateError | None = None, + ) -> HSRManagedUpdateResult: + return HSRManagedUpdateResult( + transaction_id=str(journal.payload["transaction_id"]), + engine=layout.engine, + from_version=current_version, + to_version=release.version, + state=state, + installation_path=str(install_root), + journal_path=str(journal.path), + backup_path=str(backup) if backup.exists() else None, + error_code=error.code if error else None, + error=str(error) if error else None, + ) + + def _call_hook(self, phase: str) -> None: + if self.phase_hook is not None: + self.phase_hook(phase) + + +def safe_extract_hsr_archive( + archive: Path, + destination: Path, + *, + max_members: int = 100_000, + max_uncompressed_bytes: int = 8 * 1024 * 1024 * 1024, + max_member_bytes: int = 2 * 1024 * 1024 * 1024, + max_ratio: float = 300.0, +) -> tuple[Path, ...]: + """Extract a trusted-digest ZIP while rejecting Windows path hazards.""" + + archive = Path(archive) + destination = Path(destination) + if destination.exists(): + raise HSRManagedUpdateError( + "STAGING_EXISTS", + "事务 staging 目录已存在", + ) + destination.mkdir(parents=True, exist_ok=False) + try: + with zipfile.ZipFile(archive, "r") as package: + entries = package.infolist() + if len(entries) > max_members: + raise HSRManagedUpdateError( + "ARCHIVE_BUDGET_EXCEEDED", + f"ZIP 成员数超限: {len(entries)}", + ) + normalized: list[tuple[zipfile.ZipInfo, str]] = [] + seen: set[str] = set() + total = 0 + for entry in entries: + name = _normalize_archive_name(entry.filename) + collision_key = name.casefold() + if collision_key in seen: + raise HSRManagedUpdateError( + "ARCHIVE_PATH_COLLISION", + f"ZIP 包含 Windows 路径碰撞: {name}", + ) + seen.add(collision_key) + mode = entry.external_attr >> 16 + file_type = stat.S_IFMT(mode) + if file_type == stat.S_IFLNK: + raise HSRManagedUpdateError( + "ARCHIVE_LINK_REJECTED", + f"ZIP 包含符号链接: {name}", + ) + if file_type not in (0, stat.S_IFREG, stat.S_IFDIR): + raise HSRManagedUpdateError( + "ARCHIVE_SPECIAL_FILE_REJECTED", + f"ZIP 包含特殊文件: {name}", + ) + if entry.file_size > max_member_bytes: + raise HSRManagedUpdateError( + "ARCHIVE_BUDGET_EXCEEDED", + f"ZIP 单文件超限: {name}", + ) + total += entry.file_size + if total > max_uncompressed_bytes: + raise HSRManagedUpdateError( + "ARCHIVE_BUDGET_EXCEEDED", + "ZIP 解压总大小超限", + ) + if ( + entry.file_size > 64 * 1024 * 1024 + and entry.compress_size > 0 + and entry.file_size / entry.compress_size > max_ratio + ): + raise HSRManagedUpdateError( + "ARCHIVE_RATIO_REJECTED", + f"ZIP 压缩比异常: {name}", + ) + normalized.append((entry, name)) + + extracted: list[Path] = [] + written_total = 0 + destination_resolved = destination.resolve() + for entry, name in normalized: + target = destination / Path(name) + target_resolved = target.resolve() + try: + target_resolved.relative_to(destination_resolved) + except ValueError as exc: + raise HSRManagedUpdateError( + "ARCHIVE_PATH_ESCAPE", + f"ZIP 路径越界: {name}", + ) from exc + if entry.is_dir() or name.endswith("/"): + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + written = 0 + with package.open(entry, "r") as source, target.open("xb") as sink: + while chunk := source.read(1024 * 1024): + written += len(chunk) + written_total += len(chunk) + if ( + written > entry.file_size + or written_total > max_uncompressed_bytes + ): + raise HSRManagedUpdateError( + "ARCHIVE_BUDGET_EXCEEDED", + f"ZIP 解压数据超出声明大小: {name}", + ) + sink.write(chunk) + sink.flush() + os.fsync(sink.fileno()) + if written != entry.file_size: + raise HSRManagedUpdateError( + "ARCHIVE_SIZE_MISMATCH", + f"ZIP 成员大小不一致: {name}", + ) + extracted.append(target) + return tuple(extracted) + except Exception: + # Keep the isolated transaction directory for audit/recovery. It is + # never published as the user's active installation. + raise + + +def _validate_relative_path(value: str) -> str: + raw = str(value or "").strip().replace("\\", "/") + pure = PurePosixPath(raw) + windows = PureWindowsPath(raw) + if ( + not raw + or pure.is_absolute() + or windows.is_absolute() + or windows.drive + or any(part in ("", ".", "..") for part in pure.parts) + ): + raise ValueError(f"必须使用安全的相对路径: {value!r}") + for part in pure.parts: + if "\0" in part or ":" in part or part.endswith((" ", ".")): + raise ValueError(f"Windows 不安全路径片段: {part!r}") + basename = part.split(".", 1)[0].upper() + if basename in _WINDOWS_RESERVED_NAMES: + raise ValueError(f"Windows 保留路径名: {part!r}") + return pure.as_posix() + + +def _normalize_archive_name(value: str) -> str: + try: + normalized = _validate_relative_path(value.rstrip("/")) + except ValueError as exc: + raise HSRManagedUpdateError( + "ARCHIVE_PATH_REJECTED", + str(exc), + ) from exc + return f"{normalized}/" if value.endswith(("/", "\\")) else normalized + + +def _validate_install_root(path: Path) -> Path: + raw = Path(path).expanduser() + try: + _validate_relative_path(raw.name) + except ValueError as exc: + raise HSRManagedUpdateError( + "UNSAFE_INSTALL_ROOT", + f"安装目录名称不符合 Windows 安全规则: {raw.name!r}", + ) from exc + lexical = _lexical_absolute(raw) + if lexical == Path(lexical.anchor) or lexical.parent == lexical: + raise HSRManagedUpdateError( + "UNSAFE_INSTALL_ROOT", + "拒绝把卷根目录作为脚本安装目录", + ) + if not lexical.parent.is_dir(): + raise HSRManagedUpdateError( + "UNSAFE_INSTALL_ROOT", + "脚本安装目录的父目录不存在或不是目录", + ) + _assert_no_reparse_components( + lexical.parent, + code="UNSAFE_INSTALL_ROOT", + ) + if _path_lexically_exists(lexical): + _assert_no_reparse_components( + lexical, + code="UNSAFE_INSTALL_ROOT", + ) + return lexical + + +def _workspace_root(install_root: Path) -> Path: + workspace = install_root.parent / f".{install_root.name}.automas-hsr-update" + if workspace.parent != install_root.parent: + raise HSRManagedUpdateError( + "UNSAFE_WORKSPACE", + "更新事务目录必须与安装目录同级", + ) + if _path_lexically_exists(workspace): + _assert_no_reparse_components( + workspace, + code="UNSAFE_WORKSPACE", + ) + if not workspace.is_dir() or _is_reparse_point(workspace): + raise HSRManagedUpdateError( + "UNSAFE_WORKSPACE", + "更新事务路径必须是非 reparse 的实体目录", + ) + else: + workspace.mkdir(parents=False, exist_ok=False) + _assert_no_reparse_components( + workspace, + code="UNSAFE_WORKSPACE", + ) + return workspace + + +def _transaction_root( + workspace: Path, + *, + transaction_id: str, + create_parent: bool, +) -> Path: + transaction_id = _validate_transaction_id(transaction_id) + transactions = workspace / "transactions" + if not _path_lexically_exists(transactions): + if not create_parent: + raise HSRManagedUpdateError( + "TRANSACTION_NOT_FOUND", + "事务目录不存在", + ) + transactions.mkdir(parents=False, exist_ok=False) + _assert_no_reparse_components( + transactions, + boundary=workspace, + code="UNSAFE_TRANSACTION_PATH", + ) + if not transactions.is_dir() or _is_reparse_point(transactions): + raise HSRManagedUpdateError( + "UNSAFE_TRANSACTION_PATH", + "transactions 必须是非 reparse 的实体目录", + ) + tx_root = transactions / transaction_id + if not create_parent: + if not tx_root.is_dir() or _is_reparse_point(tx_root): + raise HSRManagedUpdateError( + "TRANSACTION_NOT_FOUND", + "指定事务不存在或不安全", + ) + _assert_no_reparse_components( + tx_root, + boundary=transactions, + code="UNSAFE_TRANSACTION_PATH", + ) + return tx_root + + +def _assert_missing_current_is_pristine(workspace: Path) -> None: + transactions = workspace / "transactions" + if not _path_lexically_exists(transactions): + return + _assert_no_reparse_components( + transactions, + boundary=workspace, + code="UNSAFE_TRANSACTION_PATH", + ) + if not transactions.is_dir(): + raise HSRManagedUpdateError( + "UNSAFE_TRANSACTION_PATH", + "transactions 不是目录", + ) + for transaction in transactions.iterdir(): + if ( + _is_reparse_point(transaction) + or not transaction.is_dir() + ): + raise HSRManagedUpdateError( + "UNSAFE_TRANSACTION_PATH", + "历史事务条目必须是非 reparse 的实体目录", + ) + try: + _validate_transaction_id(transaction.name) + except HSRManagedUpdateError as exc: + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "历史事务目录名无效,无法证明 CURRENT 缺失是安全的", + ) from exc + journal_path = transaction / "journal.json" + if not _path_lexically_exists(journal_path): + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "历史事务缺少 journal,无法证明 CURRENT 缺失是安全的", + ) + if _is_reparse_point(journal_path) or not journal_path.is_file(): + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "历史事务 journal 不是安全普通文件", + ) + journal = _Journal.load(journal_path) + if journal.payload.get("schema_version") != 2: + raise HSRManagedUpdateError( + "JOURNAL_MIGRATION_REQUIRED", + "CURRENT 缺失时存在旧版事务 journal,禁止继续安装", + ) + previous_current = _journal_previous_current(journal) + phase = journal.phase + manual_rollback = bool(journal.payload.get("manual")) + if ( + previous_current is not None + or phase == "committed" + or (phase == "rolled_back" and manual_rollback) + or phase not in { + "failed", + "rolled_back", + "recovered_aborted", + } + ): + raise HSRManagedUpdateError( + "CURRENT_MISSING", + "检测到已发布或未恢复的事务,但 CURRENT 指针缺失", + ) + + +def _select_payload_root( + unpacked: Path, + layout: HSRManagedUpdateLayout, +) -> Path: + def complete(candidate: Path) -> bool: + if not candidate.is_dir() or _is_reparse_point(candidate): + return False + for relative in layout.required_paths: + required = candidate / Path(relative) + if not _path_lexically_exists(required): + return False + _assert_no_reparse_components( + required, + boundary=candidate, + code="REQUIRED_PATH_REPARSE_REJECTED", + ) + _assert_path_owned_by(required, candidate) + if not required.is_file(): + raise HSRManagedUpdateError( + "REQUIRED_PATH_TYPE_INVALID", + f"必需路径必须是普通文件: {relative}", + ) + return True + + if complete(unpacked): + return unpacked + candidates = [ + child + for child in unpacked.iterdir() + if child.is_dir() and not _is_reparse_point(child) and complete(child) + ] + if len(candidates) != 1: + raise HSRManagedUpdateError( + "ARCHIVE_ROOT_AMBIGUOUS", + "无法唯一确定发布包安装根", + ) + return candidates[0] + + +def _apply_preservation_rules( + install_root: Path, + candidate_root: Path, + rules: tuple[HSRPreservationRule, ...], +) -> None: + for rule in rules: + source = install_root / Path(rule.path) + if not _path_lexically_exists(source): + continue + _assert_no_reparse_components( + source, + boundary=install_root, + code="PRESERVED_LINK_REJECTED", + ) + _assert_path_owned_by(source, install_root) + destination = candidate_root / Path(rule.path) + if _is_reparse_point(source): + raise HSRManagedUpdateError( + "PRESERVED_LINK_REJECTED", + f"保留路径不能是符号链接: {rule.path}", + ) + if source.is_file(): + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + continue + if not source.is_dir(): + raise HSRManagedUpdateError( + "PRESERVED_SPECIAL_FILE_REJECTED", + f"保留路径类型不受支持: {rule.path}", + ) + for current_root, directories, files in os.walk( + source, + topdown=True, + followlinks=False, + ): + current = Path(current_root) + for directory in tuple(directories): + directory_path = current / directory + if _is_reparse_point(directory_path): + raise HSRManagedUpdateError( + "PRESERVED_LINK_REJECTED", + f"保留目录包含符号链接: {directory_path}", + ) + for filename in files: + source_file = current / filename + if _is_reparse_point(source_file): + raise HSRManagedUpdateError( + "PRESERVED_LINK_REJECTED", + f"保留目录包含符号链接: {source_file}", + ) + relative = source_file.relative_to(source) + relative_text = relative.as_posix() + if rule.patterns and not any( + fnmatch.fnmatch(relative_text, pattern) + or fnmatch.fnmatch(filename, pattern) + for pattern in rule.patterns + ): + continue + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_file, target) + + +def _assert_path_owned_by(path: Path, root: Path) -> None: + try: + path.resolve(strict=True).relative_to(root.resolve(strict=True)) + except (OSError, ValueError) as exc: + raise HSRManagedUpdateError( + "PRESERVED_PATH_ESCAPE", + f"保留路径越出安装目录: {path}", + ) from exc + + +def _verify_artifact(path: Path, artifact: HSRTrustedArtifact) -> None: + if _is_reparse_point(path) or not path.is_file(): + raise HSRManagedUpdateError( + "ARTIFACT_PATH_REJECTED", + "下载资产必须是非 reparse 的普通文件", + ) + try: + size = path.stat().st_size + except OSError as exc: + raise HSRManagedUpdateError( + "ARTIFACT_MISSING", + f"无法读取下载资产: {exc}", + ) from exc + if size != artifact.size: + raise HSRManagedUpdateError( + "ARTIFACT_SIZE_MISMATCH", + f"资产大小不一致: expected={artifact.size}, actual={size}", + ) + digest = _sha256(path) + if digest != artifact.sha256: + raise HSRManagedUpdateError( + "ARTIFACT_DIGEST_MISMATCH", + "资产 SHA-256 与插件固定值不一致", + ) + if not zipfile.is_zipfile(path): + raise HSRManagedUpdateError( + "ARTIFACT_FORMAT_INVALID", + "固定资产不是有效 ZIP", + ) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def _capture_tree_identity(path: Path) -> dict[str, int]: + if ( + not _path_lexically_exists(path) + or _is_reparse_point(path) + or not path.is_dir() + ): + raise HSRManagedUpdateError( + "TREE_IDENTITY_UNAVAILABLE", + f"无法为非实体目录建立事务树身份: {path}", + ) + _assert_no_reparse_components( + path, + code="UNSAFE_TRANSACTION_PATH", + ) + try: + metadata = os.stat(path, follow_symlinks=False) + except OSError as exc: + raise HSRManagedUpdateError( + "TREE_IDENTITY_UNAVAILABLE", + f"无法读取事务树身份: {path}", + ) from exc + device = int(metadata.st_dev) + inode = int(metadata.st_ino) + if inode <= 0: + raise HSRManagedUpdateError( + "TREE_IDENTITY_UNAVAILABLE", + "文件系统未提供可跨 rename 验证的目录身份", + ) + return { + "schema_version": 1, + "device": device, + "inode": inode, + } + + +def _validate_tree_identity_payload(value: Any) -> dict[str, int]: + if not isinstance(value, dict) or set(value) != _TREE_IDENTITY_KEYS: + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "事务树身份 schema 无效", + ) + schema_version = value.get("schema_version") + if ( + not isinstance(schema_version, int) + or isinstance(schema_version, bool) + or schema_version != 1 + ): + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "事务树身份版本无效", + ) + for key in ("device", "inode"): + item = value.get(key) + if ( + not isinstance(item, int) + or isinstance(item, bool) + or item < 0 + or (key == "inode" and item == 0) + ): + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + f"事务树身份 {key} 无效", + ) + return { + "schema_version": 1, + "device": int(value["device"]), + "inode": int(value["inode"]), + } + + +def _journal_tree_identity( + journal: _Journal, + key: str, +) -> dict[str, int]: + try: + return _validate_tree_identity_payload(journal.payload.get(key)) + except HSRManagedUpdateError as exc: + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + f"更新 journal 的 {key} 无效", + ) from exc + + +def _assert_tree_identity( + path: Path, + expected: dict[str, int], + *, + role: str, +) -> None: + actual = _capture_tree_identity(path) + if actual != _validate_tree_identity_payload(expected): + raise HSRManagedUpdateError( + "TRANSACTION_TREE_IDENTITY_MISMATCH", + f"{role} 目录不是该事务记录的原始目录对象", + ) + + +def _rename(source: Path, destination: Path) -> None: + if not _path_lexically_exists(source): + raise HSRManagedUpdateError( + "TRANSACTION_SOURCE_MISSING", + f"事务源不存在: {source}", + ) + if _is_reparse_point(source): + raise HSRManagedUpdateError( + "UNSAFE_TRANSACTION_SOURCE", + f"事务源不能是符号链接、junction 或 reparse point: {source}", + ) + _assert_no_reparse_components( + source, + code="UNSAFE_TRANSACTION_SOURCE", + ) + try: + source_mode = os.lstat(source).st_mode + except OSError as exc: + raise HSRManagedUpdateError( + "TRANSACTION_SOURCE_MISSING", + f"无法读取事务源: {source}", + ) from exc + if not (stat.S_ISREG(source_mode) or stat.S_ISDIR(source_mode)): + raise HSRManagedUpdateError( + "UNSAFE_TRANSACTION_SOURCE", + f"事务源类型不受支持: {source}", + ) + if _path_lexically_exists(destination): + raise HSRManagedUpdateError( + "TRANSACTION_PATH_COLLISION", + f"事务目标已存在: {destination}", + ) + _assert_no_reparse_components( + destination.parent, + code="UNSAFE_TRANSACTION_PATH", + ) + source_parent = source.parent + _replace_path_write_through( + source, + destination, + replace_existing=False, + ) + if ( + not _path_lexically_exists(destination) + or _is_reparse_point(destination) + ): + raise HSRManagedUpdateError( + "TRANSACTION_RENAME_UNVERIFIED", + f"rename 后无法验证事务目标: {destination}", + ) + _flush_directory_best_effort(source_parent) + if destination.parent != source_parent: + _flush_directory_best_effort(destination.parent) + + +def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + _assert_no_reparse_components( + path.parent, + code="UNSAFE_ATOMIC_WRITE_PATH", + ) + if _path_lexically_exists(path) and _is_reparse_point(path): + raise HSRManagedUpdateError( + "UNSAFE_ATOMIC_WRITE_PATH", + f"原子写目标不能是 reparse point: {path}", + ) + temporary = path.with_name(f".{path.name}.{uuid4().hex}.tmp") + try: + with temporary.open("x", encoding="utf-8", newline="\n") as sink: + json.dump(payload, sink, ensure_ascii=False, sort_keys=True, indent=2) + sink.write("\n") + sink.flush() + os.fsync(sink.fileno()) + if _is_reparse_point(temporary): + raise HSRManagedUpdateError( + "UNSAFE_ATOMIC_WRITE_PATH", + "原子写临时文件不能是 reparse point", + ) + _replace_path_write_through( + temporary, + path, + replace_existing=True, + ) + _flush_directory_best_effort(path.parent) + finally: + if _path_lexically_exists(temporary): + try: + temporary.unlink() + except OSError: + pass + + +def _replace_path_write_through( + source: Path, + destination: Path, + *, + replace_existing: bool, +) -> None: + """Rename atomically; Windows requests metadata write-through. + + File contents are fsync'd by the caller before publication. MoveFileExW + with MOVEFILE_WRITE_THROUGH narrows the metadata-loss window on Windows, + but tests still cannot prove survival across real power loss/controller + cache loss. + """ + + if os.name != "nt": + if replace_existing: + os.replace(source, destination) + else: + source.replace(destination) + return + + import ctypes + from ctypes import wintypes + + move_file_ex = ctypes.WinDLL( + "kernel32", + use_last_error=True, + ).MoveFileExW + move_file_ex.argtypes = ( + wintypes.LPCWSTR, + wintypes.LPCWSTR, + wintypes.DWORD, + ) + move_file_ex.restype = wintypes.BOOL + flags = 0x00000008 + if replace_existing: + flags |= 0x00000001 + if not move_file_ex(str(source), str(destination), flags): + error = ctypes.get_last_error() + raise OSError( + error, + f"MoveFileExW failed: {source} -> {destination}", + ) + + +def _flush_directory_best_effort(path: Path) -> bool: + """Best-effort parent metadata flush; unsupported filesystems may refuse.""" + + if os.name != "nt": + flags = os.O_RDONLY | int(getattr(os, "O_DIRECTORY", 0)) + try: + descriptor = os.open(path, flags) + except OSError: + return False + try: + os.fsync(descriptor) + except OSError: + return False + finally: + os.close(descriptor) + return True + + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + create_file = kernel32.CreateFileW + create_file.argtypes = ( + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ) + create_file.restype = wintypes.HANDLE + flush = kernel32.FlushFileBuffers + flush.argtypes = (wintypes.HANDLE,) + flush.restype = wintypes.BOOL + close = kernel32.CloseHandle + close.argtypes = (wintypes.HANDLE,) + close.restype = wintypes.BOOL + handle = create_file( + str(path), + 0x40000000, + 0x00000001 | 0x00000002 | 0x00000004, + None, + 3, + 0x02000000, + None, + ) + invalid = wintypes.HANDLE(-1).value + if handle == invalid: + return False + try: + return bool(flush(handle)) + finally: + close(handle) + + +def _version_key(value: str) -> Version: + normalized = str(value or "").strip().lstrip("vV") + try: + return Version(normalized) + except InvalidVersion as exc: + raise HSRManagedUpdateError( + "VERSION_UNPARSEABLE", + f"无法比较版本号: {value}", + ) from exc + + +def _validate_transaction_id(value: str) -> str: + normalized = str(value or "").strip().lower() + if len(normalized) != 32 or any( + character not in "0123456789abcdef" for character in normalized + ): + raise HSRManagedUpdateError( + "INVALID_TRANSACTION", + "事务 ID 无效", + ) + return normalized + + +def _lexical_absolute(path: Path) -> Path: + return Path( + os.path.abspath( + os.path.expanduser(os.fspath(path)), + ) + ) + + +def _path_lexically_exists(path: Path) -> bool: + try: + os.lstat(path) + except (FileNotFoundError, NotADirectoryError): + return False + return True + + +def _is_reparse_point(path: Path) -> bool: + try: + metadata = os.lstat(path) + except (FileNotFoundError, NotADirectoryError): + return False + if stat.S_ISLNK(metadata.st_mode): + return True + attributes = int(getattr(metadata, "st_file_attributes", 0)) + reparse_flag = int(getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)) + if attributes & reparse_flag: + return True + is_junction = getattr(path, "is_junction", None) + return bool(is_junction is not None and is_junction()) + + +def _assert_no_reparse_components( + path: Path, + *, + boundary: Path | None = None, + code: str, +) -> None: + lexical = _lexical_absolute(path) + if boundary is None: + start = Path(lexical.anchor) + parts = lexical.parts[1:] + else: + lexical_boundary = _lexical_absolute(boundary) + _assert_lexically_owned(lexical, lexical_boundary, code=code) + start = lexical_boundary + if _path_lexically_exists(start) and _is_reparse_point(start): + raise HSRManagedUpdateError( + code, + f"路径包含 reparse point: {start}", + ) + parts = lexical.relative_to(lexical_boundary).parts + current = start + for part in parts: + current = current / part + if not _path_lexically_exists(current): + break + if _is_reparse_point(current): + raise HSRManagedUpdateError( + code, + f"路径包含符号链接、junction 或 reparse point: {current}", + ) + + +def _assert_lexically_owned( + path: Path, + root: Path, + *, + code: str, +) -> None: + path_text = os.path.normcase(os.fspath(_lexical_absolute(path))) + root_text = os.path.normcase(os.fspath(_lexical_absolute(root))) + try: + common = os.path.commonpath((path_text, root_text)) + except ValueError as exc: + raise HSRManagedUpdateError( + code, + f"路径不属于预期目录: {path}", + ) from exc + if common != root_text: + raise HSRManagedUpdateError( + code, + f"路径不属于预期目录: {path}", + ) + + +def _assert_transaction_child(path: Path, tx_root: Path) -> None: + _assert_lexically_owned( + path, + tx_root, + code="UNSAFE_TRANSACTION_PATH", + ) + if _lexical_absolute(path) == _lexical_absolute(tx_root): + raise HSRManagedUpdateError( + "UNSAFE_TRANSACTION_PATH", + "事务子路径不能等于事务根", + ) + _assert_no_reparse_components( + path.parent, + boundary=tx_root, + code="UNSAFE_TRANSACTION_PATH", + ) + + +def _prepare_cache_root(path: Path) -> Path: + raw = Path(path).expanduser() + try: + _validate_relative_path(raw.name) + except ValueError as exc: + raise HSRManagedUpdateError( + "UNSAFE_CACHE_ROOT", + f"缓存目录名称不符合 Windows 安全规则: {raw.name!r}", + ) from exc + cache_root = _lexical_absolute(raw) + if cache_root == Path(cache_root.anchor): + raise HSRManagedUpdateError( + "UNSAFE_CACHE_ROOT", + "缓存目录不能是卷根", + ) + if _path_lexically_exists(cache_root): + _assert_no_reparse_components( + cache_root, + code="UNSAFE_CACHE_ROOT", + ) + if not cache_root.is_dir() or _is_reparse_point(cache_root): + raise HSRManagedUpdateError( + "UNSAFE_CACHE_ROOT", + "缓存路径必须是非 reparse 的实体目录", + ) + else: + _assert_no_reparse_components( + cache_root.parent, + code="UNSAFE_CACHE_ROOT", + ) + if not cache_root.parent.is_dir(): + cache_root.parent.mkdir(parents=True, exist_ok=True) + _assert_no_reparse_components( + cache_root.parent, + code="UNSAFE_CACHE_ROOT", + ) + cache_root.mkdir(parents=False, exist_ok=False) + return cache_root + + +def _current_generation(pointer: dict[str, Any] | None) -> int: + if pointer is None: + return 0 + generation = pointer.get("generation") + if ( + not isinstance(generation, int) + or isinstance(generation, bool) + or generation < 1 + ): + raise HSRManagedUpdateError( + "CURRENT_CORRUPTED", + "CURRENT generation 必须是正整数", + ) + return generation + + +def _read_current_pointer( + workspace: Path, + *, + install_root: Path, +) -> dict[str, Any] | None: + path = workspace / _CURRENT_POINTER_NAME + if not _path_lexically_exists(path): + return None + if _is_reparse_point(path) or not path.is_file(): + raise HSRManagedUpdateError( + "CURRENT_CORRUPTED", + "CURRENT 必须是非 reparse 的普通文件", + ) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise HSRManagedUpdateError( + "CURRENT_CORRUPTED", + f"无法读取 CURRENT: {exc}", + ) from exc + _validate_current_pointer_payload( + payload, + install_root=install_root, + ) + return payload + + +def _validate_current_pointer_payload( + payload: Any, + *, + install_root: Path, +) -> None: + if ( + not isinstance(payload, dict) + or set(payload) != _CURRENT_POINTER_KEYS + ): + raise HSRManagedUpdateError( + "CURRENT_CORRUPTED", + "CURRENT schema 无效", + ) + schema_version = payload.get("schema_version") + if ( + not isinstance(schema_version, int) + or isinstance(schema_version, bool) + or schema_version != 1 + ): + raise HSRManagedUpdateError( + "CURRENT_CORRUPTED", + "CURRENT schema 版本无效", + ) + _current_generation(payload) + if payload.get("state") not in {"managed", "unmanaged", "absent"}: + raise HSRManagedUpdateError( + "CURRENT_CORRUPTED", + "CURRENT state 无效", + ) + active_transaction = payload.get("active_transaction") + if active_transaction is not None: + try: + normalized_transaction = ( + _validate_transaction_id(active_transaction) + if isinstance(active_transaction, str) + else None + ) + except HSRManagedUpdateError as exc: + raise HSRManagedUpdateError( + "CURRENT_CORRUPTED", + "CURRENT active_transaction 无效", + ) from exc + if normalized_transaction != active_transaction: + raise HSRManagedUpdateError( + "CURRENT_CORRUPTED", + "CURRENT active_transaction 无效", + ) + engine = payload.get("engine") + if engine not in {"SRA", "M7A"}: + raise HSRManagedUpdateError( + "CURRENT_CORRUPTED", + "CURRENT engine 无效", + ) + state = payload["state"] + if state == "managed" and ( + active_transaction is None + or not isinstance(payload.get("version"), str) + or not str(payload["version"]).strip() + or payload["version"] != str(payload["version"]).strip() + or not isinstance(payload.get("release_id"), int) + or isinstance(payload.get("release_id"), bool) + or int(payload["release_id"]) <= 0 + ): + raise HSRManagedUpdateError( + "CURRENT_CORRUPTED", + "managed CURRENT 必须包含严格的事务、release 和 version", + ) + if state in {"unmanaged", "absent"} and active_transaction is not None: + raise HSRManagedUpdateError( + "CURRENT_CORRUPTED", + f"{state} CURRENT 不得包含 active transaction", + ) + if state == "absent" and payload.get("version") is not None: + raise HSRManagedUpdateError( + "CURRENT_CORRUPTED", + "absent CURRENT 不得包含 version", + ) + if state in {"unmanaged", "absent"} and payload.get("release_id") is not None: + raise HSRManagedUpdateError( + "CURRENT_CORRUPTED", + f"{state} CURRENT 不得包含 release_id", + ) + if state == "unmanaged" and ( + not isinstance(payload.get("version"), str) + or not str(payload["version"]).strip() + or payload["version"] != str(payload["version"]).strip() + ): + raise HSRManagedUpdateError( + "CURRENT_CORRUPTED", + "unmanaged CURRENT 必须包含严格 version", + ) + if ( + not isinstance(payload.get("install_root"), str) + or payload.get("install_root") != str(install_root) + ): + raise HSRManagedUpdateError( + "CURRENT_CORRUPTED", + "CURRENT install_root 与当前安装目录不一致", + ) + + +def _write_current_pointer( + workspace: Path, + pointer: dict[str, Any], + *, + install_root: Path, +) -> None: + _validate_current_pointer_payload( + pointer, + install_root=install_root, + ) + _atomic_write_json(workspace / _CURRENT_POINTER_NAME, pointer) + + +def _assert_current_pointer( + workspace: Path, + *, + install_root: Path, + expected: dict[str, Any] | None, +) -> None: + actual = _read_current_pointer( + workspace, + install_root=install_root, + ) + if actual != expected: + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "CURRENT generation/active transaction 已变化", + ) + + +def _assert_current_lineage( + actual: dict[str, Any] | None, + canonical: dict[str, Any], + *, + workspace: Path, + install_root: Path, +) -> None: + if actual is None: + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "CURRENT 缺失,无法证明事务 lineage", + ) + if _current_generation(actual) < _current_generation(canonical): + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "CURRENT generation 早于事务提交 generation", + ) + for key in _CURRENT_POINTER_KEYS - {"generation"}: + if actual.get(key) != canonical.get(key): + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "CURRENT 已指向不同事务 lineage", + ) + if actual == canonical: + return + + transactions = workspace / "transactions" + if ( + not transactions.is_dir() + or _is_reparse_point(transactions) + ): + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "无法验证 CURRENT lineage 的事务目录", + ) + _assert_no_reparse_components( + transactions, + boundary=workspace, + code="UNSAFE_TRANSACTION_PATH", + ) + journals: list[_Journal] = [] + for child in transactions.iterdir(): + if ( + not child.is_dir() + or _is_reparse_point(child) + ): + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "CURRENT lineage 包含不安全事务条目", + ) + try: + child_transaction_id = _validate_transaction_id(child.name) + except HSRManagedUpdateError as exc: + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "CURRENT lineage 包含无效事务目录名", + ) from exc + journal_path = child / "journal.json" + if not _path_lexically_exists(journal_path): + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "CURRENT lineage 包含无 journal 的事务", + ) + journal = _Journal.load(journal_path) + if ( + journal.payload.get("schema_version") == 2 + and journal.phase == "rolled_back" + and journal.payload.get("manual") is True + ): + if journal.payload.get("transaction_id") != child_transaction_id: + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "CURRENT lineage 的事务目录与 journal 身份不一致", + ) + journals.append(journal) + + known = dict(canonical) + target_generation = _current_generation(actual) + while _current_generation(known) < target_generation: + edges: list[dict[str, Any]] = [] + for journal in journals: + try: + previous = _journal_previous_current(journal) + except HSRManagedUpdateError as exc: + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "CURRENT lineage 包含损坏的 previous pointer", + ) from exc + if previous != known: + continue + transaction_id = _validate_transaction_id( + str(journal.payload.get("transaction_id") or "") + ) + active = { + "schema_version": 1, + "generation": _current_generation(known) + 1, + "state": "managed", + "active_transaction": transaction_id, + "engine": journal.payload.get("engine"), + "release_id": journal.payload.get("release_id"), + "version": journal.payload.get("to_version"), + "install_root": str(install_root), + } + _validate_current_pointer_payload( + active, + install_root=install_root, + ) + if journal.payload.get("expected_generation") != ( + _current_generation(known) + ): + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "CURRENT lineage 的事务起始 generation 不一致", + ) + edge = _rollback_current_pointer( + journal=journal, + install_root=install_root, + active_current=active, + ) + if ( + journal.payload.get("install_root") != str(install_root) + or journal.payload.get("manual_expected_generation") + != _current_generation(active) + or journal.payload.get("manual_active_pointer") != active + or journal.payload.get("rollback_pointer") != edge + or journal.payload.get("current_pointer") != edge + ): + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "CURRENT lineage 的回滚事务证据不一致", + ) + forward = journal.path.parent / "replaced-current" + if ( + journal.payload.get("replaced_current") != str(forward) + or not _path_lexically_exists(forward) + ): + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "CURRENT lineage 缺少规范 replaced-current 证据", + ) + try: + _assert_tree_identity( + forward, + _journal_tree_identity( + journal, + "candidate_identity", + ), + role="lineage replaced-current", + ) + except HSRManagedUpdateError as exc: + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "CURRENT lineage 的 replaced-current 身份无效", + ) from exc + edges.append(edge) + if len(edges) != 1: + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "CURRENT generation 缺少唯一、连续的回滚 lineage 证据", + ) + known = edges[0] + if _current_generation(known) > target_generation: + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "CURRENT generation 与回滚 lineage 不连续", + ) + if known != actual: + raise HSRManagedUpdateError( + "CURRENT_STATE_CHANGED", + "CURRENT 与已验证回滚 lineage 的终点不一致", + ) + + +def _journal_previous_current( + journal: _Journal, +) -> dict[str, Any] | None: + value = journal.payload.get("previous_current") + if value is None: + return None + if not isinstance(value, dict): + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "previous_current 必须是对象或 null", + ) + install_root = journal.payload.get("install_root") + if not isinstance(install_root, str) or not install_root: + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "journal install_root 无效", + ) + try: + _validate_current_pointer_payload( + value, + install_root=Path(install_root), + ) + except HSRManagedUpdateError as exc: + raise HSRManagedUpdateError( + "JOURNAL_CORRUPTED", + "journal previous_current schema 无效", + ) from exc + return value + + +def _managed_current_pointer( + *, + journal: _Journal, + install_root: Path, + release: HSRTrustedRelease, +) -> dict[str, Any]: + transaction_id = _validate_transaction_id( + str(journal.payload.get("transaction_id") or "") + ) + previous = _journal_previous_current(journal) + return { + "schema_version": 1, + "generation": _current_generation(previous) + 1, + "state": "managed", + "active_transaction": transaction_id, + "engine": release.engine, + "release_id": release.release_id, + "version": release.version, + "install_root": str(install_root), + } + + +def _rollback_current_pointer( + *, + journal: _Journal, + install_root: Path, + active_current: dict[str, Any], +) -> dict[str, Any]: + next_generation = _current_generation(active_current) + 1 + previous = _journal_previous_current(journal) + if previous is not None: + restored = dict(previous) + restored["generation"] = next_generation + restored["install_root"] = str(install_root) + return restored + had_existing = bool(journal.payload.get("had_existing")) + return { + "schema_version": 1, + "generation": next_generation, + "state": "unmanaged" if had_existing else "absent", + "active_transaction": None, + "engine": journal.payload.get("engine"), + "release_id": None, + "version": ( + _optional_string(journal.payload.get("from_version")) + if had_existing + else None + ), + "install_root": str(install_root), + } + + +def _coerce_update_error(error: Exception) -> HSRManagedUpdateError: + if isinstance(error, HSRManagedUpdateError): + return error + return HSRManagedUpdateError( + "UPDATE_FAILED", + f"{type(error).__name__}: {error}", + ) + + +def _optional_string(value: Any) -> str | None: + if value is None: + return None + return str(value) diff --git a/packages/automas_script_hsr/src/automas_script_hsr/plugin.py b/packages/automas_script_hsr/src/automas_script_hsr/plugin.py index 74e9e0d..9374df3 100644 --- a/packages/automas_script_hsr/src/automas_script_hsr/plugin.py +++ b/packages/automas_script_hsr/src/automas_script_hsr/plugin.py @@ -6,7 +6,6 @@ from app.core import Config from app.core.script_types import script_type_registry -from app.models.plugin_script_config import PluginScriptConfig from app.plugins import ( PluginHttpRequest, PluginHttpResponse, @@ -17,6 +16,7 @@ from app.plugins.script_config_store import ScriptConfigStore from .registry import HSRRegistryService +from .runtime.config_access import resolve_script_storage from .schema import HSRConfig, HSRUserConfig @@ -171,11 +171,11 @@ async def _stage_options(self, request: PluginHttpRequest) -> PluginHttpResponse async def _load_script(self, script_id: str) -> tuple[ScriptConfigStore, Any]: script_uid = uuid.UUID(script_id) - storage = Config.ScriptConfig[script_uid] - if not isinstance(storage, PluginScriptConfig): - raise ValueError("脚本不是 HSR 插件配置") - if str(storage.get("Meta", "PluginTypeKey") or "").strip() != "HSR": - raise ValueError("脚本不是 HSR 插件配置") + storage = resolve_script_storage( + Config, + script_uid, + expected_type_key="HSR", + ) if self.provider is None: raise LookupError("HSR provider 当前未注册") store = ScriptConfigStore( diff --git a/packages/automas_script_hsr/src/automas_script_hsr/registry.py b/packages/automas_script_hsr/src/automas_script_hsr/registry.py index b22084d..6f9e279 100644 --- a/packages/automas_script_hsr/src/automas_script_hsr/registry.py +++ b/packages/automas_script_hsr/src/automas_script_hsr/registry.py @@ -12,6 +12,7 @@ HSRController, HSREngine, HSRTaskCatalogProvider, + HSRUpdateProvider, ) @@ -63,6 +64,7 @@ def register_group( owner: str, task_catalog: HSRTaskCatalogProvider, controller: HSRController, + update_provider: HSRUpdateProvider | None = None, ) -> HSRAdapterGroup: """原子注册同一引擎的任务目录与控制器。""" @@ -72,6 +74,10 @@ def register_group( raise TypeError("HSR 任务目录未实现 HSRTaskCatalogProvider") if not isinstance(controller, HSRController): raise TypeError("HSR 控制器未实现 HSRController") + if update_provider is not None and not isinstance( + update_provider, HSRUpdateProvider + ): + raise TypeError("HSR 更新提供器未实现 HSRUpdateProvider") task_engine = task_catalog.descriptor.engine controller_engine = controller.descriptor.engine @@ -79,6 +85,14 @@ def register_group( raise ValueError( f"HSR 任务目录与控制器引擎不一致: {task_engine}/{controller_engine}" ) + if ( + update_provider is not None + and update_provider.descriptor.engine != task_engine + ): + raise ValueError( + "HSR 更新提供器与任务目录引擎不一致: " + f"{update_provider.descriptor.engine}/{task_engine}" + ) with self._lock: existing = self._groups.get(task_engine) @@ -90,6 +104,7 @@ def register_group( owner=owner, task_catalog=task_catalog, controller=controller, + update_provider=update_provider, ) self._groups[task_engine] = group self._changed() @@ -195,6 +210,16 @@ def snapshot( ready, ready_reason = group.controller.probe(script_config) adapter_data = descriptor.asdict() adapter_data.update({"ready": ready, "ready_reason": ready_reason}) + if group.update_provider is not None: + installation = ( + group.update_provider.inspect(script_config).asdict() + if script_config is not None + else None + ) + adapter_data["update"] = { + "policy": group.update_provider.descriptor.asdict(), + "installation": installation, + } adapters.append(adapter_data) for task in group.task_catalog.list_tasks(): diff --git a/packages/automas_script_hsr/src/automas_script_hsr/runtime/autoproxy.py b/packages/automas_script_hsr/src/automas_script_hsr/runtime/autoproxy.py index 746c1ab..d088a6d 100644 --- a/packages/automas_script_hsr/src/automas_script_hsr/runtime/autoproxy.py +++ b/packages/automas_script_hsr/src/automas_script_hsr/runtime/autoproxy.py @@ -24,13 +24,13 @@ import asyncio import uuid +from collections.abc import Mapping from contextlib import suppress from datetime import datetime from pathlib import Path from typing import Any, Literal from app.core import Config -from app.models.ConfigBase import MultipleConfig from app.models.task import LogRecord, ScriptItem, TaskExecuteBase, UserItem from app.services.system import System from app.utils import ProcessManager, get_logger, is_process_running @@ -102,7 +102,7 @@ def __init__( self, script_info: ScriptItem, script_config: Any, - user_config: MultipleConfig[Any], + user_config: Mapping[uuid.UUID, Any], user_item: UserItem, runtime: HSRRuntimeState, ) -> None: @@ -472,17 +472,21 @@ def _queue_eow_completion_if_confirmed( self, user_id: str, user_name: str, - eow_enabled: bool, + eow_required: bool, result: HSRNativeRunResult, script: Literal["M7A", "SRA"], + *, + monitor_eow_completion: bool = False, ) -> None: - """外部脚本确认历战余响完成后,登记完成态。""" + """按真实日志登记 EOW 完成态,不把“仅监测”误报为未完成。""" - if not eow_enabled: + if not eow_required and not monitor_eow_completion: return completed, reason = detect_echo_of_war_completion(result, script) if not completed: + if not eow_required: + return self._record_module_result( user_id=user_id, user_name=user_name, @@ -636,7 +640,18 @@ def _resolve_daily_runnable_parts( uid: str, daily_eow_enabled: bool, ) -> tuple[bool, bool]: - """判断体力模块实际可执行内容;缺少关卡配置时跳过而不是失败。""" + """判断体力模块实际可执行内容;缺少关卡配置时跳过而不是失败。 + + 培养目标模式(CultivationTarget.Enabled=True)下,引擎会用自己的 + 原生培养目标识别流程生成任务,因此即使 main_configured 为 false 也不 + 跳过 Daily。SRA 的原生培养目标会自行把 EOW 加入执行计划,且没有只排除 + EOW 的配置开关;此时由 SRA 接管执行时机,MAS 只依据实际日志监测并回写 + 完成状态。M7A 仍由 MAS 的周常开关与手动 EOW 关卡控制。 + """ + + cultivation_enabled = bool( + self.script_config.get("CultivationTarget", "Enabled") + ) if assigned_script == "SRA": main_configured = ( @@ -652,8 +667,21 @@ def _resolve_daily_runnable_parts( main_configured = resolve_m7a_main_stage(user_cfg) is not None eow_configured = resolve_m7a_eow_stage(user_cfg) is not None - effective_eow_enabled = daily_eow_enabled and eow_configured - if daily_eow_enabled and not eow_configured: + if cultivation_enabled: + main_configured = True + + sra_target_owns_eow = cultivation_enabled and assigned_script == "SRA" + effective_eow_enabled = ( + daily_eow_enabled + if sra_target_owns_eow + else daily_eow_enabled and eow_configured + ) + if sra_target_owns_eow: + self._append_log( + f"用户「{user_name}」已启用 SRA 原生培养目标;" + "历战余响执行时机由 SRA 培养目标接管,MAS 将按实际日志回写完成状态" + ) + elif daily_eow_enabled and not eow_configured: reason = "本周需要历战余响,但未配置历战余响关卡,已跳过" self._append_log(f"用户「{user_name}」历战余响跳过:{reason}") self._record_module_result( @@ -711,6 +739,7 @@ def _build_phase_items( effective_engines=self.runtime.effective_engines, ) module_daily_eow_enabled = daily_eow_enabled + monitor_eow_completion = False if module.key == "Daily": main_configured, module_daily_eow_enabled = ( self._resolve_daily_runnable_parts( @@ -734,6 +763,15 @@ def _build_phase_items( reason=reason, ) continue + monitor_eow_completion = ( + module_daily_eow_enabled + or ( + assigned == "SRA" + and bool( + self.script_config.get("CultivationTarget", "Enabled") + ) + ) + ) items.append( HSRRunItem( @@ -754,6 +792,7 @@ def _build_phase_items( user_id=uid, user_name=user_name, daily_eow_enabled=module_daily_eow_enabled, + monitor_eow_completion=monitor_eow_completion, ), extra={"daily_eow_enabled": module_daily_eow_enabled}, ) @@ -770,6 +809,7 @@ def _build_module_success_callback( user_id: str, user_name: str, daily_eow_enabled: bool, + monitor_eow_completion: bool = False, ): """Build core-owned completion handling for a normalized module result.""" @@ -780,6 +820,7 @@ def _build_module_success_callback( daily_eow_enabled, result, engine, + monitor_eow_completion=monitor_eow_completion, ) if module_key in ("DivergentUniverse", "CurrencyWars"): def on_weekly_success(result: HSRNativeRunResult) -> None: diff --git a/packages/automas_script_hsr/src/automas_script_hsr/runtime/config_access.py b/packages/automas_script_hsr/src/automas_script_hsr/runtime/config_access.py new file mode 100644 index 0000000..7139ba9 --- /dev/null +++ b/packages/automas_script_hsr/src/automas_script_hsr/runtime/config_access.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import copy +import uuid +from collections.abc import Iterator, Mapping +from typing import Any + + +def get_config_value( + config: object, + group: str, + field: str, + default: Any = None, +) -> Any: + """Read one grouped field from a runtime model or a JSON-compatible mapping.""" + + if isinstance(config, Mapping): + group_data = config.get(group) + if isinstance(group_data, Mapping): + return copy.deepcopy(group_data.get(field, default)) + return default + + getter = getattr(config, "get", None) + if not callable(getter): + return default + try: + return getter(group, field) + except (AttributeError, KeyError, TypeError): + return default + + +def resolve_script_storage( + config_api: object, + script_id: str | uuid.UUID, + *, + expected_type_key: str, +) -> Any: + """Resolve one authoritative script record without importing storage classes.""" + + uid = script_id if isinstance(script_id, uuid.UUID) else uuid.UUID(str(script_id)) + scripts = getattr(config_api, "ScriptConfig", None) + if scripts is None: + raise LookupError("宿主未提供脚本配置集合") + try: + storage = scripts[uid] + except (KeyError, TypeError) as exc: + raise LookupError("脚本配置不存在,可能已被删除") from exc + + resolver = getattr(config_api, "get_script_type_key", None) + if callable(resolver): + try: + type_key = str(resolver(uid) or "").strip() + except (KeyError, TypeError, ValueError) as exc: + raise LookupError("无法解析脚本类型") from exc + else: + type_key = str( + get_config_value(storage, "Meta", "PluginTypeKey", "") or "" + ).strip() + + if type_key != expected_type_key: + raise ValueError(f"脚本不是 {expected_type_key} 插件配置") + return storage + + +class StructuredConfigMapping(Mapping[str, Any]): + """Immutable JSON-style config view with grouped ``get(group, field)``.""" + + def __init__(self, payload: Mapping[str, Any]) -> None: + self._payload = copy.deepcopy(dict(payload)) + + def __getitem__(self, key: str) -> Any: + return copy.deepcopy(self._payload[key]) + + def __iter__(self) -> Iterator[str]: + return iter(self._payload) + + def __len__(self) -> int: + return len(self._payload) + + def get( # type: ignore[override] + self, + group: str, + field: str | None = None, + ) -> Any: + if field is None: + return copy.deepcopy(self._payload.get(group)) + return get_config_value(self._payload, group, field) + + def asdict(self) -> dict[str, Any]: + return copy.deepcopy(self._payload) + + +def load_structured_collection( + payload: Mapping[str, Any], +) -> tuple[StructuredConfigMapping, ...]: + """Normalize one wire-format config collection into ordered mapping views.""" + + if "Info" in payload or "Data" in payload: + return (StructuredConfigMapping(payload),) + + raw_order = payload.get("instances", payload.get("order", ())) + ordered_keys: list[str] = [] + if isinstance(raw_order, (list, tuple)): + for item in raw_order: + raw_uid = item.get("uid") if isinstance(item, Mapping) else item + if raw_uid not in (None, ""): + ordered_keys.append(str(raw_uid)) + + result: list[StructuredConfigMapping] = [] + seen: set[str] = set() + for key in ordered_keys: + item = payload.get(key) + if isinstance(item, Mapping): + result.append(StructuredConfigMapping(item)) + seen.add(key) + + for raw_key, item in payload.items(): + key = str(raw_key) + if key in {"instances", "order"} or key in seen: + continue + if isinstance(item, Mapping): + result.append(StructuredConfigMapping(item)) + return tuple(result) diff --git a/packages/automas_script_hsr/src/automas_script_hsr/runtime/game.py b/packages/automas_script_hsr/src/automas_script_hsr/runtime/game.py index a322bf8..5da8f85 100644 --- a/packages/automas_script_hsr/src/automas_script_hsr/runtime/game.py +++ b/packages/automas_script_hsr/src/automas_script_hsr/runtime/game.py @@ -41,10 +41,13 @@ def _is_config_value_readable(user_config: Any, group: str, key: str) -> bool: - """检查配置项当前存储值是否能通过自身 validator。""" + """检查 RuntimeConfigModel/结构化配置当前是否能读取目标字段。""" - item = user_config._config_item_index[group][key] - return item.validator.validate(item.value) + try: + user_config.get(group, key) + except (AttributeError, KeyError, TypeError, ValueError): + return False + return True def resolve_game_executable_path(script_config: Any) -> Path: diff --git a/packages/automas_script_hsr/src/automas_script_hsr/runtime/log_detect.py b/packages/automas_script_hsr/src/automas_script_hsr/runtime/log_detect.py index 1354733..6e83abb 100644 --- a/packages/automas_script_hsr/src/automas_script_hsr/runtime/log_detect.py +++ b/packages/automas_script_hsr/src/automas_script_hsr/runtime/log_detect.py @@ -63,6 +63,9 @@ "主循环超时", "强制退出", "未识别到战斗按钮", + # ---- SRA 原生培养目标无可执行内容(进程可能仍以 0 退出)---- + "未找到任何培养目标任务", + "未识别到可执行任务", # ---- SRA 货币战争 final_failure(参考 HSR-外部脚本日志语义审计.md 2.5)---- "[页面定位] 检测超时", # CurrencyWars.py:159 "等待挑战结束超时", # CurrencyWars.py:708 @@ -116,7 +119,6 @@ HSR_DAILY_FINAL_SUCCESS_M7A: tuple[str, ...] = ( "每日实训尚未刷新", # daily.py:40/69 "每日实训未开启", # daily.py:42/71 - "清体力未开启,跳过历战余响和清体力", # daily.py:63 ) HSR_DAILY_FINAL_SUCCESS_SRA: tuple[str, ...] = ( "任务完成:领取每日实训奖励", # ReceiveRewardsTask.py:230 @@ -184,6 +186,11 @@ def has_failure_output(*texts: str) -> bool: """判断外部脚本输出中是否包含明确的失败语义。""" full_text = "\n".join(str(text) for text in texts if text) + if ( + "培养目标OCR识别为空" in full_text + and "识别到所需物品:" not in full_text + ): + return True has_noninteractive_eof = all( marker in full_text for marker in HSR_EOF_FAILURE_CONTEXT_MARKERS[:2] ) or ( diff --git a/packages/automas_script_hsr/src/automas_script_hsr/runtime/manager.py b/packages/automas_script_hsr/src/automas_script_hsr/runtime/manager.py index 63951ce..97f45b2 100644 --- a/packages/automas_script_hsr/src/automas_script_hsr/runtime/manager.py +++ b/packages/automas_script_hsr/src/automas_script_hsr/runtime/manager.py @@ -22,13 +22,12 @@ import asyncio import uuid +from collections.abc import Mapping from datetime import datetime from pathlib import Path from typing import Any from app.core import Config -from app.models.ConfigBase import MultipleConfig -from app.models.plugin_script_config import PluginScriptConfig from app.models.task import LogRecord, ScriptItem, TaskExecuteBase, UserItem from app.plugins.script_config_store import ScriptConfigStore from app.services import Notify @@ -45,6 +44,7 @@ script_supports, ) from .notify import push_notification +from .config_access import resolve_script_storage from .locks import acquire_external_path_locks, release_external_path_locks from .game import ( check_user_credentials, @@ -90,7 +90,7 @@ def __init__(self, script_info: ScriptItem, *, provider: Any, registry: Any): self.begin_time: str = "" self.crashed: bool = False self.script_config: Any | None = None - self.user_config: MultipleConfig[Any] | None = None + self.user_config: Mapping[uuid.UUID, Any] | None = None self.store: ScriptConfigStore | None = None self.effective_engines: tuple[str, ...] = () # 真实执行成功后的完成态写回队列。执行链路只登记意图,等待 final_task() @@ -149,12 +149,14 @@ async def check(self) -> str: return "HSR 暂不支持该任务模式,请检查任务配置" script_id = uuid.UUID(self.script_info.script_id) - if script_id not in Config.ScriptConfig: - return "脚本配置不存在,可能已被删除" - - storage_config = Config.ScriptConfig[script_id] - if not isinstance(storage_config, PluginScriptConfig): - return "脚本配置类型错误,不是 HSR 插件脚本类型" + try: + storage_config = resolve_script_storage( + Config, + script_id, + expected_type_key="HSR", + ) + except (LookupError, ValueError) as exc: + return str(exc) self.store = ScriptConfigStore( provider=self.provider, @@ -187,7 +189,7 @@ async def check(self) -> str: effective_engines=self.effective_engines, ): continue - raw_assigned = script_config._config_item_index["TaskMapping"][module.key].value + raw_assigned = script_config.get("TaskMapping", module.key) if len(self.effective_engines) > 1 and not script_supports( module.key, raw_assigned ): @@ -243,8 +245,7 @@ def _is_executable_user(user_config) -> bool: and user_config.get("Info", "RemainedDay") != 0 ) - @staticmethod - def _user_needs_sra(user_config, script_config: Any) -> bool: + def _user_needs_sra(self, user_config, script_config: Any) -> bool: """判断用户是否需要 SRA StartGame 登录/切号。""" for module in HSR_TASK_MODULES: @@ -340,7 +341,6 @@ async def _apply_completion_writebacks(self) -> None: f"用户「{item.user_name}」HSR 完成态已写回:{item.reason}" ) - await Config.ScriptConfig.save() self._completion_writebacks.clear() async def prepare(self): @@ -528,7 +528,6 @@ async def _sync_manual_review_user_data(self) -> None: if self.store is None: return await self.store.save_user_models(self.user_config) - await Config.ScriptConfig.save() logger.success("HSR 人工检查结果已写回用户配置") async def _unlock_script_config(self) -> bool: diff --git a/packages/automas_script_hsr/src/automas_script_hsr/runtime/manual_review.py b/packages/automas_script_hsr/src/automas_script_hsr/runtime/manual_review.py index 434b9b0..1a65ce7 100644 --- a/packages/automas_script_hsr/src/automas_script_hsr/runtime/manual_review.py +++ b/packages/automas_script_hsr/src/automas_script_hsr/runtime/manual_review.py @@ -21,12 +21,12 @@ import asyncio import uuid +from collections.abc import Mapping from datetime import datetime from pathlib import Path from typing import Any from app.core import Broadcast, Config -from app.models.ConfigBase import MultipleConfig from app.models.task import LogRecord, ScriptItem, TaskExecuteBase, UserItem from app.utils import get_logger from app.utils.constants import UTC8 @@ -45,7 +45,7 @@ def __init__( self, script_info: ScriptItem, script_config, - user_config: MultipleConfig, + user_config: Mapping[uuid.UUID, Any], user_item: UserItem, runtime: HSRRuntimeState, ) -> None: diff --git a/packages/automas_script_hsr/src/automas_script_hsr/runtime/notify.py b/packages/automas_script_hsr/src/automas_script_hsr/runtime/notify.py index 27aec1c..f63489a 100644 --- a/packages/automas_script_hsr/src/automas_script_hsr/runtime/notify.py +++ b/packages/automas_script_hsr/src/automas_script_hsr/runtime/notify.py @@ -20,14 +20,14 @@ import json +from collections.abc import Mapping from typing import Any from app.core import Config -from app.models.ConfigBase import MultipleConfig -from app.models.config import Webhook from app.services import Notify from app.utils import get_logger from jinja2 import Environment, PackageLoader, select_autoescape +from .config_access import StructuredConfigMapping, load_structured_collection logger = get_logger("HSR 通知工具") _TEMPLATE_ENV = Environment( @@ -42,8 +42,10 @@ def render_hsr_mail_template(name: str, context: dict[str, Any]) -> str: return _TEMPLATE_ENV.get_template(name).render(context) -async def load_user_custom_webhooks(user_config: Any) -> list[Webhook]: - """把插件 JSON 字段恢复为通知服务使用的 Webhook 配置对象。""" +async def load_user_custom_webhooks( + user_config: Any, +) -> list[StructuredConfigMapping]: + """把插件 JSON 字段恢复为不依赖宿主旧模型的结构化 Webhook 映射。""" raw = user_config.get("Notify", "CustomWebhooks") if isinstance(raw, str): @@ -52,12 +54,9 @@ async def load_user_custom_webhooks(user_config: Any) -> list[Webhook]: except json.JSONDecodeError: logger.warning("HSR 用户自定义 Webhook 配置不是有效 JSON,已跳过") return [] - if not isinstance(raw, dict): + if not isinstance(raw, Mapping): return [] - - collection = MultipleConfig([Webhook]) - await collection.load(raw) - return list(collection.values()) + return list(load_structured_collection(raw)) async def push_notification( diff --git a/packages/automas_script_hsr/src/automas_script_hsr/schema.py b/packages/automas_script_hsr/src/automas_script_hsr/schema.py index 875659f..a7cc4a5 100644 --- a/packages/automas_script_hsr/src/automas_script_hsr/schema.py +++ b/packages/automas_script_hsr/src/automas_script_hsr/schema.py @@ -2,9 +2,9 @@ import json from datetime import datetime -from typing import Any, Literal +from typing import Annotated, Any, Literal -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field as PydanticField from app.plugins.fields import PluginField from app.utils.constants import UTC4, UTC8 @@ -94,6 +94,33 @@ class HSRTaskMappingConfig(HSRModel): ) +class HSRCultivationTargetConfig(HSRModel): + Enabled: bool = PluginField( + default=False, + title="启用培养目标", + help=( + "由当前 TaskMapping.Daily 选中的引擎使用其原生培养目标能力。" + "SRA 模式下历战余响也由 SRA 培养目标决定,MAS 仅按实际日志回写完成状态" + ), + ) + M7ARecognitionScheme: Literal["instance", "drop"] = PluginField( + default="instance", + title="M7A 识别方案", + help="instance=按副本名称识别,drop=按副本素材识别", + ) + M7AOrnamentWeeklyCount: Annotated[int, PydanticField(ge=0, le=7)] = PluginField( + default=1, + title="M7A 饰品提取周次数", + min=0, + max=7, + ) + M7AUseUserStageWhenOnlyRelics: bool = PluginField( + default=False, + title="M7A 仅遗器时使用手动副本", + help="当培养目标只识别到侵蚀隧洞和饰品提取时,是否改用用户手动配置的副本", + ) + + class HSRConfig(HSRModel): Info: HSRInfoConfig = PluginField(default_factory=HSRInfoConfig, title="基础信息") SRA: HSRSRAConfig = PluginField(default_factory=HSRSRAConfig, title="SRA") @@ -104,6 +131,10 @@ class HSRConfig(HSRModel): default_factory=HSRTaskMappingConfig, title="任务引擎映射", ) + CultivationTarget: HSRCultivationTargetConfig = PluginField( + default_factory=HSRCultivationTargetConfig, + title="培养目标", + ) def build_hsr_tags(config: Any) -> str: From 67acb8a755b11bb789d994acf822b6d4114e0f32 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Sun, 26 Jul 2026 21:44:01 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test(hsr):=20=E6=96=B0=E5=A2=9E=E6=89=98?= =?UTF-8?q?=E7=AE=A1=E6=9B=B4=E6=96=B0=E4=B8=8E=E5=85=BB=E6=88=90=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E5=A5=91=E7=BA=A6=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增事务更新、权威运行时、SRA/M7A 养成目标等 6 个测试模块 - 更新包元数据、运行时契约与 wheel 冒烟测试 Co-Authored-By: Claude Fable 5 --- tests/test_authoritative_runtime.py | 127 ++ tests/test_cultivation_target_contract.py | 659 ++++++++ tests/test_m7a_cultivation_target.py | 1254 +++++++++++++++ tests/test_managed_update_transaction.py | 1764 +++++++++++++++++++++ tests/test_package_metadata.py | 15 +- tests/test_runtime_contract.py | 11 +- tests/test_sra_cultivation_target.py | 946 +++++++++++ tests/test_update_contract.py | 267 ++++ tests/test_wheel_smoke.py | 28 + 9 files changed, 5064 insertions(+), 7 deletions(-) create mode 100644 tests/test_authoritative_runtime.py create mode 100644 tests/test_cultivation_target_contract.py create mode 100644 tests/test_m7a_cultivation_target.py create mode 100644 tests/test_managed_update_transaction.py create mode 100644 tests/test_sra_cultivation_target.py create mode 100644 tests/test_update_contract.py diff --git a/tests/test_authoritative_runtime.py b/tests/test_authoritative_runtime.py new file mode 100644 index 0000000..81c5a1c --- /dev/null +++ b/tests/test_authoritative_runtime.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import uuid +import unittest +from collections.abc import Mapping +from pathlib import Path + +from automas_script_hsr.runtime.config_access import ( + StructuredConfigMapping, + load_structured_collection, + resolve_script_storage, +) + + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE_ROOT = ( + ROOT + / "packages" + / "automas_script_hsr" + / "src" + / "automas_script_hsr" +) + + +class _StorageRecord: + def __init__(self, type_key: str) -> None: + self.type_key = type_key + + def get(self, group: str, field: str): + if (group, field) == ("Meta", "PluginTypeKey"): + return self.type_key + raise AttributeError(f"{group}.{field}") + + +class _NativeConfig: + def __init__(self, uid: uuid.UUID, storage: object, type_key: str) -> None: + self.ScriptConfig = {uid: storage} + self.type_key = type_key + self.calls: list[uuid.UUID] = [] + + def get_script_type_key(self, script_id: uuid.UUID) -> str: + self.calls.append(script_id) + return self.type_key + + +class _StructuralConfig: + def __init__(self, uid: uuid.UUID, storage: object) -> None: + self.ScriptConfig = {uid: storage} + + +class AuthoritativeScriptStorageTests(unittest.TestCase): + def test_native_type_resolver_accepts_structural_plugin_record(self) -> None: + uid = uuid.uuid4() + storage = object() + config = _NativeConfig(uid, storage, "HSR") + + resolved = resolve_script_storage(config, uid, expected_type_key="HSR") + + self.assertIs(resolved, storage) + self.assertEqual(config.calls, [uid]) + + def test_structural_fallback_does_not_require_plugin_script_class(self) -> None: + uid = uuid.uuid4() + storage = _StorageRecord("HSR") + config = _StructuralConfig(uid, storage) + + self.assertIs( + resolve_script_storage(config, str(uid), expected_type_key="HSR"), + storage, + ) + + def test_wrong_provider_type_is_rejected(self) -> None: + uid = uuid.uuid4() + config = _NativeConfig(uid, object(), "GENERAL") + + with self.assertRaisesRegex(ValueError, "HSR"): + resolve_script_storage(config, uid, expected_type_key="HSR") + + +class StructuredWebhookTests(unittest.TestCase): + def test_wire_collection_becomes_ordered_mapping_views(self) -> None: + first = str(uuid.uuid4()) + second = str(uuid.uuid4()) + collection = load_structured_collection( + { + "instances": [ + {"uid": second, "type": "Webhook"}, + {"uid": first, "type": "Webhook"}, + ], + first: { + "Info": {"Name": "first", "Enabled": True}, + "Data": {"Url": "https://first.invalid"}, + }, + second: { + "Info": {"Name": "second", "Enabled": False}, + "Data": {"Url": "https://second.invalid"}, + }, + } + ) + + self.assertEqual([item.get("Info", "Name") for item in collection], ["second", "first"]) + self.assertTrue(all(isinstance(item, Mapping) for item in collection)) + self.assertIsInstance(collection[0], StructuredConfigMapping) + + +class RuntimeSourceContractTests(unittest.TestCase): + def test_runtime_does_not_import_legacy_config_graph(self) -> None: + sources = [PACKAGE_ROOT / "plugin.py", *(PACKAGE_ROOT / "runtime").glob("*.py")] + forbidden = ( + "app.models.ConfigBase", + "app.models.config", + "app.models.plugin_script_config", + "MultipleConfig", + "PluginScriptConfig", + "Config.ScriptConfig.save", + "_config_item_index", + ) + + for source in sources: + text = source.read_text(encoding="utf-8") + for marker in forbidden: + with self.subTest(source=source.name, marker=marker): + self.assertNotIn(marker, text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cultivation_target_contract.py b/tests/test_cultivation_target_contract.py new file mode 100644 index 0000000..d6ea4b7 --- /dev/null +++ b/tests/test_cultivation_target_contract.py @@ -0,0 +1,659 @@ +from __future__ import annotations + +import sys +import types +import unittest +from datetime import timezone, timedelta +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +# --------------------------------------------------------------------------- +# Mock the AUTO-MAS host ``app`` package so that schema.py and autoproxy.py +# can be imported without the full host runtime (loguru, ImageUtils, …). +# The mock PluginField mirrors the real host: plugin-specific ``min``/``max`` +# are UI metadata only. Runtime validation must come from the field annotation. +# --------------------------------------------------------------------------- + +from pydantic import Field as _PydField +from pydantic_core import PydanticUndefined + + +def _install_app_mocks() -> None: + if "app" in sys.modules and getattr(sys.modules["app"], "__hsr_test_mock__", False): + return + + # -- app.utils.constants ------------------------------------------------ + _constants = types.ModuleType("app.utils.constants") + _constants.UTC4 = timezone(timedelta(hours=4)) + _constants.UTC8 = timezone(timedelta(hours=8)) + sys.modules["app.utils.constants"] = _constants + + # -- app.utils ---------------------------------------------------------- + _utils = types.ModuleType("app.utils") + + class _ProcessManager: # minimal stub + def __init__(self, *args, **kwargs) -> None: + pass + + class _MockLogger: + def info(self, msg: str) -> None: + pass + + def warning(self, msg: str) -> None: + pass + + def exception(self, msg: str) -> None: + pass + + def error(self, msg: str) -> None: + pass + + class _ProcessInfo: # minimal stub + def __init__(self, *args, **kwargs) -> None: + pass + + _utils.ProcessManager = _ProcessManager + _utils.ProcessInfo = _ProcessInfo + _utils.ProcessRunner = type("_ProcessRunner", (), {}) + _utils.ProcessResult = type("_ProcessResult", (), {}) + _utils.get_logger = lambda name=None: _MockLogger() + _utils.is_process_running = lambda name: False + _utils.UTC4 = _constants.UTC4 + _utils.UTC8 = _constants.UTC8 + sys.modules["app.utils"] = _utils + + # -- app.plugins.fields ------------------------------------------------- + _fields = types.ModuleType("app.plugins.fields") + + class _PluginFieldFactory: + """Mock PluginField that wraps pydantic.Field like the real host.""" + + _PASSTHROUGH = frozenset({ + "title", "description", "default_factory", + }) + + def __call__( + self, + default: Any = PydanticUndefined, + *, + min: int | float | None = None, + max: int | float | None = None, + **kwargs: Any, + ) -> Any: + field_kwargs: dict[str, Any] = {} + for key in self._PASSTHROUGH: + if key in kwargs: + field_kwargs[key] = kwargs.pop(key) + + # Remaining plugin-specific kwargs go into json_schema_extra + extra = dict(kwargs) + if min is not None: + extra["min"] = min + if max is not None: + extra["max"] = max + if extra: + field_kwargs["json_schema_extra"] = extra + + if default is PydanticUndefined: + return _PydField(**field_kwargs) + return _PydField(default, **field_kwargs) + + _fields.PluginField = _PluginFieldFactory() + sys.modules["app.plugins.fields"] = _fields + + # -- app.plugins (parent package) --------------------------------------- + _plugins = types.ModuleType("app.plugins") + _plugins.fields = _fields + sys.modules["app.plugins"] = _plugins + + # -- app.core ----------------------------------------------------------- + _core = types.ModuleType("app.core") + + class _Config: + @staticmethod + async def send_websocket_message(**kwargs): + pass + + _core.Config = _Config + sys.modules["app.core"] = _core + + # -- app.models --------------------------------------------------------- + _models = types.ModuleType("app.models") + sys.modules["app.models"] = _models + + # -- app.models.ConfigBase ---------------------------------------------- + _config_base = types.ModuleType("app.models.ConfigBase") + + class _MultipleConfig(dict): + pass + + _config_base.MultipleConfig = _MultipleConfig + sys.modules["app.models.ConfigBase"] = _config_base + + # -- app.models.config -------------------------------------------------- + _config_model = types.ModuleType("app.models.config") + + class _Webhook: + pass + + _config_model.Webhook = _Webhook + sys.modules["app.models.config"] = _config_model + + # -- app.models.task ---------------------------------------------------- + _task = types.ModuleType("app.models.task") + + class _LogRecord: + def __init__(self, status: str = "") -> None: + self.status = status + self.content: list[str] = [] + + class _ScriptItem: + def __init__(self) -> None: + self.script_id = "" + self.task_info = None + self.log = "" + + class _TaskExecuteBase: + def __init__(self) -> None: + pass + + class _UserItem: + def __init__(self) -> None: + self.user_id = "" + self.name = "" + self.status = "" + self.result = "" + self.log_record: dict = {} + + _task.LogRecord = _LogRecord + _task.ScriptItem = _ScriptItem + _task.TaskExecuteBase = _TaskExecuteBase + _task.UserItem = _UserItem + sys.modules["app.models.task"] = _task + + # -- app.services ------------------------------------------------------- + _services = types.ModuleType("app.services") + + class _Notify: + pass + + _services.Notify = _Notify + sys.modules["app.services"] = _services + + # -- app.services.system ------------------------------------------------ + _system = types.ModuleType("app.services.system") + + class _System: + @staticmethod + async def kill_process(path: str) -> None: + pass + + _system.System = _System + sys.modules["app.services.system"] = _system + + # -- app (root) --------------------------------------------------------- + _app = types.ModuleType("app") + _app.__hsr_test_mock__ = True + _app.core = _core + _app.models = _models + _app.plugins = _plugins + _app.services = _services + _app.utils = _utils + sys.modules["app"] = _app + + +_install_app_mocks() + +# --------------------------------------------------------------------------- +# Now safe to import the modules under test. +# --------------------------------------------------------------------------- +from pydantic import ValidationError + +from automas_script_hsr.schema import ( + HSRConfig, + HSRCultivationTargetConfig, +) +from automas_script_hsr.runtime.autoproxy import HSRAutoProxyTask +from automas_script_hsr.runtime.tasks import get_assigned_script, get_module + + +class _FakeConfig: + """支持 .get(section, key) 的轻量配置替身。""" + + def __init__(self, data: dict[str, dict[str, Any]] | None = None) -> None: + self._data = data or {} + + def get(self, section: str, key: str) -> Any: + return self._data.get(section, {}).get(key) + + +class _StubAutoProxy: + """暴露 _resolve_daily_runnable_parts 所需 self 表面的最小替身。""" + + def __init__(self, script_config: _FakeConfig) -> None: + self.script_config = script_config + self.log_lines: list[str] = [] + self.recorded_module_results: list[dict[str, Any]] = [] + self.queued_writebacks: list[dict[str, Any]] = [] + + def _append_log(self, message: str) -> None: + self.log_lines.append(message) + + def _record_module_result( + self, + *, + user_id: str, + user_name: str, + module_key: str, + module_name: str, + script: str, + status: str, + reason: str = "", + ) -> None: + self.recorded_module_results.append( + { + "user_id": user_id, + "user_name": user_name, + "module_key": module_key, + "module_name": module_name, + "script": script, + "status": status, + "reason": reason, + } + ) + + @staticmethod + def _period_markers() -> tuple[str, str]: + return "2026-07-23", "2026-W30" + + def _queue_data_writeback(self, **kwargs: Any) -> None: + self.queued_writebacks.append(kwargs) + + +class CultivationTargetDefaultsTests(unittest.TestCase): + """HSRCultivationTargetConfig 默认值与边界校验。""" + + def test_default_values(self) -> None: + cfg = HSRCultivationTargetConfig() + self.assertFalse(cfg.Enabled) + self.assertEqual(cfg.M7ARecognitionScheme, "instance") + self.assertEqual(cfg.M7AOrnamentWeeklyCount, 1) + self.assertFalse(cfg.M7AUseUserStageWhenOnlyRelics) + + def test_hsr_config_default_includes_cultivation_target(self) -> None: + cfg = HSRConfig() + self.assertIsInstance(cfg.CultivationTarget, HSRCultivationTargetConfig) + self.assertFalse(cfg.CultivationTarget.Enabled) + + def test_extra_forbid_still_enforced(self) -> None: + with self.assertRaises(ValidationError): + HSRCultivationTargetConfig(Enabled=True, UnknownField="x") # type: ignore[call-arg] + + def test_hsr_config_extra_forbid_still_enforced(self) -> None: + with self.assertRaises(ValidationError): + HSRConfig(NotAField=1) # type: ignore[call-arg] + + +class CultivationTargetBackwardCompatTests(unittest.TestCase): + """旧配置(无 CultivationTarget 字段)加载后向后兼容。""" + + def test_old_config_without_cultivation_target_loads_with_defaults( + self, + ) -> None: + cfg = HSRConfig( + Info={}, + SRA={}, + M7A={}, + Game={}, + Run={}, + TaskMapping={}, + ) + self.assertFalse(cfg.CultivationTarget.Enabled) + self.assertEqual(cfg.CultivationTarget.M7ARecognitionScheme, "instance") + self.assertEqual(cfg.CultivationTarget.M7AOrnamentWeeklyCount, 1) + self.assertFalse(cfg.CultivationTarget.M7AUseUserStageWhenOnlyRelics) + + def test_empty_config_loads_with_defaults(self) -> None: + cfg = HSRConfig() + self.assertFalse(cfg.CultivationTarget.Enabled) + + +class CultivationTargetValidationTests(unittest.TestCase): + """HSRCultivationTargetConfig 非法值被拒绝。""" + + def test_ornament_weekly_count_below_min_rejected(self) -> None: + with self.assertRaises(ValidationError): + HSRCultivationTargetConfig(M7AOrnamentWeeklyCount=-1) + + def test_ornament_weekly_count_above_max_rejected(self) -> None: + with self.assertRaises(ValidationError): + HSRCultivationTargetConfig(M7AOrnamentWeeklyCount=8) + + def test_ornament_weekly_count_boundary_values_accepted(self) -> None: + self.assertEqual( + HSRCultivationTargetConfig(M7AOrnamentWeeklyCount=0).M7AOrnamentWeeklyCount, 0 + ) + self.assertEqual( + HSRCultivationTargetConfig(M7AOrnamentWeeklyCount=7).M7AOrnamentWeeklyCount, 7 + ) + + def test_ornament_weekly_count_schema_preserves_runtime_and_ui_bounds(self) -> None: + schema = HSRCultivationTargetConfig.model_json_schema() + field_schema = schema["properties"]["M7AOrnamentWeeklyCount"] + self.assertEqual(field_schema["minimum"], 0) + self.assertEqual(field_schema["maximum"], 7) + self.assertEqual(field_schema["min"], 0) + self.assertEqual(field_schema["max"], 7) + + def test_invalid_recognition_scheme_rejected(self) -> None: + with self.assertRaises(ValidationError): + HSRCultivationTargetConfig(M7ARecognitionScheme="invalid") # type: ignore[arg-type] + + def test_valid_recognition_schemes_accepted(self) -> None: + self.assertEqual( + HSRCultivationTargetConfig(M7ARecognitionScheme="instance").M7ARecognitionScheme, + "instance", + ) + self.assertEqual( + HSRCultivationTargetConfig(M7ARecognitionScheme="drop").M7ARecognitionScheme, + "drop", + ) + + +class _EmptyStageUserConfig(_FakeConfig): + """没有配置任何原生副本的用户配置替身。""" + + def __init__(self) -> None: + super().__init__( + { + "Stage": { + "Channel": "CalyxGolden", + "ScriptStage": {}, + "ScriptEchoOfWar": {}, + } + } + ) + + +class ResolveDailyRunnablePartsTests(unittest.TestCase): + """_resolve_daily_runnable_parts 在培养目标模式下的编排语义。""" + + @staticmethod + def _call_resolve( + script_config: _FakeConfig, + assigned_script: str, + user_cfg: _FakeConfig | None = None, + daily_eow_enabled: bool = False, + ) -> tuple[bool, bool, _StubAutoProxy]: + stub = _StubAutoProxy(script_config) + main_configured, effective_eow = HSRAutoProxyTask._resolve_daily_runnable_parts( + stub, + assigned_script=assigned_script, + user_cfg=user_cfg or _EmptyStageUserConfig(), + user_name="test-user", + uid="uid-1", + daily_eow_enabled=daily_eow_enabled, + ) + return main_configured, effective_eow, stub + + def test_cultivation_enabled_does_not_skip_daily_when_main_not_configured_sra( + self, + ) -> None: + """SRA 引擎下培养目标启用时不因 main_configured=False 跳过 Daily。""" + + script_config = _FakeConfig({"CultivationTarget": {"Enabled": True}}) + main_configured, effective_eow, _ = self._call_resolve( + script_config, assigned_script="SRA" + ) + self.assertTrue(main_configured) + self.assertFalse(effective_eow) + + def test_cultivation_enabled_does_not_skip_daily_when_main_not_configured_m7a( + self, + ) -> None: + """M7A 引擎下培养目标启用时不因 main_configured=False 跳过 Daily。""" + + script_config = _FakeConfig({"CultivationTarget": {"Enabled": True}}) + main_configured, effective_eow, _ = self._call_resolve( + script_config, assigned_script="M7A" + ) + self.assertTrue(main_configured) + self.assertFalse(effective_eow) + + def test_cultivation_disabled_skips_daily_when_main_not_configured_sra( + self, + ) -> None: + """培养目标关闭时,main_configured=False 且无 EOW 仍跳过(与现有逻辑一致)。""" + + script_config = _FakeConfig({"CultivationTarget": {"Enabled": False}}) + main_configured, effective_eow, _ = self._call_resolve( + script_config, assigned_script="SRA" + ) + self.assertFalse(main_configured) + self.assertFalse(effective_eow) + + def test_cultivation_disabled_skips_daily_when_main_not_configured_m7a( + self, + ) -> None: + script_config = _FakeConfig({"CultivationTarget": {"Enabled": False}}) + main_configured, effective_eow, _ = self._call_resolve( + script_config, assigned_script="M7A" + ) + self.assertFalse(main_configured) + self.assertFalse(effective_eow) + + def test_cultivation_absent_behaves_like_disabled(self) -> None: + """script_config 完全没有 CultivationTarget 段时,等同 Enabled=False。""" + + script_config = _FakeConfig({}) + main_configured, effective_eow, _ = self._call_resolve( + script_config, assigned_script="SRA" + ) + self.assertFalse(main_configured) + self.assertFalse(effective_eow) + + def test_sra_cultivation_delegates_eow_and_enables_log_monitoring(self) -> None: + """SRA 培养目标接管 EOW;无手动关卡时仍监测真实完成日志。""" + + script_config = _FakeConfig({"CultivationTarget": {"Enabled": True}}) + main_configured, effective_eow, stub = self._call_resolve( + script_config, + assigned_script="SRA", + daily_eow_enabled=True, + ) + self.assertTrue(main_configured) + self.assertTrue(effective_eow) + eow_skips = [ + r + for r in stub.recorded_module_results + if r["module_key"] == "EchoOfWar" and r["status"] == "skipped" + ] + self.assertEqual(eow_skips, []) + self.assertTrue(any("SRA 原生培养目标" in line for line in stub.log_lines)) + + def test_sra_cultivation_before_mas_start_day_does_not_require_eow(self) -> None: + """监测与要求分离:MAS 未到开始日时不制造“EOW 未完成”。""" + + script_config = _FakeConfig({"CultivationTarget": {"Enabled": True}}) + _, effective_eow, stub = self._call_resolve( + script_config, + assigned_script="SRA", + daily_eow_enabled=False, + ) + self.assertFalse(effective_eow) + self.assertEqual(stub.recorded_module_results, []) + self.assertTrue(any("SRA 原生培养目标" in line for line in stub.log_lines)) + + def test_m7a_cultivation_keeps_mas_eow_ownership(self) -> None: + script_config = _FakeConfig({"CultivationTarget": {"Enabled": True}}) + _, effective_eow, stub = self._call_resolve( + script_config, + assigned_script="M7A", + daily_eow_enabled=True, + ) + self.assertFalse(effective_eow) + self.assertEqual( + len( + [ + result + for result in stub.recorded_module_results + if result["module_key"] == "EchoOfWar" + and result["status"] == "skipped" + ] + ), + 1, + ) + + +class SraCultivationEowMonitoringTests(unittest.TestCase): + """SRA 培养目标在 MAS 非 EOW 周期内只采集证据,不制造失败噪声。""" + + @staticmethod + def _result(output: str) -> SimpleNamespace: + return SimpleNamespace( + success=True, + output=output, + error="", + returncode=0, + ) + + def test_monitor_only_without_eow_evidence_is_neutral(self) -> None: + stub = _StubAutoProxy(_FakeConfig({})) + HSRAutoProxyTask._queue_eow_completion_if_confirmed( + stub, + "uid-1", + "test-user", + False, + self._result("SRA Daily finished without EOW"), + "SRA", + monitor_eow_completion=True, + ) + self.assertEqual(stub.recorded_module_results, []) + self.assertEqual(stub.queued_writebacks, []) + self.assertEqual(stub.log_lines, []) + + def test_monitor_only_with_eow_evidence_writes_completion(self) -> None: + stub = _StubAutoProxy(_FakeConfig({})) + HSRAutoProxyTask._queue_eow_completion_if_confirmed( + stub, + "uid-1", + "test-user", + False, + self._result("本周「历战余响」剩余次数: 0/3"), + "SRA", + monitor_eow_completion=True, + ) + self.assertEqual( + [result["status"] for result in stub.recorded_module_results], + ["completed"], + ) + self.assertEqual(len(stub.queued_writebacks), 1) + + def test_required_eow_without_evidence_remains_incomplete(self) -> None: + stub = _StubAutoProxy(_FakeConfig({})) + HSRAutoProxyTask._queue_eow_completion_if_confirmed( + stub, + "uid-1", + "test-user", + True, + self._result("SRA Daily finished without EOW"), + "SRA", + ) + self.assertEqual( + [result["status"] for result in stub.recorded_module_results], + ["incomplete"], + ) + self.assertEqual(stub.queued_writebacks, []) + + +class TaskMappingDailySwitchTests(unittest.TestCase): + """TaskMapping.Daily 切换 SRA↔M7A 时培养目标意图正确跟随当前引擎。""" + + def test_daily_assigned_to_sra_when_taskmapping_sra(self) -> None: + daily_module = get_module("Daily") + self.assertIsNotNone(daily_module) + script_config = _FakeConfig( + { + "TaskMapping": {"Daily": "SRA"}, + "CultivationTarget": {"Enabled": True}, + } + ) + assigned = get_assigned_script( + daily_module, + script_config, + effective_engines=("SRA", "M7A"), + ) + self.assertEqual(assigned, "SRA") + + stub = _StubAutoProxy(script_config) + main_configured, _ = HSRAutoProxyTask._resolve_daily_runnable_parts( + stub, + assigned_script=assigned, + user_cfg=_EmptyStageUserConfig(), + user_name="test", + uid="u", + daily_eow_enabled=False, + ) + self.assertTrue(main_configured) + + def test_daily_assigned_to_m7a_when_taskmapping_m7a(self) -> None: + daily_module = get_module("Daily") + self.assertIsNotNone(daily_module) + script_config = _FakeConfig( + { + "TaskMapping": {"Daily": "M7A"}, + "CultivationTarget": {"Enabled": True}, + } + ) + assigned = get_assigned_script( + daily_module, + script_config, + effective_engines=("SRA", "M7A"), + ) + self.assertEqual(assigned, "M7A") + + stub = _StubAutoProxy(script_config) + main_configured, _ = HSRAutoProxyTask._resolve_daily_runnable_parts( + stub, + assigned_script=assigned, + user_cfg=_EmptyStageUserConfig(), + user_name="test", + uid="u", + daily_eow_enabled=False, + ) + self.assertTrue(main_configured) + + def test_cultivation_disabled_under_both_engines(self) -> None: + """培养目标关闭时,无论 Daily 映射到哪个引擎都保持 main_configured=False。""" + + daily_module = get_module("Daily") + for engine in ("SRA", "M7A"): + with self.subTest(engine=engine): + script_config = _FakeConfig( + { + "TaskMapping": {"Daily": engine}, + "CultivationTarget": {"Enabled": False}, + } + ) + assigned = get_assigned_script( + daily_module, + script_config, + effective_engines=("SRA", "M7A"), + ) + self.assertEqual(assigned, engine) + + stub = _StubAutoProxy(script_config) + main_configured, _ = HSRAutoProxyTask._resolve_daily_runnable_parts( + stub, + assigned_script=assigned, + user_cfg=_EmptyStageUserConfig(), + user_name="test", + uid="u", + daily_eow_enabled=False, + ) + self.assertFalse(main_configured) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_m7a_cultivation_target.py b/tests/test_m7a_cultivation_target.py new file mode 100644 index 0000000..65ba7a5 --- /dev/null +++ b/tests/test_m7a_cultivation_target.py @@ -0,0 +1,1254 @@ +from __future__ import annotations + +import asyncio +import sys +import tempfile +import types +import unittest +from datetime import timezone, timedelta +from pathlib import Path +from typing import Any +from unittest.mock import patch + +# --------------------------------------------------------------------------- +# Mock the AUTO-MAS host ``app`` package so that the M7A adapter and its +# import chain (automas_script_hsr.runtime.game, automas_script_hsr.schema, +# automas_hsr_adapter_m7a.runner) can be loaded without the full host runtime. +# The mock mirrors the one in test_sra_cultivation_target.py and additionally +# provides ``Config.config_path`` and ``decode_bytes`` so the runner module +# can be imported. +# --------------------------------------------------------------------------- + +from pydantic import Field as _PydField +from pydantic_core import PydanticUndefined + +_TEMP_DIR = tempfile.mkdtemp(prefix="m7a_cultivation_test_") + + +def _install_app_mocks() -> None: + already_mocked = ( + "app" in sys.modules + and getattr(sys.modules["app"], "__hsr_test_mock__", False) + ) + + if not already_mocked: + # -- app.utils.constants ------------------------------------------------ + _constants = types.ModuleType("app.utils.constants") + _constants.UTC4 = timezone(timedelta(hours=4)) + _constants.UTC8 = timezone(timedelta(hours=8)) + sys.modules["app.utils.constants"] = _constants + + # -- app.utils ---------------------------------------------------------- + _utils = types.ModuleType("app.utils") + + class _ProcessManager: # minimal stub + def __init__(self, *args, **kwargs) -> None: + pass + + class _MockLogger: + def info(self, msg: str) -> None: + pass + + def warning(self, msg: str) -> None: + pass + + def exception(self, msg: str) -> None: + pass + + def error(self, msg: str) -> None: + pass + + def debug(self, msg: str) -> None: + pass + + class _ProcessInfo: + def __init__(self, *args, **kwargs) -> None: + pass + + _utils.ProcessManager = _ProcessManager + _utils.ProcessInfo = _ProcessInfo + _utils.ProcessRunner = type("_ProcessRunner", (), {}) + _utils.ProcessResult = type("_ProcessResult", (), {}) + _utils.get_logger = lambda name=None: _MockLogger() + _utils.is_process_running = lambda name: False + _utils.UTC4 = _constants.UTC4 + _utils.UTC8 = _constants.UTC8 + _utils.decode_bytes = lambda raw: ( + raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else str(raw) + ) + sys.modules["app.utils"] = _utils + + # -- app.plugins.fields ------------------------------------------------- + _fields = types.ModuleType("app.plugins.fields") + + class _PluginFieldFactory: + _PASSTHROUGH = frozenset({ + "title", "description", "default_factory", + }) + + def __call__( + self, + default: Any = PydanticUndefined, + *, + min: int | float | None = None, + max: int | float | None = None, + **kwargs: Any, + ) -> Any: + field_kwargs: dict[str, Any] = {} + for key in self._PASSTHROUGH: + if key in kwargs: + field_kwargs[key] = kwargs.pop(key) + extra = dict(kwargs) + if min is not None: + extra["min"] = min + if max is not None: + extra["max"] = max + if extra: + field_kwargs["json_schema_extra"] = extra + if default is PydanticUndefined: + return _PydField(**field_kwargs) + return _PydField(default, **field_kwargs) + + _fields.PluginField = _PluginFieldFactory() + sys.modules["app.plugins.fields"] = _fields + + # -- app.plugins (parent package) --------------------------------------- + _plugins = types.ModuleType("app.plugins") + _plugins.fields = _fields + sys.modules["app.plugins"] = _plugins + + # -- app.core ----------------------------------------------------------- + _core = types.ModuleType("app.core") + + class _Config: + config_path = Path(_TEMP_DIR) / "config.json" + + @staticmethod + async def send_websocket_message(**kwargs): + pass + + _core.Config = _Config + sys.modules["app.core"] = _core + + # -- app.models --------------------------------------------------------- + _models = types.ModuleType("app.models") + sys.modules["app.models"] = _models + + # -- app.models.ConfigBase ---------------------------------------------- + _config_base = types.ModuleType("app.models.ConfigBase") + + class _MultipleConfig(dict): + pass + + _config_base.MultipleConfig = _MultipleConfig + sys.modules["app.models.ConfigBase"] = _config_base + + # -- app.models.config -------------------------------------------------- + _config_model = types.ModuleType("app.models.config") + + class _Webhook: + pass + + _config_model.Webhook = _Webhook + sys.modules["app.models.config"] = _config_model + + # -- app.models.task ---------------------------------------------------- + _task = types.ModuleType("app.models.task") + + class _LogRecord: + def __init__(self, status: str = "") -> None: + self.status = status + self.content: list[str] = [] + + class _ScriptItem: + def __init__(self) -> None: + self.script_id = "" + self.task_info = None + self.log = "" + + class _TaskExecuteBase: + def __init__(self) -> None: + pass + + class _UserItem: + def __init__(self) -> None: + self.user_id = "" + self.name = "" + self.status = "" + self.result = "" + self.log_record: dict = {} + + _task.LogRecord = _LogRecord + _task.ScriptItem = _ScriptItem + _task.TaskExecuteBase = _TaskExecuteBase + _task.UserItem = _UserItem + sys.modules["app.models.task"] = _task + + # -- app.services ------------------------------------------------------- + _services = types.ModuleType("app.services") + + class _Notify: + pass + + _services.Notify = _Notify + sys.modules["app.services"] = _services + + # -- app.services.system ------------------------------------------------ + _system = types.ModuleType("app.services.system") + + class _System: + @staticmethod + async def kill_process(path: str) -> None: + pass + + _system.System = _System + sys.modules["app.services.system"] = _system + + # -- app (root) --------------------------------------------------------- + _app = types.ModuleType("app") + _app.__hsr_test_mock__ = True + _app.core = _core + _app.models = _models + _app.plugins = _plugins + _app.services = _services + _app.utils = _utils + sys.modules["app"] = _app + + # Always ensure decode_bytes is available on app.utils — runner.py imports + # it alongside ProcessManager/get_logger. If another test installed the + # mock first (without decode_bytes), patch it in here. + _utils_mod = sys.modules.get("app.utils") + if _utils_mod is not None and not hasattr(_utils_mod, "decode_bytes"): + _utils_mod.decode_bytes = lambda raw: ( # type: ignore[attr-defined] + raw.decode("utf-8", errors="replace") + if isinstance(raw, bytes) + else str(raw) + ) + + # Always replace get_logger with one that returns a logger with debug() + # (runner.py / game.py call logger.debug at module and function level). + if _utils_mod is not None: + + class _FullMockLogger: + def info(self, msg: str) -> None: + pass + + def warning(self, msg: str) -> None: + pass + + def exception(self, msg: str) -> None: + pass + + def error(self, msg: str) -> None: + pass + + def debug(self, msg: str) -> None: + pass + + _utils_mod.get_logger = lambda name=None: _FullMockLogger() # type: ignore[attr-defined] + + +_install_app_mocks() + +# --------------------------------------------------------------------------- +# Now safe to import the modules under test. +# --------------------------------------------------------------------------- +import yaml # noqa: E402 + +from automas_script_hsr.runtime.tasks import get_module # noqa: E402 + +from automas_hsr_adapter_m7a.control import ( # noqa: E402 + HSRM7AControl, + _read_cultivation_target, +) +from automas_hsr_adapter_m7a import config as m7a # noqa: E402 +from automas_script_hsr.contracts import HSRNativeRunPlan # noqa: E402 + +# If runner was already imported by another test module using the incomplete +# mock logger (without debug()), re-create its module-level logger now that +# get_logger has been replaced to return a full logger. +_runner_mod = sys.modules.get("automas_hsr_adapter_m7a.runner") +if _runner_mod is not None and not hasattr(_runner_mod.logger, "debug"): + from app.utils import get_logger as _get_logger # noqa: E402 + _runner_mod.logger = _get_logger("HSR M7A 运行器") + + +# --------------------------------------------------------------------------- +# Fake configuration objects +# --------------------------------------------------------------------------- + + +class _ScriptConfig: + """支持 .get(section, key) 的轻量配置替身。""" + + def __init__(self, data: dict[str, dict[str, Any]] | None = None) -> None: + self._data = data or {} + + def get(self, section: str, key: str) -> Any: + return self._data.get(section, {}).get(key) + + +def _m7a_stage_payload( + *, + instance_type: str = "拟造花萼(金)", + instance_name: str = "Test Stage", + label: str = "Test Stage", + category_label: str = "Test Category", +) -> dict[str, Any]: + """构造一个 M7A 原生副本字段载荷(engine=M7A)。""" + + return { + "engine": "M7A", + "label": label, + "categoryLabel": category_label, + "m7a": { + "instanceType": instance_type, + "instanceName": instance_name, + }, + } + + +def _m7a_eow_payload( + *, + instance_name: str = "历战余响:Test", + label: str = "Test EOW", + category_label: str = "EOW Category", +) -> dict[str, Any]: + return { + "engine": "M7A", + "label": label, + "categoryLabel": category_label, + "m7a": { + "instanceType": "历战余响", + "instanceName": instance_name, + }, + } + + +class _EmptyStageUserConfig(_ScriptConfig): + """没有配置任何原生副本的用户配置替身。""" + + def __init__(self) -> None: + super().__init__( + { + "Stage": { + "Channel": "CalyxGolden", + "ScriptStage": {}, + "ScriptEchoOfWar": {}, + }, + "TaskOpt": {"EchoOfWarWeekday": "Monday"}, + "Info": {"Name": "test-user"}, + } + ) + + +class _ConfiguredStageUserConfig(_ScriptConfig): + """已配置有效 M7A 原生主副本的用户配置替身。""" + + def __init__(self) -> None: + super().__init__( + { + "Stage": { + "Channel": "CalyxGolden", + "ScriptStage": { + "stages": { + "CalyxGolden": _m7a_stage_payload(), + } + }, + "ScriptEchoOfWar": {}, + }, + "TaskOpt": {"EchoOfWarWeekday": "Monday"}, + "Info": {"Name": "test-user"}, + } + ) + + +class _ConfiguredEowUserConfig(_ScriptConfig): + """已配置有效 M7A 主副本和历战余响副本的用户配置替身。""" + + def __init__(self) -> None: + super().__init__( + { + "Stage": { + "Channel": "CalyxGolden", + "ScriptStage": { + "stages": { + "CalyxGolden": _m7a_stage_payload(), + } + }, + "ScriptEchoOfWar": _m7a_eow_payload(), + }, + "TaskOpt": {"EchoOfWarWeekday": "Monday"}, + "Info": {"Name": "test-user"}, + } + ) + + +def _cultivation( + *, + enabled: bool = True, + scheme: str | None = "instance", + ornament_count: int | None = 1, + use_user: bool | None = False, +) -> dict: + """构造一个 cultivation_target 字典(模拟 HSRCultivationTargetConfig 序列化)。""" + + return { + "Enabled": enabled, + "M7ARecognitionScheme": scheme, + "M7AOrnamentWeeklyCount": ornament_count, + "M7AUseUserStageWhenOnlyRelics": use_user, + } + + +# --------------------------------------------------------------------------- +# build_m7a_daily_patch: 培养目标四键精确 patch +# --------------------------------------------------------------------------- + + +class BuildDailyPatchCultivationOnTests(unittest.TestCase): + """Enabled=true 时 build_m7a_daily_patch 输出四个正确的 build_target_* 键。""" + + def test_full_patch_instance_scheme_count_zero_use_user_false(self) -> None: + patch = m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation( + enabled=True, + scheme="instance", + ornament_count=0, + use_user=False, + ), + ) + self.assertTrue(patch["build_target_enable"]) + self.assertEqual(patch["build_target_scheme"], "instance") + self.assertEqual(patch["build_target_ornament_weekly_count"], 0) + self.assertFalse(patch["build_target_use_user_instance_when_only_erosion_and_ornament"]) + + def test_full_patch_drop_scheme_count_seven_use_user_true(self) -> None: + patch = m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation( + enabled=True, + scheme="drop", + ornament_count=7, + use_user=True, + ), + ) + self.assertTrue(patch["build_target_enable"]) + self.assertEqual(patch["build_target_scheme"], "drop") + self.assertEqual(patch["build_target_ornament_weekly_count"], 7) + self.assertTrue(patch["build_target_use_user_instance_when_only_erosion_and_ornament"]) + + def test_scheme_instance_written_correctly(self) -> None: + patch = m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation(scheme="instance"), + ) + self.assertEqual(patch["build_target_scheme"], "instance") + + def test_scheme_drop_written_correctly(self) -> None: + patch = m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation(scheme="drop"), + ) + self.assertEqual(patch["build_target_scheme"], "drop") + + def test_scheme_none_falls_back_to_instance(self) -> None: + """scheme 缺省(None)时使用默认 'instance'。""" + + patch = m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation(scheme=None), + ) + self.assertEqual(patch["build_target_scheme"], "instance") + + def test_count_boundary_zero(self) -> None: + patch = m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation(ornament_count=0), + ) + self.assertEqual(patch["build_target_ornament_weekly_count"], 0) + + def test_count_boundary_seven(self) -> None: + patch = m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation(ornament_count=7), + ) + self.assertEqual(patch["build_target_ornament_weekly_count"], 7) + + def test_use_user_true_and_false_both_written(self) -> None: + for use_user in (True, False): + with self.subTest(use_user=use_user): + patch = m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation(use_user=use_user), + ) + self.assertEqual( + patch["build_target_use_user_instance_when_only_erosion_and_ornament"], + use_user, + ) + + +class BuildDailyPatchCultivationOnOffSwitchTests(unittest.TestCase): + """开/关切换无 stale 状态:连续两次调用(先开后关)第二次 build_target_enable=False。""" + + def test_off_after_on_resets_build_target_enable(self) -> None: + # 第一次:开启 + first = m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation(enabled=True, ornament_count=3), + ) + self.assertTrue(first["build_target_enable"]) + self.assertEqual(first["build_target_ornament_weekly_count"], 3) + + # 第二次:关闭 + second = m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation(enabled=False, ornament_count=3), + ) + self.assertFalse(second["build_target_enable"]) + # 关闭时不写入其他三个键(enable=false 时 M7A 不读取) + self.assertNotIn("build_target_scheme", second) + self.assertNotIn("build_target_ornament_weekly_count", second) + self.assertNotIn( + "build_target_use_user_instance_when_only_erosion_and_ornament", second + ) + + def test_cultivation_none_treated_as_disabled(self) -> None: + """cultivation_target=None 时显式写入 build_target_enable=False。""" + + patch = m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=None, + ) + self.assertFalse(patch["build_target_enable"]) + self.assertNotIn("build_target_scheme", patch) + self.assertNotIn("build_target_ornament_weekly_count", patch) + self.assertNotIn( + "build_target_use_user_instance_when_only_erosion_and_ornament", patch + ) + + def test_cultivation_absent_equivalent_to_disabled(self) -> None: + """不传 cultivation_target 时行为与 Enabled=False 一致。""" + + patch = m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + ) + self.assertFalse(patch["build_target_enable"]) + + +# --------------------------------------------------------------------------- +# build_m7a_daily_patch: 非法值 fail-closed +# --------------------------------------------------------------------------- + + +class BuildDailyPatchFailClosedTests(unittest.TestCase): + """非法 ornament_count / scheme 必须抛 ValueError,不写脏 patch。""" + + def test_count_below_zero_raises(self) -> None: + with self.assertRaises(ValueError): + m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation(ornament_count=-1), + ) + + def test_count_above_seven_raises(self) -> None: + with self.assertRaises(ValueError): + m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation(ornament_count=8), + ) + + def test_count_string_raises(self) -> None: + with self.assertRaises(ValueError): + m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation(ornament_count="abc"), # type: ignore[arg-type] + ) + + def test_count_none_raises(self) -> None: + with self.assertRaises(ValueError): + m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation(ornament_count=None), + ) + + def test_count_bool_raises(self) -> None: + """True/False 是 int 的子类但不应被当作合法计数。""" + + with self.assertRaises(ValueError): + m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation(ornament_count=True), # type: ignore[arg-type] + ) + + def test_invalid_scheme_raises(self) -> None: + with self.assertRaises(ValueError): + m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation(scheme="invalid"), # type: ignore[arg-type] + ) + + def test_failure_does_not_produce_partial_patch(self) -> None: + """raise 前没有写入部分 build_target 键到 patch(因为异常向上传播)。""" + + # 用 try/except 验证异常时不返回半成品 dict + try: + m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation(ornament_count=99), + ) + self.fail("应当 raise ValueError") + except ValueError: + pass + + +# --------------------------------------------------------------------------- +# build_m7a_daily_patch: EOW + 主副本 + 培养目标组合 +# --------------------------------------------------------------------------- + + +class BuildDailyPatchComboTests(unittest.TestCase): + """Enabled=true + eow_enabled=true + main_stage 存在时 patch 同时包含所有字段。""" + + def test_combo_main_stage_eow_and_cultivation(self) -> None: + main_stage = ("拟造花萼(金)", "Calyx Golden Stage") + patch = m7a.build_m7a_daily_patch( + _ConfiguredEowUserConfig(), + daily_eow_enabled=True, + main_stage=main_stage, + eow_name="历战余响:Test", + cultivation_target=_cultivation( + enabled=True, + scheme="drop", + ornament_count=5, + use_user=True, + ), + ) + # build_target 四键 + self.assertTrue(patch["build_target_enable"]) + self.assertEqual(patch["build_target_scheme"], "drop") + self.assertEqual(patch["build_target_ornament_weekly_count"], 5) + self.assertTrue(patch["build_target_use_user_instance_when_only_erosion_and_ornament"]) + # EOW + self.assertTrue(patch["echo_of_war_enable"]) + self.assertEqual(patch["echo_of_war_timestamp"], 0) + # 主副本回退 + self.assertEqual(patch["instance_type"], "拟造花萼(金)") + self.assertIn("拟造花萼(金)", patch["instance_names"]) + self.assertEqual(patch["instance_names"]["拟造花萼(金)"], "Calyx Golden Stage") + self.assertIn("历战余响", patch["instance_names"]) + self.assertTrue(patch["power_enable"]) + # 现有契约不变 + self.assertFalse(patch["use_fuel"]) + self.assertFalse(patch["use_reserved_trailblaze_power"]) + + def test_cultivation_on_without_manual_stage_enables_safe_no_op_fallback(self) -> None: + """培养目标必须进入 M7A Power 流程,同时用“无”覆盖旧手动副本。""" + + patch = m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation(enabled=True), + ) + self.assertTrue(patch["build_target_enable"]) + self.assertTrue(patch["power_enable"]) + self.assertEqual(patch["instance_type"], m7a.M7A_NO_OP_INSTANCE_TYPE) + self.assertEqual( + patch["instance_names"][m7a.M7A_NO_OP_INSTANCE_TYPE], + m7a.M7A_NO_OP_INSTANCE_NAME, + ) + + def test_cultivation_on_does_not_change_eow_handling(self) -> None: + """培养目标开启时不改变 EOW 完成判定口径。""" + + patch = m7a.build_m7a_daily_patch( + _ConfiguredEowUserConfig(), + daily_eow_enabled=True, + main_stage=("拟造花萼(金)", "Stage"), + eow_name="历战余响:Test", + cultivation_target=_cultivation(enabled=True), + ) + self.assertTrue(patch["echo_of_war_enable"]) + self.assertEqual(patch["echo_of_war_timestamp"], 0) + + def test_cultivation_on_eow_enabled_but_eow_name_missing_raises(self) -> None: + """Enabled=true + eow_enabled=true + eow_name=None 仍按现有契约 raise。""" + + with self.assertRaises(RuntimeError): + m7a.build_m7a_daily_patch( + _ConfiguredEowUserConfig(), + daily_eow_enabled=True, + main_stage=None, + eow_name=None, + cultivation_target=_cultivation(enabled=True), + ) + + +# --------------------------------------------------------------------------- +# merge_whitelist: 白名单外字段保留,build_target 四键被接管 +# --------------------------------------------------------------------------- + + +class MergeWhitelistCultivationTests(unittest.TestCase): + """merge_whitelist 处理后白名单外字段保留,build_target_* 被整体替换。""" + + def test_build_target_keys_replaced_not_deep_merged(self) -> None: + """build_target_* 不在 deep_merge_keys 中,应整体替换。""" + + current = { + "build_target_enable": False, + "build_target_scheme": "instance", + "build_target_ornament_weekly_count": 1, + "build_target_use_user_instance_when_only_erosion_and_ornament": False, + "user_keep_field": "should_remain", + } + patch = { + "build_target_enable": True, + "build_target_scheme": "drop", + "build_target_ornament_weekly_count": 4, + "build_target_use_user_instance_when_only_erosion_and_ornament": True, + } + merged = m7a.merge_whitelist(current, patch) + + self.assertTrue(merged["build_target_enable"]) + self.assertEqual(merged["build_target_scheme"], "drop") + self.assertEqual(merged["build_target_ornament_weekly_count"], 4) + self.assertTrue(merged["build_target_use_user_instance_when_only_erosion_and_ornament"]) + # 白名单外字段保留 + self.assertEqual(merged["user_keep_field"], "should_remain") + + def test_build_target_keys_in_whitelist(self) -> None: + """四个 build_target_* 键都在 M7A_DAILY_PATCH_WHITELIST 中。""" + + for key in ( + "build_target_enable", + "build_target_scheme", + "build_target_ornament_weekly_count", + "build_target_use_user_instance_when_only_erosion_and_ornament", + ): + with self.subTest(key=key): + self.assertIn(key, m7a.M7A_DAILY_PATCH_WHITELIST) + + def test_build_target_keys_not_in_deep_merge_keys(self) -> None: + """build_target_* 不在 deep_merge_keys 中,保证整体替换语义。""" + + for key in ( + "build_target_enable", + "build_target_scheme", + "build_target_ornament_weekly_count", + "build_target_use_user_instance_when_only_erosion_and_ornament", + ): + with self.subTest(key=key): + self.assertNotIn(key, m7a.M7A_DAILY_DEEP_MERGE_KEYS) + + def test_non_whitelisted_user_keys_preserved(self) -> None: + """白名单外用户配置(如自定义 instance_names 子键)保留。""" + + current = { + "instance_names": { + "拟造花萼(金)": "UserCustomStage", + "侵蚀隧洞": "UserRelicStage", + }, + "user_extra_setting": 42, + } + patch = { + "instance_names": {"拟造花萼(金)": "MAS Stage"}, + "build_target_enable": True, + } + merged = m7a.merge_whitelist(current, patch) + # instance_names 是 deep_merge_key,子键合并:MAS 覆盖拟造花萼,但用户侵蚀隧洞保留 + self.assertEqual(merged["instance_names"]["拟造花萼(金)"], "MAS Stage") + self.assertEqual(merged["instance_names"]["侵蚀隧洞"], "UserRelicStage") + # 白名单外字段保留 + self.assertEqual(merged["user_extra_setting"], 42) + # build_target 被接管 + self.assertTrue(merged["build_target_enable"]) + + def test_cultivation_no_op_overwrites_stale_selected_instance(self) -> None: + current = { + "instance_type": "拟造花萼(金)", + "instance_names": { + "拟造花萼(金)": "Stale User Stage", + m7a.M7A_NO_OP_INSTANCE_TYPE: "Stale Relic Stage", + }, + } + patch = m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_cultivation(enabled=True), + ) + merged = m7a.merge_whitelist(current, patch) + self.assertEqual(merged["instance_type"], m7a.M7A_NO_OP_INSTANCE_TYPE) + self.assertEqual( + merged["instance_names"][m7a.M7A_NO_OP_INSTANCE_TYPE], + m7a.M7A_NO_OP_INSTANCE_NAME, + ) + + +# --------------------------------------------------------------------------- +# write_m7a_patch: 原子写入与 fail-closed +# --------------------------------------------------------------------------- + + +class WriteM7aPatchAtomicityTests(unittest.TestCase): + """write_m7a_patch 的 .tmp + replace 原子写入语义。""" + + def _make_control(self) -> HSRM7AControl: + script_config = _ScriptConfig({}) + logs: list[str] = [] + return HSRM7AControl( + script_config=script_config, + account_switcher=None, # write_m7a_patch 不使用 account_switcher + append_log=logs.append, + ) + + def test_writes_full_patch_to_config_yaml(self) -> None: + control = self._make_control() + with tempfile.TemporaryDirectory() as temp_dir: + config_path = Path(temp_dir) / "config.yaml" + config_path.write_text( + yaml.safe_dump( + {"existing_key": "kept", "build_target_enable": False}, + allow_unicode=True, + ), + encoding="utf-8", + ) + + control.write_m7a_patch( + config_path, + { + "build_target_enable": True, + "build_target_scheme": "drop", + "build_target_ornament_weekly_count": 3, + }, + ) + + written = yaml.safe_load(config_path.read_text(encoding="utf-8-sig")) + # 白名单内字段被覆盖 + self.assertTrue(written["build_target_enable"]) + self.assertEqual(written["build_target_scheme"], "drop") + self.assertEqual(written["build_target_ornament_weekly_count"], 3) + # 白名单外字段保留 + self.assertEqual(written["existing_key"], "kept") + + def test_top_level_non_dict_raises_and_file_unchanged(self) -> None: + """上游 config.yaml 顶层非对象时 raise ValueError,且原文件不被破坏。""" + + control = self._make_control() + with tempfile.TemporaryDirectory() as temp_dir: + config_path = Path(temp_dir) / "config.yaml" + original_content = "- not_a_dict\n- also_not_a_dict\n" + config_path.write_text(original_content, encoding="utf-8") + + with self.assertRaises(ValueError): + control.write_m7a_patch(config_path, {"build_target_enable": True}) + + # 文件未被改动 + self.assertEqual( + config_path.read_text(encoding="utf-8-sig"), original_content + ) + + def test_yaml_dump_failure_does_not_corrupt_config(self) -> None: + """yaml.safe_dump 抛异常时 config.yaml 不被破坏(.tmp + replace)。""" + + control = self._make_control() + with tempfile.TemporaryDirectory() as temp_dir: + config_path = Path(temp_dir) / "config.yaml" + original_content = yaml.safe_dump( + {"existing_key": "kept"}, allow_unicode=True + ) + config_path.write_text(original_content, encoding="utf-8") + + with patch("yaml.safe_dump", side_effect=RuntimeError("dump failed")): + with self.assertRaises(RuntimeError): + control.write_m7a_patch( + config_path, {"build_target_enable": True} + ) + + # 原文件未被破坏 + self.assertEqual( + config_path.read_text(encoding="utf-8-sig"), original_content + ) + # .tmp 残留文件不应存在(write_m7a_patch 写到一半失败,但 replace 未执行) + # 注:write_m7a_patch 会先 write_text 到 .tmp 再 replace,失败时 .tmp 可能残留 + # 但 config.yaml 本身未被替换,这是原子性的核心保证 + + def test_no_tmp_file_left_after_success(self) -> None: + """成功写入后 .tmp 文件不应残留(已被 replace 替换)。""" + + control = self._make_control() + with tempfile.TemporaryDirectory() as temp_dir: + config_path = Path(temp_dir) / "config.yaml" + config_path.write_text("existing_key: kept\n", encoding="utf-8") + + control.write_m7a_patch(config_path, {"build_target_enable": True}) + + tmp_path = config_path.with_name(f"{config_path.name}.tmp") + self.assertFalse(tmp_path.exists()) + + +# --------------------------------------------------------------------------- +# 非 Daily 模块不被污染 +# --------------------------------------------------------------------------- + + +class NonDailyModuleIsolationTests(unittest.TestCase): + """ReceiveRewards/DivergentUniverse/CurrencyWars 不写入 build_target 键。""" + + def test_receive_rewards_patch_has_no_build_target_scheme(self) -> None: + patch = m7a.build_receive_rewards_patch() + # receive_rewards 仍显式关闭 build_target_enable(现有行为) + self.assertFalse(patch["build_target_enable"]) + # 但不写入其他三个 build_target 键 + self.assertNotIn("build_target_scheme", patch) + self.assertNotIn("build_target_ornament_weekly_count", patch) + self.assertNotIn( + "build_target_use_user_instance_when_only_erosion_and_ornament", patch + ) + + def test_divergent_universe_patch_has_no_build_target_keys(self) -> None: + script_config = _ScriptConfig({"M7A": {"LowPerformanceMode": False}}) + patch = m7a.build_divergent_universe_patch( + script_config, _EmptyStageUserConfig() + ) + self.assertNotIn("build_target_enable", patch) + self.assertNotIn("build_target_scheme", patch) + self.assertNotIn("build_target_ornament_weekly_count", patch) + self.assertNotIn( + "build_target_use_user_instance_when_only_erosion_and_ornament", patch + ) + + def test_currency_wars_patch_has_no_build_target_keys(self) -> None: + patch = m7a.build_currency_wars_patch(_EmptyStageUserConfig()) + self.assertNotIn("build_target_enable", patch) + self.assertNotIn("build_target_scheme", patch) + self.assertNotIn("build_target_ornament_weekly_count", patch) + self.assertNotIn( + "build_target_use_user_instance_when_only_erosion_and_ornament", patch + ) + + +# --------------------------------------------------------------------------- +# create_module_item: Daily 分支跳过逻辑 +# --------------------------------------------------------------------------- + + +def _make_control(script_config: _ScriptConfig) -> tuple[HSRM7AControl, list[str]]: + """构造 HSRM7AControl 替身,返回 (control, log_lines)。""" + + logs: list[str] = [] + control = HSRM7AControl( + script_config=script_config, + account_switcher=None, # create_module_item Daily 分支不使用 account_switcher + append_log=logs.append, + ) + return control, logs + + +class CreateModuleItemDailyBranchTests(unittest.TestCase): + """create_module_item Daily 分支在培养目标开关下的跳过逻辑。""" + + def test_cultivation_on_empty_stage_eow_off_does_not_return_none(self) -> None: + """Enabled=true + main_stage=None + eow=false:不返回 None。""" + + script_config = _ScriptConfig( + { + "CultivationTarget": {"Enabled": True}, + "M7A": {"Path": "/fake/m7a"}, + } + ) + control, logs = _make_control(script_config) + module = get_module("Daily") + + item = control.create_module_item( + user_cfg=_EmptyStageUserConfig(), + user_name="test-user", + module=module, + timeout_seconds=600, + m7a_path="/fake/m7a", + m7a_runner=None, # Daily 分支不立即使用 runner + daily_eow_enabled=False, + ) + + self.assertIsNotNone(item) + self.assertIsInstance(item, HSRNativeRunPlan) + self.assertTrue( + any("培养目标已启用" in line for line in logs), + f"expected cultivation log, got: {logs}", + ) + + def test_cultivation_off_empty_stage_eow_off_returns_none(self) -> None: + """Enabled=false + main_stage=None + eow=false:返回 None(现有行为)。""" + + script_config = _ScriptConfig( + { + "CultivationTarget": {"Enabled": False}, + "M7A": {"Path": "/fake/m7a"}, + } + ) + control, logs = _make_control(script_config) + module = get_module("Daily") + + item = control.create_module_item( + user_cfg=_EmptyStageUserConfig(), + user_name="test-user", + module=module, + timeout_seconds=600, + m7a_path="/fake/m7a", + m7a_runner=None, + daily_eow_enabled=False, + ) + + self.assertIsNone(item) + self.assertTrue( + any("无可执行副本" in line for line in logs), + f"expected skip log, got: {logs}", + ) + + def test_cultivation_absent_empty_stage_eow_off_returns_none(self) -> None: + """script_config 完全没有 CultivationTarget 段:等同 Enabled=false。""" + + script_config = _ScriptConfig({"M7A": {"Path": "/fake/m7a"}}) + control, _ = _make_control(script_config) + module = get_module("Daily") + + item = control.create_module_item( + user_cfg=_EmptyStageUserConfig(), + user_name="test-user", + module=module, + timeout_seconds=600, + m7a_path="/fake/m7a", + m7a_runner=None, + daily_eow_enabled=False, + ) + + self.assertIsNone(item) + + def test_cultivation_off_with_stage_does_not_skip(self) -> None: + """Enabled=false + 有配置副本:不跳过。""" + + script_config = _ScriptConfig( + { + "CultivationTarget": {"Enabled": False}, + "M7A": {"Path": "/fake/m7a"}, + } + ) + control, _ = _make_control(script_config) + module = get_module("Daily") + + item = control.create_module_item( + user_cfg=_ConfiguredStageUserConfig(), + user_name="test-user", + module=module, + timeout_seconds=600, + m7a_path="/fake/m7a", + m7a_runner=None, + daily_eow_enabled=False, + ) + + self.assertIsNotNone(item) + + def test_cultivation_on_with_stage_still_uses_build_target(self) -> None: + """Enabled=true + 有配置副本:仍创建执行项(main_stage 作为回退副本)。""" + + script_config = _ScriptConfig( + { + "CultivationTarget": {"Enabled": True}, + "M7A": {"Path": "/fake/m7a"}, + } + ) + control, _ = _make_control(script_config) + module = get_module("Daily") + + item = control.create_module_item( + user_cfg=_ConfiguredStageUserConfig(), + user_name="test-user", + module=module, + timeout_seconds=600, + m7a_path="/fake/m7a", + m7a_runner=None, + daily_eow_enabled=False, + ) + + self.assertIsNotNone(item) + + +class CreateModuleItemNonDailyBranchTests(unittest.TestCase): + """非 Daily 模块保持 patch 隔离并正确传递脚本级超时。""" + + def test_all_non_daily_plans_forward_timeout_to_runner(self) -> None: + with tempfile.TemporaryDirectory(prefix="m7a_non_daily_plan_") as temp_dir: + config_path = Path(temp_dir) / "config.yaml" + config_path.write_text("{}\n", encoding="utf-8") + + for module_key in ( + "ReceiveRewards", + "DivergentUniverse", + "CurrencyWars", + ): + with self.subTest(module=module_key): + control, _ = _make_control( + _ScriptConfig({"M7A": {"LowPerformanceMode": False}}) + ) + observed_timeouts: list[int | None] = [] + + async def run_m7a_command( + m7a_runner, + user_name, + module_name, + command, + timeout_seconds=None, + ): + del m7a_runner, user_name, module_name, command + observed_timeouts.append(timeout_seconds) + return types.SimpleNamespace( + success=True, + output="", + error="", + returncode=0, + ) + + control.run_m7a_command = run_m7a_command + plan = control.create_module_item( + user_cfg=_EmptyStageUserConfig(), + user_name="test-user", + module=get_module(module_key), + timeout_seconds=321, + m7a_path=temp_dir, + m7a_runner=object(), + daily_eow_enabled=False, + ) + + self.assertIsNotNone(plan) + result = asyncio.run(plan.run()) + self.assertTrue(result.success) + self.assertEqual(observed_timeouts, [321]) + + def test_all_non_daily_patch_builders_have_no_build_target_scheme(self) -> None: + for patch in ( + m7a.build_receive_rewards_patch(), + m7a.build_divergent_universe_patch( + _ScriptConfig({"M7A": {"LowPerformanceMode": False}}), + _EmptyStageUserConfig(), + ), + m7a.build_currency_wars_patch(_EmptyStageUserConfig()), + ): + with self.subTest(patch_keys=sorted(patch.keys())): + self.assertNotIn("build_target_scheme", patch) + self.assertNotIn("build_target_ornament_weekly_count", patch) + self.assertNotIn( + "build_target_use_user_instance_when_only_erosion_and_ornament", + patch, + ) + + +# --------------------------------------------------------------------------- +# _read_cultivation_target: script_config 提取 +# --------------------------------------------------------------------------- + + +class ReadCultivationTargetTests(unittest.TestCase): + """_read_cultivation_target 从 script_config 提取 CultivationTarget 字典。""" + + def test_returns_none_when_section_missing(self) -> None: + script_config = _ScriptConfig({"M7A": {"Path": "/fake"}}) + self.assertIsNone(_read_cultivation_target(script_config)) + + def test_returns_dict_when_enabled_true(self) -> None: + script_config = _ScriptConfig( + { + "CultivationTarget": { + "Enabled": True, + "M7ARecognitionScheme": "drop", + "M7AOrnamentWeeklyCount": 4, + "M7AUseUserStageWhenOnlyRelics": True, + } + } + ) + result = _read_cultivation_target(script_config) + self.assertIsNotNone(result) + self.assertTrue(result["Enabled"]) + self.assertEqual(result["M7ARecognitionScheme"], "drop") + self.assertEqual(result["M7AOrnamentWeeklyCount"], 4) + self.assertTrue(result["M7AUseUserStageWhenOnlyRelics"]) + + def test_returns_dict_when_enabled_false(self) -> None: + script_config = _ScriptConfig( + {"CultivationTarget": {"Enabled": False}} + ) + result = _read_cultivation_target(script_config) + self.assertIsNotNone(result) + self.assertFalse(result["Enabled"]) + + def test_preserves_none_for_missing_fields(self) -> None: + """缺失字段保留为 None(由 build_m7a_daily_patch fail-closed)。""" + + script_config = _ScriptConfig( + {"CultivationTarget": {"Enabled": True}} + ) + result = _read_cultivation_target(script_config) + self.assertIsNotNone(result) + self.assertTrue(result["Enabled"]) + self.assertIsNone(result["M7ARecognitionScheme"]) + self.assertIsNone(result["M7AOrnamentWeeklyCount"]) + self.assertIsNone(result["M7AUseUserStageWhenOnlyRelics"]) + + def test_end_to_end_with_build_m7a_daily_patch(self) -> None: + """_read_cultivation_target 输出可直接喂给 build_m7a_daily_patch。""" + + script_config = _ScriptConfig( + { + "CultivationTarget": { + "Enabled": True, + "M7ARecognitionScheme": "drop", + "M7AOrnamentWeeklyCount": 2, + "M7AUseUserStageWhenOnlyRelics": True, + } + } + ) + patch = m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_read_cultivation_target(script_config), + ) + self.assertTrue(patch["build_target_enable"]) + self.assertEqual(patch["build_target_scheme"], "drop") + self.assertEqual(patch["build_target_ornament_weekly_count"], 2) + self.assertTrue(patch["build_target_use_user_instance_when_only_erosion_and_ornament"]) + + def test_end_to_end_missing_ornament_count_raises(self) -> None: + """用户开启 Enabled=True 但未配置 ornament_count:fail-closed raise。""" + + script_config = _ScriptConfig( + {"CultivationTarget": {"Enabled": True}} + ) + with self.assertRaises(ValueError): + m7a.build_m7a_daily_patch( + _EmptyStageUserConfig(), + daily_eow_enabled=False, + cultivation_target=_read_cultivation_target(script_config), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_managed_update_transaction.py b/tests/test_managed_update_transaction.py new file mode 100644 index 0000000..3491c34 --- /dev/null +++ b/tests/test_managed_update_transaction.py @@ -0,0 +1,1764 @@ +from __future__ import annotations + +import hashlib +import json +import shutil +import stat +import tempfile +import unittest +import zipfile +from pathlib import Path +from typing import Literal +from unittest.mock import patch + +import automas_script_hsr.managed_update as managed_update_module +from automas_hsr_adapter_m7a.installation import M7A_MANAGED_LAYOUT +from automas_hsr_adapter_sra.installation import SRA_MANAGED_LAYOUT +from automas_script_hsr import ( + HSRManagedUpdateCrash, + HSRManagedUpdateError, + HSRManagedUpdateLayout, + HSRManagedUpdater, + HSRPreservationRule, + HSRTrustedArtifact, + HSRTrustedRelease, + safe_extract_hsr_archive, +) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + digest.update(path.read_bytes()) + return digest.hexdigest() + + +def _make_archive( + path: Path, + *, + files: dict[str, bytes | str] | None = None, + raw_entries: tuple[tuple[zipfile.ZipInfo | str, bytes | str], ...] = (), +) -> None: + payload = files or { + "app.exe": b"new executable", + "VERSION": "2.0.0\n", + "defaults/settings.json": "{}\n", + } + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as package: + for name, content in payload.items(): + package.writestr( + name, + content.encode("utf-8") + if isinstance(content, str) + else content, + ) + for entry, content in raw_entries: + package.writestr( + entry, + content.encode("utf-8") + if isinstance(content, str) + else content, + ) + + +def _trusted_release( + archive: Path, + *, + version: str = "2.0.0", + digest: str | None = None, + size: int | None = None, + engine: Literal["SRA", "M7A"] = "SRA", +) -> tuple[HSRTrustedRelease, HSRTrustedArtifact]: + artifact = HSRTrustedArtifact( + asset_id=200, + name=archive.name, + download_url="https://example.invalid/release.zip", + size=archive.stat().st_size if size is None else size, + sha256=_sha256(archive) if digest is None else digest, + ) + release = HSRTrustedRelease( + engine=engine, + release_id=100, + tag=f"v{version}", + version=version, + channel="stable", + published_at="2026-07-23T00:00:00Z", + upstream_immutable=False, + artifacts=(artifact,), + ) + return release, artifact + + +def _workspace_for(install_root: Path) -> Path: + return install_root.parent / ( + f".{install_root.name}.automas-hsr-update" + ) + + +def _current_for(install_root: Path) -> dict: + return json.loads( + (_workspace_for(install_root) / "CURRENT.json").read_text( + encoding="utf-8" + ) + ) + + +class ManagedUpdateTransactionTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory( + prefix="automas-hsr-managed-update-" + ) + self.root = Path(self.temporary.name) + self.install = self.root / "installed" + self.cache = self.root / "cache" + self.release_archive = self.root / "release.zip" + self.layout = HSRManagedUpdateLayout( + engine="SRA", + required_paths=("app.exe", "VERSION"), + preserved_paths=(HSRPreservationRule("user/settings.json"),), + ) + self.install.mkdir() + (self.install / "app.exe").write_bytes(b"old executable") + (self.install / "VERSION").write_text("1.0.0\n", encoding="utf-8") + (self.install / "user").mkdir() + (self.install / "user" / "settings.json").write_text( + '{"token":"secret"}\n', + encoding="utf-8", + ) + _make_archive(self.release_archive) + self.release, self.artifact = _trusted_release(self.release_archive) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def _updater( + self, + *, + process_active: bool = False, + validator=None, + phase_hook=None, + ) -> HSRManagedUpdater: + def download(_artifact: HSRTrustedArtifact, destination: Path) -> None: + shutil.copy2(self.release_archive, destination) + + return HSRManagedUpdater( + cache_root=self.cache, + download=download, + process_guard=lambda _root: process_active, + validator=validator, + phase_hook=phase_hook, + ) + + def test_commit_preserves_user_file_and_retains_rollback_backup(self) -> None: + result = self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + + self.assertTrue(result.success) + self.assertEqual(result.state, "committed") + self.assertEqual((self.install / "app.exe").read_bytes(), b"new executable") + self.assertEqual( + (self.install / "user" / "settings.json").read_text( + encoding="utf-8" + ), + '{"token":"secret"}\n', + ) + self.assertIsNotNone(result.backup_path) + backup = Path(str(result.backup_path)) + self.assertEqual((backup / "app.exe").read_bytes(), b"old executable") + self.assertTrue(Path(result.journal_path).is_file()) + + def test_cache_hit_is_reverified_without_downloading(self) -> None: + cached = self.cache / f"{self.artifact.sha256}.zip" + cached.parent.mkdir() + shutil.copy2(self.release_archive, cached) + + updater = HSRManagedUpdater( + cache_root=self.cache, + download=lambda _artifact, _destination: self.fail( + "cache hit must not download" + ), + process_guard=lambda _root: False, + ) + result = updater.execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + + self.assertTrue(result.success) + + def test_digest_mismatch_leaves_installation_unchanged(self) -> None: + release, artifact = _trusted_release( + self.release_archive, + digest="0" * 64, + ) + result = self._updater().execute( + install_root=self.install, + layout=self.layout, + release=release, + artifact=artifact, + current_version="1.0.0", + ) + + self.assertEqual(result.state, "failed") + self.assertEqual(result.error_code, "ARTIFACT_DIGEST_MISMATCH") + self.assertEqual((self.install / "app.exe").read_bytes(), b"old executable") + + def test_size_mismatch_leaves_installation_unchanged(self) -> None: + release, artifact = _trusted_release( + self.release_archive, + size=self.release_archive.stat().st_size + 1, + ) + result = self._updater().execute( + install_root=self.install, + layout=self.layout, + release=release, + artifact=artifact, + current_version="1.0.0", + ) + + self.assertEqual(result.state, "failed") + self.assertEqual(result.error_code, "ARTIFACT_SIZE_MISMATCH") + self.assertEqual((self.install / "VERSION").read_text(), "1.0.0\n") + + def test_active_process_is_rejected_before_transaction(self) -> None: + with self.assertRaisesRegex(HSRManagedUpdateError, "仍在运行"): + self._updater(process_active=True).execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + self.assertEqual((self.install / "app.exe").read_bytes(), b"old executable") + + def test_same_version_and_downgrade_fail_closed(self) -> None: + with self.assertRaisesRegex(HSRManagedUpdateError, "相同"): + self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="2.0.0.0", + ) + with self.assertRaisesRegex(HSRManagedUpdateError, "降级"): + self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="3.0.0", + ) + + def test_post_switch_validation_failure_rolls_back(self) -> None: + calls = 0 + + def validator(_root: Path, _release: HSRTrustedRelease) -> None: + nonlocal calls + calls += 1 + if calls == 2: + raise HSRManagedUpdateError( + "VALIDATION_FAILED", + "post-switch validation failed", + ) + + result = self._updater(validator=validator).execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + + self.assertEqual(result.state, "rolled_back") + self.assertEqual(result.error_code, "VALIDATION_FAILED") + self.assertEqual((self.install / "app.exe").read_bytes(), b"old executable") + + def test_crash_after_old_move_recovers_by_rollback(self) -> None: + transaction: dict[str, str] = {} + + def crash(phase: str) -> None: + if phase == "old_moved": + workspace = self.install.parent / ( + f".{self.install.name}.automas-hsr-update" + ) + journals = tuple(workspace.glob("transactions/*/journal.json")) + transaction["id"] = journals[0].parent.name + raise HSRManagedUpdateCrash() + + with self.assertRaises(HSRManagedUpdateCrash): + self._updater(phase_hook=crash).execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + self.assertFalse(self.install.exists()) + + recovered = self._updater().recover( + install_root=self.install, + layout=self.layout, + transaction_id=transaction["id"], + release=self.release, + ) + + self.assertEqual(recovered.state, "rolled_back") + self.assertEqual((self.install / "app.exe").read_bytes(), b"old executable") + + def test_crash_after_new_install_recovers_as_committed(self) -> None: + transaction: dict[str, str] = {} + + def crash(phase: str) -> None: + if phase == "installed": + workspace = self.install.parent / ( + f".{self.install.name}.automas-hsr-update" + ) + journals = tuple(workspace.glob("transactions/*/journal.json")) + transaction["id"] = journals[0].parent.name + raise HSRManagedUpdateCrash() + + with self.assertRaises(HSRManagedUpdateCrash): + self._updater(phase_hook=crash).execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + + recovered = self._updater().recover( + install_root=self.install, + layout=self.layout, + transaction_id=transaction["id"], + release=self.release, + ) + + self.assertEqual(recovered.state, "committed") + self.assertEqual((self.install / "app.exe").read_bytes(), b"new executable") + self.assertTrue(recovered.rollback_available) + + def test_manual_rollback_restores_previous_installation(self) -> None: + updater = self._updater() + committed = updater.execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + + rolled_back = updater.rollback( + install_root=self.install, + layout=self.layout, + transaction_id=committed.transaction_id, + release=self.release, + ) + + self.assertEqual(rolled_back.state, "rolled_back") + self.assertEqual((self.install / "app.exe").read_bytes(), b"old executable") + + def test_recovery_and_rollback_reject_transaction_path_traversal(self) -> None: + updater = self._updater() + for operation in (updater.recover, updater.rollback): + with self.subTest(operation=operation.__name__): + with self.assertRaisesRegex( + HSRManagedUpdateError, + "事务 ID 无效", + ): + operation( + install_root=self.install, + layout=self.layout, + transaction_id="../../outside", + release=self.release, + ) + + def test_single_wrapping_directory_is_selected(self) -> None: + _make_archive( + self.release_archive, + files={ + "release-root/app.exe": b"wrapped executable", + "release-root/VERSION": "2.0.0\n", + }, + ) + self.release, self.artifact = _trusted_release(self.release_archive) + + result = self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + + self.assertTrue(result.success) + self.assertEqual( + (self.install / "app.exe").read_bytes(), + b"wrapped executable", + ) + + def test_lock_is_acquired_before_process_guard_and_transaction(self) -> None: + observed: dict[str, bool] = {} + + def process_guard(_root: Path) -> bool: + workspace = _workspace_for(self.install) + observed["lock_exists"] = (workspace / "active.lock").is_file() + observed["transactions_absent"] = not ( + workspace / "transactions" + ).exists() + return True + + updater = HSRManagedUpdater( + cache_root=self.cache, + download=lambda _artifact, destination: shutil.copy2( + self.release_archive, + destination, + ), + process_guard=process_guard, + ) + with self.assertRaises(HSRManagedUpdateError) as raised: + updater.execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + + self.assertEqual(raised.exception.code, "PROCESS_ACTIVE") + self.assertEqual( + observed, + {"lock_exists": True, "transactions_absent": True}, + ) + + def test_process_guard_is_rechecked_immediately_before_switch(self) -> None: + checks = 0 + + def process_guard(_root: Path) -> bool: + nonlocal checks + checks += 1 + return checks == 2 + + updater = HSRManagedUpdater( + cache_root=self.cache, + download=lambda _artifact, destination: shutil.copy2( + self.release_archive, + destination, + ), + process_guard=process_guard, + ) + result = updater.execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + + self.assertEqual(checks, 2) + self.assertEqual(result.state, "failed") + self.assertEqual(result.error_code, "PROCESS_ACTIVE") + self.assertEqual((self.install / "app.exe").read_bytes(), b"old executable") + + def test_existing_install_without_current_version_fails_closed(self) -> None: + with self.assertRaises(HSRManagedUpdateError) as raised: + self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version=None, + ) + + self.assertEqual(raised.exception.code, "CURRENT_VERSION_REQUIRED") + workspace = _workspace_for(self.install) + self.assertFalse((workspace / "transactions").exists()) + self.assertEqual((self.install / "app.exe").read_bytes(), b"old executable") + + def test_fresh_install_commit_and_manual_rollback_use_absent_tombstone( + self, + ) -> None: + fresh = self.root / "fresh-install" + updater = self._updater() + + committed = updater.execute( + install_root=fresh, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version=None, + ) + first_pointer = _current_for(fresh) + self.assertEqual(first_pointer["generation"], 1) + self.assertEqual(first_pointer["state"], "managed") + self.assertEqual( + first_pointer["active_transaction"], + committed.transaction_id, + ) + + rolled_back = updater.rollback( + install_root=fresh, + layout=self.layout, + transaction_id=committed.transaction_id, + release=self.release, + ) + tombstone = _current_for(fresh) + self.assertEqual(rolled_back.state, "rolled_back") + self.assertFalse(fresh.exists()) + self.assertEqual(tombstone["generation"], 2) + self.assertEqual(tombstone["state"], "absent") + self.assertIsNone(tombstone["active_transaction"]) + + recovered = updater.recover( + install_root=fresh, + layout=self.layout, + transaction_id=committed.transaction_id, + release=self.release, + ) + self.assertEqual(recovered.state, "rolled_back") + + reinstalled = updater.execute( + install_root=fresh, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version=None, + ) + self.assertTrue(reinstalled.success) + self.assertEqual(_current_for(fresh)["generation"], 3) + + def test_fresh_post_switch_failure_isolates_bad_candidate(self) -> None: + fresh = self.root / "fresh-invalid" + calls = 0 + + def validator(_root: Path, _release: HSRTrustedRelease) -> None: + nonlocal calls + calls += 1 + if calls == 2: + raise HSRManagedUpdateError( + "VALIDATION_FAILED", + "installed candidate rejected", + ) + + result = self._updater(validator=validator).execute( + install_root=fresh, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version=None, + ) + + self.assertEqual(result.state, "failed") + self.assertFalse(fresh.exists()) + failed_candidate = Path(result.journal_path).parent / "failed-candidate" + self.assertEqual( + (failed_candidate / "app.exe").read_bytes(), + b"new executable", + ) + self.assertFalse((_workspace_for(fresh) / "CURRENT.json").exists()) + + def test_recovery_handles_crash_after_old_rename_before_done_record( + self, + ) -> None: + transaction: dict[str, str] = {} + + def crash(phase: str) -> None: + if phase == "after_old_move_rename": + journals = tuple( + _workspace_for(self.install).glob( + "transactions/*/journal.json" + ) + ) + transaction["id"] = journals[0].parent.name + raise HSRManagedUpdateCrash() + + with self.assertRaises(HSRManagedUpdateCrash): + self._updater(phase_hook=crash).execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + + journal = json.loads( + ( + _workspace_for(self.install) + / "transactions" + / transaction["id"] + / "journal.json" + ).read_text(encoding="utf-8") + ) + self.assertEqual(journal["phase"], "switch_old_intent") + recovered = self._updater().recover( + install_root=self.install, + layout=self.layout, + transaction_id=transaction["id"], + release=self.release, + ) + self.assertEqual(recovered.state, "rolled_back") + self.assertEqual((self.install / "app.exe").read_bytes(), b"old executable") + + def test_recovery_handles_crash_after_new_rename_before_done_record( + self, + ) -> None: + transaction: dict[str, str] = {} + + def crash(phase: str) -> None: + if phase == "after_new_install_rename": + journals = tuple( + _workspace_for(self.install).glob( + "transactions/*/journal.json" + ) + ) + transaction["id"] = journals[0].parent.name + raise HSRManagedUpdateCrash() + + with self.assertRaises(HSRManagedUpdateCrash): + self._updater(phase_hook=crash).execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + + recovered = self._updater().recover( + install_root=self.install, + layout=self.layout, + transaction_id=transaction["id"], + release=self.release, + ) + pointer = _current_for(self.install) + self.assertEqual(recovered.state, "committed") + self.assertEqual(pointer["generation"], 1) + self.assertEqual( + pointer["active_transaction"], + transaction["id"], + ) + + def test_manual_rollback_recovers_each_rename_and_pointer_gap(self) -> None: + crash_phases = ( + "after_manual_current_move_rename", + "after_manual_restore_rename", + "after_manual_pointer_publish", + ) + for crash_phase in crash_phases: + with self.subTest(crash_phase=crash_phase): + install = self.root / crash_phase + install.mkdir() + (install / "app.exe").write_bytes(b"old executable") + (install / "VERSION").write_text("1.0.0\n", encoding="utf-8") + updater = self._updater() + committed = updater.execute( + install_root=install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + fired = False + + def crash(phase: str) -> None: + nonlocal fired + if phase == crash_phase and not fired: + fired = True + raise HSRManagedUpdateCrash() + + with self.assertRaises(HSRManagedUpdateCrash): + self._updater(phase_hook=crash).rollback( + install_root=install, + layout=self.layout, + transaction_id=committed.transaction_id, + release=self.release, + ) + recovered = updater.recover( + install_root=install, + layout=self.layout, + transaction_id=committed.transaction_id, + release=self.release, + ) + pointer = _current_for(install) + self.assertEqual(recovered.state, "rolled_back") + self.assertEqual( + (install / "app.exe").read_bytes(), + b"old executable", + ) + self.assertEqual(pointer["generation"], 2) + self.assertEqual(pointer["state"], "unmanaged") + + def test_current_generation_prevents_old_transaction_aba(self) -> None: + updater = self._updater() + first = updater.execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + _make_archive( + self.release_archive, + files={ + "app.exe": b"third executable", + "VERSION": "3.0.0\n", + }, + ) + third_release, third_artifact = _trusted_release( + self.release_archive, + version="3.0.0", + ) + second = updater.execute( + install_root=self.install, + layout=self.layout, + release=third_release, + artifact=third_artifact, + current_version="2.0.0", + ) + self.assertEqual(_current_for(self.install)["generation"], 2) + + with self.assertRaises(HSRManagedUpdateError) as recovered: + updater.recover( + install_root=self.install, + layout=self.layout, + transaction_id=first.transaction_id, + release=self.release, + ) + self.assertEqual(recovered.exception.code, "CURRENT_STATE_CHANGED") + + with self.assertRaises(HSRManagedUpdateError) as raised: + updater.rollback( + install_root=self.install, + layout=self.layout, + transaction_id=first.transaction_id, + release=self.release, + ) + self.assertEqual(raised.exception.code, "CURRENT_STATE_CHANGED") + + rolled_back = updater.rollback( + install_root=self.install, + layout=self.layout, + transaction_id=second.transaction_id, + release=third_release, + ) + restored_pointer = _current_for(self.install) + self.assertEqual(rolled_back.state, "rolled_back") + self.assertEqual(restored_pointer["generation"], 3) + self.assertEqual( + restored_pointer["active_transaction"], + first.transaction_id, + ) + self.assertEqual((self.install / "app.exe").read_bytes(), b"new executable") + + def test_manual_rollback_rejects_newer_expected_generation(self) -> None: + committed = self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + current_path = _workspace_for(self.install) / "CURRENT.json" + pointer = _current_for(self.install) + pointer["generation"] += 2 + current_path.write_text( + json.dumps(pointer), + encoding="utf-8", + ) + + with self.assertRaises(HSRManagedUpdateError) as raised: + self._updater().rollback( + install_root=self.install, + layout=self.layout, + transaction_id=committed.transaction_id, + release=self.release, + ) + self.assertEqual(raised.exception.code, "CURRENT_STATE_CHANGED") + self.assertEqual((self.install / "app.exe").read_bytes(), b"new executable") + + def test_recover_honors_process_guard_before_mutation(self) -> None: + transaction: dict[str, str] = {} + + def crash(phase: str) -> None: + if phase == "old_moved": + journal = next( + _workspace_for(self.install).glob( + "transactions/*/journal.json" + ) + ) + transaction["id"] = journal.parent.name + raise HSRManagedUpdateCrash() + + with self.assertRaises(HSRManagedUpdateCrash): + self._updater(phase_hook=crash).execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + + with self.assertRaises(HSRManagedUpdateError) as raised: + self._updater(process_active=True).recover( + install_root=self.install, + layout=self.layout, + transaction_id=transaction["id"], + release=self.release, + ) + self.assertEqual(raised.exception.code, "PROCESS_ACTIVE") + self.assertFalse(self.install.exists()) + + recovered = self._updater().recover( + install_root=self.install, + layout=self.layout, + transaction_id=transaction["id"], + release=self.release, + ) + self.assertEqual(recovered.state, "rolled_back") + + def test_prerelease_comparison_uses_packaging_semantics(self) -> None: + result = self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="2.0.0rc1", + ) + self.assertTrue(result.success) + + prerelease, artifact = _trusted_release( + self.release_archive, + version="2.0.0rc2", + ) + with self.assertRaises(HSRManagedUpdateError) as raised: + self._updater().execute( + install_root=self.install, + layout=self.layout, + release=prerelease, + artifact=artifact, + current_version="2.0.0", + ) + self.assertEqual(raised.exception.code, "DOWNGRADE_REJECTED") + + def test_required_paths_reject_directory_and_reparse_types(self) -> None: + _make_archive( + self.release_archive, + files={"app.exe/placeholder": b"x", "VERSION": "2.0.0\n"}, + ) + self.release, self.artifact = _trusted_release(self.release_archive) + invalid_type = self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + self.assertEqual( + invalid_type.error_code, + "REQUIRED_PATH_TYPE_INVALID", + ) + + _make_archive(self.release_archive) + self.release, self.artifact = _trusted_release(self.release_archive) + original = managed_update_module._is_reparse_point + + def fake_reparse(path: Path) -> bool: + return path.name == "app.exe" or original(path) + + with patch.object( + managed_update_module, + "_is_reparse_point", + side_effect=fake_reparse, + ): + rejected = self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + self.assertEqual( + rejected.error_code, + "REQUIRED_PATH_REPARSE_REJECTED", + ) + + def test_asset_name_never_controls_local_download_path(self) -> None: + unsafe_artifact = HSRTrustedArtifact( + asset_id=self.artifact.asset_id, + name="../../escape.zip", + download_url=self.artifact.download_url, + size=self.artifact.size, + sha256=self.artifact.sha256, + ) + release = HSRTrustedRelease( + engine="SRA", + release_id=self.release.release_id, + tag=self.release.tag, + version=self.release.version, + channel="stable", + published_at=self.release.published_at, + upstream_immutable=False, + artifacts=(unsafe_artifact,), + ) + destinations: list[Path] = [] + + def download( + _artifact: HSRTrustedArtifact, + destination: Path, + ) -> None: + destinations.append(destination) + shutil.copy2(self.release_archive, destination) + + result = HSRManagedUpdater( + cache_root=self.cache, + download=download, + process_guard=lambda _root: False, + ).execute( + install_root=self.install, + layout=self.layout, + release=release, + artifact=unsafe_artifact, + current_version="1.0.0", + ) + + self.assertTrue(result.success) + self.assertEqual(destinations[0].parent, self.cache) + self.assertNotIn("escape", destinations[0].name) + self.assertFalse((self.root / "escape.zip.part").exists()) + + def test_terminal_recover_validates_current_and_active_tree(self) -> None: + committed = self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + (self.install / "app.exe").replace(self.install / "renamed.exe") + + with self.assertRaises(HSRManagedUpdateError) as raised: + self._updater().recover( + install_root=self.install, + layout=self.layout, + transaction_id=committed.transaction_id, + release=self.release, + ) + self.assertEqual(raised.exception.code, "VALIDATION_FAILED") + + def test_update_journal_records_intent_before_every_switch_done(self) -> None: + committed = self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + payload = json.loads( + Path(committed.journal_path).read_text(encoding="utf-8") + ) + phases = [event["phase"] for event in payload["events"]] + self.assertLess( + phases.index("switch_old_intent"), + phases.index("old_moved"), + ) + self.assertLess( + phases.index("switch_new_intent"), + phases.index("installed"), + ) + self.assertLess( + phases.index("commit_intent"), + phases.index("current_published"), + ) + + def test_update_recovery_handles_automatic_rollback_rename_gaps( + self, + ) -> None: + for crash_phase in ( + "after_failure_isolate_rename", + "after_failure_restore_rename", + ): + with self.subTest(crash_phase=crash_phase): + install = self.root / crash_phase + install.mkdir() + (install / "app.exe").write_bytes(b"old executable") + (install / "VERSION").write_text( + "1.0.0\n", + encoding="utf-8", + ) + validator_calls = 0 + + def validator( + _root: Path, + _release: HSRTrustedRelease, + ) -> None: + nonlocal validator_calls + validator_calls += 1 + if validator_calls == 2: + raise HSRManagedUpdateError( + "VALIDATION_FAILED", + "reject installed candidate", + ) + + transaction: dict[str, str] = {} + + def crash(phase: str) -> None: + if phase == crash_phase: + journal = next( + _workspace_for(install).glob( + "transactions/*/journal.json" + ) + ) + transaction["id"] = journal.parent.name + raise HSRManagedUpdateCrash() + + with self.assertRaises(HSRManagedUpdateCrash): + self._updater( + validator=validator, + phase_hook=crash, + ).execute( + install_root=install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + recovered = self._updater().recover( + install_root=install, + layout=self.layout, + transaction_id=transaction["id"], + release=self.release, + ) + self.assertEqual(recovered.state, "rolled_back") + self.assertEqual( + (install / "app.exe").read_bytes(), + b"old executable", + ) + + def test_update_recovery_handles_current_pointer_publish_gap(self) -> None: + transaction: dict[str, str] = {} + + def crash(phase: str) -> None: + if phase == "after_current_publish": + journal = next( + _workspace_for(self.install).glob( + "transactions/*/journal.json" + ) + ) + transaction["id"] = journal.parent.name + raise HSRManagedUpdateCrash() + + with self.assertRaises(HSRManagedUpdateCrash): + self._updater(phase_hook=crash).execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + recovered = self._updater().recover( + install_root=self.install, + layout=self.layout, + transaction_id=transaction["id"], + release=self.release, + ) + self.assertEqual(recovered.state, "committed") + + def test_ordinary_error_after_current_publish_keeps_committed_install( + self, + ) -> None: + def fail_after_commit_point(phase: str) -> None: + if phase == "after_current_publish": + raise RuntimeError("post-CURRENT journal failure") + + result = self._updater(phase_hook=fail_after_commit_point).execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + + self.assertEqual(result.state, "committed") + self.assertTrue(result.success) + self.assertEqual((self.install / "app.exe").read_bytes(), b"new executable") + pointer = _current_for(self.install) + self.assertEqual(pointer["active_transaction"], result.transaction_id) + + recovered = self._updater().recover( + install_root=self.install, + layout=self.layout, + transaction_id=result.transaction_id, + release=self.release, + ) + self.assertEqual(recovered.state, "committed") + + def test_cache_publish_rename_gap_is_recoverable_without_switch(self) -> None: + transaction: dict[str, str] = {} + + def crash(phase: str) -> None: + if phase == "after_cache_publish_rename": + journal = next( + _workspace_for(self.install).glob( + "transactions/*/journal.json" + ) + ) + transaction["id"] = journal.parent.name + raise HSRManagedUpdateCrash() + + with self.assertRaises(HSRManagedUpdateCrash): + self._updater(phase_hook=crash).execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + recovered = self._updater().recover( + install_root=self.install, + layout=self.layout, + transaction_id=transaction["id"], + release=self.release, + ) + self.assertEqual(recovered.state, "recovered_aborted") + self.assertEqual((self.install / "app.exe").read_bytes(), b"old executable") + self.assertTrue( + (self.cache / f"{self.artifact.sha256}.zip").is_file() + ) + + def test_missing_current_after_commit_fails_closed(self) -> None: + committed = self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + current = _workspace_for(self.install) / "CURRENT.json" + current.replace(self.root / "detached-CURRENT.json") + + with self.assertRaises(HSRManagedUpdateError) as raised: + self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="2.0.0", + ) + self.assertEqual(raised.exception.code, "CURRENT_MISSING") + self.assertTrue( + Path(committed.journal_path).is_file(), + ) + + def test_lexical_install_and_workspace_reparse_points_are_rejected( + self, + ) -> None: + with self.assertRaises(HSRManagedUpdateError) as unsafe_name: + self._updater().execute( + install_root=self.root / "unsafe.", + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version=None, + ) + self.assertEqual(unsafe_name.exception.code, "UNSAFE_INSTALL_ROOT") + + original = managed_update_module._is_reparse_point + install_parent = self.install.parent + + def parent_reparse(path: Path) -> bool: + return Path(path) == install_parent or original(path) + + with patch.object( + managed_update_module, + "_is_reparse_point", + side_effect=parent_reparse, + ): + with self.assertRaises(HSRManagedUpdateError) as install_error: + self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + self.assertEqual(install_error.exception.code, "UNSAFE_INSTALL_ROOT") + + workspace = _workspace_for(self.install) + workspace.mkdir() + + def workspace_reparse(path: Path) -> bool: + return Path(path) == workspace or original(path) + + with patch.object( + managed_update_module, + "_is_reparse_point", + side_effect=workspace_reparse, + ): + with self.assertRaises(HSRManagedUpdateError) as workspace_error: + self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + self.assertEqual(workspace_error.exception.code, "UNSAFE_WORKSPACE") + + + def test_layout_rejects_required_preserved_overlap(self) -> None: + overlaps = ( + ("app.exe", "app.exe"), + ("config/app.exe", "config"), + ("config", "config/user/settings.json"), + ("Config/App.exe", "config"), + ) + for required, preserved in overlaps: + with self.subTest(required=required, preserved=preserved): + with self.assertRaisesRegex(ValueError, "不能重叠"): + HSRManagedUpdateLayout( + engine="SRA", + required_paths=(required,), + preserved_paths=( + HSRPreservationRule(preserved), + ), + ) + + def test_missing_current_rejects_schema1_history(self) -> None: + workspace = _workspace_for(self.install) + transaction = workspace / "transactions" / ("1" * 32) + transaction.mkdir(parents=True) + (transaction / "journal.json").write_text( + json.dumps( + { + "schema_version": 1, + "transaction_id": "1" * 32, + "phase": "failed", + "events": [], + } + ), + encoding="utf-8", + ) + + with self.assertRaises(HSRManagedUpdateError) as raised: + self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + self.assertEqual( + raised.exception.code, + "JOURNAL_MIGRATION_REQUIRED", + ) + + def test_missing_current_rejects_unknown_journal_schema(self) -> None: + workspace = _workspace_for(self.install) + transaction = workspace / "transactions" / ("2" * 32) + transaction.mkdir(parents=True) + (transaction / "journal.json").write_text( + json.dumps( + { + "schema_version": 999, + "transaction_id": "2" * 32, + "phase": "failed", + "events": [], + } + ), + encoding="utf-8", + ) + + with self.assertRaises(HSRManagedUpdateError) as raised: + self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + self.assertEqual(raised.exception.code, "JOURNAL_CORRUPTED") + + def test_missing_current_rejects_terminal_history_with_previous_pointer( + self, + ) -> None: + workspace = _workspace_for(self.install) + transaction = workspace / "transactions" / ("3" * 32) + transaction.mkdir(parents=True) + previous = { + "schema_version": 1, + "generation": 1, + "state": "managed", + "active_transaction": "a" * 32, + "engine": "SRA", + "release_id": 10, + "version": "1.0.0", + "install_root": str(self.install), + } + (transaction / "journal.json").write_text( + json.dumps( + { + "schema_version": 2, + "transaction_id": "3" * 32, + "install_root": str(self.install), + "phase": "failed", + "previous_current": previous, + "events": [], + } + ), + encoding="utf-8", + ) + + with self.assertRaises(HSRManagedUpdateError) as raised: + self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + self.assertEqual(raised.exception.code, "CURRENT_MISSING") + + def test_manual_rollback_recovery_rejects_tampered_pointer(self) -> None: + committed = self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + + def crash(phase: str) -> None: + if phase == "after_manual_current_move_rename": + raise HSRManagedUpdateCrash() + + with self.assertRaises(HSRManagedUpdateCrash): + self._updater(phase_hook=crash).rollback( + install_root=self.install, + layout=self.layout, + transaction_id=committed.transaction_id, + release=self.release, + ) + journal_path = Path(committed.journal_path) + journal = json.loads(journal_path.read_text(encoding="utf-8")) + journal["rollback_pointer"]["version"] = "tampered" + journal_path.write_text( + json.dumps(journal), + encoding="utf-8", + ) + + with self.assertRaises(HSRManagedUpdateError) as raised: + self._updater().recover( + install_root=self.install, + layout=self.layout, + transaction_id=committed.transaction_id, + release=self.release, + ) + self.assertEqual(raised.exception.code, "JOURNAL_CORRUPTED") + self.assertEqual( + _current_for(self.install)["active_transaction"], + committed.transaction_id, + ) + + def test_rollback_rejects_backup_reparse_point(self) -> None: + committed = self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + backup = Path(str(committed.backup_path)) + original = managed_update_module._is_reparse_point + + def backup_reparse(path: Path) -> bool: + return Path(path) == backup or original(path) + + with patch.object( + managed_update_module, + "_is_reparse_point", + side_effect=backup_reparse, + ): + with self.assertRaises(HSRManagedUpdateError) as raised: + self._updater().rollback( + install_root=self.install, + layout=self.layout, + transaction_id=committed.transaction_id, + release=self.release, + ) + self.assertEqual(raised.exception.code, "ROLLBACK_NOT_AVAILABLE") + self.assertEqual((self.install / "app.exe").read_bytes(), b"new executable") + + def test_rename_rejects_reparse_source(self) -> None: + source = self.root / "rename-source" + destination = self.root / "rename-destination" + source.mkdir() + original = managed_update_module._is_reparse_point + + def source_reparse(path: Path) -> bool: + return Path(path) == source or original(path) + + with patch.object( + managed_update_module, + "_is_reparse_point", + side_effect=source_reparse, + ): + with self.assertRaises(HSRManagedUpdateError) as raised: + managed_update_module._rename(source, destination) + self.assertEqual( + raised.exception.code, + "UNSAFE_TRANSACTION_SOURCE", + ) + self.assertTrue(source.is_dir()) + self.assertFalse(destination.exists()) + + def test_recovery_rejects_reparse_failed_candidate(self) -> None: + validator_calls = 0 + transaction: dict[str, str] = {} + + def validator( + _root: Path, + _release: HSRTrustedRelease, + ) -> None: + nonlocal validator_calls + validator_calls += 1 + if validator_calls == 2: + raise HSRManagedUpdateError( + "VALIDATION_FAILED", + "reject installed candidate", + ) + + def crash(phase: str) -> None: + if phase == "after_failure_isolate_rename": + journal = next( + _workspace_for(self.install).glob( + "transactions/*/journal.json" + ) + ) + transaction["id"] = journal.parent.name + raise HSRManagedUpdateCrash() + + with self.assertRaises(HSRManagedUpdateCrash): + self._updater( + validator=validator, + phase_hook=crash, + ).execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + failed_candidate = ( + _workspace_for(self.install) + / "transactions" + / transaction["id"] + / "failed-candidate" + ) + original = managed_update_module._is_reparse_point + + def failed_reparse(path: Path) -> bool: + return Path(path) == failed_candidate or original(path) + + with patch.object( + managed_update_module, + "_is_reparse_point", + side_effect=failed_reparse, + ): + with self.assertRaises(HSRManagedUpdateError) as raised: + self._updater().recover( + install_root=self.install, + layout=self.layout, + transaction_id=transaction["id"], + release=self.release, + ) + self.assertEqual( + raised.exception.code, + "TREE_IDENTITY_UNAVAILABLE", + ) + + def test_rollback_rejects_replaced_active_tree_identity(self) -> None: + committed = self._updater().execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + detached = self.root / "detached-active" + self.install.replace(detached) + self.install.mkdir() + (self.install / "app.exe").write_bytes(b"new executable") + (self.install / "VERSION").write_text("2.0.0\n", encoding="utf-8") + + with self.assertRaises(HSRManagedUpdateError) as raised: + self._updater().rollback( + install_root=self.install, + layout=self.layout, + transaction_id=committed.transaction_id, + release=self.release, + ) + self.assertEqual( + raised.exception.code, + "TRANSACTION_TREE_IDENTITY_MISMATCH", + ) + + def test_two_level_manual_rollback_preserves_generation_lineage( + self, + ) -> None: + updater = self._updater() + first = updater.execute( + install_root=self.install, + layout=self.layout, + release=self.release, + artifact=self.artifact, + current_version="1.0.0", + ) + _make_archive( + self.release_archive, + files={ + "app.exe": b"third executable", + "VERSION": "3.0.0\n", + }, + ) + third_release, third_artifact = _trusted_release( + self.release_archive, + version="3.0.0", + ) + second = updater.execute( + install_root=self.install, + layout=self.layout, + release=third_release, + artifact=third_artifact, + current_version="2.0.0", + ) + + second_rollback = updater.rollback( + install_root=self.install, + layout=self.layout, + transaction_id=second.transaction_id, + release=third_release, + ) + self.assertEqual(second_rollback.state, "rolled_back") + self.assertEqual(_current_for(self.install)["generation"], 3) + self.assertEqual( + _current_for(self.install)["active_transaction"], + first.transaction_id, + ) + recovered_first = updater.recover( + install_root=self.install, + layout=self.layout, + transaction_id=first.transaction_id, + release=self.release, + ) + self.assertEqual(recovered_first.state, "committed") + + first_rollback = updater.rollback( + install_root=self.install, + layout=self.layout, + transaction_id=first.transaction_id, + release=self.release, + ) + pointer = _current_for(self.install) + self.assertEqual(first_rollback.state, "rolled_back") + self.assertEqual(pointer["generation"], 4) + self.assertEqual(pointer["state"], "unmanaged") + self.assertEqual((self.install / "app.exe").read_bytes(), b"old executable") + + def test_os_transaction_lock_rejects_second_holder(self) -> None: + workspace = _workspace_for(self.install) + workspace.mkdir() + lock_path = workspace / "active.lock" + with managed_update_module._TransactionLock( + lock_path, + "a" * 32, + ): + with self.assertRaises(HSRManagedUpdateError) as raised: + with managed_update_module._TransactionLock( + lock_path, + "b" * 32, + ): + self.fail("second lock holder must not enter") + self.assertEqual(raised.exception.code, "LOCK_HELD") + + +class SafeArchiveTests(unittest.TestCase): + def test_rejects_traversal_and_windows_drive_paths(self) -> None: + for unsafe in ("../escape.txt", "C:/escape.txt", "/absolute.txt"): + with self.subTest(path=unsafe), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive = root / "unsafe.zip" + _make_archive( + archive, + files={}, + raw_entries=((unsafe, b"bad"),), + ) + with self.assertRaises(HSRManagedUpdateError): + safe_extract_hsr_archive(archive, root / "out") + self.assertFalse((root / "escape.txt").exists()) + + def test_rejects_windows_case_collision_and_reserved_names(self) -> None: + cases = ( + (("Config.yaml", b"a"), ("config.yaml", b"b")), + (("AUX.txt", b"a"),), + (("safe:name.txt", b"a"),), + ) + for entries in cases: + with ( + self.subTest(entries=entries), + tempfile.TemporaryDirectory() as directory, + ): + root = Path(directory) + archive = root / "unsafe.zip" + _make_archive( + archive, + files={}, + raw_entries=tuple(entries), + ) + with self.assertRaises(HSRManagedUpdateError): + safe_extract_hsr_archive(archive, root / "out") + + def test_rejects_symbolic_link_entries(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive = root / "symlink.zip" + link = zipfile.ZipInfo("link") + link.create_system = 3 + link.external_attr = (stat.S_IFLNK | 0o777) << 16 + _make_archive( + archive, + files={}, + raw_entries=((link, b"target"),), + ) + with self.assertRaisesRegex(HSRManagedUpdateError, "符号链接"): + safe_extract_hsr_archive(archive, root / "out") + + +class AdapterManagedLayoutTests(unittest.TestCase): + def test_sra_layout_replaces_script_without_copying_appdata(self) -> None: + self.assertEqual(SRA_MANAGED_LAYOUT.engine, "SRA") + self.assertIn("SRA-cli.exe", SRA_MANAGED_LAYOUT.required_paths) + self.assertEqual(SRA_MANAGED_LAYOUT.preserved_paths, ()) + + def test_m7a_layout_keeps_antitamper_pair_managed(self) -> None: + self.assertEqual(M7A_MANAGED_LAYOUT.engine, "M7A") + self.assertIn( + "assets/app/images/sponsor.jpg", + M7A_MANAGED_LAYOUT.required_paths, + ) + self.assertIn( + "module/config/__init__.py", + M7A_MANAGED_LAYOUT.required_paths, + ) + preserved = {rule.path for rule in M7A_MANAGED_LAYOUT.preserved_paths} + self.assertNotIn("assets/app/images/sponsor.jpg", preserved) + self.assertNotIn("tasks", preserved) + self.assertEqual( + preserved, + { + "config.yaml", + "config", + "warp.json", + "logs", + "screenshots", + "settings", + "smtp_temp.bin", + }, + ) + + def test_m7a_transaction_preserves_user_data_but_replaces_tasks( + self, + ) -> None: + with tempfile.TemporaryDirectory( + prefix="automas-hsr-m7a-preservation-" + ) as directory: + root = Path(directory) + install = root / "m7a" + cache = root / "cache" + archive = root / "m7a.zip" + install.mkdir() + old_files = { + "March7th Assistant.exe": b"old executable", + "assets/config/version.txt": "2026.1\n", + "assets/app/images/sponsor.jpg": b"old sponsor", + "module/config/__init__.py": b"old checker", + "config.yaml": "old config\n", + "config/account.json": '{"uid":"old"}\n', + "warp.json": '{"warp":"old"}\n', + "logs/run.log": "old log\n", + "screenshots/result.png": b"old screenshot", + "settings/preferences.json": '{"theme":"old"}\n', + "smtp_temp.bin": b"old smtp", + "tasks/owned.yaml": "old source task\n", + } + for relative, content in old_files.items(): + destination = install / relative + destination.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, bytes): + destination.write_bytes(content) + else: + destination.write_text(content, encoding="utf-8") + + _make_archive( + archive, + files={ + "March7th Assistant.exe": b"new executable", + "assets/config/version.txt": "2026.2\n", + "assets/app/images/sponsor.jpg": b"new sponsor", + "module/config/__init__.py": b"new checker", + "tasks/owned.yaml": "new source task\n", + }, + ) + release, artifact = _trusted_release( + archive, + version="2026.2", + engine="M7A", + ) + + updater = HSRManagedUpdater( + cache_root=cache, + download=lambda _artifact, destination: shutil.copy2( + archive, + destination, + ), + process_guard=lambda _root: False, + ) + result = updater.execute( + install_root=install, + layout=M7A_MANAGED_LAYOUT, + release=release, + artifact=artifact, + current_version="2026.1", + ) + + self.assertTrue(result.success) + for relative in ( + "config.yaml", + "config/account.json", + "warp.json", + "logs/run.log", + "screenshots/result.png", + "settings/preferences.json", + "smtp_temp.bin", + ): + expected = old_files[relative] + actual = install / relative + if isinstance(expected, bytes): + self.assertEqual(actual.read_bytes(), expected) + else: + self.assertEqual( + actual.read_text(encoding="utf-8"), + expected, + ) + self.assertEqual( + (install / "tasks/owned.yaml").read_text(encoding="utf-8"), + "new source task\n", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_package_metadata.py b/tests/test_package_metadata.py index e46d7c4..4bb0af3 100644 --- a/tests/test_package_metadata.py +++ b/tests/test_package_metadata.py @@ -9,27 +9,31 @@ PACKAGES = { "automas_hsr": { "name": "automas-hsr", + "version": "0.1.6", "entry_point": None, "dependencies": { - "automas-script-hsr>=0.1.0,<0.2.0", - "automas-hsr-adapter-sra>=0.1.0,<0.2.0", - "automas-hsr-adapter-m7a>=0.1.0,<0.2.0", + "automas-script-hsr>=0.1.5,<0.2.0", + "automas-hsr-adapter-sra>=0.1.5,<0.2.0", + "automas-hsr-adapter-m7a>=0.1.6,<0.2.0", }, }, "automas_script_hsr": { "name": "automas-script-hsr", + "version": "0.1.5", "entry_point": "automas_script_hsr.plugin:Plugin", "dependencies": {"jinja2>=3.1", "pydantic>=2"}, }, "automas_hsr_adapter_sra": { "name": "automas-hsr-adapter-sra", + "version": "0.1.5", "entry_point": "automas_hsr_adapter_sra.plugin:Plugin", - "dependencies": {"automas-script-hsr>=0.1.0,<0.2.0"}, + "dependencies": {"automas-script-hsr>=0.1.5,<0.2.0"}, }, "automas_hsr_adapter_m7a": { "name": "automas-hsr-adapter-m7a", + "version": "0.1.6", "entry_point": "automas_hsr_adapter_m7a.plugin:Plugin", - "dependencies": {"automas-script-hsr>=0.1.0,<0.2.0", "PyYAML>=6"}, + "dependencies": {"automas-script-hsr>=0.1.5,<0.2.0", "PyYAML>=6"}, }, } @@ -42,6 +46,7 @@ def test_package_metadata_and_entry_points(self) -> None: data = tomllib.loads(pyproject.read_text(encoding="utf-8")) project = data["project"] self.assertEqual(project["name"], expected["name"]) + self.assertEqual(project["version"], expected["version"]) if expected["entry_point"] is None: self.assertNotIn("entry-points", project) self.assertEqual(data["tool"]["setuptools"]["packages"], []) diff --git a/tests/test_runtime_contract.py b/tests/test_runtime_contract.py index 8fd7b38..48a7bed 100644 --- a/tests/test_runtime_contract.py +++ b/tests/test_runtime_contract.py @@ -95,8 +95,15 @@ def __init__(self) -> None: self.close_results: list[tuple[str, ...]] = [] self.unregister_calls = 0 - def register_group(self, *, owner, task_catalog, controller) -> None: - _ = task_catalog, controller + def register_group( + self, + *, + owner, + task_catalog, + controller, + update_provider=None, + ) -> None: + _ = task_catalog, controller, update_provider self.owner = owner async def close_owner_sessions(self, owner: str) -> tuple[str, ...]: diff --git a/tests/test_sra_cultivation_target.py b/tests/test_sra_cultivation_target.py new file mode 100644 index 0000000..e41f3fa --- /dev/null +++ b/tests/test_sra_cultivation_target.py @@ -0,0 +1,946 @@ +from __future__ import annotations + +import asyncio +import json +import sys +import tempfile +import types +import unittest +from datetime import timezone, timedelta +from pathlib import Path +from typing import Any +from unittest.mock import patch + +# --------------------------------------------------------------------------- +# Mock the AUTO-MAS host ``app`` package so that the SRA adapter and its +# import chain (automas_script_hsr.runtime.game, automas_script_hsr.schema) +# can be loaded without the full host runtime. The mock mirrors the one in +# test_cultivation_target_contract.py and additionally provides +# ``Config.config_path`` so that ``write_sra_temp_config`` can write to a +# real temp directory. +# --------------------------------------------------------------------------- + +from pydantic import Field as _PydField +from pydantic_core import PydanticUndefined + +_TEMP_DIR = tempfile.mkdtemp(prefix="sra_cultivation_test_") +_CONFIG_PATH = Path(_TEMP_DIR) / "config.json" + + +def _install_app_mocks() -> None: + already_mocked = ( + "app" in sys.modules + and getattr(sys.modules["app"], "__hsr_test_mock__", False) + ) + + if not already_mocked: + # -- app.utils.constants ------------------------------------------------ + _constants = types.ModuleType("app.utils.constants") + _constants.UTC4 = timezone(timedelta(hours=4)) + _constants.UTC8 = timezone(timedelta(hours=8)) + sys.modules["app.utils.constants"] = _constants + + # -- app.utils ---------------------------------------------------------- + _utils = types.ModuleType("app.utils") + + class _ProcessManager: # minimal stub + def __init__(self, *args, **kwargs) -> None: + pass + + class _MockLogger: + def info(self, msg: str) -> None: + pass + + def warning(self, msg: str) -> None: + pass + + def exception(self, msg: str) -> None: + pass + + def error(self, msg: str) -> None: + pass + + def debug(self, msg: str) -> None: + pass + + class _ProcessInfo: + def __init__(self, *args, **kwargs) -> None: + pass + + _utils.ProcessManager = _ProcessManager + _utils.ProcessInfo = _ProcessInfo + _utils.ProcessRunner = type("_ProcessRunner", (), {}) + _utils.ProcessResult = type("_ProcessResult", (), {}) + _utils.get_logger = lambda name=None: _MockLogger() + _utils.is_process_running = lambda name: False + _utils.UTC4 = _constants.UTC4 + _utils.UTC8 = _constants.UTC8 + _utils.decode_bytes = lambda raw: ( + raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else str(raw) + ) + sys.modules["app.utils"] = _utils + + # -- app.plugins.fields ------------------------------------------------- + _fields = types.ModuleType("app.plugins.fields") + + class _PluginFieldFactory: + _PASSTHROUGH = frozenset({ + "title", "description", "default_factory", + }) + + def __call__( + self, + default: Any = PydanticUndefined, + *, + min: int | float | None = None, + max: int | float | None = None, + **kwargs: Any, + ) -> Any: + field_kwargs: dict[str, Any] = {} + for key in self._PASSTHROUGH: + if key in kwargs: + field_kwargs[key] = kwargs.pop(key) + extra = dict(kwargs) + if min is not None: + extra["min"] = min + if max is not None: + extra["max"] = max + if extra: + field_kwargs["json_schema_extra"] = extra + if default is PydanticUndefined: + return _PydField(**field_kwargs) + return _PydField(default, **field_kwargs) + + _fields.PluginField = _PluginFieldFactory() + sys.modules["app.plugins.fields"] = _fields + + # -- app.plugins (parent package) --------------------------------------- + _plugins = types.ModuleType("app.plugins") + _plugins.fields = _fields + sys.modules["app.plugins"] = _plugins + + # -- app.core ----------------------------------------------------------- + _core = types.ModuleType("app.core") + + class _Config: + config_path = _CONFIG_PATH + + @staticmethod + async def send_websocket_message(**kwargs): + pass + + _core.Config = _Config + sys.modules["app.core"] = _core + + # -- app.models --------------------------------------------------------- + _models = types.ModuleType("app.models") + sys.modules["app.models"] = _models + + # -- app.models.ConfigBase ---------------------------------------------- + _config_base = types.ModuleType("app.models.ConfigBase") + + class _MultipleConfig(dict): + pass + + _config_base.MultipleConfig = _MultipleConfig + sys.modules["app.models.ConfigBase"] = _config_base + + # -- app.models.config -------------------------------------------------- + _config_model = types.ModuleType("app.models.config") + + class _Webhook: + pass + + _config_model.Webhook = _Webhook + sys.modules["app.models.config"] = _config_model + + # -- app.models.task ---------------------------------------------------- + _task = types.ModuleType("app.models.task") + + class _LogRecord: + def __init__(self, status: str = "") -> None: + self.status = status + self.content: list[str] = [] + + class _ScriptItem: + def __init__(self) -> None: + self.script_id = "" + self.task_info = None + self.log = "" + + class _TaskExecuteBase: + def __init__(self) -> None: + pass + + class _UserItem: + def __init__(self) -> None: + self.user_id = "" + self.name = "" + self.status = "" + self.result = "" + self.log_record: dict = {} + + _task.LogRecord = _LogRecord + _task.ScriptItem = _ScriptItem + _task.TaskExecuteBase = _TaskExecuteBase + _task.UserItem = _UserItem + sys.modules["app.models.task"] = _task + + # -- app.services ------------------------------------------------------- + _services = types.ModuleType("app.services") + + class _Notify: + pass + + _services.Notify = _Notify + sys.modules["app.services"] = _services + + # -- app.services.system ------------------------------------------------ + _system = types.ModuleType("app.services.system") + + class _System: + @staticmethod + async def kill_process(path: str) -> None: + pass + + _system.System = _System + sys.modules["app.services.system"] = _system + + # -- app (root) --------------------------------------------------------- + _app = types.ModuleType("app") + _app.__hsr_test_mock__ = True + _app.core = _core + _app.models = _models + _app.plugins = _plugins + _app.services = _services + _app.utils = _utils + sys.modules["app"] = _app + + # Always ensure Config.config_path is set (for write_sra_temp_config). + from app.core import Config # noqa: E402 + Config.config_path = _CONFIG_PATH + + # Always ensure decode_bytes is available on app.utils — runner.py imports + # it alongside ProcessManager/get_logger. If another test installed the + # mock first (without decode_bytes), patch it in here. + _utils_mod = sys.modules.get("app.utils") + if _utils_mod is not None and not hasattr(_utils_mod, "decode_bytes"): + _utils_mod.decode_bytes = lambda raw: ( # type: ignore[attr-defined] + raw.decode("utf-8", errors="replace") + if isinstance(raw, bytes) + else str(raw) + ) + + # Always replace get_logger with one that returns a logger with debug() + # (runner.py / game.py call logger.debug at module and function level). + if _utils_mod is not None: + + class _FullMockLogger: + def info(self, msg: str) -> None: + pass + + def warning(self, msg: str) -> None: + pass + + def exception(self, msg: str) -> None: + pass + + def error(self, msg: str) -> None: + pass + + def debug(self, msg: str) -> None: + pass + + _utils_mod.get_logger = lambda name=None: _FullMockLogger() # type: ignore[attr-defined] + + +_install_app_mocks() + +# --------------------------------------------------------------------------- +# Now safe to import the modules under test. +# --------------------------------------------------------------------------- +from automas_script_hsr.runtime.tasks import get_module +from automas_script_hsr.runtime.log_detect import has_failure_output + +from automas_hsr_adapter_sra.runner import ( + SRACommandResult, + build_sra_module_config, + build_sra_start_game_config, + run_sra_single_task, +) +from automas_hsr_adapter_sra.control import HSRSRAControl +from automas_script_hsr.contracts import HSRRunResult, HSRNativeRunPlan + +# If runner was already imported by another test module using the incomplete +# mock logger (without debug()), re-create its module-level logger now that +# get_logger has been replaced to return a full logger. +_runner_mod = sys.modules.get("automas_hsr_adapter_sra.runner") +if _runner_mod is not None and not hasattr(_runner_mod.logger, "debug"): + from app.utils import get_logger as _get_logger # noqa: E402 + _runner_mod.logger = _get_logger("HSR SRA 运行器") + + +# --------------------------------------------------------------------------- +# Fake configuration objects +# --------------------------------------------------------------------------- + + +class _ScriptConfig: + """支持 .get(section, key) 的轻量配置替身。""" + + def __init__(self, data: dict[str, dict[str, Any]] | None = None) -> None: + self._data = data or {} + + def get(self, section: str, key: str) -> Any: + return self._data.get(section, {}).get(key) + + +def _sra_stage_payload( + *, + stage_id: str = "calyx_golden", + level: int = 1, + label: str = "Test Stage", + category_label: str = "Test Category", +) -> dict[str, Any]: + """构造一个 SRA 原生副本字段载荷(engine=SRA)。""" + + return { + "engine": "SRA", + "label": label, + "categoryLabel": category_label, + "sra": {"id": stage_id, "level": level}, + } + + +def _sra_eow_payload( + *, + stage_id: str = "echo_of_war", + level: int = 1, + label: str = "Test EOW", + category_label: str = "EOW Category", +) -> dict[str, Any]: + return _sra_stage_payload( + stage_id=stage_id, + level=level, + label=label, + category_label=category_label, + ) + + +class _ConfiguredStageUserConfig(_ScriptConfig): + """已配置有效 SRA 原生主副本的用户配置替身。""" + + def __init__(self) -> None: + super().__init__( + { + "Stage": { + "Channel": "CalyxGolden", + "ScriptStage": { + "stages": { + "CalyxGolden": _sra_stage_payload(), + } + }, + "ScriptEchoOfWar": {}, + } + } + ) + + +class _ConfiguredEowUserConfig(_ScriptConfig): + """已配置有效 SRA 主副本和历战余响副本的用户配置替身。""" + + def __init__(self) -> None: + super().__init__( + { + "Stage": { + "Channel": "CalyxGolden", + "ScriptStage": { + "stages": { + "CalyxGolden": _sra_stage_payload(), + } + }, + "ScriptEchoOfWar": _sra_eow_payload(), + } + } + ) + + +class _EmptyStageUserConfig(_ScriptConfig): + """没有配置任何原生副本的用户配置替身。""" + + def __init__(self) -> None: + super().__init__( + { + "Stage": { + "Channel": "CalyxGolden", + "ScriptStage": {}, + "ScriptEchoOfWar": {}, + } + } + ) + + +class _FakeCipherItem: + """模拟 MAS 加密配置项,getValue(if_decrypt=False) 返回密文。""" + + def __init__(self, cipher_value: str) -> None: + self._cipher = cipher_value + + def getValue(self, if_decrypt: bool = True) -> str: + return self._cipher + + +class _CipherUserConfig(_ScriptConfig): + """带账号密码密文的用户配置替身(用于 build_sra_start_game_config)。""" + + def __init__(self) -> None: + super().__init__( + { + "Info": {"Name": "cipher-user"}, + "SRA": {"Id": "plain_id_value", "Password": "plain_pw_value"}, + } + ) + self._config_item_index = { + "SRA": { + "Id": _FakeCipherItem("cipher_id_SECRET_123"), + "Password": _FakeCipherItem("cipher_pw_SECRET_456"), + } + } + + +# --------------------------------------------------------------------------- +# build_sra_module_config tests +# --------------------------------------------------------------------------- + + +class BuildModuleConfigCultivationOnTests(unittest.TestCase): + """培养目标开关开启时的 build_sra_module_config 行为。""" + + def test_cultivation_on_sets_use_build_target_true_and_empty_tasklist(self) -> None: + script_config = _ScriptConfig({"CultivationTarget": {"Enabled": True}}) + module = get_module("Daily") + cfg = build_sra_module_config(module, script_config, _EmptyStageUserConfig()) + + tp = cfg["trailblazePower"] + self.assertTrue(tp["enabled"]) + self.assertTrue(tp["useBuildTarget"]) + self.assertEqual(tp["tasklist"], []) + self.assertFalse(tp["replenish.enabled"]) + + def test_cultivation_on_does_not_inject_manual_stage_even_when_configured( + self, + ) -> None: + """即使用户配置了 ScriptStage,培养目标模式仍清空 tasklist。""" + + script_config = _ScriptConfig({"CultivationTarget": {"Enabled": True}}) + module = get_module("Daily") + cfg = build_sra_module_config( + module, script_config, _ConfiguredStageUserConfig() + ) + + tp = cfg["trailblazePower"] + self.assertTrue(tp["useBuildTarget"]) + self.assertEqual(tp["tasklist"], []) + + def test_cultivation_on_with_eow_enabled_keeps_empty_tasklist(self) -> None: + """培养目标开启 + EOW 启用:tasklist 仍为空,不注入手动 EOW 项。""" + + script_config = _ScriptConfig({"CultivationTarget": {"Enabled": True}}) + module = get_module("Daily") + cfg = build_sra_module_config( + module, + script_config, + _ConfiguredEowUserConfig(), + daily_eow_enabled=True, + ) + + tp = cfg["trailblazePower"] + self.assertTrue(tp["useBuildTarget"]) + self.assertEqual(tp["tasklist"], []) + self.assertFalse(tp["replenish.enabled"]) + self.assertEqual(tp["replenish.way"], 0) + self.assertEqual(tp["replenish.times"], 0) + + +class BuildModuleConfigCultivationOffTests(unittest.TestCase): + """培养目标开关关闭时的 build_sra_module_config 行为(现有行为不变)。""" + + def test_cultivation_off_with_configured_stage_builds_manual_tasklist(self) -> None: + script_config = _ScriptConfig({"CultivationTarget": {"Enabled": False}}) + module = get_module("Daily") + cfg = build_sra_module_config( + module, script_config, _ConfiguredStageUserConfig() + ) + + tp = cfg["trailblazePower"] + self.assertTrue(tp["enabled"]) + self.assertFalse(tp["useBuildTarget"]) + tasklist = tp["tasklist"] + self.assertEqual(len(tasklist), 1) + item = tasklist[0] + self.assertEqual(item["id"], "calyx_golden") + self.assertEqual(item["level"], 1) + self.assertTrue(item["autoDetect"]) + + def test_cultivation_off_with_eow_enabled_includes_eow_item(self) -> None: + """培养目标关闭 + EOW 启用:tasklist 包含 EOW 项(现有行为)。""" + + script_config = _ScriptConfig({"CultivationTarget": {"Enabled": False}}) + module = get_module("Daily") + cfg = build_sra_module_config( + module, + script_config, + _ConfiguredEowUserConfig(), + daily_eow_enabled=True, + ) + + tp = cfg["trailblazePower"] + self.assertFalse(tp["useBuildTarget"]) + tasklist = tp["tasklist"] + self.assertEqual(len(tasklist), 2) + ids = {item["id"] for item in tasklist} + self.assertIn("echo_of_war", ids) + self.assertIn("calyx_golden", ids) + + def test_cultivation_off_with_empty_stage_produces_empty_tasklist(self) -> None: + script_config = _ScriptConfig({"CultivationTarget": {"Enabled": False}}) + module = get_module("Daily") + cfg = build_sra_module_config( + module, script_config, _EmptyStageUserConfig() + ) + + tp = cfg["trailblazePower"] + self.assertFalse(tp["useBuildTarget"]) + self.assertEqual(tp["tasklist"], []) + + def test_use_build_target_explicitly_false_to_avoid_dirty_state(self) -> None: + """关闭时显式重置 useBuildTarget=False,不残留上一轮脏状态。""" + + script_config = _ScriptConfig({"CultivationTarget": {"Enabled": False}}) + module = get_module("Daily") + cfg = build_sra_module_config( + module, script_config, _ConfiguredStageUserConfig() + ) + self.assertFalse(cfg["trailblazePower"]["useBuildTarget"]) + + +class BuildModuleConfigCultivationAbsentTests(unittest.TestCase): + """旧配置(无 CultivationTarget 段)完全兼容。""" + + def test_cultivation_absent_behaves_like_disabled(self) -> None: + script_config = _ScriptConfig({}) + module = get_module("Daily") + cfg = build_sra_module_config( + module, script_config, _ConfiguredStageUserConfig() + ) + + tp = cfg["trailblazePower"] + self.assertFalse(tp["useBuildTarget"]) + self.assertEqual(len(tp["tasklist"]), 1) + + def test_cultivation_absent_empty_stage_empty_tasklist(self) -> None: + script_config = _ScriptConfig({}) + module = get_module("Daily") + cfg = build_sra_module_config( + module, script_config, _EmptyStageUserConfig() + ) + + tp = cfg["trailblazePower"] + self.assertFalse(tp["useBuildTarget"]) + self.assertEqual(tp["tasklist"], []) + + +class BuildModuleConfigNonDailyTests(unittest.TestCase): + """非 Daily 模块不被培养目标开关污染。""" + + def test_receive_rewards_not_affected_by_cultivation(self) -> None: + script_config = _ScriptConfig({"CultivationTarget": {"Enabled": True}}) + module = get_module("ReceiveRewards") + cfg = build_sra_module_config(module, script_config, _EmptyStageUserConfig()) + + tp = cfg["trailblazePower"] + # useBuildTarget 保持默认 False,不被开关改为 True + self.assertFalse(tp["useBuildTarget"]) + self.assertFalse(tp["enabled"]) + # receiveRewards 正常启用 + self.assertTrue(cfg["receiveRewards"]["enabled"]) + + def test_divergent_universe_not_affected_by_cultivation(self) -> None: + script_config = _ScriptConfig({"CultivationTarget": {"Enabled": True}}) + module = get_module("DivergentUniverse") + cfg = build_sra_module_config(module, script_config, _EmptyStageUserConfig()) + + tp = cfg["trailblazePower"] + self.assertFalse(tp["useBuildTarget"]) + self.assertTrue(cfg["cosmicStrife"]["divergentUniverse.enabled"]) + + def test_currency_wars_not_affected_by_cultivation(self) -> None: + script_config = _ScriptConfig( + {"CultivationTarget": {"Enabled": True}, "SRA": {"Path": ""}} + ) + module = get_module("CurrencyWars") + cfg = build_sra_module_config( + module, script_config, _ScriptConfig({"Info": {"Name": "test"}}) + ) + + tp = cfg["trailblazePower"] + self.assertFalse(tp["useBuildTarget"]) + self.assertTrue(cfg["cosmicStrife"]["currencyWars.enabled"]) + + +class CultivationTargetFailureOutputTests(unittest.TestCase): + """SRA 可能以退出码 0 返回的空培养目标仍必须判定为失败。""" + + def test_no_build_target_is_failure(self) -> None: + self.assertTrue(has_failure_output("未找到任何培养目标任务")) + + def test_no_executable_task_is_failure(self) -> None: + self.assertTrue(has_failure_output("未识别到可执行任务")) + + def test_only_empty_ocr_is_failure(self) -> None: + self.assertTrue(has_failure_output("培养目标OCR识别为空,跳过当前目标")) + + def test_empty_ocr_with_another_recognized_target_is_not_failure(self) -> None: + output = "\n".join( + ( + "培养目标OCR识别为空,跳过当前目标", + "识别到所需物品: 信用点", + ) + ) + self.assertFalse(has_failure_output(output)) + + +# --------------------------------------------------------------------------- +# Sensitive field / repr tests +# --------------------------------------------------------------------------- + + +class SensitiveFieldReprTests(unittest.TestCase): + """敏感字段(username/password)不进入 SRACommandResult 或 HSRRunResult 的 repr。""" + + def test_start_game_config_contains_ciphers_but_result_repr_does_not(self) -> None: + script_config = _ScriptConfig({"Game": {"Path": "C:/Game"}}) + user_config = _CipherUserConfig() + + cfg = build_sra_start_game_config(script_config, user_config, mode="switch") + + # 配置字典中确实包含密文(写入文件供 SRA 读取) + self.assertEqual(cfg["startGame"]["username"], "cipher_id_SECRET_123") + self.assertEqual(cfg["startGame"]["password"], "cipher_pw_SECRET_456") + + # SRACommandResult 不持有 config 内容,repr 不应包含密文 + result = SRACommandResult( + task_class="StartGameTask", + config_path="/tmp/fake.json", + module_key="daily_StartGame", + success=True, + output="done", + ) + repr_text = repr(result) + self.assertNotIn("cipher_id_SECRET_123", repr_text) + self.assertNotIn("cipher_pw_SECRET_456", repr_text) + self.assertNotIn("SECRET", repr_text) + + def test_hsr_run_result_repr_hides_native_result(self) -> None: + """HSRRunResult 的 native_result=field(repr=False) 不暴露原始结果对象。""" + + native = SRACommandResult( + task_class="StartGameTask", + config_path="/tmp/fake.json", + success=True, + output="done", + ) + run_result = HSRRunResult.from_native( + native, + default_summary="fallback", + default_error="failed", + ) + repr_text = repr(run_result) + # native_result 被 repr=False 隐藏:SRACommandResult 对象不出现在 repr 中 + self.assertNotIn("SRACommandResult", repr_text) + self.assertNotIn("native_result", repr_text) + # summary 来自 output(正常日志),但 native_result 本身被隐藏 + self.assertIn("done", repr_text) + + def test_start_game_remembered_mode_has_no_ciphers(self) -> None: + script_config = _ScriptConfig({"Game": {"Path": "C:/Game"}}) + user_config = _CipherUserConfig() + + cfg = build_sra_start_game_config( + script_config, user_config, mode="remembered" + ) + + # 基础配置始终包含 username/password 键,但 remembered 模式 + # 不写入密文,保持为空字符串 + self.assertEqual(cfg["startGame"]["username"], "") + self.assertEqual(cfg["startGame"]["password"], "") + self.assertNotIn("cipher_id_SECRET_123", json.dumps(cfg)) + self.assertNotIn("cipher_pw_SECRET_456", json.dumps(cfg)) + self.assertFalse(cfg["startGame"]["relogin"]) + self.assertFalse(cfg["startGame"]["autologin"]) + + +# --------------------------------------------------------------------------- +# create_module_item tests +# --------------------------------------------------------------------------- + + +def _make_control(script_config: _ScriptConfig) -> tuple[HSRSRAControl, list[str]]: + """构造 HSRSRAControl 替身,返回 (control, log_lines)。""" + + logs: list[str] = [] + + control = HSRSRAControl( + script_config=script_config, + account_switcher=None, # create_module_item 不使用 account_switcher + process_registry=None, + append_log=logs.append, + ) + return control, logs + + +class CreateModuleItemCultivationTests(unittest.TestCase): + """create_module_item 在培养目标开关下的跳过逻辑。""" + + def test_cultivation_on_empty_tasklist_does_not_return_none(self) -> None: + """开关开 + tasklist 空:不返回 None,创建执行项。""" + + script_config = _ScriptConfig( + {"CultivationTarget": {"Enabled": True}, "Game": {"Path": "C:/Game"}} + ) + control, logs = _make_control(script_config) + module = get_module("Daily") + + item = control.create_module_item( + user_cfg=_EmptyStageUserConfig(), + user_name="test-user", + uid="uid-1", + module=module, + timeout_seconds=600, + sra_exe_path=Path("/fake/SRA-cli.exe"), + script_id="script-1", + temp_files=[], + daily_eow_enabled=False, + ) + + self.assertIsNotNone(item) + self.assertIsInstance(item, HSRNativeRunPlan) + self.assertTrue( + any("培养目标已启用" in line for line in logs), + f"expected cultivation log, got: {logs}", + ) + + def test_cultivation_off_empty_tasklist_returns_none(self) -> None: + """开关关 + tasklist 空:返回 None(现有行为不变)。""" + + script_config = _ScriptConfig( + {"CultivationTarget": {"Enabled": False}, "Game": {"Path": "C:/Game"}} + ) + control, logs = _make_control(script_config) + module = get_module("Daily") + + item = control.create_module_item( + user_cfg=_EmptyStageUserConfig(), + user_name="test-user", + uid="uid-1", + module=module, + timeout_seconds=600, + sra_exe_path=Path("/fake/SRA-cli.exe"), + script_id="script-1", + temp_files=[], + daily_eow_enabled=False, + ) + + self.assertIsNone(item) + self.assertTrue( + any("无可执行副本" in line for line in logs), + f"expected skip log, got: {logs}", + ) + + def test_cultivation_off_with_stage_does_not_skip(self) -> None: + """开关关 + 有配置副本:不跳过,创建执行项。""" + + script_config = _ScriptConfig( + {"CultivationTarget": {"Enabled": False}, "Game": {"Path": "C:/Game"}} + ) + control, _ = _make_control(script_config) + module = get_module("Daily") + + item = control.create_module_item( + user_cfg=_ConfiguredStageUserConfig(), + user_name="test-user", + uid="uid-1", + module=module, + timeout_seconds=600, + sra_exe_path=Path("/fake/SRA-cli.exe"), + script_id="script-1", + temp_files=[], + daily_eow_enabled=False, + ) + + self.assertIsNotNone(item) + + def test_cultivation_on_with_stage_still_uses_build_target(self) -> None: + """开关开 + 有配置副本:仍设 useBuildTarget=True,tasklist 为空。""" + + script_config = _ScriptConfig( + {"CultivationTarget": {"Enabled": True}, "Game": {"Path": "C:/Game"}} + ) + control, _ = _make_control(script_config) + module = get_module("Daily") + + temp_files: list[Path] = [] + item = control.create_module_item( + user_cfg=_ConfiguredStageUserConfig(), + user_name="test-user", + uid="uid-1", + module=module, + timeout_seconds=600, + sra_exe_path=Path("/fake/SRA-cli.exe"), + script_id="script-1", + temp_files=temp_files, + daily_eow_enabled=False, + ) + + self.assertIsNotNone(item) + # 验证写入的临时配置确实用了 useBuildTarget=True + self.assertEqual(len(temp_files), 1) + written = json.loads(temp_files[0].read_text(encoding="utf-8")) + self.assertTrue(written["trailblazePower"]["useBuildTarget"]) + self.assertEqual(written["trailblazePower"]["tasklist"], []) + + +# --------------------------------------------------------------------------- +# run_sra_single_task error path tests +# --------------------------------------------------------------------------- + + +class _FakeProcessRegistry: + """模拟 SRAProcessRegistry,不真正启动子进程。""" + + def __init__(self) -> None: + self.terminated = False + self.cleared = False + self._proc = None + + async def open_process(self, *args, **kwargs): + return self._proc + + async def clear(self) -> None: + self.cleared = True + + async def terminate_current_process(self) -> bool: + self.terminated = True + return True + + +class RunSraSingleTaskErrorTests(unittest.IsolatedAsyncioTestCase): + """run_sra_single_task 的错误路径不错误标记完成。""" + + async def test_exe_not_found_returns_failure(self) -> None: + result = await run_sra_single_task( + sra_exe_path=Path("/nonexistent/SRA-cli.exe"), + task_class="TrailblazePowerTask", + config_path=Path("/tmp/fake.json"), + module_key="Daily", + ) + + self.assertFalse(result.success) + self.assertIn("does not exist", result.error) + + async def test_timeout_returns_failure(self) -> None: + """TimeoutError 路径返回 success=False,不标记完成。""" + + # 需要一个存在的 exe 路径才能通过 exe 检查 + with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as tmp_exe: + exe_path = Path(tmp_exe.name) + + try: + registry = _FakeProcessRegistry() + + async def _fake_communicate(*args, **kwargs): + raise asyncio.TimeoutError() + + with patch( + "automas_hsr_adapter_sra.runner._communicate_sra_with_live_output", + _fake_communicate, + ): + result = await run_sra_single_task( + sra_exe_path=exe_path, + task_class="TrailblazePowerTask", + config_path=Path("/tmp/fake.json"), + timeout=1, + process_registry=registry, + module_key="Daily", + ) + + self.assertFalse(result.success) + self.assertIn("timeout", result.error) + self.assertTrue(registry.terminated) + self.assertTrue(registry.cleared) + finally: + exe_path.unlink(missing_ok=True) + + async def test_general_exception_returns_failure(self) -> None: + """非超时异常路径返回 success=False。""" + + with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as tmp_exe: + exe_path = Path(tmp_exe.name) + + try: + registry = _FakeProcessRegistry() + + async def _fake_communicate(*args, **kwargs): + raise RuntimeError("simulated crash") + + with patch( + "automas_hsr_adapter_sra.runner._communicate_sra_with_live_output", + _fake_communicate, + ): + result = await run_sra_single_task( + sra_exe_path=exe_path, + task_class="TrailblazePowerTask", + config_path=Path("/tmp/fake.json"), + timeout=1, + process_registry=registry, + module_key="Daily", + ) + + self.assertFalse(result.success) + self.assertIn("simulated crash", result.error) + finally: + exe_path.unlink(missing_ok=True) + + async def test_cancelled_error_propagates(self) -> None: + """CancelledError 被重新抛出,不被吞掉或标记完成。""" + + with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as tmp_exe: + exe_path = Path(tmp_exe.name) + + try: + registry = _FakeProcessRegistry() + + async def _fake_communicate(*args, **kwargs): + raise asyncio.CancelledError() + + with patch( + "automas_hsr_adapter_sra.runner._communicate_sra_with_live_output", + _fake_communicate, + ): + with self.assertRaises(asyncio.CancelledError): + await run_sra_single_task( + sra_exe_path=exe_path, + task_class="TrailblazePowerTask", + config_path=Path("/tmp/fake.json"), + timeout=1, + process_registry=registry, + module_key="Daily", + ) + + self.assertTrue(registry.terminated) + finally: + exe_path.unlink(missing_ok=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_update_contract.py b/tests/test_update_contract.py new file mode 100644 index 0000000..cc16a4b --- /dev/null +++ b/tests/test_update_contract.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from automas_hsr_adapter_m7a.installation import ( + M7A_EXECUTABLE, + M7A_TRUSTED_RELEASE, + M7A_VERSION_PATH, + M7AUpdateProvider, +) +from automas_hsr_adapter_sra.catalog import SRATaskCatalog +from automas_hsr_adapter_sra.installation import ( + SRA_EXECUTABLE, + SRA_TRUSTED_RELEASE, + SRAUpdateProvider, +) +from automas_script_hsr import ( + HSRRegistryService, + HSRTrustedArtifact, + HSRTrustedRelease, + HSRUpdateDescriptor, +) + + +class _FakeSRAController: + descriptor = SRATaskCatalog.descriptor + + def probe(self, script_config: object) -> tuple[bool, str]: + _ = script_config + return True, "" + + def lock_paths(self, script_config: object) -> tuple[str, ...]: + _ = script_config + return () + + async def open_session(self, **kwargs: object) -> object: + _ = kwargs + return object() + + +class UpdatePolicyTests(unittest.TestCase): + def test_managed_mode_fails_closed_without_trusted_asset_metadata(self) -> None: + with self.assertRaisesRegex( + ValueError, + "可信资产元数据", + ): + HSRUpdateDescriptor( + engine="SRA", + repository_url="https://example.invalid/repository", + managed_enabled=True, + trusted_asset_metadata=False, + ) + + def test_real_adapters_pin_digest_but_keep_managed_fail_closed(self) -> None: + for provider in (SRAUpdateProvider(), M7AUpdateProvider()): + with self.subTest(engine=provider.descriptor.engine): + descriptor = provider.descriptor + self.assertEqual(descriptor.default_mode, "external") + self.assertFalse(descriptor.release_check_enabled) + self.assertFalse(descriptor.managed_enabled) + self.assertTrue(descriptor.trusted_asset_metadata) + self.assertTrue(descriptor.trusted_releases) + self.assertTrue(descriptor.managed_block_reason) + + def test_pinned_release_assets_match_observed_github_digest(self) -> None: + expected = { + "SRA": ( + SRA_TRUSTED_RELEASE, + 483523868, + 174872014, + "4ee5a9c407e418c0c5fc5b0b6fbba80" + "c7c0c28ea0e0051ea31e7eddd85e4dd86", + ), + "M7A": ( + M7A_TRUSTED_RELEASE, + 441830058, + 747724694, + "6bb1f9a599f94603cddfbc01e0cdd7d4" + "705ba19516027c950bf8067627fcabd9", + ), + } + for engine, ( + release, + asset_id, + size, + sha256, + ) in expected.items(): + with self.subTest(engine=engine): + self.assertEqual(release.engine, engine) + self.assertFalse(release.upstream_immutable) + self.assertEqual(len(release.artifacts), 1) + artifact = release.artifacts[0] + self.assertEqual(artifact.asset_id, asset_id) + self.assertEqual(artifact.size, size) + self.assertEqual(artifact.sha256, sha256) + + def test_invalid_pinned_digest_fails_closed(self) -> None: + with self.assertRaisesRegex(ValueError, "SHA-256"): + HSRTrustedArtifact( + asset_id=1, + name="package.zip", + download_url="https://example.invalid/package.zip", + size=1, + sha256="not-a-digest", + ) + + def test_descriptor_rejects_release_for_other_engine(self) -> None: + artifact = HSRTrustedArtifact( + asset_id=1, + name="package.zip", + download_url="https://example.invalid/package.zip", + size=1, + sha256="a" * 64, + ) + release = HSRTrustedRelease( + engine="M7A", + release_id=1, + tag="v1", + version="1", + channel="stable", + published_at="2026-01-01T00:00:00Z", + upstream_immutable=False, + artifacts=(artifact,), + ) + with self.assertRaisesRegex(ValueError, "引擎不一致"): + HSRUpdateDescriptor( + engine="SRA", + repository_url="https://example.invalid/repository", + trusted_asset_metadata=True, + trusted_releases=(release,), + managed_block_reason="test", + ) + + +class SRAInstallationTests(unittest.TestCase): + def test_missing_configuration_is_not_installed(self) -> None: + snapshot = SRAUpdateProvider().inspect({"SRA": {"Path": ""}}) + + self.assertFalse(snapshot.installed) + self.assertFalse(snapshot.ready) + self.assertEqual(snapshot.root_path, "") + self.assertIsNone(snapshot.version) + + def test_null_path_is_treated_as_missing_configuration(self) -> None: + snapshot = SRAUpdateProvider().inspect({"SRA": {"Path": None}}) + + self.assertFalse(snapshot.installed) + self.assertFalse(snapshot.ready) + self.assertEqual(snapshot.root_path, "") + self.assertEqual(snapshot.executable_path, "") + + def test_pe_version_is_reported_separately_from_adapter_version(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + executable = root / SRA_EXECUTABLE + executable.write_bytes(b"not-a-real-pe") + + with patch( + "automas_hsr_adapter_sra.installation._read_windows_file_version", + return_value="2.17.0.0", + ): + snapshot = SRAUpdateProvider().inspect( + {"SRA": {"Path": str(root)}} + ) + + self.assertTrue(snapshot.installed) + self.assertTrue(snapshot.ready) + self.assertEqual(snapshot.version, "2.17.0.0") + self.assertEqual(snapshot.version_source, "pe.fixed_file_info") + self.assertEqual(snapshot.executable_path, str(executable)) + + +class M7AInstallationTests(unittest.TestCase): + def test_null_path_is_treated_as_missing_configuration(self) -> None: + snapshot = M7AUpdateProvider().inspect({"M7A": {"Path": None}}) + + self.assertFalse(snapshot.installed) + self.assertFalse(snapshot.ready) + self.assertEqual(snapshot.root_path, "") + self.assertEqual(snapshot.executable_path, "") + + def test_version_uses_assets_config_version_file(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + executable = root / M7A_EXECUTABLE + version_path = root / M7A_VERSION_PATH + executable.write_bytes(b"placeholder") + version_path.parent.mkdir(parents=True) + version_path.write_text("v2026.6.8\n", encoding="utf-8") + + snapshot = M7AUpdateProvider().inspect( + {"M7A": {"Path": str(root)}} + ) + + self.assertTrue(snapshot.installed) + self.assertTrue(snapshot.ready) + self.assertEqual(snapshot.version, "v2026.6.8") + self.assertEqual( + snapshot.version_source, + "assets/config/version.txt", + ) + + def test_missing_version_does_not_make_executable_unusable(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / M7A_EXECUTABLE).write_bytes(b"placeholder") + + snapshot = M7AUpdateProvider().inspect( + {"M7A": {"Path": str(root)}} + ) + + self.assertTrue(snapshot.installed) + self.assertTrue(snapshot.ready) + self.assertIsNone(snapshot.version) + self.assertIn("无法读取版本文件", snapshot.message) + + +class RegistryUpdateSnapshotTests(unittest.TestCase): + def test_registry_exposes_policy_and_read_only_installation_snapshot( + self, + ) -> None: + registry = HSRRegistryService() + provider = SRAUpdateProvider() + registry.register_group( + owner="test:sra", + task_catalog=SRATaskCatalog(), + controller=_FakeSRAController(), + update_provider=provider, + ) + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / SRA_EXECUTABLE).write_bytes(b"not-a-real-pe") + with patch( + "automas_hsr_adapter_sra.installation._read_windows_file_version", + return_value="2.17.0.0", + ): + snapshot = registry.snapshot( + script_config={"SRA": {"Path": str(root)}} + ) + + adapter = snapshot.adapters[0] + self.assertEqual(adapter["engine"], "SRA") + self.assertEqual( + adapter["update"]["policy"]["default_mode"], + "external", + ) + self.assertFalse( + adapter["update"]["policy"]["managed_enabled"], + ) + trusted_release = adapter["update"]["policy"]["trusted_releases"][0] + self.assertEqual(trusted_release["tag"], "v2.18.3") + self.assertEqual( + trusted_release["artifacts"][0]["asset_id"], + 483523868, + ) + self.assertEqual( + adapter["update"]["installation"]["version"], + "2.17.0.0", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_wheel_smoke.py b/tests/test_wheel_smoke.py index bae28aa..798a639 100644 --- a/tests/test_wheel_smoke.py +++ b/tests/test_wheel_smoke.py @@ -4,7 +4,9 @@ import unittest import zipfile from pathlib import Path +from unittest.mock import patch +from scripts.build_all import build_environment from scripts.smoke_wheels import ( ADAPTER_DISTRIBUTIONS, CORE_DISTRIBUTION, @@ -16,6 +18,30 @@ class WheelSmokeContractTests(unittest.TestCase): + def test_build_environment_preserves_explicit_reproducible_epoch(self) -> None: + with ( + patch.dict( + "scripts.build_all.os.environ", + {"SOURCE_DATE_EPOCH": "1700000000"}, + clear=True, + ), + patch("scripts.build_all.subprocess.run") as git, + ): + environment = build_environment() + + self.assertEqual(environment["SOURCE_DATE_EPOCH"], "1700000000") + self.assertEqual(environment["PYTHONHASHSEED"], "0") + git.assert_not_called() + + def test_build_environment_rejects_invalid_explicit_epoch(self) -> None: + with patch.dict( + "scripts.build_all.os.environ", + {"SOURCE_DATE_EPOCH": "not-an-integer"}, + clear=True, + ): + with self.assertRaisesRegex(ValueError, "must be an integer"): + build_environment() + def test_workspace_dependencies_are_excluded_from_external_wheelhouse(self) -> None: requirements = _external_requirements( [ @@ -73,6 +99,8 @@ def test_ci_and_publish_use_distinct_smoke_modes(self) -> None: self.assertIn("--mode local-adapter-resolution", ci) self.assertIn("--mode local-meta-resolution", ci) self.assertIn("--mode metadata-only", publish) + self.assertIn("SOURCE_DATE_EPOCH", ci) + self.assertIn("SOURCE_DATE_EPOCH", publish) self.assertEqual( ADAPTER_DISTRIBUTIONS, ("automas-hsr-adapter-sra", "automas-hsr-adapter-m7a"), From 2111846880935bcf31620d9e79c87042f75c0484 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Sun, 26 Jul 2026 21:44:01 +0800 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E8=87=B3=200.1.5/0.1.6=20=E5=B9=B6=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=9E=84=E5=BB=BA=E4=B8=8E=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - automas-script-hsr/adapter-sra 升至 0.1.5,adapter-m7a 与元包升至 0.1.6 - CI/publish 工作流加入 SOURCE_DATE_EPOCH 可复现构建 - 新增 CHANGELOG,扩充 README,build_all 支持锁定发布产物 Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 2 + .github/workflows/publish.yml | 2 + CHANGELOG.md | 134 ++++++++++++++++++ README.md | 83 +++++++++++ packages/automas_hsr/pyproject.toml | 8 +- .../automas_hsr_adapter_m7a/pyproject.toml | 4 +- .../automas_hsr_adapter_sra/pyproject.toml | 4 +- packages/automas_script_hsr/pyproject.toml | 4 +- scripts/build_all.py | 53 ++++++- uv.lock | 10 +- 10 files changed, 289 insertions(+), 15 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36380b1..3eb3d41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - name: Set reproducible build epoch + run: echo "SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD)" >> "$GITHUB_ENV" - uses: astral-sh/setup-uv@v6 with: python-version: "3.12" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0b85f09..a7ca0cf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -42,6 +42,8 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 + - name: Set reproducible build epoch + run: echo "SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD)" >> "$GITHUB_ENV" - uses: astral-sh/setup-uv@v6 with: python-version: "3.12" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5d0f38d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,134 @@ +# Changelog + +## 0.1.5 — 托管更新事务内核(默认仍关闭) + +- 新增 HSR 插件自有的事务更新内核:固定 release/asset 身份、大小与 + SHA-256 校验,安全 ZIP 解压、同卷 staging、跨进程锁、journal、崩溃 + 恢复及保留上一版本的手动回滚。 +- 新增 SRA/M7A Managed 布局声明。SRA 安装目录整体由发布包管理且不触碰 + `%APPDATA%\SRA`;M7A 仅迁移 `config.yaml`、用户 YAML、日志和截图, + `sponsor.jpg` 与其检查器始终作为同一 release 成对更新。 +- 增加 17 项 deterministic 事务/安全测试;真实下载、真实进程占用和真实 + Windows 安装仍未手测,因此 `managed_enabled` 继续 fail-closed。 +- `automas-script-hsr` 升级到 `0.1.4`; + `automas-hsr-adapter-sra` 升级到 `0.1.4`; + `automas-hsr-adapter-m7a` 与一键安装元包升级到 `0.1.5`。 +- SRA/M7A 能力描述版本更新为已固定 release 的 `2.18.3` / `2026.6.8`。 + +## 0.1.4 — 外部安装版本与更新策略契约 + +### 新增 + +- HSR 核心新增稳定的安装快照与更新策略 DTO;适配器版本和用户安装的脚本版本 + 不再混为同一字段。 +- SRA 适配器通过 `SRA-cli.exe` 的 PE fixed-file metadata 只读探测已安装版本, + 不增加 pywin32 依赖。 +- M7A 适配器从上游真实路径 `assets/config/version.txt` 只读探测版本。 +- 能力快照向已认证前端暴露各引擎的 update policy 与 installation snapshot。 +- 固定 SRA v2.18.3 与 M7A v2026.6.8 的 GitHub release ID、asset ID、文件大小 + 和 GitHub 计算的 SHA-256 digest,供后续更新器严格比对。 + +### 安全边界 + +- 默认模式为 External;Custom-fork 仅只读探测。 +- Managed 与网络 release check 仍 fail closed。固定清单随 AUTO-MAS 插件版本发布, + 可检测上游可变 release 的资产漂移;事务安装、配置迁移和回滚接通前不得因为已有 + digest 就宣称 Managed 可用。 + +### 发行 + +- `automas-script-hsr` 升级到 `0.1.3`。 +- `automas-hsr-adapter-sra` 升级到 `0.1.3`,要求核心 `>=0.1.3`。 +- `automas-hsr-adapter-m7a` 升级到 `0.1.4`,要求核心 `>=0.1.3`。 +- 一键安装元包 `automas-hsr` 升级到 `0.1.4` 并锁定上述最低兼容版本。 + +## 0.1.3 — 培养目标执行闭环修复 + +### 修复 + +- M7A 在启用培养目标且未配置手动主关卡时,仍开启 `power_enable` 进入原生 + Power 流程,并用 `侵蚀隧洞=无` 覆盖旧的手动副本选择,避免静默跳过或误刷 + 上一轮残留关卡。 +- 移除“清体力未开启,跳过历战余响和清体力”这一伪成功标记;该输出不再能让 + Daily 被错误判定为成功。 +- SRA 培养目标模式明确由 SRA 原生逻辑接管历战余响是否进入执行计划。MAS + 不再因缺少手动 EOW 关卡记录跳过,而是始终监测真实日志,仅在确认完成后回写。 +- SRA 输出“未找到任何培养目标任务”“未识别到可执行任务”,或全部目标 OCR + 均为空时,即使进程退出码为 0 也按失败处理;部分 OCR 为空但至少一个目标识别 + 成功时不误判。 +- `M7AOrnamentWeeklyCount` 的 0..7 限制改为 Pydantic `Annotated` 运行时 + 约束;`PluginField(min/max)` 仅保留为宿主 UI 元数据,测试替身与真实宿主一致。 + +### 发行 + +- `automas-script-hsr` 升级到 `0.1.2`。 +- `automas-hsr-adapter-sra` 升级到 `0.1.2`,要求核心 `>=0.1.2`。 +- `automas-hsr-adapter-m7a` 升级到 `0.1.3`,要求核心 `>=0.1.2`。 +- 一键安装元包 `automas-hsr` 升级到 `0.1.3` 并锁定上述最低兼容版本。 + +## 0.1.2 — M7A 非 Daily 超时传递修复 + +### 修复 + +- `ReceiveRewards`、`DivergentUniverse` 与 `CurrencyWars` 创建原生运行计划时, + 将脚本级 `timeout_seconds` 显式传入共用的 patch 运行闭包,避免计划创建阶段 + 因未知关键字参数抛出 `TypeError`。 +- 新增三个非 Daily 模块的运行计划回归测试,验证计划可执行并将超时值原样传递 + 给 M7A runner。 + +### 发行 + +- `automas-hsr-adapter-m7a` 升级到 `0.1.2`。 +- 一键安装元包 `automas-hsr` 升级到 `0.1.2`,并要求 + `automas-hsr-adapter-m7a>=0.1.2,<0.2.0`。 +- `automas-script-hsr` 与 `automas-hsr-adapter-sra` 保持 `0.1.1`。 + +## 0.1.1 — 培养目标双引擎支持 + +### 新增 + +- 公共配置 `HSRConfig.CultivationTarget`,包含四个引擎无关字段: + - `Enabled: bool = False` + - `M7ARecognitionScheme: Literal["instance","drop"] = "instance"` + - `M7AOrnamentWeeklyCount: int = 1`(0..7) + - `M7AUseUserStageWhenOnlyRelics: bool = False` +- SRA 适配器:Daily 模块开启培养目标时写入 + `trailblazePower.useBuildTarget=true`、`tasklist=[]`,由 SRA 原生识别流程 + 决定副本;关闭时显式重置 `useBuildTarget=false`,保持手动副本行为。 + 目标模式下 `tasklist` 为空不再被判为「无可执行副本」而跳过 Daily。 +- M7A 适配器:Daily 模块开启培养目标时 MAS 完全接管四个 M7A 原生键 + (`build_target_enable` / `build_target_scheme` / + `build_target_ornament_weekly_count` / + `build_target_use_user_instance_when_only_erosion_and_ornament`); + 关闭时显式写入 `build_target_enable=false`。 + 非法 scheme、越界次数、顶层非对象、写盘失败均 fail closed。 +- 公共编排层 `_resolve_daily_runnable_parts`:培养目标启用时即使 + `main_configured` 为 false 也不跳过 Daily。 +- 测试:新增 `test_cultivation_target_contract.py`(21 例)、 + `test_sra_cultivation_target.py`(23 例)、 + `test_m7a_cultivation_target.py`(45 例)。 + +### 变更 + +- 四个 distribution 版本从 `0.1.0` 升至 `0.1.1`。 +- 内部依赖下限从 `>=0.1.0,<0.2.0` 升至 `>=0.1.1,<0.2.0`。 +- `M7A_DAILY_PATCH_WHITELIST` 新增三个键:`build_target_scheme`、 + `build_target_ornament_weekly_count`、 + `build_target_use_user_instance_when_only_erosion_and_ornament`。 +- `build_m7a_daily_patch` 新增 `cultivation_target` 参数。 + +### 兼容性 + +- 默认关闭,旧配置无需迁移。 +- 关闭时 SRA/M7A 适配器严格保持 0.1.0 的手动副本/EOW 行为。 +- 开关切换时显式重置上一轮脏状态,不跨轮残留。 +- 失败、超时、取消不产生伪完成状态或半写配置。 + +### 参考版本 + +- StarRailAssistant v2.16.1 +- March7thAssistant v2026.4.27 + +## 0.1.0 — 首次发布 + +四个 distribution 的初始版本。 diff --git a/README.md b/README.md index 457ac89..43c9267 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,89 @@ AUTO-MAS 的崩坏:星穹铁道插件工作区,也是 HSR 插件及其引擎 每个目录独立构建为一个 Python distribution。`automas-hsr` 不包含运行时代码或 插件 entry point;适配器依赖 `automas-script-hsr`,适配器之间互不依赖。 +## External inspection and managed transaction foundation (0.1.5+) + +- 能力快照中的适配器 `version` 表示该适配器验证过的上游基线,不代表用户当前 + 安装的脚本版本。 +- SRA 安装版本从 `SRA-cli.exe` 的 PE fixed-file metadata 读取;M7A 从 + `assets/config/version.txt` 读取。探测过程不启动脚本、游戏或 Agent。 +- 默认模式是 External,只读展示路径、可用性和本地版本;源码 fork 可使用 + Custom-fork 语义,MAS 不修改其目录。 +- AUTO-MAS 插件版本固定了经 GitHub Release API 观察到的 release ID、asset ID、 + 文件大小与 SHA-256 digest;上游 release 可变更,因此运行时必须与固定清单逐项 + 一致,不能盲目信任 `latest` 或下载后自算哈希。 +- HSR 核心包已提供固定资产校验、安全解压、同卷 staging、跨进程锁、journal、 + 崩溃恢复和手动回滚的事务内核;SRA/M7A 适配器也声明了各自的安装布局与配置 + 保留边界。 +- Managed 下载与覆盖安装仍未对用户开放:宿主鉴权下载/进度接口及真实 Windows + SRA/M7A 安装测试接通前继续 fail closed。事务内核通过 deterministic 测试不等于 + 真实安装链已经验收。 + +## Cultivation Target (0.1.1+) + +培养目标模式让 HSR 双引擎使用各自原生培养目标识别流程,由公共配置 +`CultivationTarget.Enabled` 统一开关。默认关闭,旧配置与现有手动副本行为完全不变。 + +### 公共配置(`HSRConfig.CultivationTarget`) + +| 字段 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `Enabled` | `bool` | `False` | 由当前 `TaskMapping.Daily` 选中的引擎使用其原生培养目标能力 | +| `M7ARecognitionScheme` | `Literal["instance","drop"]` | `"instance"` | M7A 识别方案:instance=按副本名称,drop=按副本素材 | +| `M7AOrnamentWeeklyCount` | `int` | `1` | M7A 饰品提取周次数,范围 0..7 | +| `M7AUseUserStageWhenOnlyRelics` | `bool` | `False` | 仅识别到侵蚀隧洞和饰品提取时是否改用用户手动副本 | + +`Enabled` 只表达引擎无关的意图;`M7A*` 前缀字段只由 M7A 适配器解释,SRA 适配器 +忽略它们。公共层不传递任何引擎原生键。 + +### SRA 适配(`automas-hsr-adapter-sra`) + +- 参考版本:StarRailAssistant v2.18.3 +- 开启时写入 `trailblazePower.useBuildTarget=true`、`tasklist=[]`,由 SRA 原生 + 识别流程决定副本;MAS 不向 tasklist 注入手动副本(含 EOW)。 +- SRA 原生培养目标可能自行把 EOW 加入计划,且没有“仅排除 EOW”的配置开关; + 因此目标模式下 EOW 的执行时机由 SRA 接管,MAS 只根据真实日志回写完成状态。 +- 关闭时显式写入 `useBuildTarget=false`,严格保持现有手动副本/EOW tasklist 行为。 +- 目标模式下 `tasklist` 为空不会被判为「无可执行副本」而跳过 Daily。 +- 无培养目标、无可执行任务、全部 OCR 为空均按失败处理,不接受退出码 0 的 + 空运行作为成功。 + +### M7A 适配(`automas-hsr-adapter-m7a`) + +- 参考版本:March7thAssistant v2026.6.8 +- 开启时 MAS 完全接管四个 M7A 原生键: + `build_target_enable`、`build_target_scheme`、 + `build_target_ornament_weekly_count`、 + `build_target_use_user_instance_when_only_erosion_and_ornament`。 +- 有手动 `Stage.ScriptStage` 时保留其回退语义;没有手动主关卡时写入 + `侵蚀隧洞=无` 的安全占位并开启 Power 流程,避免沿用旧关卡。 +- 关闭时显式写入 `build_target_enable=false`,其余手动副本行为不变。 +- 非法 scheme、越界次数、顶层非对象、写盘失败均 fail closed,不产生半文件。 + +### 行为差异 + +| 维度 | SRA | M7A | +| --- | --- | --- | +| 开关字段 | `trailblazePower.useBuildTarget` | `build_target_enable` | +| 识别方案 | 原生流程,无 MAS 侧参数 | `build_target_scheme`(instance/drop) | +| 饰品次数 | 由 SRA 自行决定 | `build_target_ornament_weekly_count` | +| 回退副本 | 不注入(tasklist 为空) | 保留 `Stage.ScriptStage` | +| 完成判定 | 依赖 SRA 日志/退出码 | 依赖 M7A 日志/退出码 | + +### 可观测性与已知限制 + +- 开关切换时 SRA/M7A 适配器均显式重置上一轮脏状态(useBuildTarget / + build_target_enable),不会跨轮残留。 +- 培养目标模式下 MAS 不伪造完成状态:EOW/周常仍依赖 + `detect_echo_of_war_completion` / `detect_weekly_completion` 从原生日志 + 中提取证据;SRA 目标模式只是扩大 EOW 日志监测范围,不会无证据写完成。 +- SRA 目标模式下若 SRA 本身未返回可判断的完成日志,MAS 不会标记 EOW 完成, + 下次运行会继续尝试。 +- `M7AOrnamentWeeklyCount` 的 0..7 边界在公共 schema 中通过 Pydantic + `Annotated` 约束执行,`PluginField(min=0,max=7)` 同时向 UI 暴露边界; + M7A 适配器在 `build_m7a_daily_patch` 中再次做整数与范围校验,非法值抛 + `ValueError` 并 fail closed。 + ## Development ```powershell diff --git a/packages/automas_hsr/pyproject.toml b/packages/automas_hsr/pyproject.toml index 3c96c56..c91f64f 100644 --- a/packages/automas_hsr/pyproject.toml +++ b/packages/automas_hsr/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "automas-hsr" -version = "0.1.0" +version = "0.1.6" description = "AUTO-MAS HSR plugin bundle for one-click core, SRA, and M7A installation" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.12" @@ -12,9 +12,9 @@ authors = [{ name = "AUTO-MAS Team" }] license = "AGPL-3.0-or-later" license-files = ["LICENSE"] dependencies = [ - "automas-script-hsr>=0.1.0,<0.2.0", - "automas-hsr-adapter-sra>=0.1.0,<0.2.0", - "automas-hsr-adapter-m7a>=0.1.0,<0.2.0", + "automas-script-hsr>=0.1.5,<0.2.0", + "automas-hsr-adapter-sra>=0.1.5,<0.2.0", + "automas-hsr-adapter-m7a>=0.1.6,<0.2.0", ] [project.urls] diff --git a/packages/automas_hsr_adapter_m7a/pyproject.toml b/packages/automas_hsr_adapter_m7a/pyproject.toml index 3a55197..9a47a79 100644 --- a/packages/automas_hsr_adapter_m7a/pyproject.toml +++ b/packages/automas_hsr_adapter_m7a/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "automas-hsr-adapter-m7a" -version = "0.1.0" +version = "0.1.6" description = "March7thAssistant adapter for AUTO-MAS HSR" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.12" @@ -12,7 +12,7 @@ authors = [{ name = "AUTO-MAS Team" }] license = "AGPL-3.0-or-later" license-files = ["LICENSE"] dependencies = [ - "automas-script-hsr>=0.1.0,<0.2.0", + "automas-script-hsr>=0.1.5,<0.2.0", "PyYAML>=6", ] diff --git a/packages/automas_hsr_adapter_sra/pyproject.toml b/packages/automas_hsr_adapter_sra/pyproject.toml index ed52027..94d3b07 100644 --- a/packages/automas_hsr_adapter_sra/pyproject.toml +++ b/packages/automas_hsr_adapter_sra/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "automas-hsr-adapter-sra" -version = "0.1.0" +version = "0.1.5" description = "StarRailAssistant adapter for AUTO-MAS HSR" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.12" authors = [{ name = "AUTO-MAS Team" }] license = "AGPL-3.0-or-later" license-files = ["LICENSE"] -dependencies = ["automas-script-hsr>=0.1.0,<0.2.0"] +dependencies = ["automas-script-hsr>=0.1.5,<0.2.0"] [project.urls] Homepage = "https://github.com/AUTO-MAS-Project/automas-hsr" diff --git a/packages/automas_script_hsr/pyproject.toml b/packages/automas_script_hsr/pyproject.toml index 4a2012d..75c5418 100644 --- a/packages/automas_script_hsr/pyproject.toml +++ b/packages/automas_script_hsr/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "automas-script-hsr" -version = "0.1.0" +version = "0.1.5" description = "HSR orchestration and adapter contracts for AUTO-MAS" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.12" authors = [{ name = "AUTO-MAS Team" }] license = "AGPL-3.0-or-later" license-files = ["LICENSE"] -dependencies = ["jinja2>=3.1", "pydantic>=2"] +dependencies = ["jinja2>=3.1", "packaging>=24", "pydantic>=2"] [project.urls] Homepage = "https://github.com/AUTO-MAS-Project/automas-hsr" diff --git a/scripts/build_all.py b/scripts/build_all.py index b5206d8..165a7e7 100644 --- a/scripts/build_all.py +++ b/scripts/build_all.py @@ -1,5 +1,7 @@ from __future__ import annotations +import argparse +import os import shutil import subprocess import sys @@ -9,20 +11,69 @@ ROOT = Path(__file__).resolve().parents[1] PACKAGES_DIR = ROOT / "packages" DIST_DIR = ROOT / "dist" +_ZIP_MINIMUM_EPOCH = 315532800 # 1980-01-01 UTC + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build every automas-hsr distribution.") + parser.add_argument( + "--dist-dir", + type=Path, + default=DIST_DIR, + help="Artifact root directory (default: repository dist directory).", + ) + return parser.parse_args() + + +def build_environment() -> dict[str, str]: + """Return a deterministic PEP 517 build environment. + + CI/release callers may provide ``SOURCE_DATE_EPOCH`` explicitly. Local + builds otherwise use the checked-out commit timestamp; an exported source + tree without Git falls back to the ZIP format's earliest portable epoch. + """ + + environment = os.environ.copy() + raw_epoch = environment.get("SOURCE_DATE_EPOCH", "").strip() + if not raw_epoch: + try: + completed = subprocess.run( + ["git", "show", "-s", "--format=%ct", "HEAD"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + raw_epoch = completed.stdout.strip() + except (OSError, subprocess.CalledProcessError): + raw_epoch = str(_ZIP_MINIMUM_EPOCH) + try: + epoch = int(raw_epoch) + except ValueError as exc: + raise ValueError("SOURCE_DATE_EPOCH must be an integer") from exc + if epoch < _ZIP_MINIMUM_EPOCH: + epoch = _ZIP_MINIMUM_EPOCH + environment["SOURCE_DATE_EPOCH"] = str(epoch) + environment.setdefault("PYTHONHASHSEED", "0") + return environment def main() -> None: + args = parse_args() + dist_dir = args.dist_dir.resolve() + environment = build_environment() packages = sorted( path for path in PACKAGES_DIR.iterdir() if (path / "pyproject.toml").is_file() ) for package in packages: - output_dir = DIST_DIR / package.name + output_dir = dist_dir / package.name if output_dir.exists(): shutil.rmtree(output_dir) output_dir.mkdir(parents=True, exist_ok=True) subprocess.run( [sys.executable, "-m", "build", str(package), "--outdir", str(output_dir)], check=True, + env=environment, ) diff --git a/uv.lock b/uv.lock index 3f0819f..b500b90 100644 --- a/uv.lock +++ b/uv.lock @@ -28,7 +28,7 @@ wheels = [ [[package]] name = "automas-hsr" -version = "0.1.0" +version = "0.1.6" source = { editable = "packages/automas_hsr" } dependencies = [ { name = "automas-hsr-adapter-m7a" }, @@ -45,7 +45,7 @@ requires-dist = [ [[package]] name = "automas-hsr-adapter-m7a" -version = "0.1.0" +version = "0.1.6" source = { editable = "packages/automas_hsr_adapter_m7a" } dependencies = [ { name = "automas-script-hsr" }, @@ -60,7 +60,7 @@ requires-dist = [ [[package]] name = "automas-hsr-adapter-sra" -version = "0.1.0" +version = "0.1.5" source = { editable = "packages/automas_hsr_adapter_sra" } dependencies = [ { name = "automas-script-hsr" }, @@ -71,16 +71,18 @@ requires-dist = [{ name = "automas-script-hsr", editable = "packages/automas_scr [[package]] name = "automas-script-hsr" -version = "0.1.0" +version = "0.1.5" source = { editable = "packages/automas_script_hsr" } dependencies = [ { name = "jinja2" }, + { name = "packaging" }, { name = "pydantic" }, ] [package.metadata] requires-dist = [ { name = "jinja2", specifier = ">=3.1" }, + { name = "packaging", specifier = ">=24" }, { name = "pydantic", specifier = ">=2" }, ]