diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b0cf2a..9f64568 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`。 > 但要让用户真正拿到修复,**必须重新构建并分发控制台安装包**。 diff --git a/apps/console/src-tauri/src/sidecar.rs b/apps/console/src-tauri/src/sidecar.rs index 4f98179..ce33ea8 100644 --- a/apps/console/src-tauri/src/sidecar.rs +++ b/apps/console/src-tauri/src/sidecar.rs @@ -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 = 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")」——前缀在目录上,拼完仍在最前