fix(console): unverbatim 用 to_str 而非 to_string_lossy(不损坏非 UTF-8 路径)

to_string_lossy 会把非法字节替换成 U+FFFD 再重建路径 —— 那是损坏路径,
比不净化更糟:原本只是 node 报 EISDIR,改完变成指向一个不存在的目录。

改为 to_str():非 UTF-8 → None → 原样返回(Windows 上是含未配对代理项的路径,
Unix 上是含非法字节的文件名),绝不悄悄改写。

新增守卫 unverbatim_对非_utf8_的_verbatim_路径必须原样返回不得损坏(#[cfg(unix)]):
构造必须同时含 \\?\ 前缀与非法字节才有区分力 —— 不带前缀时新旧实现都走
「原样返回」那一步,测不出差异。
注入故障(退回 to_string_lossy)实测该守卫如期变红;还原后 sha256 逐字节一致。
pnpm test:rust 18/18(+1),clippy 零告警。
This commit is contained in:
jkooe committed 2026-10-13 20:21:51 +08:00
1 parent 56f64e1cc1
commit 2437b0e863
2 files changed
+51 -15

No files matched your search

+12 -5
View File
@@ -464,6 +464,9 @@ Windows 侧只有打包(`console-windows.yml`)。于是「本地全绿 + CI
- 新增 `unverbatim()`:剥 `\\?\UNC\` → `\\`、剥 `\\?\` → 无前缀,其余原样返回;
**纯字符串处理,任何平台都可安全调用** —— 因此可以用 Windows 形态的字符串在
macOS / Linux 上直接守住 Windows 分支(铁律 4:跨平台差异不必真上 Windows 才能验)。
实现用 `to_str()` 而**不是** `to_string_lossy()`:后者会把非法字节替换成 U+FFFD
再重建路径,那是**损坏路径**(把「node 报 EISDIR」换成「指向不存在的路径」,
更难排查)。非 UTF-8 一律原样返回。
- 新增 `sidecar_command()`:**唯一**把路径交给 node 的地方,内部统一净化。
纪律只在这里守一次,单测直接断言它产出的 `program` / `args`(守「最终下发文本」,
不是「源码里出现过某个常量」)。
@@ -474,12 +477,16 @@ Windows 侧只有打包(`console-windows.yml`)。于是「本地全绿 + CI
与上述同源风险。
**回归**:
- 新增 3 条守卫 —— `unverbatim_去掉各类_windows_前缀`(盘符 / UNC / 普通 / POSIX / **幂等**)、
- 新增 4 条守卫 —— `unverbatim_去掉各类_windows_前缀`(盘符 / UNC / 普通 / POSIX / **幂等**)、
`unverbatim_对_resource_dir_拼接后的路径同样生效`(前缀在目录上、拼完仍在最前)、
**`交给_node_的路径必须去掉_verbatim_前缀`**(断言最终 `program` 与 `args` 文本)。
- **注入故障实测**:把 `sidecar_command` 里的 `unverbatim` 去掉 → 第三条如期变红,
报错正文即 `program 仍带 verbatim 前缀(示例:\\?\C:\…\node.exe)`;还原后 **sha256 逐字节一致**,复跑全绿。
- `pnpm test:rust` **17/17**(14 → +3)。
**`交给_node_的路径必须去掉_verbatim_前缀`**(断言最终 `program` 与 `args` 文本)、
`unverbatim_对非_utf8_的_verbatim_路径必须原样返回不得损坏`(`#[cfg(unix)]`,见上)。
- **注入故障实测(两轮)**:
① 把 `sidecar_command` 里的 `unverbatim` 去掉 → 第三条如期变红,
报错正文即 `program 仍带 verbatim 前缀(示例:\\?\C:\…\node.exe)`;
② 把 `unverbatim` 的实现退回 `to_string_lossy()` → 第四条如期变红。
两轮还原后均 **sha256 逐字节一致**,复跑全绿。
- `pnpm test:rust` **18/18**(14 → +4)。
> ⚠️ **本项属控制台上壳变更,不是被控端变更**,因此**不触发** `pnpm release patch`。
> 但要让用户真正拿到修复,**必须重新构建并分发控制台安装包**。
+39 -10
View File
@@ -75,17 +75,23 @@ use tokio::sync::oneshot;
///
/// 纯字符串处理,**任何平台都可安全调用**(非 Windows 上一般原样返回),
/// 因此可以用 Windows 形态的字符串在 macOS / Linux 上直接验证 Windows 分支。
///
/// ## 为什么用 `to_str()` 而不是 `to_string_lossy()`
/// 后者会把**非法字节替换成 U+FFFD** 再用替换后的串重建路径 —— 那是**损坏路径**,
/// 比不净化更糟:原本只是 node 会报 EISDIR,改完会变成「指向一个不存在的目录」,
/// 更难排查。故非 UTF-8 一律**原样返回**(Windows 上是含未配对代理项的路径;
/// Unix 上是含非法字节的文件名 —— 两者都不该被我们的「净化」悄悄改写)。
pub(crate) fn unverbatim(path: PathBuf) -> PathBuf {
let s = path.to_string_lossy();
if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
// \\?\UNC\server\share\x → \\server\share\x
return PathBuf::from(format!(r"\\{rest}"));
}
if let Some(rest) = s.strip_prefix(r"\\?\") {
// \\?\C:\x → C:\x
return PathBuf::from(rest);
}
path
let fixed: Option<PathBuf> = path.to_str().and_then(|s| {
let rest = s.strip_prefix(r"\\?\")?;
Some(match rest.strip_prefix("UNC\\") {
// \\?\UNC\server\share\x → \\server\share\x(UNC 必须还原成双反斜杠)
Some(unc) => PathBuf::from(format!(r"\\{unc}")),
// \\?\C:\x → C:\x
None => PathBuf::from(rest),
})
});
fixed.unwrap_or(path)
}
/// 构造「把 sidecar 拉起来」的那条命令 —— **唯一**把路径交给 node 的地方。
@@ -450,6 +456,29 @@ mod tests {
assert_eq!(unverbatim(once.clone()), once);
}
/// 反例守卫:**非 UTF-8 路径必须原样返回,不得被 lossy 重建损坏**。
///
/// ⚠️ 构造必须**同时**含 `\\?\` 前缀与非法字节,否则测不出差异 ——
/// 不带前缀时新旧实现都会落到「原样返回」那一步(`to_string_lossy` 也安全),
/// 只有在「剥前缀 → `PathBuf::from(rest)`」这一步才会把 U+FFFD 写进路径。
/// 若把实现退回 `to_string_lossy()`,本用例立刻变红:那是把「node 报 EISDIR」
/// 换成「指向一个不存在的路径」,更难排查 —— 宁可原样返回。
#[cfg(unix)]
#[test]
fn unverbatim_对非_utf8_的_verbatim_路径必须原样返回不得损坏() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
// 0xff / 0xfe 永不出现在合法 UTF-8 序列里;前缀形态按 Windows 写法给全
let raw = OsStr::from_bytes(b"\\\\?\\C:\\na-\xff\xfe-weird.mjs");
let p = PathBuf::from(raw);
assert!(p.to_str().is_none(), "构造出来的路径应确实是非 UTF-8");
assert_eq!(
unverbatim(p.clone()),
p,
"非 UTF-8 路径被改写了(很可能退回了 to_string_lossy)"
);
}
#[test]
fn unverbatim_对_resource_dir_拼接后的路径同样生效() {
// 真实形态就是「resource_dir().join("sidecar.mjs")」——前缀在目录上,拼完仍在最前