From 02df100204650b0c6f2b0ea6d0e6c3a7a2ddf763 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Tue, 29 Sep 2026 15:20:13 +0800 Subject: [PATCH] =?UTF-8?q?chore(vet):=20=E5=88=9D=E5=A7=8B=E5=8C=96=20VET?= =?UTF-8?q?=20=E4=BB=93=EF=BC=88Viper=20Environment=20Tool=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首次入库,远端 https://git.gvsds.com/GVSDS/Viper_VET 。 19 个文件全部为源码/配置,无构建产物: App/main.vp、App/lib/*.vp(VCache / VCheck / VCli / VDirs / VFs / VGuard / VGuid / VJson / VLog / VPe / VProc / VScan / __init__) 1.json、LICENSE、README.md、project.vpj、.gitignore 既有 .gitignore 已覆盖本仓的产物目录(**/output/、**/temp/、**/.tpv_build/) 与 *.exe / *.obj / *.o / *.img / *.iso / __pycache__/ / .cache/; 实测 `git add -A --dry-run` 仅 19 个文件、零产物混入,故未改动 .gitignore。 --- .gitignore | 13 + 1.json | 1 + App/lib/VCache.vp | 354 ++++++++++++ App/lib/VCheck.vp | 546 ++++++++++++++++++ App/lib/VCli.vp | 538 ++++++++++++++++++ App/lib/VDirs.vp | 302 ++++++++++ App/lib/VFs.vp | 324 +++++++++++ App/lib/VGuard.vp | 1279 +++++++++++++++++++++++++++++++++++++++++++ App/lib/VGuid.vp | 54 ++ App/lib/VJson.vp | 258 +++++++++ App/lib/VLog.vp | 242 ++++++++ App/lib/VPe.vp | 482 ++++++++++++++++ App/lib/VProc.vp | 319 +++++++++++ App/lib/VScan.vp | 618 +++++++++++++++++++++ App/lib/__init__.vp | 15 + App/main.vp | 115 ++++ LICENSE | 21 + README.md | 639 +++++++++++++++++++++ project.vpj | 40 ++ 19 files changed, 6160 insertions(+) create mode 100644 .gitignore create mode 100644 1.json create mode 100644 App/lib/VCache.vp create mode 100644 App/lib/VCheck.vp create mode 100644 App/lib/VCli.vp create mode 100644 App/lib/VDirs.vp create mode 100644 App/lib/VFs.vp create mode 100644 App/lib/VGuard.vp create mode 100644 App/lib/VGuid.vp create mode 100644 App/lib/VJson.vp create mode 100644 App/lib/VLog.vp create mode 100644 App/lib/VPe.vp create mode 100644 App/lib/VProc.vp create mode 100644 App/lib/VScan.vp create mode 100644 App/lib/__init__.vp create mode 100644 App/main.vp create mode 100644 LICENSE create mode 100644 README.md create mode 100644 project.vpj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53c4e04 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +*.o +*.obj +*.exe +*.img +*.iso +**/__pycache__/ +.cache/ +*.pyc + +# 编译 +**/output/ +**/temp/ +**/.tpv_build/ diff --git a/1.json b/1.json new file mode 100644 index 0000000..6ecccd5 --- /dev/null +++ b/1.json @@ -0,0 +1 @@ +[{"id":"80265108-c9b5-4d99-a28b-74031104ffe3","executable":"D:\\Users\\TermiNexus\\Desktop\\TransPyC\\TransPyV\\output\\TransPyV.exe","version":"0.0.3-dev.428","arch":"x86_64","source":"workspace","displayName":"Viper 0.0.3-dev.428"}] diff --git a/App/lib/VCache.vp b/App/lib/VCache.vp new file mode 100644 index 0000000..45c678c --- /dev/null +++ b/App/lib/VCache.vp @@ -0,0 +1,354 @@ +import t, c +from stdint import * +import stdlib +import string +import memhub +import json +import lib.VFs as VFs +import lib.VLog as VLog +import w32.win32file as wf +import w32.win32base as wb + + +# ============================================================ +# VCache - 发现缓存(内存态 + 缓存文件读写/解析/失效校验) +# +# 从原 App/main.vp 平移 Cache*/LoadCache/WriteCacheFile/ParseCacheText/ValidateCache。 +# 与 VJson 解耦:WriteCacheFile 不再直接读 VJson 的 Json_Buf,而是由调用方把 +# (Buf, Len)传入;表项读取经 CacheCount()/CacheExe(i)… 暴露,避免 VJson↔VCache 环。 +# +# 日志详细度:缓存载入/逐条移除明细("已载入 N 项种子"、"[移除] 已不存在: <路径>"、 +# "未找到/无法打开: <路径>")属过程细节,走 VLog.Verbose*;解析/读取失败等降级说明 +# (文本超限、工作池预检不足、JSON 解析失败、写入失败)始终输出且不内嵌路径。 +# +# 本轮改造(需求 ②):解析不再用自写的"极小 JSON 状态机"(原 ReadJsonStr + +# 逐字符扫描),改为调用仓库 json 库 json.parse(includes/json/__parser.vp:286), +# 再按字段搬进 C 侧缓存表。内存归属见 ParseCacheText 注释。 +# +# 依赖:VFs(DupStr/PathIsFile/ReadWholeFile/WriteFileAll/Utf8ToAnsi/CopyRaw)、 +# VLog、json(parse/JsonValue/JSON_* 常量)、stdlib(malloc/free)。 +# 字段/容量沿用原实现(App/main.vp 拆分前 :140-150、:188-194)。 +# ============================================================ + +CACHE_CAP: t.CDefine = 256 # 缓存条目上限 +CACHE_FILE_MAX: t.CDefine = 16777216 # 缓存文件读取上限 16 MiB +DISP_CAP: t.CDefine = 160 # "Viper " + 版本串 +CACHE_TEXT_MAX: t.CDefine = 262144 # 解析文本上限 256 KiB(典型缓存约 60 KiB;超出即视为损坏/异常,按空缓存启动) +CACHE_ARENA_PER_BYTE: t.CDefine = 128 # 解析工作池容量 / 输入字节(保守上界,见 ParseCacheText) +CACHE_ARENA_SLACK: t.CDefine = 4096 # 解析工作池固定余量 + +# 由 App/main.vp 注入 +_mbuddy: rawptr[memhub.MemBuddy] + +# 发现缓存(内存态;ANSI 路径,用于与 FindFirstFileA 枚举结果直接比对) +# Cache_Src 记录条目来源("workspace" = 扫盘发现;"check" = check 通道命中), +# 是 JSON 条目 source 字段的唯一来源(VJson.BuildEntry 读 CacheSrc)。 +Cache_Exe: t.CArray[str, CACHE_CAP] +Cache_Id: t.CArray[str, CACHE_CAP] +Cache_Ver: t.CArray[str, CACHE_CAP] +Cache_Arch: t.CArray[str, CACHE_CAP] +Cache_Src: t.CArray[str, CACHE_CAP] +Cache_Disp: t.CArray[str, CACHE_CAP] +Cache_Count: int = 0 + +# source 字面量:扫盘侧(VScan)/ check 侧(VCheck)各自传入,缓存本身不解释其含义。 +# 旧缓存文件(无 source 字段)按 SOURCE_FALLBACK 载入,保持既有输出不变。 +SOURCE_FALLBACK: t.CDefine = "workspace" + + +# ---- 表读取接口(供 VJson 构建 JSON,不暴露数组本身) ---- +def CacheCount() -> int: + return Cache_Count + + +def CacheExe(Idx: int) -> str: + return Cache_Exe[Idx] + + +def CacheId(Idx: int) -> str: + return Cache_Id[Idx] + + +def CacheVer(Idx: int) -> str: + return Cache_Ver[Idx] + + +def CacheArch(Idx: int) -> str: + return Cache_Arch[Idx] + + +def CacheSrc(Idx: int) -> str: + return Cache_Src[Idx] + + +def CacheDisp(Idx: int) -> str: + return Cache_Disp[Idx] + + +# ============================================================ +# 内存缓存 +# ============================================================ + +def CacheFind(ExeAnsi: str) -> int: + i: int = 0 + while i < Cache_Count: + if string.strcmp(Cache_Exe[i], ExeAnsi) == 0: + return i + i += 1 + return -1 + + +def FreeEntry(Idx: int): + _mbuddy.free(t.CPtr(Cache_Exe[Idx])) + _mbuddy.free(t.CPtr(Cache_Id[Idx])) + _mbuddy.free(t.CPtr(Cache_Ver[Idx])) + _mbuddy.free(t.CPtr(Cache_Arch[Idx])) + _mbuddy.free(t.CPtr(Cache_Src[Idx])) + _mbuddy.free(t.CPtr(Cache_Disp[Idx])) + + +# 移除第 Idx 条(释放各字段副本并把后续条目左移一位)。返回 1=已移除。 +# 单点实现:ValidateCache(存在性校验)与 VScan 的"每轮完整重校"都经此处剔除条目, +# 避免两处各写一遍左移逻辑。 +def RemoveAt(Idx: int) -> int: + global Cache_Count + if Idx < 0 or Idx >= Cache_Count: + return 0 + FreeEntry(Idx) + k: int = Idx + while k < Cache_Count - 1: + Cache_Exe[k] = Cache_Exe[k + 1] + Cache_Id[k] = Cache_Id[k + 1] + Cache_Ver[k] = Cache_Ver[k + 1] + Cache_Arch[k] = Cache_Arch[k + 1] + Cache_Src[k] = Cache_Src[k + 1] + Cache_Disp[k] = Cache_Disp[k + 1] + k += 1 + Cache_Count -= 1 + return 1 + + +# 清空内存表(释放全部字段副本)。用于每轮开始前的"重读 cache.json"。 +def ClearCache(): + global Cache_Count + j: int = 0 + while j < Cache_Count: + FreeEntry(j) + j += 1 + Cache_Count = 0 + + +# 把一条发现记录拷入缓存(各字段独立副本)。返回 1=成功,0=容量满/分配失败 +# Src:条目来源("workspace"/"check"),原样存入并在 JSON 输出时透传。 +def CacheAdd(ExeAnsi: str, Id: str, Ver: str, Arch: str, Src: str, Disp: str) -> int: + global Cache_Count + if Cache_Count >= CACHE_CAP: + VLog.LogFmt1(" 警告: 缓存条目已达上限 %d,忽略新条目\n", t.CSizeT(CACHE_CAP)) + return 0 + E: str = VFs.DupStr(ExeAnsi) + if E is None: + return 0 + I: str = VFs.DupStr(Id) + if I is None: + _mbuddy.free(t.CPtr(E)) + return 0 + V: str = VFs.DupStr(Ver) + if V is None: + _mbuddy.free(t.CPtr(E)) + _mbuddy.free(t.CPtr(I)) + return 0 + A: str = VFs.DupStr(Arch) + if A is None: + _mbuddy.free(t.CPtr(E)) + _mbuddy.free(t.CPtr(I)) + _mbuddy.free(t.CPtr(V)) + return 0 + S: str = VFs.DupStr(Src) + if S is None: + _mbuddy.free(t.CPtr(E)) + _mbuddy.free(t.CPtr(I)) + _mbuddy.free(t.CPtr(V)) + _mbuddy.free(t.CPtr(A)) + return 0 + D: str = VFs.DupStr(Disp) + if D is None: + _mbuddy.free(t.CPtr(E)) + _mbuddy.free(t.CPtr(I)) + _mbuddy.free(t.CPtr(V)) + _mbuddy.free(t.CPtr(A)) + _mbuddy.free(t.CPtr(S)) + return 0 + Cache_Exe[Cache_Count] = E + Cache_Id[Cache_Count] = I + Cache_Ver[Cache_Count] = V + Cache_Arch[Cache_Count] = A + Cache_Src[Cache_Count] = S + Cache_Disp[Cache_Count] = D + Cache_Count += 1 + return 1 + + +# 校验缓存项对应的 exe 是否仍存在:不存在则从缓存移除(返回移除数) +def ValidateCache() -> int: + removed: int = 0 + j: int = 0 + while j < Cache_Count: + if VFs.PathIsFile(Cache_Exe[j]) != 0: + j += 1 + else: + VLog.VerboseFmtS1(" [移除] 已不存在: %s\n", Cache_Exe[j]) + RemoveAt(j) + removed += 1 + return removed + + +# ============================================================ +# 缓存文件 +# ============================================================ + +# 把 EmitJson 生成的文本(Buf,共 Len 字节)写入缓存文件(失败只告警,不中断进程) +def WriteCacheFile(Path: str, Buf: bytes, Len: t.CSizeT) -> int: + if Buf is None: + return 0 + rc: int = VFs.WriteFileAll(Path, Buf, Len) + if rc == 0: + VLog.LogFmt1(" 警告: 缓存写入失败(打开失败, win32 err=%u)\n", wb.GetLastError()) + return 0 + if rc < 0: + VLog.LogFmt2(" 警告: 缓存写入不完整 (%zu/%zu 字节)\n", VFs.LastWriteDone, Len) + return 0 + return 1 + + +# 取对象里指定键的字符串值;键缺失/非字符串/值为空指针 ⇒ 空串 +# (与原实现"缺字段记 ''"一致)。纯只读字段遍历,先例见 includes/_dict.vp:241-261。 +def FieldStr(Obj: rawptr[json.JsonValue], Key: str) -> str: + cu: rawptr[json.JsonValue] = Obj.child + while cu != None: + if cu.key != None: + if string.strcmp(cu.key, Key) == 0: + if cu.vtype == json.JSON_STRING and cu.str_val != None: + return cu.str_val + return "" + cu = cu.next + return "" + + +# 把一个解析出的 JSON 对象搬进缓存表(逐字段取出 → CacheAdd 在 Vet_Mb 上复制持久副本)。 +# 需求 executable 与 id 非空才计入(与原实现 "TmpExe[0]!=0 and TmpId[0]!=0" 等价)。 +# source 缺失/非字符串/空串 ⇒ SOURCE_FALLBACK(旧缓存文件无该字段,输出与既有行为一致)。 +def AddParsedEntry(Obj: rawptr[json.JsonValue], ConvBuf: bytes) -> int: + Exe: str = FieldStr(Obj, "executable") + Id: str = FieldStr(Obj, "id") + if string.strlen(Exe) == 0 or string.strlen(Id) == 0: + return 0 + Src: str = FieldStr(Obj, "source") + if string.strlen(Src) == 0: + Src = SOURCE_FALLBACK + # 与扫描侧口径一致:缓存内 executable 存 ANSI(CacheFind 要与 FindFirstFileA 的 ANSI 结果比对) + if VFs.Utf8ToAnsi(Exe, ConvBuf, VFs.CONV_CAP) == 0: + VFs.CopyRaw(ConvBuf, VFs.CONV_CAP, Exe) + return CacheAdd(str(ConvBuf), Id, FieldStr(Obj, "version"), + FieldStr(Obj, "arch"), Src, FieldStr(Obj, "displayName")) + + +# 用仓库 json 库解析缓存文本(顶层数组 + 对象 + 字符串字段),逐字段搬入 C 侧缓存表。 +# 返回成功计入的条目数。 +# +# 内存归属(避免悬空/双重释放): +# * json.parse 的整棵树(JsonValue 节点 + 键名副本 + 字符串副本)全部分配在**本次调用 +# 专用的 MemPool**(bump 分配器)上,arena 由 stdlib.malloc 单独提供; +# * 树内的字符串只在 CacheAdd 期间被读取,CacheAdd 用 VFs.DupStr 在 Vet_Mb(_mbuddy) +# 上另做持久副本 —— 两池互不相干; +# * 因此解析结束后**整块** stdlib.free(arena) 即回收全部解析内存,既不悬空也不会 +# 误放缓存表项(这正是不复用 Vet_Mb 的原因:Vet_Mb 上 CacheAdd 的分配与解析树的 +# 分配交错,无法用 mark/reset_to 只回收解析树)。 +# +# 失败处理(均非致命,告警后按空缓存启动): +# * 文本为空 / 超长(> CACHE_TEXT_MAX)/ arena 分配失败 / 工作池容量预检不足 +# (Pool.AllocOk(Need) 失败 ⇒ 直接降级,见该处注释)/ 顶层非数组 / 解析出 null。 +def ParseCacheText(Txt: str) -> int: + if Txt is None: + return 0 + L: t.CSizeT = string.strlen(Txt) + if L == 0: + return 0 + if L > t.CSizeT(CACHE_TEXT_MAX): + VLog.LogFmt2("缓存: 文本 %zu 字节超出解析上限 %zu,按空缓存启动\n", + L, t.CSizeT(CACHE_TEXT_MAX)) + return 0 + # 工作池容量:解析树最坏 ≈ 每输入字节 1 个 JsonValue 节点(9 字段 × 8 = 72 字节上界,bump 池 + # 再按 8 字节对齐)+ 每字符串一次键名副本与值副本 + 数字/键名临时缓冲(最坏 ≈ 80 字节/输入字节); + # 按 CACHE_ARENA_PER_BYTE(128)再放大,覆盖每次分配的 8 字节对齐浪费。 + # (MemPool 耗尽会 printf + exit(1),故必须一次给足;这是不选 MemPool 小池的原因。) + Cap: t.CSizeT = L * t.CSizeT(CACHE_ARENA_PER_BYTE) + t.CSizeT(CACHE_ARENA_SLACK) + Arena: bytes = stdlib.malloc(Cap) + if Arena is None: + VLog.LogStr("缓存: 解析工作池分配失败,按空缓存启动\n") + return 0 + Pool: rawptr[memhub.MemPool] = memhub.MemPool(Arena, Cap) + if Pool is None: + stdlib.free(t.CPtr(Arena)) + VLog.LogStr("缓存: 解析工作池构造失败,按空缓存启动\n") + return 0 + # 池容量预检(fail-closed):整棵解析树必须先确认放得下,否则 json.parse 内部的 + # pool.alloc 会走到 includes/memhub.vp:262-272 的 printf + exit(1)。 + # Need 与 Cap 同源(同一 L、同一每字节放大系数与固定余量),且 L*128 与 4096 都是 + # MEMHUB_ALIGN(8) 的倍数 ⇒ 新池 offset=0 时 AllocOk(Need) 恒成立;一旦 Cap 的推导被 + # 改小到放不下最坏解析开销,此处即失败并按"空缓存启动"降级,不会再走到 exit(1)。 + Need: t.CSizeT = L * t.CSizeT(CACHE_ARENA_PER_BYTE) + t.CSizeT(CACHE_ARENA_SLACK) + if Pool.AllocOk(Need) == 0: + stdlib.free(t.CPtr(Arena)) + VLog.LogFmt1("缓存: 解析工作池预检不足(最坏需要 %zu 字节),按空缓存启动\n", + Need) + return 0 + ConvBuf: bytes = _mbuddy.alloc(VFs.CONV_CAP) + if ConvBuf is None: + stdlib.free(t.CPtr(Arena)) + VLog.LogStr("缓存: 编码转换缓冲分配失败,按空缓存启动\n") + return 0 + Root: rawptr[json.JsonValue] = json.parse(Pool, Txt) + added: int = 0 + if Root == None: + VLog.LogStr("缓存: JSON 解析失败(内容损坏),按空缓存启动\n") + elif Root.vtype != json.JSON_ARRAY: + VLog.LogStr("缓存: 顶层不是 JSON 数组,按空缓存启动\n") + else: + cu: rawptr[json.JsonValue] = Root.child + while cu != None: + if cu.vtype == json.JSON_OBJECT: + added += AddParsedEntry(cu, ConvBuf) + cu = cu.next + _mbuddy.free(t.CPtr(ConvBuf)) + stdlib.free(t.CPtr(Arena)) + return added + + +# 启动时读缓存做种子。文件不存在/读失败/解析失败均为非致命:告警后按空缓存启动。 +def LoadCache(Path: str) -> int: + buf: bytes = VFs.ReadWholeFile(Path, t.CSizeT(CACHE_FILE_MAX), + wf.FILE_SHARE_READ | wf.FILE_SHARE_WRITE) + if buf is None: + rc_err: int = VFs.LastReadErr + if rc_err == 1: + VLog.VerboseFmtS1("缓存: 未找到/无法打开,按空缓存启动: %s\n", Path) + elif rc_err == 2: + VLog.LogStr("缓存: 读取大小失败,按空缓存启动\n") + elif rc_err == 5: + VLog.LogStr("缓存: 分配缓冲失败,按空缓存启动\n") + else: + VLog.LogStr("缓存: 大小异常/过大,按空缓存启动\n") + return 0 + n: int = ParseCacheText(str(buf)) + stdlib.free(t.CPtr(buf)) + VLog.VerboseFmt1("缓存: 已载入 %u 项种子\n", t.CSizeT(n)) + return n + + +# 每轮开始前的"重读 cache.json":先清空内存表(释放上一轮的字段副本),再按文件重建。 +# 返回本次载入的条目数。文件不存在/读取失败/解析失败均按空缓存继续(与 LoadCache 同一口径, +# 由 LoadCache 内部告警);代价是"上一轮写盘失败时进程内已知条目会丢失"——为此把写盘失败 +# 只降级为告警(WriteCacheFile),并由"下一轮树扫描会重新发现"兜底(扫盘幂等)。 +def ReloadCache(Path: str) -> int: + ClearCache() + return LoadCache(Path) diff --git a/App/lib/VCheck.vp b/App/lib/VCheck.vp new file mode 100644 index 0000000..cc32632 --- /dev/null +++ b/App/lib/VCheck.vp @@ -0,0 +1,546 @@ +import t, c +from stdint import * +import stdlib +import string +import viperlib +import memhub +import json +import lib.VFs as VFs +import lib.VLog as VLog +import lib.VPe as VPe +import lib.VProc as VProc +import lib.VGuid as VGuid +import lib.VCache as VCache + + +# ============================================================ +# VCheck - check 请求通道的"执行 + 回执"侧(父进程 / scan 进程内) +# +# 通道形态(依附 --need-confirm:只有开了心跳、看门狗存在时才具备该通道): +# * 用户/外部往 **stdin** 写一行请求: +# {"check": "", "id": } +# * 看门狗(confirm-watch)用 json 库解析该行:含 "check"(字符串) 且含 "id"(整数) +# ⇒ 判定为 check 请求,原样逐字节转发(含 '\n')到 IPC 管道写端;该行不参与心跳判定。 +# * 父进程(scan)在**非阻塞**轮询点读该管道(见 VGuard.PollIpc),把完整一行交给本模块。 +# * 本模块执行检查并往 **stdout** 写**单行 JSON** 回执: +# 成功 {"check_result": true, "id": , "viper": {…发现项…}} (不含 check_msg) +# 失败 {"check_result": false, "id": , "check_msg": "<原因>"} +# +# 回执由仓库 json 库序列化(json.object_set + json.write(pool, obj, False)): +# 紧凑单行、转义与 UTF-8 透传由库保证、键序 = 插入序(见 includes/json/**)。 +# +# 检查内容(与扫盘候选过滤逐条同口径,口径单点来源): +# ① PE/.PRVERSN:VPe.TryReadPeInfo(打开失败的错误码经 VPe.PeOpenErr 区分 NOT_FOUND/NO_PERMISSION) +# ② --version :VProc.RunVersionProbe(超时 / 拉起失败的错误码 / 前 5 字符为 "Viper") +# +# 失败原因**只允许**以下固定英文字符串(大小写严格): +# NOT_FOUND 未找到文件(CreateFileA 失败且 GetLastError ∈ {2,3}) +# NO_PERMISSION 无权限(GetLastError ∈ {5,32},以及其余一切打开失败取值) +# NOT_EXE 非 PE(文件过短/读不出头部/MZ 缺失/e_lfanew 越界/PE 签名缺失/可选头 magic 非法) +# NO_PRVERSN 缺失 .PRVERSN 段 +# PRVERSN_ERR .PRVERSN 段异常(段定位/读取失败、段内容为空、段内容非法) +# PREFIX_ERR --version 拉起成功但输出前 5 字符不是 Viper +# EXE_ERR,0x 拉起 --version 失败,逗号 + 十六进制错误码(大写十六进制,与 VPe +# 的 "unknown(0x%X)" 同一写法;取值来自 VProc.ProbeLaunchErr) +# TIMEOUT --version 探测超时(VProc.Probe_TimedOut) +# CHANNEL_UNAVAILABLE 本模块的缓冲/回执池不可用(CheckInit 失败)⇒ 无法执行检查, +# 只给出"通道不可用"这一明确原因(降级回执,见 EmitReplyUnavailable) +# +# 优先级:check 比普通扫盘高。识别到请求后本模块**同步**完成"读 .PRVERSN → 执行 --version → +# 回执"整段,期间树扫描停在 VScan 的轮询点(PaceDir/PaceFile/探测前/轮首末)上不前进; +# 处理完 PollIpc 才返回扫盘循环。单线程串行 ⇒ 不存在"check 与树扫描交错"的窗口。 +# +# 每轮完整重校:VScan.RevalidateDiscovered 复用本模块的 VerifyExecutable(同一口径的 +# "文件存在 + PE/.PRVERSN 可解析 + --version 前 5 字符为 Viper"),不合格的从缓存剔除。 +# +# 内存归属: +# * 缓冲与回执工作池在 CheckInit 内一次性建立(_mbuddy 的字段缓冲 + stdlib 的池 arena), +# 进程生命周期内复用;回执每条先 Check_Pool.reset()(池 offset 归零)再构造; +# * ParseCheckLine 的解析树放在**本次调用专用**的 stdlib arena 上,返回前整块 free: +# 与缓存表(_mbuddy 上 CacheAdd 的持久副本)互不相干,不会悬空/双重释放 +# (与 VCache.ParseCacheText 同一手法与理由)。 +# +# 降级(fail-safe 但不静默关闭功能): +# * CheckInit 失败(缓冲/回执池分配失败)时,HandleCheckLine 仍会**识别请求并回执**一行 +# check_result=false + check_msg="CHANNEL_UNAVAILABLE":该行不依赖 _mbuddy/Check_Pool +# (用 stdlib 临时缓冲 + 固定模板直接产出,见 EmitReplyUnavailable),因此"请求已收到、 +# 但通道不可用"这一事实对请求方始终可见;只有连该临时缓冲都拿不到时才彻底不回复; +# * 每轮重校(VerifyExecutable)在 CheckInit 失败时返回 1 = 跳过重校、不剔除任何条目 +# (绝不把整表误判为不合格清掉)。该降级在扫盘启动时由 VScan.ScanInit 的告警一次性说明。 +# +# 注入前提:本模块的 _mbuddy 必须由 App/main.vp 与其它模块同一 MemBuddy 实例注入;未注入时 +# _mbuddy 指向零值静态存储(usable == NULL)⇒ 全部 alloc 返回 None ⇒ CheckInit 必失败。 +# +# 依赖本仓既有:VFs.Utf8ToAnsi/AnsiToUtf8/CopyRaw、VLog.*、VPe.TryReadPeInfo、 +# VProc.RunVersionProbe/VERSION_PROBE_DIAG_CAP、VGuid.NewGuidStr、VCache.CacheFind/ +# CacheAdd/CacheId/CacheVer/CacheArch/CacheDisp、json.parse/object/object_set/ +# string_val/int_val/bool_val/write、memhub.MemPool/NewMemPool、stdlib.malloc/free。 +# ============================================================ + +# 固定失败原因(check_msg 的全部取值;EXE_ERR 是前缀 + 十六进制码,见 FormatExeErr) +CK_NOT_FOUND: t.CDefine = "NOT_FOUND" +CK_NO_PERMISSION: t.CDefine = "NO_PERMISSION" +CK_NOT_EXE: t.CDefine = "NOT_EXE" +CK_NO_PRVERSN: t.CDefine = "NO_PRVERSN" +CK_PRVERSN_ERR: t.CDefine = "PRVERSN_ERR" +CK_PREFIX_ERR: t.CDefine = "PREFIX_ERR" # --version 拉起成功但输出前 5 字符不是 Viper +CK_TIMEOUT: t.CDefine = "TIMEOUT" +# 通道自身不可用(CheckInit 失败):降级回执的唯一原因串,见 EmitReplyUnavailable +CK_CHANNEL_UNAVAILABLE: t.CDefine = "CHANNEL_UNAVAILABLE" + +# check 命中入库时的 source 值(与扫盘侧 "workspace" 区分) +CK_SOURCE: t.CDefine = "check" + +# EXE_ERR 的原因串缓冲("EXE_ERR,0x" + 最多 8 位十六进制 + NUL) +CK_MSG_CAP: t.CDefine = 32 + +# 请求行解析上限(与 VGuard.GUARD_LINE_CAP 对齐:看门狗的行缓冲即本上限, +# 超长行在看门狗侧已被截断/丢弃,这里再兜一层,避免对异常长的输入做解析) +CK_LINE_MAX: t.CDefine = 4096 + +# 解析工作池容量 / 输入字节与固定余量:与 VCache.ParseCacheText 同一保守口径 +# (最坏 ≈ 每输入字节 1 个 JsonValue 节点 + 键名/值副本 + 数字临时缓冲 ≈ 80 字节/输入字节, +# 再按 128 放大覆盖 8 字节对齐浪费)。MemPool 耗尽会 exit(1),故必须先 AllocOk 预检。 +CK_ARENA_PER_BYTE: t.CDefine = 128 +CK_ARENA_SLACK: t.CDefine = 4096 + +# 回执工作池:72 KiB。单条回执的全部池分配(Need)见 CK_REPLY_NEED_FIXED 的推导。 +CK_REPLY_POOL_CAP: t.CDefine = 73728 + +# 单条回执在回执池上的固定开销上界: +# * JsonValue 节点 9 个(回执对象 + check_result + id + viper 子对象 + 6 个字符串值) +# × 单节点上界 72 字节 = 648(JsonValue 9 字段 × 8 字节,includes/json/__init__.vp:23-39); +# * 键名副本(含 NUL)8 个:check_result(13) + id(3) + viper(6) + id(3) + executable(11) +# + version(8) + arch(5) + source(7) + displayName(12) = 68; +# * json.write 的输出缓冲 4096(includes/json/__writer.vp:143-151 的缺省 cap); +# * 整数序列化的临时缓冲 32 字节(write 的 JSON_INT 分支 pool.alloc(32)); +# * 余量 512:各值副本的结尾 NUL 与按 MEMHUB_ALIGN(8) 向上取整的浪费。 +# 合计 648 + 68 + 4096 + 32 + 512 = 5356 ⇒ 取 5632(512 对齐)。 +CK_REPLY_NEED_FIXED: t.CDefine = 5632 + +# 回执里 executable / displayName 的取值长度上界:二者都经 Check_Conv(VFs.CONV_CAP 字节) +# 转出(WideCharToMultiByte 以 Cap 封顶并写结尾 NUL,失败时 CopyRaw 写空串)⇒ ≤ CONV_CAP-1。 +# 写 2047 = VFs.CONV_CAP(2048) - 1 的字面量(与本仓"t.CDefine 不引用跨模块常量"的惯例一致)。 +CK_CONV_MAX: t.CDefine = 2047 + +# 降级回执(EmitReplyUnavailable)的临时行缓冲字节数:stdlib.malloc,与 _mbuddy 无关, +# 因为该路径的定义就是"mb 上的缓冲拿不到"。长度上界(含换行与结尾 NUL): +# `{"check_result":false,"id":` 27 + id 最长 20(i64 十进制含负号) +# + `,"check_msg":"` 14 + CHANNEL_UNAVAILABLE 19 + `"}` 2 + '\n' 1 + NUL 1 = 84 ⇒ 取 128。 +CK_FALLBACK_CAP: t.CDefine = 128 + +# 由 App/main.vp 注入(与其它模块同一 MemBuddy 实例) +_mbuddy: rawptr[memhub.MemBuddy] + +# 一次性初始化的状态(Check_Tried 保证"初始化失败"不会每次调用都重试一遍分配) +Check_Tried: int = 0 +Check_Ready: int = 0 +Check_Handling: int = 0 # 1=正在处理一条 check(防重入;PollIpc 由扫盘多处调用) + +Check_Ver: bytes = None # .PRVERSN 版本串(VPe.VER_CAP) +Check_Arch: bytes = None # arch 字符串(VPe.ARCH_CAP) +Check_Probe: bytes = None # --version 捕获缓冲(VProc.VERSION_PROBE_DIAG_CAP) +Check_Msg: bytes = None # EXE_ERR 原因串缓冲(CK_MSG_CAP) +Check_Path: bytes = None # 请求里的 exe 路径(ANSI;VFs.PATH_BUF_CAP) +Check_Id: bytes = None # 新条目的 UUID(VGuid.GUID_STR_CAP) +Check_Disp: bytes = None # "Viper " + 版本串(VCache.DISP_CAP) +Check_Conv: bytes = None # ANSI → UTF-8 转换缓冲(VFs.CONV_CAP) +Check_Arena: bytes = None # 回执工作池 arena(stdlib.malloc,常驻) +Check_Pool: rawptr[memhub.MemPool] = None # 回执工作池(堆上就地构造,条目级 reset 复用) + +# ParseCheckLine 的结果(模块全局出参,与 VPe.Pe_RawPtr 同一惯例) +Req_Id: LONGLONG = 0 # 请求里的 id(json 库 int64 域;回执原样带回) +Req_PathOk: int = 0 # 1=Check_Path 已填(可执行检查);0=请求已识别但路径缓冲不可用 + + +# ============================================================ +# 初始化 +# ============================================================ + +# 请求路径缓冲(请求行解析与 CheckInit 共用)。看门狗进程只做"分类转发",不跑 CheckInit, +# 但它也要解析请求行(取出路径)⇒ 该缓冲由最先用到的一方按需分配,两边都不重复分配。 +def EnsurePathBuf() -> int: + global Check_Path + if Check_Path is None: + Check_Path = _mbuddy.alloc(VFs.PATH_BUF_CAP) + if Check_Path is None: + return 0 + return 1 + + +# 分配检查缓冲与回执工作池(幂等;失败只告警一次并由 Check_Ready 标记为不可用)。 +# 失败语义(fail-safe):check 请求回执 CHANNEL_UNAVAILABLE(见 EmitReplyUnavailable); +# 每轮重校直接跳过(不剔除任何条目)——绝不因"缓冲分配失败"而把整张缓存表误判为不合格清掉。 +# 告警按"哪一组分配失败"区分:四组缓冲来源不同(_mbuddy 上的字段缓冲 / stdlib 的池 arena), +# 同一句文案会让人无法判断是哪一处(历史现场:_mbuddy 未注入时四组全失败,只能看到同一句)。 +def CheckInit() -> int: + global Check_Tried, Check_Ready + global Check_Ver, Check_Arch, Check_Probe, Check_Msg + global Check_Id, Check_Disp, Check_Conv, Check_Arena, Check_Pool + if Check_Tried != 0: + return Check_Ready + Check_Tried = 1 + Check_Ver = _mbuddy.alloc(VPe.VER_CAP) + Check_Arch = _mbuddy.alloc(VPe.ARCH_CAP) + Check_Probe = _mbuddy.alloc(VProc.VERSION_PROBE_DIAG_CAP) + Check_Msg = _mbuddy.alloc(CK_MSG_CAP) + Check_Id = _mbuddy.alloc(VGuid.GUID_STR_CAP) + Check_Disp = _mbuddy.alloc(VCache.DISP_CAP) + Check_Conv = _mbuddy.alloc(VFs.CONV_CAP) + Check_Arena = stdlib.malloc(CK_REPLY_POOL_CAP) + if EnsurePathBuf() == 0: + VLog.LogStr(" 警告: check 缓冲分配失败(请求路径缓冲),check 通道不可用\n") + return 0 + if Check_Ver is None or Check_Arch is None or Check_Probe is None: + VLog.LogStr(" 警告: check 缓冲分配失败(版本/arch/探测缓冲),check 通道不可用\n") + return 0 + if Check_Msg is None or Check_Id is None: + VLog.LogStr(" 警告: check 缓冲分配失败(原因串/UUID 缓冲),check 通道不可用\n") + return 0 + if Check_Disp is None or Check_Conv is None or Check_Arena is None: + VLog.LogStr(" 警告: check 缓冲分配失败(展示串/转换缓冲/回执 arena),check 通道不可用\n") + return 0 + # 池对象走堆上就地构造(memhub.NewMemPool):Check_Pool 要跨帧使用,而本函数的栈帧 + # 在返回时即失效(与 VJson.JsonInit 同一理由,见 Debugger/memhub-lifetime.md)。 + Pool: rawptr[memhub.MemPool] = memhub.NewMemPool(Check_Arena, CK_REPLY_POOL_CAP) + if Pool is None: + VLog.LogStr(" 警告: check 回执工作池构造失败,check 通道不可用\n") + return 0 + Check_Pool = Pool + Check_Ready = 1 + return 1 + + +# ============================================================ +# 请求行解析(看门狗分类与父进程执行共用同一口径) +# ============================================================ + +# 用仓库 json 库解析一行文本,判定是否为 check 请求。 +# 返回 1 = 是(Req_Id 已填;Req_PathOk=1 时 Check_Path 也已填为 ANSI 路径),0 = 不是。 +# 判定口径(需求:不得用字符串猜测): +# 顶层是 JSON 对象,且 +# * 存在键 "check",其值是**非空字符串**(exe 路径); +# * 存在键 "id",其值是**整数**(json 库解析为 JSON_INT / int64)。 +# 其余一切情况(顶层非对象、缺键、类型不符、空路径、非 JSON、空行)⇒ 0。 +# 注意 "id" 的类型必须是**数字**(`"id": "1"` 是字符串,不算请求)。 +# Req_PathOk=0 的语义是"请求成立、但路径缓冲不可用"(_mbuddy 未注入/池不可用):仍返回 1, +# 让调用方回执 CHANNEL_UNAVAILABLE、让看门狗照常转发(绝不让一条 check 请求被误当成心跳输入)。 +# 实现:单遍遍历顶层子节点(与 VCache.FieldStr 同一只读遍历写法),两个键各自记"是否命中", +# 最后要求两者都命中——不依赖键的先后顺序,也不做任何字符串搜索。 +def ParseCheckLine(Txt: str) -> int: + global Req_Id, Req_PathOk + Req_Id = 0 + Req_PathOk = 0 + if Txt is None: + return 0 + L: t.CSizeT = string.strlen(Txt) + if L == 0 or L > t.CSizeT(CK_LINE_MAX): + return 0 + Cap: t.CSizeT = L * t.CSizeT(CK_ARENA_PER_BYTE) + t.CSizeT(CK_ARENA_SLACK) + Arena: bytes = stdlib.malloc(Cap) + if Arena is None: + return 0 + Pool: rawptr[memhub.MemPool] = memhub.MemPool(Arena, Cap) + if Pool is None: + stdlib.free(t.CPtr(Arena)) + return 0 + # 池容量预检(fail-closed):解析树的分配必须先确认放得下,否则 json.parse 内部的 + # pool.alloc 会走到 includes/memhub.vp 的 printf + exit(1)。Need 与 Cap 同源且 + # 新池 offset=0 ⇒ AllocOk(Need) 恒成立;此处只是把"容量推导被改小"变成可控降级。 + if Pool.AllocOk(Cap) == 0: + stdlib.free(t.CPtr(Arena)) + return 0 + Root: rawptr[json.JsonValue] = json.parse(Pool, Txt) + HasCk: int = 0 + HasId: int = 0 + if Root != None and Root.vtype == json.JSON_OBJECT: + cu: rawptr[json.JsonValue] = Root.child + while cu != None: + if cu.key != None: + if string.strcmp(cu.key, "check") == 0: + if cu.vtype == json.JSON_STRING and cu.str_val != None: + if string.strlen(cu.str_val) > 0: + HasCk = 1 + # 命令行/控制台输入按 UTF-8 送达;A 系列 API 需要 ANSI。 + # Utf8ToAnsi 在"非法 UTF-8"时原样拷贝,故 ANSI 输入也照常可用。 + # 路径缓冲不可用时保持 Req_PathOk=0,但仍算"是 check 请求"。 + if EnsurePathBuf() != 0: + VFs.Utf8ToAnsi(cu.str_val, Check_Path, VFs.PATH_BUF_CAP) + Req_PathOk = 1 + elif string.strcmp(cu.key, "id") == 0: + if cu.vtype == json.JSON_INT: + Req_Id = LONGLONG(cu.int_val) + HasId = 1 + cu = cu.next + stdlib.free(t.CPtr(Arena)) + if HasCk != 0 and HasId != 0: + return 1 + return 0 + + +# ParseCheckLine 的解析结果经模块全局出参暴露:Check_Path(ANSI 路径)、Req_Id(请求 id) +# 与 Req_PathOk(路径是否可用),与 VPe.Pe_RawPtr / VFs.LastReadSize 同一惯例 +# (同模块内直接读,跨模块只走函数)。 + + +# ============================================================ +# 失败原因映射 +# ============================================================ + +# PE 解析失败码(VPe.TryReadPeInfo 的 rc 表 + ParsePeHead 的 prc)→ 固定原因串。 +# 映射依据(rc 表见 VPe.vp TryReadPeInfo / ParsePeHead 的函数头注释): +# 1 CreateFileA 失败 → 按 OpenErr:2/3=NOT_FOUND;5/32=NO_PERMISSION;其它=NO_PERMISSION +# 2 GetFileSizeEx 失败(读不出头部)→ NOT_EXE +# 3 文件过短 (<0x40) → NOT_EXE +# 4 头部窗口缓冲分配失败(读不出头部)→ NOT_EXE +# 5 ReadFull 读取不足 (<0x40) → NOT_EXE +# 6 raw_size==0(段内容为空)或段越界(段定位失败)→ PRVERSN_ERR +# 7 整文件读取失败(段读取失败) → PRVERSN_ERR +# 101 Got<0x40 / 102 非 MZ / 103 e_lfanew 越界 / 104 非 PE 签名 / 105 magic 未知 → NOT_EXE +# 106 节表超出头部窗口(无法定位节表 ⇒ 段定位失败)→ PRVERSN_ERR +# 107 未找到 .PRVERSN → NO_PRVERSN +def MapPeFailure(Rc: int, OpenErr: ULONG) -> str: + if Rc == 1: + if OpenErr == 2 or OpenErr == 3: + return CK_NOT_FOUND + return CK_NO_PERMISSION + if Rc == 5 or Rc == 107: + return CK_NO_PRVERSN + if Rc == 6 or Rc == 7: + return CK_PRVERSN_ERR + if Rc >= 100: + prc: int = Rc - 100 + if prc == 7: + return CK_NO_PRVERSN + if prc == 6: + return CK_PRVERSN_ERR + return CK_NOT_EXE + return CK_NOT_EXE + + +# 把"拉起失败"的错误码格式化为 `EXE_ERR,0x<大写十六进制>`(写 Check_Msg,返回该串)。 +# 例:GetLastError=2 → "EXE_ERR,0x2";193 → "EXE_ERR,0xC1";0 → "EXE_ERR,0x0" +# (0 只会出现在"未到达任何 Win32 调用"的失败阶段,如参数/路径非法)。 +# 返回值指向模块级缓冲:调用方须立即使用(EmitReplyFail 会把它复制进回执池)。 +def FormatExeErr(Err: ULONG) -> str: + viperlib.snprintf(Check_Msg, t.CSizeT(CK_MSG_CAP), "EXE_ERR,0x%X", Err) + return str(Check_Msg) + + +# ============================================================ +# 检查执行 +# ============================================================ + +# 对一个 exe 做完整检查(PE/.PRVERSN + --version)。 +# 成功:返回 None,并把版本串/arch 写入 Check_Ver / Check_Arch、把 "Viper "+版本串 +# 写入 Check_Disp(供调用方构造回执与入库)。 +# 失败:返回固定的原因串(CK_* 常量或 FormatExeErr 的共享缓冲)。 +# 说明:本函数**不做缓存写回**,只做"检查 + 填缓冲";入库与"已存在则不重写"由调用方决定。 +def CheckExecutable(PathAnsi: str) -> str: + Check_Ver[0] = 0 + Check_Arch[0] = 0 + # ① PE / .PRVERSN(版本号与 arch 的静态来源) + rc: int = VPe.TryReadPeInfo(PathAnsi, Check_Ver, VPe.VER_CAP, Check_Arch, VPe.ARCH_CAP) + if rc != 0: + return MapPeFailure(rc, VPe.PeOpenErr()) + if string.strlen(str(Check_Ver)) == 0: + return CK_PRVERSN_ERR # 段在但内容为空(第一个字节即 NUL) + # ② --version 验证(进程正常结束且前 5 字符为 Viper 才算通过) + pc: int = VProc.RunVersionProbe(PathAnsi, Check_Probe, + t.CSizeT(VProc.VERSION_PROBE_DIAG_CAP), 0) + if pc == 2 or VProc.Probe_TimedOut != 0: + return CK_TIMEOUT + if pc != 0: + # 拉起失败(参数/命令缓冲/管道/CreateProcessA):统一报 EXE_ERR,0x<码> + return FormatExeErr(VProc.ProbeLaunchErr()) + if VProc.OutputHasViperPrefix(str(Check_Probe)) == 0: + # 拉起成功但输出前 5 字符不是 Viper ⇒ 独立原因 PREFIX_ERR(与段定位/读取/内容异常区分) + return CK_PREFIX_ERR + viperlib.snprintf(Check_Disp, t.CSizeT(VCache.DISP_CAP), "Viper %s", str(Check_Ver)) + return None + + +# 完整重校一个已发现项:文件存在 + PE/.PRVERSN 可解析 + `--version` 前 5 字符为 Viper +# (即 CheckExecutable 的全部条件,只是不需要版本/arch 的结果)。 +# 返回 1 = 通过(保留),0 = 不通过(调用方从缓存剔除)。 +# fail-safe:CheckInit 失败(缓冲/池不可用)时返回 1(不剔除),避免把整表误清空。 +# 该降级不逐条刷屏:CheckInit 只告警一次(按失败的那一组缓冲),扫描启动时由 VScan.ScanInit +# 再报一行"check 通道缓冲不可用"的轮级告警,两者共同说明"本轮不重校"的原因。 +def VerifyExecutable(PathAnsi: str) -> int: + if CheckInit() == 0: + return 1 + if CheckExecutable(PathAnsi) == None: + return 1 + return 0 + + +# ============================================================ +# 入库(check 命中) +# ============================================================ + +# 把 check 命中项写入缓存(**已存在则不重写**:不改其 id/字段,直接跳过)。 +# 返回 1 = 命中项已在缓存中(无论本轮是否新写入);0 = 缓存不可用。 +# 幂等性:CacheFind 按 ANSI 路径精确匹配(与扫盘 HandleCandidate 同一判定), +# 因此同一 exe 反复 check 不会重复入表,也不会刷新既有 id/version/arch/source。 +def TrackChecked(PathAnsi: str) -> int: + if VCache.CacheFind(PathAnsi) >= 0: + return 1 + if VCache.CacheAdd(PathAnsi, str(Check_Id), str(Check_Ver), str(Check_Arch), + CK_SOURCE, str(Check_Disp)) == 0: + VLog.LogStr(" 警告: check 命中但入库失败(容量已满或分配失败),本轮不跟踪该条目\n") + return 0 + return 1 + + +# ============================================================ +# 回执(stdout 单行 JSON) +# ============================================================ + +# 写一行 stdout(自带结尾 '\n',且**一次 WriteFile 写完**): +# json.write 的输出缓冲容量是 4096(缺省 cap),本文本远小于它,故 NUL 之后仍有空位放 +# 换行与新的 NUL。这样做是为了让"一行回执"始终是**单次写**——与别人(看门狗失败行、 +# 下一轮数组)写入不会在同一行中间交错。 +def WriteReplyLine(Txt: str) -> int: + n: t.CSizeT = string.strlen(Txt) + if n + 2 <= t.CSizeT(4096): + Txt[n] = '\n' + Txt[n + 1] = 0 + VLog.StdoutWrite(Txt) + return 1 + # 不可达(长度守卫保证文本远小于 4096):仅为不写出无换行的残行而保留的后备分支 + VLog.StdoutWrite(Txt) + VLog.StdoutWrite("\n") + return 1 + + +# 成功回执:{"check_result": true, "id": , "viper": {<发现项字典>}} +# viper 字典与既有发现项**同构同序**:id/executable/version/arch/source/displayName; +# source 固定 "check"(本次答复的来源)。已入库的 exe 用其缓存字段(id/version/arch/ +# displayName 不重写、不改动),保证同一 exe 的 id 稳定。 +def EmitReplyOk(ReqIdVal: LONGLONG, PathAnsi: str): + Id: str = str(Check_Id) + Ver: str = str(Check_Ver) + Arch: str = str(Check_Arch) + Disp: str = str(Check_Disp) + idx: int = VCache.CacheFind(PathAnsi) + if idx >= 0: + # 已在缓存中:不改其 id/字段("已存在则不写"),回执用缓存里的记录(id 稳定) + Id = VCache.CacheId(idx) + Ver = VCache.CacheVer(idx) + Arch = VCache.CacheArch(idx) + Disp = VCache.CacheDisp(idx) + else: + # 新命中:生成 UUID 后入库(失败仅告警:回执的 id 为空串,仍如实答复检查结果) + if VGuid.NewGuidStr(Check_Id, VGuid.GUID_STR_CAP) == 0: + VLog.LogStr(" 警告: CoCreateGuid 失败,check 入库项的 id 为空串\n") + Check_Id[0] = 0 + Id = str(Check_Id) + TrackChecked(PathAnsi) + Check_Pool.reset() + ValNeed: t.CSizeT = t.CSizeT(CK_CONV_MAX) + t.CSizeT(CK_CONV_MAX) + ValNeed = ValNeed + string.strlen(Id) + string.strlen(Ver) + string.strlen(Arch) + ValNeed = ValNeed + string.strlen(CK_SOURCE) + if Check_Pool.AllocOk(t.CSizeT(CK_REPLY_NEED_FIXED) + ValNeed) == 0: + VLog.LogStr(" 警告: check 回执工作池不足,未回复(不会写穿缓冲)\n") + return + Obj: rawptr[json.JsonValue] = json.object(Check_Pool) + if Obj == None: + VLog.LogStr(" 警告: check 回执对象分配失败,未回复\n") + return + json.object_set(Check_Pool, Obj, "check_result", json.bool_val(Check_Pool, True)) + json.object_set(Check_Pool, Obj, "id", json.int_val(Check_Pool, t.CInt64T(ReqIdVal))) + Sub: rawptr[json.JsonValue] = json.object(Check_Pool) + if Sub == None: + VLog.LogStr(" 警告: check 回执对象分配失败,未回复\n") + return + json.object_set(Check_Pool, Sub, "id", json.string_val(Check_Pool, Id)) + # executable / displayName 转 UTF-8 后**立即**交给库复制(Check_Conv 随后要复用) + if VFs.AnsiToUtf8(PathAnsi, Check_Conv, VFs.CONV_CAP) == 0: + VFs.CopyRaw(Check_Conv, VFs.CONV_CAP, PathAnsi) + json.object_set(Check_Pool, Sub, "executable", json.string_val(Check_Pool, str(Check_Conv))) + json.object_set(Check_Pool, Sub, "version", json.string_val(Check_Pool, Ver)) + json.object_set(Check_Pool, Sub, "arch", json.string_val(Check_Pool, Arch)) + json.object_set(Check_Pool, Sub, "source", json.string_val(Check_Pool, CK_SOURCE)) + if VFs.AnsiToUtf8(Disp, Check_Conv, VFs.CONV_CAP) == 0: + VFs.CopyRaw(Check_Conv, VFs.CONV_CAP, Disp) + json.object_set(Check_Pool, Sub, "displayName", json.string_val(Check_Pool, str(Check_Conv))) + json.object_set(Check_Pool, Obj, "viper", Sub) + Txt: str = json.write(Check_Pool, Obj, False) + if Txt == None: + VLog.LogStr(" 警告: check 回执序列化失败(工作池不足),未回复\n") + return + WriteReplyLine(Txt) + + +# 失败回执:{"check_result": false, "id": , "check_msg": "<原因>"} +def EmitReplyFail(ReqIdVal: LONGLONG, Msg: str): + Check_Pool.reset() + Need: t.CSizeT = t.CSizeT(CK_REPLY_NEED_FIXED) + if Msg != None: + Need = Need + string.strlen(Msg) + if Check_Pool.AllocOk(Need) == 0: + VLog.LogStr(" 警告: check 回执工作池不足,未回复(不会写穿缓冲)\n") + return + Obj: rawptr[json.JsonValue] = json.object(Check_Pool) + if Obj == None: + VLog.LogStr(" 警告: check 回执对象分配失败,未回复\n") + return + json.object_set(Check_Pool, Obj, "check_result", json.bool_val(Check_Pool, False)) + json.object_set(Check_Pool, Obj, "id", json.int_val(Check_Pool, t.CInt64T(ReqIdVal))) + json.object_set(Check_Pool, Obj, "check_msg", json.string_val(Check_Pool, Msg)) + Txt: str = json.write(Check_Pool, Obj, False) + if Txt == None: + VLog.LogStr(" 警告: check 回执序列化失败(工作池不足),未回复\n") + return + WriteReplyLine(Txt) + + +# 降级回执(实际输出为紧凑单行):{"check_result": false, "id": , "check_msg": "CHANNEL_UNAVAILABLE"} +# 仅在 CheckInit 失败(缓冲/回执池不可用)时使用,因此**不能**依赖 _mbuddy / Check_Pool: +# * 行缓冲走 stdlib.malloc(与 mb 池无关),写完立即 free(只在降级时发生,非常规路径); +# * 内容用固定模板 + viperlib.snprintf 直接产出,键名与固定 ASCII 原因串都无需转义, +# 与 json 库的输出形态一致(键序 check_result/id/check_msg、冒号后无空格、紧凑单行); +# 这样"请求已收到但通道不可用"对请求方始终可见,而不是静默不回复。 +# 返回 1=已回执,0=连临时缓冲都拿不到(此时确实无法回复,只能不回复)。 +def EmitReplyUnavailable(ReqIdVal: LONGLONG) -> int: + Fb: bytes = stdlib.malloc(CK_FALLBACK_CAP) + if Fb is None: + return 0 + viperlib.snprintf(Fb, t.CSizeT(CK_FALLBACK_CAP), + "{\"check_result\":false,\"id\":%lld,\"check_msg\":\"%s\"}\n", + t.CInt64T(ReqIdVal), CK_CHANNEL_UNAVAILABLE) + VLog.StdoutWrite(str(Fb)) + stdlib.free(t.CPtr(Fb)) + return 1 + + +# ============================================================ +# 一行请求的完整处理(由 VGuard.PollIpc 在非阻塞轮询点调用) +# ============================================================ + +# 执行一条 check 请求行:解析 → 检查 → 回执。返回 1=已处理(无论成功/失败都已回执), +# 0=不是 check 请求/未处理。 +# 防重入:Check_Handling 为 1 时直接返回 0——扫描侧的轮询点是"高频调用",但 check 的处理 +# 不会从 check 内部再次发起轮询;该标记只是把"未来若在检查路径上新增轮询"这类改动挡住。 +# 通道降级:CheckInit 失败(缓冲/回执池不可用)时不再静默丢弃,而是先识别请求再回执 +# CHANNEL_UNAVAILABLE(EmitReplyUnavailable),使"请求已收到、但无法执行检查"可被请求方区分。 +def HandleCheckLine(Txt: str) -> int: + global Check_Handling + if Check_Handling != 0: + return 0 + if CheckInit() == 0: + if ParseCheckLine(Txt) == 0: + return 0 + EmitReplyUnavailable(Req_Id) + return 1 + if ParseCheckLine(Txt) == 0: + return 0 + Check_Handling = 1 + Path: str = str(Check_Path) + Reason: str = CheckExecutable(Path) + if Reason == None: + EmitReplyOk(Req_Id, Path) + else: + EmitReplyFail(Req_Id, Reason) + Check_Handling = 0 + return 1 diff --git a/App/lib/VCli.vp b/App/lib/VCli.vp new file mode 100644 index 0000000..6a8fe4f --- /dev/null +++ b/App/lib/VCli.vp @@ -0,0 +1,538 @@ +import t, c +from stdint import * +import string +import sys +import lib.VLog as VLog +import lib.VDirs as VDirs + + +# ============================================================ +# VCli - 命令行解析(scan/ver、--cache/--once/--sleep/--pace/--pace-units/--need-confirm/ +# --need-confirm-timeout/--path/--verbose/--search-paths;手写解析,不引入 argparse) +# +# 从原 App/main.vp 的 RunScan 参数解析段平移,并新增可重复的 --path(需求 2): +# 一旦出现 --path 即置 VDirs 覆盖模式(仅收集路径、保序);存在性检查与 +# "不存在则告警并跳过该条"在 VDirs.BuildTable([0/3] 段内)执行。 +# --need-confirm <秒>:心跳确认间隔,缺省/0 = 关闭该功能(仅 scan 模式生效)。 +# 本轮改为"独立看门狗子进程"实现(见 VGuard.vp):父进程在本模块解析出 +# NeedConfirm/NeedConfirmTimeout 后由 App/main.vp 拉起子进程,扫盘侧不再参与确认。 +# --sleep <毫秒>:手动指定扫盘轮间隔(缺省 DEFAULT_SLEEP_MS=10000;0 = 不 sleep)。 +# --pace <毫秒>:扫盘节流点的睡眠时长(缺省 DEFAULT_PACE_SLEEP_MS=20;0 = 关闭节流)。 +# --pace-units :文件侧节流粒度——每 N 个文件一个节流点(缺省 DEFAULT_PACE_FILE_UNITS=64;1 = 每个文件)。 +# 两者"显式给出即启用":只要命令行出现其一,VScan 便打开节流(--once 下也生效); +# 未给出时 VScan 完全沿用其自身缺省(常驻启用/20ms/64,--once 关闭),行为逐字不变。 +# 目录侧粒度固定为 VScan.PACE_DIR_UNITS=16,不受 --pace-units 影响;--pace 0 时两个节流点都不 Sleep。 +# 取值经 PaceSet()/PaceSleepMs()/PaceFileUnits() 暴露给 VScan.RunScan。 +# --need-confirm-timeout <毫秒>:看门狗确认等待超时(缺省 DEFAULT_CONFIRM_TIMEOUT_MS=5000)。 +# --verbose(无值):打开详细过程日志(缺省 = 安静)。仅改变 stderr 日志详细度, +# 不影响 stdout 的单行 JSON 与缓存文件内容。取值经 Verbose() 暴露,由 App/main.vp +# 在参数解析后、任何日志之前设置到 VLog(VLog.SetVerbose)。 +# --search-paths(无值):**查询开关**——建好"会搜索的目录表"后逐行打印(stderr)并直接退出, +# 不扫盘、不拉起看门狗、不读写缓存,因此与 --cache 相互独立:未给 --cache 也可运行 +# (见 ParseScanArgs 末尾的必填校验)。取值经 SearchPaths() 暴露,由 App/main.vp 在 scan +# 分派中"拉起看门狗之前"分派到 VScan.RunSearchPathsQuery。 +# confirm-watch(内部隐藏子命令)的参数解析在 ParseWatchArgs(),不出现在用法里; +# 父进程为 verbose 时为子进程追加 --verbose,使看门狗按同一规则输出(WatchVerbose())。 +# ver 子命令的取参(sys._argv[2])在 VPe.RunVer 内,保持原样;ver 不解析开关, +# 其 verbose 由 HasVerbose()(argv 中出现 --verbose 即置位)识别。 +# 取参方式依据:sys._init_argv/_argc/_argv(includes/sys.vp:60-67)。 +# ============================================================ + +VET_VERSION: t.CDefine = "0.2.0" +SECS_MAX: t.CDefine = 86400 # --need-confirm 秒数上界(1 天;超出视为参数错误) +DEFAULT_SLEEP_MS: t.CDefine = 10000 # --sleep 缺省值(沿用原 SCAN_INTERVAL_MS) +SLEEP_MAX_MS: t.CDefine = 86400000 # --sleep 毫秒上界(1 天;超出视为参数错误) +DEFAULT_CONFIRM_TIMEOUT_MS: t.CDefine = 5000 # --need-confirm-timeout 缺省值 +CONFIRM_TIMEOUT_MAX_MS: t.CDefine = 86400000 # --need-confirm-timeout 毫秒上界(1 天) +# --pace <毫秒>:节流点睡眠时长,取值域 0..60000。0 = 关闭节流(两个节流点都不 Sleep)。 +# 上界取 1 分钟:节流只为降低扫盘对机器的持续压力,睡眠远超 1 分钟只会无谓拖慢单轮而无收益, +# 与 --sleep(轮间隔,上界 1 天)的用途不同,故不共用其宽松上界。 +DEFAULT_PACE_SLEEP_MS: t.CDefine = 20 # --pace 缺省值(沿用 VScan.PACE_SLEEP_MS,未给出时逐字保持现状) +PACE_SLEEP_MAX_MS: t.CDefine = 60000 # --pace 毫秒上界(1 分钟) +# --pace-units :文件粒度,取值域 1..1000000(每 N 个文件一个节流点;1 = 每个文件都停)。 +# 0 无意义("每 0 个文件"),与非法输入一并报错;上界 1000000 已足够稀疏(等同关闭文件侧节流)。 +DEFAULT_PACE_FILE_UNITS: t.CDefine = 64 # --pace-units 缺省值(沿用 VScan.PACE_FILE_UNITS,未给出时逐字保持现状) +PACE_UNITS_MAX: t.CDefine = 1000000 # --pace-units 文件数上界 +# --parent-pid 上界(DWORD 全域 4294967295)。 +# 注意:这里不能用 `t.CDefine = 4294967295`。CDefine 的物化规则(TPV +# ExprGenCdef._const_int_for_cdefine)把落在 u32 范围内的值一律按 **i32** 常量生成 +# (0xFFFFFFFF → i32 -1),上一轮构建 IR 现场: +# VET/.tpv_build/temp/df/df86f78f0f0481a1.ll:1214 +# %108 = call i32 @...ParseUint(i8* %107, i32 -1) +# 即 PID_MAX 传到 `Max: int` 时已成 -1。改宽类型全局常量后该值以 i64 物化 +# (字面量对 >32 位目标走 const_int64,ExprGenConst.translate_constant:105-107)。 +PID_MAX: LONGLONG = 4294967295 # --parent-pid 上界(DWORD 全域) + +Cache_Path: str = None +Once_Flag: int = 0 +Need_Confirm: int = 0 +Sleep_Ms: int = DEFAULT_SLEEP_MS +Need_Confirm_Timeout: int = DEFAULT_CONFIRM_TIMEOUT_MS +Verbose_Flag: int = 0 # --verbose(scan);1=详细日志 +# 顶层写法(`VET.exe --search-paths ...`,子命令位置直接写该开关)时由 App/main.vp 置位: +# ParseScanArgs 从 argv[2] 起扫并先把 Search_Paths_Flag 清零,故"子命令位置上的那个 +# --search-paths"它扫不到;用本标记在解析后重新并入,两种写法语义完全一致。 +Search_Paths_Implicit: int = 0 +Search_Paths_Flag: int = 0 # --search-paths(scan);1=打印将搜索的路径后直接退出 +# --pace/--pace-units(scan):显式给出即启用节流(--once 下也生效)。 +# Pace_Set 记"是否显式给出过两者之一";未给出时 VScan 完全沿用其缺省(常驻启用/20ms/64,--once 关闭)。 +Pace_Set: int = 0 # 1=命令行显式给出 --pace 或 --pace-units +Pace_Ms: int = DEFAULT_PACE_SLEEP_MS # --pace <毫秒>(0 = 关闭节流) +Pace_Units: int = DEFAULT_PACE_FILE_UNITS # --pace-units (每 N 个文件一个节流点;1 = 每个文件) +# --first-no-pace(scan):第一轮扫盘不启用节流(之后各轮按常规规则)。 +# 只影响"第 1 轮"这一件事:不改变 Pace_Set/Pace_Ms/Pace_Units,也不改变常驻/--once 的常规节流规则; +# 与 --pace 0(所有轮都不节流)互不替代——前者是"首轮抢速度",后者是"全程不限速"。 +First_No_Pace_Flag: int = 0 # 1=第一轮不启用节流 + +# confirm-watch(隐藏内部子命令)参数 +Watch_Interval: int = 0 # 秒(父进程由 --need-confirm 传入) +Watch_Timeout: int = -1 # 毫秒;-1 = 未给出(之后取 DEFAULT_CONFIRM_TIMEOUT_MS) +Watch_ParentPid: LONGLONG = 0 # 父进程 pid(宽类型:承载 DWORD 全域,见 PID_MAX) +Watch_IpcWrite: ULONGLONG = 0 # check 通道管道**写端句柄数值**(0 = 未给出 ⇒ 无通道) +Watch_Verbose: int = 0 # --verbose(由父进程在 verbose 时追加传入) + +# ParseUint64 的出参(模块全局,与 VPe.Pe_RawPtr / VFs.LastReadSize 同一惯例) +U64_Val: ULONGLONG = 0 + + +# 版本号经函数暴露给 VPe/VScan(跨模块"函数调用"路径明确支持; +# 避免依赖跨模块字符串 t.CDefine 取值——本仓已证实的是跨模块整型 t.CDefine) +def Version() -> str: + return VET_VERSION + + +def CachePath() -> str: + return Cache_Path + + +def Once() -> int: + return Once_Flag + + +def NeedConfirm() -> int: + return Need_Confirm + + +def SleepMs() -> int: + return Sleep_Ms + + +def NeedConfirmTimeout() -> int: + return Need_Confirm_Timeout + + +def Verbose() -> int: + return Verbose_Flag + + +def SearchPaths() -> int: + return Search_Paths_Flag + + +# --pace/--pace-units 取值:PaceSet()==0 时调用方(VScan.RunScan)应完全忽略后两者、沿用自身缺省, +# 以保证"未给出参数时行为逐字不变";==1 时三者的组合语义见 RunScan 的注释。 +def PaceSet() -> int: + return Pace_Set + + +def PaceSleepMs() -> int: + return Pace_Ms + + +def PaceFileUnits() -> int: + return Pace_Units + + +# --first-no-pace:是否要求"第 1 轮不启用节流"。1=是(仅第 1 轮;之后各轮按常规规则)。 +def FirstNoPace() -> int: + return First_No_Pace_Flag + + +# 顶层写法专用:`VET.exe --search-paths ...`(子命令位置就是该开关)。 +# 必须在 ParseScanArgs 之前调用——它把该意图记进 Search_Paths_Implicit,解析结束时再并入 +# Search_Paths_Flag;这样解析里"Search_Paths_Flag == 0 才要求 --cache"的放宽也一并生效。 +def SetSearchPathsImplicit(): + global Search_Paths_Implicit + Search_Paths_Implicit = 1 + + +def WatchInterval() -> int: + return Watch_Interval + + +def WatchTimeout() -> int: + return Watch_Timeout + + +def WatchParentPid() -> LONGLONG: + return Watch_ParentPid + + +def WatchIpcWrite() -> ULONGLONG: + return Watch_IpcWrite + + +def WatchVerbose() -> int: + return Watch_Verbose + + +# ver 子命令专用:ver 不解析开关(目标路径固定在 sys._argv[2]), +# 只要 argv 中出现 --verbose 即视为要求详细日志(argv 起点与 scan 一致,从下标 2 扫)。 +def HasVerbose() -> int: + i: int = 2 + while i < sys._argc: + A: str = sys._argv[i] + if A is not None: + if string.strcmp(A, "--verbose") == 0: + return 1 + i += 1 + return 0 + + +# 严格解析十进制非负整数:仅接受 '0'-'9'(空串/空白/正负号/小数点/其它字符 → -1=非法), +# 上界 Max(超出 → -1,避免无意义超大间隔与运算溢出)。 +# 不用 string.atoi(includes/string.vp:271):它对非法尾字符静默截断("3x" → 3), +# 与本参数"整数字面量"的约定不符。 +# +# 宽度(本函数修的就是这一条):Max / 累加器 v / 返回值统一用 LONGLONG(有符号 64 位)。 +# * 原 `Max: int`(i32)配 PID_MAX=4294967295 会被截断成 -1,于是 `v > Max` +# 对任何首位数字恒真 ⇒ 任何 --parent-pid 都判非法(看门狗启动即 exit 1)。 +# IR 证据:本仓上一轮构建 IR +# VET/.tpv_build/temp/df/df86f78f0f0481a1.ll:521 +# define i32 @df86f78f0f0481a1.ParseUint(i8* %S, i32 %Max) ; Max 是 i32 +# VET/.tpv_build/temp/df/df86f78f0f0481a1.ll:583 +# %35 = icmp sgt i32 %34, %Max ; 有符号比较,Max=-1 +# VET/.tpv_build/temp/df/df86f78f0f0481a1.ll:1214 +# %108 = call i32 @df86f78f0f0481a1.ParseUint(i8* %107, i32 -1) +# 后跟 :1217 `%110 = icmp sle i32 %109, 0` ⇒ P<=0 恒真 ⇒ 打错误并 ret 1。 +# * 不用 t.CSizeT/ULONGLONG:无符号返回承载不了 -1 非法哨兵,且各调用点的 +# `if X < 0`(本文件 :165/:180/:196/:253/:264/:275)在无符号比较下恒假 ⇒ 非法值被放行。 +# * 溢出(20 位数字也不会):循环顶部 `if v > Max: return -1` 是不变量守卫—— +# 进入每轮时必有 v <= Max,故 v*10 <= Max*10;本模块全部上界 <= 4294967295, +# 远小于 i64 上限/10,`v*10 + d` 不可能溢出;末尾 `or v < 0` 再兜一层 64 位 +# 回绕(防御性,按当前上界不可达)。 +def ParseUint(S: str, Max: LONGLONG) -> LONGLONG: + if S is None: + return -1 + ub: BYTEPTR = BYTEPTR(S) + if ub[0] == 0: + return -1 + v: LONGLONG = 0 + i: int = 0 + while ub[i] != 0: + ch: int = int(ub[i]) + if ch < 48 or ch > 57: + return -1 + if v > Max: # 上界短路:兼作 v*10 的溢出守卫(不变量兜底) + return -1 + v = v * 10 + LONGLONG(ch - 48) + if v > Max or v < 0: # 越界 / 64 位回绕(后者理论不可达) + return -1 + i += 1 + return v + + +# --need-confirm 的秒数解析:等价 ParseUint(S, SECS_MAX)(保持原判定口径与错误码 -1) +def ParseSecs(S: str) -> int: + return ParseUint(S, SECS_MAX) + + +# 严格解析十进制**无符号 64 位**字面量:仅接受 '0'-'9'(空串/空白/正负号/小数点/其它字符/溢出 ⇒ 失败)。 +# 成功返回 1 并把值写入 U64_Val;失败返回 0(U64_Val 置 0)。 +# +# 为什么不复用 ParseUint:`--ipc-write` 传的是**句柄的位模式**(父进程 ptrtoint 得到)。 +# 64 位 Windows 上内核句柄按符号扩展存放(最高位为 1),其无符号读数超过 INT64_MAX, +# 而 ParseUint 的返回域(LONGLONG)与 -1 非法哨兵都承载不了它;若截成 32 位传,符号扩展型 +# 句柄在子进程侧就还原不回原值(inttoptr 得到另一个地址)。故这里按 u64 全域解析。 +# 溢出守卫:不变量 v*10 + d ≤ 2^64-1 = 18446744073709551615 ⇒ v ≤ 1844674407370955161, +# 且 v 取该上界时 d 必须 ≤ 5。 +def ParseUint64(S: str) -> int: + global U64_Val + U64_Val = 0 + if S is None: + return 0 + ub: BYTEPTR = BYTEPTR(S) + if ub[0] == 0: + return 0 + v: ULONGLONG = 0 + i: int = 0 + while ub[i] != 0: + ch: int = int(ub[i]) + if ch < 48 or ch > 57: + return 0 + d: ULONGLONG = ULONGLONG(ch - 48) + if v > ULONGLONG(1844674407370955161): # 再乘 10 必溢出 + return 0 + if v == ULONGLONG(1844674407370955161) and d > ULONGLONG(5): + return 0 + v = v * ULONGLONG(10) + d + i += 1 + U64_Val = v + return 1 + + +def PrintUsage(): + VLog.LogStr("VET - Viper Environment Tool\n") + VLog.LogStr("用法:\n") + VLog.LogStr(" VET.exe ver [--verbose]\n") + VLog.LogStr(" 单次校验指定 exe(常用目录表 + .PRVERSN 段 + --version 前缀);\n") + VLog.LogStr(" --verbose 为与 scan 同名的无值开关(输出目标路径与解析/探测细节)。\n") + VLog.LogStr(" VET.exe scan --cache [--once] [--sleep <毫秒>] [--pace <毫秒>] [--pace-units ]\n") + VLog.LogStr(" [--first-no-pace] [--need-confirm <秒> [--need-confirm-timeout <毫秒>]]\n") + VLog.LogStr(" [--verbose] [--path ]...\n") + VLog.LogStr(" 常驻扫盘发现器;结果 JSON 写 stdout,缓存写 。\n") + VLog.LogStr(" VET.exe scan --search-paths [--path ]... [--verbose]\n") + VLog.LogStr(" VET.exe --search-paths [--path ]... [--verbose] (同上,可省 scan:纯查询)\n") + VLog.LogStr(" --once 只跑一轮(便于验证)。\n") + VLog.LogStr(" --sleep <毫秒> 每轮间隔(缺省 10000;0 = 不 sleep;上限 86400000)。\n") + VLog.LogStr(" --pace <毫秒> 节流点睡眠时长(缺省 20;0 = 关闭节流;上限 60000)。\n") + VLog.LogStr(" --pace-units 每 N 个文件一个节流点(缺省 64;1 = 每个文件都停;上限 1000000)。\n") + VLog.LogStr(" 显式给出 --pace 或 --pace-units 即启用节流,--once 下也生效;\n") + VLog.LogStr(" 未给出时沿用缺省(常驻启用;--once 关闭)。目录侧粒度恒为 16。\n") + VLog.LogStr(" --first-no-pace 第 1 轮扫盘不启用节流(只影响第 1 轮;之后各轮按常规规则)。\n") + VLog.LogStr(" 与 --pace 0 的区别:后者是全程不限速。仅常驻多轮时才有意义。\n") + VLog.LogStr(" --need-confirm <秒> 心跳确认:独立看门狗子进程每 <秒> 秒要求 stdin 输入一行\n") + VLog.LogStr(" {\"ok\": 1};超时内未输入或 ok!=1 则输出 {\"exit\":1, ...}\n") + VLog.LogStr(" 并终止本进程(退出码 1)。缺省/0 = 关闭;仅 scan 模式生效。\n") + VLog.LogStr(" --need-confirm-timeout <毫秒>\n") + VLog.LogStr(" 确认等待超时(缺省 5000;仅在给出 --need-confirm 时有意义)。\n") + VLog.LogStr(" --verbose 输出详细过程日志(进度/扫描根/逐条过滤/探测细节);\n") + VLog.LogStr(" 缺省安静:只留启动摘要、心跳确认提示、警告与轮末一行。\n") + VLog.LogStr(" --path 强制替换常用目录表(可重复);一出现即不做任何自动建表。\n") + VLog.LogStr(" --search-paths 打印将搜索的路径后退出:建好常用目录表即逐行列出\n") + VLog.LogStr(" (序号 + 路径 + 目录表来源,输出到 stderr),不扫盘、\n") + VLog.LogStr(" 不拉起看门狗、不读写缓存;此时 --cache 非必填。\n") + + +# 解析 scan 参数。返回 0=成功,1=参数错误(已打印用法/错误) +# 说明:本轮起 ParseScanArgs 由 App/main.vp 在分派 scan 时调用(看门狗生命周期需要 +# 在 RunScan 之前拿到 NeedConfirm),VScan.RunScan 不再自行解析。 +def ParseScanArgs() -> int: + global Cache_Path, Once_Flag, Need_Confirm, Sleep_Ms, Need_Confirm_Timeout, Verbose_Flag + global Search_Paths_Flag, Pace_Set, Pace_Ms, Pace_Units, First_No_Pace_Flag + Cache_Path = None + Once_Flag = 0 + Need_Confirm = 0 + Sleep_Ms = DEFAULT_SLEEP_MS + Need_Confirm_Timeout = DEFAULT_CONFIRM_TIMEOUT_MS + Verbose_Flag = 0 + Search_Paths_Flag = 0 + Pace_Set = 0 + Pace_Ms = DEFAULT_PACE_SLEEP_MS + Pace_Units = DEFAULT_PACE_FILE_UNITS + First_No_Pace_Flag = 0 + if Search_Paths_Implicit != 0: + # 顶层写法(`VET.exe --search-paths ...`):该开关位于 argv[1](子命令位置), + # 本函数从 argv[2] 起扫,扫不到它 ⇒ 由 main.vp 预先置位,这里并入(见其声明处)。 + Search_Paths_Flag = 1 + Has_Timeout: int = 0 + i: int = 2 + while i < sys._argc: + A: str = sys._argv[i] + if A is not None: + if string.strcmp(A, "--cache") == 0: + if i + 1 < sys._argc and sys._argv[i + 1] is not None: + Cache_Path = sys._argv[i + 1] + i += 1 + else: + VLog.LogStr("错误: --cache 需要一个路径参数\n") + return 1 + elif string.strcmp(A, "--once") == 0: + Once_Flag = 1 + elif string.strcmp(A, "--verbose") == 0: + Verbose_Flag = 1 # 无值开关:只改 stderr 详细度 + elif string.strcmp(A, "--search-paths") == 0: + Search_Paths_Flag = 1 # 无值开关:查询"会搜索的路径"后直接退出(不扫盘/不碰缓存) + elif string.strcmp(A, "--sleep") == 0: + if i + 1 < sys._argc and sys._argv[i + 1] is not None: + M: int = ParseUint(sys._argv[i + 1], SLEEP_MAX_MS) + if M < 0: + VLog.LogFmt1("错误: --sleep 需要一个 0..%u 的整数字面量毫秒数,实际为: ", + t.CSizeT(SLEEP_MAX_MS)) + VLog.LogFmtS1("%s\n", sys._argv[i + 1]) + PrintUsage() + return 1 + Sleep_Ms = M + i += 1 + else: + VLog.LogStr("错误: --sleep 需要一个毫秒数参数\n") + PrintUsage() + return 1 + elif string.strcmp(A, "--pace") == 0: + if i + 1 < sys._argc and sys._argv[i + 1] is not None: + P: int = ParseUint(sys._argv[i + 1], PACE_SLEEP_MAX_MS) + if P < 0: + VLog.LogFmt1("错误: --pace 需要一个 0..%u 的整数字面量毫秒数,实际为: ", + t.CSizeT(PACE_SLEEP_MAX_MS)) + VLog.LogFmtS1("%s\n", sys._argv[i + 1]) + PrintUsage() + return 1 + Pace_Ms = P + Pace_Set = 1 # 显式给出 ⇒ 节流启用(--once 下也生效) + i += 1 + else: + VLog.LogStr("错误: --pace 需要一个毫秒数参数\n") + PrintUsage() + return 1 + elif string.strcmp(A, "--pace-units") == 0: + if i + 1 < sys._argc and sys._argv[i + 1] is not None: + U: int = ParseUint(sys._argv[i + 1], PACE_UNITS_MAX) + # 0 与非法同为错误(ParseUint 非法返回 -1):0 会让"每 0 个文件一个节流点"无意义 + if U <= 0: + VLog.LogFmt1("错误: --pace-units 需要一个 1..%u 的整数字面量文件数,实际为: ", + t.CSizeT(PACE_UNITS_MAX)) + VLog.LogFmtS1("%s\n", sys._argv[i + 1]) + PrintUsage() + return 1 + Pace_Units = U + Pace_Set = 1 # 显式给出 ⇒ 节流启用(--once 下也生效) + i += 1 + else: + VLog.LogStr("错误: --pace-units 需要一个文件数参数\n") + PrintUsage() + return 1 + elif string.strcmp(A, "--first-no-pace") == 0: + # 无值开关:只要求"第 1 轮不节流",不改变节流参数本身、也不置 Pace_Set + # (第 2 轮起的节流规则与未给该开关时逐字相同)。 + First_No_Pace_Flag = 1 + elif string.strcmp(A, "--need-confirm") == 0: + if i + 1 < sys._argc and sys._argv[i + 1] is not None: + S: int = ParseSecs(sys._argv[i + 1]) + if S < 0: + # 两个固定元数调用拼一行(LogFmt1 只带整数,LogFmtS1 只带字符串) + VLog.LogFmt1("错误: --need-confirm 需要一个 0..%u 的整数字面量秒数,实际为: ", + t.CSizeT(SECS_MAX)) + VLog.LogFmtS1("%s\n", sys._argv[i + 1]) + PrintUsage() + return 1 + Need_Confirm = S + i += 1 + else: + VLog.LogStr("错误: --need-confirm 需要一个秒数参数\n") + PrintUsage() + return 1 + elif string.strcmp(A, "--need-confirm-timeout") == 0: + if i + 1 < sys._argc and sys._argv[i + 1] is not None: + T: int = ParseUint(sys._argv[i + 1], CONFIRM_TIMEOUT_MAX_MS) + if T < 0: + VLog.LogFmt1("错误: --need-confirm-timeout 需要一个 0..%u 的整数字面量毫秒数,实际为: ", + t.CSizeT(CONFIRM_TIMEOUT_MAX_MS)) + VLog.LogFmtS1("%s\n", sys._argv[i + 1]) + PrintUsage() + return 1 + Need_Confirm_Timeout = T + Has_Timeout = 1 + i += 1 + else: + VLog.LogStr("错误: --need-confirm-timeout 需要一个毫秒数参数\n") + PrintUsage() + return 1 + elif string.strcmp(A, "--path") == 0: + if i + 1 < sys._argc and sys._argv[i + 1] is not None: + VDirs.BeginOverride() + VDirs.AddOverridePath(sys._argv[i + 1]) + i += 1 + else: + VLog.LogStr("错误: --path 需要一个目录参数\n") + return 1 + else: + VLog.LogFmtS1("错误: 未知参数 %s\n", A) + PrintUsage() + return 1 + i += 1 + # --search-paths 是纯查询开关(只建表 + 打印,不读写缓存、不扫盘):未给 --cache 时放行。 + # 其余校验一律不放宽——给了 --cache 仍按原样接受(该查询只是不使用它)。 + if Cache_Path is None and Search_Paths_Flag == 0: + VLog.LogStr("错误: scan 模式必须提供 --cache \n") + PrintUsage() + return 1 + # 取舍:--need-confirm-timeout 单独出现时不报错(仅告警忽略)——它只是给 + # --need-confirm 的从属参数,报错会因标志顺序/复用脚本而无谓失败;告警避免静默无效。 + if Has_Timeout != 0 and Need_Confirm <= 0: + VLog.LogStr("警告: --need-confirm-timeout 未与 --need-confirm 一起使用,已忽略\n") + return 0 + + +# ============================================================ +# confirm-watch(内部隐藏子命令)参数解析 +# 命令行形如:VET.exe confirm-watch --interval <秒> --timeout <毫秒> --parent-pid +# --ipc-write <句柄数值> [--verbose] +# --interval 必填且 >=1;--timeout 缺省 DEFAULT_CONFIRM_TIMEOUT_MS;--parent-pid 必填。 +# --ipc-write 由父进程在建立 check 通道管道时传入(写端句柄的数值);缺省 0 = 无通道。 +# --verbose 由父进程在自身为 verbose 时追加,使看门狗按同一规则输出细节。 +# 该命令不出现在用法文本里,由父进程(scan + --need-confirm)自动拉起。 +# ============================================================ + +def ParseWatchArgs() -> int: + global Watch_Interval, Watch_Timeout, Watch_ParentPid, Watch_IpcWrite, Watch_Verbose + Watch_Interval = 0 + Watch_Timeout = -1 + Watch_ParentPid = 0 + Watch_IpcWrite = 0 + Watch_Verbose = 0 + Has_Pid: int = 0 + i: int = 2 + while i < sys._argc: + A: str = sys._argv[i] + if A is not None: + if string.strcmp(A, "--interval") == 0: + if i + 1 < sys._argc and sys._argv[i + 1] is not None: + S: int = ParseUint(sys._argv[i + 1], SECS_MAX) + if S <= 0: + VLog.LogStr("confirm-watch: --interval 需要一个 >=1 的整数字面量秒数\n") + return 1 + Watch_Interval = S + i += 1 + else: + VLog.LogStr("confirm-watch: --interval 需要一个秒数参数\n") + return 1 + elif string.strcmp(A, "--timeout") == 0: + if i + 1 < sys._argc and sys._argv[i + 1] is not None: + T: int = ParseUint(sys._argv[i + 1], CONFIRM_TIMEOUT_MAX_MS) + if T < 0: + VLog.LogStr("confirm-watch: --timeout 需要一个非负整数字面量毫秒数\n") + return 1 + Watch_Timeout = T + i += 1 + else: + VLog.LogStr("confirm-watch: --timeout 需要一个毫秒数参数\n") + return 1 + elif string.strcmp(A, "--parent-pid") == 0: + if i + 1 < sys._argc and sys._argv[i + 1] is not None: + P: LONGLONG = ParseUint(sys._argv[i + 1], PID_MAX) + if P <= 0: + VLog.LogStr("confirm-watch: --parent-pid 需要一个正整数\n") + return 1 + Watch_ParentPid = P + Has_Pid = 1 + i += 1 + else: + VLog.LogStr("confirm-watch: --parent-pid 需要一个 pid 参数\n") + return 1 + elif string.strcmp(A, "--ipc-write") == 0: + if i + 1 < sys._argc and sys._argv[i + 1] is not None: + if ParseUint64(sys._argv[i + 1]) == 0: + VLog.LogFmtS1("confirm-watch: --ipc-write 需要一个十进制非负整数字面量句柄值,实际为: ", + sys._argv[i + 1]) + VLog.LogStr("\n") + return 1 + Watch_IpcWrite = U64_Val + i += 1 + else: + VLog.LogStr("confirm-watch: --ipc-write 需要一个句柄数值参数\n") + return 1 + elif string.strcmp(A, "--verbose") == 0: + Watch_Verbose = 1 # 无值开关:由父进程按自身 verbose 追加 + else: + VLog.LogFmtS1("confirm-watch: 未知参数 %s\n", A) + return 1 + i += 1 + if Watch_Interval <= 0: + VLog.LogStr("confirm-watch: 缺少 --interval <秒>\n") + return 1 + if Has_Pid == 0: + VLog.LogStr("confirm-watch: 缺少 --parent-pid \n") + return 1 + if Watch_Timeout < 0: + Watch_Timeout = DEFAULT_CONFIRM_TIMEOUT_MS + return 0 diff --git a/App/lib/VDirs.vp b/App/lib/VDirs.vp new file mode 100644 index 0000000..7e8395d --- /dev/null +++ b/App/lib/VDirs.vp @@ -0,0 +1,302 @@ +import t, c +from stdint import * +import stdlib +import string +import memhub +import lib.VFs as VFs +import lib.VLog as VLog +import w32.win32base as wb + + +# ============================================================ +# VDirs - 常用目录表(自动构建 / --path 强制覆盖) +# +# 从原 App/main.vp 的 [0/3] 段平移;本轮新增"覆盖表"语义(需求 2): +# * 未给 --path:完全保持原自动建表(静态两条 + 各盘 Program Files + 桌面); +# * 给过 --path:不做任何自动构建,表 = 命令行给出的路径序列(保序、去重)。 +# 其它模块(VPe/VScan)只经 DirCount()/DirAt(i) 读取本表,不交叉写全局。 +# +# 日志详细度:目录表逐条列举、来源统计与"桌面兜底所用路径"属过程细节,走 VLog.Verbose*; +# "警告:" 类告警(--path 数量超限/表满/--path 不存在/桌面不可用/盘符枚举失败)始终输出; +# 其中被发现/处理的目录路径不再内嵌(仅 --verbose 时给出),命令行 --path 参数自身的回显除外。 +# +# 依赖本仓既有:wb.GetLastError(:62)、 +# VFs.PathIsDir / VFs.WideToUtf8Needed / VFs.WideToUtf8(本包 VFs.vp, +# 内部用 wf.GetFileAttributesA(includes/w32/win32file.vp:99) 与 +# wb.MultiByteToWideChar(:94))。 +# +# Win32 自声明(本仓无声明,按 Win32 原型自声明;x64 统一调用约定): +# kernel32!GetLogicalDrives: DWORD GetLogicalDrives(void); +# kernel32!GetDriveTypeA: UINT GetDriveTypeA(LPCSTR); +# kernel32!GetEnvironmentVariableA: DWORD GetEnvironmentVariableA(LPCSTR,LPSTR,DWORD); +# kernel32!SetErrorMode: UINT SetErrorMode(UINT); +# shell32!SHGetKnownFolderPath: HRESULT SHGetKnownFolderPath(REFKNOWNFOLDERID,DWORD,HANDLE,PWSTR*); +# ole32!CoTaskMemFree: void CoTaskMemFree(LPVOID); +# 原型出处:Win32 API 文档(winbase.h / shlobj_core.h / combaseapi.h)。 +# ============================================================ + +# Win32: typedef LONG HRESULT(有符号 32 位,负值才是失败) +HRESULT: t.CTypedef = t.CInt32T +# Win32: typedef const KNOWNFOLDERID *REFKNOWNFOLDERID(指向 16 字节 GUID) +REFKNOWNFOLDERID: t.CTypedef = t.CPtr + +extern def GetLogicalDrives() -> DWORD +extern def GetDriveTypeA(lpRootPathName: wb.LPCSTR) -> UINT +extern def GetEnvironmentVariableA(lpName: wb.LPCSTR, lpBuffer: CHARPTR, nSize: DWORD) -> DWORD +extern def SetErrorMode(uMode: UINT) -> UINT +extern def SHGetKnownFolderPath(rfid: REFKNOWNFOLDERID, dwFlags: DWORD, hToken: wb.HANDLE, + ppszPath: t.CPtr) -> HRESULT +extern def CoTaskMemFree(pv: t.CPtr) + +SEM_FAILCRITICALERRORS: t.CDefine = 0x0001 # SetErrorMode 标志(errhandlingapi.h) +KNOWNFOLDERID_SIZE: t.CDefine = 16 # KNOWNFOLDERID / GUID 字节数 +# winnt.h: FILE_ATTRIBUTE_REPARSE_POINT 在 VScan 使用 + +# GetDriveTypeA 返回值(winbase.h) +DRIVE_NO_ROOT_DIR: t.CDefine = 1 # 跳过:无根路径 +DRIVE_REMOVABLE: t.CDefine = 2 # 探测:可移动介质(U 盘/读卡器) +DRIVE_FIXED: t.CDefine = 3 # 探测:定长磁盘 +DRIVE_REMOTE: t.CDefine = 4 # 探测:网络盘 +DRIVE_CDROM: t.CDefine = 5 # 跳过:光驱 +# 决策:只跳过 NO_ROOT_DIR(1) 与 CDROM(5),其余(含 REMOVABLE)继续探测 Program Files; +# 空介质/卡读卡器触发的弹窗与阻塞由 AddFixedDriveProgramFiles 里的 SetErrorMode 抑制。 + +COMMON_DIR_CAPACITY: t.CDefine = 128 # 表容量(内联数组元素个数) +DRIVE_ROOT_CAP: t.CDefine = 8 # "X:\" + NUL 缓冲 +DESKTOP_SUFFIX_CAP: t.CDefine = 9 # r"\Desktop"(8) + NUL(1) + +# 由 App/main.vp 注入 +_mbuddy: rawptr[memhub.MemBuddy] + +# 显式长度内联数组 + 计数:避免 t.CArray[str] 单参数形态在 TPC/TransPyV 两链 +# 语义不一致(TPC=自动长度内联数组,TransPyV=指针) +Common_Directory: t.CArray[str, COMMON_DIR_CAPACITY] +Common_Dir_Count: int = 0 # 已用槽位数 + +# --path 覆盖项(保序;解析期只收集,构建期才做存在性检查并告警) +Override_Dir: t.CArray[str, COMMON_DIR_CAPACITY] +Override_Mode: int = 0 # 1 = 已出现 --path(强制替换) +Override_Count: int = 0 + + +# 表读取接口(其它模块只经此读取,不直接触碰 Common_Directory) +def DirCount() -> int: + return Common_Dir_Count + + +def DirAt(Idx: int) -> str: + return Common_Directory[Idx] + + +def IsOverride() -> int: + return Override_Mode + + +# --path 首次出现时调用(幂等:仅第一次清空覆盖项);之后不再自动建表 +def BeginOverride(): + global Override_Mode, Override_Count + if Override_Mode == 0: + Override_Mode = 1 + Override_Count = 0 + + +# 收集一条 --path 覆盖项(保序;存在性检查延后到 BuildTable,以便告警落在 [0/3] 段内) +def AddOverridePath(Path: str) -> int: + global Override_Count + if Path is None: + return 0 + if Override_Count >= COMMON_DIR_CAPACITY: + VLog.LogFmtIS(" 警告: --path 数量已达上限 (%d),忽略: %s\n", COMMON_DIR_CAPACITY, Path) + return 0 + Override_Dir[Override_Count] = Path + Override_Count += 1 + return 1 + + +# 无条件入表:仅做 strcmp 去重 + 容量上界,不做存在性检查。返回 1=已加入,0=跳过 +# 入参 Path 的生存期须覆盖整个进程(静态字面量或 MemBuddy 池内分配)。 +def AddCommonDirForce(Path: str) -> int: + global Common_Dir_Count + if Path is None: + return 0 + i: int = 0 + while i < Common_Dir_Count: + if string.strcmp(Common_Directory[i], Path) == 0: + return 0 + i += 1 + if Common_Dir_Count >= COMMON_DIR_CAPACITY: + VLog.LogFmt1(" 警告: 常用目录表已满 (%d),已忽略该条\n", t.CSizeT(COMMON_DIR_CAPACITY)) + return 0 + Common_Directory[Common_Dir_Count] = Path + Common_Dir_Count += 1 + return 1 + + +# 加入常用目录表:存在性检查 + 去重 + 容量上界(扫描/推导出的路径走这条)。 +# 返回 1=已加入,0=跳过(不存在 / 重复 / 表满) +def AddCommonDir(Path: str) -> int: + if Path is None: + return 0 + if VFs.PathIsDir(Path) == 0: + return 0 + return AddCommonDirForce(Path) + + +# 枚举存储盘的 X:\Program Files 与 X:\Program Files (x86)。返回本次新增条目数 +def AddFixedDriveProgramFiles() -> int: + # 进程级设置:抑制"请插入磁盘"等临界错误对话框,并让无介质驱动器立即返回错误, + # 避免探测空读卡器/光驱时长时间阻塞。副作用:影响本进程后续所有文件操作, + # 且本进程生命周期内持续有效(进程级、非线程级、不自动恢复)。 + SetErrorMode(SEM_FAILCRITICALERRORS) + Mask: DWORD = GetLogicalDrives() + if Mask == 0: + VLog.LogFmt1(" 警告: GetLogicalDrives 失败 (win32 err=%u)\n", wb.GetLastError()) + return 0 + Added: int = 0 + Letter: int = 0 + while Letter < 26: + if (Mask & (t.CUInt32T(1) << t.CUInt32T(Letter))) != 0: + Root: bytes = _mbuddy.alloc(DRIVE_ROOT_CAP) + if Root is not None: + Root[0] = t.CChar(65 + Letter) # 'A' + Letter + Root[1] = ':' + Root[2] = '\\' + Root[3] = 0 + Dt: UINT = GetDriveTypeA(Root) + # 决策:只跳过 NO_ROOT_DIR(1) 与 CDROM(5);REMOVABLE(2)/FIXED(3)/ + # REMOTE(4) 都继续探测 Program Files(空介质由上方 SetErrorMode 抑制弹窗)。 + if Dt != DRIVE_NO_ROOT_DIR and Dt != DRIVE_CDROM: + Pf: bytes = _mbuddy.alloc(VFs.PATH_BUF_CAP) + if Pf is not None: + string.strcpy(Pf, r"Q:\Program Files") # 'Q' 占位,下句改写为首字母 + Pf[0] = t.CChar(65 + Letter) + Added += AddCommonDir(str(Pf)) + Pfx: bytes = _mbuddy.alloc(VFs.PATH_BUF_CAP) + if Pfx is not None: + string.strcpy(Pfx, r"Q:\Program Files (x86)") + Pfx[0] = t.CChar(65 + Letter) + Added += AddCommonDir(str(Pfx)) + Letter += 1 + return Added + + +# 桌面兜底:%USERPROFILE%\Desktop(仅当 SHGetKnownFolderPath 不可用/失败时走) +# Buf 由调用方提供(>= PATH_BUF_CAP)。返回 1=已加入,0=未加入 +def AddDesktopFallback(Buf: bytes) -> int: + n: DWORD = GetEnvironmentVariableA("USERPROFILE", Buf, VFs.PATH_BUF_CAP) + # 还需追加 "\Desktop"(8 字节)+ NUL,n + 9 必须不超过缓冲容量 + if n == 0 or n + t.CUInt32T(DESKTOP_SUFFIX_CAP) > t.CUInt32T(VFs.PATH_BUF_CAP): + VLog.LogFmt1(" 桌面兜底: 获取 USERPROFILE 失败或过长 (win32 err=%u)\n", wb.GetLastError()) + return 0 + Buf[n] = 0 + string.strcat(Buf, r"\Desktop") + VLog.VerboseFmtS1(" 桌面兜底: 使用 %s\n", str(Buf)) + if AddCommonDir(str(Buf)) != 0: + return 1 + VLog.LogStr(" 警告: 桌面兜底路径不存在或非目录,未加入常用目录表\n") + return 0 + + +# 当前用户桌面:优先 SHGetKnownFolderPath(FOLDERID_Desktop)(能正确处理 +# OneDrive 等重定向场景),失败则显式退回 %USERPROFILE%\Desktop(不静默)。 +# 返回 1=已加入,0=未加入(含"不存在"如实报告) +def AddCurrentUserDesktop() -> int: + Buf: bytes = _mbuddy.alloc(VFs.PATH_BUF_CAP) + if Buf is None: + VLog.LogStr(" 警告: 桌面路径缓冲区分配失败\n") + return 0 + + # KNOWNFOLDERID 即 GUID;内存布局(小端):Data1(u32 LE) / Data2(u16 LE) / + # Data3(u16 LE) / Data4[8] 原样。FOLDERID_Desktop = + # {B4BFCC3A-DB2C-424C-B029-7FE99A87C641} ⇒ 16 字节: + # 3A CC BF B4 2C DB 4C 42 B0 29 7F E9 9A 87 C6 41 + # 字段宽度依据:wb.GUID 的 Data1: DWORD / Data2: WORD / Data3: WORD + # (includes/w32/win32base.vp:50-54)+ Microsoft GUID 序列化规范。 + # 不复用 wb.GUID:它把 Data4 建模为 BYTEPTR(指针),在 x64 上结构体虽同为 16 字节, + # 但偏移 8..15 存放的是指针值而非 GUID 的 Data4,内容错误 ⇒ 这里用裸 16 字节缓冲。 + GuidBuf: BYTEPTR = stdlib.malloc(KNOWNFOLDERID_SIZE) + if GuidBuf is None: + VLog.LogStr(" 警告: KNOWNFOLDERID 缓冲分配失败,改用兜底路径\n") + return AddDesktopFallback(Buf) + GuidBuf[0] = 0x3A + GuidBuf[1] = 0xCC + GuidBuf[2] = 0xBF + GuidBuf[3] = 0xB4 + GuidBuf[4] = 0x2C + GuidBuf[5] = 0xDB + GuidBuf[6] = 0x4C + GuidBuf[7] = 0x42 + GuidBuf[8] = 0xB0 + GuidBuf[9] = 0x29 + GuidBuf[10] = 0x7F + GuidBuf[11] = 0xE9 + GuidBuf[12] = 0x9A + GuidBuf[13] = 0x87 + GuidBuf[14] = 0xC6 + GuidBuf[15] = 0x41 + + Pwsz: WCHARPTR = None + Hr: HRESULT = SHGetKnownFolderPath(t.CPtr(GuidBuf), 0, None, t.CPtr(c.Addr(Pwsz))) + stdlib.free(t.CPtr(GuidBuf)) + if Hr < 0: + # 规范上失败时 *ppszPath 会被置 NULL;仍防御性释放非空返回值,杜绝泄漏 + if Pwsz is not None: + CoTaskMemFree(t.CPtr(Pwsz)) + VLog.LogFmt1(" 桌面: SHGetKnownFolderPath 失败 (hr=0x%X),改用兜底路径\n", t.CSizeT(t.CUInt32T(Hr))) + return AddDesktopFallback(Buf) + if Pwsz is None: + VLog.LogStr(" 桌面: SHGetKnownFolderPath 返回空指针,改用兜底路径\n") + return AddDesktopFallback(Buf) + + # UTF-16 → UTF-8:先问所需字节数(含结尾 NUL;Needed<=0 表示失败) + Needed: int = VFs.WideToUtf8Needed(Pwsz) + if Needed <= 0 or Needed > VFs.PATH_BUF_CAP: + CoTaskMemFree(t.CPtr(Pwsz)) + VLog.LogFmt2(" 桌面: 路径为空或过长 (%d 字节, 上限 %d),改用兜底路径\n", + t.CSizeT(t.CUInt32T(Needed)), t.CSizeT(VFs.PATH_BUF_CAP)) + return AddDesktopFallback(Buf) + Got: int = VFs.WideToUtf8(Pwsz, Buf, VFs.PATH_BUF_CAP) + CoTaskMemFree(t.CPtr(Pwsz)) # 用后立即释放:成功/失败分支的同一释放点 + if Got == 0: + VLog.LogFmt1(" 桌面: UTF-16→UTF-8 转换失败 (win32 err=%u),改用兜底路径\n", wb.GetLastError()) + return AddDesktopFallback(Buf) + + if AddCommonDir(str(Buf)) != 0: + return 1 + VLog.LogStr(" 警告: 桌面路径不存在或非目录,未加入常用目录表\n") + return 0 + + +# 打印常用目录表(逐条列举与合计属过程细节,仅 --verbose 时输出)。返回 0=表非空,1=表为空 +def PrintCommonDirs() -> int: + if Common_Dir_Count <= 0: + VLog.VerboseStr(" (表为空)\n") + return 1 + i: int = 0 + while i < Common_Dir_Count: + VLog.VerboseFmtIS(" [%d] %s\n", t.CSizeT(i), Common_Directory[i]) + i += 1 + VLog.VerboseFmt1(" 合计 %d 个常用目录\n", t.CSizeT(Common_Dir_Count)) + return 0 + + +# 构建并打印目录表(须在 _mbuddy 注入后调用)。返回 0=表非空,1=表为空。 +# 需求 2:日志显式区分"自动表" / "覆盖表(N 条)";覆盖模式下不存在的 --path 告警并跳过。 +def BuildTable() -> int: + if Override_Mode == 0: + AddCommonDirForce(r"C:\Viper") # 静态两条:无条件入表(不做存在性检查) + AddCommonDirForce(r"D:\Viper") + AddFixedDriveProgramFiles() # 各盘 Program Files / Program Files (x86) + AddCurrentUserDesktop() # 当前用户桌面 + VLog.VerboseFmt1(" 目录表来源: 自动表 (%u 条)\n", t.CSizeT(Common_Dir_Count)) + else: + # 强制替换:完全不做自动构建,表 = --path 序列(保序;去重由 AddCommonDirForce) + i: int = 0 + while i < Override_Count: + if VFs.PathIsDir(Override_Dir[i]) == 0: + VLog.LogFmtS1(" 警告: --path 不存在或非目录,跳过: %s\n", Override_Dir[i]) + else: + AddCommonDirForce(Override_Dir[i]) + i += 1 + VLog.VerboseFmt1(" 目录表来源: 覆盖表 (%u 条)\n", t.CSizeT(Common_Dir_Count)) + return PrintCommonDirs() diff --git a/App/lib/VFs.vp b/App/lib/VFs.vp new file mode 100644 index 0000000..498c06d --- /dev/null +++ b/App/lib/VFs.vp @@ -0,0 +1,324 @@ +import t, c +from stdint import * +import stdlib +import string +import memhub +import w32.win32file as wf +import w32.win32base as wb + + +# ============================================================ +# VFs - 路径 / 文件 / 通用字节串与编码工具 +# +# 从原 App/main.vp 平移: +# * 通用字节串:CopyRaw / DupStr / StrEqNoCase +# * 路径:JoinPath / PathIsDir / PathIsFile +# * 编码:Utf8ToAnsi / AnsiToUtf8(+ 本轮新增 WideToUtf8Needed/WideToUtf8) +# * 文件:ReadFull(+ 本轮抽出的 ReadWholeFile / WriteFileAll) +# +# 原 SeekTo(h, Off) 已删除:它以 `wf.SetFilePointerEx(h, t.CPtr(c.Addr(li)), None, wf.FILE_BEGIN)` +# 传 liDistanceToMove,而真实 Win32 该参数按值传 8 字节(传地址会让"距离"变成该地址的 +# 数值,文件指针被推到 EOF 之后,随后 ReadFile 只读回 0 字节)。该函数在当前源码中 +# 零调用点:VPe 需要段内容时改走整文件读(VPe.vp:283-297),ver [1/3] 也是整文件读 +# (VPe.vp:320-321)。需要定位读取时改用 32 位 wf.SetFilePointer(按值 LONG), +# 先例 includes/w32/fileio.vp:169。 +# +# 依赖本仓既有符号: +# wf.GetFileAttributesA(includes/w32/win32file.vp:99)、wf.FILE_ATTRIBUTE_DIRECTORY(:23)、 +# wf.CreateFileA(:88)、wf.GetFileSizeEx(:95)、wf.ReadFile(:90)、wf.WriteFile(:91)、 +# wf.GENERIC_READ(:5)、wf.GENERIC_WRITE(:6)、 +# wf.FILE_SHARE_READ/WRITE/DELETE(:10-12)、wf.OPEN_EXISTING(:16)、wf.CREATE_ALWAYS(:14)、 +# wf.FILE_ATTRIBUTE_NORMAL(:25)、wb.GetLastError(:62)、wb.CloseHandle(:64)、 +# wb.MultiByteToWideChar(:94)、wb.LARGE_INTEGER(:56)、wb.INVALID_HANDLE_VALUE(:9)。 +# +# Win32 自声明(本仓无声明;x64 统一调用约定,无需修饰): +# kernel32!WideCharToMultiByte: int WideCharToMultiByte(UINT,DWORD,LPCWCH,int,LPSTR,int,LPCCH,LPBOOL); +# 原型出处:Win32 API 文档 / winnls.h。 +# ============================================================ + +PATH_BUF_CAP: t.CDefine = 512 # 单条目录/文件路径缓冲 +CONV_CAP: t.CDefine = 2048 # 编码转换临时缓冲 +INVALID_FILE_ATTRIBUTES: t.CDefine = 0xFFFFFFFF # GetFileAttributesA 失败返回值 +CP_UTF8: t.CDefine = 65001 # Win32 代码页 UTF-8 +CP_ACP: t.CDefine = 0 # Win32 代码页 ANSI(A 系列 API 的当前页) + +extern def WideCharToMultiByte(CodePage: UINT, dwFlags: DWORD, lpWideCharStr: WCHARPTR, + cchWideChar: int, lpMultiByteStr: CHARPTR, cbMultiByte: int, + lpDefaultChar: wb.LPCSTR, lpUsedDefaultChar: BOOL | t.CPtr) -> int + +# 由 App/main.vp 注入(与其它模块同一 MemBuddy 实例) +_mbuddy: rawptr[memhub.MemBuddy] + +# ReadWholeFile / WriteFileAll 的结果输出(模块全局) +LastReadSize: t.CSizeT = 0 # 成功时的实际读取字节数 +LastReadErr: int = 0 # 1=打开失败 2=取大小失败 3=空文件 4=超过上限 5=分配失败 +LastReadWinErr: ULONG = 0 # 打开失败时的 GetLastError() +LastWriteDone: t.CSizeT = 0 # WriteFileAll 实际写入字节数 + + +# ============================================================ +# 通用字节串 +# ============================================================ + +# 复制 Src 到 Dst(含结尾 NUL),容量不足返回 0(不写穿) +def CopyRaw(Dst: bytes, Cap: int, Src: str) -> int: + if Dst is None: + return 0 + if Cap <= 0: + return 0 + if Src is None: + Dst[0] = 0 + return 0 + n: t.CSizeT = string.strlen(Src) + if n + 1 > t.CSizeT(Cap): + Dst[0] = 0 + return 0 + i: t.CSizeT = 0 + while i < n: + Dst[i] = Src[i] + i += 1 + Dst[n] = 0 + return 1 + + +# 用主池复制出 Src 的独立副本(返回 None 表示失败)。 +# 释放用 VFs._mbuddy.free(模块间共享同一池实例,见 main.vp 注入)。 +def DupStr(S: str) -> str: + if S is None: + return None + n: t.CSizeT = string.strlen(S) + b: bytes = _mbuddy.alloc(n + 1) + if b is None: + return None + i: t.CSizeT = 0 + while i < n: + b[i] = S[i] + i += 1 + b[n] = 0 + return str(b) + + +# ASCII 大小写无关比较(Windows 文件名不区分大小写) +def StrEqNoCase(A: str, B: str) -> int: + i: int = 0 + while True: + ca: int = int(A[i]) + cb: int = int(B[i]) + if ca >= 65 and ca <= 90: + ca += 32 + if cb >= 65 and cb <= 90: + cb += 32 + if ca != cb: + return 0 + if ca == 0: + return 1 + i += 1 + + +# ============================================================ +# 路径 +# ============================================================ + +# 拼接 Parent + "\" + Child 到 Out(容量 Cap),返回 1=成功,0=溢出 +# 说明:includes/os/path.vp:88 的 join 用模块全局 path._mbuddy 分配(本进程未注入 ⇒ 空指针), +# 且无显式容量上界。故此处自拼并做上界判断。 +def JoinPath(Out: bytes, Cap: int, Parent: str, Child: str) -> int: + if Out is None or Parent is None or Child is None: + return 0 + pl: t.CSizeT = string.strlen(Parent) + cl: t.CSizeT = string.strlen(Child) + need_sep: int = 0 + if pl > 0: + last: int = int(Parent[t.CInt(pl) - 1]) + if last != 47 and last != 92: # '/' or '\' + need_sep = 1 + total: t.CSizeT = pl + cl + t.CSizeT(need_sep) + if total + 1 > t.CSizeT(Cap): + return 0 + i: t.CSizeT = 0 + while i < pl: + Out[i] = Parent[i] + i += 1 + if need_sep != 0: + Out[i] = '\\' + i += 1 + j: t.CSizeT = 0 + while j < cl: + Out[i] = Child[j] + i += 1 + j += 1 + Out[i] = 0 + return 1 + + +# 路径是否为"已存在的目录":GetFileAttributesA 返回 != INVALID 且含 DIRECTORY 位 +def PathIsDir(Path: str) -> int: + Attrs: ULONG = wf.GetFileAttributesA(Path) + if Attrs == INVALID_FILE_ATTRIBUTES: + return 0 + if (Attrs & wf.FILE_ATTRIBUTE_DIRECTORY) != 0: + return 1 + return 0 + + +# 路径是否为"已存在的普通文件"(存在且非目录) +def PathIsFile(Path: str) -> int: + Attrs: ULONG = wf.GetFileAttributesA(Path) + if Attrs == INVALID_FILE_ATTRIBUTES: + return 0 + if (Attrs & wf.FILE_ATTRIBUTE_DIRECTORY) != 0: + return 0 + return 1 + + +# ============================================================ +# 编码转换(A 系列 API 走系统 ANSI;Viper 字符串约定 UTF-8) +# ============================================================ + +# UTF-8 → ANSI(失败/非法 UTF-8 时原样拷贝,视作已是 ANSI) +def Utf8ToAnsi(Src: str, Dst: bytes, Cap: int) -> int: + if Src is None: + Dst[0] = 0 + return 0 + n: int = wb.MultiByteToWideChar(CP_UTF8, 0, Src, -1, t.CPtr(0), 0) + if n <= 0: + CopyRaw(Dst, Cap, Src) + return 0 + W: WCHARPTR = stdlib.malloc(t.CSizeT(n) * 2) + if W is None: + CopyRaw(Dst, Cap, Src) + return 0 + wb.MultiByteToWideChar(CP_UTF8, 0, Src, -1, W, n) + got: int = WideCharToMultiByte(CP_ACP, 0, W, -1, Dst, Cap, None, None) + stdlib.free(t.CPtr(W)) + if got <= 0: + CopyRaw(Dst, Cap, Src) + return 0 + return 1 + + +# ANSI → UTF-8(失败时原样拷贝) +def AnsiToUtf8(Src: str, Dst: bytes, Cap: int) -> int: + if Src is None: + Dst[0] = 0 + return 0 + n: int = wb.MultiByteToWideChar(CP_ACP, 0, Src, -1, t.CPtr(0), 0) + if n <= 0: + CopyRaw(Dst, Cap, Src) + return 0 + W: WCHARPTR = stdlib.malloc(t.CSizeT(n) * 2) + if W is None: + CopyRaw(Dst, Cap, Src) + return 0 + wb.MultiByteToWideChar(CP_ACP, 0, Src, -1, W, n) + got: int = WideCharToMultiByte(CP_UTF8, 0, W, -1, Dst, Cap, None, None) + stdlib.free(t.CPtr(W)) + if got <= 0: + CopyRaw(Dst, Cap, Src) + return 0 + return 1 + + +# UTF-16 → UTF-8 所需字节数(含结尾 NUL);<=0 表示失败 +def WideToUtf8Needed(Src: WCHARPTR) -> int: + if Src is None: + return 0 + return WideCharToMultiByte(CP_UTF8, 0, Src, -1, t.CPtr(0), 0, None, None) + + +# UTF-16 → UTF-8(写入 Dst,容量 Cap),返回 1=成功 +def WideToUtf8(Src: WCHARPTR, Dst: bytes, Cap: int) -> int: + if Src is None or Dst is None: + return 0 + if WideCharToMultiByte(CP_UTF8, 0, Src, -1, Dst, Cap, None, None) <= 0: + return 0 + return 1 + + +# ============================================================ +# 文件读写 +# ============================================================ + +# 从当前文件指针读满 Want 字节(循环),返回实际读取字节数 +def ReadFull(h: wb.HANDLE, Buf: bytes, Want: t.CSizeT) -> t.CSizeT: + total: t.CSizeT = 0 + while total < Want: + chunk: t.CSizeT = Want - total + if chunk > 0x7FFFFFFF: + chunk = t.CSizeT(0x7FFFFFFF) + br: ULONG = 0 + if wf.ReadFile(h, Buf + total, ULONG(chunk), t.CPtr(c.Addr(br)), None) == 0: + break + if br == 0: + break + total += t.CSizeT(br) + return total + + +# 读取整个文件到 stdlib 分配的缓冲(含 NUL 结尾)。成功返回 bytes,调用方用 stdlib.free 释放; +# 失败返回 None,原因见 LastReadErr(4 仅在 MaxBytes>0 时产生)。 +# Share 由调用方给出(各调用点原有的 dwShareMode 语义保持一致)。 +def ReadWholeFile(Path: str, MaxBytes: t.CSizeT, Share: ULONG) -> bytes: + global LastReadSize, LastReadErr, LastReadWinErr + LastReadSize = 0 + LastReadErr = 0 + LastReadWinErr = 0 + H: wb.HANDLE = wf.CreateFileA(Path, wf.GENERIC_READ, Share, + None, wf.OPEN_EXISTING, wf.FILE_ATTRIBUTE_NORMAL, None) + if H == wb.INVALID_HANDLE_VALUE: + LastReadWinErr = wb.GetLastError() + LastReadErr = 1 + return None + li: wb.LARGE_INTEGER = wb.LARGE_INTEGER() + if wf.GetFileSizeEx(H, t.CPtr(c.Addr(li))) == 0: + wb.CloseHandle(H) + LastReadErr = 2 + return None + Sz: t.CSizeT = t.CSizeT(li.QuadPart) + if Sz <= 0: + wb.CloseHandle(H) + LastReadErr = 3 + return None + if MaxBytes > 0 and Sz > MaxBytes: + wb.CloseHandle(H) + LastReadErr = 4 + return None + Buf: bytes = stdlib.malloc(Sz + 1) + if Buf is None: + wb.CloseHandle(H) + LastReadErr = 5 + return None + Got: t.CSizeT = ReadFull(H, Buf, Sz) + wb.CloseHandle(H) + Buf[Got] = 0 + LastReadSize = Got + return Buf + + +# 把 Buf[0..Len) 全量写入 Path(CREATE_ALWAYS)。 +# 返回 1=完整写入,0=打开/写入失败,-1=写入不完整(实际字节见 LastWriteDone) +def WriteFileAll(Path: str, Buf: bytes, Len: t.CSizeT) -> int: + global LastWriteDone + LastWriteDone = 0 + if Buf is None: + return 0 + H: wb.HANDLE = wf.CreateFileA(Path, wf.GENERIC_WRITE, wf.FILE_SHARE_READ, + None, wf.CREATE_ALWAYS, wf.FILE_ATTRIBUTE_NORMAL, None) + if H == wb.INVALID_HANDLE_VALUE: + return 0 + total: t.CSizeT = 0 + while total < Len: + chunk: t.CSizeT = Len - total + if chunk > 0x7FFFFFFF: + chunk = t.CSizeT(0x7FFFFFFF) + bw: ULONG = 0 + if wf.WriteFile(H, Buf + total, ULONG(chunk), t.CPtr(c.Addr(bw)), None) == 0: + break + if bw == 0: + break + total += t.CSizeT(bw) + wb.CloseHandle(H) + LastWriteDone = total + if total < Len: + return -1 + return 1 diff --git a/App/lib/VGuard.vp b/App/lib/VGuard.vp new file mode 100644 index 0000000..70f14a6 --- /dev/null +++ b/App/lib/VGuard.vp @@ -0,0 +1,1279 @@ +import t, c +from stdint import * +import viperlib +import sys +import memhub +import lib.VLog as VLog +import lib.VCli as VCli +import lib.VCheck as VCheck +import lib.VPe as VPe +import w32.win32file as wf +import w32.win32base as wb +import w32.win32sync as wsync +import w32.win32process as wp +import w32.win32console as wc + + +# ============================================================ +# VGuard - 心跳确认看门狗(--need-confirm <秒>;仅 scan 模式) +# +# 设计(本轮重构:与扫盘彻底解耦): +# * 父进程(VET.exe scan --need-confirm x --need-confirm-timeout t)在 App/main.vp 中 +# 用 CreateProcessA 拉起"自身"作为**独立看门狗子进程**: +# VET.exe confirm-watch --interval x --timeout t --parent-pid <父 pid> +# 父进程不阻塞等待看门狗;看门狗只做: +# sleep(interval) → stderr 提示 → 在 timeout 内等 stdin 一行 {"ok": 1} +# → 成功续期继续循环;失败(期限到点仍未收到合法 {"ok": 1} / 形状合法但值不对 / +# 读失败 / stdin 无效)则 +# 在 stdout 逐字打印 {"exit":1, "msg": "timeout/ok!=1"}\n +# → TerminateProcess(父进程) → 自身退出码 1。 +# 其中"期限到点"是唯一的超时失败口:形状可疑的行被忽略并继续等待(见下方"快修"小节)。 +# * 扫盘侧完全不参与确认(VScan 已移除 GuardTick 挂钩);stdout 的"只放 JSON"约定不变。 +# +# 本轮改动:确认输入由"阻塞式读一行"改为"非阻塞取数 + deadline 驱动"。 +# * 旧实现的死结:WaitForMultipleObjects 只回答"stdin 有输入可读",但控制台处于 +# 行输入模式时(setconsolemode 文档:ENABLE_LINE_INPUT = ReadFile/ReadConsole +# "读到回车才返回"),随后的 ReadFile 会一直等到回车;于是"半行 + 不回车"把看门狗 +# 挂在读里,timeout 内是否收到合法 {"ok": 1} 无从判定。 +# * 新实现按输入形态分两路,两路共用同一 deadline、同一未消费输入 FIFO、同一行累积、 +# 同一分类(GuardClassifyLine)与同一取值口径(ParseOkOne 未改): +# ① 控制台(GetFileType=FILE_TYPE_CHAR 且 GetConsoleMode 成功): +# 进入前把 ENABLE_LINE_INPUT|ENABLE_ECHO_INPUT 清掉(掩码 GUARD_RAW_CLEAR_MASK; +# 取舍见下方"控制台模式取舍")→ 取记录(需要新数据才取,见"快修"),分两路: +# * 主路径:kernel32!ReadConsoleInputExA + CONSOLE_READ_NOWAIT(动态解析, +# 见下方"加固 1")——输入缓冲为空时立即返回,取数不再依赖"先查数量"; +# * 降级路径(旧系统无该导出):有界等"可读"(上限 min(剩余, GUARD_LEGACY_WAIT_MS)) +# → GetNumberOfConsoleInputEvents → ReadConsoleInputA(残余竞态见"加固 1")。 +# 两路都只认 KEY_EVENT(0x0001) 且 bKeyDown 的字符,过滤后**整批压入 FIFO**,再由 +# FIFO 逐字节喂行缓冲,累积到 Enter('\r' 归一成 '\n')为止 → 恢复进入前的控制台模式。 +# ② 管道 / 重定向 / 其它(非控制台):管道路径先用 PeekNamedPipe 取"当前真正可用的 +# 字节数",只读 min(可用, 缓冲余量);非管道(磁盘文件重定向等)读一块有界字节 +# (<= GUARD_CHUNK_CAP),读到 0 字节即 EOF;该批同样**整批压入 FIFO** 后再逐字节喂 +# 行缓冲;无可读数据时按 min(剩余, GUARD_POLL_MS) 等句柄推进。 +# * 兜底:任何一次等待/读取都以"剩余毫秒"(GuardRemainMs)为上限,到点即判超时; +# 读取只发生在"已确认有数据"之后,不存在"等到下一次按键 / 下一次回车才算完"的无界阻塞点。 +# +# 控制台模式取舍(为什么清 ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT): +# * 清 ENABLE_LINE_INPUT 是"能判定"的前提:文档(setconsolemode)明确 ENABLE_PROCESSED_INPUT +# 只在"ENABLE_LINE_INPUT 也启用"时才会把回车等字符交给系统处理、不进输入缓冲; +# 清掉行输入后,回车以 KEY_EVENT 记录形式到达本进程,我们才能自己判定"一整行"。 +# * 清 ENABLE_ECHO_INPUT 是"消除失效标志":该位只作用于 ReadFile/ReadConsole 的读回显, +# 且文档注明"仅在 ENABLE_LINE_INPUT 同时启用时可用";本模块根本不调用 ReadFile/ReadConsole +# 读控制台,位留着不产生任何效果。代价:按键不回显(用户看不到自己敲的字符), +# 提示行已写明应输入的内容,判定不受影响。 +# * 其余位一律不动,尤其保留 ENABLE_PROCESSED_INPUT:Ctrl+C 语义与旧实现完全一致 +# (文档:该位决定 CTRL+C 是否进入输入缓冲,还是交给处理函数)。 +# * 恢复策略:进入前用 GetConsoleMode 存原模式,成功切换过才置 Guard_RawActive; +# 正常(本行确认成功)/ 失败(期限到点、值不对、读写失败、EOF)/ 父进程退出 +# 三条路径都调 GuardLeaveRaw 写回原模式。Ctrl+C / Ctrl+Break 这条外部终止路径由 +# SetConsoleCtrlHandler 注册的处理例程在进程终止前写回("加固 2"); +# 只剩"被 TerminateProcess 强杀"(父进程 StopWatchdog 回收、心跳失败杀父)不执行 +# 任何用户态代码,仍无法恢复(该控制台输入模式可能残留"已清行输入")。 +# +# 加固 1:控制台取数改用 ReadConsoleInputExA + CONSOLE_READ_NOWAIT(消除竞态) +# * 旧组合的问题:GetNumberOfConsoleInputEvents 报出 N>0 之后再 ReadConsoleInputA, +# 若另一读者在这两步之间取走记录,ReadConsoleInputA 会**等至少一条记录** +# (该函数无超时参数)⇒ deadline 被突破。 +# * 新组合:ReadConsoleInputExA(hIn, buf, n, &read, CONSOLE_READ_NOWAIT) —— 文档明写 +# CONSOLE_READ_NOWAIT "输入缓冲为空也立即返回",取数与"此刻是否有数据"解耦, +# 不存在"查询 → 阻塞读"的两步组合。 +# * 该导出**没有导入库**(Windows 文档 Requirements:Library none),文档要求用 +# LoadLibrary/GetProcAddress 动态链接,DLL 内只有 ReadConsoleInputExA / ReadConsoleInputExW +# 两个名字。故本模块走**动态解析**(方案 a),而非静态 extern def(方案 b): +# - 静态 extern def 的名字必须是 ReadConsoleInputExA(无 ReadConsoleInputEx 这个导出; +# 本机 D:/msys64/mingw64/lib/libkernel32.a 里只有 ExA/ExW 两个导入符号,已用只读扫描确认), +# 且旧系统(缺该导出)会在**加载期**直接失败,整个 exe 起不来——为一次非阻塞取数 +# 付"VET 全面不可用"的代价不可接受; +# - 动态解析只依赖本仓既有声明 wb.GetModuleHandleA / wb.GetProcAddress +# (includes/w32/win32base.vp:66/65)+ 语言既有的函数指针变量间接调用 +# (t.Callable 注解 → i8** 槽;分派 CallGenMixin.vp:959-990、签名构造与间接调用 +# CallGenLeafKit.vp:741-812),**不新增任何链接期符号**,任何系统上都能加载。 +# - 解析失败(老系统无该导出)⇒ 回退到降级路径:有界等"可读" → +# GetNumberOfConsoleInputEvents → ReadConsoleInputA。该路径的残余竞态无法根除 +# (被取走记录时 ReadConsoleInputA 会等下一条记录),故把这一步之前的等待收紧为 +# min(剩余毫秒, GUARD_LEGACY_WAIT_MS),并在剩余为 0 时**不做**这次读。 +# * 只做一次解析(结果记在 Guard_ReadExState),不在每次取数里反复 GetProcAddress。 +# +# 加固 2:SetConsoleCtrlHandler 兜住控制台模式残留(清除行输入后 Ctrl+C 的写回) +# * 现状:Ctrl+C / Ctrl+Break / 关窗时进程由默认处理终止,来不及执行写回 ⇒ 该终端 +# 后续输入行为异常(行输入被清)。注册处理例程后,进程终止前会先回调本模块的 +# GuardCtrlHandler,把进入前的模式写回(幂等)。 +# * 注册时机:GuardEnterRaw 中"确实改过模式"时(Guard_RawActive=1)注册;摘除时机: +# GuardLeaveRaw 在写回模式后立刻 SetConsoleCtrlHandler(handler, FALSE)。注册与 +# "已切换模式"同生命周期,故 Guard_RawActive==0 ⇔ 未注册,不存在泄漏(见"静态论证")。 +# * handler 返回值一律 FALSE(交还默认处理):语义与加固前逐字一致——Ctrl+C/关窗仍由 +# 默认处理终止本进程。若返回 TRUE("已处理")则必须自行 ExitProcess,否则看门狗会继续 +# 跑到下一个 interval、再按心跳失败 TerminateProcess 父进程,把"用户主动 Ctrl+C" +# 变成"杀父";返回 FALSE 既保住原语义,也让 handler 只需一次 SetConsoleMode。 +# * handler 由系统在**新线程**上调用,只读模块级标量 + 一次 SetConsoleMode,不触碰 +# _mbuddy 池 / VLog 缓冲 / 其它模块级缓冲(无并发写,见"静态论证")。 +# +# check 请求通道(本轮新增;执行侧见 VCheck.vp): +# * 父进程建一对管道(GuardIpcCreate):读端留父进程(清继承位),写端可继承并把**数值 +# 句柄**经 `--ipc-write <数值>` 写进看门狗命令行;看门狗用 t.CPtr(数值) 还原句柄。 +# * 看门狗在 stdin 上收到一行后先**分类**(VCheck.ParseCheckLine,用仓库 json 库解析): +# 含 "check"(字符串) 与 "id"(整数) ⇒ check 请求:逐字节原样转发(补 '\n')到 IPC 写端, +# 该行**不计入心跳、也不判失败**(期限继续走;写失败只记 stderr); +# 其余 ⇒ 交给 GuardClassifyLine 三态判定(见下方"快修:批量输入与可疑行")。 +# * 父进程在 VScan 的轮询点非阻塞读该管道(PollIpc:PeekNamedPipe 取可用量 → 只读可用量 +# → 按 '\n' 切行 → 交 VCheck.HandleCheckLine 同步执行并回执)。 +# +# 快修:批量输入与可疑行(两处缺陷的修复;语义变化见本节末尾) +# * 缺陷 1「同批剩余字节被丢弃」:旧 GuardPumpStream 读到一块后逐字节喂行缓冲,**一行凑满 +# 就 return 1**,同批里"第一行之后"的字节(已被 ReadFile 从管道取走)永久丢失 ⇒ stdin +# 一次写入两行以上即出错;GuardPumpConsole 同病(一批 KEY_EVENT 记录里第一行之后的字符 +# 被丢)。修法:新增模块级**未消费输入 FIFO**(Guard_Pend/Guard_PendLen/Guard_PendPos, +# 容量 GUARD_PEND_CAP=128),两路取数共用,每轮三步: +# ① 先 GuardFeedPending():FIFO 里可能已有上一批留下的整行 ⇒ 凑满即返回 1(不发起读取); +# ② 需要新数据时才取一批(读入量同时受 GUARD_CHUNK_CAP / GUARD_REC_MAX 与 GUARD_PEND_CAP +# 约束,保证放得进 FIFO); +# ③ 把该批**全部**字节(控制台为"过滤后的字符")压入 FIFO,再 GuardFeedPending() 并返回 +# 其结果:该批可能自成一行,剩余字节留在 FIFO 由下一次调用继续喂。 +# 返回码语义不变:1=已凑满一行 / 0=无可读或尚未成行 / -1=读失败 / +# GUARD_PUMP_EOF=不可能再有输入。容量推导见 GUARD_PEND_CAP 声明处 +# (= max(GUARD_CHUNK_CAP=64, GUARD_REC_MAX=16) × 2 余量)。 +# * 缺陷 2「判定过严」:旧 GuardWaitConfirm 把"非 check、又非合法 {"ok": 1}"一律 +# GuardWatchFail,于是"管道分片/被撕裂的残行"与"用户真的输错"后果相同(都打 +# {"exit":1,…} + TerminateProcess(父))。修法:新增 GuardClassifyLine(Buf, Len) 三态分类: +# GUARD_LINE_SUSPECT(0) 形状可疑 ⇒ **忽略该行并继续在剩余期限内等待**(stderr 回显, +# 截断到 GUARD_SUSPECT_ECHO_MAX=120 字符;deadline 不变、不重置期限); +# GUARD_LINE_OK(1) ParseOkOne 通过 ⇒ 本行确认成功; +# GUARD_LINE_BADOK(2) 形状通过但 ParseOkOne 不过 ⇒ 致命失败(保留既有 stderr 文案 +# "[confirm] 收到非 {"ok": 1} 的输入: …" 与 GuardWatchFail(hParent, "ok != 1") 路径)。 +# "形状可疑"的判据(三选一即可疑,字节视角):① 去掉前导空格/制表符后首字符不是 '{'; +# ② 整行不含 '}';③ 行长 > GUARD_OK_LINE_MAX(256)(合法 {"ok": 1} 正常不超过 16 字节)。 +# check 行由 ParseCheckLine **先**判(见上),故第 ③ 项不会误伤长 check 行。 +# * 于是"唯一的超时失败口"是"期限到点仍未收到合法 {"ok": 1}";读失败 / EOF / stdin 无效 / +# 父进程退出等路径一律不变;既有 check 例外不变(优先分类转发、不计心跳、不判失败)。 +# * 被忽略的行**不会绕过期限**:两条忽略路径(check 转发 / 可疑行)都落到循环里的 +# "剩余为 0 ⇒ 超时失败"检查上(不 reset deadline)⇒ 持续涌入的可疑行或 check 行 +# 也会在期限到点收尾,"期限到点才判失败"对"无输入"与"只有被忽略的行"一致成立。 +# * 两处取舍(写在这里便于推翻): +# ① "形状合法但值不对"({"ok": 0} / {"ok": 2})**仍判致命失败**——"用户真的输错"这条 +# 保持原语义不放宽; +# ② **空行**由旧语义的"立即失败(stdin 输入为空行)"改为"按可疑行忽略"(首字符不是 '{'), +# 最终由期限到点失败 ⇒ stderr 原因由"stdin 输入为空行"变为"超时无输入",失败时机也 +# 从"收到空行"推迟到"期限到点"。这是本轮**可接受的语义变化**。 +# +# 为什么用子进程而不是线程(取舍): +# * 本仓有 includes/vthreading.vp,但线程与主流程共享同一地址空间: +# - VLog.Log_Buffer(VET/App/lib/VLog.vp:26)、VScan 的模块级进度/静态缓冲 +# (Scr_*/Prog_*,VScan.vp:60-88)都是进程级单例,线程并发写会互相踩踏, +# 需要为"日志/格式化"再加锁并让出 MemBuddy 分配路径; +# - MemBuddy(memhub.MemBuddy)池非线程安全,看门狗的分配会与扫盘分配竞争; +# - TerminateProcess 无法用于"只杀主线程",线程方案下"失败即终止整个程序" +# 要靠 ExitProcess/abort,且要保证不写缓存、不再打一轮 JSON,反而更绕。 +# * 子进程天然内存隔离:句柄表、MemBuddy 池、模块级缓冲互不干扰,失败即 +# TerminateProcess(父进程不做任何收尾 ⇒ 不写缓存、不再输出本轮数组 JSON), +# 与旧 sys.exit(1) 的"立即终止"语义一致且更强。 +# * 代价:多一个进程与一次 CreateProcessA;可被"父进程句柄"精确回收(见下)。 +# +# 生命周期与"无孤儿"保证: +# * 父正常结束(含 --once)/参数解析后提前返回:main.vp 调 StopWatchdog() → +# TerminateProcess(看门狗) + CloseHandle。 +# * 父异常退出(Ctrl+C / 被 TerminateProcess):看门狗持有父进程句柄(OpenProcess), +# 父进程对象一终止即置信号 ⇒ WaitForSingleObject / WaitForMultipleObjects 立即返回, +# 看门狗自行退出(不需要父侧配合)。 +# * 看门狗自身异常/崩溃:父进程的 Watch_Process 句柄仍可被 StopWatchdog 关闭回收。 +# +# 依赖本仓既有声明(未改 includes/): +# wf.GetStdHandle(includes/w32/win32file.vp:120)、wf.ReadFile(:90)、wf.GetFileType(:98)、 +# wf.SetHandleInformation(:140)、wf.HANDLE_FLAG_INHERIT(:136)、 +# wf.CreatePipe(:139)(check 通道管道;与 VProc.vp:121 同一用法)、 +# wf.GetModuleFileNameA(:133)、wf.STD_INPUT_HANDLE(:44)、wf.STD_OUTPUT_HANDLE(:45)、 +# wf.STD_ERROR_HANDLE(:46)、 +# wsync.WaitForSingleObject(includes/w32/win32sync.vp:37)、 +# wsync.WaitForMultipleObjects(includes/w32/win32sync.vp:39)、 +# wp.CreateProcessA(includes/w32/win32process.vp:88)、wp.TerminateProcess(:90)、 +# wp.GetExitCodeProcess(:91)、wp.OpenProcess(:93)、wp.GetCurrentProcessId(:95)、 +# wp.STARTF_USESTDHANDLES(:27)、 +# wp.STARTUPINFOA(:42)、wp.PROCESS_INFORMATION(:82)、 +# wb.WAIT_OBJECT_0(:15)、wb.WAIT_TIMEOUT(:16)、wb.WAIT_FAILED(:14)、 +# wb.INVALID_HANDLE_VALUE(:9)、wb.CloseHandle(:64)、wb.GetLastError(:62)、 +# wb.GetTickCount64(:80)、wb.Sleep(:77)、 +# wc.GetConsoleMode(includes/w32/win32console.vp:98)、wc.SetConsoleMode(:99)、 +# wc.GetNumberOfConsoleInputEvents(:102)、wc.ReadConsoleInputA(:100)(仅降级路径)、 +# wc.ENABLE_LINE_INPUT(:6)、wc.ENABLE_ECHO_INPUT(:7)、wc.KEY_EVENT(:29)、 +# wb.GetModuleHandleA(includes/w32/win32base.vp:66)、wb.GetProcAddress(:65) +# (加固 1 的动态解析:只取函数地址,不新增链接期符号)、 +# VPe.ReadU16(VET/App/lib/VPe.vp:82)、VPe.ReadU32(:88)(PE 小端读取,按字节偏移复用)、 +# sys.exit(includes/sys.vp:26)、memhub.MemBuddy.alloc(includes/memhub.vp:725)、 +# VCheck.ParseCheckLine / VCheck.HandleCheckLine(VET/App/lib/VCheck.vp:请求行分类与执行)、 +# VLog 原语(VET/App/lib/VLog.vp)、VCli.ParseUint/ParseUint64/ParseWatchArgs(VET/App/lib/VCli.vp)。 +# +# 本模块自声明(本仓 includes 未声明;prototype + DLL 见"API 证据表"): +# SYNCHRONIZE: winnt.h 0x00100000(kernel32!OpenProcess 的访问权限位)。 +# FILE_TYPE_DISK/CHAR/PIPE: fileapi.h 0x0001/0x0002/0x0003(kernel32!GetFileType 返回值)。 +# GUARD_RAW_CLEAR_MASK: ~(ENABLE_LINE_INPUT|ENABLE_ECHO_INPUT) = ~0x0006 = 0xFFFFFFF9(wincon.h)。 +# CONSOLE_READ_NOWAIT: 0x0002(ReadConsoleInputEx 的 wFlags;文档明确"输入缓冲为空也立即返回"; +# 同族的 CONSOLE_READ_NOREMOVE = 0x0001 本轮不用)。 +# CTRL_C_EVENT/CTRL_BREAK_EVENT/CTRL_CLOSE_EVENT/CTRL_LOGOFF_EVENT/CTRL_SHUTDOWN_EVENT: +# 0/1/2/5/6(consoleapi.h;处理例程的入参取值。本模块的例程对所有取值一律写回模式后返回 +# FALSE,故只在此记录常量值,不在代码里分支)。 +# kernel32!PeekNamedPipe(原型见下方 extern def;本仓 includes 无此声明)。 +# kernel32!SetConsoleCtrlHandler(原型见下方 extern def;本仓 includes 无此声明)。 +# kernel32!ReadConsoleInputExA:**无导入库、无头文件**,只能动态解析(本模块不声明 extern, +# 见"加固 1"与 GuardResolveReadEx)。 +# ============================================================ + +SYNCHRONIZE: t.CDefine = 0x00100000 # winnt.h +# OpenProcess 需要的访问权限:PROCESS_TERMINATE(0x0001) | SYNCHRONIZE(0x00100000) +WATCH_PARENT_ACCESS: t.CDefine = 0x00100001 +CONFIRM_WATCH_CMD: t.CDefine = "confirm-watch" # 内部隐藏子命令名 +# stdin 行缓冲:既要放 {"ok": 1},也要放 check 请求行({"check":"","id":}, +# 路径可达 MAX_PATH 量级)⇒ 取 4096(与 VCheck.CK_LINE_MAX 同值)。行超此上限按"一行结束" +# 处理(与旧实现"缓冲写满即截断成一行"一致;超长的 check 行会因路径被截断而判 NOT_FOUND)。 +GUARD_LINE_CAP: t.CDefine = 4096 +GUARD_STAMP_CAP: t.CDefine = 64 # 时间戳文本缓冲 +SELF_PATH_CAP: t.CDefine = 1024 # GetModuleFileNameA 输出缓冲 +# 命令行额外量(子命令 + interval/timeout/parent-pid/ipc-write + --verbose + NUL;句柄按 +# 最多 20 位十进制字符计。留足余量以免 snprintf 截断出"看似正常实则错参"的命令行) +WATCH_CMD_EXTRA_CAP: t.CDefine = 256 +WATCH_HANDLE_COUNT: t.CDefine = 2 # WaitForMultipleObjects 句柄个数 +# 拉起后"存活校验"的等待窗口(毫秒)。代价:每次 scan 启动多等这么一会儿(README 已注明); +# 取 400 是"够看门狗跑完参数解析/OpenProcess/首次等待"与"不显著拖慢启动"之间的折中。 +WATCH_ALIVE_PROBE_MS: t.CDefine = 400 + +# ---- 非阻塞确认输入(控制台 / 管道 / 重定向)---- +# GetFileType 返回值(fileapi.h;本仓 include 未声明这几个常量) +FILE_TYPE_DISK: t.CDefine = 0x0001 # 磁盘文件(含 `< file` 重定向) +FILE_TYPE_CHAR: t.CDefine = 0x0002 # 字符设备(控制台/LPT 等) +FILE_TYPE_PIPE: t.CDefine = 0x0003 # 管道/套接字(含 `|` 重定向) +# 输入形态(GuardInputKind 返回值) +GUARD_IN_CONSOLE: t.CDefine = 1 # 控制台(CHAR + GetConsoleMode 成功) +GUARD_IN_PIPE: t.CDefine = 2 # 管道(可用 PeekNamedPipe 预询可读量) +GUARD_IN_STREAM: t.CDefine = 3 # 其它流(磁盘文件等:只能有界读) +# 清位掩码:ENABLE_LINE_INPUT(0x0002)|ENABLE_ECHO_INPUT(0x0004) 的补(wincon.h) +GUARD_RAW_CLEAR_MASK: t.CDefine = 0xFFFFFFF9 +# INPUT_RECORD 内 KEY_EVENT_RECORD 字段偏移(x64 布局推导见 VET/README.md「控制台记录的读取口径」) +GUARD_KEY_DOWN_OFF: t.CDefine = 4 # KeyEvent.bKeyDown (BOOL) +GUARD_KEY_CHAR_OFF: t.CDefine = 14 # KeyEvent.uChar (WCHAR/CHAR union 头 2 字节) +GUARD_REC_SIZE: t.CDefine = 20 # sizeof(INPUT_RECORD)=EventType(2)+pad(2)+union(16) +GUARD_REC_MAX: t.CDefine = 16 # 单次 ReadConsoleInputA 最多读的记录数 +GUARD_REC_CAP: t.CDefine = 320 # 记录缓冲字节数(20*16) +GUARD_CHUNK_CAP: t.CDefine = 64 # 管道/文件单次 ReadFile 上限(字节) +GUARD_POLL_MS: t.CDefine = 20 # 无可读数据时的推进节拍(同 VScan.PACE_SLEEP_MS) +# 取数结果码(两路共用;-1 为"读取失败") +GUARD_PUMP_EOF: t.CDefine = -2 # 不可能再出现输入(ReadFile 读到 0 字节) + +# ---- 快修:未消费输入 FIFO(两路取数共用;见模块头「快修:批量输入与可疑行」)---- +# 容量的推导(常量实际值取自本文件上方声明): +# * 管道/文件路径单批入队量 = min(管道可用量, GUARD_CHUNK_CAP=64, GUARD_PEND_CAP) <= 64; +# * 控制台路径单批入队量 = 单次取的记录条数 <= GUARD_REC_MAX=16,且每条记录至多产出 1 个字节; +# * 故"单批入队量"的全局上界 = max(64, 16) = 64;再取 2 倍余量 => 128。 +# 为什么 2 倍余量就够(不必按 GUARD_LINE_CAP=4096 取):GuardPump* 只在"需要新数据"时才读, +# 而"需要新数据"的唯一条件是 GuardFeedPending() 返回 0(该返回只可能出现在 FIFO 被耗尽之后), +# 即"入队时 FIFO 必为空";此时 FIFO 只需容纳一个整批。余量用于吸收"未来把批上限调大"的改动。 +# 不变量(改动任一常量时须保持):GUARD_PEND_CAP >= GUARD_CHUNK_CAP,且 >= GUARD_REC_MAX +# (否则单批读入量放不进 FIFO;GuardPumpStream/GuardPumpConsole 里的两处夹取是该不变量的 +# 显式执行,GuardPendPut 的边界守卫是最后一道防线)。 +GUARD_PEND_CAP: t.CDefine = 128 # 未消费输入 FIFO 容量(字节) +# 可疑行回显的截断长度(stderr 只回显前缀,避免把超长输入整行打出去) +GUARD_SUSPECT_ECHO_MAX: t.CDefine = 120 +# 行分类结果码(GuardClassifyLine 返回值,见模块头「快修:批量输入与可疑行」) +GUARD_LINE_SUSPECT: t.CDefine = 0 # 形状可疑 ⇒ 忽略该行并继续等(期限不变) +GUARD_LINE_OK: t.CDefine = 1 # 合法 {"ok": 1} ⇒ 本行确认成功 +GUARD_LINE_BADOK: t.CDefine = 2 # 形状合法但值不对 ⇒ 致命失败(口径不放宽) +# 形状检查的长度上界:合法 {"ok": 1} 正常不超过 16 字节(去掉空格/制表符后更短), +# 取 256 留足余量(可容忍 "{ \"ok\" : 1 }" 之类的空白展开),超过即按"被撕裂的残行/噪声"处理。 +GUARD_OK_LINE_MAX: t.CDefine = 256 + +# ---- check 请求通道(看门狗 → 父进程的 IPC 管道;依附 --need-confirm)---- +# 行缓冲上限与看门狗侧 GUARD_LINE_CAP 同值(请求行由看门狗逐字节转发,长度不会超过它) +IPC_LINE_CAP: t.CDefine = 4096 +IPC_READ_CHUNK: t.CDefine = 1024 # 单次 ReadFile 上限(只读"已确认可用"的部分) +IPC_PIPE_SIZE: t.CDefine = 65536 # CreatePipe 的 nSize 提示(默认过小,给足以免看门狗写阻塞) + +# ---- 加固 1:ReadConsoleInputExA 动态解析(无导入库,只能 GetProcAddress)---- +CONSOLE_READ_NOWAIT: t.CDefine = 0x0002 # 输入缓冲为空也立即返回(文档 wFlags) +# ReadConsoleInputExA 的解析状态(Guard_ReadExState;0 = 未尝试解析) +GUARD_EX_OK: t.CDefine = 1 # 已解析到函数地址(走主路径) +GUARD_EX_MISSING: t.CDefine = 2 # 该导出不存在(走降级路径,不再重试) +# 降级路径"等可读"的单次上限(毫秒):把这一步的等待收紧,避免一次等待吃掉整个剩余预算; +# 取 5 是"够等一次输入到达"与"不显著拖慢判定"之间的折中(其后还有 GUARD_POLL_MS 节拍)。 +GUARD_LEGACY_WAIT_MS: t.CDefine = 5 +# SetConsoleCtrlHandler 的 Add 参数(consoleapi.h 的 TRUE/FALSE 同名,此处用字面量以免混淆) +GUARD_CTRL_ADD: t.CDefine = 1 +GUARD_CTRL_REMOVE: t.CDefine = 0 + +# Win32 自声明(本仓 includes/w32 无 PeekNamedPipe): +# kernel32!PeekNamedPipe: BOOL PeekNamedPipe(HANDLE hNamedPipe, LPVOID lpBuffer, +# DWORD nBufferSize, LPDWORD lpBytesRead, LPDWORD lpTotalBytesAvail, +# LPDWORD lpBytesLeftThisMessage); +# 原型出处:namedpipeapi.h / Kernel32.lib / kernel32.dll(project.vpj 已链接 kernel32)。 +# 语义:句柄必须是管道读端;不取走数据、立即返回;lpTotalBytesAvail = 当前可读字节数。 +extern def PeekNamedPipe(hNamedPipe: wb.HANDLE, lpBuffer: t.CPtr, nBufferSize: ULONG, + lpBytesRead: t.CPtr, lpTotalBytesAvail: ULONG | t.CPtr, + lpBytesLeftThisMessage: t.CPtr) -> BOOL + +# Win32 自声明(本仓 includes/w32 无 SetConsoleCtrlHandler): +# kernel32!SetConsoleCtrlHandler: BOOL SetConsoleCtrlHandler(PHANDLER_ROUTINE HandlerRoutine, +# BOOL Add); +# typedef BOOL (WINAPI *PHANDLER_ROUTINE)(DWORD dwCtrlType); // consoleapi.h:114-115 +# 原型出处:consoleapi.h / Kernel32.lib / kernel32.dll(本机 libkernel32.a 内含该导入符号)。 +# 第一个形参按本仓"函数指针/void* 用 t.CPtr 传"的写法声明;实参直接写处理例程的函数名, +# 由 TPV 把函数引用 bitcast 成函数指针(先例:includes/vthreading.vp:22-27 把函数名传给 +# CreateThread 的 VOIDPTR 形参;IR 现场 Test/BuddyTest/.tpv_build/temp/8e/ +# 8efc91cbab0452f0.ll:2672-2676 的 `bitcast i32 (i8*)* @... to i8*` + `call @CreateThread`)。 +# 返回 0 = 失败(GetLastError 取原因);Add=FALSE 时摘除。 +extern def SetConsoleCtrlHandler(HandlerRoutine: t.CPtr, Add: BOOL) -> BOOL + +# 由 App/main.vp 注入(与其它模块同一 MemBuddy 实例) +_mbuddy: rawptr[memhub.MemBuddy] + +# 看门狗句柄(父进程侧;StopWatchdog 负责关闭) +Watch_Process: wb.HANDLE = None # CreateProcessA → PROCESS_INFORMATION.hProcess +Watch_Thread: wb.HANDLE = None # PROCESS_INFORMATION.hThread + +# 看门狗侧缓冲(confirm-watch 内一次性分配) +Guard_Line: bytes = None # stdin 行缓冲 +Guard_Stamp: bytes = None # "当前时间"文本缓冲 +Guard_Dl: bytes = None # "本轮期限"文本缓冲 +Guard_Recs: bytes = None # 控制台 INPUT_RECORD 数组缓冲(原生字节,按偏移读) +Guard_Chunk: bytes = None # 管道/文件读取块缓冲 +# 未消费输入 FIFO(快修;两路取数共用):读取批次先整批压入这里,再逐字节喂 Guard_Line。 +# 语义:[Guard_PendPos, Guard_PendLen) 为"尚未喂给行缓冲"的字节区间;Pos==Len 表示空。 +Guard_Pend: bytes = None # 未消费输入 FIFO(容量 GUARD_PEND_CAP) +Guard_PendLen: int = 0 # FIFO 已写入的字节数(写入端游标) +Guard_PendPos: int = 0 # FIFO 已喂出的字节数(消费端游标) +Guard_Fill: int = 0 # 本轮已累积的行长度(Guard_Line 内,不含 NUL) +Guard_SavedMode: ULONG = 0 # 进入"原始按键"模式前的控制台模式 +Guard_RawActive: int = 0 # 1=当前处于已切换模式(退出前必须写回) + +# 看门狗侧:check 通道的管道写端(由父进程经 `--ipc-write <数值>` 传入;None=无通道)。 +# 只用于"原样转发 check 请求行",写失败仅记 stderr(不改变心跳语义)。 +Guard_IpcWrite: wb.HANDLE = None + +# ---- check 请求通道(父进程侧;非阻塞轮询)---- +# 管道由父进程(本模块)建立:读端留在父进程(清掉继承位),写端设为可继承并把**数值句柄** +# 经命令行 `--ipc-write <数值>` 交给看门狗(看门狗用 t.CPtr(数值) 得到句柄直接 WriteFile)。 +Ipc_Read: wb.HANDLE = None # 管道读端(父进程持有;None=未建立/已关闭) +Ipc_Line: bytes = None # 行累积缓冲(IPC_LINE_CAP) +Ipc_Chunk: bytes = None # 单次读取块(IPC_READ_CHUNK) +Ipc_Fill: int = 0 # 已累积的字节数(不含 NUL) + +# 加固 1:ReadConsoleInputExA 的函数指针槽(t.Callable 注解 → i8** 槽,调用即间接 call; +# 形参表按 ABI 记:HANDLE/PINPUT_RECORD/LPDWORD 都是 i8*,DWORD=i32,wFlags=USHORT=i16)。 +# 值由 GuardResolveReadEx 一次性填入;Guard_ReadExState 取值为 0/GUARD_EX_OK/GUARD_EX_MISSING。 +Guard_ReadEx: t.Callable[[t.CPtr, t.CPtr, ULONG, t.CPtr, USHORT], BOOL] = None +Guard_ReadExState: int = 0 + +# 加固 2:控制事件处理例程的注册状态与"已切换模式"的控制台句柄 +# (两者都在 GuardCtrlHandler 所在的系统线程里被读取,故只在主线程注册前写入一次) +Guard_ConHandle: wb.HANDLE = None # GuardEnterRaw 改过模式的那个控制台句柄 +Guard_CtrlHandlerOn: int = 0 # 1=已注册(GuardLeaveRaw 负责摘除) + + +# WaitForMultipleObjects 需要一个"句柄数组"(HANDLE 即 VOIDPTR,x64 下 8 字节)。 +# 用两个 HANDLE 字段的类型化结构体,按声明顺序连续存放 = 16 字节,恰为 Win32 期望。 +class WATCH_HANDLES(): + H0: wb.HANDLE + H1: wb.HANDLE + + +# ============================================================ +# check 请求通道:IPC 管道(父进程侧:建立 / 关闭 / 非阻塞轮询) +# +# 为什么用"管道 + 数值句柄经命令行传递": +# * 父子之间已有 stdin/stdout/stderr 三个共享句柄,但它们都被"用户输入 / 结果 JSON / +# 日志"占用(stdout 的契约是"只放单行 JSON",不能被请求流污染),故另开一对管道; +# * 管道由父进程 CreatePipe 建立:读端留在父进程(SetHandleInformation 清掉继承位), +# 写端保持可继承(bInheritHandle=1)并随 CreateProcessA(bInheritHandles=1) 进入看门狗; +# 看门狗侧无需知道管道任何信息,只需 "句柄数值" ⇒ 经命令行 `--ipc-write <数值>` 传入; +# * 传"数值"而不是"复制/命名管道":句柄值本身就是 pid 一样的小整数,子进程用 +# t.CPtr(数值)(即 ptrtoint/inttoptr 的逆运算)即可还原为可用的 HANDLE。 +# 句柄值按 u64 位模式传递(ParseUint64 严格解析),因此内核句柄那种"高位为 1 的 +# 符号扩展值"也能原样还原,不做 32 位截断。 +# * 父进程在 CreateProcessA 成功后**立即关掉自己的写端副本**:此后本进程再拉起 +# `--version` 子进程时不会把这个写端一起继承出去(读端本就不可继承)。 +# +# 父进程侧读取**绝不阻塞**: +# * 先 PeekNamedPipe 取"当前真正可用的字节数";可用为 0 就直接返回(不调用 ReadFile); +# * 只读 min(可用, IPC_READ_CHUNK) 字节——ReadFile 在"确有数据可读"时立即返回; +# * 每次轮询的数据量上界 = 进入时管道中已有的字节数(排空后即返回),无任何"等下一次到来", +# 故轮询点可以放在扫盘的高频路径上(PaceDir/PaceFile)而不会拖慢/挂住扫盘。 +# +# 行切分:与看门狗侧同一口径——'\n' 结束一行(看门狗把行原样转发时补的那个 '\n'); +# 行内容不含 '\n'(看门狗侧 GuardFeedByte 已把 '\n' 排除在缓冲之外)。 +# 超长行(≥ IPC_LINE_CAP-1 仍未见到 '\n')按"丢弃该行"处理(Ipc_Fill 归零), +# 既不越界也不把两行粘成一行。 +# ============================================================ + +# 建立 IPC 管道:成功返回**写端句柄**(由调用方交给看门狗并在用完后 CloseHandle), +# 失败返回 None(调用方按"看门狗拉起失败=致命"处理)。读端存入 Ipc_Read。 +def GuardIpcCreate() -> wb.HANDLE: + global Ipc_Read, Ipc_Line, Ipc_Chunk, Ipc_Fill + GuardIpcClose() # 幂等:如已有旧管道先关掉 + if Ipc_Line is None: + Ipc_Line = _mbuddy.alloc(IPC_LINE_CAP) + if Ipc_Chunk is None: + Ipc_Chunk = _mbuddy.alloc(IPC_READ_CHUNK) + if Ipc_Line is None or Ipc_Chunk is None: + return None + Sa: wb.SECURITY_ATTRIBUTES = wb.SECURITY_ATTRIBUTES() + Sa.nLength = t.CUInt32T(wb.SECURITY_ATTRIBUTES.__sizeof__()) + Sa.lpSecurityDescriptor = t.CPtr(0) + Sa.bInheritHandle = 1 # 两端先都建为可继承,随后单方面清读端 + hRead: wb.HANDLE = None + hWrite: wb.HANDLE = None + if wf.CreatePipe(t.CPtr(c.Addr(hRead)), t.CPtr(c.Addr(hWrite)), + t.CPtr(c.Addr(Sa)), ULONG(IPC_PIPE_SIZE)) == 0: + VLog.LogFmt1("[confirm] 警告: check 通道 CreatePipe 失败 (GetLastError=%u)\n", + t.CSizeT(wb.GetLastError())) + return None + # 读端只在父进程用:清掉继承位,避免被任何子进程(看门狗、--version 探测)继承 + wf.SetHandleInformation(hRead, wf.HANDLE_FLAG_INHERIT, 0) + wf.SetHandleInformation(hWrite, wf.HANDLE_FLAG_INHERIT, wf.HANDLE_FLAG_INHERIT) + Ipc_Read = hRead + Ipc_Fill = 0 + return hWrite + + +# 关闭 IPC 读端(幂等;StopWatchdog 与 SpawnWatchdog 的失败路径都会调)。 +def GuardIpcClose(): + global Ipc_Read, Ipc_Fill + if Ipc_Read is not None: + wb.CloseHandle(Ipc_Read) + Ipc_Read = None + Ipc_Fill = 0 + + +# 非阻塞轮询 IPC 读端:把"当前已可用"的字节全部取走并按 '\n' 切行,逐行交给 VCheck 处理 +# (处理是同步的:check 的"读 .PRVERSN → --version → 回执"整段在本次调用内完成)。 +# 返回本次已处理的 check 请求行数(0 = 无数据/非 check 行/通道未建立)。 +def PollIpc() -> int: + global Ipc_Fill + if Ipc_Read is None or Ipc_Read == wb.INVALID_HANDLE_VALUE: + return 0 + Done: int = 0 + while True: + Avail: ULONG = 0 + if PeekNamedPipe(Ipc_Read, t.CPtr(0), 0, t.CPtr(0), t.CPtr(c.Addr(Avail)), t.CPtr(0)) == 0: + return Done # 管道异常:本轮不再读(不阻塞、不重试) + if Avail == 0: + return Done # 无数据 ⇒ 立即返回(轮询点的高频路径) + N: ULONG = ULONG(IPC_READ_CHUNK) + if Avail < N: + N = Avail # 只读"确已有数据"的部分 ⇒ ReadFile 不会阻塞 + Br: ULONG = 0 + if wf.ReadFile(Ipc_Read, Ipc_Chunk, N, t.CPtr(c.Addr(Br)), None) == 0: + return Done + if Br == 0: + return Done + ub: BYTEPTR = BYTEPTR(Ipc_Chunk) + i: int = 0 + while i < t.CInt(Br): + Ch: int = int(ub[i]) + i += 1 + if Ch == 10: # '\n':一行结束 + Ipc_Line[Ipc_Fill] = 0 + if Ipc_Fill > 0: + if VCheck.HandleCheckLine(str(Ipc_Line)) != 0: + Done += 1 + Ipc_Fill = 0 + else: + if Ipc_Fill >= IPC_LINE_CAP - 1: + Ipc_Fill = 0 # 超长行:丢弃该行(不越界、不与下一行粘连) + else: + Ipc_Line[Ipc_Fill] = t.CChar(Ch) + Ipc_Fill += 1 + + +# ============================================================ +# 父进程侧:拉起 / 停止看门狗 +# ============================================================ + +# 取自身 exe 全路径(GetModuleFileNameA(NULL,...))。返回 1=成功(Out 已 NUL 结尾),0=失败。 +def SelfExePath(Out: bytes, Cap: int) -> int: + if Out is None or Cap < 2: + return 0 + n: ULONG = wf.GetModuleFileNameA(None, Out, ULONG(Cap)) + if n == 0: + return 0 + if t.CSizeT(n) >= t.CSizeT(Cap): + return 0 # 缓冲不足(路径被截断)⇒ 视为失败 + Out[t.CInt(n)] = 0 + return 1 + + +# 拉起看门狗子进程。返回 1=已拉起,0=失败。 +# IntervalSec:--need-confirm 的秒数(>0,调用方保证);TimeoutMs:确认等待超时(毫秒)。 +# 说明: +# * lpApplicationName=NULL,命令行首 token 为自身全路径(与 VProc 的 CreateProcessA 用法一致)。 +# * stdout/stderr/stdin 三个标准句柄都显式指向父进程当前句柄(STARTF_USESTDHANDLES), +# 因此看门狗的 JSON 与日志与父进程落同一处(控制台或重定向),不新建管道、不复制输出。 +# * dwCreationFlags=0:让子进程共享父进程控制台(不新开窗口),保证控制台输入句柄在子进程内 +# 仍可读、且 Ctrl+C 能同时送达(双保险:另有父进程句柄监控)。 +# * 拉起成功后做一次**存活校验**(WaitForSingleObject(hProcess, WATCH_ALIVE_PROBE_MS)): +# CreateProcessA 返回非 0 只说明进程对象建起来了,不代表看门狗真在跑——它可能因 +# 自身参数/环境问题立刻退出(历史现场:--parent-pid 被截断成 -1 判非法 → exit 1), +# 那样 --need-confirm 会静默失效(README 的"拉起失败=致命"落空)。 +# WAIT_OBJECT_0(已退出)⇒ 取退出码打 stderr + stdout 打唯一一行失败 JSON + 关句柄 +# ⇒ 返回 0(main.vp 的既有失败分支据此中止 scan)。WAIT_TIMEOUT(仍在跑)⇒ 视为存活, +# 继续。其余等待返回值(如 WAIT_FAILED)信息不足,按存活处理(宁可少报也不误杀 scan)。 +# 时序:CreateProcessA → 探一次(<=400ms) → 存活则打"已启动"日志并继续扫盘 / +# 已退出则打诊断 + 失败 JSON 并返回 0(致命)。 +def SpawnWatchdog(IntervalSec: int, TimeoutMs: int) -> int: + global Watch_Process, Watch_Thread + if IntervalSec <= 0: + return 1 # 关闭:不拉起(调用方仅在 >0 时调用),视为"无需看门狗" + StopWatchdog() # 幂等:如已有旧看门狗先回收 + + Self: bytes = _mbuddy.alloc(SELF_PATH_CAP) + Cmd: bytes = _mbuddy.alloc(SELF_PATH_CAP + WATCH_CMD_EXTRA_CAP) + if Self is None or Cmd is None: + if Self is not None: + _mbuddy.free(t.CPtr(Self)) + if Cmd is not None: + _mbuddy.free(t.CPtr(Cmd)) + VLog.LogStr("[confirm] 警告: 看门狗缓冲分配失败,未启动\n") + return 0 + if SelfExePath(Self, SELF_PATH_CAP) == 0: + _mbuddy.free(t.CPtr(Self)) + _mbuddy.free(t.CPtr(Cmd)) + VLog.LogStr("[confirm] 警告: GetModuleFileNameA 取自身路径失败,看门狗未启动\n") + return 0 + + # check 请求通道:先建管道(写端数值要写进命令行,必须在 CreateProcessA 之前拿到)。 + # 失败即致命(与看门狗拉起失败同一处理):用户显式要求心跳确认,其附带的 check 通道 + # 若静默缺失,等于悄悄少了一项被请求的能力。 + IpcW: wb.HANDLE = GuardIpcCreate() + if IpcW is None: + _mbuddy.free(t.CPtr(Self)) + _mbuddy.free(t.CPtr(Cmd)) + VLog.LogStr("[confirm] 警告: check 通道管道建立失败,看门狗未启动\n") + return 0 + + # 命令行首 token 为自身全路径(引号包裹,兼容路径含空格); + # 父进程为 --verbose 时为子进程追加同名开关,使看门狗按同一详细度规则输出。 + # --ipc-write <数值>:check 请求通道的管道**写端句柄数值**(父进程按 u64 位模式给出, + # 看门狗用 t.CPtr(数值) 还原句柄)。缺该参数(如手工调用)时看门狗侧按"无通道"处理。 + Pid: ULONG = wp.GetCurrentProcessId() + Extra: str = "" + if VCli.Verbose() != 0: + Extra = " --verbose" + viperlib.snprintf(Cmd, t.CSizeT(SELF_PATH_CAP + WATCH_CMD_EXTRA_CAP), + "\"%s\" %s --interval %u --timeout %u --parent-pid %u --ipc-write %llu%s", + str(Self), CONFIRM_WATCH_CMD, + t.CSizeT(IntervalSec), t.CSizeT(TimeoutMs), t.CSizeT(Pid), + t.CUInt64T(IpcW), Extra) + + # 标准句柄:与父进程同一(继承控制台/重定向);stdin 也一并交给看门狗 + hIn: wb.HANDLE = wf.GetStdHandle(wf.STD_INPUT_HANDLE) + hOut: wb.HANDLE = wf.GetStdHandle(wf.STD_OUTPUT_HANDLE) + hErr: wb.HANDLE = wf.GetStdHandle(wf.STD_ERROR_HANDLE) + if hIn is None or hIn == wb.INVALID_HANDLE_VALUE: + wb.CloseHandle(IpcW) + GuardIpcClose() + _mbuddy.free(t.CPtr(Self)) + _mbuddy.free(t.CPtr(Cmd)) + VLog.LogStr("[confirm] 警告: stdin 句柄无效,看门狗未启动(心跳确认需要 stdin)\n") + return 0 + # 尽力把标准句柄标记为可继承(控制台句柄通常本就随控制台继承;失败忽略) + wf.SetHandleInformation(hIn, wf.HANDLE_FLAG_INHERIT, wf.HANDLE_FLAG_INHERIT) + if hOut is not None and hOut != wb.INVALID_HANDLE_VALUE: + wf.SetHandleInformation(hOut, wf.HANDLE_FLAG_INHERIT, wf.HANDLE_FLAG_INHERIT) + if hErr is not None and hErr != wb.INVALID_HANDLE_VALUE: + wf.SetHandleInformation(hErr, wf.HANDLE_FLAG_INHERIT, wf.HANDLE_FLAG_INHERIT) + + Si: wp.STARTUPINFOA = wp.STARTUPINFOA() + Si.cb = t.CUInt32T(wp.STARTUPINFOA.__sizeof__()) + Si.dwFlags = wp.STARTF_USESTDHANDLES + Si.hStdInput = hIn + Si.hStdOutput = hOut + Si.hStdError = hErr + Pi: wp.PROCESS_INFORMATION = wp.PROCESS_INFORMATION() + + Ok: BOOL = wp.CreateProcessA(t.CPtr(0), Cmd, t.CPtr(0), t.CPtr(0), 1, 0, + t.CPtr(0), t.CPtr(0), t.CPtr(c.Addr(Si)), t.CPtr(c.Addr(Pi))) + CreateErr: ULONG = wb.GetLastError() # 紧跟调用读取(free 会覆盖 GetLastError) + _mbuddy.free(t.CPtr(Self)) + _mbuddy.free(t.CPtr(Cmd)) + if Ok == 0: + wb.CloseHandle(IpcW) + GuardIpcClose() + VLog.LogFmt1("[confirm] 警告: 看门狗 CreateProcessA 失败 (GetLastError=%u),未启动\n", + t.CSizeT(CreateErr)) + return 0 + + # 写端已由看门狗继承 ⇒ 父进程立即关掉自己的副本:这样后续任何 CreateProcessA + # (--version 探测)都不会把这个写端再继承出去。 + wb.CloseHandle(IpcW) + + Watch_Process = Pi.hProcess + Watch_Thread = Pi.hThread + + # 存活校验(详见函数头注释):只把 WAIT_OBJECT_0(子进程已退出)判为致命。 + Wp: ULONG = wsync.WaitForSingleObject(Watch_Process, WATCH_ALIVE_PROBE_MS) + if Wp == wb.WAIT_OBJECT_0: + Ec: ULONG = 0 + wp.GetExitCodeProcess(Watch_Process, t.CPtr(c.Addr(Ec))) + VLog.LogFmt1("[confirm] 看门狗拉起后立即退出 (exit=%u),心跳确认无法生效\n", + t.CSizeT(Ec)) + # 致命路径与 StopWatchdog 一样各自 CloseHandle 一次,并清空模块级句柄 + # (避免随后 StopWatchdog/再次 Spawn 造成双重关闭)。 + wb.CloseHandle(Watch_Process) + Watch_Process = None + wb.CloseHandle(Watch_Thread) + Watch_Thread = None + GuardIpcClose() # 看门狗已退出 ⇒ check 通道随之作废,读端一并关闭 + # 与看门狗自身失败路径(GuardWatchFail)逐字节相同的那一行:格式仍是单行 JSON。 + # 保留同一 msg(而非新造 "watchdog exited"):消费方按 "exit"==1 判中止, + # 且 README 已把这行标为看门狗失败的唯一 stdout 约定;精确原因走上一条 stderr。 + VLog.StdoutWrite("{\"exit\":1, \"msg\": \"timeout/ok!=1\"}\n") + return 0 + VLog.LogFmt2("[confirm] 心跳看门狗已启动 (pid=%u, 每 %u 秒一次", + t.CSizeT(Pi.dwProcessId), t.CSizeT(IntervalSec)) + VLog.LogFmt1(", 确认超时 %u ms)\n", t.CSizeT(TimeoutMs)) + return 1 + + +# 停止看门狗(幂等;父进程正常结束/提前返回时调用)。 +# TerminateProcess 立即置"终止"标记,子进程不会再执行用户态代码;再 WaitForSingleObject +# 有界等待其真正退出,确保父进程结束前无遗留;最后关闭两个句柄与 check 通道的管道读端。 +def StopWatchdog(): + global Watch_Process, Watch_Thread + if Watch_Process is not None: + wp.TerminateProcess(Watch_Process, 0) + wsync.WaitForSingleObject(Watch_Process, 5000) + wb.CloseHandle(Watch_Process) + Watch_Process = None + if Watch_Thread is not None: + wb.CloseHandle(Watch_Thread) + Watch_Thread = None + GuardIpcClose() + + +# ============================================================ +# 输入解析(沿用原判定口径) +# ============================================================ + +# 严格判定"存在 ok 且其值为 1"(容忍 key/值与冒号之间的空格 32 与制表 9)。 +# 结构约束:`"ok"` 前一个非空白字符必须是 '{'(123) 或 ','(44)(或缓冲区起点)—— +# 避免把某个字符串值里的 "ok" 误当成键;值必须是数字 1(49) 且其后不得紧跟数字 +# (拒绝 12/01)。返回 1=ok==1;其它一切情况(ok:0 / 空行 / 非 JSON / ok 缺失 / +# 值带引号 / 非数字 / 负数)返回 0。 +def ParseOkOne(Buf: bytes, Len: int) -> int: + if Buf is None or Len <= 0: + return 0 + ub: BYTEPTR = BYTEPTR(Buf) + i: int = 0 + while i < Len: + if int(ub[i]) == 34: # '"' + KeyPos: int = 0 + if i == 0: + KeyPos = 1 + else: + k: int = i - 1 + while k >= 0 and (int(ub[k]) == 32 or int(ub[k]) == 9): + k -= 1 + if k < 0: + KeyPos = 1 + else: + pk: int = int(ub[k]) + if pk == 123 or pk == 44: # '{' 或 ',' + KeyPos = 1 + if KeyPos != 0 and i + 3 < Len: + # 依次为 'o'(111) 'k'(107) '"'(34) + if int(ub[i + 1]) == 111 and int(ub[i + 2]) == 107 and int(ub[i + 3]) == 34: + j: int = i + 4 + while j < Len and (int(ub[j]) == 32 or int(ub[j]) == 9): + j += 1 + if j < Len and int(ub[j]) == 58: # ':' + j += 1 + while j < Len and (int(ub[j]) == 32 or int(ub[j]) == 9): + j += 1 + if j < Len and int(ub[j]) == 49: # '1' + if j + 1 >= Len: + return 1 + nx: int = int(ub[j + 1]) + if nx < 48 or nx > 57: # 后随非数字 ⇒ 值恰为 1 + return 1 + i += 1 + return 0 + + +# 行分类(快修;判定顺序见模块头「快修:批量输入与可疑行」)。返回值: +# GUARD_LINE_SUSPECT(0) = 形状可疑 ⇒ 忽略该行、继续在剩余期限内等待(不判失败); +# GUARD_LINE_OK(1) = 合法 {"ok": 1} ⇒ 本行确认成功; +# GUARD_LINE_BADOK(2) = 形状合法但值不对(如 {"ok": 0} / {"ok": 2})⇒ 致命失败。 +# 顺序:先做形状检查(三项任一不满足即"可疑"),形状通过后才用既有的 ParseOkOne 判值—— +# ParseOkOne 的口径一字未改,本函数只负责"值不对"与"形状可疑"的分流。 +# 三项形状判据(字节视角,容忍前导空格 32 / 制表 9): +# ① 去掉前导空白后的首字符不是 '{'(123)(含空行、全空白行); +# ② 整行不含 '}'(125)(被撕裂/截断的残行); +# ③ 行长超过 GUARD_OK_LINE_MAX(256)(明显异常)。 +# 注意分类的调用位置:check 请求行由 VCheck.ParseCheckLine **先**判(该行优先转发,且长 check 行 +# 可远超 256 字节),故第 ③ 项不会误伤 check 行。 +def GuardClassifyLine(Buf: bytes, Len: int) -> int: + if Buf is None or Len <= 0: + return GUARD_LINE_SUSPECT # ①(空行/无内容) + if Len > GUARD_OK_LINE_MAX: + return GUARD_LINE_SUSPECT # ③ + ub: BYTEPTR = BYTEPTR(Buf) + i: int = 0 + while i < Len and (int(ub[i]) == 32 or int(ub[i]) == 9): + i += 1 + if i >= Len: + return GUARD_LINE_SUSPECT # ①(全空白行) + if int(ub[i]) != 123: # '{' + return GUARD_LINE_SUSPECT # ① + j: int = i + while j < Len: + if int(ub[j]) == 125: # '}' + break + j += 1 + if j >= Len: + return GUARD_LINE_SUSPECT # ② + if ParseOkOne(Buf, Len) != 0: + return GUARD_LINE_OK + return GUARD_LINE_BADOK + + +# 把当前这一行(Guard_Line / Guard_Fill)逐字节转发到 check 通道的 IPC 写端,并补一个 '\n' +# 作为行分隔(父进程按 '\n' 切行)。行内容与用户输入逐字节相同(含空格与大小写)。 +# 写失败/无通道只记 stderr:该行本就不参与心跳判定,**不改变心跳语义**(期限继续走)。 +def GuardForwardLine(): + if Guard_IpcWrite is None or Guard_IpcWrite == wb.INVALID_HANDLE_VALUE: + VLog.LogStr("[confirm] 警告: 收到 check 请求但无 check 通道(未传 --ipc-write),已丢弃该行\n") + return + bw: ULONG = 0 + if Guard_Fill > 0: + if wf.WriteFile(Guard_IpcWrite, Guard_Line, ULONG(Guard_Fill), + t.CPtr(c.Addr(bw)), None) == 0: + VLog.LogStr("[confirm] 警告: check 请求转发失败(写管道失败),已丢弃该行\n") + return + if wf.WriteFile(Guard_IpcWrite, "\n", 1, t.CPtr(c.Addr(bw)), None) == 0: + VLog.LogStr("[confirm] 警告: check 请求转发失败(写行分隔符失败),该行可能不完整\n") + + +# ============================================================ +# 非阻塞取数(控制台 / 管道 / 重定向两路共用同一 deadline 与同一行累积) +# ============================================================ + +# 取剩余毫秒:GetTickCount64 差值,已过期饱和到 0(调用方据此判超时,不出现负值)。 +# 上界依据:--timeout 上界 CONFIRM_TIMEOUT_MAX_MS = 86400000(VCli.vp:36 校验), +# 故 Deadline - Now < 2^32,转 ULONG 不截断。 +def GuardRemainMs(Deadline: ULONGLONG) -> ULONG: + Now: ULONGLONG = wb.GetTickCount64() + if Now >= Deadline: + return 0 + return ULONG(Deadline - Now) + + +# 把一个字节累积进行缓冲(两路唯一的累积点)。返回 1=已凑满一行,0=尚未成行。 +# 行结束口径与旧 ReadConfirmLine 逐条一致: +# * '\n'(10) 结束一行且不入缓冲,其前紧邻的 '\r'(13) 去掉(重定向输入的 CRLF); +# * 缓冲写满(GUARD_LINE_CAP-1)按一行结束处理(旧实现同样返回截断行); +# * 每次结束/追加后写 NUL,Guard_Fill 为本行内容长度(供 ParseOkOne 与日志使用)。 +# 控制台路径把 Enter 的 '\r' 归一成 '\n' 再送入本函数,故两种输入形态共用同一口径。 +def GuardFeedByte(Ch: int) -> int: + global Guard_Fill + ub: BYTEPTR = BYTEPTR(Guard_Line) # 读取用字节视图(同 ParseOkOne/旧 ReadConfirmLine) + if Ch == 10: # '\n' + if Guard_Fill > 0 and int(ub[Guard_Fill - 1]) == 13: + Guard_Fill -= 1 + Guard_Line[Guard_Fill] = 0 + return 1 + if Guard_Fill >= GUARD_LINE_CAP - 1: + Guard_Line[Guard_Fill] = 0 + return 1 + Guard_Line[Guard_Fill] = t.CChar(Ch) + Guard_Fill += 1 + Guard_Line[Guard_Fill] = 0 + return 0 + + +# ---- 快修:未消费输入 FIFO 的三个小函数(两路取数共用;见模块头「快修:批量输入与可疑行」)---- + +# 清空 FIFO(新一轮确认开始时调用:新窗口不带上一轮的残留)。 +def GuardPendReset(): + global Guard_PendLen, Guard_PendPos + Guard_PendLen = 0 + Guard_PendPos = 0 + + +# 把 FIFO 里尚未消费的字节按顺序逐个喂给行缓冲。返回 1=途中凑满了一行(剩余字节留在 FIFO, +# 由下一次调用继续喂),0=FIFO 已耗尽且未成行(此时两个游标归零,FIFO 回到空且可从头部复用)。 +def GuardFeedPending() -> int: + global Guard_PendLen, Guard_PendPos + ub: BYTEPTR = BYTEPTR(Guard_Pend) + while Guard_PendPos < Guard_PendLen: + Ch: int = int(ub[Guard_PendPos]) + Guard_PendPos += 1 + if GuardFeedByte(Ch) != 0: + return 1 + Guard_PendPos = 0 + Guard_PendLen = 0 + return 0 + + +# 把一个字节写入 FIFO(写入端游标 Guard_PendLen)。带边界守卫:满则丢弃该字节并记一行 stderr +# 告警,绝不越界写。按构造成立的不变量(见 GUARD_PEND_CAP 的推导),入队时 FIFO 必为空且 +# 单批入队量 <= 64 < GUARD_PEND_CAP,故"满"不可达;守卫只为让将来改大批上限时退化为"丢字节" +# 而不是写穿缓冲。 +def GuardPendPut(Ch: int): + global Guard_PendLen + if Guard_PendLen >= GUARD_PEND_CAP: + VLog.LogFmt1("[confirm] 警告: 未消费输入 FIFO 已满(容量 %u),丢弃该批字节\n", + t.CSizeT(GUARD_PEND_CAP)) + return + Guard_Pend[Guard_PendLen] = t.CChar(Ch) + Guard_PendLen += 1 + + +# 判定 stdin 形态:控制台 / 管道 / 其它流。 +# 依据:GetFileType 返回 FILE_TYPE_CHAR 的除控制台外还有 LPT 等字符设备, +# 故再用 GetConsoleMode 确认(成功 = 该句柄是控制台输入缓冲 ⇒ 可按记录读)。 +def GuardInputKind(hIn: wb.HANDLE) -> int: + T: ULONG = wf.GetFileType(hIn) + if T == ULONG(FILE_TYPE_CHAR): + Mode: ULONG = 0 + if wc.GetConsoleMode(hIn, t.CPtr(c.Addr(Mode))) != 0: + return GUARD_IN_CONSOLE + return GUARD_IN_STREAM + if T == ULONG(FILE_TYPE_PIPE): + return GUARD_IN_PIPE + # FILE_TYPE_DISK(`< file` 重定向)/ FILE_TYPE_UNKNOWN(NUL 等)等一律按"其它流"处理 + return GUARD_IN_STREAM + + +# ============================================================ +# 加固 1:ReadConsoleInputExA 动态解析 +# ============================================================ + +# 解析 kernel32!ReadConsoleInputExA 到 Guard_ReadEx(整个进程只做一次)。 +# 返回 1=可用(含此前已解析成功),0=不可用(该导出不存在 ⇒ 调用方走降级路径)。 +# 解析不到的情形(旧系统无该导出)在这里被"记住"(GUARD_EX_MISSING),不再重复 +# GetProcAddress —— 取数路径每次判定都会调本函数,重复解析会白烧一次 API。 +# 两个字符串以字面量传(与 VDirs.vp:186 把 "USERPROFILE" 传给 LPCSTR 形参同一写法): +# * "kernel32.dll":该 DLL 在所有 Win32 进程里都已加载,GetModuleHandleA 只取句柄、不增引用; +# * "ReadConsoleInputExA":DLL 导出名带 A 后缀(无 Ex 这个名字),见模块头"加固 1"。 +def GuardResolveReadEx() -> int: + global Guard_ReadEx, Guard_ReadExState + if Guard_ReadExState == GUARD_EX_OK: + return 1 + if Guard_ReadExState == GUARD_EX_MISSING: + return 0 + Guard_ReadExState = GUARD_EX_MISSING # 先按"不可用"记:任何一步失败都不再重试 + Hm: wb.HANDLE = wb.GetModuleHandleA("kernel32.dll") + if Hm is None or Hm == wb.INVALID_HANDLE_VALUE: + return 0 + Fp: VOIDPTR = wb.GetProcAddress(Hm, "ReadConsoleInputExA") + if Fp is None: + return 0 + Guard_ReadEx = Fp + Guard_ReadExState = GUARD_EX_OK + return 1 + + +# ============================================================ +# 加固 2:控制事件处理例程(SetConsoleCtrlHandler) +# ============================================================ + +# 控制事件处理例程。职责极小:把进入"原始按键"模式前的控制台模式写回(幂等), +# 然后一律返回 FALSE(交还默认处理 ⇒ Ctrl+C/Ctrl+Break/关窗仍按原语义终止本进程)。 +# 取舍见模块头"加固 2"(返回 TRUE 必须自行 ExitProcess,否则会把"用户主动 Ctrl+C" +# 变成"看门狗继续跑到下一轮再杀父")。 +# 线程安全:本函数可能被系统在**新线程**上调用,只读三个模块级标量(Guard_RawActive / +# Guard_ConHandle / Guard_SavedMode)并调用一次 SetConsoleMode;不分配内存、不写缓冲、 +# 不碰 _mbuddy 池与 VLog 缓冲,故不与主线程的池/日志/扫描缓冲并发写。 +# 被 TerminateProcess 强杀(父进程 StopWatchdog 回收、心跳失败杀父)时不执行本函数。 +# 返回值写 0(= FALSE;本模块只 import 了 w32.win32base as wb,没有 FALSE 这个裸名) +def GuardCtrlHandler(CtrlType: ULONG) -> BOOL: + if Guard_RawActive == 0: + return 0 + if Guard_ConHandle is not None: + wc.SetConsoleMode(Guard_ConHandle, Guard_SavedMode) + return 0 + + +# 摘除控制事件处理例程(幂等;未注册时一次 API 都不调用)。 +def GuardRemoveCtrlHandler(): + global Guard_CtrlHandlerOn + if Guard_CtrlHandlerOn == 0: + return + SetConsoleCtrlHandler(GuardCtrlHandler, GUARD_CTRL_REMOVE) + Guard_CtrlHandlerOn = 0 + + +# 进入"原始按键"控制台模式:清 ENABLE_LINE_INPUT 与 ENABLE_ECHO_INPUT(取舍见模块头)。 +# 返回 1=可用(已切换,或两位本就未置位而无需切换),0=GetConsoleMode/SetConsoleMode 失败 +# (此时模式未改动,调用方按失败处理)。 +# 加固 2 的注册顺序(都在"确实要改模式"的分支里,两位本就未置位时不注册): +# ① 置 Guard_RawActive=1 并登记控制台句柄(先让 handler 具备写回所需的一切); +# ② 注册 handler(best-effort:失败只告警,正常路径仍在 GuardLeaveRaw 写回模式); +# ③ 真正 SetConsoleMode。这样 Ctrl+C 落在②与③之间时 handler 写回的是"尚未改动的原模式" +# (幂等空操作),不会留下"已清行输入 + 无 handler"的窗口。 +def GuardEnterRaw(hIn: wb.HANDLE) -> int: + global Guard_SavedMode, Guard_RawActive, Guard_ConHandle, Guard_CtrlHandlerOn + Mode: ULONG = 0 + if wc.GetConsoleMode(hIn, t.CPtr(c.Addr(Mode))) == 0: + return 0 + Guard_SavedMode = Mode + Guard_RawActive = 0 + New: ULONG = Mode & ULONG(GUARD_RAW_CLEAR_MASK) + if New == Mode: + return 1 # 无需改模式 ⇒ 也无需 handler + Guard_ConHandle = hIn + Guard_RawActive = 1 + if SetConsoleCtrlHandler(GuardCtrlHandler, GUARD_CTRL_ADD) != 0: + Guard_CtrlHandlerOn = 1 + else: + VLog.LogStr("[confirm] 警告: SetConsoleCtrlHandler 注册失败,Ctrl+C 时该终端可能残留已清行输入的模式\n") + if wc.SetConsoleMode(hIn, New) == 0: + # 先清 Guard_RawActive 再清 Guard_SavedMode:handler 只在 RawActive!=0 时写回, + # 这个顺序保证它绝不会把"清零后的 SavedMode(=0)"写进控制台(此刻模式其实没变)。 + Guard_RawActive = 0 + Guard_SavedMode = 0 + GuardRemoveCtrlHandler() + return 0 + return 1 + + +# 写回进入前的控制台模式(幂等;未实际切换过则一次 API 都不调用)。 +# 顺序:写回模式 → 清 Guard_RawActive → 摘除 handler。这个顺序保证任何时刻都不存在 +# "已清行输入但没有 handler"的窗口(摘除后已无待恢复的状态);反序则会在两步之间留一个 +# 这样的窗口。注册与"已切换模式"同生命周期 ⇒ Guard_RawActive==0 ⇔ 未注册(无泄漏)。 +def GuardLeaveRaw(hIn: wb.HANDLE): + global Guard_RawActive + if Guard_RawActive == 0: + return + wc.SetConsoleMode(hIn, Guard_SavedMode) + Guard_RawActive = 0 + GuardRemoveCtrlHandler() + + +# 控制台路径:一次非阻塞取数。返回 1=已凑满一行,0=无待读记录/尚未成行,-1=读取失败。 +# 只处理 KEY_EVENT(0x0001) 且 bKeyDown 的字符:可打印 ASCII(32..126) 与制表符(9) 累积, +# Enter 的 '\r'(13) 归一成 '\n' 后入队;其余(按键抬起、方向键/功能键、退格、非 ASCII 输入) +# 一律忽略(本路径不做行编辑)。 +# 快修后的三步(与 GuardPumpStream 同构,未消费字节不再丢): +# ① 先 GuardFeedPending():FIFO 里可能已有上一批留下的完整行 ⇒ 凑满即 return 1(不发起读取); +# ② 需要新数据时才取一批记录(本轮仍是"一次调用最多 GUARD_REC_MAX 条"); +# ③ 过滤后的字符**整批**压入 FIFO(GuardPendPut),再 GuardFeedPending() 并返回其结果—— +# 该批可能自成一行,也可能只成一行的一部分,剩余字节留在 FIFO 由下次调用继续喂。 +# 取数分两路(决定走哪一路的只有"ReadConsoleInputExA 是否解析得到"): +# * 主路径(加固 1):ReadConsoleInputExA + CONSOLE_READ_NOWAIT —— 文档明写"输入缓冲 +# 为空也立即返回",故这里**没有**"先查数量再读"的两步组合,读取不再可能阻塞; +# 返回 0 条记录即"本轮暂无数据"。 +# * 降级路径(解析不到该导出):有界等"可读"(上限 = min(剩余毫秒, GUARD_LEGACY_WAIT_MS)) +# → GetNumberOfConsoleInputEvents → ReadConsoleInputA。残余竞态:查询到 N>0 之后若 +# 另一读者取走记录,ReadConsoleInputA 会等**下一条记录**(该函数无超时参数)—— +# 窗口极小但非零,见模块头"加固 1"。此处能做的收紧是:①这一步之前的等待不占满剩余 +# 预算;②剩余为 0 时直接返回、不发起这次读(绝不在"已过期"的 deadline 上进入阻塞读)。 +def GuardPumpConsole(hIn: wb.HANDLE, Deadline: ULONGLONG) -> int: + if GuardFeedPending() != 0: # ① FIFO 里已有整行 ⇒ 直接交回,不发起读取 + return 1 + Read: ULONG = 0 + if GuardResolveReadEx() != 0: + # Guard_ReadEx 为模块级 t.Callable,其返回类型签名已随模块级 AnnAssign 登记 + # (TranslatorGenMixin.handle_module_level_var),故可直接间接调用。 + if Guard_ReadEx(hIn, t.CPtr(Guard_Recs), ULONG(GUARD_REC_MAX), + t.CPtr(c.Addr(Read)), USHORT(CONSOLE_READ_NOWAIT)) == 0: + return -1 + else: + Rem: ULONG = GuardRemainMs(Deadline) + if Rem == 0: + return 0 + if Rem > ULONG(GUARD_LEGACY_WAIT_MS): + Rem = ULONG(GUARD_LEGACY_WAIT_MS) + if wsync.WaitForSingleObject(hIn, Rem) == ULONG(wb.WAIT_FAILED): + return -1 + Cnt: ULONG = 0 + if wc.GetNumberOfConsoleInputEvents(hIn, t.CPtr(c.Addr(Cnt))) == 0: + return -1 + if Cnt == 0: + return 0 + N: ULONG = Cnt + if N > ULONG(GUARD_REC_MAX): + N = ULONG(GUARD_REC_MAX) + if wc.ReadConsoleInputA(hIn, t.CPtr(Guard_Recs), N, t.CPtr(c.Addr(Read))) == 0: + return -1 + ub: BYTEPTR = BYTEPTR(Guard_Recs) + i: int = 0 + while i < t.CInt(Read): + Base: int = i * GUARD_REC_SIZE + i += 1 + if VPe.ReadU16(ub, Base) != t.CUInt32T(wc.KEY_EVENT): + continue + if VPe.ReadU32(ub, Base + GUARD_KEY_DOWN_OFF) == 0: + continue + Ch: int = t.CInt(VPe.ReadU16(ub, Base + GUARD_KEY_CHAR_OFF)) + if Ch == 13: + Ch = 10 # Enter → 行结束(与 '\n' 同一口径) + elif Ch != 9 and (Ch < 32 or Ch > 126): + continue # 非可打印/非 ASCII:忽略 + GuardPendPut(Ch) # ③ 过滤后压入 FIFO(本次调用至多 16 字节) + if GuardFeedPending() != 0: # ③ 再喂 FIFO;剩余字节留给下次调用 + return 1 + return 0 + + +# 管道/重定向路径:一次非阻塞取数。返回 1=已凑满一行,0=当前无可读字节(尚未成行), +# -1=读取失败,GUARD_PUMP_EOF=不可能再出现输入(ReadFile 读到 0 字节)。 +# 管道路径先 PeekNamedPipe 问"当前真正可用的字节数",只读 min(可用, 块上限); +# 非管道(磁盘文件重定向等)无法预询可读量,改读一块有界字节(<= GUARD_CHUNK_CAP), +# 读到 0 字节即 EOF。ReadFile 只在"已知有数据(管道路径)"或"有界块(其它路径)"时调用。 +# 快修后的三步(同 GuardPumpConsole):① 先 GuardFeedPending()(FIFO 里可能已有整行); +# ② 需要新数据才读一批(读入量同时受 GUARD_CHUNK_CAP 与 GUARD_PEND_CAP 约束,保证整批放得进 +# FIFO);③ 把该批**全部**字节压入 FIFO(不再"凑满一行就丢弃同批剩余字节"),再 GuardFeedPending() +# 并返回其结果——同批的剩余字节留在 FIFO,由下一次调用继续喂。 +# 取舍:PeekNamedPipe 报"可用 0 字节"时统一按"暂无数据"推进(不据此判 EOF)—— +# 该函数无法区分"写端仍开、暂无数据"与"写端已关、无数据",据此判 EOF 有误杀风险; +# 由调用方的 deadline 兜底(到点按超时失败收尾,与旧实现"EOF 当空行失败"的最终结果一致, +# 仅失败时机由"读到 EOF"变为"到点")。EOF 仅在非管道流上可判(读盘重定向读完即 0 字节)。 +def GuardPumpStream(hIn: wb.HANDLE, Kind: int) -> int: + if GuardFeedPending() != 0: # ① FIFO 里已有整行 ⇒ 直接交回,不发起读取 + return 1 + N: ULONG = ULONG(GUARD_CHUNK_CAP) + if Kind == GUARD_IN_PIPE: + Avail: ULONG = 0 + if PeekNamedPipe(hIn, t.CPtr(0), 0, t.CPtr(0), t.CPtr(c.Addr(Avail)), t.CPtr(0)) == 0: + return -1 + if Avail == 0: + return 0 + if Avail < N: + N = Avail + # ② 读入量的两个上界各夹一次(顺序无关,两者都必须成立): + # * 必须 ≤ 读块缓冲的实际大小 GUARD_CHUNK_CAP —— Guard_Chunk 正是按该值分配的, + # 给 ReadFile 传更大的 N 会写穿缓冲(当前 N 初值即 GUARD_CHUNK_CAP,本夹是防将来改动); + # * 必须 ≤ GUARD_PEND_CAP —— 本批要整批放进 FIFO,故声明处要求 GUARD_PEND_CAP >= GUARD_CHUNK_CAP。 + if N > ULONG(GUARD_CHUNK_CAP): + N = ULONG(GUARD_CHUNK_CAP) + if N > ULONG(GUARD_PEND_CAP): + N = ULONG(GUARD_PEND_CAP) + Br: ULONG = 0 + if wf.ReadFile(hIn, Guard_Chunk, N, t.CPtr(c.Addr(Br)), None) == 0: + return -1 + if Br == 0: + return GUARD_PUMP_EOF + ub: BYTEPTR = BYTEPTR(Guard_Chunk) + i: int = 0 + while i < t.CInt(Br): + GuardPendPut(int(ub[i])) # ③ 先整批入队(Br <= N <= GUARD_PEND_CAP) + i += 1 + if GuardFeedPending() != 0: # ③ 再喂 FIFO;剩余字节留给下次调用 + return 1 + return 0 + + +# ============================================================ +# 看门狗侧:失败处理 + 主循环 +# ============================================================ + +# 失败路径:stderr 打明原因 → stdout 逐字输出唯一一行 {"exit":1, "msg": "timeout/ok!=1"}\n +# → TerminateProcess(父进程) → sys.exit(1)。 +# 父进程被强杀(TerminateProcess 不做任何收尾):不写缓存、不再打本轮数组 JSON。 +def GuardWatchFail(hParent: wb.HANDLE, Reason: str): + if Reason is not None: + VLog.LogFmtS1("[confirm] 失败: %s\n", Reason) + VLog.StdoutWrite("{\"exit\":1, \"msg\": \"timeout/ok!=1\"}\n") + if hParent is not None and hParent != wb.INVALID_HANDLE_VALUE: + wp.TerminateProcess(hParent, 1) + wb.CloseHandle(hParent) + sys.exit(1) + + +# 看门狗侧缓冲一次性分配;返回 1=可用,0=失败 +def GuardWatchInit() -> int: + global Guard_Line, Guard_Stamp, Guard_Dl, Guard_Recs, Guard_Chunk, Guard_Pend + if Guard_Line is None: + Guard_Line = _mbuddy.alloc(GUARD_LINE_CAP) + Guard_Stamp = _mbuddy.alloc(GUARD_STAMP_CAP) + Guard_Dl = _mbuddy.alloc(GUARD_STAMP_CAP) + Guard_Recs = _mbuddy.alloc(GUARD_REC_CAP) + Guard_Chunk = _mbuddy.alloc(GUARD_CHUNK_CAP) + Guard_Pend = _mbuddy.alloc(GUARD_PEND_CAP) + if Guard_Line is None or Guard_Stamp is None or Guard_Dl is None: + return 0 + if Guard_Recs is None or Guard_Chunk is None or Guard_Pend is None: + return 0 + return 1 + + +# 从提示发出起算,在 TimeoutMs 内等"完整的一行且分类通过"(控制台/管道/重定向两路共用)。 +# 返回 1=确认成功(Guard_Line/Guard_Fill 为本轮输入);0=父进程已退出(调用方自退)。 +# 失败(期限到点仍未收到合法 {"ok": 1} / 形状合法但值不对 / 读失败 / EOF)在本函数内收尾: +# 先恢复控制台模式,再 GuardWatchFail(打 stdout 失败 JSON + 杀父 + 退出码 1),故失败路径不返回。 +# +# 全程非阻塞、由 deadline 驱动(快修后:**先取数、再等待/节拍**): +# ① 进入时清空未消费输入 FIFO 与行缓冲(GuardPendReset):新窗口不带上一轮的残留; +# ② 循环每轮先取数(GuardPump* 内部先喂 FIFO)——FIFO 里可能已有上一批留下的整行;若反过来 +# 先 WaitForMultipleObjects,会因"stdin 此刻无可读数据、句柄不置信号"白等到期限耗尽; +# ③ 取到整行后先分类(GuardClassifyLine;check 行由 ParseCheckLine 优先判并转发); +# ④ 取数无果时才等:WaitForMultipleObjects({stdin, 父进程句柄}, min(剩余, GUARD_POLL_MS))—— +# 上限取 min(剩余, 20ms) 而非整个剩余预算:读端已关的管道会永久置信号而可读字节为 0 +# (PeekNamedPipe 无法区分"写端仍在/已关"),"全剩余"等待会立即返回并形成忙等;短上限把节拍 +# 固定为 20ms(等价于旧实现取数后的 Sleep(20ms)),且父进程退出在 ≤20ms 内仍然可见; +# ⑤ 取数本身不阻塞(控制台:ReadConsoleInputExA + CONSOLE_READ_NOWAIT 立即返回;管道:可用字节 +# > 0 才读;其它流:有界块),不存在等下一次按键/下一次回车才返回的读取;控制台降级路径 +# (老系统无该导出)残余竞态见模块头"加固 1"; +# ⑥ 分类结果三态:合法 {"ok": 1} ⇒ 成功返回;形状合法但值不对 ⇒ 致命失败(口径不放宽); +# 形状可疑 ⇒ stderr 回显(截断)后忽略该行并继续等,**期限(deadline)不变**; +# ⑦ 被忽略的行(可疑行 / 转发出去的 check 行)**不重置期限**,也不会绕过期限:两条忽略路径 +# 都落到③④之间的"剩余为 0 ⇒ 超时失败"检查上 ⇒ 持续涌入的这类行同样会在期限到点收尾 +# ("期限到点仍未收到合法 {"ok": 1} 才判失败"对"无输入"与"只有被忽略的行"一致成立)。 +def GuardWaitConfirm(hIn: wb.HANDLE, hParent: wb.HANDLE, Kind: int, TimeoutMs: int) -> int: + global Guard_Fill + GuardPendReset() # ① 新一轮:FIFO 与行缓冲都不带上一轮残留 + Guard_Fill = 0 + Guard_Line[0] = 0 + if Kind == GUARD_IN_CONSOLE: + # 清 ENABLE_LINE_INPUT:清掉后回车不再由系统"行输入"消化,而以 KEY_EVENT 到达, + # 我们才能自己判"一整行"(依据见模块头"控制台模式取舍")。 + # 拿不到/改不了控制台模式 ⇒ 无法保证"timeout 内可判定"(旧行为是 ReadFile 等到回车), + # 故按既有约定失败收尾,不静默降级为会挂死的阻塞等待。 + if GuardEnterRaw(hIn) == 0: + GuardWatchFail(hParent, "控制台模式读取/切换失败") + return 0 + Deadline: ULONGLONG = wb.GetTickCount64() + ULONGLONG(t.CUInt32T(TimeoutMs)) + while True: + # ② 先取数(GuardPump* 内部先喂 FIFO):FIFO 里已有整行时不会白等一次 20ms + R: int = 0 + if Kind == GUARD_IN_CONSOLE: + R = GuardPumpConsole(hIn, Deadline) + else: + R = GuardPumpStream(hIn, Kind) + if R == 1: + # 一行凑满 ⇒ 先分类(用仓库 json 库解析,不做字符串猜测): + # * check 请求(对象含 "check" 字符串 与 "id" 整数)⇒ 原样转发到 IPC 写端; + # 该行**不计入心跳、也不判失败**:期限继续走,用户仍需在期限内发 {"ok": 1}。 + # * 其余 ⇒ 交 GuardClassifyLine 三态判定(ParseOkOne 口径不变)。 + if VCheck.ParseCheckLine(str(Guard_Line)) != 0: + GuardForwardLine() + else: + Cls: int = GuardClassifyLine(Guard_Line, Guard_Fill) + if Cls == GUARD_LINE_OK: + break # 合法 {"ok": 1} ⇒ 出循环后收尾成功 + if Cls == GUARD_LINE_BADOK: + # 形状合法但值不对("用户真的输错")⇒ 与旧实现同一失败口径与同一 stderr 文案 + GuardLeaveRaw(hIn) + VLog.LogFmtS1("[confirm] 收到非 {\"ok\": 1} 的输入: %s\n", str(Guard_Line)) + GuardWatchFail(hParent, "ok != 1") + return 0 + # 形状可疑(首字符非 '{' / 不含 '}' / 长度异常,含空行)⇒ 忽略该行并回显(截断) + if Guard_Fill > GUARD_SUSPECT_ECHO_MAX: + Guard_Line[GUARD_SUSPECT_ECHO_MAX] = 0 # 只回显前缀(该行随后整个丢弃) + VLog.LogFmtS1("[confirm] 忽略可疑输入行: %s\n", str(Guard_Line)) + Guard_Fill = 0 + Guard_Line[0] = 0 + # 两条"忽略"路径(check 转发 / 可疑行)都**落到下方的期限检查 + 等待**: + # 忽略不重置期限(Deadline 在循环外算好),但期限一旦到点仍按超时失败收尾 + # ⇒ 持续涌入的可疑行/check 行不会让本窗口无限延长("期限到点仍未收到合法 + # {"ok": 1} 才判失败"这一条对"无输入"与"只有被忽略的行"同样成立)。 + # 此处 R==1,故下方 "if R < 0" 分支不会命中,控制流直接进入等待段。 + if R < 0: + GuardLeaveRaw(hIn) + if R == GUARD_PUMP_EOF: + GuardWatchFail(hParent, "stdin 已到 EOF,不可能再有输入") + else: + GuardWatchFail(hParent, "读取 stdin 失败") + return 0 + # ③ 本轮取数无果 ⇒ 等"stdin 可读 / 父进程退出 / 到点",上限 = min(剩余, GUARD_POLL_MS) + Rem2: ULONG = GuardRemainMs(Deadline) + if Rem2 == 0: + GuardLeaveRaw(hIn) + GuardWatchFail(hParent, "超时无输入") + return 0 + Sl: ULONG = Rem2 + if Sl > ULONG(GUARD_POLL_MS): + Sl = ULONG(GUARD_POLL_MS) + Hs: WATCH_HANDLES = WATCH_HANDLES() + Hs.H0 = hIn + Hs.H1 = hParent + Wm: ULONG = wsync.WaitForMultipleObjects(ULONG(WATCH_HANDLE_COUNT), + t.CPtr(c.Addr(Hs)), 0, Sl) + if Wm == ULONG(wb.WAIT_OBJECT_0) + ULONG(1): # 父进程句柄(索引 1)置信号 + GuardLeaveRaw(hIn) + return 0 + if Wm == ULONG(wb.WAIT_FAILED): + GuardLeaveRaw(hIn) + GuardWatchFail(hParent, "等待输入失败") + return 0 + # 其余取值(stdin 置信号 / WAIT_TIMEOUT)⇒ 回到循环顶部取数 + GuardLeaveRaw(hIn) + return 1 + + +# confirm-watch 入口(App/main.vp 分派)。返回进程退出码。 +# 只做心跳确认,不碰扫盘/目录表/缓存。 +def RunConfirmWatch() -> int: + global Guard_IpcWrite + if VCli.ParseWatchArgs() != 0: + return 1 + # 详细度由父进程经 --verbose 透传(父为 --verbose 时 SpawnWatchdog 会追加该开关) + VLog.SetVerbose(VCli.WatchVerbose()) + IntervalSec: int = VCli.WatchInterval() + TimeoutMs: int = VCli.WatchTimeout() + ParentPid: LONGLONG = VCli.WatchParentPid() + IntervalMs: ULONG = ULONG(IntervalSec) * ULONG(1000) + if GuardWatchInit() == 0: + VLog.LogStr("[confirm] 看门狗: 行/记录/时间戳/未消费输入缓冲分配失败\n") + return 1 + + VLog.LogFmt3("[confirm] 看门狗启动: 每 %u 秒要求一次 {\"ok\": 1},超时 %u ms,父进程 pid=%u\n", + t.CSizeT(IntervalSec), t.CSizeT(TimeoutMs), t.CSizeT(ParentPid)) + + # 父进程句柄:同时用于"父退出检测(SYNCHRONIZE)"与"失败时终止父进程(PROCESS_TERMINATE)" + hParent: wb.HANDLE = wp.OpenProcess(ULONG(WATCH_PARENT_ACCESS), 0, ULONG(ParentPid)) + if hParent is None or hParent == wb.INVALID_HANDLE_VALUE: + # 拿不到父进程句柄 ⇒ 既无法监控也无法终止,无法履行契约:只记 stderr 并以 1 退出 + VLog.LogFmt1("[confirm] 看门狗: OpenProcess 父进程失败 (GetLastError=%u),自身退出\n", + t.CSizeT(wb.GetLastError())) + return 1 + + hIn: wb.HANDLE = wf.GetStdHandle(wf.STD_INPUT_HANDLE) + if hIn is None or hIn == wb.INVALID_HANDLE_VALUE: + GuardWatchFail(hParent, "stdin 句柄无效") + return 1 + + # check 通道写端:父进程经 `--ipc-write <数值>` 传入的**句柄数值**,这里还原成 HANDLE + # (数值按 u64 位模式传递与解析;0/缺参 ⇒ 无通道,收到 check 行时只记 stderr)。 + if VCli.WatchIpcWrite() != 0: + Guard_IpcWrite = t.CPtr(VCli.WatchIpcWrite()) + else: + Guard_IpcWrite = None + Ch0: int = 0 + if Guard_IpcWrite is not None: + Ch0 = 1 + VLog.VerboseFmt1("[confirm] 看门狗: check 通道=%u (1=已配置 0=无)\n", t.CSizeT(Ch0)) + + # 输入形态只判一次(句柄在进程生命周期内不变);控制台与管道/重定向的取数路径不同。 + Kind: int = GuardInputKind(hIn) + VLog.VerboseFmt1("[confirm] 看门狗: stdin 形态=%u (1=控制台 2=管道 3=其它流)\n", + t.CSizeT(Kind)) + # 控制台取数路径(加固 1):主路径=ReadConsoleInputExA+CONSOLE_READ_NOWAIT, + # 降级=GetNumberOfConsoleInputEvents+ReadConsoleInputA(该导出不存在的老系统)。 + # 这里提前解析一次(后续判定复用缓存的状态),顺带把实际走哪一路写进 --verbose 日志。 + if Kind == GUARD_IN_CONSOLE: + GuardResolveReadEx() + VLog.VerboseFmt1("[confirm] 看门狗: 控制台取数路径=%u (1=ReadConsoleInputExA+NOWAIT 2=降级查询+ReadConsoleInputA)\n", + t.CSizeT(Guard_ReadExState)) + + while True: + # 阶段 1:等 interval;父进程先退出则立即自退(无需父侧配合,杜绝孤儿) + Ws: ULONG = wsync.WaitForSingleObject(hParent, IntervalMs) + if Ws == wb.WAIT_OBJECT_0: + VLog.LogStr("[confirm] 看门狗: 父进程已退出,自身退出\n") + wb.CloseHandle(hParent) + return 0 + if Ws != wb.WAIT_TIMEOUT: + VLog.LogFmt1("[confirm] 看门狗: 等待父进程失败 (wait=%u)\n", t.CSizeT(Ws)) + GuardWatchFail(hParent, "等待父进程失败") + return 1 + + # 阶段 2:stderr 提示(期限自本行起算)+ 在 timeout 内非阻塞等"完整一行且分类通过" + VLog.LogFmt2("[confirm] 请在 %u ms 内输入 {\"ok\": 1}(每 %u 秒一次)\n", + t.CSizeT(TimeoutMs), t.CSizeT(IntervalSec)) + VLog.TimeStampNow(Guard_Stamp, GUARD_STAMP_CAP) + VLog.TimeStampAfter(Guard_Dl, GUARD_STAMP_CAP, t.CSizeT(TimeoutMs)) + VLog.LogFmtS2("[confirm] 当前 %s, 本轮期限 %s\n", str(Guard_Stamp), str(Guard_Dl)) + + # 失败路径(期限到点/形状合法但值不对/读失败/EOF)已在 GuardWaitConfirm 内收尾并退出进程; + # 返回 0 只表示"父进程已退出",此时看门狗自退(不杀父、不打失败 JSON)。 + if GuardWaitConfirm(hIn, hParent, Kind, TimeoutMs) == 0: + VLog.LogStr("[confirm] 看门狗: 父进程已退出,自身退出\n") + wb.CloseHandle(hParent) + return 0 + VLog.LogFmt1("[confirm] 确认成功 (ok=1),下一次约 %u 秒后\n", t.CSizeT(IntervalSec)) diff --git a/App/lib/VGuid.vp b/App/lib/VGuid.vp new file mode 100644 index 0000000..ff3fbde --- /dev/null +++ b/App/lib/VGuid.vp @@ -0,0 +1,54 @@ +import t, c +from stdint import * +import memhub +import viperlib +import lib.VPe as VPe + + +# ============================================================ +# VGuid - UUID(ole32!CoCreateGuid → 8-4-4-4-12) +# +# 从原 App/main.vp 平移 NewGuidStr 及 CoCreateGuid 自声明。 +# 依赖:VPe.ReadU16/ReadU32(PE 小端读取,includes 无对应辅助)、VFs 无直接依赖。 +# +# Win32 自声明(本仓无声明): +# ole32!CoCreateGuid: HRESULT CoCreateGuid(GUID *pguid); (objbase.h;不依赖 CoInitialize) +# HRESULT 为有符号 32 位(失败为负),与 includes/w32/win32base.vp:5 的 HANDLE 无关。 +# DLL 归属:ole32(project.vpj 已含 -lole32)。 +# ============================================================ + +GUID_SIZE: t.CDefine = 16 # GUID:Data1(4)+Data2(2)+Data3(2)+Data4(8) +GUID_STR_CAP: t.CDefine = 40 # "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + NUL + +HRESULT: t.CTypedef = t.CInt32T # Win32: typedef LONG HRESULT(负值才是失败) + +extern def CoCreateGuid(pguid: t.CPtr) -> HRESULT + +# 由 App/main.vp 注入 +_mbuddy: rawptr[memhub.MemBuddy] + + +# 生成 UUID 字符串(8-4-4-4-12,小写十六进制)写入 Out;返回 1=成功。 +# Data1/Data2/Data3 在内存中小端存放:按字段读回数值后再按 %08x/%04x 文本化, +# Data4[8] 原样按字节文本化。 +def NewGuidStr(Out: bytes, Cap: int) -> int: + GB: bytes = _mbuddy.alloc(GUID_SIZE) + if GB is None: + return 0 + Hr: HRESULT = CoCreateGuid(t.CPtr(GB)) + if Hr < 0: + _mbuddy.free(t.CPtr(GB)) + return 0 + ub: BYTEPTR = BYTEPTR(GB) + D1: t.CUInt32T = VPe.ReadU32(ub, 0) + D2: t.CUInt32T = VPe.ReadU16(ub, 4) + D3: t.CUInt32T = VPe.ReadU16(ub, 6) + viperlib.snprintf(Out, t.CSizeT(Cap), + "%08x-%04x-%04x-%02x%02x-%02x%02x%02x%02x%02x%02x", + D1, D2, D3, + t.CUInt32T(ub[8]), t.CUInt32T(ub[9]), + t.CUInt32T(ub[10]), t.CUInt32T(ub[11]), + t.CUInt32T(ub[12]), t.CUInt32T(ub[13]), + t.CUInt32T(ub[14]), t.CUInt32T(ub[15])) + _mbuddy.free(t.CPtr(GB)) + return 1 diff --git a/App/lib/VJson.vp b/App/lib/VJson.vp new file mode 100644 index 0000000..83ab59c --- /dev/null +++ b/App/lib/VJson.vp @@ -0,0 +1,258 @@ +import t, c +from stdint import * +import stdlib +import memhub +import string +import json +import lib.VFs as VFs +import lib.VLog as VLog +import lib.VCache as VCache + + +# ============================================================ +# VJson - JSON 构建(单条对象 / 整轮数组,全部走仓库 json 库) +# +# 本轮改造(需求 ①/②): +# * 不再手工拼 `{"k":"v",...}`、不再手写 JsonEscape:条目改为用库的 +# json.object + json.object_set + json.string_val 构造,再由 json.write 序列化; +# 转义(\ " \n \r \t)与 UTF-8 原样透传由库保证(includes/json/__writer.vp:39-66)。 +# * 输出为**紧凑单行**:库的 write 忽略 pretty 参数、且从不写空白/换行 +# (includes/json/__writer.vp:69-137、:151-155),故整轮数组与其中每个对象在同一行。 +# * 数组框架('[' / ',' / ']' / 结尾换行)仍由本模块拼接:库的 write 只写单个输出缓冲 +# (缺省 cap = 4096,includes/json/__writer.vp:143-148),整轮数组(最多 256 条) +# 无法一次交给库写,故逐条 write 再拼接。 +# +# 库接口(静态核验,详见交付报告「能力与用法清单」): +# json.object(pool) -> rawptr[JsonValue] +# json.string_val(pool, s: str) -> rawptr[JsonValue] # 内部复制一份字符串 +# json.object_set(pool, obj, key: str, val) -> 无返回 # 追加序 = 输出键序 +# json.write(pool, val, pretty: bool, cap: t.CSizeT = 4096) -> str +# # pretty 被忽略,恒紧凑单行;超出 cap 返回 None +# json.parse(pool, src: str) -> rawptr[JsonValue] +# pool 形参类型 rawptr[memhub.MemManager](vtable 基类)⇒ MemBuddy / MemPool 都可传 +# (先例:includes/requests/https.vp:823,883 传 rawptr[memhub.MemPool])。 +# +# 用途约定:本模块声明的 Json_Buf 同时供 stdout 与实际写盘的缓存文件使用 +# (调用点见 VScan.RunScanRound),二者逐字节相同。 +# +# 失败处理(fail-closed):条目在分配前先过 Json_Pool.AllocOk(Need) 预检(BuildEntry), +# 预检不足即跳过该条目(返回 None);逐条跳过原因(含路径)仅 --verbose 输出,默认由 +# EmitJson 的轮末聚合("本轮有 N 条被跳过")给出计数——库内部的 pool.alloc 不会在本模块 +# 路径上走到 includes/memhub.vp:262-272 的 printf + exit(1);跳过不写 Json_Buf, +# 故本轮数组仍是合法单行 JSON(末尾 ']' 与换行照常闭合)。 +# +# 依赖:VCache(CacheCount/CacheExe… 读表)、VFs(AnsiToUtf8/CopyRaw)、VLog。 +# +# 内存归属(进程生命周期,均不释放): +# * Json_Buf / Conv_Buf 由 Vet_Mb(main 注入的 MemBuddy)分配; +# * Json_Arena 由 stdlib.malloc 分配; +# * Json_Pool(MemPool 管理器对象)由 memhub.NewMemPool 在堆上就地构造 +# ——JsonInit 的栈帧在返回后失效,管理器对象必须在堆上(见 memhub.vp 的 +# NewMemPool 注释与 Debugger/memhub-lifetime.md)。 +# ============================================================ + +JSON_BUF_CAP: t.CDefine = 1048576 # 一轮 JSON 数组缓冲 1 MiB +JSON_POOL_CAP: t.CDefine = 65536 # 逐条序列化工作池 64 KiB(每条目 reset 复用) +JSON_ENTRY_MAX: t.CDefine = 4096 # 单条上限 = json 库 write 的缺省 cap(4096) +JSON_ENTRY_SLACK: t.CDefine = 512 # 单条固定语法开销上界(实测 77:键/值引号 24 + 键名 40 + 冒号 6 + 逗号 5 + 大括号 2) + +# 单条序列化在 Json_Pool 上的固定开销上界(BuildEntry 的 AllocOk 预检的一部分)。 +# 组成(一次 BuildEntry 的全部池分配;条目级 reset 之后 offset=0): +# * 7 个 JsonValue 节点(1 个对象 + 6 个字符串值)× 单节点上界 72 字节 = 504 +# (JsonValue 9 字段 × 8 字节上界,includes/json/__init__.vp:23-39,与 VCache +# 解析树的估算口径一致); +# * 6 个键名副本(含 NUL)46 字节:id 3 + executable 11 + version 8 + arch 5 +# + source 7 + displayName 12(includes/json/__init__.vp:225-229); +# * json.write 的输出缓冲 4096 字节(includes/json/__writer.vp:143-148,缺省 cap); +# * 余量 512 字节:6 个值副本各带一个结尾 NUL,加条内 20 次池分配按 +# MEMHUB_ALIGN=8 向上取整的浪费(每次 ≤7,合计 ≤140)。 +# 合计 504 + 46 + 4096 + 512 = 5158。 +JSON_ENTRY_POOL_FIXED: t.CDefine = 5158 + +# 单条里 executable / displayName 的取值长度上界:二者都由 Conv_Buf(VFs.CONV_CAP 字节) +# 拷出(WideCharToMultiByte 以 Cap 封顶并写结尾 NUL,失败时 VFs.CopyRaw 写空串), +# 故每个值 ≤ CONV_CAP-1 字符。写 2047 = VFs.CONV_CAP(2048) - 1 的字面量:模块级 +# t.CDefine 不引用跨模块常量,VFs.CONV_CAP 只在函数体内参与算术。 +JSON_ENTRY_CONV_MAX: t.CDefine = 2047 + +# 由 App/main.vp 注入 +_mbuddy: rawptr[memhub.MemBuddy] + +Json_Buf: bytes = None # 整轮数组文本(stdout 与缓存文件共用) +Json_Len: int = 0 # Json_Buf 的有效字节数 +Conv_Buf: bytes = None # ANSI → UTF-8 转换缓冲 +Json_Arena: bytes = None # 序列化工作池 arena(stdlib.malloc,常驻) +Json_Pool: rawptr[memhub.MemPool] # 序列化工作池(堆上就地构造;见 JsonInit) + + +# 分配 JSON 静态缓冲与序列化工作池(进程生命周期内复用)。返回 1=成功,0=失败 +def JsonInit() -> int: + global Json_Buf, Conv_Buf, Json_Arena, Json_Pool + Json_Buf = _mbuddy.alloc(JSON_BUF_CAP) + Conv_Buf = _mbuddy.alloc(VFs.CONV_CAP) + Json_Arena = stdlib.malloc(JSON_POOL_CAP) + if Json_Buf is None or Conv_Buf is None or Json_Arena is None: + return 0 + # 池对象走堆上就地构造(memhub.NewMemPool):Json_Pool 要被 EmitJson/BuildEntry + # 跨帧使用,而本函数的栈帧在返回时即失效——构造表达式在这里会把池对象的存储 + # 落在本帧栈上(IR:VJson 的 alloca %MemPool + __before_init__ + __init__)。 + # arena 由 stdlib.malloc 常驻;池对象同样常驻,进程生命周期内不 free。 + Pool: rawptr[memhub.MemPool] = memhub.NewMemPool(Json_Arena, JSON_POOL_CAP) + if Pool is None: + return 0 + Json_Pool = Pool + return 1 + + +# 供 VScan 写 stdout / VCache 写缓存文件读取结果 +def JsonBuf() -> bytes: + return Json_Buf + + +def JsonLen() -> int: + return Json_Len + + +def JReset(): + global Json_Len + Json_Len = 0 + if Json_Buf is not None: + Json_Buf[0] = 0 + + +# 把字面量/已序列化文本追加到 Json_Buf(容量不足返回 0,不写穿) +def JWrite(S: str) -> int: + global Json_Len + if S is None or Json_Buf is None: + return 0 + n: t.CSizeT = string.strlen(S) + if t.CSizeT(Json_Len) + n + 1 > t.CSizeT(JSON_BUF_CAP): + return 0 + i: t.CSizeT = 0 + while i < n: + Json_Buf[Json_Len + t.CInt(i)] = S[i] + i += 1 + Json_Len += t.CInt(n) + Json_Buf[Json_Len] = 0 + return 1 + + +# 构造第 Idx 条 JSON 对象并交给库序列化为紧凑单行文本。 +# 返回该条文本(分配在 Json_Pool 上,长度 < JSON_ENTRY_MAX);None=已跳过。 +# 每条跳过的具体原因(含被处理路径)仅在 --verbose 时逐条给出;默认安静模式下由 +# EmitJson 的轮末聚合("本轮有 N 条被跳过")给出计数,既不静默异常也不逐路径刷屏。 +def BuildEntry(Idx: int) -> str: + # 池容量预检(fail-closed):本条在 Json_Pool 上的全部分配必须先确认放得下, + # 否则库内部的 pool.alloc 会走到 includes/memhub.vp:262-272 的 printf + exit(1)。 + # 预检在 EmitJson 的条目级 reset 之后执行(池 offset=0),故单次 AllocOk 覆盖整条; + # Need 由固定开销 + 全部值的长度上界构成,是条内池占用(含 20 次分配的 8 字节对齐 + # 浪费)的上界,推导见 JSON_ENTRY_POOL_FIXED 与 JSON_ENTRY_CONV_MAX 的注释。 + ValNeed: t.CSizeT = t.CSizeT(JSON_ENTRY_CONV_MAX) + t.CSizeT(JSON_ENTRY_CONV_MAX) + ValNeed = ValNeed + string.strlen(VCache.CacheId(Idx)) + ValNeed = ValNeed + string.strlen(VCache.CacheVer(Idx)) + ValNeed = ValNeed + string.strlen(VCache.CacheArch(Idx)) + ValNeed = ValNeed + string.strlen(VCache.CacheSrc(Idx)) # source("workspace"/"check") + Need: t.CSizeT = t.CSizeT(JSON_ENTRY_POOL_FIXED) + ValNeed + if Json_Pool.AllocOk(Need) == 0: + VLog.VerboseFmtIS(" 警告: JSON 工作池不足(本条需 %zu 字节),已跳过: %s\n", + Need, VCache.CacheExe(Idx)) + return None + Obj: rawptr[json.JsonValue] = json.object(Json_Pool) + if Obj == None: + VLog.VerboseStr(" 警告: JSON 对象分配失败(工作池不足),已跳过该条目\n") + return None + # 键插入顺序 = 输出顺序(库按 child→next 链表输出,见 __writer.vp:92-107)。 + # executable / displayName 按既有约定转 UTF-8,且**转换后立即交给库复制**: + # Conv_Buf 随后要复用于 displayName,晚一步用就会让 executable 串被覆盖。 + json.object_set(Json_Pool, Obj, "id", json.string_val(Json_Pool, VCache.CacheId(Idx))) + if VFs.AnsiToUtf8(VCache.CacheExe(Idx), Conv_Buf, VFs.CONV_CAP) == 0: + VFs.CopyRaw(Conv_Buf, VFs.CONV_CAP, VCache.CacheExe(Idx)) + json.object_set(Json_Pool, Obj, "executable", json.string_val(Json_Pool, str(Conv_Buf))) + json.object_set(Json_Pool, Obj, "version", json.string_val(Json_Pool, VCache.CacheVer(Idx))) + json.object_set(Json_Pool, Obj, "arch", json.string_val(Json_Pool, VCache.CacheArch(Idx))) + # source 取自缓存条目(扫盘="workspace";check 通道="check",见 VCache.Cache_Src) + json.object_set(Json_Pool, Obj, "source", json.string_val(Json_Pool, VCache.CacheSrc(Idx))) + if VFs.AnsiToUtf8(VCache.CacheDisp(Idx), Conv_Buf, VFs.CONV_CAP) == 0: + VFs.CopyRaw(Conv_Buf, VFs.CONV_CAP, VCache.CacheDisp(Idx)) + json.object_set(Json_Pool, Obj, "displayName", json.string_val(Json_Pool, str(Conv_Buf))) + + # 结构自检:库在各分配点"失败即不追加/字段留空",此处逐字段确认 6 个键都带上了 + # 键名与字符串值(池耗尽时若漏检会写出 `{null:"…"}` 这类非法 JSON)。 + # 顺带累计各值长度,供容量守卫使用。 + Ok: int = 1 + N: int = 0 + Sum: t.CSizeT = 0 + cu: rawptr[json.JsonValue] = Obj.child + while cu != None: + if cu.key == None or cu.vtype != json.JSON_STRING or cu.str_val == None: + Ok = 0 + else: + Sum = Sum + string.strlen(cu.str_val) + N += 1 + cu = cu.next + if Ok == 0 or N != 6: + VLog.VerboseStr(" 警告: JSON 条目构造不完整(工作池不足),已跳过该条目\n") + return None + + # 容量守卫:库的 write 缺省 cap=4096(=JSON_ENTRY_MAX),超出已会返回 None 且不写穿 + # (includes/json/__writer.vp:143-154);此处提前守卫只是避免走到"整条被丢弃 + 告警"。 + # 上界推导(Sum = 6 个值长度和): + # 实际输出 = 各值转义长度和 + 固定语法 77(键/值引号 24 + 键名 40 + 冒号 6 + 逗号 5 + 大括号 2) + # ≤ 2*Sum + 77 ≤ 2*Sum + JSON_ENTRY_SLACK(512) + # 故 2*Sum + JSON_ENTRY_SLACK ≤ JSON_ENTRY_MAX ⇒ 实际输出 ≤ 3661 < 4095,含结尾 NUL 也放得下。 + if 2 * Sum + t.CSizeT(JSON_ENTRY_SLACK) > t.CSizeT(JSON_ENTRY_MAX): + VLog.VerboseFmtIS(" 警告: JSON 条目过长(超过 %zu 字节),已跳过: %s\n", + t.CSizeT(JSON_ENTRY_MAX), VCache.CacheExe(Idx)) + return None + + Txt: str = json.write(Json_Pool, Obj, False) + if Txt is None: + VLog.VerboseStr(" 警告: JSON 序列化失败(工作池不足),已跳过该条目\n") + return None + return Txt + + +# 把当前缓存整体构建为**单行** JSON 数组文本写入 Json_Buf(stdout 与缓存文件共用) +def EmitJson() -> int: + JReset() + if Json_Pool is None: + # 不变式:JsonInit(VScan.ScanInit 调用)成功才会走到 EmitJson;此分支仅为不崩。 + VLog.LogStr(" 警告: JSON 工作池未初始化,跳过本轮 JSON 输出\n") + return 0 + Truncated: int = 0 + Skipped: int = 0 + Count: int = 0 + if JWrite("[") == 0: + return 0 + i: int = 0 + while i < VCache.CacheCount(): + # 条目级回收:工作池峰值 = 单条(节点 + 字符串副本 + 库的 4096 字节输出缓冲) + Json_Pool.reset() + Txt: str = BuildEntry(i) + if Txt is None: + Skipped += 1 + else: + n: t.CSizeT = string.strlen(Txt) + Need: t.CSizeT = n + 3 # 自身 + 结尾 ']' + '\n' + NUL + if Count > 0: + Need = Need + 1 # 分隔逗号 + if t.CSizeT(Json_Len) + Need > t.CSizeT(JSON_BUF_CAP): + # 缓冲不足:跳过该条(不再像旧实现那样写出半条),末尾仍闭合 ']' + Truncated = 1 + else: + if Count > 0: + JWrite(",") + JWrite(Txt) + Count += 1 + i += 1 + if JWrite("]") == 0: + Truncated = 1 + if JWrite("\n") == 0: + Truncated = 1 + if Truncated != 0: + VLog.LogFmt2(" 警告: JSON 输出缓冲不足,本轮已截断(已写 %zu 条 / 缓存 %zu 条)\n", + t.CSizeT(Count), t.CSizeT(VCache.CacheCount())) + if Skipped != 0: + VLog.LogFmt1(" 警告: 本轮有 %zu 条 JSON 条目因过长/工作池不足被跳过\n", + t.CSizeT(Skipped)) + return 0 diff --git a/App/lib/VLog.vp b/App/lib/VLog.vp new file mode 100644 index 0000000..a545891 --- /dev/null +++ b/App/lib/VLog.vp @@ -0,0 +1,242 @@ +import t, c +from stdint import * +import stdlib +import string +import viperlib +import w32.win32file as wf +import w32.win32base as wb + + +# ============================================================ +# VLog - 输出原语(全部人类可读日志走 stderr;stdout 只留 JSON) +# +# 本模块从原 App/main.vp 的 [stderr 日志] 段整体平移,行为不变: +# * LogStr/LogFmt1/2/3/LogFmtS1/S2/LogFmtIS 沿用原实现与固定元数转发风格; +# * 本轮新增 LogFmt4(进度行需要 4 个整数参数)与两个时间戳辅助。 +# +# 日志详细度(本轮新增):模块级开关 Verbose_Enabled(0=默认安静,1=--verbose)。 +# * 默认安静:只保留"启动摘要 / 心跳确认提示与失败原因 / 警告与致命错误 / 轮末一行"; +# 过程细节(进度行、扫描根进出、逐条 [reject]/[pe]、逐次 --version 探测、目录表逐条 +# 列举、轮末的"下轮开始")一律不输出。 +# * VerboseStr / VerboseFmt1/2/3/4 / VerboseFmtS1/S2/VerboseFmtIS 是 Log* 的兄弟函数: +# 开关关闭时直接返回(不格式化、不写 stderr),打开时逐字转发给对应 Log*。 +# 调用点只写 Verbose*,"开关判断"集中在本模块,不散落到各调用点。 +# * 取值由 VCli 解析 --verbose 后经 SetVerbose 在**任何日志之前**设置 +# (scan:App/main.vp:VCli.ParseScanArgs 之后;看门狗:confirm-watch 参数解析之后)。 +# 依赖(本仓既有): +# wf.GetStdHandle(includes/w32/win32file.vp:120)、wf.WriteFile(:91)、 +# wf.STD_ERROR_HANDLE(:46)、wf.STD_OUTPUT_HANDLE(:45)、wb.INVALID_HANDLE_VALUE(:9)、 +# wb.GetLocalTime(includes/w32/win32base.vp:72)、wb.SystemTimeToFileTime(:74)、 +# wb.FileTimeToSystemTime(:73)、wb.SYSTEMTIME(:40)、wb.FILETIME(:36)、 +# viperlib.snprintf(includes/viperlib.vp:25)、string.strlen(includes/string.vp:69)。 +# ============================================================ + +LOG_CAP: t.CDefine = 4096 # 日志格式化缓冲 + +Log_Buffer: bytes = None # 日志格式化缓冲(stdlib.malloc,进程生命周期) + + +# 字面量/已格式化字节串原样写 stderr(不自动追加换行) +def LogStr(S: str): + if S is None: + return + H: wb.HANDLE = wf.GetStdHandle(wf.STD_ERROR_HANDLE) + if H is None or H == wb.INVALID_HANDLE_VALUE: + return + n: t.CSizeT = string.strlen(S) + if n == 0: + return + bw: ULONG = 0 + wf.WriteFile(H, S, ULONG(n), t.CPtr(c.Addr(bw)), None) + + +def LogInit() -> int: + global Log_Buffer + Log_Buffer = stdlib.malloc(LOG_CAP) + if Log_Buffer is None: + return 0 + Log_Buffer[0] = 0 + return 1 + + +# 固定元数的格式化转发(非 varargs 函数 ⇒ 不引入自定义可变参数风险): +# 内部调用既有的 viperlib.snprintf,把格式化结果写 stderr。 +def LogFmt1(Fmt: str, A: t.CSizeT) -> int: + if Log_Buffer is None: + return 0 + viperlib.snprintf(Log_Buffer, t.CSizeT(LOG_CAP), Fmt, A) + LogStr(str(Log_Buffer)) + return 1 + + +def LogFmt2(Fmt: str, A: t.CSizeT, B: t.CSizeT) -> int: + if Log_Buffer is None: + return 0 + viperlib.snprintf(Log_Buffer, t.CSizeT(LOG_CAP), Fmt, A, B) + LogStr(str(Log_Buffer)) + return 1 + + +def LogFmt3(Fmt: str, A: t.CSizeT, B: t.CSizeT, C: t.CSizeT) -> int: + if Log_Buffer is None: + return 0 + viperlib.snprintf(Log_Buffer, t.CSizeT(LOG_CAP), Fmt, A, B, C) + LogStr(str(Log_Buffer)) + return 1 + + +# 需求 3:扫盘进度行需要 4 个整数(r/dir/file/cand) +def LogFmt4(Fmt: str, A: t.CSizeT, B: t.CSizeT, C: t.CSizeT, D: t.CSizeT) -> int: + if Log_Buffer is None: + return 0 + viperlib.snprintf(Log_Buffer, t.CSizeT(LOG_CAP), Fmt, A, B, C, D) + LogStr(str(Log_Buffer)) + return 1 + + +def LogFmtS1(Fmt: str, A: str) -> int: + if Log_Buffer is None: + return 0 + viperlib.snprintf(Log_Buffer, t.CSizeT(LOG_CAP), Fmt, A) + LogStr(str(Log_Buffer)) + return 1 + + +def LogFmtS2(Fmt: str, A: str, B: str) -> int: + if Log_Buffer is None: + return 0 + viperlib.snprintf(Log_Buffer, t.CSizeT(LOG_CAP), Fmt, A, B) + LogStr(str(Log_Buffer)) + return 1 + + +def LogFmtIS(Fmt: str, A: t.CSizeT, B: str) -> int: + if Log_Buffer is None: + return 0 + viperlib.snprintf(Log_Buffer, t.CSizeT(LOG_CAP), Fmt, A, B) + LogStr(str(Log_Buffer)) + return 1 + + +# ============================================================ +# verbose 开关与"仅 verbose 才输出"的兄弟函数 +# * 开关集中在 Verbose_Enabled 一处;调用点只写 Verbose*,不写 if。 +# * 关闭时直接返回 0(连格式化都不做),打开时逐字转发给对应 Log*。 +# * 返回码与 Log* 一致(1=已写出,0=未写出/失败)。 +# ============================================================ + +Verbose_Enabled: int = 0 # 0=默认安静;1=--verbose + + +def SetVerbose(V: int): + global Verbose_Enabled + Verbose_Enabled = V + + +def VerboseOn() -> int: + return Verbose_Enabled + + +def VerboseStr(S: str) -> int: + if Verbose_Enabled == 0: + return 0 + LogStr(S) + return 1 + + +def VerboseFmt1(Fmt: str, A: t.CSizeT) -> int: + if Verbose_Enabled == 0: + return 0 + LogFmt1(Fmt, A) + return 1 + + +def VerboseFmt2(Fmt: str, A: t.CSizeT, B: t.CSizeT) -> int: + if Verbose_Enabled == 0: + return 0 + LogFmt2(Fmt, A, B) + return 1 + + +def VerboseFmt3(Fmt: str, A: t.CSizeT, B: t.CSizeT, C: t.CSizeT) -> int: + if Verbose_Enabled == 0: + return 0 + LogFmt3(Fmt, A, B, C) + return 1 + + +def VerboseFmt4(Fmt: str, A: t.CSizeT, B: t.CSizeT, C: t.CSizeT, D: t.CSizeT) -> int: + if Verbose_Enabled == 0: + return 0 + LogFmt4(Fmt, A, B, C, D) + return 1 + + +def VerboseFmtS1(Fmt: str, A: str) -> int: + if Verbose_Enabled == 0: + return 0 + LogFmtS1(Fmt, A) + return 1 + + +def VerboseFmtS2(Fmt: str, A: str, B: str) -> int: + if Verbose_Enabled == 0: + return 0 + LogFmtS2(Fmt, A, B) + return 1 + + +def VerboseFmtIS(Fmt: str, A: t.CSizeT, B: str) -> int: + if Verbose_Enabled == 0: + return 0 + LogFmtIS(Fmt, A, B) + return 1 + + +# stdout 原样写(scan 模式只输出 JSON) +def StdoutWrite(S: str): + if S is None: + return + H: wb.HANDLE = wf.GetStdHandle(wf.STD_OUTPUT_HANDLE) + if H is None or H == wb.INVALID_HANDLE_VALUE: + return + n: t.CSizeT = string.strlen(S) + if n == 0: + return + bw: ULONG = 0 + wf.WriteFile(H, S, ULONG(n), t.CPtr(c.Addr(bw)), None) + + +# ============================================================ +# 时间戳(需求 3:轮次开始/下轮开始) +# 用 Win32 本地时间;AddMs 通过 FILETIME(100ns) 加法后回转 SYSTEMTIME。 +# 依据:wb.FILETIME 为 {dwLowDateTime,dwHighDateTime}(win32base.vp:36), +# FILETIME 单位 100ns(Win32 规范),故 AddMs*10000 为增量。 +# ============================================================ + +def TimeStampNow(Out: bytes, Cap: int) -> int: + St: wb.SYSTEMTIME = wb.SYSTEMTIME() + wb.GetLocalTime(t.CPtr(c.Addr(St))) + viperlib.snprintf(Out, t.CSizeT(Cap), "%04u-%02u-%02u %02u:%02u:%02u", + t.CUInt32T(St.wYear), t.CUInt32T(St.wMonth), t.CUInt32T(St.wDay), + t.CUInt32T(St.wHour), t.CUInt32T(St.wMinute), t.CUInt32T(St.wSecond)) + return 1 + + +def TimeStampAfter(Out: bytes, Cap: int, AddMs: t.CSizeT) -> int: + St: wb.SYSTEMTIME = wb.SYSTEMTIME() + wb.GetLocalTime(t.CPtr(c.Addr(St))) + Ft: wb.FILETIME = wb.FILETIME() + if wb.SystemTimeToFileTime(t.CPtr(c.Addr(St)), t.CPtr(c.Addr(Ft))) == 0: + return TimeStampNow(Out, Cap) + Ticks: ULONGLONG = (ULONGLONG(Ft.dwHighDateTime) << 32) | ULONGLONG(Ft.dwLowDateTime) + Ticks = Ticks + (ULONGLONG(AddMs) * ULONGLONG(10000)) + Ft.dwLowDateTime = DWORD(Ticks & ULONGLONG(0xFFFFFFFF)) + Ft.dwHighDateTime = DWORD(Ticks >> 32) + St2: wb.SYSTEMTIME = wb.SYSTEMTIME() + if wb.FileTimeToSystemTime(t.CPtr(c.Addr(Ft)), t.CPtr(c.Addr(St2))) == 0: + return TimeStampNow(Out, Cap) + viperlib.snprintf(Out, t.CSizeT(Cap), "%04u-%02u-%02u %02u:%02u:%02u", + t.CUInt32T(St2.wYear), t.CUInt32T(St2.wMonth), t.CUInt32T(St2.wDay), + t.CUInt32T(St2.wHour), t.CUInt32T(St2.wMinute), t.CUInt32T(St2.wSecond)) + return 1 diff --git a/App/lib/VPe.vp b/App/lib/VPe.vp new file mode 100644 index 0000000..75cc896 --- /dev/null +++ b/App/lib/VPe.vp @@ -0,0 +1,482 @@ +import t, c +from stdint import * +import stdlib +import string +import viperlib +import sys +import lib.VFs as VFs +import lib.VLog as VLog +import lib.VDirs as VDirs +import lib.VProc as VProc +import lib.VCli as VCli +import w32.win32file as wf +import w32.win32base as wb + + +# ============================================================ +# VPe - PE/COFF 解析 + ver 子命令流程 +# +# 从原 App/main.vp 平移 PE 解析(ReadU16/ReadU32/节表/.PRVERSN/arch)与 [1/3] 诊断, +# 并承载 ver 的三段式流程([0/3] 目录表 → [1/3] 段读取 → [2/3] --version 前缀)。 +# +# 依赖:VFs(路径/读文件/通用字节)、VLog(日志)、VDirs(目录表)、VProc(带超时探测)、VCli(版本号)。 +# 偏移/常量依据:PE/COFF 规范;本仓原实现(本次拆分前 App/main.vp:29-58、:517-988)。 +# +# 日志详细度:逐次 PE 解析中间量([pe] 行)、被校验目标路径、目录表逐条列举、--version 探测 +# 细节属过程细节,走 VLog.Verbose*(默认安静,仅 --verbose 时输出);ver 的 [x/3] 步骤结果、 +# PE/版本段失败原因、警告与降级说明始终输出,且不内嵌被处理路径。 +# ============================================================ + +# PE/COFF 常量 +DOS_SIGNATURE: t.CDefine = 0x5A4D # "MZ" 小端 +NT_SIGNATURE: t.CDefine = 0x00004550 # "PE\0\0" 小端 +OPT_MAGIC_PE32: t.CDefine = 0x10B +OPT_MAGIC_PE32P: t.CDefine = 0x20B +SEC_ENTRY_SIZE: t.CDefine = 40 + +E_LFANEW_OFF: t.CDefine = 0x3C # DOS 头 e_lfanew +COFF_MACHINE_OFF: t.CDefine = 4 # PE签名(4)+COFF: Machine @ COFF+0 +COFF_NUMSEC_OFF: t.CDefine = 6 # PE签名(4)+COFF: NumberOfSections @ COFF+2 +COFF_OPTSIZE_OFF: t.CDefine = 20 # PE签名(4)+COFF: SizeOfOptionalHeader @ COFF+16 +OPT_MAGIC_OFF: t.CDefine = 24 # PE签名(4)+COFF(20) 之后为可选头 Magic + +SEC_NAME_OFF: t.CDefine = 0 +SEC_RAWSIZE_OFF: t.CDefine = 16 +SEC_RAWPTR_OFF: t.CDefine = 20 + +VERSION_SECTION: t.CDefine = ".PRVERSN" + +# COFF Machine 值(winnt.h)→ arch 字符串 +MACHINE_X86_64: t.CDefine = 0x8664 +MACHINE_X86: t.CDefine = 0x14C +MACHINE_ARM64: t.CDefine = 0xAA64 +MACHINE_ARM: t.CDefine = 0x1C0 +MACHINE_ARMNT: t.CDefine = 0x1C4 + +# 扫盘用 PE 读取上限 +PE_HEAD_READ: t.CDefine = 8192 # 单次读取的"头部窗口"(DOS+COFF+可选头+节表) + +VER_CAP: t.CDefine = 128 # .PRVERSN 版本串缓冲 +ARCH_CAP: t.CDefine = 32 # arch 字符串缓冲("unknown(0xFFFF)" 14 字节) + +# ParsePeHead 的输出(用模块全局而非数组出参) +Pe_RawPtr: t.CUInt32T = 0 +Pe_RawSize: t.CUInt32T = 0 + +# TryReadPeInfo 打开失败(rc=1)时的 GetLastError(check 通道据此区分 NOT_FOUND / NO_PERMISSION)。 +# 生命周期:TryReadPeInfo 每次进入复位为 0;仅在 CreateFileA 返回 INVALID_HANDLE_VALUE 时写入, +# 且**紧跟该调用读取**(其后任何 Win32 调用——包括 PeDbgPrint 里的 WriteFile——都会覆盖它)。 +Pe_OpenErr: ULONG = 0 + +# 失败路径诊断([pe] 行)的中间量:ParsePeHead 每步写入此处,供调用点在失败时打印。 +# Pe_DbgGot :本次参与解析的字节数(scan=头部窗口实读字节数;ver=[1/3] 为整文件字节数) +# Pe_DbgSecOff :节表起始偏移 = e_lfanew + 24 + SizeOfOptionalHeader(尚未算出时保持 0) +# Pe_DbgLastRc :最近一次解析失败码(0=成功;scan 路径为 ParsePeHead 的 prc, +# ver 路径为 Phase1VersionSection 按同一条件编码的等价码) +# 生命周期:TryReadPeInfo 每次进入复位;Phase1VersionSection 每次进入复位。 +Pe_DbgLfanew: t.CUInt32T = 0 +Pe_DbgNsec: t.CUInt32T = 0 +Pe_DbgOptSize: t.CUInt32T = 0 +Pe_DbgSecOff: t.CUInt32T = 0 +Pe_DbgGot: t.CSizeT = 0 +Pe_DbgLastRc: int = 0 + + +# ============================================================ +# 小端读取(Buf 为无符号字节基址,Buf[i] 即 u8) +# ============================================================ + +def ReadU16(Buf: BYTEPTR, Off: int) -> t.CUInt32T: + Val: t.CUInt32T = t.CUInt32T(Buf[Off]) + Val = Val | (t.CUInt32T(Buf[Off + 1]) << 8) + return Val + + +def ReadU32(Buf: BYTEPTR, Off: int) -> t.CUInt32T: + Val: t.CUInt32T = t.CUInt32T(Buf[Off]) + Val = Val | (t.CUInt32T(Buf[Off + 1]) << 8) + Val = Val | (t.CUInt32T(Buf[Off + 2]) << 16) + Val = Val | (t.CUInt32T(Buf[Off + 3]) << 24) + return Val + + +# 复制 8 字节节名并补 NUL,写入 Out(调用方保证 >=9 字节) +def CopySectionName(Buf: BYTEPTR, Off: int, Out: bytes): + k: int = 0 + while k < 8: + Out[k] = Buf[Off + k] + k += 1 + Out[8] = 0 + + +# ============================================================ +# PE 解析(轻量,无诊断输出;供 scan 与 ver 共用同一套常量/偏移) +# ============================================================ + +# COFF Machine → arch 字符串。返回 1=已写入 +def FillArch(OutArch: bytes, Cap: int, Machine: int) -> int: + if Cap < 24: + return 0 + if Machine == MACHINE_X86_64: + VFs.CopyRaw(OutArch, Cap, "x86_64") + elif Machine == MACHINE_X86: + VFs.CopyRaw(OutArch, Cap, "x86") + elif Machine == MACHINE_ARM64: + VFs.CopyRaw(OutArch, Cap, "arm64") + elif Machine == MACHINE_ARM or Machine == MACHINE_ARMNT: + VFs.CopyRaw(OutArch, Cap, "arm") + else: + viperlib.snprintf(OutArch, t.CSizeT(Cap), "unknown(0x%X)", t.CUInt32T(Machine)) + return 1 + + +# [诊断] 打印一行 PE 解析中间量(单行由多次固定元数转发拼出,不引入自定义 varargs)。 +# Rc:失败码;Fsz:文件大小(该点尚不可知时传 0);RawPtr/RawSize:.PRVERSN 段位置(尚不可知时传 0)。 +# sectab_end = sec_off + nsec * 40,由打印时当场推算(尚未解析出的量保持 0)。 +# 属"内部路径细节"(逐次解析中间量),默认安静,仅 --verbose 时输出。 +def PeDbgPrint(Rc: int, Fsz: t.CSizeT, RawPtr: t.CUInt32T, RawSize: t.CUInt32T): + global Pe_DbgLastRc + Pe_DbgLastRc = Rc + SecTabEnd: t.CSizeT = t.CSizeT(Pe_DbgSecOff) + t.CSizeT(Pe_DbgNsec) * t.CSizeT(SEC_ENTRY_SIZE) + VLog.VerboseFmt4(" [pe] rc=%u lfanew=%u nsec=%u optsize=%u", + t.CSizeT(Rc), t.CSizeT(Pe_DbgLfanew), t.CSizeT(Pe_DbgNsec), t.CSizeT(Pe_DbgOptSize)) + VLog.VerboseFmt4(" sec_off=%u sectab_end=%u got=%u fsz=%u", + t.CSizeT(Pe_DbgSecOff), SecTabEnd, Pe_DbgGot, Fsz) + VLog.VerboseFmt2(" rawptr=%u rawsize=%u\n", t.CSizeT(RawPtr), t.CSizeT(RawSize)) + + +# 取"最近一次 TryReadPeInfo 的 CreateFileA 失败错误码"(rc=1 时才有意义;其余情况为 0)。 +# check 通道按此映射:2/3(NOT_FOUND) → NOT_FOUND;5/32 → NO_PERMISSION;其它 → NO_PERMISSION。 +def PeOpenErr() -> ULONG: + return Pe_OpenErr + + +# 纯解析:从已读入的"头部窗口" Buf(Got 字节)解析出 .PRVERSN 的 raw_ptr/raw_size。 +# 结果写入模块全局 Pe_RawPtr / Pe_RawSize;同时填 OutArch。 +# 返回 0=找到 .PRVERSN;1..7=各类失败(仅需 0/非 0 语义): +# 1=Got<0x40(头部窗口不足) 2=缺少 MZ 签名 3=e_lfanew 越界 +# 4=缺少 PE 签名 5=可选头 Magic 未知 6=节表超出头部窗口 +# 7=未找到 .PRVERSN +# 每个 return 前把失败码写入 Pe_DbgLastRc,关键中间量写入 Pe_DbgLfanew/Pe_DbgNsec/ +# Pe_DbgOptSize/Pe_DbgSecOff/Pe_DbgGot,供调用点在失败时打 [pe] 行。 +def ParsePeHead(Buf: bytes, Got: t.CSizeT, OutArch: bytes, ArchCap: int) -> int: + global Pe_RawPtr, Pe_RawSize + global Pe_DbgLfanew, Pe_DbgNsec, Pe_DbgOptSize, Pe_DbgSecOff, Pe_DbgGot, Pe_DbgLastRc + Pe_RawPtr = 0 + Pe_RawSize = 0 + Pe_DbgLfanew = 0 + Pe_DbgNsec = 0 + Pe_DbgOptSize = 0 + Pe_DbgSecOff = 0 + Pe_DbgGot = Got + Pe_DbgLastRc = 0 + if Got < 0x40: + Pe_DbgLastRc = 1 + return 1 + ub: BYTEPTR = BYTEPTR(Buf) + if ReadU16(ub, 0) != DOS_SIGNATURE: + Pe_DbgLastRc = 2 + return 2 + lfanew: t.CUInt32T = ReadU32(ub, E_LFANEW_OFF) + Pe_DbgLfanew = lfanew + if t.CSizeT(lfanew) + 24 > Got: + Pe_DbgLastRc = 3 + return 3 + if ReadU32(ub, t.CInt(lfanew)) != NT_SIGNATURE: + Pe_DbgLastRc = 4 + return 4 + nsec: t.CUInt32T = ReadU16(ub, t.CInt(lfanew) + COFF_NUMSEC_OFF) + optsize: t.CUInt32T = ReadU16(ub, t.CInt(lfanew) + COFF_OPTSIZE_OFF) + Pe_DbgNsec = nsec + Pe_DbgOptSize = optsize + magic: t.CUInt32T = ReadU16(ub, t.CInt(lfanew) + OPT_MAGIC_OFF) + if magic != OPT_MAGIC_PE32 and magic != OPT_MAGIC_PE32P: + Pe_DbgLastRc = 5 + return 5 + mach: t.CUInt32T = ReadU16(ub, t.CInt(lfanew) + COFF_MACHINE_OFF) + FillArch(OutArch, ArchCap, t.CInt(mach)) + sec_off: t.CInt = t.CInt(lfanew) + 24 + t.CInt(optsize) + Pe_DbgSecOff = t.CUInt32T(sec_off) + if t.CSizeT(sec_off) + t.CSizeT(nsec) * t.CSizeT(SEC_ENTRY_SIZE) > Got: + Pe_DbgLastRc = 6 + return 6 # 节表超出头部窗口(极端多节 PE,跳过) + i: int = 0 + while i < t.CInt(nsec): + entry: t.CInt = sec_off + i * SEC_ENTRY_SIZE + if string.strncmp(str(Buf + entry + SEC_NAME_OFF), VERSION_SECTION, 8) == 0: + Pe_RawPtr = ReadU32(ub, entry + SEC_RAWPTR_OFF) + Pe_RawSize = ReadU32(ub, entry + SEC_RAWSIZE_OFF) + return 0 + i += 1 + Pe_DbgLastRc = 7 + return 7 + + +# 把 Src[Off .. Off+Len) 中的版本串(到第一个 NUL 或 VerCap-1)拷入 OutVer +def CopyVersion(Src: bytes, Off: t.CInt, Len: t.CInt, OutVer: bytes, VerCap: int) -> int: + n: t.CInt = 0 + while n < Len and n < VerCap - 1: + if Src[Off + n] == 0: + break + OutVer[n] = Src[Off + n] + n += 1 + OutVer[n] = 0 + return n + + +# 扫描用轻量 PE 探测:单次打开,先读头部窗口(PE_HEAD_READ)解析节表,段内容不在窗口内时 +# 改走整文件读(不再做 seek/二次定位)。成功返回 0 并填 OutVer/OutArch。 +# 返回非 0 = 跳过(非 PE / 无 .PRVERSN / 读取失败): +# 1=CreateFileA 打开失败 2=GetFileSizeEx 取文件大小失败 +# 3=文件过短 (fsz < 0x40) 4=头部窗口缓冲分配失败 +# 5=ReadFull 读取不足 (got < 0x40) 100+prc=ParsePeHead 失败(prc 含义见 ParsePeHead) +# 6=raw_size==0 或 raw_ptr+raw_size 超出文件大小(注意与 106=prc 6 区分) +# 7=整文件读取失败(VFs.ReadWholeFile 返回 None;原因见 VFs.LastReadErr) +# 每个非 0 返回前先打一行 [pe] 诊断(中间量见 Pe_Dbg*;本次尚未解析出的量打印 0)。 +def TryReadPeInfo(Target: str, OutVer: bytes, VerCap: int, OutArch: bytes, ArchCap: int) -> int: + global Pe_DbgLfanew, Pe_DbgNsec, Pe_DbgOptSize, Pe_DbgSecOff, Pe_DbgGot, Pe_DbgLastRc, Pe_OpenErr + OutVer[0] = 0 + OutArch[0] = 0 + # 每次进入先复位诊断中间量:本次 [pe] 行只反映本次解析(尚未解析出的量保持 0) + Pe_DbgLfanew = 0 + Pe_DbgNsec = 0 + Pe_DbgOptSize = 0 + Pe_DbgSecOff = 0 + Pe_DbgGot = 0 + Pe_DbgLastRc = 0 + Pe_OpenErr = 0 + h: wb.HANDLE = wf.CreateFileA( + Target, wf.GENERIC_READ, + wf.FILE_SHARE_READ | wf.FILE_SHARE_WRITE | wf.FILE_SHARE_DELETE, + None, wf.OPEN_EXISTING, wf.FILE_ATTRIBUTE_NORMAL, None) + if h == wb.INVALID_HANDLE_VALUE: + Pe_OpenErr = wb.GetLastError() # 紧跟 CreateFileA 读取(PeDbgPrint 的写日志会覆盖) + PeDbgPrint(1, t.CSizeT(0), t.CUInt32T(0), t.CUInt32T(0)) + return 1 + li: wb.LARGE_INTEGER = wb.LARGE_INTEGER() + if wf.GetFileSizeEx(h, t.CPtr(c.Addr(li))) == 0: + wb.CloseHandle(h) + PeDbgPrint(2, t.CSizeT(0), t.CUInt32T(0), t.CUInt32T(0)) + return 2 + fsz: t.CSizeT = t.CSizeT(li.QuadPart) + if fsz < 0x40: + wb.CloseHandle(h) + PeDbgPrint(3, fsz, t.CUInt32T(0), t.CUInt32T(0)) + return 3 + head_cap: t.CSizeT = t.CSizeT(PE_HEAD_READ) + if fsz < head_cap: + head_cap = fsz + head: bytes = stdlib.malloc(head_cap + 1) + if head is None: + wb.CloseHandle(h) + PeDbgPrint(4, fsz, t.CUInt32T(0), t.CUInt32T(0)) + return 4 + got: t.CSizeT = VFs.ReadFull(h, head, head_cap) + head[got] = 0 + Pe_DbgGot = got + if got < 0x40: + stdlib.free(t.CPtr(head)) + wb.CloseHandle(h) + PeDbgPrint(5, fsz, t.CUInt32T(0), t.CUInt32T(0)) + return 5 + + prc: int = ParsePeHead(head, got, OutArch, ArchCap) + if prc != 0: + stdlib.free(t.CPtr(head)) + wb.CloseHandle(h) + PeDbgPrint(100 + prc, fsz, Pe_RawPtr, Pe_RawSize) + return 100 + prc + raw_ptr: t.CUInt32T = Pe_RawPtr + raw_size: t.CUInt32T = Pe_RawSize + if raw_size == 0 or t.CSizeT(raw_ptr) + t.CSizeT(raw_size) > fsz: + stdlib.free(t.CPtr(head)) + wb.CloseHandle(h) + PeDbgPrint(6, fsz, raw_ptr, raw_size) + return 6 + + if t.CSizeT(raw_ptr) + t.CSizeT(raw_size) <= got: + # 段内容已在头部窗口内 + CopyVersion(head, t.CInt(raw_ptr), t.CInt(raw_size), OutVer, VerCap) + else: + # 段内容不在头部窗口内:改走"整文件读"路径(与 ver [1/3] 同一套 API,已验证可用)。 + # 这里不用 wf.SetFilePointerEx 定位:其 liDistanceToMove 在真实 Win32 上按值传 8 字节, + # 传地址时"距离"会变成该地址值,文件指针被推到 EOF 之后,后续 ReadFile 只能读回 0 字节。 + # VFs 据此实现的 SeekTo 已删除(零调用点),见 VFs.vp:19-24。 + stdlib.free(t.CPtr(head)) + wb.CloseHandle(h) + whole: bytes = VFs.ReadWholeFile( + Target, 0, + wf.FILE_SHARE_READ | wf.FILE_SHARE_WRITE | wf.FILE_SHARE_DELETE) + if whole is None: + PeDbgPrint(7, fsz, raw_ptr, raw_size) + return 7 + CopyVersion(whole, t.CInt(raw_ptr), t.CInt(raw_size), OutVer, VerCap) + stdlib.free(t.CPtr(whole)) + return 0 + + stdlib.free(t.CPtr(head)) + wb.CloseHandle(h) + return 0 + + +# ============================================================ +# ver [1/3]:打开目标 exe,解析 PE 节表,读取 .PRVERSN 段内容(保留原有逐条诊断输出) +# 返回 0=找到并通过,1=失败 +# 本段是独立解析流程(不调用 ParsePeHead);失败时打 [pe] 行,其中 rc 按 ParsePeHead 的 +# 同一条件编码:1=字节数<0x40 2=缺少 MZ 签名 3=e_lfanew 越界 4=缺少 PE 签名 +# 5=可选头 Magic 未知 6=节表越界(段内容越界亦按 6 打印) 7=未找到 .PRVERSN +# ============================================================ +def Phase1VersionSection(Target: str) -> int: + global Pe_DbgLfanew, Pe_DbgNsec, Pe_DbgOptSize, Pe_DbgSecOff, Pe_DbgGot, Pe_DbgLastRc + # 每次进入先复位诊断中间量:本次 [pe] 行只反映本次解析(尚未解析出的量保持 0) + Pe_DbgLfanew = 0 + Pe_DbgNsec = 0 + Pe_DbgOptSize = 0 + Pe_DbgSecOff = 0 + Pe_DbgGot = 0 + Pe_DbgLastRc = 0 + buf: bytes = VFs.ReadWholeFile(Target, 0, + wf.FILE_SHARE_READ | wf.FILE_SHARE_WRITE | wf.FILE_SHARE_DELETE) + if buf is None: + rc_err: int = VFs.LastReadErr + if rc_err == 1: + VLog.LogFmt1(" 打开文件失败 (win32 err=%u)\n", t.CSizeT(VFs.LastReadWinErr)) + elif rc_err == 2: + VLog.LogStr(" 读取文件大小失败\n") + elif rc_err == 3: + VLog.LogStr(" 文件为空\n") + else: + VLog.LogStr(" 分配缓冲区失败\n") + return 1 + total: t.CSizeT = VFs.LastReadSize + Pe_DbgGot = total + + if total < 0x40: + VLog.LogStr(" 不是有效的 PE 文件(文件过短)\n") + PeDbgPrint(1, total, t.CUInt32T(0), t.CUInt32T(0)) + stdlib.free(t.CPtr(buf)) + return 1 + + ub: BYTEPTR = BYTEPTR(buf) + + # DOS 头 + if ReadU16(ub, 0) != DOS_SIGNATURE: + VLog.LogStr(" 不是有效的 PE 文件(缺少 MZ 头)\n") + PeDbgPrint(2, total, t.CUInt32T(0), t.CUInt32T(0)) + stdlib.free(t.CPtr(buf)) + return 1 + + # NT 头 + lfanew: t.CUInt32T = ReadU32(ub, E_LFANEW_OFF) + Pe_DbgLfanew = lfanew + if t.CSizeT(lfanew) + 24 > total: + VLog.LogStr(" 不是有效的 PE 文件(e_lfanew 越界)\n") + PeDbgPrint(3, total, t.CUInt32T(0), t.CUInt32T(0)) + stdlib.free(t.CPtr(buf)) + return 1 + if ReadU32(ub, t.CInt(lfanew)) != NT_SIGNATURE: + VLog.LogStr(" 不是有效的 PE 文件(缺少 PE 签名)\n") + PeDbgPrint(4, total, t.CUInt32T(0), t.CUInt32T(0)) + stdlib.free(t.CPtr(buf)) + return 1 + + nsec: t.CUInt32T = ReadU16(ub, t.CInt(lfanew) + COFF_NUMSEC_OFF) + optsize: t.CUInt32T = ReadU16(ub, t.CInt(lfanew) + COFF_OPTSIZE_OFF) + Pe_DbgNsec = nsec + Pe_DbgOptSize = optsize + magic: t.CUInt32T = ReadU16(ub, t.CInt(lfanew) + OPT_MAGIC_OFF) + if magic != OPT_MAGIC_PE32 and magic != OPT_MAGIC_PE32P: + VLog.LogFmt1(" 可选头 Magic 未知: 0x%X\n", magic) + PeDbgPrint(5, total, t.CUInt32T(0), t.CUInt32T(0)) + stdlib.free(t.CPtr(buf)) + return 1 + + sec_off: t.CInt = t.CInt(lfanew) + 24 + t.CInt(optsize) + Pe_DbgSecOff = t.CUInt32T(sec_off) + if t.CSizeT(sec_off) + t.CSizeT(nsec) * SEC_ENTRY_SIZE > total: + VLog.LogFmt1(" 节表越界(节数=%u)\n", nsec) + PeDbgPrint(6, total, t.CUInt32T(0), t.CUInt32T(0)) + stdlib.free(t.CPtr(buf)) + return 1 + + # 查找 .PRVERSN + found: int = 0 + raw_ptr: t.CUInt32T = 0 + raw_size: t.CUInt32T = 0 + i: int = 0 + while i < t.CInt(nsec): + entry: t.CInt = sec_off + i * SEC_ENTRY_SIZE + if string.strncmp(str(buf + entry + SEC_NAME_OFF), VERSION_SECTION, 8) == 0: + found = 1 + raw_size = ReadU32(ub, entry + SEC_RAWSIZE_OFF) + raw_ptr = ReadU32(ub, entry + SEC_RAWPTR_OFF) + break + i += 1 + + if found == 0: + VLog.LogFmtS1(" 未找到 %s 节\n", VERSION_SECTION) + PeDbgPrint(7, total, t.CUInt32T(0), t.CUInt32T(0)) + VLog.LogStr(" 实际节名:\n") + name8: bytes = stdlib.malloc(9) + if name8 is not None: + j: int = 0 + while j < t.CInt(nsec): + CopySectionName(ub, sec_off + j * SEC_ENTRY_SIZE, name8) + VLog.LogFmtS1(" %s\n", str(name8)) + j += 1 + stdlib.free(t.CPtr(name8)) + stdlib.free(t.CPtr(buf)) + return 1 + + # 取到第一个 NUL 为止 + if t.CSizeT(raw_ptr) + t.CSizeT(raw_size) > total: + VLog.LogFmt2(" 段内容越界 (offset=%u size=%u)\n", raw_ptr, raw_size) + PeDbgPrint(6, total, raw_ptr, raw_size) + stdlib.free(t.CPtr(buf)) + return 1 + vlen: t.CUInt32T = 0 + while vlen < raw_size and ub[t.CInt(raw_ptr) + t.CInt(vlen)] != 0: + vlen += 1 + + VLog.LogFmt1(" 原始长度: %u 字节\n", vlen) + VLog.LogFmtS1(" 版本串: %s\n", str(buf + t.CInt(raw_ptr))) + stdlib.free(t.CPtr(buf)) + return 0 + + +# ============================================================ +# ver 子命令:沿用原有 [0/3]→[1/3]→[2/3] 检查与返回码(日志走 stderr) +# ============================================================ +def RunVer() -> int: + if sys._argc < 3 or sys._argv[2] is None: + VLog.LogStr("用法: VET.exe ver <目标 exe 路径>\n") + return 1 + target: str = sys._argv[2] + # 横幅(不含路径)保留;被校验目标路径属过程细节,仅 --verbose 时输出 + VLog.LogFmtS1("VET - Viper Environment Tool %s\n\n", VCli.Version()) + VLog.VerboseFmtS1("目标: %s\n\n", target) + + VLog.LogStr("[0/3] 常用目录表\n") + rc0: int = VDirs.BuildTable() + if rc0 == 0: + VLog.LogStr(" ✓ 通过\n\n") + else: + VLog.LogStr(" ✗ 失败(表为空)\n\n") + + VLog.LogFmtS1("[1/3] 内嵌版本段 (%s)\n", VERSION_SECTION) + rc1: int = Phase1VersionSection(target) + if rc1 == 0: + VLog.LogStr(" ✓ 通过\n\n") + else: + VLog.LogStr(" ✗ 失败\n\n") + + VLog.LogStr("[2/3] --version 前缀\n") + rc2: int = VProc.RunVersionPrefixCheck(target) + if rc2 == 0: + VLog.LogStr(" ✓ 通过\n") + else: + VLog.LogStr(" ✗ 失败\n") + + if rc0 == 0 and rc1 == 0 and rc2 == 0: + return 0 + return 1 diff --git a/App/lib/VProc.vp b/App/lib/VProc.vp new file mode 100644 index 0000000..e5e18d2 --- /dev/null +++ b/App/lib/VProc.vp @@ -0,0 +1,319 @@ +import t, c +from stdint import * +import stdlib +import string +import viperlib +import w32.win32base as wb +import w32.win32file as wf +import w32.win32process as wp +import w32.win32sync as wsync +import lib.VLog as VLog + + +# ============================================================ +# VProc - 带超时的子进程执行( --version) +# +# 需求 4:不再依赖 includes/subprocess.vp(其 WaitForSingleObject(INFINITE) 无超时, +# 被探测 exe 挂起会卡死整轮)。本模块照搬 subprocess.vp:64 起的 +# "CreatePipe + STARTUPINFOA + CreateProcessA + 读管道" 结构,并加超时: +# WaitForSingleObject(hProcess, 5000): +# WAIT_OBJECT_0 → 读干管道取输出; +# WAIT_TIMEOUT → TerminateProcess(hProcess, 1) + WaitForSingleObject(INFINITE) 回收 → 标记超时; +# 任何路径都 CloseHandle(进程/线程/管道读端),写端在父进程 CreateProcess 成功后立即关闭。 +# +# 结构体:直接用 w32 声明的类型化结构体(wb.SECURITY_ATTRIBUTES / wp.STARTUPINFOA / +# wp.PROCESS_INFORMATION),字段赋值 + __sizeof__(),与 includes/subprocess.vp:78-123 +# 的已验证写法一致;不再用裸字节缓冲 + 偏移写入。 +# +# 关键:`bytes` 是指针类型(TransPyC/lib/core/Handles/HandlesBase.py:483 中 +# 'bytes': (t.CChar, 1)),故 c.Addr(Buf) 取到的是"指针变量槽"的地址(char**), +# 不是缓冲首地址(char*);缓冲首地址就是 Buf 本身。 +# +# 依赖本仓既有声明(未改 includes): +# wf.CreatePipe(includes/w32/win32file.vp:139)、wf.SetHandleInformation(:140)、 +# wf.HANDLE_FLAG_INHERIT(:136)、wf.ReadFile(:90)、 +# wp.CreateProcessA(includes/w32/win32process.vp:88)、wp.TerminateProcess(:90)、 +# wp.GetExitCodeProcess(:91)、wp.CREATE_NO_WINDOW(:23)、wp.STARTF_USESTDHANDLES(:27)、 +# wsync.WaitForSingleObject(includes/w32/win32sync.vp:37)、 +# wb.CloseHandle(:64)、wb.WAIT_OBJECT_0(:15)、wb.WAIT_TIMEOUT(:16)、wb.INFINITE(:13)。 +# ============================================================ + +VERSION_PROBE_TIMEOUT_MS: t.CDefine = 5000 # --version 探测超时(毫秒) +VERSION_PROBE_DIAG_CAP: t.CDefine = 4096 # ver [2/3] 诊断输出缓冲 +PROBE_CMD_EXTRA_CAP: t.CDefine = 32 # 命令缓冲额外量(引号 + " --version" + NUL) +PROBE_TARGET_MAX: t.CDefine = 4096 # 目标路径上界(超限直接判启动失败,不构造命令) +PROBE_LINE_CAP: t.CDefine = 208 # 探测输出"首行"诊断缓冲(200 字符 + NUL + 对齐) +VERSION_PREFIX: t.CDefine = "Viper" + +# 结果输出(模块全局);RunVersionProbe 每次进入先复位 +Probe_ExitCode: ULONG = 0 +Probe_TimedOut: int = 0 + +# 最近一次"拉起失败"(rc=1)的失败阶段与错误码(check 通道据此产出 `EXE_ERR,0x`)。 +# Probe_LaunchStage:0=无失败;1=参数/路径非法(未调用任何 Win32);2=命令缓冲分配失败; +# 3=CreatePipe 失败;4=CreateProcessA 失败 +# Probe_LaunchErr :失败时的 GetLastError。CreateProcessA 的取值**紧跟该调用读取** +# (其后 stdlib.free 等 Win32 调用会覆盖线程的 last-error)。 +# 生命周期:RunVersionProbe 每次进入复位;仅在 rc=1 的出口写入。 +Probe_LaunchStage: int = 0 +Probe_LaunchErr: ULONG = 0 + + +# 前缀经函数暴露给 VScan(跨模块函数调用路径明确支持;避免跨模块字符串 t.CDefine 取值) +def VersionPrefix() -> str: + return VERSION_PREFIX + + +# 捕获输出前 5 字符是否为 "Viper"(口径与 ver [2/3] / scan 候选过滤逐字一致: +# string.strncmp(Out, "Viper", 5) == 0;Out 短于 5 字符时 strncmp 在 NUL 处停下 ⇒ 不通过)。 +def OutputHasViperPrefix(Out: str) -> int: + if Out is None: + return 0 + if string.strncmp(Out, VERSION_PREFIX, 5) == 0: + return 1 + return 0 + + +# 取最近一次拉起失败的阶段 / 错误码(rc=1 时才有意义) +def ProbeLaunchStage() -> int: + return Probe_LaunchStage + + +def ProbeLaunchErr() -> ULONG: + return Probe_LaunchErr + + +# [诊断] 取捕获输出的首行(到 NUL/CR/LF 或 Cap-1 字符为止),写入 Out(NUL 结尾)。 +# 返回写入长度。上界由 Out 中的 NUL(ReadFile 循环末尾所写)保证,不会越读。 +def CopyFirstLine(Src: bytes, Out: bytes, Cap: int) -> int: + ub: BYTEPTR = BYTEPTR(Src) + n: int = 0 + while n < Cap - 1: + ch: int = int(ub[n]) + if ch == 0 or ch == 10 or ch == 13: + break + Out[n] = ub[n] + n += 1 + Out[n] = 0 + return n + + +# ============================================================ +# 执行 " --version"(含超时) +# Out/OutCap:捕获缓冲(stdout+stderr 合并;到 EOF 或缓冲满为止,NUL 结尾) +# Trace:1=scan 路径(打印探测前/后各一行);0=ver 路径(不打印这两行) +# 详细度:`[--version] 开始/结束/捕获 N 字节, 首行` 属"逐次探测细节",统一走 VLog.Verbose* +# (默认安静,仅 --verbose 时输出);"警告:" 前缀的失败诊断始终输出。 +# 返回:0=进程正常结束(退出码见 Probe_ExitCode) +# 1=启动失败(参数非法/管道/进程创建/缓冲分配失败) +# 2=超时(已 TerminateProcess;输出为部分) +# 失败路径诊断(定位 cand=0 / ver 失败):每条非 0 出口都写明 rc 语义;"警告:" 前缀的 +# 失败诊断(含 GetLastError / 等待结果 / 等待时长)始终输出,且不内嵌被探测路径——路径与 +# 已捕获内容/首行属逐次探测细节,仅 --verbose 时经 Verbose* 给出(默认不逐路径刷屏)。 +# ============================================================ +def RunVersionProbe(Target: str, Out: bytes, OutCap: t.CSizeT, Trace: int) -> int: + global Probe_ExitCode, Probe_TimedOut, Probe_LaunchStage, Probe_LaunchErr + Probe_ExitCode = 0 + Probe_TimedOut = 0 + Probe_LaunchStage = 0 + Probe_LaunchErr = 0 + if Target is None or Out is None or OutCap < 2: + Probe_LaunchStage = 1 + if Trace != 0: + VLog.VerboseStr(" [--version] 启动失败 (rc=1): 参数非法\n") + return 1 + if string.strlen(Target) > t.CSizeT(PROBE_TARGET_MAX): + Probe_LaunchStage = 1 + VLog.LogStr(" 警告: --version 目标路径过长 (rc=1),已跳过该候选\n") + return 1 + + T0: ULONGLONG = wb.GetTickCount64() + if Trace != 0: + VLog.VerboseFmtS1(" [--version] 开始: %s\n", Target) + + # --- 命令缓冲:"" --version --- + n: t.CSizeT = string.strlen(Target) + t.CSizeT(PROBE_CMD_EXTRA_CAP) + CmdLine: bytes = stdlib.malloc(n) + if CmdLine is None: + Probe_LaunchStage = 2 + VLog.LogStr(" 警告: --version 探测启动失败 (rc=1, 命令缓冲分配失败)\n") + return 1 + viperlib.snprintf(CmdLine, n, "\"%s\" --version", Target) + + # --- SECURITY_ATTRIBUTES(栈上结构体;必须在 CreatePipe 之前填好) --- + # nLength 必须等于 sizeof(SECURITY_ATTRIBUTES)=24,否则 CreatePipe 直接失败 + # (ERROR_INVALID_PARAMETER);lpSecurityDescriptor=NULL 表示默认安全描述符。 + Sa: wb.SECURITY_ATTRIBUTES = wb.SECURITY_ATTRIBUTES() + Sa.nLength = t.CUInt32T(wb.SECURITY_ATTRIBUTES.__sizeof__()) + Sa.lpSecurityDescriptor = t.CPtr(0) + Sa.bInheritHandle = 1 + + # --- 管道(可继承;父进程随后关写端) --- + hRead: wb.HANDLE = None + hWrite: wb.HANDLE = None + if wf.CreatePipe(t.CPtr(c.Addr(hRead)), t.CPtr(c.Addr(hWrite)), + t.CPtr(c.Addr(Sa)), 0) == 0: + PipeErr: ULONG = wb.GetLastError() # 紧跟 CreatePipe 读取 + Probe_LaunchStage = 3 + Probe_LaunchErr = PipeErr + stdlib.free(t.CPtr(CmdLine)) + VLog.LogStr(" 警告: --version 探测启动失败 (rc=1, CreatePipe)\n") + return 1 + # 读端不继承给子进程(写端继承由 SECURITY_ATTRIBUTES.bInheritHandle 决定) + wf.SetHandleInformation(hRead, wf.HANDLE_FLAG_INHERIT, 0) + + # --- STARTUPINFOA:cb=sizeof, dwFlags=STARTF_USESTDHANDLES, hStdOutput=hStdError=写端 --- + Si: wp.STARTUPINFOA = wp.STARTUPINFOA() + Si.cb = t.CUInt32T(wp.STARTUPINFOA.__sizeof__()) + Si.dwFlags = wp.STARTF_USESTDHANDLES + Si.hStdOutput = hWrite + Si.hStdError = hWrite + + Pi: wp.PROCESS_INFORMATION = wp.PROCESS_INFORMATION() + + # --- CreateProcessA(bInheritHandles=TRUE;CREATE_NO_WINDOW:输出走管道,不开控制台窗口) --- + Ok: BOOL = wp.CreateProcessA(t.CPtr(0), CmdLine, t.CPtr(0), t.CPtr(0), 1, + wp.CREATE_NO_WINDOW, t.CPtr(0), t.CPtr(0), + t.CPtr(c.Addr(Si)), t.CPtr(c.Addr(Pi))) + # GetLastError 紧跟调用读取:其后 stdlib.free 等都会覆盖它 + CreateErr: ULONG = wb.GetLastError() + stdlib.free(t.CPtr(CmdLine)) + if Ok == 0: + Probe_LaunchStage = 4 + Probe_LaunchErr = CreateErr + wb.CloseHandle(hRead) + wb.CloseHandle(hWrite) + VLog.LogFmt1(" 警告: --version 探测启动失败 (rc=1, CreateProcessA, GetLastError=%u)\n", + t.CSizeT(CreateErr)) + return 1 + + # 父进程关闭写端(子进程已持有其副本);此后管道只由子进程持有写端 + wb.CloseHandle(hWrite) + + # --- 超时等待:先等进程退出,再读干管道 --- + TimedOut: int = 0 + KillMs: t.CSizeT = 0 + Wr: ULONG = wsync.WaitForSingleObject(Pi.hProcess, VERSION_PROBE_TIMEOUT_MS) + if Wr != wb.WAIT_OBJECT_0: + # WAIT_TIMEOUT(或 WAIT_FAILED 等):杀进程并回收,避免悬挂 + TimedOut = 1 + KillMs = t.CSizeT(wb.GetTickCount64() - T0) + wp.TerminateProcess(Pi.hProcess, 1) + wsync.WaitForSingleObject(Pi.hProcess, wb.INFINITE) + + # 读干管道:子进程已退出/被杀 ⇒ 写端全关,ReadFile 到 EOF 返回 + total: t.CSizeT = 0 + while total + 1 < OutCap: + br: ULONG = 0 + room: ULONG = ULONG(OutCap - 1 - total) + if wf.ReadFile(hRead, Out + total, room, t.CPtr(c.Addr(br)), None) == 0: + break + if br == 0: + break + total += t.CSizeT(br) + Out[total] = 0 + + ExitCode: ULONG = 0 + wp.GetExitCodeProcess(Pi.hProcess, t.CPtr(c.Addr(ExitCode))) + Probe_ExitCode = ExitCode + Probe_TimedOut = TimedOut + + wb.CloseHandle(Pi.hProcess) + wb.CloseHandle(Pi.hThread) + wb.CloseHandle(hRead) + + Elapsed: t.CSizeT = t.CSizeT(wb.GetTickCount64() - T0) + if TimedOut != 0: + # 超时:写明等待结果(WAIT_TIMEOUT=258 / WAIT_FAILED=4294967295 等)与等待时长。 + # 该候选路径与"超时前已捕获内容"属逐次探测细节,仅 --verbose 时给出(默认不逐路径刷屏) + VLog.LogFmt3(" 警告: --version 探测超时 (rc=2, wait=%u, 上限 %u ms),已等待 %u ms 后终止子进程\n", + t.CSizeT(Wr), t.CSizeT(VERSION_PROBE_TIMEOUT_MS), KillMs) + if total == 0: + VLog.VerboseStr(" 警告: 超时前已捕获 0 字节(空)\n") + else: + VLog.VerboseFmt1(" 警告: 超时前已捕获 %u 字节,内容:\n", total) + VLog.VerboseStr(str(Out)) + VLog.VerboseStr("\n") + if Trace != 0: + VLog.VerboseFmt4(" [--version] 结束: 耗时 %u ms, 退出码 %u, 超时 %u, 输出 %u 字节\n", + Elapsed, t.CSizeT(ExitCode), t.CSizeT(TimedOut), total) + if TimedOut == 0: + # 正常结束(rc=0):捕获字节数与首行属逐次探测细节,仅 --verbose 时输出 + # (ver 路径的 [2/3] 结果仍由 RunVersionPrefixCheck 照常给出)。 + Line: bytes = stdlib.malloc(PROBE_LINE_CAP) + if Line is None: + VLog.VerboseFmt1(" [--version] 捕获 %u 字节(首行缓冲分配失败)\n", total) + else: + CopyFirstLine(Out, Line, PROBE_LINE_CAP) + if total == 0: + VLog.VerboseStr(" [--version] 捕获 0 字节(空)\n") + else: + VLog.VerboseFmtIS(" [--version] 捕获 %u 字节, 首行: %s\n", total, str(Line)) + stdlib.free(t.CPtr(Line)) + if TimedOut != 0: + return 2 + return 0 + + +# ============================================================ +# ver [2/3]:执行 --version,校验捕获输出前 5 字符是否为 "Viper" +# 返回 0=通过,1=失败(启动失败/超时/前缀不符) +# 行为增强:改用带超时探测(原实现无超时),其余日志与判定口径不变。 +# ============================================================ +def RunVersionPrefixCheck(Target: str) -> int: + OutCap: t.CSizeT = t.CSizeT(VERSION_PROBE_DIAG_CAP) + Out: bytes = stdlib.malloc(OutCap) + if Out is None: + VLog.LogStr(" 分配缓冲区失败\n") + return 1 + rc: int = RunVersionProbe(Target, Out, OutCap, 0) + if rc == 1: + VLog.LogStr(" 子进程启动失败\n") + stdlib.free(t.CPtr(Out)) + return 1 + if rc == 2: + VLog.LogStr(" 探测超时,判定失败\n") + stdlib.free(t.CPtr(Out)) + return 1 + + VLog.LogFmt1(" 退出码: %d\n", t.CSizeT(t.CUInt32T(Probe_ExitCode))) + out: str = str(Out) + if out is None: + VLog.LogStr(" 未捕获到 stdout\n") + stdlib.free(t.CPtr(Out)) + return 1 + + head: bytes = stdlib.malloc(6) + if head is None: + VLog.LogStr(" 分配缓冲区失败\n") + stdlib.free(t.CPtr(Out)) + return 1 + k: int = 0 + while k < 5 and out[k] != 0: + head[k] = out[k] + k += 1 + head[k] = 0 + + if string.strncmp(out, VERSION_PREFIX, 5) == 0: + VLog.LogFmtS1(" 前 5 字符: %s\n", str(head)) + stdlib.free(t.CPtr(head)) + stdlib.free(t.CPtr(Out)) + return 0 + + VLog.LogFmtS2(" 前 5 字符: %s(期望为 %s)\n", str(head), VERSION_PREFIX) + VLog.LogStr(" 原始输出:\n") + VLog.LogStr(out) + VLog.LogStr("\n") + stdlib.free(t.CPtr(head)) + stdlib.free(t.CPtr(Out)) + return 1 + + +# ============================================================ +# 结构体布局(x64 / LLP64):由 w32 声明的类型化结构体 + __sizeof__() 决定, +# 本模块不再手工推导偏移。已核验(只读,本机 Python ctypes,未编译任何东西): +# STARTUPINFOA size=104 align=8: cb@0 dwFlags@60 hStdInput@80 hStdOutput@88 hStdError@96 +# PROCESS_INFORMATION size=24 align=8: hProcess@0 hThread@8 dwProcessId@16 dwThreadId@20 +# SECURITY_ATTRIBUTES size=24 align=8: nLength@0 lpSecurityDescriptor@8 bInheritHandle@16 +# 与 win32base.vp:24 / win32process.vp:42,82 的字段声明一致。 +# ============================================================ diff --git a/App/lib/VScan.vp b/App/lib/VScan.vp new file mode 100644 index 0000000..813728e --- /dev/null +++ b/App/lib/VScan.vp @@ -0,0 +1,618 @@ +import t, c +from stdint import * +import string +import viperlib +import memhub +import lib.VFs as VFs +import lib.VLog as VLog +import lib.VPe as VPe +import lib.VGuid as VGuid +import lib.VJson as VJson +import lib.VCache as VCache +import lib.VCheck as VCheck +import lib.VDirs as VDirs +import lib.VProc as VProc +import lib.VGuard as VGuard +import lib.VCli as VCli +import w32.win32file as wf +import w32.win32base as wb + + +# ============================================================ +# VScan - 扫盘(目录树遍历 / 候选处理 / 进度输出 / 一轮驱动) +# +# 从原 App/main.vp 平移 ScanInit/ScanTree/HandleCandidate/ProbeVersion 与节流; +# 节流开关 Pace_Enabled:常驻模式默认启用(1);未显式给出节流参数时,--once 单轮扫描由 RunScan() 置 0, +# 扫描期间一次都不 sleep(计数与进度输出照常,仅跳过 Sleep)。 +# 节流参数(--pace <毫秒> / --pace-units ,见 VCli):显式给出其一即启用节流(--once 下也生效)—— +# * --pace 设 Pace_Sleep_Ms(0 = 两个节流点都不 Sleep);--pace-units 设 Pace_File_Units(1 = 每个文件都停)。 +# * 目录侧粒度恒为 PACE_DIR_UNITS=16(--pace-units 只作用文件侧 PaceFile)。 +# * 两者均未给出时逐字沿用缺省:常驻启用 + 20ms + 64,--once 关闭。 +# +# 日志详细度(本轮):stderr 默认安静——只打启动摘要(工具名+版本+模式+间隔/once/ +# 确认开关,不含路径)、心跳确认提示(看门狗侧)、警告/降级聚合与致命错误、轮末一行 +# (不含路径)。过程细节(轮次开始、周期性进度行、每个扫描根进入/离开、逐条 [reject]/ +# [pe]/[跳过]/[命中]、逐次 --version 探测、目录表逐条列举、缓存载入明细、轮末"下轮开始" +# 时间戳)一律走 VLog.Verbose*,仅在 --verbose 时输出。日志只写 stderr,stdout 仍只输出 +# 每轮全量 JSON。 +# +# 心跳确认改由独立看门狗子进程承担(见 VGuard.vp),扫盘侧不再参与确认、也不再被确认打断。 +# 轮间隔由 --sleep <毫秒> 指定(VCli.SleepMs(),缺省 10000;0 = 不 sleep)。 +# +# --search-paths 查询入口 RunSearchPathsQuery():复用 VDirs.BuildTable 建同一份目录表 → +# 逐行打印(序号/路径/是否会实际扫)→ 返回 0。它不扫盘、不看门狗、不碰缓存,故不经过 +# ScanInit、不依赖扫盘静态缓冲(建表只用 VDirs 自己的 _mbuddy 分配)。 +# +# check 请求通道(本轮新增,见 VCheck.vp / VGuard.PollIpc): +# * 轮询点(**全部非阻塞**):PaceDir/PaceFile(扫盘高频处)、每次发起 `--version` 探测前、 +# 每轮开始与结束。轮询到的 check 请求在本进程内**同步**执行完(读 .PRVERSN → `--version` +# → 回执),期间扫盘停在轮询点 ⇒ check 优先级高于树扫描。 +# * 只有开了 --need-confirm(看门狗存在)才可能有请求;未开时 PollIpc 立即返回。 +# +# 每轮流程(本轮按需求调整顺序):轮开始 → 轮询 check → **重读 cache.json** → +# **对已发现项做完整重校(不合格剔除)** → 遍历目录树(其间持续轮询 check)→ +# 存在性校验 → 轮询 check → 输出本轮数组(stdout)→ 写回 cache.json。 +# +# 依赖本仓既有:wf.FindFirstFileA(includes/w32/win32file.vp:115)、wf.FindNextFileA(:117)、 +# wf.FindClose(:119)、wf.FILE_ATTRIBUTE_DIRECTORY(:23)、wb.Sleep(:77)、wb.GetTickCount64(:80)。 +# ============================================================ + +MAX_SCAN_DEPTH: t.CDefine = 6 # 递归深度上限 +PACE_DIR_UNITS: t.CDefine = 16 # 每处理 16 个目录项 +PACE_FILE_UNITS: t.CDefine = 64 # 每处理 64 个文件 +PACE_SLEEP_MS: t.CDefine = 20 # 上述节流点 Sleep(20) +PROG_INTERVAL_MS: t.CDefine = 1000 # 进度行最短间隔(需求 3) +PROG_STAMP_CAP: t.CDefine = 64 # 时间戳文本缓冲 +PROG_ELAPSED_CAP: t.CDefine = 32 # "3.2s" 文本缓冲 +REJECT_LOG_MAX: t.CDefine = 20 # [reject] 逐条打印上限(超出后只在轮末打一次汇总) +# WIN32_FIND_DATAA:4+8+8+8+4+4+4+4 = 44 之后为 cFileName(见"API 证据表"推导) +FD_ATTRIBUTES_OFF: t.CDefine = 0 +FD_FILENAME_OFF: t.CDefine = 44 +FIND_DATA_SIZE: t.CDefine = 320 # sizeof(WIN32_FIND_DATAA):318 对齐到 4 = 320 +# winnt.h: FILE_ATTRIBUTE_REPARSE_POINT(符号链接/挂载点等重解析点,递归时跳过以防成环) +FILE_ATTRIBUTE_REPARSE_POINT: t.CDefine = 0x400 + +# 由 App/main.vp 注入 +_mbuddy: rawptr[memhub.MemBuddy] + +# 扫盘静态缓冲(进程生命周期内复用;单线程,无并发) +Scr_Ver: bytes = None +Scr_Arch: bytes = None +Scr_Id: bytes = None +Scr_Disp: bytes = None +Probe_Buf: bytes = None +Prog_Stamp: bytes = None +Prog_Elapsed: bytes = None + +# 节流开关:1=启用节流 sleep(常驻模式默认),0=禁用(--once 单轮扫描时由 RunScan 置 0) +Pace_Enabled: int = 1 + +# "常规轮次"应当采用的节流开关值(由 RunScan 一次性算出):Pace_Enabled 是**本轮**的实际值, +# 每轮开头由 RunScanRound 从本值恢复,再叠加 --first-no-pace 的"第 1 轮不节流"。 +# 拆成两个变量是为了让"首轮特例"只影响第 1 轮,不会污染第 2 轮起的常规规则。 +Pace_Configured: int = 1 + +# 节流时长与文件粒度(可变):初值即缺省常量,未给 --pace/--pace-units 时始终取这两个初值。 +# --pace/--pace-units 显式给出时由 RunScan 覆盖(见其开头),且不再被 --once 关闭。 +Pace_Sleep_Ms: int = PACE_SLEEP_MS # --pace <毫秒>;0 = 两个节流点都不 Sleep +Pace_File_Units: int = PACE_FILE_UNITS # --pace-units ;文件侧粒度(目录侧仍是 PACE_DIR_UNITS) + +# 节流计数 +Pace_Dir_Count: int = 0 +Pace_File_Count: int = 0 + +# 进度状态(需求 3) +Prog_Round: int = 0 +Prog_Roots: int = 0 +Prog_T0: ULONGLONG = 0 +Prog_LastTick: ULONGLONG = 0 +Prog_Dirs: int = 0 +Prog_Files: int = 0 +Prog_Cands: int = 0 +Prog_Cur: str = None +Round_Added: int = 0 + +# [reject] 失败路径诊断计数(每轮由 RunScanRound 复位;模块级,单线程扫盘无并发) +Reject_Shown: int = 0 # 本轮已逐条打印的 [reject] 行数(上限 REJECT_LOG_MAX) +Reject_Total: int = 0 # 本轮被 PE/.PRVERSN 过滤掉的候选总数(含已逐条打印的) + + +# ============================================================ +# 扫盘策略小工具 +# ============================================================ + +# 是否跳过该目录名(需求约定的黑名单,大小写无关) +def IsSkippedDir(Name: str) -> int: + if VFs.StrEqNoCase(Name, "WinSxS") != 0: + return 1 + if VFs.StrEqNoCase(Name, "$Recycle.Bin") != 0: + return 1 + if VFs.StrEqNoCase(Name, "System Volume Information") != 0: + return 1 + if VFs.StrEqNoCase(Name, "node_modules") != 0: + return 1 + if VFs.StrEqNoCase(Name, ".git") != 0: + return 1 + return 0 + + +# 文件名是否以 .exe 结尾(大小写无关) +def HasExeSuffix(Name: str) -> int: + n: t.CSizeT = string.strlen(Name) + if n < 4: + return 0 + a: int = int(Name[t.CInt(n) - 4]) + b: int = int(Name[t.CInt(n) - 3]) + d0: int = int(Name[t.CInt(n) - 2]) + e0: int = int(Name[t.CInt(n) - 1]) + if a >= 65 and a <= 90: + a += 32 + if b >= 65 and b <= 90: + b += 32 + if d0 >= 65 and d0 <= 90: + d0 += 32 + if e0 >= 65 and e0 <= 90: + e0 += 32 + if a == 46 and b == 101 and d0 == 120 and e0 == 101: # ".exe" + return 1 + return 0 + + +# ============================================================ +# 进度输出(需求 3;全部走 VLog → stderr;默认安静,仅 --verbose 时输出) +# 计数器与节流基线的更新不受开关影响,只有"打印"这一步走 Verbose*。 +# ============================================================ + +# 打印一行进度(含 r/dir/file/cand/cur/elapsed),并刷新时间基线 +def ProgPrint(): + global Prog_LastTick + Now: ULONGLONG = wb.GetTickCount64() + Prog_LastTick = Now + ElapsedMs: t.CSizeT = t.CSizeT(Now - Prog_T0) + Secs: t.CSizeT = ElapsedMs // 1000 + Tenths: t.CSizeT = (ElapsedMs % 1000) // 100 + viperlib.snprintf(Prog_Elapsed, t.CSizeT(PROG_ELAPSED_CAP), "%u.%us", + t.CUInt32T(Secs), t.CUInt32T(Tenths)) + Cur: str = Prog_Cur + if Cur is None: + Cur = "?" + VLog.VerboseFmt4("[scan] r=%u dir=%u file=%u cand=%u", + t.CSizeT(Prog_Round), t.CSizeT(Prog_Dirs), t.CSizeT(Prog_Files), t.CSizeT(Prog_Cands)) + VLog.VerboseFmtS2(" cur=%s elapsed=%s\n", Cur, str(Prog_Elapsed)) + + +# 仅在距上次进度输出 >= PROG_INTERVAL_MS 时打印(保证"无输出超过 ~1s"可见) +def ProgMaybePrint(): + Now: ULONGLONG = wb.GetTickCount64() + if Now - Prog_LastTick >= ULONGLONG(PROG_INTERVAL_MS): + ProgPrint() + + +# 轮次开始:复位计数并打印(轮次 / 扫描根数 / 时间戳) +def ProgReset(RoundIdx: int, Roots: int): + global Prog_Round, Prog_Roots, Prog_T0, Prog_LastTick + global Prog_Dirs, Prog_Files, Prog_Cands, Prog_Cur + Prog_Round = RoundIdx + Prog_Roots = Roots + Prog_T0 = wb.GetTickCount64() + Prog_LastTick = Prog_T0 + Prog_Dirs = 0 + Prog_Files = 0 + Prog_Cands = 0 + Prog_Cur = None + VLog.TimeStampNow(Prog_Stamp, PROG_STAMP_CAP) + VLog.VerboseFmt2("==== 扫盘轮次 %u 开始 (roots=%u", t.CSizeT(RoundIdx), t.CSizeT(Roots)) + VLog.VerboseFmtS1(", t=%s) ====\n", str(Prog_Stamp)) + + +def ProgRootEnter(Dir: str): + VLog.VerboseFmtS1(" [root] 进入: %s\n", Dir) + + +def ProgRootLeave(Dir: str, ElapsedMs: t.CSizeT): + global Prog_Cur + VLog.VerboseFmtIS(" [root] 离开 (耗时 %u ms): %s\n", ElapsedMs, Dir) + Prog_Cur = None # 该根结束后其路径缓冲即将释放,避免遗留悬空指针 + + +def PaceDir(): + global Pace_Dir_Count, Prog_Dirs + VGuard.PollIpc() # check 通道轮询点(非阻塞;无通道/无数据时立即返回) + Pace_Dir_Count += 1 + Prog_Dirs += 1 + if Pace_Dir_Count >= PACE_DIR_UNITS: # 目录侧粒度恒为 PACE_DIR_UNITS=16(--pace-units 只作用文件粒度) + Pace_Dir_Count = 0 + # Pace_Sleep_Ms == 0(--pace 0):不调用 Sleep(0)——它不是"关闭节流",而是让出时间片, + # 与本参数的"关闭节流 sleep"语义不符。计数器与下方进度输出照常推进。 + if Pace_Enabled != 0 and Pace_Sleep_Ms > 0: + wb.Sleep(ULONG(Pace_Sleep_Ms)) + ProgPrint() + else: + ProgMaybePrint() + + +def PaceFile(): + global Pace_File_Count, Prog_Files + VGuard.PollIpc() # check 通道轮询点(非阻塞;无通道/无数据时立即返回) + Pace_File_Count += 1 + Prog_Files += 1 + if Pace_File_Count >= Pace_File_Units: # 文件侧粒度由 --pace-units 决定(缺省 PACE_FILE_UNITS=64) + Pace_File_Count = 0 + if Pace_Enabled != 0 and Pace_Sleep_Ms > 0: # 理由同 PaceDir(--pace 0 不 Sleep(0)) + wb.Sleep(ULONG(Pace_Sleep_Ms)) + ProgMaybePrint() + + +# ============================================================ +# 初始化 +# ============================================================ + +# 分配扫盘静态缓冲(进程生命周期内复用) +def ScanInit() -> int: + global Scr_Ver, Scr_Arch, Scr_Id, Scr_Disp, Probe_Buf, Prog_Stamp, Prog_Elapsed + Scr_Ver = _mbuddy.alloc(VPe.VER_CAP) + Scr_Arch = _mbuddy.alloc(VPe.ARCH_CAP) + Scr_Id = _mbuddy.alloc(VGuid.GUID_STR_CAP) + Scr_Disp = _mbuddy.alloc(VCache.DISP_CAP) + Probe_Buf = _mbuddy.alloc(VProc.VERSION_PROBE_DIAG_CAP) + Prog_Stamp = _mbuddy.alloc(PROG_STAMP_CAP) + Prog_Elapsed = _mbuddy.alloc(PROG_ELAPSED_CAP) + if Scr_Ver is None or Scr_Arch is None or Scr_Id is None or Scr_Disp is None \ + or Probe_Buf is None or Prog_Stamp is None or Prog_Elapsed is None: + VLog.LogStr("[VET] FATAL: 扫盘缓冲分配失败\n") + return 0 + if VJson.JsonInit() == 0: + VLog.LogStr("[VET] FATAL: 扫盘缓冲分配失败\n") + return 0 + # check 通道缓冲/回执池:分配失败只降级(check 只回执失败原因 CHANNEL_UNAVAILABLE、 + # 每轮重校跳过不剔除任何条目),不影响扫盘本身 + if VCheck.CheckInit() == 0: + VLog.LogStr("警告: check 通道缓冲不可用,check 请求只回执失败原因(CHANNEL_UNAVAILABLE)、每轮不重校已发现项\n") + return 1 + + +# ============================================================ +# 候选处理 +# ============================================================ + +# ----version 前缀探测(带 5s 超时;超时/启动失败 ⇒ 判不通过,不入缓存) +def ProbeVersion(Target: str) -> int: + rc: int = VProc.RunVersionProbe(Target, Probe_Buf, t.CSizeT(VProc.VERSION_PROBE_DIAG_CAP), 1) + if rc == 0: + if VProc.OutputHasViperPrefix(str(Probe_Buf)) != 0: + return 1 + return 0 + + +# 处理一个 .exe 候选:过滤 PE + .PRVERSN,通过后 `--version` 校验,命中则入缓存。 +# 返回 1=新增命中,0=未命中/已存在/失败 +# +# rc 含义表(VPe.TryReadPeInfo 返回码;同 rc 的中间量在该函数内以 [pe] 行打印): +# 1 CreateFileA 打开失败 VPe.vp TryReadPeInfo +# 2 GetFileSizeEx 取文件大小失败 VPe.vp TryReadPeInfo +# 3 文件过短(fsz < 0x40) VPe.vp TryReadPeInfo +# 4 头部窗口缓冲分配失败 VPe.vp TryReadPeInfo +# 5 ReadFull 读取不足(got < 0x40) VPe.vp TryReadPeInfo +# 100+prc ParsePeHead 失败:prc 1=Got<0x40 2=非 MZ 3=e_lfanew 越界 4=非 PE 签名 +# 5=可选头 magic 未知 6=节表超出头部窗口 7=未找到 .PRVERSN VPe.vp ParsePeHead +# (即 101=Got<0x40、102=非 MZ、103=e_lfanew 越界、104=非 PE 签名、 +# 105=magic 未知、106=节表超出头部窗口、107=未找到 .PRVERSN) +# 6 raw_size==0 或 raw_ptr+raw_size 超出文件大小 VPe.vp TryReadPeInfo +# (注意与 106 区分:106 是 ParsePeHead 的 prc=6) +# 7 整文件读取失败(VFs.ReadWholeFile 返回 None) VPe.vp TryReadPeInfo +def HandleCandidate(ExeAnsi: str) -> int: + global Prog_Cands, Round_Added, Reject_Shown, Reject_Total + if VCache.CacheFind(ExeAnsi) >= 0: + return 0 # 已知:不重复调用 --version + rc: int = VPe.TryReadPeInfo(ExeAnsi, Scr_Ver, VPe.VER_CAP, Scr_Arch, VPe.ARCH_CAP) + if rc != 0: + # 非 PE 或无 .PRVERSN:逐条打印前 REJECT_LOG_MAX 条,其余在轮末汇总(仅 --verbose) + Reject_Total += 1 + if Reject_Shown < REJECT_LOG_MAX: + Reject_Shown += 1 + VLog.VerboseFmtIS(" [reject] rc=%u %s\n", t.CSizeT(rc), ExeAnsi) + return 0 + Prog_Cands += 1 + # 仅对已过 PE + .PRVERSN 过滤的 exe 调用 --version(这类很少)。 + # 发起探测前轮询 check 通道(probe 最长可占 5 秒;把排队的 check 先放行,避免它被一次 + # 探测压后——check 优先级高于扫盘)。 + VGuard.PollIpc() + if ProbeVersion(ExeAnsi) == 0: + VLog.VerboseFmtS1(" [跳过] PE+PRVERSN 命中但 --version 前缀不符: %s\n", ExeAnsi) + return 0 + if VGuid.NewGuidStr(Scr_Id, VGuid.GUID_STR_CAP) == 0: + VLog.LogStr(" 警告: CoCreateGuid 失败,跳过该条目\n") + return 0 + viperlib.snprintf(Scr_Disp, t.CSizeT(VCache.DISP_CAP), "Viper %s", Scr_Ver) + if VCache.CacheAdd(ExeAnsi, str(Scr_Id), str(Scr_Ver), str(Scr_Arch), + "workspace", str(Scr_Disp)) == 0: + return 0 + Round_Added += 1 + VLog.VerboseFmtS2(" [命中] %s (版本 %s)\n", ExeAnsi, str(Scr_Ver)) + return 1 + + +# ============================================================ +# 目录树遍历 +# ============================================================ + +# 递归枚举目录,查找 .exe。Depth 为当前深度(从 1 起,上限 MAX_SCAN_DEPTH)。 +# +# 两遍枚举(减少 FindNextFileA 次数:文件项不再逐个进入本逻辑): +# ① `Dir\*` → 只认目录:递归子目录(跳过重解析点与黑名单目录) +# ② `Dir\*.exe` → 扩展名交给系统过滤,只认文件:逐个交给 HandleCandidate +# 两遍共用同一块模式缓冲与 FIND_DATA 缓冲:先跑完 ① 并 FindClose,再跑 ②; +# 递归帧各自持有自己的缓冲,互不干扰。 +# ② 仍保留 HasExeSuffix 守卫:8.3 短名可能让 `*.exe` 命中长名并非 .exe 的文件, +# 后缀判定可把这类误匹配排除掉。 +def ScanTree(DirAnsi: str, Depth: int) -> int: + global Prog_Cur + Prog_Cur = DirAnsi # 进度行的"当前目录" + Pat: bytes = _mbuddy.alloc(VFs.PATH_BUF_CAP) + Fd: bytes = _mbuddy.alloc(FIND_DATA_SIZE) + if Pat is None or Fd is None: + if Pat is not None: + _mbuddy.free(t.CPtr(Pat)) + if Fd is not None: + _mbuddy.free(t.CPtr(Fd)) + return 0 + + # ---- ① 目录:枚举子目录并递归 ---- + if VFs.JoinPath(Pat, VFs.PATH_BUF_CAP, DirAnsi, "*") == 0: + _mbuddy.free(t.CPtr(Pat)) + _mbuddy.free(t.CPtr(Fd)) + return 0 + hDir: wb.HANDLE = wf.FindFirstFileA(Pat, t.CPtr(Fd)) + if hDir != wb.INVALID_HANDLE_VALUE: + while True: + PaceDir() + Attrs: t.CUInt32T = VPe.ReadU32(BYTEPTR(Fd), FD_ATTRIBUTES_OFF) + Name: str = str(Fd + FD_FILENAME_OFF) + if string.strcmp(Name, ".") != 0 and string.strcmp(Name, "..") != 0: + if (Attrs & wf.FILE_ATTRIBUTE_DIRECTORY) != 0: + # 跳过重解析点(符号链接/挂载点)与黑名单目录,避免成环与噪声 + if (Attrs & FILE_ATTRIBUTE_REPARSE_POINT) == 0 and IsSkippedDir(Name) == 0: + if Depth < MAX_SCAN_DEPTH: + Child: bytes = _mbuddy.alloc(VFs.PATH_BUF_CAP) + if Child is not None: + if VFs.JoinPath(Child, VFs.PATH_BUF_CAP, DirAnsi, Name) != 0: + ScanTree(str(Child), Depth + 1) + Prog_Cur = DirAnsi # 递归返回后复原(Child 即将释放) + _mbuddy.free(t.CPtr(Child)) + if wf.FindNextFileA(hDir, t.CPtr(Fd)) == 0: + break + wf.FindClose(hDir) + + # ---- ② 文件:只枚举 *.exe(目录名也可能是 *.exe,已在 ① 递归过,此处跳过)---- + if VFs.JoinPath(Pat, VFs.PATH_BUF_CAP, DirAnsi, "*.exe") != 0: + hExe: wb.HANDLE = wf.FindFirstFileA(Pat, t.CPtr(Fd)) + if hExe != wb.INVALID_HANDLE_VALUE: + while True: + Attrs2: t.CUInt32T = VPe.ReadU32(BYTEPTR(Fd), FD_ATTRIBUTES_OFF) + Name2: str = str(Fd + FD_FILENAME_OFF) + if (Attrs2 & wf.FILE_ATTRIBUTE_DIRECTORY) == 0: + PaceFile() + if HasExeSuffix(Name2) != 0: + Full: bytes = _mbuddy.alloc(VFs.PATH_BUF_CAP) + if Full is not None: + if VFs.JoinPath(Full, VFs.PATH_BUF_CAP, DirAnsi, Name2) != 0: + HandleCandidate(str(Full)) + _mbuddy.free(t.CPtr(Full)) + if wf.FindNextFileA(hExe, t.CPtr(Fd)) == 0: + break + wf.FindClose(hExe) + + _mbuddy.free(t.CPtr(Pat)) + _mbuddy.free(t.CPtr(Fd)) + return 0 + + +# ============================================================ +# 一轮扫盘 +# ============================================================ + +# 对"已发现项"做**完整重校**并剔除不合格者,返回剔除数。 +# 口径(需求 7):文件存在 + PE/.PRVERSN 可解析 + ` --version` 前 5 字符为 Viper +# ——即 VCheck.VerifyExecutable(与 check 通道、扫盘候选过滤同一套判定)。 +# 顺序:排在普通树扫描之前("优先确认已发现项"),逐条探测前轮询 check 通道 +# (每条最多占 5 秒探测窗口,轮询保证排队的 check 先被受理)。 +# 代价:每条已发现项都要重新打开/解析/跑一次 --version(这类条目很少,但单条最长 5 秒, +# 见 README 的限制说明)。 +def RevalidateDiscovered() -> int: + removed: int = 0 + j: int = 0 + while j < VCache.CacheCount(): + Exe: str = VCache.CacheExe(j) # 先取用(RemoveAt 会释放该槽) + VGuard.PollIpc() # 探测前轮询(check 优先) + if VCheck.VerifyExecutable(Exe) == 0: + VLog.VerboseFmtS1(" [重校剔除] %s\n", Exe) + VCache.RemoveAt(j) + removed += 1 + else: + j += 1 + return removed + + +# 执行一轮扫盘:轮开始(轮询 check → 重读 cache.json → 完整重校已发现项) +# → 遍历常用目录 / 递归查 .exe(其间持续轮询 check)→ 校验缓存存在性 → 轮询 check +# → stdout 输出 JSON → 写缓存文件。 +def RunScanRound(CachePath: str, RoundIdx: int) -> int: + global Round_Added, Reject_Shown, Reject_Total + global Pace_Enabled + Round_Added = 0 + Reject_Shown = 0 + Reject_Total = 0 + # 本轮节流开关:从"常规值"(RunScan 依据 --pace/--pace-units/--once 算出)恢复, + # 再叠加 --first-no-pace 的"第 1 轮不节流"。放在这里(而不是 RunScan 的循环里)是因为 + # 它属于"一轮"的语义:每轮开头都重算一次 ⇒ 第 1 轮的特例不会泄漏到第 2 轮起的常规轮次。 + Pace_Enabled = Pace_Configured + if VCli.FirstNoPace() != 0 and RoundIdx == 1: + Pace_Enabled = 0 + T0: ULONGLONG = wb.GetTickCount64() + ProgReset(RoundIdx, VDirs.DirCount()) + # 轮开始(顺序有讲究): + # ① 重读 cache.json —— 把内存表换成本轮开盘时的文件内容(先清空再重建); + # ② 受理排队的 check 请求 —— 优先级高于扫盘(含重校与树扫描);**放在重读之后**, + # 这样 check 新入库的命中项不会被紧随其后的重读清掉,本轮就能进入数组并写回文件; + # ③ 对已发现项做完整重校并剔除不合格者("优先确认已发现项",排在树扫描之前)。 + VCache.ReloadCache(CachePath) + VGuard.PollIpc() + Revalidated: int = RevalidateDiscovered() + Dirs: int = 0 + k: int = 0 + while k < VDirs.DirCount(): + AnsiDir: bytes = _mbuddy.alloc(VFs.PATH_BUF_CAP) + if AnsiDir is not None: + VFs.Utf8ToAnsi(VDirs.DirAt(k), AnsiDir, VFs.PATH_BUF_CAP) + if VFs.PathIsDir(str(AnsiDir)) != 0: + Dirs += 1 + R0: ULONGLONG = wb.GetTickCount64() + ProgRootEnter(str(AnsiDir)) + ScanTree(str(AnsiDir), 1) + ProgRootLeave(str(AnsiDir), t.CSizeT(wb.GetTickCount64() - R0)) + else: + # 自动表里的静态两条(C:\Viper / D:\Viper)与盘符枚举结果可能本就不存在, + # 逐条回显属过程细节,仅在 --verbose 时给出(--path 的显式告警在 VDirs.BuildTable) + VLog.VerboseFmtS1(" 跳过(不存在或非目录): %s\n", str(AnsiDir)) + _mbuddy.free(t.CPtr(AnsiDir)) + k += 1 + + # 本轮被 PE/.PRVERSN 过滤掉的候选:前 REJECT_LOG_MAX 条已在 HandleCandidate 逐条打印, + # 这里只补一行汇总(不逐条重打)——逐条与汇总均属过程细节,仅 --verbose 时给出 + if Reject_Total > REJECT_LOG_MAX: + VLog.VerboseFmt2(" [reject] 其余省略…(本轮累计 %u 条,已逐条打印前 %u 条)\n", + t.CSizeT(Reject_Total), t.CSizeT(REJECT_LOG_MAX)) + + # 轮结束前的最后一次轮询:此后到写盘之间不再有等待点(check 命中项因此能进入本轮数组) + VGuard.PollIpc() + removed: int = VCache.ValidateCache() + VJson.EmitJson() + VLog.StdoutWrite(str(VJson.JsonBuf())) + VCache.WriteCacheFile(CachePath, VJson.JsonBuf(), t.CSizeT(VJson.JsonLen())) + + T1: ULONGLONG = wb.GetTickCount64() + # 轮末一行(默认保留,不含路径):常驻模式下它是"本轮已跑完"的存活信号(与启动摘要同构), + # --once 时即结束行;"下轮开始"时间戳属下轮过程细节,仅 --verbose。 + # "移除"含两部分:完整重校剔除(Revalidated)+ 存在性校验移除(removed)。 + VLog.LogFmt3("轮次 %u 结束: 扫描目录 %u 个, 已知条目 %u 项", + t.CSizeT(RoundIdx), t.CSizeT(Dirs), t.CSizeT(VCache.CacheCount())) + VLog.LogFmt3(", 新增 %u 项, 移除 %u 项, 耗时 %u ms", + t.CSizeT(Round_Added), t.CSizeT(Revalidated + removed), t.CSizeT(T1 - T0)) + # "下轮开始"时间戳属过程细节(仅 --verbose) + if VLog.VerboseOn() != 0: + VLog.TimeStampAfter(Prog_Stamp, PROG_STAMP_CAP, t.CSizeT(VCli.SleepMs())) + VLog.LogFmtS1(", 下轮开始: %s", str(Prog_Stamp)) + VLog.LogStr("\n") + return 0 + + +# ============================================================ +# scan 子命令:参数已由 App/main.vp(VCli.ParseScanArgs)解析并完成看门狗拉起; +# 本函数只做初始化 + 常驻循环(轮间隔 --sleep,缺省 10000;0 = 不 sleep)。 +# ============================================================ +def RunScan() -> int: + global Pace_Enabled, Pace_Configured, Pace_Sleep_Ms, Pace_File_Units + CachePath: str = VCli.CachePath() + Once: int = VCli.Once() + + # 启动摘要(默认保留,单行):工具名 + 版本 + 模式 + 参数摘要。 + # 不含缓存路径与其他被处理路径(路径属过程细节,仅 --verbose 时经目录表/进度行给出): + # 该行只承担"进程已启动"这一可观测信号,与轮末一行共同构成本模式的存活指示。 + VLog.LogFmtS1("VET - Viper Environment Tool %s (scan)", VCli.Version()) + VLog.LogFmt2(" sleep=%u once=%u", t.CSizeT(VCli.SleepMs()), t.CSizeT(Once)) + VLog.LogFmt1(" need-confirm=%u\n", t.CSizeT(VCli.NeedConfirm())) + + if VCli.PaceSet() != 0: + # 显式给出 --pace 或 --pace-units ⇒ 一律启用节流(--once 下也生效),时长/粒度取命令行值; + # 只给其一则另一项保持缺省(--pace 缺省 20ms、--pace-units 缺省 64)——因为 VCli 未给出的那项 + # 仍返回其缺省常量。本分支先于下面的 --once 判断,故 --once 不再关闭这类显式节流。 + Pace_Enabled = 1 + Pace_Configured = 1 + Pace_Sleep_Ms = VCli.PaceSleepMs() + Pace_File_Units = VCli.PaceFileUnits() + VLog.VerboseFmt2("--pace: 已启用扫描节流 sleep=%u units=%u\n", + t.CSizeT(Pace_Sleep_Ms), t.CSizeT(Pace_File_Units)) + elif Once != 0: + # 未显式给出节流参数时的原有行为:--once 只跑一轮,关闭节流 sleep(PaceDir/PaceFile 均不再 Sleep) + Pace_Enabled = 0 + Pace_Configured = 0 + VLog.VerboseStr("--once: 已关闭扫描节流 sleep\n") + # --first-no-pace:只改**第 1 轮**的实际开关(每轮开头由 RunScanRound 从 Pace_Configured + # 恢复 + 叠加该特例),故这里不写 Pace_Enabled,只提示一行(属过程细节,仅 --verbose)。 + if VCli.FirstNoPace() != 0: + VLog.VerboseStr("--first-no-pace: 第 1 轮不启用节流 sleep\n") + + if ScanInit() == 0: + return 1 + + VLog.VerboseStr("[0/3] 常用目录表\n") + rc0: int = VDirs.BuildTable() + if rc0 == 0: + VLog.VerboseStr(" ✓ 通过\n\n") + else: + # 表为空 = 本轮无扫描根:属功能性异常,默认仍报一行(不再是静默) + VLog.LogStr("警告: 常用目录表为空(本轮无扫描根)\n\n") + + # 缓存载入移到每轮开始(RunScanRound → VCache.ReloadCache):需求是"每轮开始重读 + # cache.json",故这里不再单独读一次,避免第 1 轮把同一文件解析两遍。 + # `--sleep` 之前的第一次读盘由第 1 轮的 ReloadCache 承担(--once 也经过第 1 轮)。 + + Round: int = 0 + while True: + Round += 1 + RunScanRound(CachePath, Round) + if Once != 0: + break + # --sleep:轮间隔(缺省 10000;0 = 不 sleep,立即开始下一轮) + SleepMs: int = VCli.SleepMs() + if SleepMs > 0: + wb.Sleep(ULONG(SleepMs)) + return 0 + + +# ============================================================ +# scan 查询开关 --search-paths:建好"会搜索的目录表"→ 逐行打印(stderr)→ 返回 0 +# +# * 表来源与内容全部复用 VDirs.BuildTable(同一份表、同一顺序,含 --path 覆盖语义与 +# 逐条告警),本函数不复制任何建表逻辑。 +# * 打印口径 = **建表结果**,即 RunScan 每轮按 DirCount()/DirAt() 实际遍历的那份表 +# (VScan.vp:440-455):自动表里两条静态目录(C:\Viper / D:\Viper,VDirs.vp:287-288 +# 无条件入表、不检查存在性)可能并不存在,RunScanRound 会在遍历时按 VFs.PathIsDir +# 跳过它们。这里用**同一判定**(先 VFs.Utf8ToAnsi 再 VFs.PathIsDir,与 +# VScan.vp:443-444 逐字一致)标注"扫盘时会跳过",故标注后与"实际会去搜索的路径"逐条一致。 +# * 不扫盘:只对表内条目各做一次属性查询,不做任何目录树遍历(黑名单目录、递归深度、 +# 重解析点等只作用于树内部,不影响表内条目)。 +# * 退出码恒为 0:查询本身完成即成功(空表时打印明确说明后同样返回 0)。本函数不写 stdout, +# 全部输出走 stderr,故不会污染 scan 的"stdout 只放单行 JSON"约定。 +# ============================================================ +def RunSearchPathsQuery() -> int: + VDirs.BuildTable() + Src: str = "自动表" + if VDirs.IsOverride() != 0: + Src = "覆盖表 (--path)" + VLog.LogFmtS1("[search] 目录表来源: %s\n", Src) + N: int = VDirs.DirCount() + if N <= 0: + # 空表必须显式说明,避免用户误以为卡住 + VLog.LogStr("[search] 没有可搜索的路径(常用目录表为空)\n") + return 0 + Ansi: bytes = _mbuddy.alloc(VFs.PATH_BUF_CAP) + if Ansi is None: + VLog.LogStr(" 警告: 查询缓冲分配失败,仅列出路径(不标注是否会被扫盘跳过)\n") + i: int = 0 + while i < N: + Path: str = VDirs.DirAt(i) + Skip: int = 0 # 1=扫盘时会跳过;Ansi 不可用时保持 0(不标注) + if Ansi is not None: + VFs.Utf8ToAnsi(Path, Ansi, VFs.PATH_BUF_CAP) + if VFs.PathIsDir(str(Ansi)) == 0: + Skip = 1 + if Skip != 0: + VLog.LogFmtIS(" [search] [%u] %s (不存在或非目录,扫盘时会跳过)\n", t.CSizeT(i), Path) + else: + VLog.LogFmtIS(" [search] [%u] %s\n", t.CSizeT(i), Path) + i += 1 + if Ansi is not None: + _mbuddy.free(t.CPtr(Ansi)) + VLog.LogFmt1(" [search] 合计 %u 条;未扫盘、未读写缓存,查询到此结束\n", t.CSizeT(N)) + return 0 diff --git a/App/lib/__init__.vp b/App/lib/__init__.vp new file mode 100644 index 0000000..dc1d52b --- /dev/null +++ b/App/lib/__init__.vp @@ -0,0 +1,15 @@ +import t, c +from stdint import * +import sys + + +# ============================================================ +# VET App 包入口(包标记文件) +# +# 作用:标记 App/lib 为包,使 App/main.vp 能以 `import lib.X as X` 形式引入子模块 +# (与 TransPyV/App/lib/__init__.vp 的包标记约定一致)。 +# 共享内存池不在此声明:VET 各子模块自持模块级 `_mbuddy`,由 App/main.vp 统一注入; +# 其它模块只经各模块公开函数访问,不交叉读写本文件的全局。 +# ============================================================ + +LIB_PACKAGE_MARKER: t.CDefine = 1 diff --git a/App/main.vp b/App/main.vp new file mode 100644 index 0000000..634b54f --- /dev/null +++ b/App/main.vp @@ -0,0 +1,115 @@ +import t, c +from stdint import * +import stdlib +import string +import sys +import memhub +import w32.win32console as w32cmd +import lib.VLog as VLog +import lib.VFs as VFs +import lib.VDirs as VDirs +import lib.VGuid as VGuid +import lib.VCache as VCache +import lib.VJson as VJson +import lib.VProc as VProc +import lib.VPe as VPe +import lib.VScan as VScan +import lib.VCheck as VCheck +import lib.VGuard as VGuard +import lib.VCli as VCli + + +# ============================================================ +# VET - Viper Environment Tool(入口) +# +# 三种模式(详见 README): +# ver : 单 exe 校验(目录表 + .PRVERSN 段 + `--version` 前缀) +# scan : 常驻扫盘发现器(轮间隔由 --sleep 指定,缺省 10 秒;stdout 只输出 JSON) +# confirm-watch : 内部隐藏子命令(心跳确认看门狗),由 scan + --need-confirm 自动拉起, +# 不出现在用法里;只做 sleep→等 stdin 的 {"ok": 1},失败即终止父进程。 +# +# 本文件只做:控制台代码页 → Log 初始化 → 建主内存池 → 注入各模块 `_mbuddy` +# → 初始化 argv → 分派子命令。所有实现见 lib/ 下各模块。 +# ============================================================ + +CODE_PAGE: t.CDefine = 65001 +POOL_SIZE: t.CDefine = 16777216 # MemBuddy 主内存池 16 MiB + + +@t.CExport +def main() -> int: + w32cmd.SetConsoleOutputCP(CODE_PAGE) + w32cmd.SetConsoleCP(CODE_PAGE) + + VLog.LogInit() + + # MemBuddy 内存池:sys._init_argv 与各模块的池分配都依赖它 + arena: bytes = stdlib.malloc(POOL_SIZE) + if arena is None: + VLog.LogStr("[VET] FATAL: 内存池分配失败\n") + return 1 + mb: rawptr[memhub.MemBuddy] = memhub.MemBuddy(arena, POOL_SIZE) + if mb is None: + VLog.LogStr("[VET] FATAL: MemBuddy 初始化失败\n") + return 1 + + # 逐个注入同一池实例(各模块声明模块级 _mbuddy;不使用 varargs 自定义函数) + # 注意:声明了 _mbuddy 的模块一个都不能漏——漏注入时该模块的 _mbuddy 指向零值静态存储 + # (usable == NULL),其全部 alloc 返回 None,功能静默降级(历史现场:VCheck 漏注入 ⇒ + # "check 缓冲分配失败")。 + VFs._mbuddy = mb + VDirs._mbuddy = mb + VGuid._mbuddy = mb + VCache._mbuddy = mb + VJson._mbuddy = mb + VScan._mbuddy = mb + VCheck._mbuddy = mb + VGuard._mbuddy = mb + sys._mbuddy = mb + + sys._init_argv() + if sys._argc < 2 or sys._argv[1] is None: + VCli.PrintUsage() + return 1 + Cmd: str = sys._argv[1] + + if string.strcmp(Cmd, "ver") == 0: + # ver 不解析开关(目标路径固定在 _argv[2]):argv 中出现 --verbose 即打开细节日志 + if VCli.HasVerbose() != 0: + VLog.SetVerbose(1) + return VPe.RunVer() + # `--search-paths` 允许省掉 `scan` 直接写在子命令位置(`VET.exe --search-paths ...`): + # 它是纯查询(不扫盘、不读写缓存),不需要 scan 语境;语义与 `scan --search-paths` 逐字相同。 + # 先置位再由 ParseScanArgs 并入(该函数从 argv[2] 起扫,扫不到 argv[1] 上的这个开关)。 + if string.strcmp(Cmd, "--search-paths") == 0: + VCli.SetSearchPathsImplicit() + if string.strcmp(Cmd, "scan") == 0 or string.strcmp(Cmd, "--search-paths") == 0: + # 参数在分派层解析:看门狗的"拉起 → 运行 → 回收"跨越整个 RunScan 生命周期, + # 必须在进入 RunScan 之前拿到 --need-confirm/--need-confirm-timeout。 + if VCli.ParseScanArgs() != 0: + return 1 + # --verbose 生效时机:解析后立即设置,早于"看门狗拉起日志"与"启动摘要", + # 之后所有 VLog.Verbose* 调用据此决定是否输出(默认关闭 = 安静)。 + VLog.SetVerbose(VCli.Verbose()) + # --search-paths:查询开关——建好"会搜索的目录表"后逐行打印(stderr)并直接退出。 + # 插在 SpawnWatchdog 之前:该查询不扫盘、不读写缓存,也就不该因 --need-confirm 把看门狗 + # 拉起来(否则会出现"看门狗已在跑、父进程随即退出"的窗口,还得为它做一次无谓的存活校验)。 + # 该分支也不经过 VScan.ScanInit,故不分配任何扫盘缓冲——建表只用 VDirs 自己的 + # _mbuddy 分配(VDirs.vp:159/169/174/204),注入已在 main.vp:61 完成。 + if VCli.SearchPaths() != 0: + return VScan.RunSearchPathsQuery() + # --need-confirm > 0:拉起独立看门狗子进程(与扫盘解耦)。 + # 失败即致命:用户显式要求了心跳确认,不能静默降级为"无确认"运行。 + if VCli.NeedConfirm() > 0: + if VGuard.SpawnWatchdog(VCli.NeedConfirm(), VCli.NeedConfirmTimeout()) == 0: + VLog.LogStr("错误: 心跳确认看门狗启动失败,已中止 scan\n") + return 1 + RcScan: int = VScan.RunScan() + VGuard.StopWatchdog() # 父正常结束(含 --once)务必回收看门狗,杜绝孤儿进程 + return RcScan + # 内部隐藏子命令:心跳确认看门狗(不出现在用法里;由上面的 scan 分支以自身 exe 拉起) + if string.strcmp(Cmd, "confirm-watch") == 0: + return VGuard.RunConfirmWatch() + VLog.LogFmtS1("错误: 未知子命令 %s\n", Cmd) + VCli.PrintUsage() + return 1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cee2f62 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TermiNexus + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..abdb2bd --- /dev/null +++ b/README.md @@ -0,0 +1,639 @@ +# VET — Viper Environment Tool + +`VET` 是一个用 [Viper](../wiki/01-overview.md) 语言编写的 **Viper 工具链产物/环境检查器**(定位类似 PET / Python Environment Tool)。 + +它有两种模式: + +- **`ver `**:对给定的 Windows PE 可执行文件做三项静态/动态校验,确认该产物确实由 TransPyV 工具链生成、且自报版本正确。 +- **`scan --cache `**:**常驻扫盘发现器**——默认每 10 秒一轮(可用 `--sleep <毫秒>` 改),遍历"常用目录表",递归查找所有同时满足"可解析 PE 且含 `.PRVERSN` 段且 ` --version` 输出以 `Viper` 开头"的 exe,把结果以 JSON 数组写到 **stdout**,并把发现记录写入 JSON 缓存文件。目录表默认自动构建,也可用可重复的 `--path ` **强制替换**;扫盘过程日志(进度行、扫描根进出、逐条过滤诊断、目录表清单等,含被处理路径)默认**静默**,仅在 `--verbose` 时写到 **stderr**;可用 `--need-confirm <秒>` 开启**心跳确认**——由**独立的看门狗子进程**(`VET.exe confirm-watch …`,内部隐藏命令)每 x 秒要求 stdin 输入一行 `{"ok": 1}`,**期限(默认 5000 ms,可用 `--need-confirm-timeout <毫秒>` 改)到点仍未收到合法 `{"ok": 1}`、或收到形状合法但取值不是 `1` 的行**(如 `{"ok": 0}`)即输出 `{"exit":1, "msg": "timeout/ok!=1"}` 并终止 scan 进程;**形状可疑的行**(去掉前导空白后首字符不是 `{` / 整行不含 `}` / 行长超过 256 字节,**含空行**)被忽略并继续在剩余期限内等待,不判失败。 +- **`--need-confirm` 开启时另有一条 `check` 请求通道**(依附心跳,见「check 请求通道」):往 stdin 写一行 `{"check": "", "id": <整数>}`,看门狗把它转发给 scan 进程,scan **优先**完成"读 `.PRVERSN` → 执行 `--version` 验证",并把结果以**单行 JSON** 回执写到 stdout。该行不参与心跳判定(期限继续走,仍需照常回 `{"ok": 1}`)。 +- **`scan --search-paths`**:**查询开关**(无值)——建好"会搜索的目录表"后**逐行打印**(序号 + 路径 + 目录表来源,输出到 **stderr**)并**直接退出**:不扫盘、不拉起看门狗、不读写缓存,因此**未给 `--cache` 也可运行**(见下文「查询开关 `--search-paths`」)。 + +> **输出流约定**:所有人类可读日志(含 `[0/3]…` 步骤、`[scan]…` 扫盘进度、`[confirm]…` 心跳提示)一律写 **stderr**;**stdout 只输出 JSON 结果**(`ver` 模式不输出 JSON,stdout 为空)。 +> +> **日志详细度**:默认(无 `--verbose`)走**安静模式**——stderr 只保留错误/告警与降级聚合、`[confirm]` 心跳提示、以及每轮一行与启动摘要(均不含被处理路径);进度行、扫描根进出、逐条 `[reject]`/`[pe]`/`[跳过]` 诊断、目录表逐条清单、缓存载入明细、`--version` 探测细节等"过程日志"一律静默。加 `--verbose` 恢复上述全部过程日志。 + +--- + +## 用法 + +```powershell +# 单 exe 校验 +VET.exe ver <目标 exe 路径> + +# 常驻扫盘发现器(每 10 秒一轮) +VET.exe scan --cache <缓存 JSON 路径> [--once] [--sleep <毫秒>] [--pace <毫秒>] [--pace-units ] [--first-no-pace] [--need-confirm <秒>] [--verbose] [--path <目录>]... + +# 查询将搜索的路径(建表后逐行打印到 stderr 即退出;不扫盘、不读写缓存,--cache 非必填) +VET.exe scan --search-paths +VET.exe scan --search-paths --path D:\X --path D:\Y + +# 恢复全部过程日志(进度行/扫描根/逐条过滤诊断/目录表清单/缓存明细/探测细节) +VET.exe scan --cache <缓存 JSON 路径> --once --verbose > result.json + +# 心跳确认 + 自定义确认超时(看门狗子进程,每 3 秒要一次 {"ok": 1},超时 2000 ms) +VET.exe scan --cache <缓存 JSON 路径> --need-confirm 3 --need-confirm-timeout 2000 +``` + +- `--cache `:**必填**(缺失即报错退出,返回码 1)。既是启动时的种子来源,也是每轮结束时写回的发现记录文件。**唯一例外**:与 `--search-paths` 同时使用时非必填(该查询只建表 + 打印,不读写缓存);若同时给了 `--cache` 仍按原样校验(只是不使用)。 +- `--once`:只跑一轮后退出(便于验证)。 +- `--sleep <毫秒>`:**轮间隔**(缺省 `10000`;`0` = 不 sleep 立即开始下一轮;上界 `86400000`)。非法值报错 + 用法 + 返回码 1。 +- `--pace <毫秒>`:**节流点睡眠时长**(缺省 `20`;`0` = **关闭节流**,两个节流点都不 Sleep;上界 `60000`)。非法值报错 + 用法 + 返回码 1。见下文「节流参数 `--pace` / `--pace-units`」。 +- `--pace-units `:**文件侧节流粒度**——每 `N` 个文件一个节流点(缺省 `64`;`N=1` = **每个文件都停一次**;取值域 `1..1000000`)。`0` 与非法值一律报错 + 用法 + 返回码 1。它**只作用文件侧**,目录侧粒度恒为 `16`。见下文「节流参数 `--pace` / `--pace-units`」。 +- `--first-no-pace`(无值):**第 1 轮扫盘不启用节流**,第 2 轮起按常规规则(常驻默认启用;`--pace 0` 则全程不节流)。仅常驻多轮时才有实际差别;不影响节流参数本身。见下文「节流参数 `--pace` / `--pace-units`」。 +- `--need-confirm <秒>`:**心跳确认**(缺省/`0` = 关闭;**仅 scan 模式生效**,`ver` 不接受该参数)。见下文「心跳确认」。 +- `--need-confirm-timeout <毫秒>`:心跳确认的**等待超时**(缺省 `5000`,上界 `86400000`)。**仅在同时给出 `--need-confirm`(且 >0)时有意义**;单独给出不报错,但会在 stderr 打一行"已忽略"的告警(理由见「心跳确认」)。 +- `--verbose`:**输出详细过程日志**(无值开关,缺省关闭 = 安静模式)。开启后恢复:`==== 扫盘轮次 r 开始 ====`、周期性 `[scan] r=… dir=… file=… cand=… cur=<当前目录> elapsed=…`、每个扫描根 `[root] 进入/离开`、逐条 `[reject]`/`[pe]`/`[跳过]`/`[命中]`、目录表逐条清单与来源统计、缓存载入明细、`--version` 探测前后与首行、轮末"下轮开始"时间戳,以及 `ver` 的目标路径与 PE 解析中间量。`ver` 子命令同样接受该开关(`argv` 中出现即生效)。它只改变 stderr 的详细度,不影响 stdout 的 JSON、缓存文件内容与所有计数/节流行为。 +- `--path `:**可重复**。**一旦出现 `--path`,常用目录表被"强制替换"**——完全不做自动构建(不枚举盘符、不加静态 `C:\Viper`/`D:\Viper`、不取桌面),扫描根 = 命令行给出的路径序列(保持给定顺序,按 `strcmp` 去重)。**不存在的 `--path`:stderr 告警并跳过该条,其余继续**(不静默、也不整体退出)。未给 `--path` 时保持原有自动建表行为。 +- `--search-paths`:**查询开关**(无值,仅 `scan`)。建好"会搜索的目录表"后**逐行打印**(序号 + 路径 + 目录表来源,全部输出到 **stderr**)并**直接退出**:不扫盘、不拉起看门狗、不读写缓存,`--cache` 因此非必填(退出码恒为 0,空表也返回 0 并打印"没有可搜索的路径")。可与 `--path` / `--verbose` 组合(`--path` 决定表内容,`--verbose` 额外打印建表过程细节)。细节见下文「查询开关 `--search-paths`」。 +- 未知参数 / 未知子命令 / 参数缺失:打印用法到 stderr,返回码 1。`--need-confirm` 的取值必须是十进制非负整数字面量(仅 `0`-`9`,上界 `86400`);`--sleep` / `--need-confirm-timeout` / `--pace` 同理(`0`-`9`,上界分别为 `86400000` / `86400000` / `60000`);`--pace-units` 只接受十进制整数字面量 `1`-`1000000`(`0` 与非法值一律报错)。缺值或非法值:报错 + 用法 + 返回码 1。 +- `confirm-watch` 是**内部隐藏子命令**(看门狗),不出现在用法里,手工调用无意义(缺少 `--parent-pid` 会直接报错退出)。它的参数由 scan 自动拼出:`--interval <秒> --timeout <毫秒> --parent-pid --ipc-write <句柄数值>`(最后一个是 check 通道的管道写端句柄,缺省 0 = 无通道)。 + +### 新参数默认值表 + +| 参数 | 缺省值 | 上界 | `0` 的含义 | 适用范围 | +|------|--------|------|-----------|----------| +| `--sleep <毫秒>` | `10000` | `86400000`(1 天) | 不 sleep(轮间隔 0) | scan | +| `--pace <毫秒>` | `20` | `60000`(1 分钟) | **关闭节流**(两个节流点都不 Sleep) | scan | +| `--pace-units ` | `64` | `1000000` | **非法**(`N` 必须 ≥1,报错退出) | scan | +| `--first-no-pace`(无值) | 关 | — | — | scan(无值开关;第 1 轮不节流,之后按常规规则) | +| `--need-confirm <秒>` | `0`(关闭) | `86400`(1 天) | 关闭心跳确认 | scan | +| `--need-confirm-timeout <毫秒>` | `5000` | `86400000`(1 天) | 不等待(几乎必然判失败) | scan + `--need-confirm>0` | + +> `--verbose` 与 `--search-paths` 均为无值开关,缺省关闭,不属于上表的数值参数;`scan` 与 `ver` 均接受 `--verbose`,`--search-paths` 仅 `scan`。 + +### 节流参数 `--pace` / `--pace-units` + +扫盘在高频位置(每个目录项、每个文件)设"节流点"以降低对机器的持续压力:到达节流点时 `Sleep` 一小段。`--pace` 与 `--pace-units` 把原先写死的两个量**开放为命令行旋钮**: + +| 旋钮 | 作用 | 缺省 | 取值域 | 特殊取值 | +|------|------|------|--------|----------| +| `--pace <毫秒>` | 节流点的 `Sleep` 时长 | `20` | `0..60000` | `0` = **关闭节流**:两个节流点都不再 `Sleep`(含目录侧) | +| `--pace-units ` | **每 N 个文件**一个节流点(文件侧粒度) | `64` | `1..1000000` | `1` = **每个文件都停一次**(最慢) | + +- **只作用文件粒度**:`--pace-units` 只改文件侧粒度;目录侧粒度**恒为** `16`(`VScan.PACE_DIR_UNITS`),不受该开关影响。 +- **显式给出即启用**:只要命令行出现 `--pace` 或 `--pace-units` 之一,节流即启用——**`--once` 下也生效**(未给出时 `--once` 仍按原样关闭节流)。只给出其一时,另一项保持其缺省(`--pace` 缺省 20 ms、`--pace-units` 缺省 64)。 +- **`--pace 0` 的目录侧行为**:`--pace 0` 是"关闭节流",因此目录侧节流点也不再 `Sleep`(不只是文件侧);两个计数器与进度输出逻辑保持不变(`--pace 0` 不调用 `Sleep(0)`——`Sleep(0)` 只是让出时间片,语义上不是"关闭节流")。 +- **`--first-no-pace`(无值)**:只让**第 1 轮**不节流,第 2 轮起回到常规规则(常驻默认启用;若同时给了 `--pace 0`,则全程都不节流,该开关无额外效果)。实现上,`RunScan` 先把"常规值"算进 `VScan.Pace_Configured`,每轮开头由 `RunScanRound` 恢复成本轮实际值、再叠加"第 1 轮 ⇒ 0" ⇒ **首轮特例不会泄漏到第 2 轮起**。它不改变 `--pace`/`--pace-units` 的取值,也不改变 `--once`(单轮时第 1 轮即全部,二者叠加结果就是"不节流")。 +- **未给出时逐字兼容**:不给这两个开关时,行为与改造前完全一致——常驻默认启用、时长 20 ms、文件粒度 64、目录粒度 16,`--once` 关闭节流。 +- 计数与进度不受影响:`--pace` / `--pace-units` 只决定"是否 Sleep / 睡多久 / 每多少个文件睡一次",不改变 `Prog_Dirs`/`Prog_Files` 计数与进度行输出规则。 + + +### 构建(本机自编译,本仓库不预置产物) + +```powershell +# 确保 PATH 含 mingw 与 LLVM +$env:PATH = "D:\msys64\mingw64\bin;D:\LLVM\bin;$env:PATH" + +# 用 Viper 自举的前端 TransPyV(识别 project.vpj 的 build_dir) +D:\Users\TermiNexus\Desktop\TransPyC\exe\TransPyV.exe ` + --project .\project.vpj +``` + +产物位于 `VET\.tpv_build\output\VET.exe`(`build_dir` = `./.tpv_build`,`linker.output` = `VET.exe`)。 + +> 本次把单文件 `App/main.vp` 拆成 `App/main.vp` + `App/lib/*.vp`(新增 11 个源文件)。 +> 仓库内 `.tpv_build/temp` 可能残留旧单文件构建的中间产物;首次重建建议清缓存: +> `TransPyV.exe --project .\project.vpj --clear-cache`(或直接删除 `VET\.tpv_build`)。 +> 多文件项目由 `source_dir`(`./App`)**递归**收集 `.vp/.py`,子目录 `lib/` 会自动纳入。 + +> 也可用 Python 前端:`python ..\TransPyC\Projectrans.py --project .\project.vpj`, +> 但 `Projectrans.py` **忽略 `build_dir`**(见 `TransPyC/lib/Projectrans/Config.py:98-100`), +> 此时产物落在 `VET\output\VET.exe`。 + +### 运行示例 + +```powershell +# 单 exe 校验(日志在 stderr) +.\.tpv_build\output\VET.exe ver ..\exe\TransPyV.exe + +# 扫盘:只跑一轮验证;stdout 重定向到文件即得到 JSON +.\.tpv_build\output\VET.exe scan --cache .\vet_cache.json --once > result.json + +# 扫盘:仅扫两个指定目录(强制替换目录表;不做任何自动建表) +.\.tpv_build\output\VET.exe scan --cache .\vet_cache.json --once --path .\App --path D:\Viper > result.json + +# 查询将搜索的路径(建表后逐行打印到 stderr 即退出;不需要 --cache,也不扫盘) +.\.tpv_build\output\VET.exe scan --search-paths 2>&1 | Tee-Object .\search.log + +# 查询覆盖表:只看这两个目录(不存在的会告警并跳过) +.\.tpv_build\output\VET.exe scan --search-paths --path D:\X --path D:\Y + +# 常驻扫盘(每 10 秒一轮,Ctrl+C 退出) +.\.tpv_build\output\VET.exe scan --cache .\vet_cache.json + +# 扫盘:轮间隔改为 1 秒(--sleep 1000) +.\.tpv_build\output\VET.exe scan --cache .\vet_cache.json --sleep 1000 + +# 心跳确认:看门狗子进程每 3 秒要求 stdin 输入一行 {"ok": 1},确认超时 2000 ms。 +# 注意:确认由独立子进程按固定节拍驱动,与扫盘是否进行无关(scan 必须活得比 interval 长才看得到效果) +.\.tpv_build\output\VET.exe scan --cache .\vet_cache.json --need-confirm 3 --need-confirm-timeout 2000 2>&1 | Tee-Object .\confirm.log +``` + +`ver` 模式输出形如(**全部在 stderr**;安静模式不含被校验目标路径与目录表清单): + +``` +VET - Viper Environment Tool 0.2.0 + +[0/3] 常用目录表 + ✓ 通过 + +[1/3] 内嵌版本段 (.PRVERSN) + 原始长度: 13 字节 + 版本串: 0.0.3-dev.428 + ✓ 通过 + +[2/3] --version 前缀 + 退出码: 0 + 前 5 字符: Viper + ✓ 通过 +``` + +加 `--verbose` 后,上述步骤前会先给出目标路径,`[0/3]` 内会列出目录表逐条与来源统计,`[1/3]`/`[2/3]` 内会给出 PE 解析中间量(`[pe]`)与 `--version` 探测细节: + +``` +VET - Viper Environment Tool 0.2.0 + +目标: ..\exe\TransPyV.exe + +[0/3] 常用目录表 + 目录表来源: 自动表 (N 条) + [0] C:\Viper + [1] D:\Viper + ... + 合计 N 个常用目录 + ✓ 通过 + +[1/3] 内嵌版本段 (.PRVERSN) + 原始长度: 13 字节 + 版本串: 0.0.3-dev.428 + ✓ 通过 + +[2/3] --version 前缀 + 退出码: 0 + 前 5 字符: Viper + ✓ 通过 +``` + +退出码:`0` = **三项全部通过**(`[0/3]` 仅在"目录表为空"时计为失败);`1` = 任一项失败或参数缺失。 + +`scan` 模式的 stdout 形如(**单行**;数组与其中每个对象都在同一行,末尾一个换行): + +```json +[{"id":"3f2504e0-4f89-11d3-9a0c-0305e82c3301","executable":"C:\\Viper\\VET.exe","version":"0.0.3-dev.428","arch":"x86_64","source":"workspace","displayName":"Viper 0.0.3-dev.428"}] +``` + +空缓存(本轮无命中)时输出 `[]` + 换行。写入 `--cache` 文件的**内容与 stdout 逐字节相同** +(同一份 `VJson.Json_Buf`,长度 `VJson.JsonLen()`)。 + +> 序列化/解析均改用仓库自带 `includes/json/**` 库(Viper 源码,**无新增 DLL 依赖**): +> 条目由 `json.object` / `json.object_set` / `json.string_val` 构造后交 `json.write` 输出 +> (紧凑单行、转义与 UTF-8 透传由库保证、键序 = 插入序);缓存读回走 `json.parse`。 + +--- + +## `ver` 模式检查项 + +| 检查项 | 说明 | +|--------|------| +| `[0/3] 常用目录表` | 构建常用目录清单(静态两项 + 各存储盘的 `Program Files`/`Program Files (x86)` + 当前用户桌面),非空即视为通过 | +| `[1/3] 内嵌版本段` | 解析目标 exe 的 PE 段表,定位 `.PRVERSN` 段,读出其中的版本串(如 `0.0.3-dev.428`),打印原始长度与字符串 | +| `[2/3] --version 前缀` | 执行 `<目标 exe> --version`,捕获其 stdout,校验前 5 个字符是否为 `Viper` | + +### `[0/3] 常用目录表` 细则 + +- **表来源**:未给 `--path` → **自动表**;给过 `--path` → **覆盖表**。该统计(`目录表来源: 自动表 (N 条)` / `目录表来源: 覆盖表 (N 条)`)与逐条清单一样属过程细节,仅 `--verbose` 时打印。 +- **自动表**: + - **静态两条无条件入表**:`C:\Viper`、`D:\Viper` 走 `AddCommonDirForce`,只做 `strcmp` 去重与容量检查,**不检查目录是否存在**。 + - **盘符范围**:枚举 `GetLogicalDrives()` 列出的盘符,**只跳过 `DRIVE_NO_ROOT_DIR(1)` 与 `DRIVE_CDROM(5)`**;`DRIVE_REMOVABLE(2)` / `DRIVE_FIXED(3)` / `DRIVE_REMOTE(4)` 都继续探测其 `Program Files` 与 `Program Files (x86)`。 + - **`SetErrorMode(SEM_FAILCRITICALERRORS)`**:枚举前调用一次,以抑制"请插入磁盘"等临界错误对话框,并让无介质驱动器(空读卡器/光驱)立即返回错误、避免长时间阻塞。这是**进程级**设置,会**影响本进程后续所有文件操作**且进程生命周期内不自动恢复。 + - **桌面取值**:优先 `SHGetKnownFolderPath(FOLDERID_Desktop)`(能正确处理 OneDrive 等重定向),用 `WideCharToMultiByte(CP_UTF8, ...)` 转为 UTF-8 后写入目录表,并 `CoTaskMemFree` 释放返回的 `PWSTR`;失败(`HRESULT < 0` / 转为空或超长 / 转换失败)时**显式退回** `%USERPROFILE%\Desktop`(退回原因照常告警);实际选用的兜底路径属过程细节,仅 `--verbose` 时打印;若桌面路径不可用,另打一行不含路径的告警。 +- **覆盖表(`--path`)**:**完全不调用**上面的自动逻辑;表 = 命令行给出的 `--path` 序列(保序,`strcmp` 去重)。逐条 `GetFileAttributesA` 校验存在性,**不存在则告警并跳过该条**,其余继续。表为空时 `[0/3]` 计为失败(与旧行为一致)。 +- `ver` 子命令不支持 `--path`,始终使用自动表(保持原行为)。 + + +`.PRVERSN` 段由 TransPyV 源码中形如下面的声明生成: + +```python +V_TPV_VERSION: t.CArray[char] | t.attr.section(".PRVERSN") = "0.0.3-dev.428" +``` + +(见 `TransPyV/App/main.vp:45`,`t.attr.section` 即 `__attribute__((section(...)))`,定义于 `TransPyC/lib/includes/t.py:832`。) + +--- + +## `scan` 模式细则 + +### 查询开关 `--search-paths`(建表后打印即退出) + +`VET.exe scan --search-paths [--path ]... [--verbose]` 是 `scan` 的**查询开关**(无值):它只回答"这一次会去搜索哪些路径",**打印完就直接退出**,完全不进入扫盘循环。 + +**两种写法等价**:因为它是纯查询(不扫盘、不读写缓存),也可以**省掉子命令**直接写 `VET.exe --search-paths [--path ]... [--verbose]`——子命令位置上就是该开关,分派层把它当作 `scan --search-paths` 处理(`main.vp` 先置位,`ParseScanArgs` 再并入,故"未给 `--cache` 也放行"这条同样生效)。 + +- **建表**:复用 `VDirs.BuildTable()`(与 `[0/3]`、每轮扫盘**同一份表、同一顺序**,不复制建表逻辑)。给了 `--path` 走覆盖表(完全不做自动构建),否则走自动表(静态两条 + 各盘 `Program Files`/`Program Files (x86)` + 当前用户桌面)。 +- **打印**:全部输出到 **stderr**(stdout 保持为空,不违反"stdout 只放单行 JSON"的约定),形如 + + ``` + [search] 目录表来源: 自动表 + [search] [0] C:\Viper (不存在或非目录,扫盘时会跳过) + [search] [1] D:\Viper (不存在或非目录,扫盘时会跳过) + [search] [2] C:\Program Files + ... + [search] 合计 N 条;未扫盘、未读写缓存,查询到此结束 + ``` + + 序号与顺序 = 目录表内的槽位与顺序(`VDirs.DirCount()`/`VDirs.DirAt(i)`)。**来源**只在表级给出(`自动表` / `覆盖表 (--path)`,取自 `VDirs.IsOverride()`)——`VDirs` 的表内条目是裸路径串,**不保存逐条的来源标记**(静态/盘符枚举/桌面),故逐条来源无从给出,不做重新推导以免与建表逻辑重复。 +- **口径 = 建表结果 + 逐条"是否会实际扫"标注**:自动表里的两条静态项(`C:\Viper`/`D:\Viper`)**无条件入表、不检查存在性**,而 `RunScanRound` 遍历时会按 `GetFileAttributesA` 跳过不存在的项。为了让输出与"实际会搜索的路径"逐条一致,本查询对表内条目**用同一判定**(`VFs.Utf8ToAnsi` → `VFs.PathIsDir`,与扫盘遍历处逐字一致)做一次属性查询,对不存在的条目标注 `(不存在或非目录,扫盘时会跳过)`;不存在的条目**照旧打印**(不隐藏),只是标注出来。覆盖表模式下 `--path` 不存在项已在建表时告警并跳过,因此通常不会出现该标注。 +- **不做的事**:**不扫盘**(无目录树遍历、无 `--version` 探测、无 `WinSxS`/`.git` 等黑名单与递归深度逻辑——那些只作用于树内部,不影响表内条目);**不拉起看门狗**(即使同时给了 `--need-confirm`,也在 `SpawnWatchdog` **之前**就返回,不存在"看门狗已起、父进程已退"的窗口,也不做那 400 ms 存活校验);**不读写缓存**(`--cache` 非必填,给了也不使用);**不分配扫盘静态缓冲**(不经过 `VScan.ScanInit`,建表只用 `VDirs` 自己的池分配)。 +- **退出码**:恒为 `0`(查询本身完成即成功;**空表也返回 0**,此时 stderr 打 `[search] 没有可搜索的路径(常用目录表为空)`,避免用户误以为卡住)。参数错误仍为 `1`(沿用既有校验,`--search-paths` 只放宽 `--cache` 的必填)。 +- `ver` 子命令不接受 `--search-paths`;而 `--search-paths` 既可写成 `scan --search-paths`,也可**省掉子命令**直接写在子命令位置(`VET.exe --search-paths ...`,语义逐字相同,见上文「两种写法等价」)。 + +### 一轮流程 + +1. **重读 `cache.json`**(`VCache.ReloadCache`:清空内存表 → 按文件重建)。文件不存在/读取失败/解析失败一律按空缓存继续(与启动时同一口径,打告警)。 +2. **受理排队的 `check` 请求**(若有;见「check 请求通道」)。放在重读**之后**:这样 check 新入库的命中项不会被紧随其后的重读清掉,本轮就能进入数组并写回文件。 +3. **对每条已发现项做完整重校**(顺序在普通树扫描**之前**,即"优先确认已发现项"):**文件存在 + PE/`.PRVERSN` 可解析 + ` --version` 前 5 字符为 `Viper`**;不合格的**从缓存剔除**(同时从本轮结果中消失),逐条明细在 `--verbose` 下打 ` [重校剔除] <路径>`。**任何一轮的重校失败都不影响 cache 写回**:被剔除项已不在内存表中,轮末照常把剩下的条目写回文件。 +4. 遍历**常用目录表**(自动表或 `--path` 覆盖表),逐个 `GetFileAttributesA` **确认目录存在**(不存在则跳过并记日志)。 +5. 对存在的目录**递归查找 `.exe`**(`FindFirstFileA` / `FindNextFileA` / `FindClose`)。 +6. 候选过滤:**PE 可解析** 且 **`.PRVERSN` 段可识别**。 +7. 通过过滤后**才**调用 ` --version`(**带 5 秒超时**),要求捕获输出前 5 个字符是 `Viper`。 +8. 命中则构造 JSON 条目写入内存缓存;扫描结束后校验缓存项对应的 exe 是否仍存在(不存在则移除);**轮结束前再轮询一次 `check` 请求**。 +9. 把**当前完整数组**打到 stdout;把同一份 JSON 写入 `--cache` 指定文件。 +10. 若未指定 `--once`,`Sleep(--sleep)`(缺省 10000 ms)后开始下一轮。 + +> 第 2 步之外,`check` 请求还会在扫盘过程中被**持续受理**(每次枚举一个目录项/一个候选文件、每次发起 `--version` 探测之前、每轮结束前一次);这些都是**非阻塞**轮询点(`PeekNamedPipe` 无数据即返回),因此不会因为"等请求"而拖慢扫盘。 + +### 存活输出与过程日志(stderr) + +**安静模式(默认)**:stderr 只保留以下内容,且均**不含被处理路径**: + +- **启动摘要**(单行):`VET - Viper Environment Tool <版本> (scan) sleep= once=<0|1> need-confirm=<秒>`; +- **每轮一行**:`轮次 结束: 扫描目录 个, 已知条目 项, 新增 项, 移除 项, 耗时 ms`; +- **错误/告警与降级聚合**:参数错误与用法、`FATAL`、`--need-confirm` 看门狗拉起失败(致命),以及确实发生降级时的一行短告警(缓存解析失败按空缓存启动、`--version` 探测超时、JSON 条目因过长/工作池不足被跳过「本轮 N 条」等)。 + +判断卡死:**启动摘要**给出"进程已启动"的信号,之后每完成一轮打一行**轮末汇总**——长期看不到新的轮末汇总(超过一轮的正常耗时)即为可疑卡死;开启 `--need-confirm` 时,`[confirm]` 心跳行每 x 秒一次,提供一轮之内的独立活跃信号。 + +**`--verbose`(恢复全部过程日志)**:以下内容仅在开启时输出(含路径的过程细节都在这里): + +- **每轮开始**:`==== 扫盘轮次 开始 (roots=, t=) ====` +- **进入/离开每个扫描根**各一行:` [root] 进入: ` / ` [root] 离开 (耗时 ms): ` +- **周期性进度**(每 16 个目录项 或 距上次输出满 1 秒):形如 + `[scan] r=3 dir=1240 file=5678 cand=2 cur=D:\Viper\App\lib elapsed=3.2s` + (`cur` 为当前正在枚举的目录;`cand` 为已通过 `PE+.PRVERSN` 过滤的候选数) +- **逐条候选诊断**:` [reject] rc=<码> `(每轮逐条上限 20 条,其余在轮末汇总一行)、` [pe] rc=… …`(PE 解析中间量)、` [跳过] …`、` [命中] …` +- **`--version` 探测前后**各一行(仅 scan;含该 exe 路径、耗时、是否超时): + ` [--version] 开始: ` / ` [--version] 结束: 耗时 ms, 退出码 , 超时 <0|1>, 输出 字节` +- **目录表清单与来源统计**、**缓存载入明细**(`缓存: 已载入 N 项种子`)、**逐条移除明细** +- **每轮汇总末的"下轮开始"时间戳**:`…, 耗时 ms, 下轮开始: <时间戳>` + +> 目标:任何一次超过 ~2 秒没有 stderr 输出都能被察觉为可疑卡死(**需 `--verbose`**)。`--version` 探测由 5 秒超时封顶,因此探测期间的静默不超过 5 秒;扫描期间的静默由周期性进度封顶 ~1 秒(单个 `FindNextFileA`/`GetFileAttributesA` 自身阻塞超过 2 秒属 OS 层慢 I/O,本工具无法打断,见"限制")。**安静模式**下以"启动摘要 + 每轮一行"为存活信号(`--need-confirm` 时另有心跳行),单轮内的静默不做约束。 + +### 心跳确认(`--need-confirm <秒>`,需求 5)——独立看门狗子进程 + +**仅 `scan` 模式生效**(`ver` 不接该参数)。`--need-confirm x`(x 为十进制非负整数字面量,缺省/`0` = 关闭)开启后,**确认与扫盘彻底解耦**:scan 只跑自己的扫盘任务,确认交给一个**独立的看门狗子进程**。 + +**看门狗怎么来的**(`App/main.vp` + `App/lib/VGuard.vp`):`scan` 分派层先解析参数,若 `--need-confirm > 0` 就用 `CreateProcessA` 把**自身 exe** 拉起为隐藏子进程 + +``` +VET.exe confirm-watch --interval --timeout --parent-pid +``` + +- `confirm-watch` 是内部隐藏子命令,不出现在用法里;它**不做任何扫盘/目录表/缓存动作**,只跑看门狗循环。 +- 父进程**不做无界等待**:拉起后只做一次**有界存活校验**(`WaitForSingleObject(hProcess, 400ms)`,见 `WATCH_ALIVE_PROBE_MS`),随后立即进入扫盘;父进程正常结束(含 `--once`)时调 `StopWatchdog()` 主动终止看门狗,杜绝孤儿进程。 + - **新增代价**:每次 `scan` 启动会因这次校验**多等约 0.4 秒**(看门狗正常存活时也要等满这个窗口才继续)。这是把"拉起成功、其实没在跑"的静默降级变成致命的必要成本。 +- 看门狗的标准输入/输出/错误与父进程**同一**(`STARTF_USESTDHANDLES` 指向父进程的同一批句柄,非新建管道),因此它打印的 JSON 与日志落在与 scan 相同的位置(控制台或重定向)。 + +**看门狗循环(本轮改动:确认输入由"阻塞读一行"改为"非阻塞取数 + deadline 驱动")**: + +1. 等 **x 秒**(`WaitForSingleObject(父进程句柄, x*1000)`);若等到的不是超时而是"**父进程已退出**",看门狗立即自行退出(无需父侧配合,见下"生命周期与无孤儿")。 +2. 到点:stderr 提示 `[confirm] 请在 ms 内输入 {"ok": 1}(每 秒一次)` + `[confirm] 当前 <时间>, 本轮期限 <时间>`,并**以此为 deadline 起点**(`GetTickCount64`;`--verbose` 时另打一行 `[confirm] 看门狗: stdin 形态=<1|2|3>`)。 +3. 在 **t 毫秒内**用"非阻塞取数"凑出完整一行并判定。stdin 形态由 `GetFileType` + `GetConsoleMode` 判定一次,分两路;**每轮循环先取数、取不到才等**(取数内部先消费"未消费输入 FIFO",见下"批量输入(快修)"一条): + - **控制台**(`FILE_TYPE_CHAR` 且 `GetConsoleMode` 成功):先把输入模式切换为"原模式清掉 `ENABLE_LINE_INPUT`/`ENABLE_ECHO_INPUT`"(掩码 `0xFFFFFFF9`,其余位——尤其 `ENABLE_PROCESSED_INPUT`(Ctrl+C)——不动),需要新数据时才取记录;本轮取不到数据时才用 `WaitForMultipleObjects({stdin, 父进程句柄}, FALSE, min(剩余, 20ms))` 等"有记录 / 父退出 / 到点",再按 `ReadConsoleInputExA` 是否可用分两路(只用 `KEY_EVENT(0x0001)` 且 `bKeyDown` 的字符:可打印 ASCII 与制表符入队,Enter 的 `\r` 归一成 `\n`,退格/方向键/非 ASCII 忽略): + - **主路径(本轮的加固 1)**:`ReadConsoleInputExA(hIn, buf, n, &read, CONSOLE_READ_NOWAIT)`——文档明写 `CONSOLE_READ_NOWAIT(0x0002)` 下"输入缓冲为空也立即返回",取数与"此刻是否有数据"解耦,**没有**"先查数量、再阻塞读"的两步组合。该导出**没有导入库/头文件**(Windows 文档 Requirements:Library none),只能动态链接,DLL 内只有 `ReadConsoleInputExA`/`ReadConsoleInputExW` 两个名字;因此本模块**动态解析**:`GetModuleHandleA("kernel32.dll")` + `GetProcAddress("ReadConsoleInputExA")`(这两个 API 本仓既有声明:`includes/w32/win32base.vp:66/65`),把地址存进 `t.Callable` 注解的函数指针变量后间接调用——**不新增任何链接期符号**,任何系统上 VET 都能加载。解析只做一次并缓存状态。 + - **降级路径(该导出不存在的老系统)**:`WaitForSingleObject(stdin, min(剩余, 5ms))` → `GetNumberOfConsoleInputEvents` → `ReadConsoleInputA`。**残余竞态**:查询报出 N>0 之后若该记录被别的读者取走,`ReadConsoleInputA`(无超时参数)会等**下一条记录**;本实现只能收紧、不能根除——①这一步之前的等待不占满剩余预算(上限 `GUARD_LEGACY_WAIT_MS=5`),②剩余为 0 时直接返回、不发起这次读(绝不在已过期的 deadline 上进入阻塞读)。`--verbose` 时看门狗启动日志会打印实际走的是哪一路(`控制台取数路径=1|2`)。 + - 本行判定完(成功或失败)立即写回进入前的控制台模式,并摘除控制事件处理例程(见下"控制台模式的恢复")。 + - **管道 / 重定向 / 其它**:管道路径先用 `PeekNamedPipe` 取"当前真正可用的字节数",只读 `min(可用, 块上限 64B)`;非管道(`< file` 重定向等)读一块有界字节(≤64B),读到 0 字节即 EOF;当前无可读数据时按 `WaitForMultipleObjects({stdin, 父进程句柄}, FALSE, min(剩余, 20ms))` 等句柄——既监控父进程退出,又避免"读端已关闭的管道会永久置信号、可读字节却为 0"(`PeekNamedPipe` 无法区分)造成的忙等。 + - **批量输入(快修)**:两路取到的**整批**字节(控制台路径为"过滤后的字符")先压入模块级**未消费输入 FIFO**(`Guard_Pend`,容量 `GUARD_PEND_CAP=128`),再由 FIFO 逐字节喂行缓冲;**一行凑满时同批剩下的字节留在 FIFO**,由下一次取数继续喂 ⇒ 一次往 stdin 写入两行以上不再出错(旧实现"一行凑满即 `return 1`"会把同批剩余字节永久丢掉)。 + - 两路共用同一行累积;凑满一行后**先分类**: + - **check 请求**(对象含 `"check"` 字符串 与 `"id"` 数字,由 `VCheck.ParseCheckLine` 用仓库 json 库解析)⇒ 原样逐字节转发到 IPC 写端(补一个 `\n`);该行**不计入心跳、也不判失败**——期限继续走,用户仍需在期限内回 `{"ok": 1}`。写失败只记 stderr。 + - **其余** ⇒ 交 `GuardClassifyLine` 三态分类(`ParseOkOne` 口径不变):合法 `{"ok": 1}` ⇒ 确认成功;**形状合法但值不对**(`{"ok": 0}`、`{"ok": 2}` 等)⇒ 致命失败;**形状可疑**(去掉前导空格/制表符后首字符不是 `{`、整行不含 `}`、行长 > `GUARD_OK_LINE_MAX=256`,**含空行**)⇒ stderr 回显该行(截断到 120 字符)后**忽略并继续在剩余期限内等待**,deadline 不变、不重置期限。 + - **每一次等待的上限都是 `min(剩余毫秒, 20ms)`(控制台主路径的取数调用本身立即返回、不等待),到点即判超时。** + - 父进程退出(等待返回到父进程句柄那一位)⇒ 写回控制台模式后看门狗自行退出。 +4. **失败**(期限到点仍未收到合法 `{"ok": 1}` / 形状合法但值不对 / 读失败 / stdin 到 EOF / stdin 句柄无效 / 控制台模式切换失败):写回控制台模式后,在 **stdout** 逐字打印唯一一行 + + ```json + {"exit":1, "msg": "timeout/ok!=1"} + ``` + + → `TerminateProcess(父进程句柄)` 杀掉 scan → 看门狗自身以退出码 `1` 结束。 + +> **为什么必须清 `ENABLE_LINE_INPUT`(本轮的根因)**:`setconsolemode` 文档明确——行输入模式下 `ReadFile`/`ReadConsole`"**读到回车才返回**",且 `ENABLE_PROCESSED_INPUT` 只有在 `ENABLE_LINE_INPUT` **也启用**时才把回车等字符"交给系统处理、不放进输入缓冲"。旧实现等到的只是"stdin 有输入可读",随后那次 `ReadFile` 在行输入模式下会一直等到回车 ⇒ **半行 + 不回车会把看门狗挂在读里**,"t 毫秒内是否收到合法 `{"ok": 1}`"无从判定。清掉行输入后回车以 `KEY_EVENT` 记录到达本进程,判定完全由本进程的 deadline 驱动。 +> **取舍:为什么连 `ENABLE_ECHO_INPUT` 一起清**:该位只作用于 `ReadFile`/`ReadConsole` 的读回显,文档并注明"仅在 `ENABLE_LINE_INPUT` 同时启用时可用";本模块根本不调用这两个函数读控制台,位留着不产生任何效果,一并清掉以免后来的人误判"还会回显"。**代价:等待期间按键不回显**(用户看不到自己敲的字符),提示行已写明应输入的内容。 + +**控制台记录的读取口径(`INPUT_RECORD`/`KEY_EVENT_RECORD` 布局,x64)**: + +`ReadConsoleInputA` 把记录写进一段**原生字节缓冲**(`Guard_Recs`,320B = 20×16),随后按下列偏移用字节小端读取(复用 `VPe.ReadU16`/`ReadU32`,与 `VScan` 读 `WIN32_FIND_DATAA` 同一手法)。**不复用** `includes/w32/win32console.vp:77` 的 `class INPUT_RECORD`:它把 union 成员声明为嵌套结构体字段(`Event: KEY_EVENT_RECORD`),而本仓没有"对结构体值再取字段"的用例;按字节偏移读只依赖 Win32 ABI 的字段布局,不依赖编译器的结构体填充规则。 + +| 记录内偏移(记录步长 20B) | 字段 | 类型 | 依据 | +|---|---|---|---| +| `+0` | `EventType` | `WORD`(2B) | `INPUT_RECORD` 第 1 个字段;只对 `KEY_EVENT(0x0001)` 继续看后面的字段 | +| `+4` | `KeyEvent.bKeyDown` | `BOOL`(4B) | union `Event` 的起点 = 2B `EventType` + 2B 对齐填充 | +| `+8` | `KeyEvent.wRepeatCount` | `WORD`(2B) | `KEY_EVENT_RECORD` 第 2 个字段 | +| `+10` | `KeyEvent.wVirtualKeyCode` | `WORD`(2B) | 第 3 个字段 | +| `+12` | `KeyEvent.wVirtualScanCode` | `WORD`(2B) | 第 4 个字段 | +| `+14` | `KeyEvent.uChar` | `union{ WCHAR; CHAR }`(2B) | 第 5 个字段(UTF-16 码元;Enter = `0x000D`) | +| `+16` | `KeyEvent.dwControlKeyState` | `DWORD`(4B) | 第 6 个字段 | +| `20` | 下一条记录 | — | `sizeof(KEY_EVENT_RECORD)` = 4+2+2+2+2+4 = 16;union 内最大成员(`MOUSE_EVENT_RECORD`)也是 16 ⇒ `sizeof(INPUT_RECORD)` = 2 + 2(填充) + 16 = **20**(x86/x64 一致) | + +本轮只读取 `EventType`(+0)、`bKeyDown`(+4)、`uChar`(+14) 三个偏移。 + +判定口径(取值口径沿用原实现,`ParseOkOne`):只认"存在 `ok` 且其值为数字 `1`",容忍 key/值与冒号之间的空格与制表符(`{ "ok" : 1 }` 通过);`{"ok": 0}`、`{"ok": 12}`、`{"ok": "1"}`、`{"ok": true}` 均不通过。`"ok"` 前一个非空白字符必须是 `{` 或 `,`,避免把字符串值里的 `ok` 误当键。 + +分类顺序(快修,`GuardClassifyLine`):**先做形状检查、再做取值判定**——形状不过(去掉前导空格/制表符后首字符不是 `{`、整行不含 `}`、行长 > `GUARD_OK_LINE_MAX=256`,其中**含空行**)⇒ 该行按"可疑"**忽略**(stderr 回显,截断到 120 字符)并继续在剩余期限内等待;形状通过而 `ParseOkOne` 不通过(如 `{"ok": 0}` / `{"ok": 2}`)⇒ **仍按失败收尾**("用户真的输错"这条口径不放宽)。 + +**参数与默认值**: + +- `--need-confirm <秒>`:确认节拍(缺省/`0` = 关闭;上界 `86400`)。 +- `--need-confirm-timeout <毫秒>`:确认等待超时(缺省 `5000`)。**只给 `--need-confirm-timeout` 而不给 `--need-confirm` 时不报错,但会打印一行"已忽略"告警**——理由:它只是 `--need-confirm` 的从属参数,报错会因标志顺序或复用脚本而无谓失败,告警可避免"静默无效"。看门狗未启动时该值无用。 +- 看门狗**拉起失败**(例如 `GetModuleFileNameA` 失败、stdin 句柄无效、`CreateProcessA` 失败)为**致命**:打印错误并中止 scan(返回码 1)。理由:用户显式要求了心跳确认,若静默降级为"无确认"运行,等于违背安全契约。 +- 看门狗**拉起成功但随即退出**同样为**致命**(本轮新增):`CreateProcessA` 返回非 0 只说明进程对象建起来了。父进程在拉起后做**存活校验**——若 400 ms 内 `WaitForSingleObject(hProcess, 400)` 返回 `WAIT_OBJECT_0`(子进程已退出),就 `GetExitCodeProcess` 取退出码打 stderr 诊断,再在 **stdout** 打同一行 `{"exit":1, "msg": "timeout/ok!=1"}`,关掉 `hProcess`/`hThread`,然后按"看门狗启动失败"中止 scan(返回码 1,`main.vp` 的既有失败分支)。返回 `WAIT_TIMEOUT` 视为存活、继续;其它等待返回值信息不足,也按存活处理(宁可少报也不误杀 scan)。**已知边界**:只观察 400 ms 这一个窗口——若看门狗"慢于 400 ms 才退出"仍会漏判,本方案不做父子握手。 + +**失败语义与"不写缓存"**:失败路径由**看门狗**执行 `TerminateProcess(父进程)`,scan **不做任何收尾**——不写缓存、不再输出本轮数组 JSON(避免把不完整的一轮当种子落盘;扫盘本身幂等,下一轮会重新发现并复核)。stdout 新增内容恰为上面那一行 JSON(看门狗自身失败与"拉起后立即退出"的存活校验失败打的都是这一行;后者把退出码等细节另打 stderr)。 + +**生命周期与"无孤儿"**(`VGuard.vp:119-125`): + +| 事件 | 父进程动作 | 看门狗动作 | +|------|-----------|-----------| +| 父拉起看门狗 | `CreateProcessA`(不等待),存 `hProcess`/`hThread` | 启动,`OpenProcess(PROCESS_TERMINATE\|SYNCHRONIZE, 父 pid)` | +| 拉起后 400 ms 内看门狗已退出 | 存活校验命中:`GetExitCodeProcess` 打退出码 + stdout 打 `{"exit":1,…}` → `CloseHandle` 两个句柄 → 中止 scan(返回码 1) | 已自行退出(如 `--parent-pid` 非法 → exit 1) | +| 确认成功 | 继续扫盘 | 回到"等 x 秒"步骤,续期 | +| 确认失败 | 被 `TerminateProcess` 强杀 | 打印 `{"exit":1,…}` → 杀父 → 退出码 1 | +| 父正常结束(含 `--once`) | `StopWatchdog()`:`TerminateProcess` + `WaitForSingleObject` + `CloseHandle` | 被杀,无残留(强杀不执行用户态代码 ⇒ 若正在等待,控制台模式可能残留"已清行输入") | +| 看门狗所在终端收到 Ctrl+C / Ctrl+Break / 关窗 | 同一控制台,同样收到该事件(无处理例程 ⇒ 按默认处理终止) | 已注册的处理例程先写回控制台模式,再返回 `FALSE` 交还默认处理 ⇒ 本进程被终止(终端输入模式不再残留) | +| 父异常退出(Ctrl+C / 被强杀) | 无法执行回收 | 父进程句柄置信号 ⇒ 阶段 1 的 `WaitForSingleObject` 与阶段 2 的 `WaitForMultipleObjects` 立即返回 ⇒ 写回控制台模式后自行退出(无孤儿) | + +**边界**: + +- **"t 毫秒内没有输入"的准确含义**:控制台上是"t 毫秒内没有敲出并回车的一整行"(Enter 即行结束,`{"ok": 1}` 后不回车不算确认);管道/重定向上是"t 毫秒内没有以 `\n` 结束的一整行"。两路都不再有"必须等到下一次回车/下一次按键"的阻塞读。被忽略的**形状可疑行**(见下一条)不计入"收到输入"。 +- **批量输入与可疑行(快修)**:一次往 stdin 写入多行时,同一批里"第一行之后"的字节**不再丢失**(整批先入未消费输入 FIFO,再逐行消费)。形状可疑的行(去掉前导空白后首字符不是 `{` / 整行不含 `}` / 行长 > 256 字节,**含空行**)只记一行 stderr 并忽略,**不判失败、不重置期限**;只有"期限到点仍未收到合法 `{"ok": 1}`"或"形状合法但值不对(如 `{"ok": 0}`)"才按失败收尾。 +- **控制台等待期间按键不回显**(清 `ENABLE_ECHO_INPUT` 的代价,理由见上),且**不做行编辑**:退格、方向键、功能键不参与累积;**非 ASCII(含 IME 全角)字符被忽略** ⇒ 必须输入 ASCII 的 `{"ok": 1}`。 +- **控制台模式的恢复(本轮加固 2)**:进入等待前用 `GetConsoleMode` 存原模式,**成功 / 失败 / 父进程退出**三条路径都写回(`GuardLeaveRaw`,幂等)。**确实改过模式时**同时用 `SetConsoleCtrlHandler(handler, TRUE)` 注册一个处理例程:它只做一件事——把进入前的模式 `SetConsoleMode` 写回(幂等),随后**一律返回 `FALSE` 交还默认处理**。因此 Ctrl+C / Ctrl+Break / 关窗这条"进程被默认处理终止"的路径也会先写回模式(终端不再残留"已清行输入");返回值取 `FALSE` 而非 `TRUE` 的理由:与加固前逐字同一语义(本进程仍由默认处理终止),若返回 `TRUE`("已处理")就必须自行 `ExitProcess`,否则看门狗会继续跑到下一轮 interval、再按心跳失败 `TerminateProcess` 父进程,把"用户主动 Ctrl+C"变成"杀父"。模式写回成功后立即 `SetConsoleCtrlHandler(handler, FALSE)` 摘除(注册与"已切换模式"同生命周期,故无泄漏)。**仍然无解的边界**:被 `TerminateProcess` 强杀(父进程 `StopWatchdog()` 回收、心跳失败杀父)不执行任何用户态代码,模式仍可能残留。 +- **stdin 走到尽头**:`< file` 这类读盘重定向读完(`ReadFile` 读到 0 字节)⇒ 立即按失败收尾。`|` 管道写端关闭时,句柄会一直置信号而可读字节可能为 0 —— `PeekNamedPipe` 无法区分"写端仍在、暂无数据"与"写端已关、无数据",故按"暂无数据"推进,到 deadline 判**超时无输入**(旧实现把这种情况当"空行"失败;最终结果一致,只是失败时机与 stderr 原因不同)。 +- **非控制台句柄一律有界读**:管道路径先问可读字节数(`PeekNamedPipe`),只读现有字节;`< file` 这类 `FILE_TYPE_DISK` 句柄无法预询可读量(`PeekNamedPipe` 只接受管道句柄),改读固定上限的块(64B),读到 0 字节即 EOF;每个"无数据"轮次另用 `WaitForMultipleObjects({stdin, 父进程句柄}, FALSE, min(剩余, 20ms))` 作为节拍(既推进循环、又监控父进程退出),不空转。 +- 新看门狗的节拍与扫盘无关:即使单轮扫描远超 x 秒、或扫盘在 `Sleep(--sleep)` 长眠,看门狗仍按自己的 `WaitForSingleObject` 严格每 x 秒提示(不再受"钩子被长 Sleep 掩盖"影响)。 +- 与 `--once` 的组合:`--once` 只跑一轮后父进程就 `StopWatchdog()`;若该轮**快于 x 秒**,看门狗在被回收前**来不及**发起/判定确认,因此不会出现 `{"exit":1,…}`。要观察心跳/超时,请让 scan 运行时间超过 x 秒(例如不加 `--once`,或加 `--sleep` 配合多轮)。 + +### 为什么是子进程而不是线程 + +本仓有 `includes/vthreading.vp`,但线程方案会与主流程共享同一地址空间:`VLog.Log_Buffer`(`VET/App/lib/VLog.vp:26`)与 `VScan` 的一批模块级缓冲(`Scr_*/Prog_*`,`VScan.vp:58-85`)都是进程级单例,并发写会互相踩踏;`MemBuddy` 池也非线程安全,看门狗的分配会与扫盘分配竞争。选择**子进程**的理由:内存/句柄/模块缓冲天然隔离;失败即 `TerminateProcess(父进程)`,与旧的"立即终止、不写缓存、不再打一轮 JSON"语义一致且更强;回收点清晰(父持看门狗句柄、看门狗持父进程句柄,双向兜底 ⇒ 无孤儿)。**若**改用线程,则必须为日志格式化与 `MemBuddy` 分配加锁、并把"终止整个程序"从 `sys.exit` 改为 `ExitProcess`,同时保证不写缓存——复杂度更高、收益为零,故未采用。 + +### check 请求通道(依附 `--need-confirm`) + +`check` 是**心跳确认通道上的"顺带请求"**:只有开了心跳(看门狗子进程存在)才具备该通道,**没有独立开关**。用途是"临时点名校验一个 exe",比普通扫盘**优先级高**。 + +**请求**(往 **stdin** 写一行;`--need-confirm` 必须 > 0): + +```json +{"check": "D:\\Viper\\VET.exe", "id": 123} +``` + +- `check`:必填,**字符串**,exe 的绝对路径(UTF-8 或 ANSI 均可;解析后转 ANSI 供 A 系列 API 使用)。 +- `id`:必填,**整数**(JSON 数字,不是字符串)。用于消息配对,**回执原样带回同一个值**。 +- 判定由**仓库 json 库解析**后按键/类型判定(不是字符串搜索):顶层是对象,且含 `"check"`(非空字符串)与 `"id"`(数字)⇒ 是 check 请求。 + +**回执**(scan 进程写到 **stdout**,单行 JSON,一次 `WriteFile` 写完): + +```json +{"check_result":true,"id":123,"viper":{"id":"3f2504e0-4f89-11d3-9a0c-0305e82c3301","executable":"D:\\Viper\\VET.exe","version":"0.0.3-dev.428","arch":"x86_64","source":"check","displayName":"Viper 0.0.3-dev.428"}} +{"check_result":false,"id":124,"check_msg":"NOT_FOUND"} +``` + +- 成功:`check_result: true` + `id` + `viper`(**不含 `check_msg`**)。`viper` 字典与发现项**同构同序**:`id`/`executable`/`version`/`arch`/`source`/`displayName`(`source` 固定 `"check"` 以区分来源)。 +- 失败:`check_result: false` + `id` + `check_msg`(**不含 `viper`**)。 +- 键序 = 插入序,转义与 UTF-8 透传由 json 库保证(`json.object_set` + `json.write(pool, obj, False)`,与发现项数组同一套序列化)。 + +**失败原因(`check_msg` 只有这 9 种取值,大小写严格)**: + +| `check_msg` | 含义 | 判定依据(源码路径) | +|---|---|---| +| `NOT_FOUND` | 未找到文件 | `CreateFileA` 失败且 `GetLastError ∈ {2,3}`(`ERROR_FILE_NOT_FOUND`/`ERROR_PATH_NOT_FOUND`) | +| `NO_PERMISSION` | 无权限 | `CreateFileA` 失败且 `GetLastError ∈ {5,32}`(`ERROR_ACCESS_DENIED`/`ERROR_SHARING_VIOLATION`);**其余一切打开失败取值也归此类** | +| `NOT_EXE` | 文件非 PE | 文件过短/读不出头部、缺少 `MZ`、`e_lfanew` 越界、缺少 `PE` 签名、可选头 magic 非法(`TryReadPeInfo` 的 rc 2/3/4/5 与 101–105) | +| `NO_PRVERSN` | 缺失 `.PRVERSN` 段 | rc 107(节表里没有该段名) | +| `PRVERSN_ERR` | `.PRVERSN` 段异常 | 段内容为空(首字节即 NUL)、段内容越界/段定位失败(rc 6/7/106)、整文件读取失败(rc 7)、节表超出头部窗口(rc 106) | +| `PREFIX_ERR` | `--version` 输出前缀异常 | `--version` 拉起成功(`RunVersionProbe` 返回 0)但输出前 5 字符不是 `Viper`(`VProc.OutputHasViperPrefix` 返回 0) | +| `EXE_ERR,0x` | 拉起程序失败 | `RunVersionProbe` 返回 rc=1:`CreateProcessA` 失败(错误码 = 紧跟该调用的 `GetLastError`),另含参数/路径非法(0x0)、命令缓冲分配失败(0x0)、`CreatePipe` 失败(= 该调用的 `GetLastError`) | +| `TIMEOUT` | 超时 | `--version` 探测超过 5000 ms(`Probe_TimedOut`,已 `TerminateProcess` 回收子进程) | +| `CHANNEL_UNAVAILABLE` | check 通道自身不可用(无法执行检查) | `CheckInit` 失败(本模块的缓冲或回执工作池分配失败,`VCheck.vp` `CheckInit`);该回执由 `EmitReplyUnavailable` 直接产出,不依赖工作池,因此"请求已收到但无法检查"始终可见 | + +> **十六进制格式**:`EXE_ERR,0x` 之后是**大写**十六进制、不带前导零(例:`GetLastError=2` → `EXE_ERR,0x2`;193 → `EXE_ERR,0xC1`),与 `VPe` 既有的 `unknown(0x%X)` 同一写法。 + +**优先级与挂起点**:scan 在**非阻塞轮询点**(`PaceDir`/`PaceFile`(每个目录项/每个候选文件)、每次发起 `--version` 探测前、每轮开始与结束)读 IPC 管道;一旦读到完整的请求行,就**同步**跑完"读 `.PRVERSN` → 执行 `--version` 验证 → 写回执"整段,期间树扫描停在那个轮询点上不前进(单线程串行,不存在与扫盘交错的窗口)。正在进行的单个 Win32 调用(`FindNextFileA`/`ReadFile` 等)与单次 `--version` 探测(≤5 秒)无法被抢占,只能在其返回后的下一个轮询点受理。 + +**通道怎么走的**:scan 用 `CreatePipe` 建一对管道,**读端**留在自己进程(清掉继承位 `HANDLE_FLAG_INHERIT=0`),**写端**保持可继承,并把**数值句柄**经命令行 `--ipc-write <数值>` 交给看门狗(看门狗用 `t.CPtr(数值)` 还原句柄后直接 `WriteFile`);`CreateProcessA` 成功后父进程**立即关掉自己的写端副本**(写端已由看门狗继承),因此后续 `--version` 子进程不会再继承它。看门狗把请求行**原样逐字节转发**(补一个 `\n` 作行分隔)。 + +**心跳语义**:转发 `check` 行**不算心跳确认、也不判失败**——期限继续走,用户仍需在期限内回 `{"ok": 1}`。转发的写失败只记 stderr(stdout/心跳契约不变);**非 check 的行交 `GuardClassifyLine` 分类**:形状合法但值不对(如 `{"ok": 0}`)仍按失败处理(打 `{"exit":1,…}` + 杀父);**形状可疑的行(含空行)只记一行 stderr 并忽略**,期限不变、继续等——只有期限到点仍未收到合法 `{"ok": 1}` 才判失败。 + +**入库规则**:check 命中的条目**写入 cache 并进入后续数组跟踪**(`source` = `"check"`);但若该 `executable` **已在 cache 中,则不重写**(不改其 `id`/字段,只是跳过),回执里的 `viper` 也就用既有条目的 `id`/`version`/`arch`/`displayName`(`id` 因此对同一 exe 保持稳定)。 + +**边界**: + +- 通道**只在 `--need-confirm > 0` 时存在**(看门狗即该通道的载体);未开心跳时往 stdin 写的任何东西都没有读者。 +- `id` 取值域 = json 库的 **int64**(`string.atoll`);超过 2⁶³−1 的 `uint64` 会被库截断后再回带,配对仍一致但不保证等于原文本。 +- 请求行上限 4096 字节(含 NUL);更长的行会被丢弃(既不越界也不与下一行粘连)——超长路径的请求会因此判成 `NOT_FOUND`。 +- 一次请求只产生**一行回执**;回执与每轮数组都写 stdout,**逐行消费**(每行都是完整 JSON,二者不会写在同一行里)。 +- 启动时若打「check 通道缓冲不可用」告警,说明 check 通道缓冲/回执池分配失败(正常条件下不会出现,见「内存」一节):此时请求**仍会被受理并回执** `CHANNEL_UNAVAILABLE`,每轮重校**跳过**(不剔除任何已发现项)。 + +### JSON 条目字段 + +```json +{ "id": "uuid-string", "executable": "绝对路径", "version": ".PRVERSN内容", + "arch": "架构", "source": "workspace", "displayName": "Viper 0.0.3-dev.428" } +``` + +| 字段 | 说明 | +|------|------| +| `id` | 真 UUID(`ole32!CoCreateGuid`),格式 `8-4-4-4-12` 小写十六进制 | +| `executable` | 命中 exe 的绝对路径(内部为 ANSI,输出前转 UTF-8 并转义 `\` 与 `"`) | +| `version` | `.PRVERSN` 段内容(到第一个 NUL 为止) | +| `arch` | 由 PE COFF `Machine` 解析:`0x8664`→`x86_64`、`0x14c`→`x86`、`0xaa64`→`arm64`、`0x1c0`/`0x1c4`→`arm`,其余→`unknown(0x...)` | +| `source` | 条目来源:扫盘发现 = `"workspace"`;`check` 通道命中 = `"check"`(旧缓存文件缺该字段时按 `"workspace"` 载入) | +| `displayName` | `"Viper " + version` | + +### 资源与节流 + +| 项 | 取值 | +|----|------| +| 轮次间隔 | `--sleep`,缺省 10000 ms;`0` = 不 sleep | +| 递归深度上限 | 6(相对种子目录向下的层数) | +| 跳过目录 | `WinSxS`、`$Recycle.Bin`、`System Volume Information`、`node_modules`、`.git`(大小写无关);另跳过 `.`/`..` 与**重解析点**(`FILE_ATTRIBUTE_REPARSE_POINT`,即符号链接/挂载点,防成环) | +| 节流 | 每 16 个目录项(目录侧粒度固定)或每 `--pace-units`(缺省 64)个文件 `Sleep(--pace)`(缺省 20 ms;`--pace 0` = 不 Sleep);显式给出 `--pace`/`--pace-units` 时 `--once` 下也生效;`--first-no-pace` 时第 1 轮不节流 | +| 进度输出间隔 | 每 16 个目录项或每满 1 秒一行(stderr,仅 `--verbose`) | +| `--version` 调用范围 | **仅**对已过 `PE + .PRVERSN` 过滤的 exe 调用(这类极少) | +| `--version` 超时 | **5000 ms**(`VERSION_PROBE_TIMEOUT_MS`);超时 `TerminateProcess` 并判为不通过、不入缓存 | +| PE 读取 | 先读 8 KiB 头部窗口解析节表;命中 `.PRVERSN` 才二次定位读取段内容(上限 4 KiB) | +| 每轮重校 | 每条已发现项重跑一次"PE + `.PRVERSN` + `--version`"(单条最长 5 秒;条目少时开销可忽略,条目多时会被重校拖长单轮) | +| check 回执工作池 | 72 KiB(`CK_REPLY_POOL_CAP`),单条回执在该池上的用量有固定上界预检(`CK_REPLY_NEED_FIXED + 值长度`) | + +### 缓存 + +- **路径必须由 `--cache ` 传入**(缺失即报错退出)。唯一例外是 `--search-paths` 查询:它不读写缓存,因此未给 `--cache` 也允许(给了也不使用)。 +- **每轮开始重读缓存**(`VCache.ReloadCache`):先清空内存表,再按文件重建(`json.parse`,文本上限 256 KiB)。文件不存在 = 正常(首次运行),按空缓存启动;打开/读取/解析失败 = **非致命**,打警告到 stderr 后按空(或尽力解析出的部分)缓存继续。 +- **每轮写回**:写失败只告警不中断(下一轮会再试)。写入内容与 stdout **逐字节相同**(单行 JSON 数组)。 +- **每轮完整重校**:重读后对每条已发现项做"文件存在 + PE/`.PRVERSN` 可解析 + `--version` 前 5 字符为 Viper",不合格的从缓存剔除(剔除以"本轮不写回该条"体现;重校失败不影响其余条目的写回)。 +- **存在性校验**:每轮扫描后对每个缓存项 `GetFileAttributesA` 校验其 exe 是否仍存在,不存在则从结果与缓存中移除。 +- **去重**:扫描到的 exe 若已在缓存中,不重复调用 `--version`;`check` 命中的 exe 若已在缓存中,不重写其 `id`/字段。 + +--- + +### 库依赖(kernel32 / shell32 / ole32) + +`App/` 各模块引用的 Win32 符号及所属 DLL(均为本仓既有声明,未改 `includes/`): + +| 符号 | 所属 DLL | 链接选项 | 用途 | 自声明位置 | +|------|----------|----------|------|-----------| +| `SHGetKnownFolderPath` | `shell32.dll` | `-lshell32` | 取当前用户桌面(`FOLDERID_Desktop`) | `App/lib/VDirs.vp` | +| `CoTaskMemFree` | `ole32.dll` | `-lole32` | 释放 `SHGetKnownFolderPath` 返回的 `PWSTR` | `App/lib/VDirs.vp` | +| `CoCreateGuid` | `ole32.dll` | `-lole32` | 生成发现条目的 UUID | `App/lib/VGuid.vp` | +| `GetLogicalDrives` / `GetDriveTypeA` / `GetEnvironmentVariableA` / `SetErrorMode` / `WideCharToMultiByte` | `kernel32.dll` | `-lkernel32` | 盘符枚举 / 环境变量 / 抑制临界弹窗 / 宽窄转换 | `VDirs.vp` / `VFs.vp` | +| `MultiByteToWideChar` | `kernel32.dll` | `-lkernel32` | 宽窄转换 | 既有 `includes/w32/win32base.vp:94` | +| `FindFirstFileA` / `FindNextFileA` / `FindClose` / `GetFileAttributesA` | `kernel32.dll` | `-lkernel32` | 目录枚举 / 属性查询 | 既有 `includes/w32/win32file.vp:115/117/119/99` | +| `GetStdHandle` / `WriteFile` | `kernel32.dll` | `-lkernel32` | 写 stderr(日志)与 stdout(JSON) | 既有 `win32file.vp:120/91` | +| `CreatePipe` / `SetHandleInformation` | `kernel32.dll` | `-lkernel32` | `--version` 探测的管道与继承位;心跳看门狗的标准句柄继承位;**check 通道管道**(读端清继承位、写端保持可继承) | 既有 `win32file.vp:139/140` | +| `CreateProcessA` / `TerminateProcess` / `GetExitCodeProcess` / `OpenProcess` / `GetCurrentProcessId` | `kernel32.dll` | `-lkernel32` | `--version` 子进程;看门狗子进程创建/强杀父进程/拿父进程句柄/取自进程 pid | 既有 `w32/win32process.vp:88/90/91/93/95` | +| `GetModuleFileNameA` | `kernel32.dll` | `-lkernel32` | 取自身 exe 全路径(看门狗命令行首 token) | 既有 `w32/win32file.vp:133` | +| `WaitForSingleObject` / `WaitForMultipleObjects` | `kernel32.dll` | `-lkernel32` | 超时等待(5000ms/interval)与回收(INFINITE);看门狗同时等 stdin 与父进程句柄(每次等待上限 = 本次剩余毫秒) | 既有 `w32/win32sync.vp:37/39` | +| `GetFileType` | `kernel32.dll` | `-lkernel32` | 判定 stdin 形态(控制台 / 管道 / 其它流) | 既有 `w32/win32file.vp:98` | +| `GetConsoleMode` / `SetConsoleMode` | `kernel32.dll` | `-lkernel32` | 读/写控制台输入模式(进入等待前清 `ENABLE_LINE_INPUT`/`ENABLE_ECHO_INPUT`,三条路径写回 + 控制事件例程写回) | 既有 `w32/win32console.vp:98/99` | +| `ReadConsoleInputExA` | `kernel32.dll` | —(**动态解析,不参与链接**) | 控制台非阻塞取记录(`CONSOLE_READ_NOWAIT=0x0002`);无导入库/头文件,只能 `GetProcAddress` | 本仓无声明(无导入库);VET 于 `App/lib/VGuard.vp` 用 `wb.GetModuleHandleA` + `wb.GetProcAddress` 取地址,存 `t.Callable` 变量后间接调用 | +| `GetNumberOfConsoleInputEvents` / `ReadConsoleInputA` | `kernel32.dll` | `-lkernel32` | **仅降级路径**:老系统解析不到 `ReadConsoleInputExA` 时"取未读记录数 → 只读已有记录"(残余竞态见上) | 既有 `w32/win32console.vp:102/100` | +| `SetConsoleCtrlHandler` | `kernel32.dll` | `-lkernel32` | Ctrl+C/Ctrl+Break/关窗时把进入前的控制台模式写回(处理例程只 `SetConsoleMode` + 返回 `FALSE`) | 本仓 include 未声明,VET 自声明于 `App/lib/VGuard.vp`(consoleapi.h:114-115) | +| `GetModuleHandleA` / `GetProcAddress` | `kernel32.dll` | `-lkernel32` | 动态解析 `ReadConsoleInputExA`(只取地址,不新增链接期符号) | 既有 `w32/win32base.vp:66/65` | +| `PeekNamedPipe` | `kernel32.dll` | `-lkernel32` | 管道 stdin 的"当前可读字节数"(不取走数据、立即返回);**check 通道读端的非阻塞轮询**(同一手法) | 本仓 include 未声明,VET 自声明于 `App/lib/VGuard.vp`(namedpipeapi.h) | +| `SYNCHRONIZE`(`0x00100000`) | `kernel32.dll` | `-lkernel32` | `OpenProcess` 的访问权限位(可等待) | 本仓 include 未声明,VET 自声明于 `App/lib/VGuard.vp`(winnt.h) | +| `FILE_TYPE_DISK` / `FILE_TYPE_CHAR` / `FILE_TYPE_PIPE`(`0x0001`/`0x0002`/`0x0003`) | `kernel32.dll` | `-lkernel32` | `GetFileType` 返回值判定 | 本仓 include 未声明,VET 自声明于 `App/lib/VGuard.vp`(fileapi.h) | +| `GetTickCount64` / `Sleep` / `GetLastError` / `CloseHandle` / `GetLocalTime` / `SystemTimeToFileTime` / `FileTimeToSystemTime` | `kernel32.dll` | `-lkernel32` | 计时(deadline)/ 节拍 / 错误码 / 关句柄 / 时间戳 | 既有 `win32base.vp:80/77/62/64/72/74/73` | +| `CoCreateGuid` 归属依据 | — | — | `includes/fltk/cfltk` tree 调用 `UuidCreate`(`src/drivers/WinAPI/Fl_WinAPI_System_Driver.cxx:54`),与 `CoCreateGuid` 同属 `ole32`/`combase` 的 GUID 生成族;`-lole32` 已在 `project.vpj`。**最终正确性以实际链接为准(待验证)** | + +> **本次 `project.vpj` 无库变更**(`-lole32`、`-lkernel32`、`-lshell32` 均已存在)。 +> 本轮新增的是**编译期源码依赖**:`includes/json/**`(`__init.vp` / `__writer.vp` / `__parser.vp`, +> 纯 Viper,已由 `includes: ["../includes"]` 覆盖),**不新增 DLL / 链接库**。 +> 本轮**移除**对 `includes/subprocess.vp` 的依赖:其 `WaitForSingleObject(hProcess, INFINITE)` 无超时接口;改由 `App/lib/VProc.vp` 自实现带超时的管道捕获。 +> 心跳看门狗新增的 Win32 符号(`GetModuleFileNameA` / `OpenProcess` / `GetCurrentProcessId` / `WaitForMultipleObjects` / `TerminateProcess` / `GetFileType` / `GetConsoleMode` / `SetConsoleMode` / `GetNumberOfConsoleInputEvents` / `ReadConsoleInputA` / `SetConsoleCtrlHandler` / `PeekNamedPipe`)**全部属于已链接的 `kernel32`**(`PeekNamedPipe` 在 kernel32 的导入即 `namedpipeapi.h` 声明,链接选项不变),`project.vpj` 无需改动;本仓 include 未声明的是常量 `SYNCHRONIZE`(winnt.h)、`FILE_TYPE_DISK`/`FILE_TYPE_CHAR`/`FILE_TYPE_PIPE`(fileapi.h)、`CONSOLE_READ_NOWAIT`(0x0002),以及函数 `PeekNamedPipe`、`SetConsoleCtrlHandler`(consoleapi.h)——均在 `VGuard.vp` 内以 `t.CDefine` / `extern def` 自声明。 +> 本轮两项加固**不引入任何链接期符号**:`SetConsoleCtrlHandler` 走既有 `-lkernel32`;`ReadConsoleInputExA`(无导入库)走 `GetModuleHandleA` + `GetProcAddress` 动态解析,解析不到就降级为 `GetNumberOfConsoleInputEvents` + `ReadConsoleInputA`。故老系统上 VET 仍能加载运行(只是控制台取数退化为降级路径)。 + +--- + +## 目录结构 + +``` +VET/ +├── .gitignore # 忽略 output/、temp/、.tpv_build/、*.exe 等 +├── LICENSE # MIT(与 Venom 同款) +├── README.md # 本文件 +├── project.vpj # 项目配置(llc + clang++,输出 VET.exe) +└── App/ + ├── main.vp # 薄入口:控制台代码页 → 建池 → 注入各模块 _mbuddy → argv → 子命令分派 + │ # (scan 分支:解析参数 → --search-paths 查询(建表打印即退出) + │ # → 拉起/回收心跳看门狗;confirm-watch 隐藏分派) + └── lib/ # 包(含 __init__.vp 包标记) + ├── VLog.vp # stderr/stdout 原语 + 固定元数格式化转发 + 时间戳 + ├── VFs.vp # 路径/文件/通用字节串/编码(JoinPath/PathIsDir/ReadWholeFile/WriteFileAll…) + ├── VPe.vp # PE/COFF 解析(ReadU16/ReadU32/节表/.PRVERSN/arch)+ ver 三段式流程 + ├── VGuid.vp # UUID(ole32!CoCreateGuid → 8-4-4-4-12) + ├── VJson.vp # JSON 构建(仓库 json 库:单条对象 + 整轮单行数组) + ├── VCache.vp # 发现缓存(内存表 + 缓存文件读写/解析(json.parse)/失效校验) + ├── VDirs.vp # 常用目录表(自动构建 / --path 强制覆盖) + ├── VProc.vp # 带 5s 超时的 `--version` 子进程探测(另记录"最近一次拉起失败"的阶段与 GetLastError) + ├── VScan.vp # 扫盘(目录树遍历/候选处理/进度输出/一轮驱动/每轮重读+重校/check 轮询点/节流旋钮) + │ # + --search-paths 查询入口 RunSearchPathsQuery()(建表 → 逐行打印 → 返回 0) + ├── VCheck.vp # check 请求通道的执行侧:请求行解析(json 库)/8 种失败原因映射/命中入库/单行 JSON 回执 + ├── VGuard.vp # 心跳看门狗:父侧拉起/回收自身子进程 + 子进程 confirm-watch 循环 + check 通道 IPC 管道 + │ # (确认输入为 deadline 驱动的非阻塞取数:控制台走动态解析的 ReadConsoleInputExA + CONSOLE_READ_NOWAIT, + │ # 并由 SetConsoleCtrlHandler 兜住模式写回;check 行经 IPC 原样转发给父进程) + └── VCli.vp # 命令行解析(scan/ver、--cache/--once/--sleep/--pace/--pace-units/--need-confirm[-timeout]/ + # --verbose/--path/--search-paths、confirm-watch 的 --ipc-write)+ 用法文本 +``` + +> 跨模块共享内存池沿用本仓惯例:各模块声明模块级 `_mbuddy: rawptr[memhub.MemBuddy]`,由 `App/main.vp` 建池后逐个注入(同一 MemBuddy 实例);不使用 varargs 自定义函数。 + +--- + +## 当前能力(已验证 / 未验证边界) + +> 本仓库交付时 **未编译、未运行**,所有结论均为静态论证。以下标注"已验证"指"已由源码/规范静态论证"。 + +- **已验证(静态)**: + - 取参方式 `sys._init_argv()` + `sys._argc` / `sys._argv`(`includes/sys.vp:67`、`includes/sys.vp:60-64`)。 + - 文件读取走 `w32/win32file.vp` 的 `CreateFileA` / `GetFileSizeEx` / `ReadFile` / `SetFilePointerEx` / `WriteFile` / `CloseHandle`(`win32file.vp:88/95/90/93/91`、`win32base.vp:64`)。 + - 目录枚举走 `FindFirstFileA` / `FindNextFileA` / `FindClose`(`win32file.vp:115/117/119`),返回结构用**裸 320 字节缓冲 + 偏移读取**(`dwFileAttributes`@0、`cFileName`@44),不依赖 Viper 结构体打包。 + - stdout/stderr 走 `GetStdHandle(STD_OUTPUT_HANDLE/STD_ERROR_HANDLE)`(`win32file.vp:120/45/46`)+ `WriteFile`(`:91`)。 + - PE 手工解析的偏移量按 PE/COFF 规范(详见交付报告"静态论证"一节)。 + - `--version` 子进程捕获由 `App/lib/VProc.vp` 自实现:`CreatePipe` + 裸缓冲 `SECURITY_ATTRIBUTES`/`STARTUPINFOA`/`PROCESS_INFORMATION`(偏移写入)+ `CreateProcessA` + `WaitForSingleObject(5000)`(超时 `TerminateProcess`)+ 读干管道 + 全句柄 `CloseHandle`;不再使用 `includes/subprocess.vp`。 + - UUID 走 `ole32!CoCreateGuid`;GUID 小端布局(Data1 u32 / Data2 u16 / Data3 u16 / Data4[8])与 `wb.GUID`(`win32base.vp:50-54`)字段宽度一致,按数值格式化后即 `8-4-4-4-12`。 + - 编码:枚举与文件 API 统一走 A 系列(系统 ANSI),JSON 输出前经 `MultiByteToWideChar(CP_ACP)→WideCharToMultiByte(CP_UTF8)` 转 UTF-8。 + - **check 通道**(`App/lib/VCheck.vp` + `VGuard.PollIpc`):请求行用仓库 json 库解析(按 `"check"` 字符串 + `"id"` 数字判定,不做字符串搜索);回执用 `json.object_set` + `json.write(pool, obj, False)` 序列化为单行;失败原因 9 种取值(8 种来自判定 = `TryReadPeInfo` 的 rc 表 + `CreateFileA`/`CreateProcessA` 的 `GetLastError` + `Probe_TimedOut` + `OutputHasViperPrefix`,第 9 种是通道自身不可用 `CHANNEL_UNAVAILABLE`)的映射见「check 请求通道」对照表。 + - check 命中/重校与扫盘共用同一套判定(`VPe.TryReadPeInfo` + `VProc.RunVersionProbe`),"文件存在 + PE/`.PRVERSN` + 前 5 字符 `Viper`"只有一个实现点(`VCheck.CheckExecutable`)。 +- **待验证(需实际编译运行)**: + - 各模块编译通过性(交付时未编译);跨模块 `t.CDefine`(如 `VPe.VER_CAP`)、跨模块 `extern` 引用与多文件项目(`App/lib/*.vp` 自动递归收集)的解析。 + - `CoCreateGuid` 的实际链接(`-lole32`)与返回 UUID 的格式/取值。 + - `VProc` 的 `CreateProcessA`/管道捕获在 `VET` 自身进程内的实际行为、`STARTUPINFOA`/`PROCESS_INFORMATION`/`SECURITY_ATTRIBUTES` 裸偏移写入的正确性(ctypes 已只读核验尺寸/偏移,见交付报告)。 + - 超时路径(`WaitForSingleObject` 返回 5000ms 超时 → `TerminateProcess`)在真实挂起 exe 下的行为。 + - 心跳看门狗(`App/lib/VGuard.vp`,本轮重构)的实际运行行为:`GetModuleFileNameA(NULL,…)` 取自身全路径、`CreateProcessA` 以 `STARTF_USESTDHANDLES` 复用父进程 stdin/stdout/stderr、`OpenProcess(PROCESS_TERMINATE|SYNCHRONIZE, 父 pid)`、`WaitForSingleObject`/`WaitForMultipleObjects` 对**控制台输入句柄**的信号语义、`ReadFile` 按行读取(含 `\r\n` 处理)、`TerminateProcess(父进程)` 的强杀、以及父正常结束时 `StopWatchdog()` 的回收。引用符号:`wf.GetModuleFileNameA`(`win32file.vp:133`)、`wf.GetStdHandle`(`:120`)、`wf.ReadFile`(`:90`)、`wf.SetHandleInformation`(`:140`)、`wsync.WaitForSingleObject`/`WaitForMultipleObjects`(`win32sync.vp:37/39`)、`wp.CreateProcessA`/`TerminateProcess`/`OpenProcess`/`GetCurrentProcessId`(`win32process.vp:88/90/93/95`)、`wb.CloseHandle/GetLastError`(`win32base.vp:64/62`)、`sys.exit`(`sys.vp:26`),均为本仓既有声明(**无新增 DLL/库依赖**;仅 `SYNCHRONIZE` 常量自声明)。 + - `STARTF_USESTDHANDLES` 复用控制台句柄时,句柄必须可继承(VET 用 `SetHandleInformation(...HANDLE_FLAG_INHERIT...)` 尽力设置、失败忽略);若某终端/重定向下句柄不可继承,`CreateProcessA` 会失败——VET 会打印 `GetLastError` 并**中止 scan**(**待确认**:真实控制台/`>` 重定向/管道三种场景下的句柄可继承性)。 + - **check 通道的端到端行为**:`--ipc-write <句柄数值>` 的 ptrtoint/inttoptr 往返(`t.CUInt64T(HANDLE)` → `%llu` → `VCli.ParseUint64` → `t.CPtr(数值)`)、管道写端确实被看门狗继承、父进程非阻塞轮询确实不阻塞、回执在真实 stdout 上的单行性(**待确认**;本文档给出的所有保证均为静态论证)。 + - `%llu` 变参格式化(`viperlib.snprintf`)在 `t.CUInt64T` 实参下的位宽正确性:同形用法已在本仓多处存在(`includes/memhub.vp:930-932` 的 `pool_base=%llu` 配 `t.CUInt64T(self.usable)`),故按既有先例采用(**待确认**:句柄值高位为 1 时能否原样往返)。 + - `.PRVERSN` 段在真实产物中的存在性(仅声明了该段的 exe 会有)。 + - `WIN32_FIND_DATAA` 的 `cFileName`@44 偏移在真实 `kernel32!FindFirstFileA` 下的正确性。 + - `SHGetKnownFolderPath` / `CoTaskMemFree` / `WideCharToMultiByte` / `SetErrorMode` 的实际运行行为。 + - `SetErrorMode(SEM_FAILCRITICALERRORS)` 对本进程后续文件操作的实际影响。 + - `GetLocalTime`/`SystemTimeToFileTime`/`FileTimeToSystemTime` 的时间戳(含 FILETIME 100ns 加法)正确性。 +- **已知偏差(重要)**:仓库内现有预编译产物 `exe/TransPyV.exe` **并不包含** `.PRVERSN` 段—— + 实测其段表为 `.text/.rdata/.buildid/.data/.pdata/.INTR/.WEAK/.reloc`, + 版本串 `0.0.3-dev.428` 落在 `.data` 段内(源码头 `TransPyV/App/main.vp:45` 虽声明了 + `t.attr.section(".PRVERSN")`)。因此对**该**产物运行 VET,`[1/3]` 会输出"未找到 .PRVERSN 节" + 并列出实际节名,`scan` 也不会命中它。这是产物/工具链侧的偏差,不是 VET 的解析缺陷;若工具链 + 已改为真正发射该段,或重新构建 TransPyV,则应能命中。 + +--- + +## 限制 + +- **仅 Windows / PE32 / PE32+**:解析的是 Windows PE 段表,不支持 ELF/Mach-O。 +- **仅校验首个匹配的 `.PRVERSN` 段**:不处理多个同名段(现实中不会有)。 +- **`--version` 校验只认前 5 字符 `Viper`**:不做语义化版本比较。 +- **`--version` 超时 5 秒**:`App/lib/VProc.vp` 用 `WaitForSingleObject(hProcess, 5000)`;超时即 `TerminateProcess` 并判该 exe 不通过(不入缓存)。此前用 `subprocess.run`(`WaitForSingleObject(INFINITE)`)无超时,被探测 exe 挂起会卡住整轮——此为**行为增强**。 +- **先等进程、后读管道**:为拿到"进程已退出/已杀"的确定态,`VProc` 先 `WaitForSingleObject(5000)` 再读干管道。因此若某 exe 的 `--version` 输出**超过管道缓冲(约 64 KiB)**,子进程会写阻塞、父进程等超时后强杀,导致该 exe 被判超时。`--version` 输出远小于此,实践无影响(已记录为限制)。 +- **stderr 合并进 stdout**:`VProc` 把 `hStdError` 与 `hStdOutput` 接到同一管道(便于诊断),因此"前 5 字符为 `Viper`"校验对混入前置 stderr 的 exe 会失败(与旧 `subprocess` 行为一致,`subprocess.vp:104`)。 +- **进度不能打断单个 API**:进度行由扫描循环驱动;若单个 `FindNextFileA`/`GetFileAttributesA` 自身阻塞(慢 I/O/网络盘)超过 2 秒,工具无法打断(无看门狗线程)。`--version` 探测则由 5 秒超时封顶。 +- **心跳确认 = 独立看门狗子进程**:`scan --need-confirm x` 会用 `CreateProcessA` 拉起 `VET.exe confirm-watch …`,与扫盘完全并行、`WaitForSingleObject`/`WaitForMultipleObjects` 驱动,**严格每 x 秒**提示(不再受扫盘钩子/长 `Sleep` 影响)。确认窗口内是**非阻塞取数**:控制台走 `ReadConsoleInputExA` + `CONSOLE_READ_NOWAIT`(动态解析,老系统退化为"查数量→`ReadConsoleInputA`"并有残余竞态),管道按 `PeekNamedPipe` 的可读字节数读,重定向文件按有界块读;取到的整批先入**未消费输入 FIFO**(`GUARD_PEND_CAP=128`,同批剩余字节不丢),每次等待的上限都是 `min(本次剩余毫秒, 20ms)`,**期限到点或形状合法但值不对**即判失败(不再出现"半行不回车把看门狗挂在 `ReadFile` 里";形状可疑的行只记 stderr 并忽略)。代价:多一个常驻进程、多一次 `CreateProcessA`(细节与"无孤儿"保证见「心跳确认」)。 +- **等待期间按键不回显、不做行编辑**:控制台路径清掉 `ENABLE_LINE_INPUT`/`ENABLE_ECHO_INPUT`(理由与代价见「心跳确认」);退格/方向键/非 ASCII 被忽略,需输入 ASCII 的 `{"ok": 1}`。Ctrl+C / Ctrl+Break / 关窗时由注册的 `SetConsoleCtrlHandler` 处理例程写回模式(终端不再残留"已清行输入");**只有被 `TerminateProcess` 强杀**(父进程回收看门狗、心跳失败杀父)不执行用户态代码、模式仍可能残留。 +- **心跳失败不写缓存**:失败路径由看门狗 `TerminateProcess(父进程)` 强杀 scan,父进程**不做任何收尾** ⇒ 本轮尚未落盘的命中项丢失(下一轮会重新发现并复核,故幂等安全);stdout 恰好多出那一行 `{"exit":1,…}`。 +- **心跳看门狗拉起失败 = 致命**:`--need-confirm > 0` 时若 `GetModuleFileNameA`/`stdin` 句柄/`CreateProcessA` 任一失败,scan 打印错误并以返回码 1 中止(不静默降级为"无确认")。 +- **`--once` + `--need-confirm`**:单轮快于 x 秒时看门狗来不及判定就被回收,看不到确认提示/退出 JSON;要观察需让 scan 活过 x 秒。 +- **看门狗"拿不到父进程句柄"时不打印退出 JSON**:`OpenProcess` 失败(现实几乎不可能,同用户同会话)时看门狗只在 stderr 记一行并以 1 退出(既不杀父也不写 JSON,因为无法履行契约)。 +- **进度行可能较密集**(仅 `--verbose`):每 16 个目录项即一行(沿用 `PACE_DIR_UNITS`),大量小目录下 stderr 行数较多;如需更稀疏可调 `VScan.PACE_DIR_UNITS` 或仅依赖 1 秒阈值。注意 `--pace-units` **不改变**进度行密度(它只改文件侧节流粒度,进度输出规则独立)。默认安静模式不输出进度行,不受此影响。 +- **安静模式单轮内无进度**:默认只打启动摘要与每轮一行,单轮内的长时间无输出属正常现象;要观察单轮进展需 `--verbose`(开启 `--need-confirm` 时另可按心跳行判断活跃)。 +- **`--search-paths` 是"建表时刻的快照"**:打印后即退出,之后的路径增删、盘符热插拔都不会反映;其"是否会实际扫"标注只是建表后对表内条目各做一次属性查询(`GetFileAttributesA`)的结果,与真正开始扫盘之间若目录消失,扫盘仍会照旧跳过(标注可能因此与实际略有偏差)。它也不给出逐条来源(静态/盘符枚举/桌面)——`VDirs` 表内不保存该标记。另外该查询**不读缓存也不扫盘**,因此它的输出是"计划搜索的根目录",不是"本轮命中的 exe"。 +- **路径长度受 `MAX_PATH`(A 系列 API)限制**:完整路径超过约 512 字节(`PATH_BUF_CAP`)时 `JoinPath` 直接跳过该项。 +- **PE 头部窗口 8 KiB**:若某 exe 的节表起点超过 8 KiB(极端多节 PE),将被跳过而非误判。 +- **JSON 输出缓冲 1 MiB**:一轮条目文本超出时**跳过放不下的条目**(仍保证是合法 JSON,末尾闭合 `]`),并告警。 +- **单条 JSON 上限 4096 字节**:json 库的 `write` 使用固定 4096 字节输出缓冲且无容量参数 + (`includes/json/__writer.vp:114`),VET 侧在序列化前用"转义最坏翻倍"的保守上界预检 + (`VJson.JSON_ENTRY_MAX`),超限条目**跳过并告警**(不会写穿缓冲)。 +- **缓存文本解析上限 256 KiB**:超过(`VCache.CACHE_TEXT_MAX`)或解析失败一律按"空缓存启动" + 并告警;解析工作池按 `CACHE_ARENA_PER_BYTE`(128 字节/输入字节)保守预留,保证不会中途耗尽。 +- **缓存不做增量合并**:每轮以"重读缓存文件 + 完整重校 + 重新扫盘 + 存在性校验"重建结果;不在缓存中做基于 mtime/版本的差异合并。 +- **check 通道依附心跳**:只在 `--need-confirm > 0` 时存在;没有心跳就没有看门狗,也就没有请求的读者与转发方(往 stdin 写什么都不会被处理)。未开该通道时 scan 的 stdout 只有每轮数组。 +- **check 与扫盘共用单线程**:check 执行期间扫盘停在轮询点(这正是"check 优先级更高"的实现方式)。因此**单个 check 最长可占用 ~5 秒**(`--version` 探测超时上限),且只有当扫盘走到下一个轮询点时才被受理——正在进行的单个 Win32 调用与单次探测不可抢占。 +- **每轮完整重校会重跑 `--version`**:缓存里每条已发现项每轮都要重新打开/解析/拉起一次 `--version`(单条最长 5 秒)。条目多时单轮耗时明显上升;这是"完整重校"的直接代价(不做按 mtime/版本号的短路)。 +- **每轮重读缓存会让"上一轮写盘失败"的进程内条目丢失**:`ReloadCache` 先清空内存表再按文件重建,若上一轮 `WriteCacheFile` 失败,那一轮的命中项在本轮开盘就不在表里——由"扫盘会重新发现、check 会重新命中"兜底(扫盘幂等),缓存文件本身保持"最后成功写入的那份"。 +- **check 请求行上限 4096 字节**:更长的行被丢弃(看门狗侧缓冲上限 `GUARD_LINE_CAP` 与父侧 `IPC_LINE_CAP` 同值);超长路径即便合法也会因行被丢弃而无回执,或(若路径被截断成合法 JSON 的短串)判成 `NOT_FOUND`。 +- **`id` 受 json 库的 int64 域限制**:`string.atoll` 解析,超过 2⁶³−1 的 `uint64` 在解析/回带时被截断(配对仍自身一致)。 +- **check 通道的管道容量是"提示值"**:`CreatePipe` 的 `nSize` 传 65536,实际缓冲由系统决定;短时大量请求若超出管道容量,看门狗的 `WriteFile` 可能短暂阻塞(父进程在每个轮询点排空,实践中不易触发)。 + +--- + +## 许可 + +MIT,见 [LICENSE](./LICENSE)。 diff --git a/project.vpj b/project.vpj new file mode 100644 index 0000000..322c4be --- /dev/null +++ b/project.vpj @@ -0,0 +1,40 @@ +{ + "name": "VET", + "version": "1.0.0", + "source_dir": "./App", + "build_dir": "./.tpv_build", + "compiler": { + "cmd": "llc", + "flags": [ + "-filetype=obj", + "-relocation-model=pic" + ] + }, + "linker": { + "cmd": "D:/msys64/mingw64/bin/clang++.exe", + "flags": [ + "-fuse-ld=lld", + "-lmsvcrt", + "-lucrt", + "-lpthread", + "-lmingwex", + "-lkernel32", + "-lshell32", + "-lole32", + "-Wl,--allow-multiple-definition" + ], + "output": "VET.exe" + }, + "includes": [ + "../includes" + ], + "target": { + "triple": "x86_64-pc-windows-gnu", + "datalayout": "e-m:w-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128" + }, + "options": { + "slice_level": 3, + "target": "llvm", + "strict_mode": true + } +}