# 含特殊字符路径的钻带文件读取修复说明 > 修复日期:2026-09-20(任务 `.trellis/tasks/09-20-fix-special-char-path-read`) > 影响面:所有钻带/参数文件读取入口(共 15 处调用) ## 问题描述 选择路径或文件名中含特定字符的钻带/参数文件时,加载直接失败: ``` 加载钻带文件失败:文件路径包含不允许的命令行特殊字符(Parameter 'filePath') ``` 命中字符包括 `(` `)` `&` `%` `!` `^` `@` `|` `<` `>` 等。实际业务文件名很容易命中,例如: - `1009B-2013钻机参数表(D2-f)(A1A2厂) (1).doc` - `demo_drl (1)/xx.drl` ## 问题分析 ### 直接原因 读取统一入口 `CommandTypeFileReader.ReadAllText` 把文件路径拼进命令行,用子进程读文件: ```csharp FileName = "cmd.exe", Arguments = $"/d /c type \"{filePath}\"", ``` 因为路径会成为命令行的组成部分,代码在 `ValidateAndNormalizePath` 里加了一串字符黑名单 (`ForbiddenPathChars`)“防炸”,命中即抛异常。 ### 历史成因(git 证据) | 提交 | 内容 | |---|---| | `44de1cb`(2025-12-17) | “优化 sort.txt 读取方式…改用 cmd type 命令读取…提升编码兼容性”。真实动因是编码:.NET 6 下 `Encoding.Default` = UTF-8,读 GB2312 文件乱码,而当时 `Encoding.GetEncoding(936)` 因未注册 CodePages provider 会抛异常,于是绕道 `cmd type` + `StandardOutputEncoding = 936` | | `473b2ca`(2026-05-23) | 注册 `CodePagesEncodingProvider`,删掉 `GetEncoding` 的 catch 回退 —— 真正的根因已修,但没拆掉多余的 cmd 层 | | `03d9b8b`(2026-05-16,Refactor) | 把散落的内联 `ProcessStartInfo` 抽成 `CommandTypeFileReader`,**首次**引入 `ForbiddenPathChars` 黑名单 | 即:把路径拼进命令行 ⇒ 必须“防炸” ⇒ 造出黑名单。黑名单并非业务需求,是实现的自我约束。 ### 实测结论(Windows 10.0.26100,`cmd /d /c type ""`) | 路径中的字符 | 实测结果 | 说明 | |---|---|---| | `(` `)` `&` `^` `@` 空格 | **可正常读取** | 双引号内 cmd 不把 `&`/`^` 当分隔符 | | `%CD%` / `%TEMP%` / `%USERNAME%` | **读取失败**(rc=1) | 变量展开在双引号内**依然发生** | | `!CD!` | 默认可读;`/v:on` 时失败 | 依赖延迟展开开关 | | 含 `0x1A` 等二进制字节 | 字节完全一致 | `type` 是字节透传,不做任何转换 | 结论:黑名单属于**过度防御** —— 真正会坏的只有 `%VAR%`(必坏)和 `!VAR!`(延迟展开开启时坏), 却把 `(` `)` `&` `^` `@` 等完全安全的字符一并禁掉,直接误伤 `(D2-f)`、`(1)` 这类正常文件名。 而 `type` 既然只是字节透传,这层 cmd 对编码问题其实毫无贡献。 ## 解决方案 1. 新增 `AnsiFileReader.cs`(原 `CommandTypeFileReader.cs` 已删除),改为直接文件 IO: ```csharp using var stream = new FileStream(normalizedPath, FileMode.Open, FileAccess.Read, FileShare.Read); using var reader = new StreamReader(stream, encoding, detectEncodingFromByteOrderMarks: true); return reader.ReadToEnd(); ``` `detectEncodingFromByteOrderMarks: true` 是为了**保持旧行为**:原先经 `Process.StandardOutput`(内部是 `StreamReader`)解码时默认会探测 BOM,所以带 UTF-8/UTF-16 BOM 的文件仍然按 BOM 指定的编码解码。 2. 删除 `ForbiddenPathChars` 与两处字符校验;路径只保留“非空”与“文件存在”两项校验。 3. IO 异常统一包装为可诊断消息:`InvalidOperationException("读取文件失败:{路径}({原因})")`,并保留 `innerException`。 4. 默认编码仍为 ANSI/GB2312(code page 936),`ReadAllText(filePath, encoding)` 重载能力不变。 5. 同步更新 `.trellis/spec/backend/` 中 6 处已失效的规范描述(`cmd type`、危险字符校验、进程退出码)。 ## 验证结果 用真实 `demo_drl/` 样例与旧实现对照(同一编码解码): | 验证项 | 结果 | |---|---| | 14 个 demo 样例:新旧实现字符串**逐字符一致、行数一致** | ✅ PASS | | 特殊字符路径可读:`demo (a&b) 100%/xx (1) -A&B.drl`、`yy (2)!^.drl` | ✅ PASS | | `%VAR%` 字面路径可读:`pct%TEMP%end/pct%TEMP%end (3).drl`(旧实现 rc=1 失败) | ✅ PASS | | BOM 行为一致:UTF-8 BOM / UTF-16 LE / UTF-16 BE 文件新旧实现结果相同 | ✅ PASS | | 文件被独占占用 → `InvalidOperationException("读取文件失败:...")` 且 `InnerException is IOException` | ✅ PASS | | 空路径 → `ArgumentException("文件路径不能为空")` | ✅ PASS | | 不存在路径 → `FileNotFoundException("文件不存在:...")` | ✅ PASS | | `dotnet build -p:Platform=x86` | 0 错误,无新增警告 | ## 行为一致性说明(解码层) 本次只替换“怎么拿到字节”(去掉命令行),**没有改变“怎么解码”**: | 场景 | 旧实现(cmd type + StreamReader) | 新实现(FileStream + StreamReader) | |---|---|---| | 无 BOM 的 GB2312/ANSI 文件 | 936 解码 | 936 解码(一致) | | UTF-8 BOM 文件 | 探测到 BOM → 改用 UTF-8、去掉 BOM | 相同(一致) | | UTF-16 LE/BE BOM 文件 | 探测到 BOM → 按 UTF-16 解码 | 相同(一致) | | 含 `0x1A` 等二进制字节 | 字节透传后 936 解码 | 相同(一致) | 即:保留历史实现**隐式具备**的 BOM 支持,而不是新增特性,也不是回归。 若将来要改成固定 936(不探测 BOM),属于行为变更,需要单独任务与用户确认。 ## 后续约定 - 钻带/参数文件读取统一走 `AnsiFileReader.ReadAllText`,**不要再引入子进程/命令行方式读文件**。 - 不要对文件路径做字符黑名单校验:读取不经过命令行,此类限制只会误伤正常文件名。 - 需要更换编码时使用 `ReadAllText(filePath, encoding)` 重载,不要另起第二套读取逻辑。