use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, thiserror::Error)]
pub enum BrowserError {
#[error("Invalid URL: {0}")]
InvalidUrl(String),
#[error("URL contains dangerous characters: {0}")]
DangerousCharacters(String),
#[error("IO error: {0}")]
IoError(String),
}
fn validate_shell_safe(input: &str) -> Result<(), BrowserError> {
let dangerous = ['&', '|', ';', '`', '\n', '\r', '\x00'];
for ch in input.chars() {
if dangerous.contains(&ch) {
return Err(BrowserError::DangerousCharacters(format!(
"character '{}' not allowed",
if ch == '\n' {
"\\n".to_string()
} else if ch == '\r' {
"\\r".to_string()
} else if ch == '\x00' {
"\\x00".to_string()
} else {
ch.to_string()
}
)));
}
if ('\u{0080}'..='\u{009F}').contains(&ch) {
return Err(BrowserError::DangerousCharacters(format!(
"control character U+{:04X} not allowed",
ch as u32
)));
}
}
if input.contains("$(") {
return Err(BrowserError::DangerousCharacters(
"pattern '$(' not allowed".to_string(),
));
}
Ok(())
}
fn validate_url_format(url: &str) -> Result<(), BrowserError> {
if url.is_empty() {
return Err(BrowserError::InvalidUrl("URL cannot be empty".to_string()));
}
let dangerous_schemes = ["javascript:", "data:", "vbscript:"];
for dangerous in dangerous_schemes {
if url.to_lowercase().starts_with(dangerous) {
return Err(BrowserError::InvalidUrl(format!(
"URL scheme '{}' is not allowed",
dangerous.trim_end_matches(':')
)));
}
}
if url.starts_with('/')
|| url.starts_with("./")
|| url.starts_with("../")
|| url.contains('\\')
|| (url.len() > 1 && url.as_bytes().get(1) == Some(&b':'))
{
return Ok(());
}
if let Some(scheme_end) = url.find("://") {
let scheme = &url[..scheme_end];
let allowed_schemes = [
"http", "https", "ftp", "ftps", "file", "mailto", "tel", "ws", "wss",
];
if !allowed_schemes.contains(&scheme) {
return Err(BrowserError::InvalidUrl(format!(
"URL scheme '{}' is not allowed",
scheme
)));
}
if scheme == "file" {
validate_file_url(url)?;
}
}
Ok(())
}
fn validate_file_url(url: &str) -> Result<(), BrowserError> {
let path_str = if let Some(stripped) = url.strip_prefix("file://localhost/") {
stripped.to_string()
} else if let Some(stripped) = url.strip_prefix("file:///") {
stripped.to_string()
} else if let Some(stripped) = url.strip_prefix("file://") {
let rest = stripped;
if let Some(first_slash) = rest.find('/') {
if !rest[..first_slash].is_empty() && !rest.starts_with('/') {
return Err(BrowserError::InvalidUrl(
"Remote file:// URLs not allowed".to_string(),
));
}
}
rest.to_string()
} else {
return Err(BrowserError::InvalidUrl("Invalid file:// URL".to_string()));
};
let path = Path::new(&path_str);
for component in path.components() {
use std::path::Component;
match component {
Component::ParentDir => {
return Err(BrowserError::InvalidUrl(
"Path traversal not allowed in file:// URLs".to_string(),
));
}
Component::RootDir => {
let path_str_lower = path_str.to_lowercase();
for sensitive in &[
"/etc/passwd",
"/etc/shadow",
"/etc/hosts",
"/etc/sudoers",
"/root/",
"/boot/",
"/sys/",
"/proc/",
] {
if path_str_lower.starts_with(sensitive) {
return Err(BrowserError::InvalidUrl(format!(
"Access to {} is not allowed",
sensitive
)));
}
}
}
_ => {}
}
}
Ok(())
}
fn validate_input(input: &str) -> Result<(), BrowserError> {
validate_shell_safe(input)?;
validate_url_format(input)?;
Ok(())
}
pub fn open_browser(url: &str) -> bool {
if validate_input(url).is_err() {
return false;
}
#[cfg(target_os = "macos")]
let result = Command::new("open").arg(url).spawn();
#[cfg(target_os = "linux")]
let result = Command::new("xdg-open").arg(url).spawn();
#[cfg(target_os = "windows")]
let result = Command::new("cmd").args(["/C", "start", "", url]).spawn();
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let result: Result<std::process::Child, std::io::Error> = Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"Unsupported platform",
));
result.is_ok()
}
pub fn open_url(url: &str) -> Result<(), BrowserError> {
validate_input(url)?;
#[cfg(target_os = "macos")]
let child = Command::new("open")
.arg(url)
.spawn()
.map_err(|e| BrowserError::IoError(e.to_string()))?;
#[cfg(target_os = "linux")]
let child = Command::new("xdg-open")
.arg(url)
.spawn()
.map_err(|e| BrowserError::IoError(e.to_string()))?;
#[cfg(target_os = "windows")]
let child = Command::new("cmd")
.args(["/C", "start", "", url])
.spawn()
.map_err(|e| BrowserError::IoError(e.to_string()))?;
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
return Err(BrowserError::IoError("Unsupported platform".to_string()));
drop(child);
Ok(())
}
pub fn open_file(path: &str) -> bool {
open_browser(path)
}
pub fn open_folder(path: &str) -> bool {
if validate_input(path).is_err() {
return false;
}
#[cfg(target_os = "macos")]
let result = Command::new("open").arg(path).spawn();
#[cfg(target_os = "linux")]
let result = Command::new("xdg-open").arg(path).spawn();
#[cfg(target_os = "windows")]
let result = Command::new("explorer").arg(path).spawn();
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let result: Result<std::process::Child, std::io::Error> = Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"Unsupported platform",
));
result.is_ok()
}
pub fn reveal_in_finder(path: &str) -> bool {
if validate_input(path).is_err() {
return false;
}
#[cfg(target_os = "macos")]
let result = Command::new("open").args(["-R", path]).spawn();
#[cfg(target_os = "linux")]
let result = {
let parent = std::path::Path::new(path)
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string());
Command::new("xdg-open").arg(&parent).spawn()
};
#[cfg(target_os = "windows")]
let result = Command::new("explorer").args(["/select,", path]).spawn();
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let result: Result<std::process::Child, std::io::Error> = Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"Unsupported platform",
));
result.is_ok()
}