//! 环境检测实现(架构 §5) //! //! 只做「探测」不执行用户命令:调用系统 `--version` 类只读命令并解析输出; //! 进程执行统一走 `std::process::Command`,不做 shell 拼接。 use std::path::{Path, PathBuf}; use std::process::Command; use crate::model::{Capabilities, PlatformEnv, RuntimeInfo, Runtimes, Shells}; /// 全量环境检测(Windows / Linux 双平台) pub fn detect_env() -> PlatformEnv { #[cfg(windows)] let (os, os_version) = (String::from("windows"), windows_os_version()); #[cfg(not(windows))] let (os, os_version) = (String::from("linux"), linux_os_version()); let (distro, distro_version) = distro_info(); PlatformEnv { os, os_version, arch: std::env::consts::ARCH.to_string(), distro, distro_version, shells: detect_shells(), runtimes: detect_runtimes(), path_entries: path_entries(), capabilities: detect_capabilities(), } } #[cfg(windows)] fn distro_info() -> (Option, Option) { (None, None) } #[cfg(not(windows))] fn distro_info() -> (Option, Option) { parse_os_release(&std::fs::read_to_string("/etc/os-release").unwrap_or_default()) } /// 解析 os-release 内容,返回 (发行版名, 版本号)。 /// 独立成纯函数以便跨平台单元测试。 pub fn parse_os_release(content: &str) -> (Option, Option) { let mut id = None; let mut ver = None; for line in content.lines() { if let Some(v) = line.strip_prefix("ID=") { id = Some(v.trim().trim_matches('"').to_string()); } else if let Some(v) = line.strip_prefix("VERSION_ID=") { ver = Some(v.trim().trim_matches('"').to_string()); } } (id, ver) } /// 从命令输出中提取首个版本号(如 "v24.18.0" -> "24.18.0")。 /// 规则:取第一段以数字开头、由数字与点组成的子串,尾部点剔除。 pub fn extract_version(output: &str) -> Option { let bytes = output.as_bytes(); let n = bytes.len(); let mut i = 0; while i < n { if bytes[i].is_ascii_digit() { let mut j = i; while j < n && (bytes[j].is_ascii_digit() || bytes[j] == b'.') { j += 1; } let s = output[i..j].trim_end_matches('.'); if !s.is_empty() { return Some(s.to_string()); } i = j; } else { i += 1; } } None } /// 按平台分隔符拆分 PATH fn path_entries() -> Vec { std::env::var("PATH") .unwrap_or_default() .split(path_list_sep()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()) .collect() } #[cfg(windows)] fn path_list_sep() -> char { ';' } #[cfg(not(windows))] fn path_list_sep() -> char { ':' } /// 在 PATH 中解析可执行文件。 /// Windows 优先 .exe/.cmd/.bat(CreateProcess 无法直接执行无扩展名文件, /// 例如 nodejs 目录下的 npm bash 脚本),最后才回退无扩展名。 fn resolve_program(program: &str) -> Option { let direct = Path::new(program); if direct.is_absolute() && direct.is_file() { return Some(direct.to_path_buf()); } let exts: &[&str] = if cfg!(windows) { &[".exe", ".cmd", ".bat", ""] } else { &[""] }; for dir in path_entries() { for ext in exts { let cand = Path::new(&dir).join(format!("{program}{ext}")); if cand.is_file() { return Some(cand); } } } None } /// 探测一个可执行文件:返回状态 / 版本 / 路径。 /// 结果分类:installed / not_in_path / permission_denied / exec_failed / version_unparseable。 fn probe(program: &str, args: &[&str]) -> Option { let path = resolve_program(program); let path_str = path.as_ref().map(|p| p.to_string_lossy().to_string()); let Some(exe) = path else { return Some(RuntimeInfo { status: "not_in_path".into(), version: None, path: None, }); }; let mut cmd = Command::new(&exe); cmd.args(args); #[cfg(windows)] { use std::os::windows::process::CommandExt; // CREATE_NO_WINDOW:探测命令不闪黑框 cmd.creation_flags(0x0800_0000); } let output = match cmd.output() { Ok(o) => o, Err(e) => { // 架构 §5:把「权限不足」与一般执行失败拆分开(总工 Wave 0 🟡) let status = if e.kind() == std::io::ErrorKind::PermissionDenied { "permission_denied" } else { "exec_failed" }; return Some(RuntimeInfo { status: status.into(), version: None, path: path_str, }); } }; if !output.status.success() { return Some(RuntimeInfo { status: "exec_failed".into(), version: None, path: path_str, }); } let stdout = String::from_utf8_lossy(&output.stdout); let text = if stdout.trim().is_empty() { String::from_utf8_lossy(&output.stderr).to_string() } else { stdout.to_string() }; match extract_version(&text) { Some(version) => Some(RuntimeInfo { status: "installed".into(), version: Some(version), path: path_str, }), None => Some(RuntimeInfo { status: "version_unparseable".into(), version: None, path: path_str, }), } } fn detect_runtimes() -> Runtimes { Runtimes { node: probe("node", &["--version"]), npm: probe("npm", &["--version"]), python: probe_python(), uv: probe("uv", &["--version"]), git: probe("git", &["--version"]), winget: probe("winget", &["--version"]), apt: probe("apt", &["--version"]), } } /// Python:优先 `python --version`,失败退回 `py --version`(Windows 启动器) fn probe_python() -> Option { let direct = probe("python", &["--version"]); if matches!(direct, Some(ref i) if i.status == "installed") { return direct; } probe("py", &["--version"]) } fn detect_shells() -> Shells { Shells { powershell_version: probe_powershell(), pwsh_version: probe("pwsh", &["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"]) .and_then(|i| i.version), bash_available: resolve_program("bash").is_some(), } } #[cfg(windows)] fn probe_powershell() -> Option { probe( "powershell", &["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"], ) .and_then(|i| i.version) } #[cfg(not(windows))] fn probe_powershell() -> Option { None } fn detect_capabilities() -> Capabilities { #[cfg(windows)] { Capabilities { // Windows Credential Manager 常驻可用(架构 §4.1) keyring: "ok".into(), // `net session` 仅在管理员上下文返回成功(提权探测,仅检测不执行用户命令) can_elevate: run_success("net", &["session"]), } } #[cfg(not(windows))] { Capabilities { keyring: if resolve_program("secret-tool").is_some() { "ok".into() } else { "missing".into() }, can_elevate: std::env::var("USER").map(|u| u == "root").unwrap_or(false), } } } fn run_success(program: &str, args: &[&str]) -> bool { let mut cmd = Command::new(program); cmd.args(args); #[cfg(windows)] { use std::os::windows::process::CommandExt; cmd.creation_flags(0x0800_0000); } cmd.output().map(|o| o.status.success()).unwrap_or(false) } #[cfg(windows)] fn windows_os_version() -> String { use winreg::enums::HKEY_LOCAL_MACHINE; use winreg::RegKey; let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); match hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion") { Ok(key) => { let product: String = key.get_value("ProductName").unwrap_or_else(|_| "Windows".into()); let display: String = key.get_value("DisplayVersion").unwrap_or_default(); let build: String = key.get_value("CurrentBuildNumber").unwrap_or_default(); let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(0); let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0); let ver = if !display.is_empty() { display } else { format!("{major}.{minor}") }; format!("{product} {ver} (Build {build})") } Err(_) => "Windows (unknown version)".into(), } } #[cfg(not(windows))] fn linux_os_version() -> String { let content = std::fs::read_to_string("/etc/os-release").unwrap_or_default(); for line in content.lines() { if let Some(v) = line.strip_prefix("PRETTY_NAME=") { return v.trim().trim_matches('"').to_string(); } } "Linux (unknown distro)".into() } #[cfg(test)] mod tests { use super::*; #[test] fn extracts_version_from_common_outputs() { assert_eq!(extract_version("v24.18.0"), Some("24.18.0".into())); assert_eq!(extract_version("11.16.0\n"), Some("11.16.0".into())); assert_eq!(extract_version("Python 3.14.6"), Some("3.14.6".into())); assert_eq!(extract_version("git version 2.47.1.windows.1"), Some("2.47.1".into())); assert_eq!(extract_version("GNU bash, version 5.2.26(1)-release"), Some("5.2.26".into())); assert_eq!(extract_version("v1.8.1911"), Some("1.8.1911".into())); assert_eq!(extract_version("5.1.26100.1"), Some("5.1.26100.1".into())); } #[test] fn extracts_none_when_no_digits() { assert_eq!(extract_version("command not found"), None); assert_eq!(extract_version(""), None); } #[test] fn trims_trailing_dots() { assert_eq!(extract_version("v1.2.3."), Some("1.2.3".into())); } #[test] fn parses_os_release_distro_and_version() { let content = "NAME=\"Ubuntu\"\nVERSION=\"24.04.1 LTS (Noble Numbat)\"\nID=ubuntu\nID_LIKE=debian\nVERSION_ID=\"24.04\"\n"; let (distro, ver) = parse_os_release(content); assert_eq!(distro.as_deref(), Some("ubuntu")); assert_eq!(ver.as_deref(), Some("24.04")); } #[test] fn parses_os_release_missing_fields() { let (distro, ver) = parse_os_release("NAME=\"Other\"\n"); assert_eq!(distro, None); assert_eq!(ver, None); } #[cfg(windows)] #[test] fn windows_distro_is_none() { let env = detect_env(); assert_eq!(env.distro, None); assert_eq!(env.distro_version, None); } #[test] fn env_has_path_on_windows() { // PATH 变量在任何真实 Windows/Linux 上都存在 assert!(!path_entries().is_empty(), "PATH 不应为空"); } /// 实机自测:打印本机环境检测快照(cargo test -p agentdock-platform -- --ignored --nocapture) #[test] #[ignore] fn print_detect_env_snapshot() { let env = detect_env(); println!("{:#?}", env); assert_eq!(env.os, "windows"); assert!(!env.os_version.is_empty()); assert!(!env.arch.is_empty()); } }