use super::error::WasiError;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct SandboxConfig {
pub working_directory: PathBuf,
pub follow_symlinks: bool,
pub require_existence: bool,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
working_directory: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
follow_symlinks: true,
require_existence: true,
}
}
}
#[derive(Debug, Clone)]
pub struct SandboxValidator {
config: SandboxConfig,
}
impl SandboxValidator {
pub fn new(config: SandboxConfig) -> Self {
Self { config }
}
pub fn with_working_directory(working_directory: PathBuf) -> Self {
Self::new(SandboxConfig {
working_directory,
..Default::default()
})
}
pub fn validate_path(&self, pattern: &str) -> Result<PathBuf, WasiError> {
let expanded = self.expand_home(pattern);
let absolute = if expanded.is_relative() {
self.config.working_directory.join(&expanded)
} else {
expanded
};
let resolved = if self.config.follow_symlinks {
if self.config.require_existence {
absolute.canonicalize().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
WasiError::PathNotFound(absolute.clone())
} else {
WasiError::SymlinkResolution {
path: absolute.clone(),
source: e,
}
}
})?
} else {
self.normalize_path(&absolute)
}
} else {
self.normalize_path(&absolute)
};
let is_relative =
pattern.starts_with("./") || pattern.starts_with("../") || !pattern.starts_with('/');
if is_relative && !pattern.starts_with('~') {
let cwd_canonical = self
.config
.working_directory
.canonicalize()
.unwrap_or_else(|_| self.config.working_directory.clone());
if !resolved.starts_with(&cwd_canonical) {
return Err(WasiError::sandbox_escape(pattern, &resolved));
}
}
Ok(resolved)
}
pub fn validate_directory(&self, pattern: &str) -> Result<PathBuf, WasiError> {
let resolved = self.validate_path(pattern)?;
if self.config.require_existence && !resolved.is_dir() {
return Err(WasiError::NotADirectory(resolved));
}
Ok(resolved)
}
fn expand_home(&self, path: &str) -> PathBuf {
if let Some(suffix) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(suffix);
}
} else if path == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
}
PathBuf::from(path)
}
fn normalize_path(&self, path: &Path) -> PathBuf {
let mut components = Vec::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
if matches!(components.last(), Some(std::path::Component::Normal(_))) {
components.pop();
} else {
components.push(component);
}
}
std::path::Component::CurDir => {
}
_ => {
components.push(component);
}
}
}
components.iter().collect()
}
}
pub fn validate_env_pattern(pattern: &str) -> Result<(), WasiError> {
if pattern.is_empty() {
return Err(WasiError::InvalidEnvPattern("empty pattern".to_string()));
}
let wildcard_count = pattern.matches('*').count();
if wildcard_count > 1 {
return Err(WasiError::InvalidEnvPattern(format!(
"multiple wildcards not supported: {}",
pattern
)));
}
if wildcard_count == 1 && !pattern.ends_with('*') {
return Err(WasiError::InvalidEnvPattern(format!(
"wildcard must be at end of pattern: {}",
pattern
)));
}
if !pattern
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '*')
{
return Err(WasiError::InvalidEnvPattern(format!(
"invalid characters in pattern: {}",
pattern
)));
}
if pattern == "*" {
return Err(WasiError::InvalidEnvPattern(
"bare wildcard '*' not allowed (too permissive)".to_string(),
));
}
Ok(())
}
pub fn expand_env_pattern(pattern: &str) -> Result<Vec<(String, String)>, WasiError> {
validate_env_pattern(pattern)?;
if let Some(prefix) = pattern.strip_suffix('*') {
Ok(std::env::vars()
.filter(|(key, _)| key.starts_with(prefix))
.collect())
} else {
match std::env::var(pattern) {
Ok(value) => Ok(vec![(pattern.to_string(), value)]),
Err(std::env::VarError::NotPresent) => {
Ok(vec![])
}
Err(std::env::VarError::NotUnicode(_)) => Err(WasiError::InvalidEnvPattern(format!(
"environment variable '{}' contains invalid unicode",
pattern
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_env_pattern_valid() {
assert!(validate_env_pattern("HOME").is_ok());
assert!(validate_env_pattern("PATH").is_ok());
assert!(validate_env_pattern("MY_VAR").is_ok());
assert!(validate_env_pattern("MY_*").is_ok());
assert!(validate_env_pattern("MYAPP_CONFIG_*").is_ok());
}
#[test]
fn test_validate_env_pattern_invalid() {
assert!(validate_env_pattern("").is_err());
assert!(validate_env_pattern("*").is_err());
assert!(validate_env_pattern("*_SECRET").is_err());
assert!(validate_env_pattern("MY_*_VAR").is_err());
assert!(validate_env_pattern("MY_*_*").is_err());
assert!(validate_env_pattern("MY-VAR").is_err());
assert!(validate_env_pattern("MY.VAR").is_err());
}
#[test]
fn test_normalize_path() {
let validator = SandboxValidator::new(SandboxConfig {
working_directory: PathBuf::from("/home/user"),
follow_symlinks: false,
require_existence: false,
});
let result = validator.normalize_path(Path::new("/home/user/./data"));
assert_eq!(result, PathBuf::from("/home/user/data"));
let result = validator.normalize_path(Path::new("/home/user/data/../config"));
assert_eq!(result, PathBuf::from("/home/user/config"));
}
#[test]
fn test_expand_home() {
let validator = SandboxValidator::new(SandboxConfig::default());
if dirs::home_dir().is_some() {
let expanded = validator.expand_home("~/data");
assert!(!expanded.to_string_lossy().contains('~'));
assert!(expanded.to_string_lossy().ends_with("/data"));
}
}
}