Files

640 lines
80 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VET — Viper Environment Tool
`VET` 是一个用 [Viper](../wiki/01-overview.md) 语言编写的 **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` 恢复上述全部过程日志。
---
## 用法
```powershell
# 单 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` 计数与进度行输出规则。
### 构建(本机自编译,本仓库不预置产物)
```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 <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** 逐字打印唯一一行
```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<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 条目字段
```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](./LICENSE)。