374 lines
11 KiB
Rust
374 lines
11 KiB
Rust
//! 环境检测实现(架构 §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<String>, Option<String>) {
|
||
(None, None)
|
||
}
|
||
|
||
#[cfg(not(windows))]
|
||
fn distro_info() -> (Option<String>, Option<String>) {
|
||
parse_os_release(&std::fs::read_to_string("/etc/os-release").unwrap_or_default())
|
||
}
|
||
|
||
/// 解析 os-release 内容,返回 (发行版名, 版本号)。
|
||
/// 独立成纯函数以便跨平台单元测试。
|
||
pub fn parse_os_release(content: &str) -> (Option<String>, Option<String>) {
|
||
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<String> {
|
||
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<String> {
|
||
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<PathBuf> {
|
||
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<RuntimeInfo> {
|
||
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<RuntimeInfo> {
|
||
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<String> {
|
||
probe(
|
||
"powershell",
|
||
&["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"],
|
||
)
|
||
.and_then(|i| i.version)
|
||
}
|
||
|
||
#[cfg(not(windows))]
|
||
fn probe_powershell() -> Option<String> {
|
||
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());
|
||
}
|
||
}
|