251 lines
7.4 KiB
Rust
251 lines
7.4 KiB
Rust
//! Platform abstraction layer for cross-platform compatibility.
|
|
//!
|
|
//! This module provides unified interfaces for platform-specific operations,
|
|
//! making it easy to add support for new platforms by modifying only this file.
|
|
|
|
use std::env;
|
|
use std::fs;
|
|
use std::io;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Supported platform types.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Platform {
|
|
Windows,
|
|
Unix,
|
|
}
|
|
|
|
impl Platform {
|
|
/// Detect the current platform.
|
|
pub fn current() -> Self {
|
|
if cfg!(target_os = "windows") {
|
|
Platform::Windows
|
|
} else {
|
|
Platform::Unix
|
|
}
|
|
}
|
|
|
|
/// Check if running on Windows.
|
|
pub fn is_windows() -> bool {
|
|
cfg!(target_os = "windows")
|
|
}
|
|
}
|
|
|
|
/// Shell information for command execution.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ShellInfo {
|
|
/// Tool name exposed to LLM.
|
|
pub name: &'static str,
|
|
/// Shell executable name.
|
|
pub executable: &'static str,
|
|
/// Arguments to pass before the command.
|
|
pub args: &'static [&'static str],
|
|
}
|
|
|
|
impl ShellInfo {
|
|
/// Get the default shell for the current platform.
|
|
pub fn default() -> Self {
|
|
Self::for_platform(Platform::current())
|
|
}
|
|
|
|
/// Get shell info for a specific platform.
|
|
pub fn for_platform(platform: Platform) -> Self {
|
|
match platform {
|
|
Platform::Windows => ShellInfo {
|
|
name: "shell",
|
|
executable: "powershell",
|
|
args: &["-Command"],
|
|
},
|
|
Platform::Unix => ShellInfo {
|
|
name: "bash",
|
|
executable: "bash",
|
|
args: &["-c"],
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Alternative shells available on the platform.
|
|
pub fn available_shells(platform: Platform) -> Vec<ShellInfo> {
|
|
match platform {
|
|
Platform::Windows => vec![
|
|
ShellInfo {
|
|
name: "shell",
|
|
executable: "powershell",
|
|
args: &["-Command"],
|
|
},
|
|
ShellInfo {
|
|
name: "shell",
|
|
executable: "cmd",
|
|
args: &["/C"],
|
|
},
|
|
],
|
|
Platform::Unix => vec![
|
|
ShellInfo {
|
|
name: "bash",
|
|
executable: "bash",
|
|
args: &["-c"],
|
|
},
|
|
// Future: could add zsh, fish, sh
|
|
// ShellInfo { name: "zsh", executable: "zsh", args: &["-c"] },
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Dangerous command patterns for safety guards.
|
|
pub fn dangerous_command_patterns() -> Vec<String> {
|
|
vec![
|
|
// Unix dangerous commands
|
|
r"\brm\s+-[rf]{1,2}\b".to_string(),
|
|
r"\bchmod\s+-[Rr]".to_string(),
|
|
r"\bchown\s+-[Rr]".to_string(),
|
|
// Windows dangerous commands
|
|
r"\bdel\s+/[fq]\b".to_string(),
|
|
r"\brmdir\s+/s\b".to_string(),
|
|
r"\bformat\s+".to_string(),
|
|
// PowerShell dangerous commands
|
|
r"\bRemove-Item\s+.*-Recurse".to_string(),
|
|
r"\bRemove-Item\s+.*-Force".to_string(),
|
|
// Fork bomb (cross-platform)
|
|
r":\(\)\s*\{.*\};\s*:".to_string(),
|
|
]
|
|
}
|
|
|
|
/// Get the user's home directory.
|
|
///
|
|
/// Supports environment variable overrides for testing:
|
|
/// - `HOME` (Unix-style, works on all platforms for testing)
|
|
/// - `USERPROFILE` (Windows-specific)
|
|
pub fn home_dir() -> Option<PathBuf> {
|
|
// Test scenario: support HOME variable override on all platforms
|
|
env::var_os("HOME")
|
|
.map(PathBuf::from)
|
|
.or_else(|| {
|
|
// Windows: support USERPROFILE
|
|
env::var_os("USERPROFILE").map(PathBuf::from)
|
|
})
|
|
.or_else(|| dirs::home_dir())
|
|
}
|
|
|
|
/// Atomically rename a file, handling platform differences.
|
|
///
|
|
/// On Windows, `fs::rename` fails if the destination exists, so we need to
|
|
/// remove it first. On Unix, rename is atomic and replaces the destination.
|
|
pub fn atomic_rename(src: &Path, dst: &Path) -> io::Result<()> {
|
|
if Platform::is_windows() && dst.exists() {
|
|
fs::remove_file(dst)?;
|
|
}
|
|
fs::rename(src, dst)
|
|
}
|
|
|
|
/// Convert a filesystem path to a file:// URI.
|
|
///
|
|
/// Handles platform-specific path formats:
|
|
/// - Unix: `/path/to/file` -> `file:///path/to/file`
|
|
/// - Windows: `C:\path\to\file` -> `file:///C:/path/to/file`
|
|
pub fn path_to_uri(path: &Path) -> String {
|
|
let path_str = path.display().to_string();
|
|
if Platform::is_windows() {
|
|
// Windows paths use backslashes which must be converted to forward slashes
|
|
let normalized = path_str.replace('\\', "/");
|
|
format!("file:///{}", normalized)
|
|
} else {
|
|
format!("file://{}", path_str)
|
|
}
|
|
}
|
|
|
|
/// XML escape utility.
|
|
pub fn xml_escape(value: &str) -> String {
|
|
value
|
|
.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_platform_detect() {
|
|
let platform = Platform::current();
|
|
if cfg!(target_os = "windows") {
|
|
assert_eq!(platform, Platform::Windows);
|
|
} else {
|
|
assert_eq!(platform, Platform::Unix);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_shell_info_default() {
|
|
let shell = ShellInfo::default();
|
|
if cfg!(target_os = "windows") {
|
|
assert_eq!(shell.executable, "powershell");
|
|
assert_eq!(shell.args, &["-Command"]);
|
|
} else {
|
|
assert_eq!(shell.executable, "bash");
|
|
assert_eq!(shell.args, &["-c"]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_shell_info_for_platform() {
|
|
let win_shell = ShellInfo::for_platform(Platform::Windows);
|
|
assert_eq!(win_shell.executable, "powershell");
|
|
|
|
let unix_shell = ShellInfo::for_platform(Platform::Unix);
|
|
assert_eq!(unix_shell.executable, "bash");
|
|
}
|
|
|
|
#[test]
|
|
fn test_path_to_uri() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let test_path = temp_dir.path().join("test.txt");
|
|
let uri = path_to_uri(&test_path);
|
|
|
|
assert!(uri.starts_with("file://"));
|
|
assert!(uri.contains("test.txt"));
|
|
assert!(!uri.contains('\\')); // No backslashes
|
|
}
|
|
|
|
#[test]
|
|
fn test_path_to_uri_windows_format() {
|
|
if cfg!(target_os = "windows") {
|
|
let win_path = PathBuf::from("C:\\Users\\test\\file.txt");
|
|
let uri = path_to_uri(&win_path);
|
|
assert!(uri.starts_with("file:///C:/"));
|
|
assert_eq!(uri, "file:///C:/Users/test/file.txt");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_atomic_rename() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let src = temp_dir.path().join("source.txt");
|
|
let dst = temp_dir.path().join("dest.txt");
|
|
|
|
fs::write(&src, "content").unwrap();
|
|
fs::write(&dst, "old content").unwrap();
|
|
|
|
atomic_rename(&src, &dst).unwrap();
|
|
|
|
assert!(!src.exists());
|
|
assert!(dst.exists());
|
|
assert_eq!(fs::read_to_string(&dst).unwrap(), "content");
|
|
}
|
|
|
|
#[test]
|
|
fn test_dangerous_patterns() {
|
|
let patterns = dangerous_command_patterns();
|
|
assert!(!patterns.is_empty());
|
|
// Should contain patterns for both platforms
|
|
assert!(patterns.iter().any(|p| p.contains("rm")));
|
|
assert!(patterns.iter().any(|p| p.contains("del")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_xml_escape() {
|
|
assert_eq!(xml_escape("a & b"), "a & b");
|
|
assert_eq!(xml_escape("<tag>"), "<tag>");
|
|
}
|
|
} |