# VET — Viper Environment Tool `VET` 是一个用 [Viper](../wiki/01-overview.md) 语言编写的 **Viper 工具链产物/环境检查器**(定位类似 PET / Python Environment Tool)。 它有两种模式: - **`ver `**:对给定的 Windows PE 可执行文件做三项静态/动态校验,确认该产物确实由 ViperC 工具链生成、且自报版本正确。 - **`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 自举的前端 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`。 ### 运行示例 ```powershell # 单 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 形如(**单行**;数组与其中每个对象都在同一行,末尾一个换行): ```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` 段由 ViperC 源码中形如下面的声明生成: ```python 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 ]... [--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/、__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](./LICENSE)。