From 0bba69fd51f12bc9d16bb210626adb553f984328 Mon Sep 17 00:00:00 2001 From: CNWei Date: Thu, 10 Sep 2026 20:15:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=A4=9A=E7=89=88?= =?UTF-8?q?=E6=9C=AC=20target=20=E9=85=8D=E7=BD=AE=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=89=A7=E8=A1=8C=E5=89=8D=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 MirrorTarget.target 改造为 TargetConfig 枚举(支持 Static 与 Versioned) - TargetConfig 独立实现 FromLua,解耦反序列化与业务校验 - to_command 调整为显式接收执行路径,简化构建逻辑 - 增加未选择版本时的前置拦截与友好提示 --- mirror-core/src/error.rs | 39 +++++++++++ mirror-core/src/lib.rs | 4 +- mirror-core/src/model.rs | 127 ++++++++++++++++++++++++++++++++++ mirror-core/src/utils.rs | 60 ++++++++++++++++ mirror-core/src/validators.rs | 24 +++++++ mirror-shim/src/executor.rs | 24 +++++-- mirror-shim/src/mirror.rs | 22 +++--- mirror-shim/src/validators.rs | 77 +++++++++++++++++++-- 8 files changed, 355 insertions(+), 22 deletions(-) create mode 100644 mirror-core/src/model.rs diff --git a/mirror-core/src/error.rs b/mirror-core/src/error.rs index 8664855..93cb671 100644 --- a/mirror-core/src/error.rs +++ b/mirror-core/src/error.rs @@ -1,3 +1,4 @@ +use std::fmt; use std::path::PathBuf; use thiserror::Error; // 构造一个带上下文的 FromLua 转换错误,便于定位配置问题 @@ -89,3 +90,41 @@ pub enum Error { #[error("用户自定义扩展错误: {0}")] Other(#[source] Box), } + + +/// 将 MirrorTarget 构建为 Command 时可能触发的阻断性错误 +#[derive(Debug, Clone)] +pub enum TargetExecutionError { + /// 多版本工具未激活版本 (current == None) + UnselectedVersion { + tool: String, + }, + /// 目标可执行文件物理不存在 + NotFound { + tool: String, + path: PathBuf, + }, +} + +impl fmt::Display for TargetExecutionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnselectedVersion { tool } => { + write!( + f, + "工具 '{}' 当前未指定有效版本。\n提示: 请先运行 'mr use {}' 选择版本。", + tool, tool + ) + } + Self::NotFound { tool, path } => { + write!( + f, + "工具 '{}' 的目标程序不存在: {:?}", + tool, path + ) + } + } + } +} + +impl std::error::Error for TargetExecutionError {} \ No newline at end of file diff --git a/mirror-core/src/lib.rs b/mirror-core/src/lib.rs index 99d65b8..fbe9a2b 100644 --- a/mirror-core/src/lib.rs +++ b/mirror-core/src/lib.rs @@ -5,6 +5,7 @@ mod plugins; mod runtime; mod utils; mod validators; +mod model; pub use layout::Layout; // pub use mirror_shim::mirror::Mirror; @@ -15,4 +16,5 @@ pub use plugins::register_basics; pub use utils::normalize_path_for_lua; pub use validators::validate_command_name; // 把 Result 和 Error 提升到根命名空间,给外部带来极致便利 -pub use error::{Error, Result,validation_error,conversion_err}; +pub use error::{Error, Result,validation_error,conversion_err,TargetExecutionError}; +pub use model::TargetConfig; \ No newline at end of file diff --git a/mirror-core/src/model.rs b/mirror-core/src/model.rs new file mode 100644 index 0000000..7c2ca66 --- /dev/null +++ b/mirror-core/src/model.rs @@ -0,0 +1,127 @@ +use std::ffi::OsString; +use mlua::{FromLua, Lua, LuaString, Value}; +use std::path::PathBuf; +use crate::utils::lua_string_2_os_string; +use crate::validation_error; +use crate::validators::ensure_no_nul; + +/// 目标可执行文件的定位配置(支持静态路径与多版本 Table) +#[derive(Debug, Clone)] +pub enum TargetConfig{ + /// 静态单版本工具 + Static(PathBuf), + /// 多版本管理 + Versioned{ + /// 工具主根目录(供 mr use 扫描候选版本) + home:PathBuf, + /// 版本真相源文件名(如 ".version",可选,默认 ".version") + version_file:Option, + /// 当前激活版本的可执行文件完整路径(若未激活或不存在则为 None) + current:Option, + } +} +impl Default for TargetConfig { + fn default() -> Self { + TargetConfig::Static(PathBuf::default()) + } +} +impl TargetConfig { + /// 垫片程序(mirror-shim)专用快速通道:提取最终可执行路径 + /// + /// - Static: 返回自身路径 + /// - Versioned: 返回 current 路径(若未激活则返回 None) + + pub fn executable_path(&self) -> Option<&PathBuf> { + match self { + TargetConfig::Static(path) => Some(path), + TargetConfig::Versioned { current, .. } => current.as_ref(), + } + } + + /// 管理程序(mr-cli)专用辅助方法:判断是否支持多版本 + pub fn is_versioned(&self) -> bool { + matches!(self, TargetConfig::Versioned { .. }) + } +} + +impl FromLua for TargetConfig { + fn from_lua(value: Value, _lua: &Lua) -> mlua::Result { + match value { + // 分支 1:静态单版本工具 target = "..." + Value::String(s) => { + let os_str = lua_string_2_os_string(&s)?; + ensure_no_nul(&"target", &os_str)?; + Ok(TargetConfig::Static(PathBuf::from(os_str))) + } + + // 分支 2:多版本管理工具 target = { home = "...", ... } + Value::Table(table) => { + // 1. home (必填字段) + let home = match table.get::>("home")? { + Some(Value::String(s)) => { + let os_str = lua_string_2_os_string(&s)?; + ensure_no_nul(&"target.home", &os_str)?; + PathBuf::from(os_str) + } + None | Some(Value::Nil) => { + return Err(validation_error("多版本 target 表缺少必填字段 'home'")); + } + Some(other) => { + return Err(validation_error(format!( + "target.home 需为路径字符串,实际类型是 {}", + other.type_name() + ))); + } + }; + + // 2. version_file (选填字段,未填或 nil 时为 None) + let version_file = match table.get::>("version_file")? { + Some(Value::String(s)) => { + let os_str = lua_string_2_os_string(&s)?; + ensure_no_nul(&"target.version_file", &os_str)?; + Some(os_str.to_string_lossy().into_owned()) + } + None | Some(Value::Nil) => None, + Some(other) => { + return Err(validation_error(format!( + "target.version_file 需为文件名字符串,实际类型是 {}", + other.type_name() + ))); + } + }; + + // 3. current (选填/可为 nil,表示未激活或当前未指定版本) + let current = match table.get::>("current")? { + Some(Value::String(s)) => { + let os_str = lua_string_2_os_string(&s)?; + ensure_no_nul(&"target.current", &os_str)?; + Some(PathBuf::from(os_str)) + } + // Lua 侧 ver 为 nil 时,current 拼出的也是 nil,此时合法解析为 None + None | Some(Value::Nil) => None, + Some(other) => { + return Err(validation_error(format!( + "target.current 需为有效路径字符串或 nil,实际类型是 {}", + other.type_name() + ))); + } + }; + + Ok(TargetConfig::Versioned { + home, + version_file, + current, + }) + } + + Value::Nil => Err(validation_error( + "缺少必填字段 target(应为字符串路径或多版本配置表)", + )), + other => Err(validation_error(format!( + "target 需为有效路径字符串或多版本配置表,实际类型是 {}", + other.type_name() + ))), + } + } +} + diff --git a/mirror-core/src/utils.rs b/mirror-core/src/utils.rs index b4f9a0d..0a6e730 100644 --- a/mirror-core/src/utils.rs +++ b/mirror-core/src/utils.rs @@ -9,3 +9,63 @@ pub fn normalize_path_for_lua(path: &Path) -> String { simplified.to_string_lossy().replace('\\', "/") } +pub fn lua_string_2_os_string(s: &LuaString) -> mlua::Result { + let raw_bytes = &s.as_bytes(); + // 1. Unix 平台:直接零拷贝透传原始字节(无损支持任意编码) + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + Ok(OsStr::from_bytes(raw_bytes).to_os_string()) + } + // 2. Windows 平台:优先按 UTF-8 解码,失败则按当前系统本地代码页 (ANSI/GBK) 转换 + #[cfg(windows)] + { + // 先尝试标准的 UTF-8 + if let Ok(utf8_str) = std::str::from_utf8(raw_bytes) { + return Ok(OsString::from(utf8_str)); + } + + unsafe { + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::Globalization::{ + CP_ACP, MB_ERR_INVALID_CHARS, MultiByteToWideChar, + }; + + if raw_bytes.is_empty() { + return Ok(OsString::new()); + } + + let len = MultiByteToWideChar( + CP_ACP, + MB_ERR_INVALID_CHARS, + raw_bytes.as_ptr(), + raw_bytes.len() as i32, + std::ptr::null_mut(), + 0, + ); + + if len <= 0 { + // 构造一个带上下文的 FromLua 转换错误,便于定位配置问题 + return Err(mlua::Error::FromLuaConversionError { + from: "LuaString", + to: "OsString".to_string(), + message: Some( + format!("字符串{:?}包含无效或当前系统无法识别的编码字节", s).to_string(), + ), + }); + } + + let mut buf = vec![0u16; len as usize]; + MultiByteToWideChar( + CP_ACP, + MB_ERR_INVALID_CHARS, + raw_bytes.as_ptr(), + raw_bytes.len() as i32, + buf.as_mut_ptr(), + len, + ); + + Ok(OsString::from_wide(&buf)) + } + } +} \ No newline at end of file diff --git a/mirror-core/src/validators.rs b/mirror-core/src/validators.rs index 63ef75e..7b8186b 100644 --- a/mirror-core/src/validators.rs +++ b/mirror-core/src/validators.rs @@ -1,3 +1,4 @@ +use std::fmt; use crate::error::validation_error; use mlua::Value; @@ -39,3 +40,26 @@ pub fn validate_command_name(name: &Value) -> mlua::Result { } Ok(name_str) } +pub fn ensure_no_nul(context: &impl fmt::Display, os_str: &std::ffi::OsStr) -> mlua::Result<()> { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + if os_str.as_bytes().contains(&0) { + return Err(conversion_error(format!( + "{} 的值不能包含 NUL 字符", + context + ))); + } + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + if os_str.encode_wide().any(|c| c == 0) { + return Err(validation_error(format!( + "{} 的值不能包含 NUL 字符", + context + ))); + } + } + Ok(()) +} \ No newline at end of file diff --git a/mirror-shim/src/executor.rs b/mirror-shim/src/executor.rs index 4eff325..c6b741d 100644 --- a/mirror-shim/src/executor.rs +++ b/mirror-shim/src/executor.rs @@ -3,7 +3,7 @@ use crate::sys::{ ERROR_ELEVATION_REQUIRED, EXIT_FAILED_LOAD_SHIM, EXIT_FAILED_SPAWN_PROG, EXIT_FAILED_WAIT_PROG, EXIT_PROG_TERMINATED, execute_elevated, }; -use mirror_core::{Layout, LuaRuntime, register_basics}; +use mirror_core::{Layout, LuaRuntime, TargetConfig, TargetExecutionError, register_basics}; use std::env; use std::ffi::OsString; use std::process::exit; @@ -45,14 +45,26 @@ pub fn run(layout: &Layout) -> i32 { return EXIT_FAILED_LOAD_SHIM; } }; - + + // 1. 提取生效路径,缺失版本时直接抛出 UnselectedVersion + let target_path = match mr.target.executable_path() { + Some(path) => path, + None => { + warn!( + "[mirror] 工具 '{}' 当前未指定生效版本。\n提示: 请先运行 'mr use {}' 选择版本。", + target_name, target_name + ); + return EXIT_FAILED_LOAD_SHIM; + } + }; + // 3. 解析调用参数与代理 Mirror 配置 let calling_args: Vec<_> = env::args_os().skip(1).collect(); let combined_args = mr.args.iter().chain(calling_args.iter()); // 构建 Command:复用 ShimConfig::to_command(含 args/env 注入),避免重复逻辑 - let mut cmd = mr.to_command(combined_args); + let mut cmd = mr.to_command(target_path, combined_args); let mut child = match cmd.spawn() { Ok(v) => v, @@ -60,12 +72,12 @@ pub fn run(layout: &Layout) -> i32 { // 提权回退时需要完整参数:配置默认参数 + 调用方透传参数 let elevated_args: Vec = cmd.get_args().map(|s| s.to_os_string()).collect(); - exit(execute_elevated(&mr.target, &elevated_args, Some(&mr.env))) + exit(execute_elevated(target_path, &elevated_args, Some(&mr.env))) } Err(e) => { error!( "启动目标程序 [{}] 时发生错误: {}", - mr.target.to_string_lossy(), + target_path.to_string_lossy(), e ); return EXIT_FAILED_SPAWN_PROG; @@ -77,7 +89,7 @@ pub fn run(layout: &Layout) -> i32 { Err(e) => { error!( "等待目标程序 [{}] 执行完毕时发生错误: {}", - mr.target.to_string_lossy(), + target_path.to_string_lossy(), e ); EXIT_FAILED_WAIT_PROG diff --git a/mirror-shim/src/mirror.rs b/mirror-shim/src/mirror.rs index f88a58a..9ff8d10 100644 --- a/mirror-shim/src/mirror.rs +++ b/mirror-shim/src/mirror.rs @@ -1,13 +1,13 @@ use crate::validators::LuaValidator; use anyhow::{Context, Result, bail}; use mirror_core::validate_command_name; -use mirror_core::{Layout, LuaRuntime}; -use mirror_core::{conversion_err, validation_error}; +use mirror_core::{Layout, LuaRuntime, TargetConfig}; +use mirror_core::{TargetExecutionError, conversion_err, validation_error}; use mlua::{FromLua, Lua, Table, Value}; use std::collections::HashMap; use std::env; use std::ffi::OsString; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; use tracing::{debug, trace}; @@ -60,7 +60,7 @@ impl FromLua for MirrorRegistry { #[derive(Debug, Clone, Default)] pub struct MirrorTarget { /// 目标程序可执行文件路径 - pub target: PathBuf, + pub target: TargetConfig, /// 默认预设参数列表 pub args: Vec, /// 参数别名映射表(用于展开以 mr: 为前缀的别名) @@ -104,12 +104,13 @@ impl MirrorTarget { expanded_args } - pub fn to_command(&self, combined_args: I) -> Command + pub fn to_command(&self, target: P, combined_args: I) -> Command where + P: AsRef, I: IntoIterator, S: AsRef, { - let mut cmd = Command::new(&self.target); + let mut cmd = Command::new(target.as_ref()); // 2. 传入预打平的别名字典,查表并展开所有以 `mr:` 为前缀的别名 let final_args = self.resolve_args(combined_args); @@ -128,7 +129,7 @@ impl MirrorTarget { } impl FromLua for MirrorTarget { - fn from_lua(value: Value, _lua: &Lua) -> mlua::Result { + fn from_lua(value: Value, lua: &Lua) -> mlua::Result { // 脚本返回必须是一个 Table 变体 let table = match value { Value::Table(t) => t, @@ -143,9 +144,12 @@ impl FromLua for MirrorTarget { // 必填字段: target(严格限定为字符串,避免数字被 mlua 宽松转为字符串后掩盖错误) let target = match table.get::>("target")? { None | Some(Value::Nil) => { - return Err(validation_error("缺少必填字段 target(应为字符串路径)")); + return Err(validation_error( + "缺少必填字段 target(应为字符串路径或多版本配置表)", + )); } - Some(target_val) => LuaValidator::parse_target(&target_val)?, + // Some(target_val) => LuaValidator::parse_target(&target_val)?, + Some(target_val) => TargetConfig::from_lua(target_val, lua)?, }; // 可选字段: args(缺失或 nil 时默认空列表;保留空字符串参数,与提权路径的 "" 语义一致) diff --git a/mirror-shim/src/validators.rs b/mirror-shim/src/validators.rs index b484fb2..b7a37b3 100644 --- a/mirror-shim/src/validators.rs +++ b/mirror-shim/src/validators.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result, anyhow}; -use mirror_core::validation_error; +use mirror_core::{TargetConfig, validation_error}; use mlua::{LuaString, Table, Value}; use std::collections::{HashMap, HashSet}; use std::ffi::OsString; @@ -212,17 +212,79 @@ impl LuaValidator { } impl LuaValidator { /// 解析并校验 `target` - pub fn parse_target(value: &Value) -> mlua::Result { + pub fn parse_target(value: &Value) -> mlua::Result { let ctx = Self::Target; match value { Value::String(s) => { let os_str = lua_string_2_os_string(&s)?; Self::ensure_no_nul(&ctx, &os_str)?; - Ok(PathBuf::from(os_str)) + Ok(TargetConfig::Static(PathBuf::from(os_str))) } - Value::Nil => Err(validation_error("缺少必填字段 target(应为字符串路径)")), + // 分支 2:多版本动态工具 target = { home = "...", version_file = "...", current = "..." } + Value::Table(t) => { + // 1. 解析 home(必填) + let home_val = t.get::("home")?; + let home = match home_val { + Value::String(ref s) => { + let os_str = lua_string_2_os_string(s)?; + Self::ensure_no_nul(&"target.home", &os_str)?; + PathBuf::from(os_str) + } + Value::Nil => { + return Err(validation_error("多版本 target 表缺少必填字段 'home'")); + } + other => { + return Err(validation_error(format!( + "target.home 需为有效路径字符串,实际类型是 {}", + other.type_name() + ))); + } + }; + + // 2. 解析 version_file(选填,默认为 None,后续业务可 fallback 到 .version) + let version_file = match t.get::>("version_file")? { + Some(Value::String(ref s)) => { + let os_str = lua_string_2_os_string(s)?; + Self::ensure_no_nul(&"target.version_file", &os_str)?; + Some(os_str.to_string_lossy().into_owned()) + } + Some(Value::Nil) | None => None, + Some(other) => { + return Err(validation_error(format!( + "target.version_file 应为文件名字符串,实际类型是 {}", + other.type_name() + ))); + } + }; + + // 3. 解析 current(选填/可为 nil:未激活或找不到版本时允许为 None) + let current = match t.get::>("current")? { + Some(Value::String(ref s)) => { + let os_str = lua_string_2_os_string(s)?; + Self::ensure_no_nul(&"target.current", &os_str)?; + Some(PathBuf::from(os_str)) + } + // Lua 侧 ver 为 nil 时,current 计算结果即为 nil,合法放行 + Some(Value::Nil) | None => None, + Some(other) => { + return Err(validation_error(format!( + "target.current 需为有效路径字符串或 nil,实际类型是 {}", + other.type_name() + ))); + } + }; + + Ok(TargetConfig::Versioned { + home, + version_file, + current, + }) + } + Value::Nil => Err(validation_error( + "缺少必填字段 target(应为字符串路径或多版本配置表)", + )), other => Err(validation_error(format!( - "{} 需为有效的路径且类型必须是字符串,实际类型是 {}", + "{} 需为有效的路径且类型必须是字符串或多版本配置表,实际类型是 {}", ctx, other.type_name() ))), @@ -534,7 +596,10 @@ mod tests { "#, ) .unwrap(); - assert_eq!(cfg.target, PathBuf::from("C:/tools/git.exe")); + assert_eq!( + cfg.target.executable_path().unwrap().to_path_buf(), + PathBuf::from("C:/tools/git.exe") + ); // assert_eq!(cfg.args, vec!["--no-pager", "2"]); assert_eq!(cfg.env.get("HOME").unwrap().to_str(), Some("C:/tools/home")); // PATH 前缀来自配置,随后附加 get_env("PATH") 拆出的宿主 PATH 段