Просмотр исходного кода

feat(apilot): env injection stubs and install_all assembly

EnvData + install_env: L_GameEnvManager (data -> Python dict via AP_getEnvKey
closure, KeyError-safe), LuaBridgeCtl (Inst single-instance, Get/SetModulesSwitch
default {2:1} heart-beat on), FileUtils (state_dir sandbox via _safe_join commonpath
check, subdir mkdir, isFileExist returns false for escape), gameEnviroment minimal
3-method face, plus release_print/releasePrint/GET_STRING/JUMPTO/GET_SETTING/
CHECK_SETTING/CHANGE_SETTING globals backed by _PY_ENV.

Lupa notes baked in:
  - function obj:method(k) 冒号定义使 self 显式, 兼容游戏 L_GameEnvManager:GetEnvDataByKey(...)
  - 暴露 AP_getEnvKey/AP_getSwitch/AP_setSwitch 闭包规避 dict[k] 抛 KeyError
  - FileUtils cc.FileUtils Lua 薄壳 + AP_writeStringToFile/AP_isFileExist 直挂 Python
  - install_noop_globals 已建 cc.FileUtils 骨架时, env 仍兜底 cc = cc or {} 自启

install_all: noop -> scheduler -> env -> networkctl -> gameEnviroment.GetNetClient
hook -> global.* 引用 (networkCtl/LuaBridgeCtl/L_GameEnvManager/gameEnviroment).
nc 透传给 gameEnviroment.GetNetClient() (Task 6 真实游戏代码会读这个)。

tests/test_env_stub.py: 4 用例 (env keys / bridge+fileutils / 路径逃逸被拒 /
install_all 端到端 networkCtl:SendMsg -> 环回网关). 20 passed (16 prior + 4 new).
每文件 <250 行 (env.py=125, __init__.py=45, test=111).
wuzilong 1 месяц назад
Родитель
Сommit
9692a2ac93
3 измененных файлов с 281 добавлено и 0 удалено
  1. 45 0
      apilot/stubs/__init__.py
  2. 125 0
      apilot/stubs/env.py
  3. 111 0
      tests/test_env_stub.py

+ 45 - 0
apilot/stubs/__init__.py

@@ -0,0 +1,45 @@
+"""apilot.stubs: 全量装配入口 install_all。"""
+from __future__ import annotations
+
+from typing import Any
+
+from apilot.stubs.env import EnvData, install_env
+from apilot.stubs.netclient import NetClientStub
+from apilot.stubs.networkctl import NetworkCtlStub
+from apilot.stubs.noop import install_noop_globals
+from apilot.stubs.scheduler import Scheduler, install_scheduler
+
+
+def install_all(lua: Any, nc: NetClientStub, ctl: NetworkCtlStub,
+                sched: Scheduler, env: EnvData, state_dir: str) -> None:
+    """全量装配到 lua runtime: noop → scheduler → env → networkctl → global.* 引用。
+
+    顺序说明:
+      1) install_noop_globals 装 cc/ccui/ccs/ccexp 骨架
+      2) install_scheduler 装 PerformWithDelayGlobal/Schedule/UnSchedule/SL_getScheduler
+      3) install_env 装 L_GameEnvManager/LuaBridgeCtl/cc.FileUtils/gameEnviroment + 杂项全局
+      4) ctl.install_to_lua 装 networkCtl/LuaNetworkController(最后, 真实实现覆盖 no-op)
+      5) 把 C++ 静态单例引用挂到 global.* (游戏原版 Lua 大量通过 global.X 访问)
+      6) gameEnviroment.GetNetClient 指向真实 nc (Task 6 之后 game code 会用)
+    """
+    install_noop_globals(lua)
+    install_scheduler(lua, sched)
+    install_env(lua, env, state_dir)
+    ctl.install_to_lua(lua)
+    lua.globals()["AP_netclient_ref"] = nc
+    lua.execute("gameEnviroment.GetNetClient = function() return AP_netclient_ref end")
+    lua.execute("global = global or {}")
+    lua.execute("global.networkCtl = networkCtl; global.LuaBridgeCtl = LuaBridgeCtl")
+    lua.execute("global.L_GameEnvManager = L_GameEnvManager; global.gameEnviroment = gameEnviroment")
+
+
+__all__ = [
+    "EnvData",
+    "NetClientStub",
+    "NetworkCtlStub",
+    "Scheduler",
+    "install_all",
+    "install_env",
+    "install_noop_globals",
+    "install_scheduler",
+]

+ 125 - 0
apilot/stubs/env.py

@@ -0,0 +1,125 @@
+"""环境注入: L_GameEnvManager / FileUtils / LuaBridgeCtl / gameEnviroment / 杂项全局函数。
+
+Task 5 契约: EnvData + install_env; 不引入 python.eval, FileUtils 走 AP_* 直挂。
+"""
+from __future__ import annotations
+
+import os
+from typing import Any
+
+SWITCHES_DEFAULT: dict[int, int] = {2: 1}  # Modules_Index_Enable_Check_Heart_Beat=1(心跳开)
+
+
+class EnvData:
+    """游戏环境数据: 登录态/账号/渠道/签名等。data 透传给 L_GameEnvManager.GetEnvDataByKey; switches / settings 独立 dict。"""
+
+    def __init__(self, data: dict[str, Any]) -> None:
+        self.data: dict[str, Any] = dict(data)
+        self.switches: dict[int, int] = dict(SWITCHES_DEFAULT)
+        self.settings: dict[int, list[Any]] = {}
+
+
+def _safe_join(abs_state: str, rel: str) -> str:
+    """join 后必须仍在 abs_state 内(防 rel 形如 ../escape)。返回 abspath。"""
+    p = os.path.abspath(os.path.join(abs_state, rel))
+    if os.path.commonpath([p, abs_state]) != abs_state:
+        raise ValueError(f"path escape: {rel!r} not under state_dir")
+    return p
+
+
+def install_env(lua: Any, env: EnvData, state_dir: str) -> None:
+    """注入环境: L_GameEnvManager / LuaBridgeCtl / FileUtils / gameEnviroment / 杂项全局。"""
+    os.makedirs(state_dir, exist_ok=True)
+    abs_state = os.path.abspath(state_dir)
+
+    g = lua.globals()
+    g["_PY_ENV"] = env
+    g["_PY_STATE_DIR"] = state_dir
+
+    # Lupa 不翻译 KeyError -> nil, 暴露 get/switch helper 规避
+    def _get_env_key(k: str) -> Any:
+        return env.data.get(k)
+
+    def _get_switch(i: int) -> int:
+        return env.switches.get(int(i), 0)
+
+    def _set_switch(i: int, v: int) -> None:
+        env.switches[int(i)] = int(v)
+
+    def _get_setting(i: int) -> Any:
+        return env.settings.get(int(i))
+
+    def _set_setting(i: int, v: Any) -> None:
+        env.settings[int(i)] = v
+
+    g["AP_getEnvKey"] = _get_env_key
+    g["AP_getSwitch"] = _get_switch
+    g["AP_setSwitch"] = _set_switch
+    g["AP_getSetting"] = _get_setting
+    g["AP_setSetting"] = _set_setting
+
+    # Lua 侧: env manager / bridge ctl / 杂项全局
+    # 冒号定义使 self 显式, 兼容游戏里 L_GameEnvManager:GetEnvDataByKey(...) / LuaBridgeCtl:Inst():...
+    lua.execute(r"""
+L_GameEnvManager = {}
+function L_GameEnvManager:GetEnvDataByKey(k) return AP_getEnvKey(k) end
+
+LuaBridgeCtl = {}
+function LuaBridgeCtl:Inst() return self end
+function LuaBridgeCtl:GetModulesSwitch(i) return AP_getSwitch(i) end
+function LuaBridgeCtl:SetModulesSwitch(i, v) AP_setSwitch(i, v) end
+
+function release_print(...) end
+releasePrint = release_print
+function GET_STRING(id) return "" end
+function JUMPTO(...) end
+function GET_SETTING(i) return AP_getSetting(i) end
+function CHECK_SETTING(i)
+    local s = AP_getSetting(i)
+    return s ~= nil and s[1] == 1 or false
+end
+function CHANGE_SETTING(i, v) AP_setSetting(i, v) end
+""")
+
+    # FileUtils: Python 静态方法暴露为 Lua 全局, cc.FileUtils Lua 薄壳包一层
+    class _FU:
+        @staticmethod
+        def getInstance() -> "type[_FU]":  # noqa: N802
+            return _FU
+
+        @staticmethod
+        def getWritablePath() -> str:  # noqa: N802
+            return abs_state + os.sep
+
+        @staticmethod
+        def writeStringToFile(s: str, rel: str) -> None:  # noqa: N802
+            p = _safe_join(abs_state, rel)
+            os.makedirs(os.path.dirname(p), exist_ok=True)
+            with open(p, "w", encoding="utf-8") as f:
+                f.write(s)
+
+        @staticmethod
+        def isFileExist(rel: str) -> bool:  # noqa: N802
+            try:
+                return os.path.exists(_safe_join(abs_state, rel))
+            except ValueError:
+                return False
+
+    g["AP_writeStringToFile"] = _FU.writeStringToFile
+    g["AP_isFileExist"] = _FU.isFileExist
+    g["AP_getWritablePath"] = _FU.getWritablePath
+    lua.execute("""
+cc = cc or {}
+cc.FileUtils = {getInstance = function() return cc.FileUtils end}
+function cc.FileUtils:writeStringToFile(s, rel) AP_writeStringToFile(s, rel) end
+function cc.FileUtils:isFileExist(rel) return AP_isFileExist(rel) end
+function cc.FileUtils:getWritablePath() return AP_getWritablePath() end
+""")
+
+    # gameEnviroment(小写 e) - 全局 C++ 单例, 最小面
+    lua.execute("""
+gameEnviroment = gameEnviroment or {}
+function gameEnviroment.GetNetClient() return nil end
+function gameEnviroment.SetLoginAccount(a) end
+function gameEnviroment.GetDownloadRootURL() return "" end
+""")

+ 111 - 0
tests/test_env_stub.py

@@ -0,0 +1,111 @@
+# tests/test_env_stub.py
+"""Task 5: EnvData + install_env (L_GameEnvManager/LuaBridgeCtl/FileUtils/gameEnviroment) + install_all 装配。"""
+import socket
+import threading
+import time
+
+import lupa.luajit21 as lj
+import pytest
+
+
+def test_env_manager_keys(tmp_path):
+    from apilot.stubs.env import EnvData, install_env
+
+    rt = lj.LuaRuntime()
+    env = EnvData({
+        "uid": "1186115037",
+        "token": "jwt.x",
+        "isBoxLogin": 1,
+        "signkey": "k" * 32,
+    })
+    install_env(rt, env, str(tmp_path))
+    assert rt.eval("L_GameEnvManager:GetEnvDataByKey('uid')") == "1186115037"
+    assert rt.eval("L_GameEnvManager:GetEnvDataByKey('isBoxLogin')") == 1
+    assert rt.eval("L_GameEnvManager:GetEnvDataByKey('missing')") is None
+    rt.execute("global = global or {}; global.L_GameEnvManager = L_GameEnvManager")
+    assert rt.eval("global.L_GameEnvManager:GetEnvDataByKey('token')") == "jwt.x"
+
+
+def test_bridge_ctl_and_fileutils(tmp_path):
+    from apilot.stubs.env import EnvData, install_env
+
+    rt = lj.LuaRuntime()
+    install_env(rt, EnvData({}), str(tmp_path))
+    # 心跳开关默认开
+    assert rt.eval("LuaBridgeCtl:Inst():GetModulesSwitch(2)") == 1
+    rt.execute("LuaBridgeCtl:Inst():SetModulesSwitch(2, 1)")
+    assert rt.eval("LuaBridgeCtl:Inst():GetModulesSwitch(2)") == 1
+    # FileUtils 写入相对路径
+    rt.execute("cc.FileUtils:getInstance():writeStringToFile('hi', 'a.txt')")
+    assert (tmp_path / "a.txt").read_text() == "hi"
+    # GET_STRING 未命中返回空串
+    assert rt.eval("GET_STRING(99999)") == ""
+
+
+def test_fileutils_path_safety(tmp_path):
+    """writeStringToFile/isFileExist 拒绝逃逸 state_dir 的相对路径。"""
+    from apilot.stubs.env import EnvData, install_env
+
+    rt = lj.LuaRuntime()
+    install_env(rt, EnvData({}), str(tmp_path))
+    # 子目录写入 OK
+    rt.execute("cc.FileUtils:getInstance():writeStringToFile('ok', 'sub/b.txt')")
+    assert (tmp_path / "sub" / "b.txt").read_text() == "ok"
+    # writeStringToFile 路径逃逸被拒
+    with pytest.raises(Exception):
+        rt.execute("cc.FileUtils:getInstance():writeStringToFile('x', '../escape.txt')")
+    # isFileExist 逃逸路径返回 false
+    assert rt.eval("cc.FileUtils:getInstance():isFileExist('../escape.txt')") is False
+
+
+def test_install_all_networkctl_roundtrip(tmp_path):
+    """install_all 装配后 Lua 侧可经 global.networkCtl 走 netClient 抵达环回网关。"""
+    from apilot.stubs import install_all
+    from apilot.stubs.env import EnvData
+    from apilot.stubs.netclient import NetClientStub
+    from apilot.stubs.networkctl import NetworkCtlStub
+    from apilot.stubs.scheduler import Scheduler
+
+    received = bytearray()
+    finished = threading.Event()
+
+    def gateway(server_sock):
+        conn, _ = server_sock.accept()
+        try:
+            conn.settimeout(2.0)
+            while not finished.is_set():
+                try:
+                    chunk = conn.recv(4096)
+                except (socket.timeout, OSError):
+                    break
+                if not chunk:
+                    break
+                received.extend(chunk)
+        finally:
+            conn.close()
+            server_sock.close()
+
+    srv = socket.socket()
+    srv.bind(("127.0.0.1", 0))
+    srv.listen(1)
+    port = srv.getsockname()[1]
+    threading.Thread(target=gateway, args=(srv,), daemon=True).start()
+
+    nc = NetClientStub()
+    nc.connect("127.0.0.1", port)
+    ctl = NetworkCtlStub(nc)
+    sched = Scheduler()
+    env = EnvData({"uid": "u1"})
+
+    rt = lj.LuaRuntime()
+    install_all(rt, nc, ctl, sched, env, str(tmp_path))
+    rt.execute("global.networkCtl:SendMsg(529, 12345, 1, 2, 3, 'hi', 2)")
+
+    time.sleep(0.2)
+    finished.set()
+    nc.disconnect()
+    time.sleep(0.1)
+
+    # 48 字节 encoded header + 2 字节 body
+    assert len(received) >= 50, f"expected 50 bytes, got {len(received)}: {bytes(received)!r}"
+    assert bytes(received[48:50]) == b"hi"