diff --git a/Cargo.lock b/Cargo.lock index 6fab4a9..1f4aa55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -151,6 +151,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "crossbeam-channel" version = "0.5.16" @@ -166,36 +175,150 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "inquire" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" +dependencies = [ + "bitflags", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -214,6 +337,18 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -263,14 +398,28 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + [[package]] name = "mirror-cli" version = "0.1.0" dependencies = [ "anyhow", "clap", + "inquire", "mirror-core", "mlua", + "tempfile", "tracing", ] @@ -427,6 +576,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -459,12 +614,40 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -510,6 +693,37 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "smallvec" version = "1.15.2" @@ -550,6 +764,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.20" @@ -695,6 +922,18 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "utf8parse" version = "0.2.2" @@ -707,6 +946,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "which" version = "8.0.6" @@ -716,6 +961,28 @@ dependencies = [ "libc", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/mirror-cli/Cargo.toml b/mirror-cli/Cargo.toml index ec1b2c3..aa20da0 100644 --- a/mirror-cli/Cargo.toml +++ b/mirror-cli/Cargo.toml @@ -11,6 +11,8 @@ path = "src/main.rs" [dependencies] mirror-core = { path = "../mirror-core" } clap = { version = "4.6.6", features = ["cargo", "color", "derive","string"] } +inquire="0.9.4" +tempfile="3.27.0"# 用于安全原子写入 mlua = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } diff --git a/mirror-cli/src/cli.rs b/mirror-cli/src/cli.rs index 4583cc9..c09ec95 100644 --- a/mirror-cli/src/cli.rs +++ b/mirror-cli/src/cli.rs @@ -2,16 +2,20 @@ use crate::commands::dynamic::MrCommand; use clap::{Arg, ArgAction, Command}; use mirror_core::LogLevel; use std::ffi::OsString; +// 核心保留字:系统底座命令,神圣不可侵犯 +const RESERVED_COMMANDS: &[&str] = &["use", "log", "help"]; // 引入强类型枚举 pub fn build(dynamic_cmds: MrCommand) -> Command { let mut app = Command::new("mr") .about(env!("CARGO_PKG_DESCRIPTION")) .version(env!("CARGO_PKG_VERSION")) .arg_required_else_help(true) - .subcommand(log_subcommand()); + .subcommand(log_subcommand()) + .subcommand(use_subcommand()); for (name, sub_cmd) in dynamic_cmds.commands { - if name == "log" { + if RESERVED_COMMANDS.contains(&name.as_str()) { + // 用户要是手痒写了 use.lua 或 log.lua,直接装作没看见 continue; } // let a =name.as_str() @@ -24,6 +28,24 @@ pub fn build(dynamic_cmds: MrCommand) -> Command { } app } +/// 核心内置命令:版本切换 +fn use_subcommand() -> Command { + Command::new("use") + .about("切换指定工具的生效版本 (支持交互式选择)") + .arg( + Arg::new("tool") + .required(true) + .value_name("TOOL") + .help("目标工具名称(在 mirror.lua 中声明的 key,例如 node)"), + ) + .arg( + Arg::new("version") + .required(false) + .value_name("VERSION") + .help("目标版本号(省略时进入交互式单选)"), + ) +} + /// 构建基础 CLI 骨架(内置 Rust 原生命令与参数规则) fn log_subcommand() -> Command { Command::new("log") diff --git a/mirror-cli/src/commands/dynamic.rs b/mirror-cli/src/commands/dynamic.rs index 2071471..2d706d5 100644 --- a/mirror-cli/src/commands/dynamic.rs +++ b/mirror-cli/src/commands/dynamic.rs @@ -3,7 +3,7 @@ use mirror_core::{conversion_err, validation_error}; use mlua::{FromLua, Lua, Value}; use std::collections::HashMap; -const ALLOWED_KEYS: &[&str] = &["handler", "description"]; +const ALLOWED_KEYS: &[&str] = &["module", "description"]; #[derive(Debug, Default, Clone)] pub struct MrCommand { diff --git a/mirror-cli/src/commands/mod.rs b/mirror-cli/src/commands/mod.rs index 5632530..5afb850 100644 --- a/mirror-cli/src/commands/mod.rs +++ b/mirror-cli/src/commands/mod.rs @@ -1,2 +1,5 @@ pub(crate) mod builtin; -pub(crate) mod dynamic; \ No newline at end of file +pub(crate) mod dynamic; +mod use_cmd; + +pub use use_cmd::switch_version; \ No newline at end of file diff --git a/mirror-cli/src/commands/use_cmd/mod.rs b/mirror-cli/src/commands/use_cmd/mod.rs new file mode 100644 index 0000000..512d749 --- /dev/null +++ b/mirror-cli/src/commands/use_cmd/mod.rs @@ -0,0 +1,145 @@ +use inquire::Select; +use std::fs::{self, File}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use tempfile::NamedTempFile; + +// 引入你的 mirror-core 类型 +use mirror_core::{MirrorRegistry, TargetConfig}; + +#[derive(Debug)] +pub enum UseError { + ToolNotFound(String), + NotVersioned(String), + HomeDirNotFound(PathBuf), + NoVersionsAvailable(PathBuf), + UserCancelled, + Io(io::Error), + TargetValidationFailed(PathBuf), +} + +/// 执行版本切换主流程 +pub fn switch_version( + registry: &MirrorRegistry, + tool_name: &str, + target_version: Option<&str>, +) -> Result<(), UseError> { + // 1. 获取配置并断言必须是多版本工具 + let spec = registry + .targets + .get(tool_name) + .ok_or_else(|| UseError::ToolNotFound(tool_name.to_string()))?; + + let (home, version_file) = match &spec.target { + TargetConfig::Versioned { + home, version_file, .. + } => { + let v_file = version_file.as_deref().unwrap_or(".version"); + (home, v_file) + } + TargetConfig::Static(_) => { + return Err(UseError::NotVersioned(tool_name.to_string())); + } + }; + + if !home.exists() { + return Err(UseError::HomeDirNotFound(home.clone())); + } + + // 2. 扫描候选版本目录 + let available_versions = scan_versions(home)?; + if available_versions.is_empty() { + return Err(UseError::NoVersionsAvailable(home.clone())); + } + + // 3. 确定目标版本(命令行传入 OR 唤起 Inquire 单选菜单) + let selected_ver = match target_version { + Some(ver) => { + // 如果用户传了参,校验是否在候选集中(防呆) + if !available_versions.iter().any(|v| v == ver) { + eprintln!( + "警告: 目录 {:?} 下未发现版本 '{}',仍将强制写入。", + home, ver + ); + } + ver.to_string() + } + None => { + // 尝试读取当前已激活的版本,作为 Inquire 的默认初始高亮项 + let current_version_path = home.join(version_file); + let current_ver = fs::read_to_string(¤t_version_path) + .map(|s| s.trim().to_string()) + .ok(); + + prompt_user_select(&available_versions, current_ver.as_deref())? + } + }; + + // 4. 原子化持久化写入版本文件 + let dest_version_path = home.join(version_file); + atomic_write(&dest_version_path, &selected_ver).map_err(UseError::Io)?; + + println!("✔ 已成功将 '{}' 切换至版本: {}", tool_name, selected_ver); + Ok(()) +} + +/// 扫描 home 目录下的非隐藏有效子文件夹作为版本列表 +fn scan_versions(home: &Path) -> Result, UseError> { + let mut versions = Vec::new(); + + let entries = fs::read_dir(home).map_err(UseError::Io)?; + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.is_dir() { + if let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) { + // 排除以点开头的隐藏目录(如 .git, .cache) + if !dir_name.starts_with('.') { + versions.push(dir_name.to_string()); + } + } + } + } + + // 自然排序版本列表 + versions.sort(); + Ok(versions) +} + +/// 唤起 Inquire 进行交互单选 +fn prompt_user_select( + versions: &[String], + default_version: Option<&str>, +) -> Result { + let mut prompt = Select::new("请选择要激活的目标版本:", versions.to_vec()); + + // 如果能读取到当前版本,默认选中该项 + if let Some(def) = default_version { + if let Some(idx) = versions.iter().position(|v| v == def) { + prompt.starting_cursor = idx; + } + } + + prompt.prompt().map_err(|_| UseError::UserCancelled) +} + +/// 使用临时文件同目录重命名实现“原子写入” +/// +/// 杜绝中途掉电、杀死进程导致的空文件损坏 +fn atomic_write(dest_path: &Path, content: &str) -> io::Result<()> { + let parent_dir = dest_path + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Parent dir not found"))?; + + // 必须在目标文件的同级目录下创建临时文件,跨盘符/分区会导致原子重命名 (Rename) 失败 + let mut temp_file = NamedTempFile::new_in(parent_dir)?; + + // 写入纯净内容,并强制落盘刷新 (Flush) + temp_file.write_all(content.trim().as_bytes())?; + temp_file.write_all(b"\n")?; // 末尾留标准换行符 + temp_file.as_file().sync_all()?; + + // 核心操作:原子覆盖(在 Windows 上内部会调用 ReplaceFileW,在 Unix 则是 rename) + temp_file.persist(dest_path).map_err(|e| e.error)?; + + Ok(()) +} diff --git a/mirror-cli/src/executor.rs b/mirror-cli/src/executor.rs index 80bbad6..429a9cf 100644 --- a/mirror-cli/src/executor.rs +++ b/mirror-cli/src/executor.rs @@ -2,8 +2,10 @@ use crate::cli; use crate::commands::{builtin, dynamic::MrCommand}; use crate::error::ExecuteError; use crate::plugins::register_mr_command; +use crate::commands::switch_version; use anyhow::{Context, Result}; use mirror_core::{Layout, LuaRuntime, register_basics}; +use mirror_core::MirrorRegistry; // 成功的标准退出码 pub const EXIT_SUCCESS: i32 = 0; @@ -14,7 +16,7 @@ pub const EXIT_COMMAND_NOT_FOUND: i32 = 12; // 找不到指定的子命令 pub const EXIT_BUILTIN_ERROR: i32 = 13; // 原生命令(如 log)执行失败 pub fn run(layout: &Layout) -> Result<(), ExecuteError> { - let registry_file = layout.base_dir.join("commands").join("commands.lua"); + let runtime = LuaRuntime::builder() .with(|lua| register_basics(lua, layout)) @@ -22,6 +24,7 @@ pub fn run(layout: &Layout) -> Result<(), ExecuteError> { .build() .map_err(|e| ExecuteError::new(EXIT_RUNTIME_ERROR, e))?; + let registry_file = layout.base_dir.join("commands").join("commands.lua"); let commands: MrCommand = runtime .eval_script(®istry_file) .with_context(|| format!("加载命令配置失败: {}", registry_file.display())) @@ -31,6 +34,24 @@ pub fn run(layout: &Layout) -> Result<(), ExecuteError> { let matches = app.get_matches(); match matches.subcommand() { + + Some(("use", sub_matches)) => { + let tool = sub_matches.get_one::("tool").unwrap(); + let version = sub_matches.get_one::("version").map(|s| s.as_str()); + + // 惰性加载 mirror.lua:按需获取 MirrorRegistry + let mirror_file = layout.base_dir.join("mirror.lua"); + let registry: MirrorRegistry = runtime + .eval_script(&mirror_file) + .with_context(|| format!("解析主配置失败: {}", mirror_file.display())) + .map_err(|e| ExecuteError::new(EXIT_CONFIG_ERROR, e))?; + + if let Err(e) = switch_version(®istry, tool, version) { + eprintln!("[mr] 切换失败: {:?}", e); + std::process::exit(1); + } + } + // 分支 A: 原生 Rust 实现的命令 Some(("log", sub_m)) => { builtin::log_handle(&layout, sub_m) @@ -58,7 +79,7 @@ pub fn run(layout: &Layout) -> Result<(), ExecuteError> { } // 分支 C: 未输入子命令(通常已被 Clap 的 arg_required_else_help 拦截) - None => {} + None => {}//unreachable!(), } Ok(()) } diff --git a/mirror-cli/src/plugins.rs b/mirror-cli/src/plugins.rs index 43222f3..1f64466 100644 --- a/mirror-cli/src/plugins.rs +++ b/mirror-cli/src/plugins.rs @@ -1,4 +1,4 @@ -use mirror_core::{Error, Result}; +use mirror_core::Result; use mlua::{Lua, Table, Value}; /// 仅存在于 CLI 侧的 DSL 扩展函数 diff --git a/mirror-core/Cargo.toml b/mirror-core/Cargo.toml index 1529630..31adc53 100644 --- a/mirror-core/Cargo.toml +++ b/mirror-core/Cargo.toml @@ -15,3 +15,9 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } tracing-appender = { workspace = true } thiserror = "2.0.20" + + + +[features] +default = [] +args = [] \ No newline at end of file diff --git a/mirror-core/src/alias.rs b/mirror-core/src/alias.rs new file mode 100644 index 0000000..1783753 --- /dev/null +++ b/mirror-core/src/alias.rs @@ -0,0 +1,52 @@ +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 + where + I: IntoIterator, + S: AsRef, + { + let arg_iter = args.into_iter(); + + // 1. 预分配容量:利用迭代器的下限提示,避免多次 Realloc + let (lower_bound, _) = arg_iter.size_hint(); + let mut expanded_args: Vec = Vec::with_capacity(lower_bound); + + for i in arg_iter { + let os_str = i.as_ref(); + + let arg_str = os_str.to_string_lossy(); + + // 检查参数是否带有 mr: 前缀 + if let Some(alias_key) = arg_str.strip_prefix("mr:") { + // 如果在加载期打平好的字典中找到了对应的别名,直接展开追加 + if let Some(alias_values) = self.aliases.get(alias_key) { + expanded_args.extend(alias_values.iter().cloned()); + } else { + // 如果找不到对应的别名,按原样参数追加 + expanded_args.push(os_str.to_os_string()); + } + } else { + // 普通参数,直接追加 + expanded_args.push(os_str.to_os_string()); + } + } + expanded_args + } + +} + + diff --git a/mirror-core/src/layout.rs b/mirror-core/src/layout.rs index bb8879d..dca3ae8 100644 --- a/mirror-core/src/layout.rs +++ b/mirror-core/src/layout.rs @@ -4,7 +4,6 @@ use std::path::{Path, PathBuf}; use tracing::debug; pub struct Layout { pub base_dir: PathBuf, - pub bin_dir: PathBuf, pub target_name: String, } @@ -23,7 +22,6 @@ impl Layout { if let Ok(home_val) = env::var("MIRROR_HOME") { let trimmed = home_val.trim(); if !trimmed.is_empty() { - debug!(home = %trimmed, "检测到 MIRROR_HOME,采用环境变量配置"); return Self::from_base_dir(PathBuf::from(trimmed), target_name); @@ -41,11 +39,16 @@ impl Layout { bail!("指定的根目录不存在或不是有效目录: [{}]", base_dir.display()); } - let bin_dir = base_dir.join("bin"); + let mirror_file = base_dir.join("mirror.lua"); + if !mirror_file.is_file() { + bail!( + "根目录 [{}] 下未找到核心配置文件 [mirror.lua]", + base_dir.display() + ); + } Ok(Self { base_dir, - bin_dir, target_name, }) } @@ -53,20 +56,55 @@ impl Layout { /// 从当前可执行文件解析 shim 安装目录布局 fn from_executable(exe_path: &Path, target_name: String) -> Result { // let exe_path = exe_path.as_ref(); - let bin_dir = exe_path + let start_dir = exe_path .parent() - .with_context(|| format!("无法获取程序 [{}] 的父级 bin 目录", exe_path.display()))? - .to_path_buf(); + .with_context(|| format!("无法获取程序 [{}] 的所在目录", exe_path.display()))?; - debug!("bin_dir目录 {}", bin_dir.display()); + debug!("可执行程序所在目录: {}", start_dir.display()); - let base_dir = bin_dir - .parent() - .with_context(|| format!("无法获取 bin 目录 [{}] 的父级 root 目录", bin_dir.display()))? - .to_path_buf(); + // let base_dir = start_dir + // .parent() + // .with_context(|| format!("无法获取 bin 目录 [{}] 的父级 root 目录", bin_dir.display()))? + // .to_path_buf(); - debug!("base_dir 目录 {}", base_dir.display()); + // 2. 向上回溯寻找 mirror.lua 锚点 + let base_dir = find_base_dir_by_anchor(start_dir).ok_or_else(|| { + anyhow::anyhow!( + "路径推导失败:从 [{}] 向上逐级回溯均未找到 [mirror.lua]。\n\ + 提示: 请确保程序位于包含 mirror.lua 的项目内,或配置 MIRROR_HOME 环境变量。", + start_dir.display() + ) + })?; + debug!("成功探测到 base_dir 目录 {}", base_dir.display()); - Self::from_base_dir(base_dir, target_name) + // 此时 base_dir 下必然有 mirror.lua,直接构建 + Ok(Self { + base_dir, + target_name, + }) } } +/// 向上查找包含 mirror.lua 的根目录 +fn find_base_dir_by_anchor(start_dir: &Path) -> Option { + let mut current = Some(start_dir); + + // 最大向上回溯层级(例如 3~4 层,防止死循环跑到磁盘根目录甚至网络挂载点) + let mut depth = 0; + const MAX_DEPTH: usize = 4; + + while let Some(dir) = current { + if depth > MAX_DEPTH { + break; + } + + // 只要当前目录下有 mirror.lua,这里就是真正的 base_dir! + if dir.join("mirror.lua").is_file() { + return Some(dir.to_path_buf()); + } + + current = dir.parent(); + depth += 1; + } + + None +} diff --git a/mirror-core/src/lib.rs b/mirror-core/src/lib.rs index fbe9a2b..0601da2 100644 --- a/mirror-core/src/lib.rs +++ b/mirror-core/src/lib.rs @@ -6,15 +6,16 @@ mod runtime; mod utils; mod validators; mod model; +mod alias; +pub use model::{MirrorRegistry,MirrorTarget,TargetConfig}; pub use layout::Layout; // pub use mirror_shim::mirror::Mirror; pub use runtime::LuaRuntime; -pub use logger::{LogConfig, LogLevel, init_logging_from}; +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::{Error, Result,validation_error,conversion_err,TargetExecutionError}; -pub use model::TargetConfig; \ No newline at end of file +pub use error::{conversion_err, validation_error, Error, Result, TargetExecutionError}; \ No newline at end of file diff --git a/mirror-core/src/model.rs b/mirror-core/src/model.rs index 7c2ca66..fde4e5a 100644 --- a/mirror-core/src/model.rs +++ b/mirror-core/src/model.rs @@ -1,10 +1,115 @@ +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::validation_error; -use crate::validators::ensure_no_nul; +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{ diff --git a/mirror-core/src/plugins.rs b/mirror-core/src/plugins.rs index 16cf4a4..5b0dbfe 100644 --- a/mirror-core/src/plugins.rs +++ b/mirror-core/src/plugins.rs @@ -1,7 +1,8 @@ use crate::error::{Error, Result}; use crate::{Layout, normalize_path_for_lua}; use mlua::{Lua, Table, Value}; -use std::env; +use std::path::PathBuf; +use std::{env, fs}; use tracing::{debug, warn}; const MIRROR_DIR: &str = "__MIRROR_DIR__"; @@ -12,6 +13,7 @@ pub fn register_basics(lua: &Lua, layout: &Layout) -> Result<()> { register_mirror_dir(lua, &base_dir)?; register_get_env(lua)?; setup_require(lua, &base_dir)?; + register_mr_version(lua)?; Ok(()) } @@ -109,3 +111,52 @@ pub fn setup_require(lua: &Lua, base_dir: &str) -> Result<()> { Ok(()) } + +/// 向 Lua 全局环境注册 `mr` 模块及其原生辅助函数 +pub fn register_mr_version(lua: &Lua) -> Result<()> { + // 1. 获取或创建全局 `mr` Table + let globals = lua.globals(); + let mr_table = match globals.get::("mr")? { + Value::Table(t) => t, + _ => { + let t = lua.create_table()?; + globals.set("mr", t.clone())?; + t + } + }; + + // 2. 实现 mr.version(home, version_file) -> Option + // 规则: + // - version_file 为可选参数,缺省默认使用 ".version" + // - 文件不存在、空内容或读取失败时,安全返回 nil,绝不让 Lua 脚本崩盘 + // - 自动 trim 掉末尾的 \r\n,仅提取纯净版本号 + let version_fn = lua.create_function( + |_lua, (home_str, version_file_opt): (String, Option)| { + let version_filename = version_file_opt + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| ".version".to_string()); + + let mut target_path = PathBuf::from(home_str); + target_path.push(version_filename); + + // 读取文件并安全修剪 + match fs::read_to_string(&target_path) { + Ok(content) => { + let trimmed = content.trim(); + if trimmed.is_empty() { + Ok(None::) // 文件内容为空字符串时,视为未设置版本 + } else { + Ok(Some(trimmed.to_string())) + } + } + // 常见错误(NotFound 等)直接返回 nil,不抛出 Lua error + Err(_) => Ok(None::), + } + }, + )?; + + // 3. 挂载到 mr 表 + mr_table.set("version", version_fn)?; + + Ok(()) +} diff --git a/mirror-core/src/runtime.rs b/mirror-core/src/runtime.rs index d0f1d1a..77043c5 100644 --- a/mirror-core/src/runtime.rs +++ b/mirror-core/src/runtime.rs @@ -102,7 +102,7 @@ mod tests { let root = std::env::temp_dir().join("rshim-test-layout"); Layout { base_dir: root.clone(), - bin_dir: root.join("bin"), + // bin_dir: root.join("bin"), target_name: "muna".to_string(), } } diff --git a/mirror-core/src/utils.rs b/mirror-core/src/utils.rs index 0a6e730..e265113 100644 --- a/mirror-core/src/utils.rs +++ b/mirror-core/src/utils.rs @@ -68,4 +68,166 @@ pub fn lua_string_2_os_string(s: &LuaString) -> mlua::Result { Ok(OsString::from_wide(&buf)) } } -} \ No newline at end of file +} + + + +/// 将输入的字符串按 Shell 规则切分为独立的 CLI 参数 Token +/// - 自动过滤连续空格 +/// - 支持单引号 `'...'` 和双引号 `"..."` 包裹包含空格的参数 +pub fn parse_tokens(input: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current_token = String::new(); + let mut in_quote: Option = None; + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + match (ch, in_quote) { + // 处理转义字符 (例如 \") + ('\\', _quote) => { + if let Some(&next_ch) = chars.peek() { + let should_escape = if cfg!(windows) { + // Windows 策略:只有在转义引号、反斜杠本身时才剥离 \ + // (如果在双引号内部,空格也不应该被 \ 转义) + next_ch == '"' || next_ch == '\'' || next_ch == '\\' + } else { + // Unix 策略:标准 Shell 转义(引号、反斜杠、空格等) + next_ch == '"' + || next_ch == '\'' + || next_ch == '\\' + || next_ch.is_whitespace() + }; + + if should_escape { + chars.next(); // 消耗掉下一个字符 + current_token.push(next_ch); + } else { + // 保留 Windows 路径分隔符或未知转义中的 \ + current_token.push('\\'); + } + } else { + // 结尾孤立的 \ + current_token.push('\\'); + } + } + // 遇到引号:开启或关闭引号包裹 + ('"' | '\'', None) => { + in_quote = Some(ch); + } + ('"' | '\'', Some(q)) if q == ch => { + in_quote = None; + } + // 引号外部遇到空白字符:切分出一个完整的 Token + (ch, None) if ch.is_whitespace() => { + if !current_token.is_empty() { + tokens.push(OsString::from(std::mem::take(&mut current_token))); + } + } + // 其他字符或引号内部字符:直接追加 + (ch, _) => { + current_token.push(ch); + } + } + } + + // 收尾最后一个 Token + if !current_token.is_empty() { + tokens.push(OsString::from(current_token)); + } + + tokens +} +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + /// 辅助宏:简化声明与断言对比 + macro_rules! assert_tokens { + ($input:expr, $expected:expr) => { + let actual = parse_tokens($input); + let expected_os: Vec = $expected.into_iter().map(OsString::from).collect(); + assert_eq!( + actual, expected_os, + "\n测试输入: {:?}\n期望输出: {:?}\n实际输出: {:?}", + $input, expected_os, actual + ); + }; + } + #[test] + fn test_parse_tokens_basic_split() { + // 场景 1:基础多参数拆分(空格分隔) + assert_tokens!("cargo run --verbose", vec!["cargo", "run", "--verbose"]); + assert_tokens!("git status", vec!["git", "status"]); + } + + #[test] + fn test_parse_tokens_multi_alias_ref() { + // 场景 2:多别名混合与组合引用 + assert_tokens!( + "mr:run --bin mr:base_flags", + vec!["mr:run", "--bin", "mr:base_flags"] + ); + assert_tokens!( + "mr:app1 mr:app2 --flag", + vec!["mr:app1", "mr:app2", "--flag"] + ); + } + + #[test] + fn test_parse_tokens_continuous_whitespaces() { + // 场景 3:连续多空格与制表符过滤 + assert_tokens!( + "mr:run --bin \t my_app", + vec!["mr:run", "--bin", "my_app"] + ); + assert_tokens!(" cargo build ", vec!["cargo", "build"]); + } + + #[test] + fn test_parse_tokens_double_quotes() { + // 场景 4:双引号包裹包含空格的参数 + assert_tokens!( + "git commit -m \"fix a bug\"", + vec!["git", "commit", "-m", "fix a bug"] + ); + assert_tokens!("echo \"hello world\"", vec!["echo", "hello world"]); + } + + #[test] + fn test_parse_tokens_single_quotes() { + // 场景 5:单引号包裹包含空格的参数 + assert_tokens!( + "gcc -O2 'my file.c' -o app", + vec!["gcc", "-O2", "my file.c", "-o", "app"] + ); + assert_tokens!( + "python 'script with space.py'", + vec!["python", "script with space.py"] + ); + } + + #[test] + fn test_parse_tokens_escaped_characters() { + // 场景 6:反斜杠转义字符 + assert_tokens!("echo hello\\ world", vec!["echo", "hello\\", "world"]); + assert_tokens!( + "echo \"hello \\\"world\\\"\"", + vec!["echo", "hello \"world\""] + ); + } + + #[test] + fn test_parse_tokens_single_scalar_and_edge_cases() { + // 场景 7:单标量参数与边界情况 + assert_tokens!("git", vec!["git"]); + assert_tokens!("8000", vec!["8000"]); + assert_tokens!("", Vec::<&str>::new()); + assert_tokens!(" ", Vec::<&str>::new()); + } + + #[test] + fn test_parse_tokens_unclosed_quotes() { + // 场景 8:未闭合引号的容错处理(会尽量追加到当前 Token 中) + assert_tokens!("echo \"hello world", vec!["echo", "hello world"]); + } +} diff --git a/mirror-core/src/validators.rs b/mirror-core/src/validators.rs index 7b8186b..6f63ced 100644 --- a/mirror-core/src/validators.rs +++ b/mirror-core/src/validators.rs @@ -62,4 +62,535 @@ pub fn ensure_no_nul(context: &impl fmt::Display, os_str: &std::ffi::OsStr) -> m } } Ok(()) -} \ No newline at end of file +} +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/mirror.lua b/mirror-shim/mirror.lua index 63f6a0c..563be88 100644 --- a/mirror-shim/mirror.lua +++ b/mirror-shim/mirror.lua @@ -10,7 +10,6 @@ return { -- 1. 标准相对路径 + 正斜杠拼接 (最推荐,绿色便携) --------------------------------------------------- ["numa"] = { - home="", target = base_dir .. "/tools/numa/numa.exe", -- 追加参数 args = { "--help" }, diff --git a/mirror-shim/src/command.rs b/mirror-shim/src/command.rs new file mode 100644 index 0000000..92583d4 --- /dev/null +++ b/mirror-shim/src/command.rs @@ -0,0 +1,25 @@ +use mirror_core::MirrorTarget; +use std::ffi::OsString; +use std::path::Path; +use std::process::Command; +use tracing::debug; +pub fn to_command

(spec: &MirrorTarget, target: P, final_args: &[OsString]) -> Command +where + P: AsRef, +{ + let mut cmd = Command::new(target.as_ref()); + + // 2. 传入预打平的别名字典,查表并展开所有以 `mr:` 为前缀的别名 + // let final_args = self.resolve_args(combined_args); + debug!("拼接后的命令行参数{:?}", final_args); + // 3. 将解析展开后的无环参数一次性注入 Command + cmd.args(final_args); + + // 4. 注入配置好的环境变量 + for (key, val) in &spec.env { + // 直接应用环境变量(Lua 端已经处理好字符串拼接或列表合并) + cmd.env(key, val); + } + + cmd +} diff --git a/mirror-shim/src/executor.rs b/mirror-shim/src/executor.rs index c6b741d..2dd3707 100644 --- a/mirror-shim/src/executor.rs +++ b/mirror-shim/src/executor.rs @@ -1,4 +1,4 @@ -use crate::mirror::MirrorRegistry; +use mirror_core::MirrorRegistry; use crate::sys::{ ERROR_ELEVATION_REQUIRED, EXIT_FAILED_LOAD_SHIM, EXIT_FAILED_SPAWN_PROG, EXIT_FAILED_WAIT_PROG, EXIT_PROG_TERMINATED, execute_elevated, @@ -8,6 +8,7 @@ use std::env; use std::ffi::OsString; use std::process::exit; use tracing::{error, warn}; +use crate::command; pub fn run(layout: &Layout) -> i32 { let global_config = &layout.base_dir.join("mirror.lua"); @@ -62,17 +63,17 @@ pub fn run(layout: &Layout) -> i32 { let calling_args: Vec<_> = env::args_os().skip(1).collect(); let combined_args = mr.args.iter().chain(calling_args.iter()); - + let final_args = mr.resolve_args(combined_args); // 构建 Command:复用 ShimConfig::to_command(含 args/env 注入),避免重复逻辑 - let mut cmd = mr.to_command(target_path, combined_args); + let mut cmd = command::to_command(mr,target_path, &final_args); let mut child = match cmd.spawn() { Ok(v) => v, Err(e) if e.raw_os_error() == Some(ERROR_ELEVATION_REQUIRED) => { // 提权回退时需要完整参数:配置默认参数 + 调用方透传参数 - let elevated_args: Vec = cmd.get_args().map(|s| s.to_os_string()).collect(); + // let elevated_args: Vec = cmd.get_args().map(|s| s.to_os_string()).collect(); - exit(execute_elevated(target_path, &elevated_args, Some(&mr.env))) + exit(execute_elevated(target_path, &final_args, Some(&mr.env))) } Err(e) => { error!( diff --git a/mirror-shim/src/main.rs b/mirror-shim/src/main.rs index 913fc9a..0a29079 100644 --- a/mirror-shim/src/main.rs +++ b/mirror-shim/src/main.rs @@ -1,14 +1,13 @@ use crate::sys::*; use anyhow::Context; -use mirror_core::{Layout, init_logging_from}; +use mirror_core::{init_logging_from, Layout}; use std::{env, process::exit}; use tracing::debug; mod executor; -pub mod mirror; pub mod sys; -pub mod utils; pub mod validators; +pub mod command; fn main() { let current_exe = match env::current_exe().context("获取代理程序路径失败") { diff --git a/mirror-shim/src/mirror.rs b/mirror-shim/src/mirror.rs deleted file mode 100644 index 9ff8d10..0000000 --- a/mirror-shim/src/mirror.rs +++ /dev/null @@ -1,180 +0,0 @@ -use crate::validators::LuaValidator; -use anyhow::{Context, Result, bail}; -use mirror_core::validate_command_name; -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::{Path, PathBuf}; -use std::process::Command; -use tracing::{debug, trace}; - -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 MirrorTarget { - /// 1. 核心参数路由解析(Delegation 到 Spec 的路由逻辑) - fn resolve_args(&self, args: I) -> Vec - where - I: IntoIterator, - S: AsRef, - { - let arg_iter = args.into_iter(); - - // 1. 预分配容量:利用迭代器的下限提示,避免多次 Realloc - let (lower_bound, _) = arg_iter.size_hint(); - let mut expanded_args: Vec = Vec::with_capacity(lower_bound); - - for i in arg_iter { - let os_str = i.as_ref(); - - let arg_str = os_str.to_string_lossy(); - - // 检查参数是否带有 mr: 前缀 - if let Some(alias_key) = arg_str.strip_prefix("mr:") { - // 如果在加载期打平好的字典中找到了对应的别名,直接展开追加 - if let Some(alias_values) = self.aliases.get(alias_key) { - expanded_args.extend(alias_values.iter().cloned()); - } else { - // 如果找不到对应的别名,按原样参数追加 - expanded_args.push(os_str.to_os_string()); - } - } else { - // 普通参数,直接追加 - expanded_args.push(os_str.to_os_string()); - } - } - expanded_args - } - - pub fn to_command(&self, target: P, combined_args: I) -> Command - where - P: AsRef, - I: IntoIterator, - S: AsRef, - { - let mut cmd = Command::new(target.as_ref()); - - // 2. 传入预打平的别名字典,查表并展开所有以 `mr:` 为前缀的别名 - let final_args = self.resolve_args(combined_args); - debug!("拼接后的命令行参数{:?}", final_args); - // 3. 将解析展开后的无环参数一次性注入 Command - cmd.args(&final_args); - - // 4. 注入配置好的环境变量 - for (key, val) in &self.env { - // 直接应用环境变量(Lua 端已经处理好字符串拼接或列表合并) - cmd.env(key, val); - } - - cmd - } -} - -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, - }) - } -} diff --git a/mirror-shim/src/utils.rs b/mirror-shim/src/utils.rs deleted file mode 100644 index d29e947..0000000 --- a/mirror-shim/src/utils.rs +++ /dev/null @@ -1,225 +0,0 @@ -use mlua::LuaString; -use std::ffi::OsString; - -/// 将 Lua 字符串转为 Rust 字符串;非 UTF-8 按系统默认的 ANSI/OEM (如 GBK) 进行安全解码 -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 -/// - 自动过滤连续空格 -/// - 支持单引号 `'...'` 和双引号 `"..."` 包裹包含空格的参数 -pub fn parse_tokens(input: &str) -> Vec { - let mut tokens = Vec::new(); - let mut current_token = String::new(); - let mut in_quote: Option = None; - let mut chars = input.chars().peekable(); - - while let Some(ch) = chars.next() { - match (ch, in_quote) { - // 处理转义字符 (例如 \") - ('\\', _quote) => { - if let Some(&next_ch) = chars.peek() { - let should_escape = if cfg!(windows) { - // Windows 策略:只有在转义引号、反斜杠本身时才剥离 \ - // (如果在双引号内部,空格也不应该被 \ 转义) - next_ch == '"' || next_ch == '\'' || next_ch == '\\' - } else { - // Unix 策略:标准 Shell 转义(引号、反斜杠、空格等) - next_ch == '"' - || next_ch == '\'' - || next_ch == '\\' - || next_ch.is_whitespace() - }; - - if should_escape { - chars.next(); // 消耗掉下一个字符 - current_token.push(next_ch); - } else { - // 保留 Windows 路径分隔符或未知转义中的 \ - current_token.push('\\'); - } - } else { - // 结尾孤立的 \ - current_token.push('\\'); - } - } - // 遇到引号:开启或关闭引号包裹 - ('"' | '\'', None) => { - in_quote = Some(ch); - } - ('"' | '\'', Some(q)) if q == ch => { - in_quote = None; - } - // 引号外部遇到空白字符:切分出一个完整的 Token - (ch, None) if ch.is_whitespace() => { - if !current_token.is_empty() { - tokens.push(OsString::from(std::mem::take(&mut current_token))); - } - } - // 其他字符或引号内部字符:直接追加 - (ch, _) => { - current_token.push(ch); - } - } - } - - // 收尾最后一个 Token - if !current_token.is_empty() { - tokens.push(OsString::from(current_token)); - } - - tokens -} - -#[cfg(test)] -mod tests { - use super::*; - use std::ffi::OsString; - /// 辅助宏:简化声明与断言对比 - macro_rules! assert_tokens { - ($input:expr, $expected:expr) => { - let actual = parse_tokens($input); - let expected_os: Vec = $expected.into_iter().map(OsString::from).collect(); - assert_eq!( - actual, expected_os, - "\n测试输入: {:?}\n期望输出: {:?}\n实际输出: {:?}", - $input, expected_os, actual - ); - }; - } - #[test] - fn test_parse_tokens_basic_split() { - // 场景 1:基础多参数拆分(空格分隔) - assert_tokens!("cargo run --verbose", vec!["cargo", "run", "--verbose"]); - assert_tokens!("git status", vec!["git", "status"]); - } - - #[test] - fn test_parse_tokens_multi_alias_ref() { - // 场景 2:多别名混合与组合引用 - assert_tokens!( - "mr:run --bin mr:base_flags", - vec!["mr:run", "--bin", "mr:base_flags"] - ); - assert_tokens!( - "mr:app1 mr:app2 --flag", - vec!["mr:app1", "mr:app2", "--flag"] - ); - } - - #[test] - fn test_parse_tokens_continuous_whitespaces() { - // 场景 3:连续多空格与制表符过滤 - assert_tokens!( - "mr:run --bin \t my_app", - vec!["mr:run", "--bin", "my_app"] - ); - assert_tokens!(" cargo build ", vec!["cargo", "build"]); - } - - #[test] - fn test_parse_tokens_double_quotes() { - // 场景 4:双引号包裹包含空格的参数 - assert_tokens!( - "git commit -m \"fix a bug\"", - vec!["git", "commit", "-m", "fix a bug"] - ); - assert_tokens!("echo \"hello world\"", vec!["echo", "hello world"]); - } - - #[test] - fn test_parse_tokens_single_quotes() { - // 场景 5:单引号包裹包含空格的参数 - assert_tokens!( - "gcc -O2 'my file.c' -o app", - vec!["gcc", "-O2", "my file.c", "-o", "app"] - ); - assert_tokens!( - "python 'script with space.py'", - vec!["python", "script with space.py"] - ); - } - - #[test] - fn test_parse_tokens_escaped_characters() { - // 场景 6:反斜杠转义字符 - assert_tokens!("echo hello\\ world", vec!["echo", "hello\\", "world"]); - assert_tokens!( - "echo \"hello \\\"world\\\"\"", - vec!["echo", "hello \"world\""] - ); - } - - #[test] - fn test_parse_tokens_single_scalar_and_edge_cases() { - // 场景 7:单标量参数与边界情况 - assert_tokens!("git", vec!["git"]); - assert_tokens!("8000", vec!["8000"]); - assert_tokens!("", Vec::<&str>::new()); - assert_tokens!(" ", Vec::<&str>::new()); - } - - #[test] - fn test_parse_tokens_unclosed_quotes() { - // 场景 8:未闭合引号的容错处理(会尽量追加到当前 Token 中) - assert_tokens!("echo \"hello world", vec!["echo", "hello world"]); - } -} diff --git a/mirror-shim/src/validators.rs b/mirror-shim/src/validators.rs index b7a37b3..e66be62 100644 --- a/mirror-shim/src/validators.rs +++ b/mirror-shim/src/validators.rs @@ -1,539 +1,14 @@ -use anyhow::{Context, Result, anyhow}; -use mirror_core::{TargetConfig, validation_error}; -use mlua::{LuaString, Table, Value}; -use std::collections::{HashMap, HashSet}; +use anyhow::Context; +use mlua::{Table, Value}; use std::ffi::OsString; -use std::fmt; 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()); - } - } - } - } -} #[cfg(test)] mod tests { use super::*; - use crate::mirror::MirrorTarget; + use mirror_core::MirrorTarget; use mlua::{FromLua, Lua}; fn parse(src: &str) -> mlua::Result {