Files
Agent-Tools/crates/agentdock-platform/src/detect.rs
T

374 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 环境检测实现(架构 §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/.batCreateProcess 无法直接执行无扩展名文件,
/// 例如 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());
}
}