//! 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 { 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. /// /// Returns patterns filtered by the current platform. Platform-specific /// rules (e.g. `format` on Windows, `rm` on Unix) are only injected on /// their target platform to avoid false positives. pub fn dangerous_command_patterns() -> Vec { dangerous_command_patterns_for_platform(Platform::current()) } /// Platform-specific dangerous command patterns. /// /// Exposed primarily for testing. Callers should prefer /// [`dangerous_command_patterns`] which auto-detects the platform. pub fn dangerous_command_patterns_for_platform(platform: Platform) -> Vec { let mut patterns: Vec = Vec::new(); // Cross-platform: fork bomb patterns.push(r":\(\)\s*\{.*\};\s*:".to_string()); match platform { Platform::Unix => { // Unix dangerous commands patterns.push(r"\brm\s+-[rf]{1,2}\b".to_string()); patterns.push(r"\bchmod\s+-[Rr]".to_string()); patterns.push(r"\bchown\s+-[Rr]".to_string()); } Platform::Windows => { // Windows cmd dangerous commands. // `format` requires a drive letter (`[a-z]:`) somewhere after it, // so legitimate uses like `dart format lib/` or // `pytest --format json` (no drive letter) are not matched. patterns.push(r"\bformat\s+.*[a-z]:".to_string()); patterns.push(r"\bdel\s+/[fq]\b".to_string()); patterns.push(r"\brmdir\s+/s\b".to_string()); // PowerShell dangerous commands. Patterns are lowercase because // `guard_command` lowercases the command string before matching. patterns.push(r"\bremove-item\s+.*-recurse".to_string()); patterns.push(r"\bremove-item\s+.*-force".to_string()); } } patterns } /// Disable Windows console QuickEdit mode. /// /// With QuickEdit enabled (the default), clicking the console window enters /// mark/selection mode and silently blocks every console write from this /// process until the user presses Enter — making the gateway look frozen. /// Disabling it keeps keyboard input working while preventing mouse-click /// freezes. No-op on non-Windows platforms or when no console is attached. /// /// If stdin is redirected (so `GetStdHandle(STD_INPUT_HANDLE)` is not a /// console handle), falls back to opening `CONIN$` to reach the attached /// console directly. Note that a manual "Edit → Mark" from the title-bar /// menu can still freeze console writes; non-blocking log writers in /// `logging.rs` keep the runtime alive in that case. pub fn disable_console_quick_edit() { #[cfg(target_os = "windows")] windows_disable_console_quick_edit(); } #[cfg(target_os = "windows")] fn windows_disable_console_quick_edit() { const STD_INPUT_HANDLE: i32 = -10; const ENABLE_QUICK_EDIT_MODE: u32 = 0x0040; const ENABLE_EXTENDED_FLAGS: u32 = 0x0080; const GENERIC_READ: u32 = 0x8000_0000; const GENERIC_WRITE: u32 = 0x4000_0000; const FILE_SHARE_READ: u32 = 0x0000_0001; const FILE_SHARE_WRITE: u32 = 0x0000_0002; const OPEN_EXISTING: u32 = 3; const INVALID_HANDLE_VALUE: isize = -1; #[allow(non_snake_case)] unsafe extern "system" { fn GetStdHandle(n_std_handle: i32) -> isize; fn GetConsoleMode(h_console_handle: isize, lp_mode: *mut u32) -> i32; fn SetConsoleMode(h_console_handle: isize, dw_mode: u32) -> i32; fn CreateFileW( lp_file_name: *const u16, dw_desired_access: u32, dw_share_mode: u32, lp_security_attributes: *const std::ffi::c_void, dw_creation_disposition: u32, dw_flags_and_attributes: u32, h_template_file: isize, ) -> isize; fn CloseHandle(h_object: isize) -> i32; } unsafe { let mut mode: u32 = 0; let mut opened_conin = false; let stdin = GetStdHandle(STD_INPUT_HANDLE); let console = if stdin != 0 && stdin != INVALID_HANDLE_VALUE && GetConsoleMode(stdin, &mut mode) != 0 { stdin } else { // stdin is redirected/closed but a console may still be attached // (e.g. output shown in a window started via `start /B`). Open // CONIN$ to reach the console input buffer directly. let name: Vec = "CONIN$\0".encode_utf16().collect(); let conin = CreateFileW( name.as_ptr(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, std::ptr::null(), OPEN_EXISTING, 0, 0, ); if conin == INVALID_HANDLE_VALUE || GetConsoleMode(conin, &mut mode) == 0 { if conin != INVALID_HANDLE_VALUE { let _ = CloseHandle(conin); } return; } opened_conin = true; conin }; if mode & ENABLE_QUICK_EDIT_MODE != 0 { let new_mode = (mode & !ENABLE_QUICK_EDIT_MODE) | ENABLE_EXTENDED_FLAGS; let _ = SetConsoleMode(console, new_mode); } // The mode change is a property of the console itself, so the // CONIN$ handle can be released immediately. if opened_conin { let _ = CloseHandle(console); } } } /// 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 { // 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) } /// 返回 PicoBot 主目录,若无法确定则回退到当前目录 `"."`。 /// /// 包装 [`home_dir`] 并内置 fallback,消除各模块重复的 /// `dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))` 模式。 pub fn picobot_home_dir() -> PathBuf { home_dir().unwrap_or_else(|| PathBuf::from(".")) } /// 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()); // Cross-platform fork bomb rule is always present assert!(patterns.iter().any(|p| p.contains(r":\(\)"))); } #[test] fn test_dangerous_patterns_unix() { let patterns = dangerous_command_patterns_for_platform(Platform::Unix); // Unix-specific rules assert!(patterns.iter().any(|p| p.contains("rm"))); assert!(patterns.iter().any(|p| p.contains("chmod"))); assert!(patterns.iter().any(|p| p.contains("chown"))); // Windows-specific rules must NOT be present on Unix assert!(!patterns.iter().any(|p| p.contains("format"))); assert!(!patterns.iter().any(|p| p.contains("del"))); assert!(!patterns.iter().any(|p| p.contains("remove-item"))); } #[test] fn test_dangerous_patterns_windows() { let patterns = dangerous_command_patterns_for_platform(Platform::Windows); // Windows-specific rules assert!(patterns.iter().any(|p| p.contains("del"))); assert!(patterns.iter().any(|p| p.contains("format"))); assert!(patterns.iter().any(|p| p.contains("remove-item"))); // Unix-specific rules must NOT be present on Windows. // Use r"\brm\s" to match rm-as-command without matching rmdir. assert!(!patterns.iter().any(|p| p.contains(r"\brm\s"))); assert!(!patterns.iter().any(|p| p.contains("chmod"))); assert!(!patterns.iter().any(|p| p.contains("chown"))); } #[test] fn test_format_pattern_precision() { let patterns = dangerous_command_patterns_for_platform(Platform::Windows); let format_pat = patterns .iter() .find(|p| p.contains("format")) .expect("format pattern should exist on Windows"); let re = regex::Regex::new(format_pat).unwrap(); // Helper: guard_command lowercases before matching, so tests must too. let m = |cmd: &str| re.is_match(&cmd.to_lowercase()); // Truly dangerous commands — should match assert!(m("format c:")); assert!(m("format /q c:")); assert!(m("format d: /fs:ntfs")); assert!(m("echo ok | format c:")); assert!(m("echo ok; format c:")); // Sub-shell invocation must also be caught assert!(m(r#"cmd /c "format C:""#)); // Legitimate commands containing literal "format " — should NOT match assert!(!m("dart format lib/")); assert!(!m("buf format -w")); assert!(!m("pytest --format json")); assert!(!m(r#"echo "please format the disk""#)); assert!(!m(r#"git log --pretty=format:"%h""#)); } #[test] fn test_xml_escape() { assert_eq!(xml_escape("a & b"), "a & b"); assert_eq!(xml_escape(""), "<tag>"); } }