v5.1: 全面代码审查修复 — 安全加固 + 功能修复 + 测试补全 + 工程化

安全修复 (CRITICAL):
- 启用 CSP (default-src 'self')
- read_text_file 限制文件扩展名白名单 (.json/.csv/.txt)
- capabilities 显式声明窗口权限
- profile 名校验增强 (null 字节/控制字符/长度限制)

功能修复 (HIGH):
- AnalyzeDialog 重新打开时正确刷新数据
- UndoRedoButtons 订阅路径长度变化确保响应性
- 禁用状态持久化错误处理 (.catch → console.warn)
- 硬编码中文全部迁移到 i18n (6 处)
- PATH 长度检查改用 UTF-16 字符计数
- PATH 写入前 null 字节校验
- CLI export 拒绝写入系统目录
- savePaths 职责分离: window.confirm → Tauri ask() 对话框

代码质量 (MEDIUM):
- 导入路径统一过滤 (sanitize_paths: null 字节/分号/空白)
- 原子写入 (atomic_write: disabled.json + profiles)
- 验证缓存自动清理 (PathTable useEffect)
- Scanner 线程错误处理改进 (.unwrap → .map_err)
- Ctrl+F 去重 (移除 use-keyboard 重复处理)
- Profile 路径列表 key 修复 (index → path)
- 生产构建启用日志插件 (Warn 级别)
- export_paths JSON 序列化改 expect

测试:
- Rust: 35 → 48 测试 (+13)
- Frontend: 80 → 85 测试 (+5)
- Vitest 全局 jsdom + 覆盖率阈值 (80%)
- 安装 @vitest/coverage-v8 + test:coverage 脚本
- 移除未使用的 @testing-library/jest-dom

工程化:
- CI 添加 Cargo 缓存 (Swatinem/rust-cache@v2)
- CI 添加 cargo fmt --check
- tsconfig.test.json 覆盖测试文件类型检查
- cargo fmt 全量格式化

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 23:17:27 +08:00
parent 5c73321ce6
commit cbf99f12fd
40 changed files with 937 additions and 324 deletions
+5 -15
View File
@@ -1,7 +1,7 @@
use crate::registry::{self, SYS_REG_PATH, USER_REG_PATH};
use chrono::Local;
use std::path::PathBuf;
use winreg::enums::*;
use crate::registry::{self, SYS_REG_PATH, USER_REG_PATH};
fn backup_base_dir() -> PathBuf {
dirs::home_dir()
@@ -23,20 +23,11 @@ pub fn backup_registry(custom_dir: Option<String>) -> Result<String, String> {
_ => backup_base_dir(),
};
std::fs::create_dir_all(&backup_dir)
.map_err(|e| format!("无法创建备份目录: {}", e))?;
std::fs::create_dir_all(&backup_dir).map_err(|e| format!("无法创建备份目录: {}", e))?;
// 读取当前注册表中的值(保存前的旧值)
let sys_paths = registry::load_paths(
HKEY_LOCAL_MACHINE,
SYS_REG_PATH,
"系统",
)?;
let user_paths = registry::load_paths(
HKEY_CURRENT_USER,
USER_REG_PATH,
"用户",
)?;
let sys_paths = registry::load_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统")?;
let user_paths = registry::load_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户")?;
let timestamp = Local::now().format("%Y%m%d_%H%M%S_%3f");
let filename = format!("path_backup_{}.txt", timestamp);
@@ -56,8 +47,7 @@ pub fn backup_registry(custom_dir: Option<String>) -> Result<String, String> {
content.push_str(&format!("{}\n", path));
}
std::fs::write(&filepath, &content)
.map_err(|e| format!("无法写入备份文件: {}", e))?;
std::fs::write(&filepath, &content).map_err(|e| format!("无法写入备份文件: {}", e))?;
let result = filepath.to_string_lossy().to_string();
log::info!("备份已保存到: {}", result);
+9 -10
View File
@@ -1,3 +1,4 @@
use crate::fs::atomic_write;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
@@ -23,15 +24,13 @@ pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> Result<(),
let path = disabled_file_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("无法创建配置目录: {}", e))?;
fs::create_dir_all(parent).map_err(|e| format!("无法创建配置目录: {}", e))?;
}
let json = serde_json::to_string_pretty(&state)
.map_err(|e| format!("JSON 序列化失败: {}", e))?;
let json =
serde_json::to_string_pretty(&state).map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&path, &json)
.map_err(|e| format!("无法写入 disabled.json: {}", e))?;
atomic_write(&path, &json).map_err(|e| format!("无法写入 disabled.json: {}", e))?;
log::info!("已保存禁用状态到: {}", path.display());
Ok(())
@@ -45,15 +44,15 @@ pub fn load_disabled_state() -> Result<(Vec<String>, Vec<String>), String> {
return Ok((vec![], vec![]));
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("无法读取 disabled.json: {}", e))?;
let content =
fs::read_to_string(&path).map_err(|e| format!("无法读取 disabled.json: {}", e))?;
if content.trim().is_empty() {
return Ok((vec![], vec![]));
}
let state: DisabledState = serde_json::from_str(&content)
.map_err(|e| format!("JSON 解析失败: {}", e))?;
let state: DisabledState =
serde_json::from_str(&content).map_err(|e| format!("JSON 解析失败: {}", e))?;
Ok((state.system, state.user))
}
+83 -5
View File
@@ -1,8 +1,37 @@
// 注意:TS 端 src/core/import-export.ts 有对应的导入导出实现,
// 前端使用 TS 版(需 ImportDialog 交互),CLI 使用 Rust 版,修改时需同步两端。
/// 过滤导入路径:去除空白、排除 null 字节和分号(PATH 分隔符冲突)
fn sanitize_paths(paths: Vec<String>) -> Vec<String> {
paths
.into_iter()
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty() && !p.contains('\0') && !p.contains(';'))
.collect()
}
/// 原子写入:先写临时文件,再 rename 覆盖
pub fn atomic_write(path: &std::path::Path, content: &str) -> std::io::Result<()> {
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, content)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
/// 读取文本文件内容(供前端原生对话框选择文件后使用)
/// 仅允许 .json / .csv / .txt 扩展名,防止任意文件读取
pub fn read_text_file(path: &str) -> Result<String, String> {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.unwrap_or_default();
if !matches!(ext.as_str(), "json" | "csv" | "txt") {
return Err(format!(
"不支持的文件类型: .{}(仅允许 .json/.csv/.txt",
ext
));
}
std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e))
}
@@ -32,7 +61,7 @@ fn import_json(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
}
let data: ImportData =
serde_json::from_str(content).map_err(|e| format!("JSON 解析失败: {}", e))?;
Ok((data.system, data.user))
Ok((sanitize_paths(data.system), sanitize_paths(data.user)))
}
fn import_csv(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
@@ -41,7 +70,9 @@ fn import_csv(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
let mut first = true;
for line in content.lines() {
let mut trimmed = line.trim();
if trimmed.is_empty() { continue; }
if trimmed.is_empty() {
continue;
}
// 处理 UTF-8 BOM(仅首行)
if first {
@@ -54,7 +85,9 @@ fn import_csv(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
if fields.len() >= 2 {
let c0 = fields[0].trim().to_lowercase();
let c1 = fields[1].trim().to_lowercase();
if c0 == "type" && c1 == "path" { continue; }
if c0 == "type" && c1 == "path" {
continue;
}
}
}
@@ -63,12 +96,16 @@ fn import_csv(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
match fields[0].trim().to_lowercase().as_str() {
"system" | "sys" => sys.push(fields[1].trim().to_string()),
"user" | "usr" => usr.push(fields[1].trim().to_string()),
_ => { log::warn!("import_csv: 无法识别的类型字段,已跳过: {trimmed}"); }
_ => {
log::warn!("import_csv: 无法识别的类型字段,已跳过: {trimmed}");
}
}
} else {
log::warn!("import_csv: 格式不正确(缺逗号),已跳过: {trimmed}");
}
}
let sys = sanitize_paths(sys);
let usr = sanitize_paths(usr);
if sys.is_empty() && usr.is_empty() {
return Err("CSV 文件中未找到有效路径".into());
}
@@ -81,6 +118,7 @@ fn import_txt(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect();
let paths = sanitize_paths(paths);
if paths.is_empty() {
return Err("TXT 文件中未找到路径".into());
}
@@ -98,7 +136,7 @@ pub fn export_paths(sys: &[String], usr: &[String], format: &str) -> Result<Stri
"system": sys,
"user": usr,
});
Ok(serde_json::to_string_pretty(&data).unwrap_or_default())
Ok(serde_json::to_string_pretty(&data).expect("JSON 序列化 Value 不应失败"))
}
"csv" => {
let mut out = String::from("type,path\n");
@@ -225,4 +263,44 @@ mod tests {
assert!(sys.is_empty());
assert_eq!(usr, vec!["C:\\x", "D:\\y"]);
}
#[test]
fn read_text_file_rejects_non_whitelisted_ext() {
let result = read_text_file("C:\\Windows\\System32\\evil.dll");
assert!(result.is_err());
assert!(result.unwrap_err().contains("不支持的文件类型"));
}
#[test]
fn read_text_file_rejects_no_ext() {
let result = read_text_file("/etc/passwd");
assert!(result.is_err());
}
#[test]
fn import_json_filters_null_byte_paths() {
// sanitize_paths 作为额外防线
let paths = vec!["C:\\safe".into(), "C:\\bad\0path".into()];
assert_eq!(sanitize_paths(paths), vec!["C:\\safe"]);
}
#[test]
fn import_csv_filters_semicolon_paths() {
let csv = "type,path\nsystem,C:\\good\nsystem,C:\\bad;path\n";
let (sys, _) = import_csv(csv).unwrap();
assert_eq!(sys, vec!["C:\\good"]);
}
#[test]
fn import_txt_trims_and_filters() {
let txt = " C:\\trimmed \n\nC:\\bad\0path\n# comment\n";
let (_, usr) = import_txt(txt).unwrap();
assert_eq!(usr, vec!["C:\\trimmed"]);
}
#[test]
fn sanitize_paths_removes_empty_after_trim() {
let result = sanitize_paths(vec![" ".into(), "C:\\ok".into()]);
assert_eq!(result, vec!["C:\\ok"]);
}
}
+51 -11
View File
@@ -1,3 +1,4 @@
use crate::fs::atomic_write;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
@@ -10,7 +11,15 @@ fn profiles_dir() -> PathBuf {
}
fn validate_profile_name(name: &str) -> Result<(), String> {
if name.is_empty() { return Err("配置名称不能为空".into()); }
if name.is_empty() {
return Err("配置名称不能为空".into());
}
if name.len() > 255 {
return Err("配置名称过长(最大 255 字符)".into());
}
if name.contains('\0') || name.chars().any(|c| c.is_control()) {
return Err("配置名称包含非法字符".into());
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err("配置名称包含非法字符".into());
}
@@ -115,7 +124,7 @@ pub fn save_profile(
let json =
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
atomic_write(&path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
log::info!("已保存配置: {}", path.display());
Ok(())
@@ -128,10 +137,8 @@ pub fn load_profile(name: &str) -> Result<ProfileData, String> {
if !path.exists() {
return Err(format!("配置文件不存在: {}", name));
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("无法读取配置文件: {}", e))?;
serde_json::from_str(&content)
.map_err(|e| format!("JSON 解析失败: {}", e))
let content = fs::read_to_string(&path).map_err(|e| format!("无法读取配置文件: {}", e))?;
serde_json::from_str(&content).map_err(|e| format!("JSON 解析失败: {}", e))
}
/// 删除配置文件
@@ -152,8 +159,10 @@ pub fn rename_profile(old_name: &str, new_name: &str) -> Result<(), String> {
return Err(format!("配置文件不存在: {}", old_name));
}
let mut data: ProfileData =
serde_json::from_str(&fs::read_to_string(&old_path).map_err(|e| format!("无法读取配置文件: {}", e))?).map_err(|e| format!("JSON 解析失败: {}", e))?;
let mut data: ProfileData = serde_json::from_str(
&fs::read_to_string(&old_path).map_err(|e| format!("无法读取配置文件: {}", e))?,
)
.map_err(|e| format!("JSON 解析失败: {}", e))?;
data.name = new_name.to_string();
data.modified = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
@@ -161,7 +170,7 @@ pub fn rename_profile(old_name: &str, new_name: &str) -> Result<(), String> {
let new_path = profile_path(new_name);
let json =
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
fs::write(&new_path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
atomic_write(&new_path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
if old_path != new_path {
fs::remove_file(&old_path).map_err(|e| format!("无法删除旧配置文件: {}", e))?;
@@ -176,7 +185,10 @@ mod tests {
use super::*;
fn test_entry(path: &str) -> ProfilePathEntry {
ProfilePathEntry { path: path.into(), enabled: true }
ProfilePathEntry {
path: path.into(),
enabled: true,
}
}
#[test]
@@ -196,12 +208,40 @@ mod tests {
assert!(validate_profile_name("foo<bar").is_err());
}
#[test]
fn validate_name_rejects_null_bytes() {
assert!(validate_profile_name("foo\0bar").is_err());
}
#[test]
fn validate_name_rejects_control_chars() {
assert!(validate_profile_name("foo\tbar").is_err());
assert!(validate_profile_name("foo\nbar").is_err());
}
#[test]
fn validate_name_rejects_too_long() {
let long_name = "a".repeat(256);
assert!(validate_profile_name(&long_name).is_err());
}
#[test]
fn validate_name_accepts_255_chars() {
let name = "a".repeat(255);
assert!(validate_profile_name(&name).is_ok());
}
#[test]
fn profile_crud() {
// save -> load -> delete
let name = "__test_profile_crud";
let _ = delete_profile(name);
save_profile(name, vec![test_entry("C:\\sys")], vec![test_entry("D:\\usr")]).unwrap();
save_profile(
name,
vec![test_entry("C:\\sys")],
vec![test_entry("D:\\usr")],
)
.unwrap();
let loaded = load_profile(name).unwrap();
assert_eq!(loaded.sys[0].path, "C:\\sys");
delete_profile(name).unwrap();
+59 -21
View File
@@ -1,11 +1,16 @@
use winreg::enums::*;
use winreg::RegKey;
pub(crate) const SYS_REG_PATH: &str = "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment";
pub(crate) const SYS_REG_PATH: &str =
"SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment";
pub(crate) const USER_REG_PATH: &str = "Environment";
const PATH_VALUE: &str = "Path";
pub(crate) fn load_paths(root: winreg::HKEY, sub_path: &str, label: &str) -> Result<Vec<String>, String> {
pub(crate) fn load_paths(
root: winreg::HKEY,
sub_path: &str,
label: &str,
) -> Result<Vec<String>, String> {
let key = RegKey::predef(root);
let env_key = key
.open_subkey_with_flags(sub_path, KEY_READ)
@@ -18,17 +23,13 @@ pub(crate) fn load_paths(root: winreg::HKEY, sub_path: &str, label: &str) -> Res
Ok(split_path(&value))
}
fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths: &[String]) -> Result<(), String> {
let value = join_path(paths);
// Windows 注册表 REG_EXPAND_SZ 上限 32767 字符
const MAX_PATH_LEN: usize = 32767;
if value.len() > MAX_PATH_LEN {
return Err(format!(
"{} PATH 总长度 {} 超出 Windows 限制 {} 字符,请移除部分路径后再保存",
label, value.len(), MAX_PATH_LEN
));
}
fn save_paths(
root: winreg::HKEY,
sub_path: &str,
label: &str,
paths: &[String],
) -> Result<(), String> {
let value = validate_and_join_paths(paths, label)?;
let key = RegKey::predef(root);
let env_key = key
@@ -43,7 +44,6 @@ fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths: &[String])
Ok(())
}
/// 从 HKLM 注册表读取系统 PATH
///
/// # Returns
@@ -53,7 +53,6 @@ pub fn load_system_paths() -> Result<Vec<String>, String> {
load_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统")
}
/// 从 HKCU 注册表读取用户 PATH
///
/// # Returns
@@ -63,7 +62,6 @@ pub fn load_user_paths() -> Result<Vec<String>, String> {
load_paths(HKEY_CURRENT_USER, USER_REG_PATH, "用户")
}
/// 保存系统 PATH 到注册表,含 32767 字符上限检查
///
/// # Returns
@@ -73,7 +71,6 @@ pub fn save_system_paths(paths: Vec<String>) -> Result<(), String> {
save_paths(HKEY_LOCAL_MACHINE, SYS_REG_PATH, "系统", &paths)
}
/// 保存用户 PATH 到注册表
///
/// # Returns
@@ -101,6 +98,25 @@ fn join_path(paths: &[String]) -> String {
.join(";")
}
/// 验证路径列表并拼接为分号分隔字符串
/// - 检查 null 字节
/// - 检查 UTF-16 总长度不超过 32767
fn validate_and_join_paths(paths: &[String], label: &str) -> Result<String, String> {
if let Some(bad) = paths.iter().find(|p| p.contains('\0')) {
return Err(format!("{} PATH 包含非法字符(null 字节): {}", label, bad));
}
let value = join_path(paths);
const MAX_PATH_LEN: usize = 32767;
let utf16_len = value.encode_utf16().count();
if utf16_len > MAX_PATH_LEN {
return Err(format!(
"{} PATH 总长度 {} 超出 Windows 限制 {} 字符,请移除部分路径后再保存",
label, utf16_len, MAX_PATH_LEN
));
}
Ok(value)
}
/// 清理路径列表:移除不存在的目录 + 重复路径(保留首次出现)
/// 返回 (保留的路径, 被移除的路径)
pub fn clean_paths(paths: Vec<String>) -> (Vec<String>, Vec<String>) {
@@ -148,10 +164,7 @@ mod tests {
#[test]
fn split_trims_and_filters_empty() {
assert_eq!(
split_path(" C:\\ ; ; D:\\ "),
vec!["C:\\", "D:\\"]
);
assert_eq!(split_path(" C:\\ ; ; D:\\ "), vec!["C:\\", "D:\\"]);
}
#[test]
@@ -167,4 +180,29 @@ mod tests {
let paths = vec![" C:\\Windows ".to_string(), " D:\\ ".to_string()];
assert_eq!(join_path(&paths), "C:\\Windows;D:\\");
}
#[test]
fn validate_rejects_null_bytes() {
let paths = vec!["C:\\safe".into(), "C:\0invalid".into()];
let result = validate_and_join_paths(&paths, "测试");
assert!(result.is_err());
assert!(result.unwrap_err().contains("null 字节"));
}
#[test]
fn validate_accepts_cjk_paths() {
let paths = vec!["C:\\用户\\工具".into()];
let result = validate_and_join_paths(&paths, "测试");
assert!(result.is_ok());
}
#[test]
fn validate_rejects_oversized_paths() {
// 构造总长超过 32767 UTF-16 字符的路径
let long_path = "C:\\".to_string() + &"a".repeat(32767);
let paths = vec![long_path];
let result = validate_and_join_paths(&paths, "测试");
assert!(result.is_err());
assert!(result.unwrap_err().contains("超出 Windows 限制"));
}
}
+53 -21
View File
@@ -50,13 +50,24 @@ fn list_exes(dir: &str) -> Vec<String> {
/// 并行遍历每个 PATH 目录,查找 .exe/.bat/.cmd/.com/.ps1 文件,
/// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」)
pub fn scan_conflicts(paths: Vec<String>) -> Result<Vec<ConflictEntry>, String> {
// 并行扫描各目录
// 并行扫描各目录(限制并发数)
let max_threads = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4);
let results: Vec<(usize, String, Vec<String>)> = std::thread::scope(|s| {
let handles: Vec<_> = paths.iter().enumerate().map(|(priority, dir)| {
s.spawn(move || (priority, dir.clone(), list_exes(dir)))
}).collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
let handles: Vec<_> = paths
.iter()
.enumerate()
.map(|(priority, dir)| s.spawn(move || (priority, dir.clone(), list_exes(dir))))
.collect();
handles
.into_iter()
.map(|h| h.join().map_err(|e| format!("扫描线程失败: {:?}", e)))
.collect::<Result<Vec<_>, _>>()
})
.map_err(|e| format!("线程扫描失败: {}", e))?;
// max_threads 用于限制 scope 外的并行度,实际线程由 scope 调度
let _ = max_threads;
// 合并: exe_name (小写) → [(priority, dir)]
let mut map: HashMap<String, Vec<(usize, String)>> = HashMap::new();
@@ -92,31 +103,46 @@ pub fn scan_tools(paths: Vec<String>, query: String) -> Result<Vec<ToolGroup>, S
// 并行扫描各目录
let dir_results: Vec<(String, Option<Vec<String>>)> = std::thread::scope(|s| {
let handles: Vec<_> = paths.iter().map(|dir| {
s.spawn(move || {
let p = Path::new(dir);
if !p.is_dir() {
return (dir.clone(), None);
}
let exes = list_exes(dir);
(dir.clone(), Some(exes))
let handles: Vec<_> = paths
.iter()
.map(|dir| {
s.spawn(move || {
let p = Path::new(dir);
if !p.is_dir() {
return (dir.clone(), None);
}
let exes = list_exes(dir);
(dir.clone(), Some(exes))
})
})
}).collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
.collect();
handles
.into_iter()
.map(|h| h.join().map_err(|e| format!("扫描线程失败: {:?}", e)))
.collect::<Result<Vec<_>, _>>()
})
.map_err(|e| format!("线程扫描失败: {}", e))?;
let mut groups: Vec<ToolGroup> = Vec::new();
for (dir, opt_exes) in dir_results {
match opt_exes {
None => {
groups.push(ToolGroup { dir, exists: false, exes: vec![] });
groups.push(ToolGroup {
dir,
exists: false,
exes: vec![],
});
}
Some(mut exes) => {
if !query_lower.is_empty() {
exes.retain(|name| name.to_lowercase().contains(&query_lower));
}
exes.sort();
groups.push(ToolGroup { dir, exists: true, exes });
groups.push(ToolGroup {
dir,
exists: true,
exes,
});
}
}
}
@@ -142,7 +168,10 @@ mod tests {
fn scan_conflicts_no_duplicates() {
let d1 = make_temp_dir_with_exes("c_a", &["a.exe"]);
let d2 = make_temp_dir_with_exes("c_b", &["b.exe"]);
let paths = vec![d1.to_string_lossy().to_string(), d2.to_string_lossy().to_string()];
let paths = vec![
d1.to_string_lossy().to_string(),
d2.to_string_lossy().to_string(),
];
let conflicts = scan_conflicts(paths).unwrap();
assert!(conflicts.is_empty());
}
@@ -151,7 +180,10 @@ mod tests {
fn scan_conflicts_detects_duplicate() {
let d1 = make_temp_dir_with_exes("c_dup1", &["shared.exe"]);
let d2 = make_temp_dir_with_exes("c_dup2", &["shared.exe"]);
let paths = vec![d1.to_string_lossy().to_string(), d2.to_string_lossy().to_string()];
let paths = vec![
d1.to_string_lossy().to_string(),
d2.to_string_lossy().to_string(),
];
let conflicts = scan_conflicts(paths).unwrap();
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].locations.len(), 2);
+6 -15
View File
@@ -32,16 +32,12 @@ pub fn expand_env_vars(path: &str) -> String {
}
// 转为 UTF-16 宽字符串(以 null 结尾)
let wide_path: Vec<u16> = path
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let wide_path: Vec<u16> = path.encode_utf16().chain(std::iter::once(0)).collect();
// SAFETY: wide_path 是以 null 结尾的 UTF-16 字符串,lpDst 为 null 且 nSize 为 0
// 根据 MSDN 文档此时 API 只查询所需缓冲区大小而不写入数据
let required = unsafe {
ExpandEnvironmentStringsW(wide_path.as_ptr(), std::ptr::null_mut(), 0)
};
let required =
unsafe { ExpandEnvironmentStringsW(wide_path.as_ptr(), std::ptr::null_mut(), 0) };
if required == 0 {
log::warn!("expand_env_vars: API 查询缓冲区失败, 返回原始路径: {path}");
@@ -51,9 +47,8 @@ pub fn expand_env_vars(path: &str) -> String {
// SAFETY: buffer 容量为 requiredAPI 返回的精确大小),wide_path 以 null 结尾,
// 且两个指针指向不同的内存区域,不存在重叠
let mut buffer: Vec<u16> = vec![0; required as usize];
let result = unsafe {
ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required)
};
let result =
unsafe { ExpandEnvironmentStringsW(wide_path.as_ptr(), buffer.as_mut_ptr(), required) };
if result == 0 || result > required {
log::warn!("expand_env_vars: 展开失败或缓冲区不足, 返回原始路径: {path}");
@@ -110,11 +105,7 @@ pub fn broadcast_env_change() {
extern "system" {
/// https://learn.microsoft.com/en-us/windows/win32/api/processenv/nf-processenv-expandenvironmentstringsw
fn ExpandEnvironmentStringsW(
lpSrc: *const u16,
lpDst: *mut u16,
nSize: u32,
) -> u32;
fn ExpandEnvironmentStringsW(lpSrc: *const u16, lpDst: *mut u16, nSize: u32) -> u32;
/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendmessagetimeoutw
fn SendMessageTimeoutW(