mirror of
https://github.com/LHY0125/PathEditor.git
synced 2026-06-29 09:55:56 +08:00
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:
+5
-15
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 容量为 required(API 返回的精确大小),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(
|
||||
|
||||
Reference in New Issue
Block a user