diff --git a/Cargo.lock b/Cargo.lock index 1f4aa55..516ac02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -319,6 +319,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -427,7 +436,6 @@ dependencies = [ name = "mirror-core" version = "0.1.0" dependencies = [ - "anyhow", "dunce", "mlua", "serde", @@ -460,8 +468,10 @@ checksum = "d96e5d00f19d8c46c71ceaced99593b90c31c57aa1fe2cb3e93a8b1698eedba9" dependencies = [ "bstr", "either", + "inventory", "libc", "mlua-sys", + "mlua_derive", "num-traits", "parking_lot", "rustc-hash", @@ -481,6 +491,17 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "mlua_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de1e4bdfaa4bb99d8c1449d8a34b0f19e649ef68eb5d4e8dbf1e9ec6f4a084" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -636,6 +657,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "scopeguard" version = "1.2.0" diff --git a/Cargo.toml b/Cargo.toml index 5c8eeff..48836e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ description = "A fast, safe Rust shim launcher for Scoop" [workspace.dependencies] anyhow = "1.0.104" -mlua = { version = "0.12.0", features = ["lua54", "vendored","send"] } +mlua = { version = "0.12.0", features = ["lua54", "vendored","send","macros"] } windows-sys = { version = "0.61.2", features = [ "Win32_Foundation", "Win32_System_Com", diff --git a/mirror-core/Cargo.toml b/mirror-core/Cargo.toml index 31adc53..0acea3d 100644 --- a/mirror-core/Cargo.toml +++ b/mirror-core/Cargo.toml @@ -4,7 +4,6 @@ version = { workspace = true } edition = { workspace = true } [dependencies] -anyhow = { workspace = true } mlua = { workspace = true } dunce = { workspace = true } tinyjson = { workspace = true } diff --git a/mirror-core/src/alias.rs b/mirror-core/src/args/alias.rs similarity index 72% rename from mirror-core/src/alias.rs rename to mirror-core/src/args/alias.rs index 1783753..732fc86 100644 --- a/mirror-core/src/alias.rs +++ b/mirror-core/src/args/alias.rs @@ -1,20 +1,11 @@ -use crate::validators::LuaValidator; -use anyhow::{Context, Result, bail}; -use crate::{conversion_err, validate_command_name, validation_error, TargetConfig}; -use mlua::{FromLua, Lua, Table, Value}; use std::collections::HashMap; -use std::env; use std::ffi::OsString; -use std::path::{Path, PathBuf}; -use std::process::Command; -use tracing::{debug, trace}; -use crate::model::MirrorTarget; /// 单个目标程序的代理规格配置 -impl MirrorTarget { + /// 1. 核心参数路由解析(Delegation 到 Spec 的路由逻辑) - pub fn resolve_args(&self, args: I) -> Vec + pub fn expand_aliases(aliases: &HashMap>, args: I) -> Vec where I: IntoIterator, S: AsRef, @@ -33,7 +24,7 @@ impl MirrorTarget { // 检查参数是否带有 mr: 前缀 if let Some(alias_key) = arg_str.strip_prefix("mr:") { // 如果在加载期打平好的字典中找到了对应的别名,直接展开追加 - if let Some(alias_values) = self.aliases.get(alias_key) { + if let Some(alias_values) = aliases.get(alias_key) { expanded_args.extend(alias_values.iter().cloned()); } else { // 如果找不到对应的别名,按原样参数追加 @@ -46,7 +37,3 @@ impl MirrorTarget { } expanded_args } - -} - - diff --git a/mirror-core/src/args/mod.rs b/mirror-core/src/args/mod.rs new file mode 100644 index 0000000..636cc96 --- /dev/null +++ b/mirror-core/src/args/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod alias; +pub(crate) mod tokens; \ No newline at end of file diff --git a/mirror-core/src/utils.rs b/mirror-core/src/args/tokens.rs similarity index 70% rename from mirror-core/src/utils.rs rename to mirror-core/src/args/tokens.rs index e265113..8cb8cb4 100644 --- a/mirror-core/src/utils.rs +++ b/mirror-core/src/args/tokens.rs @@ -1,76 +1,4 @@ -use mlua::LuaString; use std::ffi::OsString; -use std::path::Path; - -/// 将 Path 转换为适合 Lua 使用的安全字符串路径 -pub fn normalize_path_for_lua(path: &Path) -> String { - // 自动将 Windows UNC 规范路径转回传统路径 - let simplified = dunce::simplified(path); - 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)) - } - } -} - - /// 将输入的字符串按 Shell 规则切分为独立的 CLI 参数 Token /// - 自动过滤连续空格 @@ -230,4 +158,4 @@ mod tests { // 场景 8:未闭合引号的容错处理(会尽量追加到当前 Token 中) assert_tokens!("echo \"hello world", vec!["echo", "hello world"]); } -} +} \ No newline at end of file diff --git a/mirror-core/src/config/convert.rs b/mirror-core/src/config/convert.rs new file mode 100644 index 0000000..a1d6de9 --- /dev/null +++ b/mirror-core/src/config/convert.rs @@ -0,0 +1,89 @@ +use crate::{Error, Result}; +use mlua::LuaString; +use std::ffi::{OsStr, OsString}; +use std::fmt; + +/// Lua 字符串 -> OsString(Linux 直接透传字节,Windows 回退到系统代码页) +pub(crate) fn lua_string_2_os_string(s: &LuaString) -> 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(Error::EncodingError { + from: "LuaString", + to: "OsString".to_string(), + message: format!("字符串{:?}包含无效或当前系统无法识别的编码字节", s), + }); + } + + 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)) + } + } +} + +/// 跨平台检查 `OsStr` 中是否含 NUL 字符 +pub(crate) fn ensure_no_nul(context: &impl fmt::Display, os_str: &OsStr) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + if os_str.as_bytes().contains(&0) { + return Err(Error::ContainsNul(format!( + "{} 的值不能包含 NUL 字符", + context + ))); + } + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + if os_str.encode_wide().any(|c| c == 0) { + return Err(Error::ContainsNul(format!( + "{} 的值不能包含 NUL 字符", + context + ))); + } + } + Ok(()) +} diff --git a/mirror-core/src/config/dto.rs b/mirror-core/src/config/dto.rs new file mode 100644 index 0000000..81cc288 --- /dev/null +++ b/mirror-core/src/config/dto.rs @@ -0,0 +1,132 @@ +//! Lua 边界层:只负责把 Lua 值搬成 DTO,不做任何语义判断。 +//! +//! 这一层实现的是 mlua 的 `FromLua`,错误天然是 `mlua::Error`。 +//! 领域规则(必填字段、类型、编码、命令名、别名环)全部在 `model.rs` 的 +//! `TryFrom` 里,那里的错误统一是 `crate::Error`。 + +use super::model::{MirrorSpec, TargetSpec}; +use mlua::{FromLua, Lua, Table, Value}; + +/// 注册表 DTO:键值对原样保留,命令名合法性交给领域层判断。 +pub(crate) struct MirrorSpecDto { + pub entries: Vec<(Value, TargetSpecDto)>, +} + +impl FromLua for MirrorSpecDto { + fn from_lua(value: Value, _lua: &Lua) -> mlua::Result { + let root = value + .as_table() + .ok_or_else(|| mlua::Error::FromLuaConversionError { + from: value.type_name(), + to: "Table".into(), + message: Some("mirror 全局注册表顶层必须是 table".into()), + })?; + + let mut entries = Vec::new(); + for pair in root.pairs::() { + let (key, entry) = pair?; + let entry = entry + .as_table() + .ok_or_else(|| mlua::Error::FromLuaConversionError { + from: entry.type_name(), + to: "Table".into(), + message: Some(format!( + "目标工具 '{}' 的配置必须是 table,实际是 {}", + display_key(&key), + entry.type_name() + )), + })?; + entries.push((key, TargetSpecDto::from_table(&entry)?)); + } + Ok(Self { entries }) + } +} + +/// 单个目标工具配置的 DTO:字段原样保留,类型判断留给领域层。 +/// +/// 字段一律用 `Value`:Lua 里"字段不存在"和"字段是 nil"是同一件事, +/// 都用 `Value::Nil` 表示。用 `Option` 反而会有两种"缺失"写法。 +pub(crate) struct TargetSpecDto { + pub target: TargetDto, + pub args: Value, + pub env: Value, + pub aliases: Value, +} + +impl TargetSpecDto { + fn from_table(table: &Table) -> mlua::Result { + Ok(Self { + target: TargetDto::from_value(table.get::("target")?)?, + args: table.get::("args")?, + env: table.get::("env")?, + aliases: table.get::("aliases")?, + }) + } +} + +/// target 的"形状":标量还是多版本 table。 +/// +/// `Value::Nil`(字段没写)会落进 `Scalar`,由领域层决定它是错误还是默认值。 +/// 字符串能不能当路径、table 里 `home` 是否必填,也都留给领域层判断。 +pub(crate) enum TargetDto { + Scalar(Value), + Versioned { + home: Value, + version_file: Value, + current: Value, + }, +} + +impl TargetDto { + fn from_value(value: Value) -> mlua::Result { + match value { + Value::Table(table) => Ok(Self::Versioned { + home: table.get::("home")?, + version_file: table.get::("version_file")?, + current: table.get::("current")?, + }), + other => Ok(Self::Scalar(other)), + } + } +} + +/// 错误信息里显示注册表 key;非字符串 key 退化成类型名。 +fn display_key(key: &Value) -> String { + match key { + Value::String(s) => s + .to_str() + .map(|s| s.to_string()) + .unwrap_or_else(|_| "<非 UTF-8>".to_string()), + other => other.type_name().to_string(), + } +} + +// --------------------------------------------------------------------------- +// 兼容适配器 +// +// 消费方目前仍使用 `runtime.eval_script::(...)` 和 +// `MirrorTarget::from_lua(...)`,所以保留这两个薄适配器: +// 边界提取(DTO)→ 领域转换(TryFrom)→ 错误翻译(Into)。 +// 领域逻辑一行都不在这两个 impl 里。 +// --------------------------------------------------------------------------- + +impl FromLua for MirrorSpec { + fn from_lua(value: Value, lua: &Lua) -> mlua::Result { + let dto = MirrorSpecDto::from_lua(value, lua)?; + MirrorSpec::try_from(dto).map_err(Into::into) + } +} + +impl FromLua for TargetSpec { + fn from_lua(value: Value, _lua: &Lua) -> mlua::Result { + let table = value + .as_table() + .ok_or_else(|| mlua::Error::FromLuaConversionError { + from: value.type_name(), + to: "Table".into(), + message: Some("目标工具配置必须是 table".into()), + })?; + let dto = TargetSpecDto::from_table(&table)?; + TargetSpec::try_from(dto).map_err(Into::into) + } +} diff --git a/mirror-core/src/config/mod.rs b/mirror-core/src/config/mod.rs new file mode 100644 index 0000000..3dc59e5 --- /dev/null +++ b/mirror-core/src/config/mod.rs @@ -0,0 +1,26 @@ +pub(crate) mod convert; +pub(crate) mod dto; +pub(crate) mod model; +pub(crate) mod naming; +pub(crate) mod values; + +pub use model::{MirrorSpec, Target, TargetSpec}; +pub use naming::parse_command_name; + +// 兼容旧名:mirror-shim / mirror-cli 目前仍按这些名字引用。 +// 等消费方迁移到 MirrorSpec / TargetSpec / Target 后可以删掉。 +pub type MirrorRegistry = MirrorSpec; +pub type MirrorTarget = TargetSpec; +pub type TargetConfig = Target; + +use crate::{LuaRuntime, Result}; +use std::path::Path; + +/// 配置加载入口:边界提取(DTO)→ 领域转换(TryFrom)。 +/// +/// 调用方拿到的错误全部是 `crate::Error`;DTO 与 mlua 的错误类型 +/// 都被关在这个函数内部。 +pub fn load_mirror_spec(runtime: &LuaRuntime, path: impl AsRef) -> Result { + let dto: dto::MirrorSpecDto = runtime.eval_script(path)?; + MirrorSpec::try_from(dto) +} diff --git a/mirror-core/src/config/model.rs b/mirror-core/src/config/model.rs new file mode 100644 index 0000000..54bae6c --- /dev/null +++ b/mirror-core/src/config/model.rs @@ -0,0 +1,268 @@ +//! 领域模型:只描述"配置应该长什么样",不实现 mlua 的 `FromLua`。 +//! +//! 两阶段加载: +//! 1. `config::dto` 把 Lua 值搬成 DTO(边界,错误是 mlua 形状) +//! 2. 本文件的 `TryFrom` 做语义校验与类型转换(错误全部是 `crate::Error`) + +use crate::config::convert::{ensure_no_nul, lua_string_2_os_string}; +use crate::config::dto::{MirrorSpecDto, TargetDto, TargetSpecDto}; +use crate::config::values::{parse_aliases, parse_args, parse_env}; +use crate::{Error, Result, parse_command_name}; +use mlua::Value; +use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; +use std::path::PathBuf; + +pub struct MirrorSpec { + pub targets: HashMap, +} + +impl TryFrom for MirrorSpec { + type Error = Error; + + fn try_from(dto: MirrorSpecDto) -> Result { + let mut targets = HashMap::with_capacity(dto.entries.len()); + for (key, tool) in dto.entries { + let name = parse_command_name(&key)?; + targets.insert(name, TargetSpec::try_from(tool)?); + } + Ok(Self { targets }) + } +} + +#[derive(Debug, Clone, Default)] +pub struct TargetSpec { + /// 目标程序可执行文件路径 + pub target: Target, + /// 默认预设参数列表 + pub args: Vec, + /// 参数别名映射表(用于展开以 mr: 为前缀的别名) + pub aliases: HashMap>, + /// 注入/覆盖的环境变量 + pub env: HashMap, +} + +impl TargetSpec { + pub fn resolve_args(&self, args: I) -> Vec + where + I: IntoIterator, + S: AsRef, + { + crate::args::alias::expand_aliases(&self.aliases, args) + } +} + +impl TryFrom for TargetSpec { + type Error = Error; + + fn try_from(dto: TargetSpecDto) -> Result { + let target = Target::try_from(dto.target)?; + + let args = match dto.args { + Value::Nil => Vec::new(), + value => parse_args(&value)?, + }; + let aliases = match dto.aliases { + Value::Nil => HashMap::new(), + value => parse_aliases(&value)?, + }; + let env = match dto.env { + Value::Nil => HashMap::new(), + value => parse_env(&value)?, + }; + + Ok(Self { + target, + args, + aliases, + env, + }) + } +} + +/// 目标可执行文件的定位配置(支持静态路径与多版本 Table) +#[derive(Debug, Clone)] +pub enum Target { + /// 静态单版本工具 + Static(PathBuf), + /// 多版本管理 + Versioned { + /// 工具主根目录(供 mr use 扫描候选版本) + home: PathBuf, + /// 版本真相源文件名(如 ".version",可选,默认 ".version") + version_file: Option, + /// 当前激活版本的可执行文件完整路径(若未激活或不存在则为 None) + current: Option, + }, +} + +impl Default for Target { + fn default() -> Self { + Target::Static(PathBuf::default()) + } +} + +impl Target { + /// 垫片程序(mirror-shim)专用快速通道:提取最终可执行路径 + /// + /// - Static: 返回自身路径 + /// - Versioned: 返回 current 路径(若未激活则返回 None) + pub fn executable_path(&self) -> Option<&PathBuf> { + match self { + Target::Static(path) => Some(path), + Target::Versioned { current, .. } => current.as_ref(), + } + } + + /// 管理程序(mr-cli)专用辅助方法:判断是否支持多版本 + pub fn is_versioned(&self) -> bool { + matches!(self, Target::Versioned { .. }) + } +} + +impl TryFrom for Target { + type Error = Error; + + fn try_from(dto: TargetDto) -> Result { + match dto { + TargetDto::Scalar(Value::String(s)) => { + let os_str = lua_string_2_os_string(&s)?; + ensure_no_nul(&"target", &os_str)?; + Ok(Target::Static(PathBuf::from(os_str))) + } + TargetDto::Scalar(Value::Nil) => Err(Error::MissingField { + field: "target(应为字符串路径或多版本配置表)".into(), + }), + TargetDto::Scalar(other) => Err(Error::TypeMismatch { + field: "target".into(), + expected: "有效路径字符串或多版本配置表", + actual: other.type_name(), + }), + TargetDto::Versioned { + home, + version_file, + current, + } => { + let home = required_path(home, "home(多版本 target 表)", "target.home")?; + let version_file = optional_string(version_file, "target.version_file")?; + let current = optional_path(current, "target.current")?; + + Ok(Target::Versioned { + home, + version_file, + current, + }) + } + } + } +} + +/// 必填路径字段:缺失/nil 报 `MissingField`,类型不符报 `TypeMismatch` +fn required_path(value: Value, field: &str, context: &str) -> Result { + match value { + Value::Nil => Err(Error::MissingField { + field: field.to_string(), + }), + Value::String(s) => { + let os_str = lua_string_2_os_string(&s)?; + ensure_no_nul(&context, &os_str)?; + Ok(PathBuf::from(os_str)) + } + other => Err(Error::TypeMismatch { + field: context.to_string(), + expected: "路径字符串", + actual: other.type_name(), + }), + } +} + +/// 可选字符串字段:缺失/nil 视为 None +fn optional_string(value: Value, context: &str) -> Result> { + match value { + Value::Nil => Ok(None), + Value::String(s) => { + let os_str = lua_string_2_os_string(&s)?; + ensure_no_nul(&context, &os_str)?; + Ok(Some(os_str.to_string_lossy().into_owned())) + } + other => Err(Error::TypeMismatch { + field: context.to_string(), + expected: "字符串 (String)", + actual: other.type_name(), + }), + } +} + +/// 可选路径字段:缺失/nil 视为 None +fn optional_path(value: Value, context: &str) -> Result> { + match value { + Value::Nil => Ok(None), + Value::String(s) => { + let os_str = lua_string_2_os_string(&s)?; + ensure_no_nul(&context, &os_str)?; + Ok(Some(PathBuf::from(os_str))) + } + other => Err(Error::TypeMismatch { + field: context.to_string(), + expected: "有效路径字符串 (LuaString) 或 nil", + actual: other.type_name(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 领域转换直接产出 crate::Error,不再被 mlua 包一层 + #[test] + fn scalar_target_with_wrong_type_is_domain_error() { + let err = Target::try_from(TargetDto::Scalar(Value::Integer(42))).unwrap_err(); + match err { + Error::TypeMismatch { field, actual, .. } => { + assert_eq!(field, "target"); + assert_eq!(actual, "integer"); + } + other => panic!("unexpected error: {other}"), + } + } + + #[test] + fn versioned_target_requires_home() { + let dto = TargetDto::Versioned { + home: Value::Nil, + version_file: Value::Nil, + current: Value::Nil, + }; + let err = Target::try_from(dto).unwrap_err(); + assert!(matches!(err, Error::MissingField { field } if field.starts_with("home"))); + } + + /// 字段不存在和字段是 nil,在 Lua 里是同一件事 + #[test] + fn nil_target_is_missing_field() { + let err = Target::try_from(TargetDto::Scalar(Value::Nil)).unwrap_err(); + assert!(matches!(err, Error::MissingField { .. })); + } + + #[test] + fn optional_field_type_is_checked() { + let err = optional_string(Value::Integer(1), "target.version_file").unwrap_err(); + assert!(matches!(err, Error::TypeMismatch { field, .. } if field == "target.version_file")); + + let err = optional_path(Value::Integer(1), "target.current").unwrap_err(); + assert!(matches!(err, Error::TypeMismatch { field, .. } if field == "target.current")); + } + + #[test] + fn missing_target_is_missing_field() { + let dto = TargetSpecDto { + target: TargetDto::Scalar(Value::Nil), + args: Value::Nil, + env: Value::Nil, + aliases: Value::Nil, + }; + let err = TargetSpec::try_from(dto).unwrap_err(); + assert!(matches!(err, Error::MissingField { .. })); + } +} diff --git a/mirror-core/src/config/naming.rs b/mirror-core/src/config/naming.rs new file mode 100644 index 0000000..f0bcadc --- /dev/null +++ b/mirror-core/src/config/naming.rs @@ -0,0 +1,146 @@ +//! 名称规则:注册表键(命令名)、环境变量名、别名键。 +//! +//! 三类名称的规则不同: +//! - 命令名:非空、不能含空白字符、不能含 `=`、不能含 NUL +//! - 环境变量名 / 别名:非空、不能含 `=`、不能含 NUL(允许空格) + +use crate::{Error, NameKind, Result}; +use mlua::Value; + +/// 校验注册表键(命令名) +pub fn parse_command_name(name: &Value) -> Result { + let name = parse_string(name, NameKind::Command)?; + + if name.trim().is_empty() { + return Err(invalid(NameKind::Command, "不能为空字符串")); + } + if name.contains(|c: char| c.is_whitespace()) { + return Err(invalid( + NameKind::Command, + format!("[{name}] 不能包含空格或空白字符"), + )); + } + if name.contains('=') { + return Err(invalid(NameKind::Command, format!("[{name}] 不能包含 '='"))); + } + if name.contains('\0') { + return Err(invalid( + NameKind::Command, + format!("[{name}] 不能包含 NUL 字符"), + )); + } + Ok(name) +} + +/// 校验环境变量名 / 别名键 +pub(crate) fn parse_name(kind: NameKind, name: &Value) -> Result { + let name = parse_string(name, kind)?; + + if name.is_empty() { + return Err(invalid(kind, "不能为空")); + } + if name.contains('=') { + return Err(invalid(kind, format!("[{name}] 不能包含 '='"))); + } + if name.contains('\0') { + return Err(invalid(kind, format!("[{name}] 不能包含 NUL 字符"))); + } + Ok(name) +} + +/// 取字符串值;类型不对报 `TypeMismatch`,字节不是 UTF-8 报 `EncodingError` +fn parse_string(value: &Value, kind: NameKind) -> Result { + match value { + Value::String(s) => { + s.to_str() + .map(|text| text.to_string()) + .map_err(|e| Error::EncodingError { + from: "LuaString", + to: "String".to_string(), + message: format!("{kind}不是合法的 UTF-8 字符串: {e}"), + }) + } + other => Err(Error::TypeMismatch { + field: kind.to_string(), + expected: "字符串 (String)", + actual: other.type_name(), + }), + } +} + +fn invalid(kind: NameKind, reason: impl Into) -> Error { + Error::InvalidName { + kind, + reason: reason.into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mlua::Lua; + + fn string(lua: &Lua, text: &str) -> Value { + Value::String(lua.create_string(text).unwrap()) + } + + #[test] + fn command_name_accepts_plain_name() { + let lua = Lua::new(); + assert_eq!(parse_command_name(&string(&lua, "git")).unwrap(), "git"); + } + + #[test] + fn command_name_rejects_whitespace_and_equals() { + let lua = Lua::new(); + for bad in ["git status", "git=1", " ", ""] { + let err = parse_command_name(&string(&lua, bad)).unwrap_err(); + assert!( + matches!( + err, + Error::InvalidName { + kind: NameKind::Command, + .. + } + ), + "unexpected error for {bad:?}: {err}" + ); + } + } + + #[test] + fn command_name_type_error_is_type_mismatch() { + let err = parse_command_name(&Value::Integer(1)).unwrap_err(); + assert!(matches!(err, Error::TypeMismatch { .. })); + } + + #[test] + fn env_name_allows_space_but_rejects_equals() { + let lua = Lua::new(); + assert_eq!( + parse_name(NameKind::EnvVar, &string(&lua, "MY VAR")).unwrap(), + "MY VAR" + ); + let err = parse_name(NameKind::EnvVar, &string(&lua, "FOO=1")).unwrap_err(); + assert!(matches!( + err, + Error::InvalidName { + kind: NameKind::EnvVar, + .. + } + )); + } + + #[test] + fn alias_name_rejects_empty() { + let lua = Lua::new(); + let err = parse_name(NameKind::Alias, &string(&lua, "")).unwrap_err(); + assert!(matches!( + err, + Error::InvalidName { + kind: NameKind::Alias, + .. + } + )); + } +} diff --git a/mirror-core/src/config/values/aliases.rs b/mirror-core/src/config/values/aliases.rs new file mode 100644 index 0000000..c6357f3 --- /dev/null +++ b/mirror-core/src/config/values/aliases.rs @@ -0,0 +1,172 @@ +use super::as_table; +use super::scalar::{ScalarMode, to_os_strings}; +use super::sequence::as_strict_sequence; +use crate::config::naming::parse_name; +use crate::{Error, NameKind, Result}; +use mlua::Value; +use std::collections::HashMap; +use std::ffi::OsString; + +const CONTEXT: &str = "命令行别名 (aliases)"; +/// 数组嵌套上限,防止畸形配置打爆调用栈 +const MAX_DEPTH: usize = 16; +/// `mr:` 引用链的最大深度 +const MAX_ALIAS_DEPTH: usize = 32; + +/// `aliases = { name = value }` → 已展开的别名表。 +/// +/// value 是"命令行片段":字符串按 shell 规则分词,数组逐项处理。 +/// `mr:other` 在加载期递归展开;引用不存在的别名时原样保留;循环引用报错。 +pub(crate) fn parse_aliases(value: &Value) -> Result>> { + let table = as_table(value, CONTEXT)?; + let mut raw: HashMap> = HashMap::with_capacity(table.raw_len().min(128)); + + for pair in table.pairs::() { + let (raw_key, raw_value) = pair?; + let name = parse_name(NameKind::Alias, &raw_key)?; + let context = format!("别名 '{name}'"); + + let mut parts = Vec::new(); + flatten(&raw_value, &context, 0, &mut parts)?; + raw.insert(name, parts); + } + + expand_graph(&raw) +} + +/// 递归展平标量与嵌套数组,字符串按 shell 规则分词 +fn flatten(value: &Value, context: &str, depth: usize, out: &mut Vec) -> Result<()> { + if depth > MAX_DEPTH { + return Err(Error::ParseError(format!( + "{context} 的数组嵌套层级过深(超过 {MAX_DEPTH} 层)" + ))); + } + match value { + Value::Table(table) => { + for item in as_strict_sequence(table, context)? { + flatten(&item, context, depth + 1, out)?; + } + } + scalar => out.extend(to_os_strings(scalar, ScalarMode::Tokens, context)?), + } + Ok(()) +} + +/// 一次 DFS 完成"环检测 + 递归展开 + 结果缓存" +fn expand_graph(raw: &HashMap>) -> Result>> { + let mut cache: HashMap> = HashMap::with_capacity(raw.len()); + let mut stack: Vec = Vec::new(); + + for key in raw.keys() { + resolve(key, raw, &mut cache, &mut stack)?; + } + Ok(cache) +} + +fn resolve( + key: &str, + raw: &HashMap>, + cache: &mut HashMap>, + stack: &mut Vec, +) -> Result> { + if let Some(done) = cache.get(key) { + return Ok(done.clone()); + } + if stack.iter().any(|pending| pending == key) { + return Err(Error::AliasCycle { + name: key.to_string(), + }); + } + if stack.len() >= MAX_ALIAS_DEPTH { + return Err(Error::AliasTooDeep { + max: MAX_ALIAS_DEPTH, + }); + } + + stack.push(key.to_string()); + + let mut expanded = Vec::new(); + if let Some(args) = raw.get(key) { + for arg in args { + let text = arg.to_string_lossy(); + let referenced = text + .strip_prefix("mr:") + .filter(|name| raw.contains_key(*name)) + .map(str::to_string); + + match referenced { + Some(name) => expanded.extend(resolve(&name, raw, cache, stack)?), + None => expanded.push(arg.clone()), + } + } + } + + stack.pop(); + cache.insert(key.to_string(), expanded.clone()); + Ok(expanded) +} + +#[cfg(test)] +mod tests { + use super::*; + use mlua::Lua; + + fn with_aliases(code: &str) -> Result>> { + let lua = Lua::new(); + let value: Value = lua.load(code).eval().unwrap(); + parse_aliases(&value) + } + + #[test] + fn tokenizes_values_and_expands_references() { + let aliases = with_aliases( + r#"return { base = "log --graph", all = { "mr:base", "--all" }, blank = " " }"#, + ) + .unwrap(); + + assert_eq!( + aliases.get("base").unwrap(), + &vec![OsString::from("log"), OsString::from("--graph")] + ); + assert_eq!( + aliases.get("all").unwrap(), + &vec![ + OsString::from("log"), + OsString::from("--graph"), + OsString::from("--all"), + ] + ); + assert!(aliases.get("blank").unwrap().is_empty()); + } + + #[test] + fn scalar_number_and_boolean_are_supported() { + let aliases = with_aliases(r#"return { n = 10, b = true }"#).unwrap(); + assert_eq!(aliases.get("n").unwrap(), &vec![OsString::from("10")]); + assert_eq!(aliases.get("b").unwrap(), &vec![OsString::from("true")]); + } + + #[test] + fn unknown_reference_is_kept_literal() { + let aliases = with_aliases(r#"return { a = "mr:nope" }"#).unwrap(); + assert_eq!(aliases.get("a").unwrap(), &vec![OsString::from("mr:nope")]); + } + + #[test] + fn cycle_is_reported() { + let err = with_aliases(r#"return { a = "mr:b", b = "mr:a" }"#).unwrap_err(); + assert!(matches!(err, Error::AliasCycle { .. }), "unexpected: {err}"); + } + + #[test] + fn invalid_alias_name_is_reported() { + let err = with_aliases(r#"return { [""] = "x" }"#).unwrap_err(); + assert!(matches!( + err, + Error::InvalidName { + kind: NameKind::Alias, + .. + } + )); + } +} diff --git a/mirror-core/src/config/values/args.rs b/mirror-core/src/config/values/args.rs new file mode 100644 index 0000000..e114c72 --- /dev/null +++ b/mirror-core/src/config/values/args.rs @@ -0,0 +1,78 @@ +use super::as_table; +use super::scalar::{ScalarMode, to_os_strings}; +use super::sequence::as_strict_sequence; +use crate::{Error, Result}; +use mlua::Value; +use std::ffi::OsString; + +const CONTEXT: &str = "命令行参数 (args)"; + +/// `args = { … }`:一个数组元素就是一个参数(空串保留为空参数)。 +/// +/// 只支持一维基础标量:嵌套 table 会让"一个元素 = 一个参数"的语义失效。 +pub(crate) fn parse_args(value: &Value) -> Result> { + let table = as_table(value, CONTEXT)?; + let items = as_strict_sequence(table, CONTEXT)?; + + let mut args = Vec::with_capacity(items.len()); + for (i, item) in items.iter().enumerate() { + if matches!(item, Value::Table(_)) { + return Err(Error::ParseError(format!( + "{CONTEXT} 仅支持一维数组,第 {} 个元素不能是 table", + i + 1 + ))); + } + let context = format!("{CONTEXT} 第 {} 个元素", i + 1); + args.extend(to_os_strings(item, ScalarMode::Verbatim, &context)?); + } + Ok(args) +} + +#[cfg(test)] +mod tests { + use super::*; + use mlua::Lua; + + fn with_args(code: &str) -> Result> { + let lua = Lua::new(); + let value: Value = lua.load(code).eval().unwrap(); + parse_args(&value) + } + + #[test] + fn parses_scalars_and_keeps_empty_string() { + let args = with_args(r#"return { "--no-pager", 2, true, "" }"#).unwrap(); + assert_eq!( + args, + vec![ + OsString::from("--no-pager"), + OsString::from("2"), + OsString::from("true"), + OsString::from(""), + ] + ); + } + + #[test] + fn empty_array_is_empty() { + assert!(with_args("return {}").unwrap().is_empty()); + } + + #[test] + fn rejects_nested_table() { + let err = with_args(r#"return { { "a" } }"#).unwrap_err(); + assert!(matches!(err, Error::ParseError(_))); + } + + #[test] + fn rejects_sparse_array() { + let err = with_args(r#"return { "a", nil, "b" }"#).unwrap_err(); + assert!(matches!(err, Error::ParseError(_))); + } + + #[test] + fn rejects_non_table() { + let err = with_args(r#"return "-B""#).unwrap_err(); + assert!(matches!(err, Error::TypeMismatch { .. })); + } +} diff --git a/mirror-core/src/config/values/env.rs b/mirror-core/src/config/values/env.rs new file mode 100644 index 0000000..9b93059 --- /dev/null +++ b/mirror-core/src/config/values/env.rs @@ -0,0 +1,120 @@ +use super::as_table; +use super::scalar::{ScalarMode, to_os_strings}; +use super::sequence::as_strict_sequence; +use crate::config::naming::parse_name; +use crate::{Error, NameKind, Result}; +use mlua::Value; +use std::collections::HashMap; +use std::ffi::OsString; + +const CONTEXT: &str = "环境变量 (env)"; +/// 数组嵌套上限,防止畸形配置打爆调用栈 +const MAX_DEPTH: usize = 16; + +/// `env = { KEY = value }` → 环境变量表。 +/// +/// value 可以是标量,也可以是任意深度的数组;数组会递归展平,然后按系统 +/// 路径分隔符(Windows `;`)拼接。空字符串 / 空数组 → 空值(清空该变量)。 +pub(crate) fn parse_env(value: &Value) -> Result> { + let table = as_table(value, CONTEXT)?; + let mut env = HashMap::with_capacity(table.raw_len().min(128)); + + for pair in table.pairs::() { + let (raw_key, raw_value) = pair?; + let name = parse_name(NameKind::EnvVar, &raw_key)?; + let context = format!("环境变量 '{name}' 的值"); + + let mut parts = Vec::new(); + flatten(&raw_value, &context, 0, &mut parts)?; + + let joined = std::env::join_paths(parts) + .map_err(|e| Error::ParseError(format!("{context} 无法用系统路径分隔符拼接: {e}")))?; + env.insert(name, joined); + } + + Ok(env) +} + +/// 递归展平标量与嵌套数组(env 的字符串不做分词,整段作为一个路径段) +fn flatten(value: &Value, context: &str, depth: usize, out: &mut Vec) -> Result<()> { + if depth > MAX_DEPTH { + return Err(Error::ParseError(format!( + "{context} 的数组嵌套层级过深(超过 {MAX_DEPTH} 层)" + ))); + } + match value { + Value::Table(table) => { + for item in as_strict_sequence(table, context)? { + flatten(&item, context, depth + 1, out)?; + } + } + scalar => out.extend(to_os_strings(scalar, ScalarMode::Verbatim, context)?), + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use mlua::Lua; + + fn with_env(code: &str) -> Result> { + let lua = Lua::new(); + let value: Value = lua.load(code).eval().unwrap(); + parse_env(&value) + } + + #[test] + fn flattens_nested_arrays_with_path_separator() { + let env = with_env(r#"return { PATH = { "C:/a", { "C:/b", "C:/c" } } }"#).unwrap(); + assert_eq!(env.get("PATH").unwrap().to_str(), Some("C:/a;C:/b;C:/c")); + } + + #[test] + fn scalars_and_empty_values() { + let env = with_env(r#"return { A = "", B = {}, C = 3, D = true }"#).unwrap(); + assert_eq!(env.get("A").unwrap().to_str(), Some("")); + assert_eq!(env.get("B").unwrap().to_str(), Some("")); + assert_eq!(env.get("C").unwrap().to_str(), Some("3")); + assert_eq!(env.get("D").unwrap().to_str(), Some("true")); + } + + #[test] + fn rejects_invalid_key() { + let err = with_env(r#"return { ["FOO=1"] = "x" }"#).unwrap_err(); + assert!(matches!( + err, + Error::InvalidName { + kind: NameKind::EnvVar, + .. + } + )); + + let err = with_env(r#"return { [""] = "x" }"#).unwrap_err(); + assert!(matches!( + err, + Error::InvalidName { + kind: NameKind::EnvVar, + .. + } + )); + } + + #[test] + fn rejects_sparse_array_and_wrong_types() { + let err = with_env(r#"return { P = { "a", nil, "b" } }"#).unwrap_err(); + assert!(matches!(err, Error::ParseError(_))); + + let err = with_env(r#"return { F = function() end }"#).unwrap_err(); + assert!(matches!(err, Error::TypeMismatch { .. })); + + let err = with_env(r#"return "PATH=C:/x""#).unwrap_err(); + assert!(matches!(err, Error::TypeMismatch { .. })); + } + + #[test] + fn rejects_nul_in_value() { + let err = with_env(r#"return { P = { string.char(0) } }"#).unwrap_err(); + assert!(matches!(err, Error::ContainsNul(_))); + } +} diff --git a/mirror-core/src/config/values/mod.rs b/mirror-core/src/config/values/mod.rs new file mode 100644 index 0000000..0bb80f8 --- /dev/null +++ b/mirror-core/src/config/values/mod.rs @@ -0,0 +1,27 @@ +//! Lua 值 → Rust 值的转换:`args` / `env` / `aliases`。 +//! +//! 这一层只管"值本身":数组形状、元素类型、键合法性、分词与拼接。 +//! 产出的错误统一是 `crate::Error` 的结构化变体;字段级语义 +//! (必填、默认值)由 `model.rs` 决定。 + +mod aliases; +mod args; +mod env; +mod scalar; +mod sequence; + +pub(crate) use aliases::parse_aliases; +pub(crate) use args::parse_args; +pub(crate) use env::parse_env; + +use crate::{Error, Result}; +use mlua::{Table, Value}; + +/// 值必须是 table,否则报 `TypeMismatch` +pub(crate) fn as_table<'a>(value: &'a Value, context: &str) -> Result<&'a Table> { + value.as_table().ok_or_else(|| Error::TypeMismatch { + field: context.to_string(), + expected: "键值表 (table)", + actual: value.type_name(), + }) +} diff --git a/mirror-core/src/config/values/scalar.rs b/mirror-core/src/config/values/scalar.rs new file mode 100644 index 0000000..9cbe21d --- /dev/null +++ b/mirror-core/src/config/values/scalar.rs @@ -0,0 +1,52 @@ +use crate::args::tokens::parse_tokens; +use crate::config::convert::{ensure_no_nul, lua_string_2_os_string}; +use crate::{Error, Result}; +use mlua::Value; +use std::ffi::OsString; + +/// 字符串元素的处理策略 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ScalarMode { + /// 整体作为一个参数(args:一个数组元素 = 一个参数,空串保留为空参数) + Verbatim, + /// 按 shell 规则分词(aliases:"status -s" → 两个参数) + Tokens, +} + +/// 标量 → 参数列表 +/// +/// - `Nil` 产生 0 个参数(在 env 里表示"清空该变量") +/// - 字符串 / 整数 / 浮点 / 布尔都可以转成字符串 +/// - 其它类型报 `TypeMismatch` +/// - 产出的每个 `OsString` 都会做 NUL 检查 +pub(super) fn to_os_strings( + value: &Value, + mode: ScalarMode, + context: &str, +) -> Result> { + let os_str: OsString = match value { + Value::String(s) => lua_string_2_os_string(s)?, + Value::Integer(i) => i.to_string().into(), + Value::Number(n) => n.to_string().into(), + Value::Boolean(b) => b.to_string().into(), + Value::Nil => return Ok(Vec::new()), + other => { + return Err(Error::TypeMismatch { + field: context.to_string(), + expected: "string / number / boolean", + actual: other.type_name(), + }); + } + }; + + ensure_no_nul(&context, &os_str)?; + + Ok(match mode { + ScalarMode::Verbatim => vec![os_str], + ScalarMode::Tokens => match os_str.to_str() { + Some(text) => parse_tokens(text), + // 非 UTF-8 字节没法按 shell 规则切分,整体作为一个参数 + None => vec![os_str], + }, + }) +} diff --git a/mirror-core/src/config/values/sequence.rs b/mirror-core/src/config/values/sequence.rs new file mode 100644 index 0000000..9c350e9 --- /dev/null +++ b/mirror-core/src/config/values/sequence.rs @@ -0,0 +1,41 @@ +use crate::{Error, Result}; +use mlua::{Table, Value}; + +/// 严格连续的纯数组校验:一次遍历 `pairs`,要求键恰好是 `1..=n` 的连续整数。 +/// +/// 返回按顺序排列的元素。空洞(`{ "a", nil, "b" }`)或字典键都会报错。 +pub(super) fn as_strict_sequence(table: &Table, context: &str) -> Result> { + let mut slots: Vec> = Vec::with_capacity(table.raw_len().min(128)); + + for pair in table.pairs::() { + let (key, value) = pair?; + let index = match key { + Value::Integer(i) if i >= 1 => (i - 1) as usize, + other => { + return Err(Error::TypeMismatch { + field: context.to_string(), + expected: "连续整数下标 (数组)", + actual: other.type_name(), + }); + } + }; + if slots.len() <= index { + slots.resize_with(index + 1, || None); + } + slots[index] = Some(value); + } + + let mut items = Vec::with_capacity(slots.len()); + for (i, slot) in slots.into_iter().enumerate() { + match slot { + Some(value) => items.push(value), + None => { + return Err(Error::ParseError(format!( + "{context} 第 {} 个元素为 nil 或不存在(请检查漏写引号或变量未定义)", + i + 1 + ))); + } + } + } + Ok(items) +} diff --git a/mirror-core/src/error.rs b/mirror-core/src/error.rs index 93cb671..5de2d06 100644 --- a/mirror-core/src/error.rs +++ b/mirror-core/src/error.rs @@ -1,53 +1,78 @@ use std::fmt; use std::path::PathBuf; use thiserror::Error; -// 构造一个带上下文的 FromLua 转换错误,便于定位配置问题 -/// 配置校验错误 -pub fn validation_error(message: impl Into) -> mlua::Error { - mlua::Error::RuntimeError(message.into()) -} -pub fn conversion_err( - from: &'static str, - to: impl Into, - msg: impl Into, -) -> mlua::Error { - mlua::Error::FromLuaConversionError { - from, - to: to.into(), - message: Some(msg.into()), - } -} -/// 将底层 Lua 语法错误转换为用户友好的提示 -pub fn syntax_error(err: mlua::Error) -> mlua::Error { - match &err { - mlua::Error::SyntaxError { message, .. } => { - if message.contains("invalid escape sequence") || message.contains("unfinished string") - { - return validation_error(format!( - "配置文件语法错误:检测到非法的字符串转义。\n\ - 提示:在 Windows 路径末尾或字符串中使用反斜杠 '\\' 时:\n\ - 1. 请使用双反斜杠转义,例如: \"D:\\\\CNWei\\\\CNW\\\\Rust\\\\\"\n\ - 2. 或使用 Lua 原始字符串 (Raw String),例如: [[D:\\CNWei\\CNW\\Rust\\]]\n\ - 底层错误: {}", - message - )); - } - } - _ => {} - } - err -} /// 全局统一的 `Result` 别名,默认错误类型为 [`Error`]。 pub type Result = std::result::Result; +/// 名称所属的类别,用于区分命令名 / 环境变量名 / 别名的校验与报错 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NameKind { + Command, + EnvVar, + Alias, +} + +impl fmt::Display for NameKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Command => "命令名", + Self::EnvVar => "环境变量名", + Self::Alias => "别名", + }) + } +} + #[derive(Error, Debug)] pub enum Error { #[error("初始化 Lua 沙箱失败: {0}")] LuaInit(#[source] mlua::Error), - #[error("构建 LuaRuntime 异常")] - BuildError, - + + // #[error("构建 LuaRuntime 异常")] + // BuildError, + #[error("{kind}非法: {reason}")] + InvalidName { kind: NameKind, reason: String }, + + #[error("别名 '{name}' 存在循环引用")] + AliasCycle { name: String }, + + #[error("别名展开层级过深(超过 {max} 层)")] + AliasTooDeep { max: usize }, + + #[error("{0}")] + ContainsNul(String), + + /// 值的结构不合法(数组空洞、字典键、嵌套过深等) + #[error("{0}")] + ParseError(String), + + #[error("目录布局解析失败: {0}")] + Layout(String), + + #[error("注册全局变量 '{name}' 失败: {source}")] + RegisterGlobal { + name: &'static str, + #[source] + source: mlua::Error, + }, + + #[error("{from} -> {to} 转换失败: {message}")] + EncodingError { + from: &'static str, + to: String, + message: String, + }, + // 类型不符:Lua 值的类型与期望不符,用户应修改配置写法 + #[error("字段 {field} 类型不符:期望 {expected},实际是 {actual}")] + TypeMismatch { + field: String, + expected: &'static str, + actual: &'static str, + }, + + #[error("缺少必填字段 {field}")] + MissingField { field: String }, + ///读取脚本文件失败 #[error("读取脚本文件失败 ({path}): {source}")] ScriptRead { @@ -79,31 +104,56 @@ pub enum Error { // #[source] // source: mlua::Error, // }, - - #[error("Lua 运行时未指定上下文错误: {0}")] - LuaError(#[from] mlua::Error), + #[error("Lua 适配层错误: {0}")] + LuaAdapter(#[from] mlua::Error), // #[error("设置 {0} 环境变量失败")] // SetMirrorDir(String), - /// 用户自定义扩展错误,用于包装第三方插件产生的错误。 #[error("用户自定义扩展错误: {0}")] Other(#[source] Box), } - +/// 出站转换:core 自己的错误 -> Lua 世界能看懂的错误。 +/// +/// 触发时机:函数返回 `mlua::Result`,但 `?` 的表达式返回 `crate::Result`。 +/// 有了这个 impl,Lua 边界的代码只写 `?`,不需要在每个调用点手工构造 mlua 错误。 +impl From for mlua::Error { + fn from(err: Error) -> Self { + match err { + // 本来就是 mlua 的错误:原样透传,保留错误种类与位置信息 + Error::LuaInit(e) | Error::LuaAdapter(e) => e, + // source 自带 chunk 名(set_name 已写入),所以这里直接透传内层错误 + Error::LuaRuntime { source, .. } => source, + Error::EncodingError { from, to, message } => mlua::Error::FromLuaConversionError { + from, + to, + message: Some(message), + }, + Error::TypeMismatch { + field, + expected, actual, + } => mlua::Error::FromLuaConversionError { + from: actual, + to: expected.into(), + message: Some(format!("字段 '{field}' 类型不符")), + }, + Error::MissingField { field } => { + mlua::Error::RuntimeError(format!("缺少必填字段 {field}")) + } + // 其余是 core 的领域错误:翻译成 Lua 侧可见的运行时错误, + // to_string() 由 thiserror 的 #[error("...")] 生成,也就是用户看到的中文 + other => mlua::Error::RuntimeError(other.to_string()), + } + } +} /// 将 MirrorTarget 构建为 Command 时可能触发的阻断性错误 #[derive(Debug, Clone)] pub enum TargetExecutionError { /// 多版本工具未激活版本 (current == None) - UnselectedVersion { - tool: String, - }, + UnselectedVersion { tool: String }, /// 目标可执行文件物理不存在 - NotFound { - tool: String, - path: PathBuf, - }, + NotFound { tool: String, path: PathBuf }, } impl fmt::Display for TargetExecutionError { @@ -117,14 +167,65 @@ impl fmt::Display for TargetExecutionError { ) } Self::NotFound { tool, path } => { - write!( - f, - "工具 '{}' 的目标程序不存在: {:?}", - tool, path - ) + write!(f, "工具 '{}' 的目标程序不存在: {}", tool, path.display()) } } } } -impl std::error::Error for TargetExecutionError {} \ No newline at end of file +impl std::error::Error for TargetExecutionError {} + +#[cfg(test)] +mod tests { + use super::*; + + /// 领域错误出站后,原始的中文提示必须还在 + #[test] + fn config_error_converts_to_lua_error_with_payload() { + let lua_err: mlua::Error = Error::InvalidName { + kind: NameKind::Command, + reason: "不能为空".into(), + } + .into(); + assert!( + lua_err.to_string().contains("不能为空"), + "unexpected message: {lua_err}" + ); + } + + /// Lua 原生错误出站时要原样透传,不能被包成 RuntimeError 丢掉种类 + #[test] + fn native_lua_error_passes_through() { + let lua_err: mlua::Error = + Error::LuaAdapter(mlua::Error::RuntimeError("boom".into())).into(); + assert!( + lua_err.to_string().contains("boom"), + "unexpected message: {lua_err}" + ); + } + #[test] + fn conversion_error_keeps_kind() { + let lua_err: mlua::Error = Error::EncodingError { + from: "LuaString", + to: "OsString".into(), + message: "坏字节".into(), + } + .into(); + assert!(matches!( + lua_err, + mlua::Error::FromLuaConversionError { .. } + )); + } + + #[test] + fn missing_field_message_has_no_duplication() { + let lua_err: mlua::Error = Error::MissingField { + field: "home".into(), + } + .into(); + // mlua 会给 RuntimeError 加 "runtime error: " 前缀,所以只断言内容正确且不重复 + let message = lua_err.to_string(); + assert_eq!(message.matches("缺少必填字段").count(), 1, "{message}"); + assert!(message.contains("home"), "{message}"); + } +} diff --git a/mirror-core/src/layout.rs b/mirror-core/src/fs/layout.rs similarity index 77% rename from mirror-core/src/layout.rs rename to mirror-core/src/fs/layout.rs index dca3ae8..2f9b5fe 100644 --- a/mirror-core/src/layout.rs +++ b/mirror-core/src/fs/layout.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result, bail}; +use crate::{Error, Result}; use std::env; use std::path::{Path, PathBuf}; use tracing::debug; @@ -15,7 +15,12 @@ impl Layout { let target_name = current_exe .file_stem() .and_then(|s| s.to_str()) - .with_context(|| format!("无法从路径 [{}] 提取有效的程序名称", current_exe.display()))? + .ok_or_else(|| { + Error::Layout(format!( + "无法从路径 [{}] 提取有效的程序名称", + current_exe.display() + )) + })? .to_lowercase(); // 策略 1: 环境变量优先 @@ -36,15 +41,23 @@ impl Layout { /// 基于确定的根目录构建完整布局 fn from_base_dir(base_dir: PathBuf, target_name: String) -> Result { if !base_dir.is_dir() { - bail!("指定的根目录不存在或不是有效目录: [{}]", base_dir.display()); + debug!("指定的根目录不存在或不是有效目录: [{}]", base_dir.display()); + return Err(Error::Layout(format!( + "指定的根目录不存在或不是有效目录: [{}]", + base_dir.display() + ))); } let mirror_file = base_dir.join("mirror.lua"); if !mirror_file.is_file() { - bail!( + debug!( "根目录 [{}] 下未找到核心配置文件 [mirror.lua]", base_dir.display() ); + return Err(Error::Layout(format!( + "根目录 [{}] 下未找到核心配置文件 [mirror.lua]", + base_dir.display() + ))); } Ok(Self { @@ -56,9 +69,9 @@ impl Layout { /// 从当前可执行文件解析 shim 安装目录布局 fn from_executable(exe_path: &Path, target_name: String) -> Result { // let exe_path = exe_path.as_ref(); - let start_dir = exe_path - .parent() - .with_context(|| format!("无法获取程序 [{}] 的所在目录", exe_path.display()))?; + let start_dir = exe_path.parent().ok_or_else(|| { + Error::Layout(format!("无法获取程序 [{}] 的所在目录", exe_path.display())) + })?; debug!("可执行程序所在目录: {}", start_dir.display()); @@ -69,11 +82,11 @@ impl Layout { // 2. 向上回溯寻找 mirror.lua 锚点 let base_dir = find_base_dir_by_anchor(start_dir).ok_or_else(|| { - anyhow::anyhow!( + Error::Layout(format!( "路径推导失败:从 [{}] 向上逐级回溯均未找到 [mirror.lua]。\n\ 提示: 请确保程序位于包含 mirror.lua 的项目内,或配置 MIRROR_HOME 环境变量。", start_dir.display() - ) + )) })?; debug!("成功探测到 base_dir 目录 {}", base_dir.display()); @@ -94,6 +107,7 @@ fn find_base_dir_by_anchor(start_dir: &Path) -> Option { while let Some(dir) = current { if depth > MAX_DEPTH { + debug!("超出最大向上回溯层级:4"); break; } diff --git a/mirror-core/src/fs/mod.rs b/mirror-core/src/fs/mod.rs new file mode 100644 index 0000000..7e04cba --- /dev/null +++ b/mirror-core/src/fs/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod layout; +pub(crate) mod path; +pub use layout::Layout; diff --git a/mirror-core/src/fs/path.rs b/mirror-core/src/fs/path.rs new file mode 100644 index 0000000..c0a60fc --- /dev/null +++ b/mirror-core/src/fs/path.rs @@ -0,0 +1,10 @@ +use std::path::Path; + +/// 将 Path 转换为适合 Lua 使用的安全字符串路径 +pub fn normalize_path_for_lua(path: &Path) -> String { + // 自动将 Windows UNC 规范路径转回传统路径 + let simplified = dunce::simplified(path); + simplified.to_string_lossy().replace('\\', "/") +} + + diff --git a/mirror-core/src/lib.rs b/mirror-core/src/lib.rs index 0601da2..738c5a1 100644 --- a/mirror-core/src/lib.rs +++ b/mirror-core/src/lib.rs @@ -1,21 +1,18 @@ +mod args; +mod config; mod error; -mod layout; -mod logger; -mod plugins; -mod runtime; -mod utils; -mod validators; -mod model; -mod alias; +mod fs; +mod logging; +mod lua; -pub use model::{MirrorRegistry,MirrorTarget,TargetConfig}; -pub use layout::Layout; +pub use config::{ + load_mirror_spec, parse_command_name, MirrorRegistry, MirrorSpec, MirrorTarget, Target, + TargetConfig, TargetSpec, +}; +pub use fs::Layout; // pub use mirror_shim::mirror::Mirror; -pub use runtime::LuaRuntime; +pub use logging::{LogConfig, LogLevel, init_logging_from}; +pub use lua::{register_basics, LuaRuntime}; -pub use logger::{init_logging_from, LogConfig, LogLevel}; -pub use plugins::register_basics; -pub use utils::normalize_path_for_lua; -pub use validators::validate_command_name; // 把 Result 和 Error 提升到根命名空间,给外部带来极致便利 -pub use error::{conversion_err, validation_error, Error, Result, TargetExecutionError}; \ No newline at end of file +pub use error::{Error, NameKind, Result, TargetExecutionError}; diff --git a/mirror-core/src/logger.rs b/mirror-core/src/logging.rs similarity index 99% rename from mirror-core/src/logger.rs rename to mirror-core/src/logging.rs index e52a935..54bb198 100644 --- a/mirror-core/src/logger.rs +++ b/mirror-core/src/logging.rs @@ -1,4 +1,4 @@ -use crate::Layout; +use crate::fs::Layout; use std::convert::Infallible; use std::fs; use std::path::{Path, PathBuf}; diff --git a/mirror-core/src/lua/mod.rs b/mirror-core/src/lua/mod.rs new file mode 100644 index 0000000..76ee64a --- /dev/null +++ b/mirror-core/src/lua/mod.rs @@ -0,0 +1,5 @@ +pub mod runtime; +pub mod plugins; + +pub use plugins::register_basics; +pub use runtime::LuaRuntime; \ No newline at end of file diff --git a/mirror-core/src/plugins.rs b/mirror-core/src/lua/plugins.rs similarity index 96% rename from mirror-core/src/plugins.rs rename to mirror-core/src/lua/plugins.rs index 5b0dbfe..ce28074 100644 --- a/mirror-core/src/plugins.rs +++ b/mirror-core/src/lua/plugins.rs @@ -1,5 +1,5 @@ use crate::error::{Error, Result}; -use crate::{Layout, normalize_path_for_lua}; +use crate::fs::{Layout, path::normalize_path_for_lua}; use mlua::{Lua, Table, Value}; use std::path::PathBuf; use std::{env, fs}; @@ -20,7 +20,12 @@ pub fn register_basics(lua: &Lua, layout: &Layout) -> Result<()> { /// 注入全局锚点变量 pub fn register_mirror_dir(lua: &Lua, base_dir: &str) -> Result<()> { debug!("设置 {MIRROR_DIR} 环境变量"); - lua.globals().set(MIRROR_DIR, base_dir)?; + lua.globals() + .set(MIRROR_DIR, base_dir) + .map_err(|e| Error::RegisterGlobal { + name: MIRROR_DIR, + source: e, + })?; // .context(format!("设置 {MIRROR_DIR} 环境变量失败"))?; Ok(()) } diff --git a/mirror-core/src/runtime.rs b/mirror-core/src/lua/runtime.rs similarity index 64% rename from mirror-core/src/runtime.rs rename to mirror-core/src/lua/runtime.rs index 77043c5..bb0a37b 100644 --- a/mirror-core/src/runtime.rs +++ b/mirror-core/src/lua/runtime.rs @@ -1,4 +1,4 @@ -use crate::{Error, Result}; +use crate::error::{Error, Result}; use mlua::{FromLua, Lua, StdLib, Value}; use std::fs; use std::path::Path; @@ -98,18 +98,21 @@ mod tests { use super::*; use crate::Layout; - fn test_layout() -> Layout { - let root = std::env::temp_dir().join("rshim-test-layout"); - Layout { - base_dir: root.clone(), - // bin_dir: root.join("bin"), - target_name: "muna".to_string(), - } + /// 测试用运行时:和线上一样注册宿主函数(get_env / require 包装等) + fn test_runtime() -> LuaRuntime { + let layout = Layout { + base_dir: std::env::temp_dir().join("mirror-core-test-layout"), + target_name: "mirror-shim".to_string(), + }; + LuaRuntime::builder() + .with(|lua| crate::lua::register_basics(lua, &layout)) + .build() + .unwrap() } #[test] fn missing_module_require_returns_nil() { - let runtime = LuaRuntime::builder().build().unwrap(); + let runtime = test_runtime(); let value: Value = runtime .lua .load(r#"return require("rshim_test_no_such_module")"#) @@ -120,7 +123,7 @@ mod tests { #[test] fn builtin_module_require_still_works() { - let runtime = LuaRuntime::builder().build().unwrap(); + let runtime = test_runtime(); let value: Value = runtime .lua .load(r#"return pcall(require, "string")"#) @@ -131,7 +134,7 @@ mod tests { #[test] fn get_env_returns_split_table() { - let runtime = LuaRuntime::builder().build().unwrap(); + let runtime = test_runtime(); let value: Value = runtime .lua .load(r#"return get_env("PATH")"#) @@ -158,7 +161,7 @@ mod tests { #[test] fn get_env_missing_returns_empty_table() { - let runtime = LuaRuntime::builder().build().unwrap(); + let runtime = test_runtime(); let value: Value = runtime .lua .load(r#"return get_env("RSHIM_TEST_NO_SUCH_VAR_12345")"#) @@ -169,57 +172,4 @@ mod tests { other => panic!("expected table, got {}", other.type_name()), } } - - // #[test] - // fn path_with_get_env_joins_without_quote_error() { - // // 复现用户场景:PATH = { base_dir .. "/tools/numa", get_env("PATH") } - // // 宿主 PATH 即使含双引号,也应拆分后正常拼接,而不是报错 - // let runtime = LuaRuntime::new(&test_layout()).unwrap(); - // let cfg: Mirror = runtime - // .lua - // .load( - // r#" - // return { - // target = __SHIM_DIR__ .. "/tools/numa/numa.exe", - // args = { "--help" }, - // env = { - // PATH = { __SHIM_DIR__ .. "/tools/numa", get_env("PATH") } - // } - // } - // "#, - // ) - // .eval() - // .unwrap(); - // - // let path = cfg.env.get("PATH").unwrap().to_str().unwrap(); - // let prefix = std::env::temp_dir() - // .join("rshim-test-layout") - // .to_string_lossy() - // .replace('\\', "/") - // + "/tools/numa;"; - // assert!(path.starts_with(&prefix), "unexpected PATH: {path}"); - // - // // 宿主 PATH 的段应被附加在配置前缀之后 - // let host = std::env::var("PATH").unwrap_or_default(); - // if !host.is_empty() { - // let host_first = std::env::split_paths(&host) - // .next() - // .unwrap() - // .to_string_lossy() - // .into_owned(); - // assert!( - // path.contains(&host_first), - // "missing host PATH segment: {host_first}" - // ); - // } - // - // // 宿主 PATH 里引号包裹的畸形段(如 "D:\\...\\bin;")应被原样保留, - // // 而不是让整个配置加载失败 - // if host.contains('"') { - // assert!( - // path.contains('"'), - // "quoted host segments should be preserved" - // ); - // } - // } } diff --git a/mirror-core/src/model.rs b/mirror-core/src/model.rs deleted file mode 100644 index fde4e5a..0000000 --- a/mirror-core/src/model.rs +++ /dev/null @@ -1,232 +0,0 @@ -use std::collections::HashMap; -use std::ffi::OsString; -use mlua::{FromLua, Lua, LuaString, Value}; -use std::path::PathBuf; -use crate::utils::lua_string_2_os_string; -use crate::{conversion_err, validate_command_name, validation_error}; -use crate::validators::{ensure_no_nul, LuaValidator}; -pub struct MirrorRegistry { - pub targets: HashMap, -} -impl FromLua for MirrorRegistry { - fn from_lua(value: Value, lua: &Lua) -> mlua::Result { - let root_tbl = match value { - Value::Table(t) => t, - other => { - return Err(conversion_err( - other.type_name(), - "MirrorRegistry", - format!( - "mirror 全局注册表顶层必须是 Table,实际检测到: {}", - other.type_name() - ), - )); - } - }; - let mut targets = HashMap::new(); - for pair in root_tbl.pairs::() { - let (key, entry) = pair?; - - let target_name = validate_command_name(&key)?; - // 2. 校验 entry 是否是 Table - if !entry.is_table() { - return Err(conversion_err( - entry.type_name(), - "MirrorRegistry", - format!( - "目标工具 '{target_name}' 的配置必须是 Table,实际检测到: {}", - entry.type_name() - ), - )); - } - // 3. 直接交由子命令解析,外层负责补充错误上下文 - let target_spec = MirrorTarget::from_lua(entry, lua).map_err(|err| { - validation_error(format!("目标工具 '{target_name}' 配置解析失败:\n{err}")) - })?; - - targets.insert(target_name, target_spec); - } - Ok(Self { targets }) - } -} -#[derive(Debug, Clone, Default)] -pub struct MirrorTarget { - /// 目标程序可执行文件路径 - pub target: TargetConfig, - /// 默认预设参数列表 - pub args: Vec, - /// 参数别名映射表(用于展开以 mr: 为前缀的别名) - pub aliases: HashMap>, - /// 注入/覆盖的环境变量 - pub env: HashMap, -} -impl FromLua for MirrorTarget { - fn from_lua(value: Value, lua: &Lua) -> mlua::Result { - // 脚本返回必须是一个 Table 变体 - let table = match value { - Value::Table(t) => t, - _ => { - return Err(validation_error(format!( - "期望得到一个 Lua Table 配置对象,实际是 {}", - value.type_name() - ))); - } - }; - - // 必填字段: target(严格限定为字符串,避免数字被 mlua 宽松转为字符串后掩盖错误) - let target = match table.get::>("target")? { - None | Some(Value::Nil) => { - return Err(validation_error( - "缺少必填字段 target(应为字符串路径或多版本配置表)", - )); - } - // Some(target_val) => LuaValidator::parse_target(&target_val)?, - Some(target_val) => TargetConfig::from_lua(target_val, lua)?, - }; - - // 可选字段: args(缺失或 nil 时默认空列表;保留空字符串参数,与提权路径的 "" 语义一致) - let args = match table.get::>("args")? { - None | Some(Value::Nil) => Vec::new(), - Some(args_val) => LuaValidator::parse_args(&args_val)?, - }; - - // 可选字段: env(只允许缺失/nil,其他类型由 Option 转换报错,不再静默忽略) - let env = match table.get::>("env")? { - None | Some(Value::Nil) => HashMap::new(), - Some(env_val) => LuaValidator::parse_env(&env_val)?, - }; - - // 可选字段: aliases(只允许缺失/nil,其他类型由 Option
转换报错,不再静默忽略) - let aliases = match table.get::>("aliases")? { - None | Some(Value::Nil) => HashMap::new(), - Some(env_val) => LuaValidator::parse_aliases(&env_val)?, - }; - // println!("环境变量结果:{:?}", env); - Ok(Self { - target, - args, - aliases, - env, - }) - } -} -/// 目标可执行文件的定位配置(支持静态路径与多版本 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/validators.rs b/mirror-core/src/validators.rs deleted file mode 100644 index 6f63ced..0000000 --- a/mirror-core/src/validators.rs +++ /dev/null @@ -1,596 +0,0 @@ -use std::fmt; -use crate::error::validation_error; -use mlua::Value; - -pub fn validate_command_name(name: &Value) -> mlua::Result { - let name_str = match name { - Value::String(s) => s - .to_str() - .map_err(|_| validation_error("子命令名称必须是合法的 UTF-8 字符串"))? - .to_string(), - other => { - return Err(validation_error(format!( - "命令行键名类型错误:期望 string,实际是 {}", - other.type_name() - ))); - } - }; - let trimmed = name_str.trim(); - if trimmed.is_empty() { - return Err(validation_error("子命令名称不能为空字符串")); - } - // 2. 禁止包含空格与不可见控制符(否则 shell 与 clap 无法正确定位) - if name_str.contains(|c: char| c.is_whitespace()) { - return Err(validation_error(format!( - "子命令名称 [{name_str}] 非法:命令名不能包含空格或空白字符" - ))); - } - - if name_str.contains('=') { - return Err(validation_error(format!( - "子命令名称 [{}] 不能包含 '='", - name_str - ))); - } - if name_str.contains('\0') { - return Err(validation_error(format!( - "子命令名称 [{}] 不能包含 NUL 字符", - name_str - ))); - } - 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(()) -} -use anyhow::{Context, Result, anyhow}; -use crate::{TargetConfig,}; -use mlua::{LuaString, Table, }; -use std::collections::{HashMap, HashSet}; -use std::ffi::OsString; - -use std::path::PathBuf; -use std::str::FromStr; -// use tinyjson::JsonValue; -use crate::utils::{lua_string_2_os_string, parse_tokens}; -use tracing::debug; - -/// Lua 值校验器:针对不同上下文定义校验规则 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LuaValidator { - /// 目标程序路径:必须是字符串路径 - Target, - /// 命令行参数元素:仅支持一维基础标量,禁止嵌套 Table - Args, - /// 环境变量值:支持基础标量及多维嵌套 Table(递归展平) - Env, - Aliases, -} - -impl fmt::Display for LuaValidator { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Target => write!(f, "目标路径 (target)"), - Self::Args => write!(f, "命令行参数 (args)"), - Self::Env => write!(f, "环境变量 (env)"), - Self::Aliases => write!(f, "命令行别名 (aliases)"), - } - } -} -impl LuaValidator { - fn parse_sequence(&self, index: i64, item: &Value) -> mlua::Result<()> { - match item { - Value::String(_) | Value::Integer(_) | Value::Number(_) | Value::Boolean(_) => Ok(()), - Value::Table(tbl) => match self { - Self::Env | Self::Aliases => self.validate_sequence_table(tbl), - Self::Args => Err(validation_error(format!( - "{} 仅支持一维数组,不能包含嵌套 Table,索引位置: {index}", - self - ))), - Self::Target => Err(validation_error(format!("{} 仅支持字符串", self))), - }, - other => Err(validation_error(format!( - "{} 第 {} 个元素类型无效: {}", - self, - index, - other.type_name() - ))), - } - } - - /// 校验 Table 是否为严格连续的纯数组,并递归校验其内部元素 - fn validate_sequence_table(&self, tbl: &Table) -> mlua::Result<()> { - let mut index = 1i64; - - // 1. 顺序遍历连续整数索引 1..N - loop { - let item: Value = tbl.raw_get(index)?; - if matches!(&item, Value::Nil) { - break; - } - self.parse_sequence(index, &item)?; - - index += 1; - } - - // 2. 查漏:校验是否存在空洞索引或字典键 (Key-Value 键值对) - for pair in tbl.pairs::() { - let (key, _) = pair?; - match key { - Value::Integer(i) if i >= 1 && i < index => {} // 已遍历放行 - Value::Integer(_) => { - return Err(validation_error(format!( - "{} 第 {} 个元素为 nil 或不存在(请检查漏写引号或变量未定义)", - self, index - ))); - } - _ => { - return Err(validation_error(format!( - "{} 必须是纯列表,不能包含键值对/字典结构", - self - ))); - } - } - } - - Ok(()) - } - - /// 底层 NUL 字符跨平台安全检查 - 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(()) - } - - /// 【纯粹数据转换与展开】将已通过校验的 Value 递归解析为 OsString 动态数组 - fn collect_value_into(&self, value: &Value, out: &mut Vec) -> mlua::Result<()> { - match value { - Value::Nil => {} - Value::String(s) => { - let os_str = lua_string_2_os_string(s)?; - match self { - Self::Args | Self::Aliases => { - if let Some(str_ref) = os_str.to_str() { - // 使用 Tokenizer 切分空格与引号 - out.extend(parse_tokens(str_ref)); - } else { - // 对于无法转为 UTF-8 的特殊二进制数据,作为整体追加 - out.push(os_str); - } - } - Self::Env => { - out.push(os_str); - } - Self::Target => {} - } - } - Value::Integer(i) => out.push(OsString::from(i.to_string())), - Value::Number(n) => { - tracing::warn!(%self, value = %n, "浮点数将按十进制格式转换为字符串"); - out.push(OsString::from(n.to_string())); - } - Value::Boolean(b) => { - tracing::warn!(%self, value = %b, "布尔值将转换为字符串"); - out.push(OsString::from(b.to_string())); - } - Value::Table(tbl) => { - let mut index = 1i64; - loop { - let item: Value = tbl.raw_get(index)?; - if matches!(item, Value::Nil) { - break; - } - - Self::collect_value_into(self, &item, out)?; - index += 1; - } - } - _ => unreachable!("传入收集器的 Value 应已通过 validate 校验"), - } - Ok(()) - } - fn parse_name(name: &Value) -> mlua::Result { - let name_str = match name { - Value::String(s) => match s.to_str() { - Ok(str_ref) => str_ref.to_string(), - Err(_) => return Err(validation_error("环境变量名必须是合法的 UTF-8 字符串")), - }, - other => { - return Err(validation_error(format!( - "环境变量键名类型错误:期望 string,实际是 {}", - other.type_name() - ))); - } - }; - if name_str.is_empty() { - return Err(validation_error("环境变量名不能为空")); - } - if name_str.contains('=') { - return Err(validation_error(format!( - "环境变量名 [{}] 不能包含 '='", - name_str - ))); - } - if name_str.contains('\0') { - return Err(validation_error(format!( - "环境变量名 [{}] 不能包含 NUL 字符", - name_str - ))); - } - Ok(name_str) - } - fn parse_val(&self, name: &str, value: &Value) -> mlua::Result<()> { - match value { - Value::Nil - | Value::String(_) - | Value::Integer(_) - | Value::Number(_) - | Value::Boolean(_) => Ok(()), - Value::Table(tbl) => self.validate_sequence_table(tbl), - other => Err(validation_error(format!( - "{} [{}]不支持 {} 类型, 仅支持 string/number/boolean 或嵌套数组", - self, - name, - other.type_name() - ))), - } - } -} -impl LuaValidator { - /// 解析并校验 `target` - 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(TargetConfig::Static(PathBuf::from(os_str))) - } - // 分支 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() - ))), - } - } - - /// 解析并校验 `args` - pub fn parse_args(value: &Value) -> mlua::Result> { - { - debug!("args 配置已关闭 "); - #[cfg(not(feature = "args"))] - Ok(Vec::new()) - } - #[cfg(feature = "args")] - { - debug!("args 配置已开启"); - let ctx = Self::Args; - match value { - Value::Table(tbl) => { - ctx.validate_sequence_table(tbl)?; - - let capacity = tbl.raw_len().min(128); - - let mut raw_parts = Vec::with_capacity(capacity); - - Self::collect_value_into(&ctx, value, &mut raw_parts)?; - - for part in &raw_parts { - Self::ensure_no_nul(&ctx, &part)?; - } - Ok(raw_parts) - } - other => Err(validation_error(format!( - "{} 必须是数组列表,实际类型是 {}", - ctx, - other.type_name() - )))?, - } - } - } - - /// 校验环境变量名的合法性(Windows 约束:非空、不含 '='、不含 NUL) - fn parse_env_name(name: &Value) -> mlua::Result { - Self::parse_name(name) - } - - /// 解析、校验并拼接单个环境变量键值对 (`key`, `value`) -> (`OsString`) - fn parse_env_val( - context: &LuaValidator, - name: &str, - raw_val: &Value, - ) -> mlua::Result { - context.parse_val(name, raw_val)?; - - // let ctx = format!("{} [{}]", context, name); - let capacity = match raw_val { - Value::Table(t) => t.raw_len().min(128), - Value::Nil => 0, - _ => 1, - }; - - let mut parts = Vec::with_capacity(capacity); - - Self::collect_value_into(&context, raw_val, &mut parts)?; - // context.collect_value_into(raw_val, &mut parts)?; - - // 校验每个展开元素的 NUL 字符 - for part in &parts { - Self::ensure_no_nul(&name, part)?; - } - // 3. 使用系统路径分隔符拼接数组列表 - let joined_os_str = std::env::join_paths(parts).map_err(|e| { - validation_error(format!("{} 的值无法用系统路径分隔符拼接: {}", context, e)) - })?; - Ok(joined_os_str) - } - - /// 解析整个 `env` Table,直接返回安全的环境变量 Map - pub fn parse_env(value: &Value) -> mlua::Result> { - let ctx = Self::Env; - - let Value::Table(tbl) = value else { - // 这里作为防御性校验,外部虽然 match 过了,但内层仍保持强类型安全 - return Err(validation_error(format!( - "{} 必须是键值表 (table),实际类型是 {}", - ctx, - value.type_name() - ))); - }; - - let mut env_map = HashMap::new(); - let mut n = 0i32; - - for pair in tbl.pairs::() { - n += 1; - - let (raw_key, raw_val) = pair?; - debug!("env {} {}", n, raw_val.type_name()); - - // 1. 解析并校验 Key,拿到安全的 String - let key = Self::parse_env_name(&raw_key)?; - - // 2. 借用 &name 传递给 Value 解析器作为上下文 - let val = Self::parse_env_val(&ctx, &key, &raw_val)?; - - env_map.insert(key, val); - } - - Ok(env_map) - } -} - -impl LuaValidator { - /// 校验环境变量名的合法性(Windows 约束:非空、不含 '='、不含 NUL) - fn parse_aliases_name(name: &Value) -> mlua::Result { - Self::parse_name(name) - } - fn parse_aliases_val( - context: &LuaValidator, - name: &str, - raw_val: &Value, - ) -> mlua::Result> { - context.parse_val(name, raw_val)?; - - // let ctx = format!("{} [{}]", context, name); - // 预估容量:标量为 1,表取其实际长度(设置上限以防止异常输入) - let capacity = match raw_val { - Value::Table(t) => (t.raw_len() as usize).min(16), - Value::Nil => 0, - _ => 1, - }; - - let mut parts = Vec::with_capacity(capacity); - Self::collect_value_into(context, raw_val, &mut parts)?; - Ok(parts) - } - /// 解析并打平别名表 (aliases) - /// - 支持输入为 Nil / None / Table - /// - 别名的值支持:String, Number, Boolean, Nil, " " 空白串, Table(连续数组) - /// - 字符串作为整体参数保存,仅做 trim() 清理首尾空格,不按空格拆分 - /// - 包含拓扑展开与死环检测 - pub fn parse_aliases(value: &Value) -> mlua::Result>> { - let ctx = Self::Aliases; - // 1. 处理 nil / None 的情况,直接返回空 HashMap - let Value::Table(table) = value else { - return Err(validation_error(format!( - "{} 必须是键值表 (table) ,当前类型: {}", - ctx, - value.type_name() - ))); - }; - - // 阶段一:提取原始别名映射 (Raw Extraction) - let mut raw_aliases: HashMap> = HashMap::new(); - - for pair in table.pairs::() { - let (raw_key, raw_val) = pair?; - let key = Self::parse_aliases_name(&raw_key)?; - let val = Self::parse_aliases_val(&ctx, &key, &raw_val)?; - - raw_aliases.insert(key, val); - } - // 阶段二:递归拓扑打平与循环引用检测 (Flattening & Cycle Detection) - let mut visited_stack = HashSet::new(); - - for key in raw_aliases.keys() { - visited_stack.clear(); - Self::detect_alias_cycle(key, &raw_aliases, &mut visited_stack)?; - } - - // 阶段三:无环前提下的高效展开 - let mut flattened_aliases: HashMap> = - HashMap::with_capacity(raw_aliases.len()); - - let mut resolved_args = Vec::new(); - - for key in raw_aliases.keys() { - resolved_args.clear(); - Self::expand_alias_dfs(key, &raw_aliases, &mut resolved_args); - flattened_aliases.insert(key.clone(), resolved_args.clone()); - } - - Ok(flattened_aliases) - } - - /// 仅用于校验别名依赖图中是否存在死循环(不消耗额外的参数拼接内存) - fn detect_alias_cycle( - current_key: &str, - raw_aliases: &HashMap>, - visited_stack: &mut HashSet, - ) -> mlua::Result<()> { - // 递归栈中再次遇到相同的 Key,说明存在死循环 - if visited_stack.contains(current_key) { - return Err(validation_error(format!( - "配置加载失败: 检测到别名循环嵌套依赖 'mr:{}'", - current_key - ))); - } - - if let Some(args) = raw_aliases.get(current_key) { - // 压栈 - visited_stack.insert(current_key.to_string()); - - for arg in args { - let arg_str = arg.to_string_lossy(); - if let Some(sub_key) = arg_str.strip_prefix("mr:") { - // 如果引用的子别名在映射表中存在,则深度优先校验 - if raw_aliases.contains_key(sub_key) { - Self::detect_alias_cycle(sub_key, raw_aliases, visited_stack)?; - } - } - } - - // 出栈(回溯) - visited_stack.remove(current_key); - } - - Ok(()) - } - /// 安全拓扑展开:在保证绝对无环的前提下递归展开 mr: 前缀参数 - fn expand_alias_dfs( - current_key: &str, - raw_aliases: &HashMap>, - out: &mut Vec, - ) { - if let Some(args) = raw_aliases.get(current_key) { - for arg in args { - let arg_str = arg.to_string_lossy(); - if let Some(sub_key) = arg_str.strip_prefix("mr:") { - if raw_aliases.contains_key(sub_key) { - // 安全地直接递归展开,无需再检查死循环 - Self::expand_alias_dfs(sub_key, raw_aliases, out); - } else { - // 找不到对应的别名,按原样参数输出 - out.push(arg.clone()); - } - } else { - // 普通参数,直接输出 - out.push(arg.clone()); - } - } - } - } -} diff --git a/mirror-shim/node.lua b/mirror-shim/node.lua new file mode 100644 index 0000000..1d7fd74 --- /dev/null +++ b/mirror-shim/node.lua @@ -0,0 +1,39 @@ +--- +--- Generated by EmmyLua(https://github.com/EmmyLua) +--- Created by Administrator. +--- DateTime: 2026/9/10 20:16 +--- + +-- tools/node.lua +local home = base_dir .. "/tools/node" +local version_file = ".version" + +-- 安全读取当前激活版本:未激活或文件不存在时返回 nil,不抛出异常 +local ver = mr.version(home, version_file) + +return { + -- 1. target 配置为 Table,自动对应 Rust 侧的 TargetConfig::Versioned + target = { + home = home, + version_file = version_file, + -- 若版本未激活,current 为 nil;若激活,则拼出绝对路径 + current = ver and (home .. "/" .. ver .. "/node.exe") or nil + }, + + -- 2. 默认追加参数 + args = {}, + + -- 3. 别名映射(mr: 前缀可直接展开) + aliases = { + ["v"] = { "--version" }, + }, + + -- 4. 环境变量联动:将对应版本的 bin 目录前置到 PATH + env = { + NODE_HOME = ver and (home .. "/" .. ver) or "", + PATH = { + ver and (home .. "/" .. ver) or "", + get_env("PATH") + } + } +} \ No newline at end of file