From 16bec4c09ed7fba1526ed4dfbe086f14ee4259c1 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 17 Oct 2026 03:41:13 +0800 Subject: [PATCH] fix(runtime): validate process identity and preserve detached launch flags --- app/runtime/state.py | 22 ++++---- tests/test_runtime_process_state.py | 78 +++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 tests/test_runtime_process_state.py diff --git a/app/runtime/state.py b/app/runtime/state.py index 4851e94bd..a39da46c7 100644 --- a/app/runtime/state.py +++ b/app/runtime/state.py @@ -35,7 +35,7 @@ class SystemHelper(ConfigReloadMixin): __local_restart_log_file = ( get_runtime_setting('LOG_PATH') / "moviepilot.restart.stdout.log" ) - __one_shot_dev_update_flag_file = ( + __one_shot_dev_update_flag_file: Path = ( get_runtime_setting('TEMP_PATH') / "moviepilot.pending_dev_update" ) __prepared_update_manifest = ( @@ -46,11 +46,11 @@ class SystemHelper(ConfigReloadMixin): __supervisor_socket = Path("/run/moviepilot/supervisor.sock") __supervisor_update_worker = "moviepilot-update-worker" - def on_config_changed(self): + def on_config_changed(self) -> None: """配置变化后重新应用日志设置。""" logger.update_loggers() - def get_reload_name(self): + def get_reload_name(self) -> str: """返回配置重载日志使用的组件名称。""" return "日志设置" @@ -69,7 +69,7 @@ class SystemHelper(ConfigReloadMixin): ) @staticmethod - def _load_runtime_file(path: Path) -> Optional[dict]: + def _load_runtime_file(path: Path) -> Optional[dict[str, object]]: """安全读取本地进程运行状态文件。""" if not path.exists(): return None @@ -88,12 +88,12 @@ class SystemHelper(ConfigReloadMixin): pid = runtime.get("pid") create_time = runtime.get("create_time") - if not pid: + if not pid or not isinstance(pid, (str, int, float)): return False try: pid = int(pid) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): return False if pid != os.getpid(): @@ -101,10 +101,13 @@ class SystemHelper(ConfigReloadMixin): if create_time is None: return True + if not isinstance(create_time, (str, int, float)): + return False try: current_process = psutil.Process(os.getpid()) - return abs(current_process.create_time() - float(create_time)) <= 2 + started_at: float = current_process.create_time() + return abs(started_at - float(create_time)) <= 2 except (psutil.Error, TypeError, ValueError): return False @@ -174,7 +177,7 @@ class SystemHelper(ConfigReloadMixin): "close_fds": True, "env": env, } - if os.name == "nt": + if sys.platform == "win32": kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS else: kwargs["start_new_session"] = True @@ -199,6 +202,7 @@ class SystemHelper(ConfigReloadMixin): ) -> None: """延迟调用本地 supervisor 控制命令,确保重启接口有机会完成响应。""" def run_command() -> None: + """执行已选定的 supervisor 操作并记录启动失败。""" command = [ str(SystemHelper.__supervisorctl), "-c", @@ -296,7 +300,7 @@ class SystemHelper(ConfigReloadMixin): return False, message return True, "已安排 Dev 更新并重启" - def set_system_modified(self): + def set_system_modified(self) -> None: """ 设置系统已修改标志 """ diff --git a/tests/test_runtime_process_state.py b/tests/test_runtime_process_state.py new file mode 100644 index 000000000..457bdeafc --- /dev/null +++ b/tests/test_runtime_process_state.py @@ -0,0 +1,78 @@ +"""本地进程状态记录及跨平台子进程参数的兼容回归。""" + +import json +import os +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from app import cli +from app.runtime.state import SystemHelper + + +@pytest.mark.parametrize( + ("created_at", "expected"), + [(None, True), (100, True), (102.0, True), (103, False), ("100", True), ("invalid", False), ([], False), ({}, False)], +) +def test_runtime_identity_checks_creation_time( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, created_at: object, expected: bool +) -> None: + """创建时间可缺省或使用数字字符串;损坏记录不能冒充当前受管进程。""" + state_path = tmp_path / "runtime.json" + state_path.write_text(json.dumps({"pid": os.getpid(), "create_time": created_at}), encoding="utf-8") + monkeypatch.setattr(SystemHelper, "_SystemHelper__local_backend_runtime_file", state_path) + monkeypatch.setattr("app.runtime.state.psutil.Process", lambda _pid: SimpleNamespace(create_time=lambda: 100.0)) + + assert SystemHelper._is_local_cli_managed() is expected + + +@pytest.mark.parametrize("pid", [None, [], {}, "invalid", 0, float("inf"), float("-inf"), float("nan")]) +def test_invalid_process_identity_is_rejected( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, pid: object +) -> None: + """不可转换的 PID 和空 PID 均不建立受管进程身份。""" + state_path = tmp_path / "runtime.json" + state_path.write_text(json.dumps({"pid": pid}), encoding="utf-8") + monkeypatch.setattr(SystemHelper, "_SystemHelper__local_backend_runtime_file", state_path) + + assert SystemHelper._is_local_cli_managed() is False + + +@pytest.mark.parametrize("platform", ["win32", "linux", "darwin"]) +@pytest.mark.parametrize("entrypoint", ["cli", "restart"]) +def test_detached_process_flags_match_platform( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, platform: str, entrypoint: str +) -> None: + """模拟平台并截获启动参数,验证 Windows 分离标志和 Unix 新会话保持一致。""" + for attribute, name in ( + ("_SystemHelper__local_restart_log_file", "restart.log"), + ("_SystemHelper__prepared_update_manifest", "install.json"), + ("_SystemHelper__one_shot_dev_update_flag_file", "pending-update"), + ): + monkeypatch.setattr(SystemHelper, attribute, tmp_path / name) + spawn = Mock(return_value=SimpleNamespace(pid=123)) + monkeypatch.setattr(subprocess, "Popen", spawn) + monkeypatch.setattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x200, raising=False) + monkeypatch.setattr(subprocess, "DETACHED_PROCESS", 0x8, raising=False) + + with monkeypatch.context() as platform_patch: + platform_patch.setattr(sys, "platform", platform) + if entrypoint == "cli": + cli._spawn_process(["test-command"], cwd=tmp_path, log_file=None) + else: + SystemHelper._spawn_local_restart_helper() + + spawn.assert_called_once() + options = spawn.call_args.kwargs + assert options["stdin"] == subprocess.DEVNULL + assert options["close_fds"] is True + if platform == "win32": + assert options["creationflags"] == 0x208 + assert "start_new_session" not in options + else: + assert options["start_new_session"] is True + assert "creationflags" not in options