Files

80 KiB
Raw Permalink Blame History

VET — Viper Environment Tool

VET 是一个用 Viper 语言编写的 Viper 工具链产物/环境检查器(定位类似 PET / Python Environment Tool)。

它有两种模式:

  • ver <exe>:对给定的 Windows PE 可执行文件做三项静态/动态校验,确认该产物确实由 ViperC 工具链生成、且自报版本正确。
  • scan --cache <path>:常驻扫盘发现器——默认每 10 秒一轮(可用 --sleep <毫秒> 改),遍历"常用目录表",递归查找所有同时满足"可解析 PE 且含 .PRVERSN 段且 <exe> --version 输出以 Viper 开头"的 exe,把结果以 JSON 数组写到 stdout,并把发现记录写入 JSON 缓存文件。目录表默认自动构建,也可用可重复的 --path <dir> 强制替换;扫盘过程日志(进度行、扫描根进出、逐条过滤诊断、目录表清单等,含被处理路径)默认静默,仅在 --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": "<exe 绝对路径>", "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 恢复上述全部过程日志。


用法

# 单 exe 校验
VET.exe ver <目标 exe 路径>

# 常驻扫盘发现器(每 10 秒一轮)
VET.exe scan --cache <缓存 JSON 路径> [--once] [--sleep <毫秒>] [--pace <毫秒>] [--pace-units <N>] [--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 <path>:必填(缺失即报错退出,返回码 1)。既是启动时的种子来源,也是每轮结束时写回的发现记录文件。唯一例外:与 --search-paths 同时使用时非必填(该查询只建表 + 打印,不读写缓存);若同时给了 --cache 仍按原样校验(只是不使用)。
  • --once:只跑一轮后退出(便于验证)。
  • --sleep <毫秒>:轮间隔(缺省 10000;0 = 不 sleep 立即开始下一轮;上界 86400000)。非法值报错 + 用法 + 返回码 1。
  • --pace <毫秒>:节流点睡眠时长(缺省 20;0 = 关闭节流,两个节流点都不 Sleep;上界 60000)。非法值报错 + 用法 + 返回码 1。见下文「节流参数 --pace / --pace-units」。
  • --pace-units <N>:文件侧节流粒度——每 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 <dir>:可重复。一旦出现 --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 <pid> --ipc-write <句柄数值>(最后一个是 check 通道的管道写端句柄,缺省 0 = 无通道)。

新参数默认值表

参数 缺省值 上界 0 的含义 适用范围
--sleep <毫秒> 10000 86400000(1 天) 不 sleep(轮间隔 0) scan
--pace <毫秒> 20 60000(1 分钟) 关闭节流(两个节流点都不 Sleep) scan
--pace-units <N> 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> 每 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 计数与进度行输出规则。

构建(本机自编译,本仓库不预置产物)

# 确保 PATH 含 mingw 与 LLVM
$env:PATH = "D:\msys64\mingw64\bin;D:\LLVM\bin;$env:PATH"

# 用 Viper 自举的前端 ViperC(识别 project.vpj 的 build_dir)
D:\Users\TermiNexus\Desktop\TransPyC\exe\viperc.exe `
    --project .\project.vpj

产物位于 VET\__vpcbuild__\output\VET.exe(build_dir = ./__vpcbuild__,linker.output = VET.exe)。

本次把单文件 App/main.vp 拆成 App/main.vp + App/lib/*.vp(新增 11 个源文件)。 仓库内 __vpcbuild__/temp 可能残留旧单文件构建的中间产物;首次重建建议清缓存: viperc.exe --project .\project.vpj --clear-cache(或直接删除 VET\__vpcbuild__)。 多文件项目由 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。

运行示例

# 单 exe 校验(日志在 stderr)
.\__vpcbuild__\output\VET.exe ver ..\exe\viperc.exe

# 扫盘:只跑一轮验证;stdout 重定向到文件即得到 JSON
.\__vpcbuild__\output\VET.exe scan --cache .\vet_cache.json --once > result.json

# 扫盘:仅扫两个指定目录(强制替换目录表;不做任何自动建表)
.\__vpcbuild__\output\VET.exe scan --cache .\vet_cache.json --once --path .\App --path D:\Viper > result.json

# 查询将搜索的路径(建表后逐行打印到 stderr 即退出;不需要 --cache,也不扫盘)
.\__vpcbuild__\output\VET.exe scan --search-paths 2>&1 | Tee-Object .\search.log

# 查询覆盖表:只看这两个目录(不存在的会告警并跳过)
.\__vpcbuild__\output\VET.exe scan --search-paths --path D:\X --path D:\Y

# 常驻扫盘(每 10 秒一轮,Ctrl+C 退出)
.\__vpcbuild__\output\VET.exe scan --cache .\vet_cache.json

# 扫盘:轮间隔改为 1 秒(--sleep 1000)
.\__vpcbuild__\output\VET.exe scan --cache .\vet_cache.json --sleep 1000

# 心跳确认:看门狗子进程每 3 秒要求 stdin 输入一行 {"ok": 1},确认超时 2000 ms。
# 注意:确认由独立子进程按固定节拍驱动,与扫盘是否进行无关(scan 必须活得比 interval 长才看得到效果)
.\__vpcbuild__\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\viperc.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 形如(单行;数组与其中每个对象都在同一行,末尾一个换行):

[{"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 段由 ViperC 源码中形如下面的声明生成:

V_VPC_VERSION: t.CArray[char] | t.attr.section(".PRVERSN") = "0.0.3-dev.428"

(见 ViperC/App/main.vp:45,t.attr.section 即 __attribute__((section(...))),定义于 TransPyC/lib/includes/t.py:832。)


scan 模式细则

查询开关 --search-paths(建表后打印即退出)

VET.exe scan --search-paths [--path <dir>]... [--verbose] 是 scan 的查询开关(无值):它只回答"这一次会去搜索哪些路径",打印完就直接退出,完全不进入扫盘循环。

两种写法等价:因为它是纯查询(不扫盘、不读写缓存),也可以省掉子命令直接写 VET.exe --search-paths [--path <dir>]... [--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 可解析 + <exe> --version 前 5 字符为 Viper;不合格的从缓存剔除(同时从本轮结果中消失),逐条明细在 --verbose 下打 [重校剔除] <路径>。任何一轮的重校失败都不影响 cache 写回:被剔除项已不在内存表中,轮末照常把剩下的条目写回文件。
  4. 遍历常用目录表(自动表或 --path 覆盖表),逐个 GetFileAttributesA 确认目录存在(不存在则跳过并记日志)。
  5. 对存在的目录递归查找 .exe(FindFirstFileA / FindNextFileA / FindClose)。
  6. 候选过滤:PE 可解析 且 .PRVERSN 段可识别。
  7. 通过过滤后才调用 <exe> --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=<ms> once=<0|1> need-confirm=<秒>;
  • 每轮一行:轮次 <r> 结束: 扫描目录 <n> 个, 已知条目 <m> 项, 新增 <a> 项, 移除 <d> 项, 耗时 <ms> ms;
  • 错误/告警与降级聚合:参数错误与用法、FATAL、--need-confirm 看门狗拉起失败(致命),以及确实发生降级时的一行短告警(缓存解析失败按空缓存启动、--version 探测超时、JSON 条目因过长/工作池不足被跳过「本轮 N 条」等)。

判断卡死:启动摘要给出"进程已启动"的信号,之后每完成一轮打一行轮末汇总——长期看不到新的轮末汇总(超过一轮的正常耗时)即为可疑卡死;开启 --need-confirm 时,[confirm] 心跳行每 x 秒一次,提供一轮之内的独立活跃信号。

--verbose(恢复全部过程日志):以下内容仅在开启时输出(含路径的过程细节都在这里):

  • 每轮开始:==== 扫盘轮次 <r> 开始 (roots=<n>, t=<YYYY-MM-DD HH:MM:SS>) ====
  • 进入/离开每个扫描根各一行: [root] 进入: <dir> / [root] 离开 (耗时 <ms> ms): <dir>
  • 周期性进度(每 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=<码> <exe>(每轮逐条上限 20 条,其余在轮末汇总一行)、 [pe] rc=… …(PE 解析中间量)、 [跳过] …、 [命中] …
  • --version 探测前后各一行(仅 scan;含该 exe 路径、耗时、是否超时): [--version] 开始: <exe> / [--version] 结束: 耗时 <ms> ms, 退出码 <c>, 超时 <0|1>, 输出 <n> 字节
  • 目录表清单与来源统计、缓存载入明细(缓存: 已载入 N 项种子)、逐条移除明细
  • 每轮汇总末的"下轮开始"时间戳:…, 耗时 <ms> 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 <x 秒> --timeout <t 毫秒> --parent-pid <scan 的 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] 请在 <t> ms 内输入 {"ok": 1}(每 <x> 秒一次) + [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 逐字打印唯一一行

    {"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):

{"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 写完):

{"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<HEX> 拉起程序失败 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 条目字段

{ "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 <path> 传入(缺失即报错退出)。唯一例外是 --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/、__vpcbuild__/、*.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/viperc.exe 并不包含 .PRVERSN 段—— 实测其段表为 .text/.rdata/.buildid/.data/.pdata/.INTR/.WEAK/.reloc, 版本串 0.0.3-dev.428 落在 .data 段内(源码头 ViperC/App/main.vp:45 虽声明了 t.attr.section(".PRVERSN"))。因此对该产物运行 VET,[1/3] 会输出"未找到 .PRVERSN 节" 并列出实际节名,scan 也不会命中它。这是产物/工具链侧的偏差,不是 VET 的解析缺陷;若工具链 已改为真正发射该段,或重新构建 ViperC,则应能命中。

限制

  • 仅 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。