use std::path::{Component, Path};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::RwLock;
use std::time::{Duration, Instant};
use tracing::warn;
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
pub use bamboo_infrastructure::config::settings::PermissionMode;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionType {
WriteFile,
ExecuteCommand,
GitWrite,
HttpRequest,
DeleteOperation,
TerminalSession,
}
impl PermissionType {
pub fn description(&self) -> &'static str {
match self {
PermissionType::WriteFile => "Write files to disk",
PermissionType::ExecuteCommand => "Execute shell commands",
PermissionType::GitWrite => "Perform Git write operations (commit, push, etc.)",
PermissionType::HttpRequest => "Make HTTP requests to external services",
PermissionType::DeleteOperation => "Delete files or directories",
PermissionType::TerminalSession => "Run interactive terminal sessions",
}
}
pub fn risk_level(&self) -> RiskLevel {
match self {
PermissionType::WriteFile => RiskLevel::Medium,
PermissionType::ExecuteCommand => RiskLevel::High,
PermissionType::GitWrite => RiskLevel::High,
PermissionType::HttpRequest => RiskLevel::Medium,
PermissionType::DeleteOperation => RiskLevel::High,
PermissionType::TerminalSession => RiskLevel::High,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RiskLevel {
Low,
Medium,
High,
}
impl RiskLevel {
pub fn label(&self) -> &'static str {
match self {
RiskLevel::Low => "Low Risk",
RiskLevel::Medium => "Medium Risk",
RiskLevel::High => "High Risk",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionRule {
pub tool_type: PermissionType,
pub resource_pattern: String,
pub allowed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl PermissionRule {
pub fn new(
tool_type: PermissionType,
resource_pattern: impl Into<String>,
allowed: bool,
) -> Self {
Self {
tool_type,
resource_pattern: resource_pattern.into(),
allowed,
expires_at: None,
}
}
pub fn with_expiration(mut self, expires_at: chrono::DateTime<chrono::Utc>) -> Self {
self.expires_at = Some(expires_at);
self
}
pub fn is_expired(&self) -> bool {
self.expires_at
.map(|exp| chrono::Utc::now() > exp)
.unwrap_or(false)
}
pub fn matches(&self, perm_type: PermissionType, resource: &str) -> bool {
if self.tool_type != perm_type {
return false;
}
if self.is_expired() {
return false;
}
let normalized_resource = match perm_type {
PermissionType::WriteFile => canonicalize_path_for_matching(resource),
_ => Some(resource.to_string()),
};
let normalized_resource = match normalized_resource {
Some(r) => r,
None => return false,
};
match_glob_pattern(&self.resource_pattern, &normalized_resource)
}
}
#[derive(Debug, Clone)]
pub struct SessionGrant {
pub granted_at: Instant,
pub expires_at: Instant,
pub resource_pattern: String,
}
impl SessionGrant {
pub fn new(resource_pattern: impl Into<String>, duration: Duration) -> Self {
let now = Instant::now();
Self {
granted_at: now,
expires_at: now + duration,
resource_pattern: resource_pattern.into(),
}
}
pub fn is_expired(&self) -> bool {
Instant::now() > self.expires_at
}
pub fn matches(&self, perm_type: PermissionType, resource: &str) -> bool {
if self.is_expired() {
return false;
}
let normalized_resource = match perm_type {
PermissionType::WriteFile => canonicalize_path_for_matching(resource),
_ => Some(resource.to_string()),
};
let normalized_resource = match normalized_resource {
Some(r) => r,
None => return false,
};
match_glob_pattern(&self.resource_pattern, &normalized_resource)
}
}
pub fn canonicalize_path_for_matching(path: &str) -> Option<String> {
let path_obj = Path::new(path);
if !path_obj.is_absolute() {
warn!("Permission check rejected non-absolute path: {}", path);
return None;
}
if has_path_traversal(path) {
warn!("Permission check rejected path with traversal: {}", path);
return None;
}
if let Ok(canonical) = std::fs::canonicalize(path_obj) {
let canonical_str = canonical.to_str()?.to_string();
#[cfg(windows)]
{
let normalized = if canonical_str.starts_with(r"\\?\") {
&canonical_str[4..]
} else {
&canonical_str
};
return Some(normalized.replace('\\', "/"));
}
#[cfg(not(windows))]
{
return Some(canonical_str);
}
}
if let Some(parent) = path_obj.parent() {
if let Some(file_name) = path_obj.file_name() {
if let Ok(canonical_parent) = std::fs::canonicalize(parent) {
let mut result = canonical_parent;
result.push(file_name);
#[cfg(windows)]
{
let result_str = result.to_str()?.to_string();
let normalized = if result_str.starts_with(r"\\?\") {
&result_str[4..]
} else {
&result_str
};
return Some(normalized.replace('\\', "/"));
}
#[cfg(not(windows))]
{
return Some(result.to_str()?.to_string());
}
}
}
}
let normalized = normalize_path_basic(path);
Some(normalized)
}
fn normalize_path_basic(path: &str) -> String {
let path = path.replace('\\', "/");
let components: Vec<&str> = path
.split('/')
.filter(|s| !s.is_empty() && *s != ".")
.collect();
if !components.is_empty() && components[0].ends_with(':') {
return components.join("/");
}
"/".to_string() + &components.join("/")
}
pub fn has_path_traversal(path: &str) -> bool {
Path::new(path)
.components()
.any(|c| matches!(c, Component::ParentDir))
}
pub fn open_file_no_follow(path: &Path) -> Result<std::fs::File, std::io::Error> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(false)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x00200000;
std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(false)
.attributes(FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)
}
#[cfg(not(any(unix, windows)))]
{
if let Ok(metadata) = std::fs::symlink_metadata(path) {
if metadata.file_type().is_symlink() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Path is a symbolic link",
));
}
}
std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(false)
.open(path)
}
}
pub fn open_file_for_write_secure(path: &Path) -> Result<std::fs::File, std::io::Error> {
if path.exists() {
return open_file_no_follow(path);
}
let parent = path.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Path has no parent directory",
)
})?;
let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Parent directory cannot be resolved: {}", e),
)
})?;
let parent_metadata = std::fs::metadata(&canonical_parent)?;
if !parent_metadata.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Parent path is not a directory",
));
}
let file_name = path.file_name().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "Path has no file name")
})?;
let canonical_path = canonical_parent.join(file_name);
if canonical_path.exists() {
return open_file_no_follow(&canonical_path);
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o644) .open(&canonical_path)
}
#[cfg(not(unix))]
{
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&canonical_path)
}
}
fn normalize_path_separators(path: &str) -> String {
path.replace('\\', "/")
}
pub(crate) fn match_glob_pattern(pattern: &str, resource: &str) -> bool {
let resource = normalize_path_separators(resource);
if pattern == "*" || pattern == "**/*" {
return true;
}
if pattern.starts_with("*.") && !pattern.contains('/') {
let suffix = &pattern[1..]; return resource.ends_with(suffix);
}
if match_pattern_internal(pattern, &resource) {
return true;
}
if resource.starts_with("/private/tmp/") && pattern.starts_with("/tmp/") {
let alt_resource = resource.replacen("/private/tmp/", "/tmp/", 1);
if match_pattern_internal(pattern, &alt_resource) {
return true;
}
}
if resource.starts_with("/tmp/") && pattern.starts_with("/private/tmp/") {
let alt_resource = resource.replacen("/tmp/", "/private/tmp/", 1);
if match_pattern_internal(pattern, &alt_resource) {
return true;
}
}
false
}
fn match_pattern_internal(pattern: &str, resource: &str) -> bool {
if pattern.ends_with("/*") && !pattern.contains("**") {
let prefix = &pattern[..pattern.len() - 1]; return resource.starts_with(prefix) && !resource[prefix.len()..].contains('/');
}
if let Some(prefix) = pattern.strip_suffix("/**") {
return resource.starts_with(prefix)
&& (resource.len() == prefix.len() || resource[prefix.len()..].starts_with('/'));
}
resource == pattern
}
#[derive(Debug)]
pub struct PermissionConfig {
whitelist: DashMap<String, PermissionRule>,
session_grants: DashMap<PermissionType, Vec<SessionGrant>>,
session_grant_duration: Duration,
enabled: AtomicBool,
mode: RwLock<PermissionMode>,
}
impl Default for PermissionConfig {
fn default() -> Self {
Self::new()
}
}
impl PermissionConfig {
pub fn new() -> Self {
Self {
whitelist: DashMap::new(),
session_grants: DashMap::new(),
session_grant_duration: Duration::from_secs(30 * 60), enabled: AtomicBool::new(true),
mode: RwLock::new(PermissionMode::Default),
}
}
pub fn with_settings(enabled: bool, session_duration: Duration) -> Self {
Self {
whitelist: DashMap::new(),
session_grants: DashMap::new(),
session_grant_duration: session_duration,
enabled: AtomicBool::new(enabled),
mode: RwLock::new(PermissionMode::Default),
}
}
pub fn is_enabled(&self) -> bool {
self.enabled.load(Ordering::Relaxed)
}
pub fn set_enabled(&self, enabled: bool) {
self.enabled.store(enabled, Ordering::Relaxed);
}
pub fn mode(&self) -> PermissionMode {
*self.mode.read().expect("mode lock poisoned")
}
pub fn set_mode(&self, mode: PermissionMode) {
*self.mode.write().expect("mode lock poisoned") = mode;
}
pub fn session_grant_duration(&self) -> Duration {
self.session_grant_duration
}
pub fn set_session_grant_duration(&mut self, duration: Duration) {
self.session_grant_duration = duration;
}
pub fn add_rule(&self, rule: PermissionRule) {
let key = format!("{:?}:{}", rule.tool_type, rule.resource_pattern);
self.whitelist.insert(key, rule);
}
pub fn remove_rule(&self, tool_type: PermissionType, resource_pattern: &str) -> bool {
let key = format!("{:?}:{}", tool_type, resource_pattern);
self.whitelist.remove(&key).is_some()
}
pub fn get_rules(&self) -> Vec<PermissionRule> {
self.whitelist
.iter()
.map(|entry| entry.value().clone())
.filter(|rule| !rule.is_expired())
.collect()
}
pub fn clear_rules(&self) {
self.whitelist.clear();
}
pub fn grant_session_permission(
&self,
perm_type: PermissionType,
resource_pattern: impl Into<String>,
) {
let grant = SessionGrant::new(resource_pattern, self.session_grant_duration);
self.session_grants
.entry(perm_type)
.and_modify(|grants| {
grants.push(grant.clone());
})
.or_insert_with(|| vec![grant]);
}
pub fn is_session_granted(&self, perm_type: PermissionType, resource: &str) -> bool {
if let Some(grants) = self.session_grants.get(&perm_type) {
let has_match = grants.iter().any(|grant| {
if grant.is_expired() {
return false;
}
grant.matches(perm_type, resource)
});
if has_match {
return true;
}
}
false
}
pub fn clear_session_grants(&self) {
self.session_grants.clear();
}
pub fn cleanup_expired_grants(&self) {
for mut entry in self.session_grants.iter_mut() {
entry.value_mut().retain(|grant| !grant.is_expired());
}
}
pub fn is_whitelist_allowed(&self, perm_type: PermissionType, resource: &str) -> Option<bool> {
let mut allowed = None;
for entry in self.whitelist.iter() {
let rule = entry.value();
if rule.matches(perm_type, resource) {
if rule.allowed {
allowed = Some(true);
} else {
return Some(false);
}
}
}
allowed
}
pub fn needs_confirmation(&self, perm_type: PermissionType, resource: &str) -> bool {
if !self.is_enabled() {
return false;
}
if self.is_session_granted(perm_type, resource) {
return false;
}
match self.is_whitelist_allowed(perm_type, resource) {
Some(true) => false, Some(false) => true, None => true, }
}
pub fn to_serializable(&self) -> SerializablePermissionConfig {
SerializablePermissionConfig {
whitelist: self.get_rules(),
enabled: self.is_enabled(),
session_grant_duration_secs: self.session_grant_duration.as_secs(),
mode: Some(self.mode()),
}
}
pub fn from_serializable(config: SerializablePermissionConfig) -> Self {
let whitelist = DashMap::new();
for rule in config.whitelist {
let key = format!("{:?}:{}", rule.tool_type, rule.resource_pattern);
whitelist.insert(key, rule);
}
let mode = config.mode.unwrap_or_default();
Self {
whitelist,
session_grants: DashMap::new(),
session_grant_duration: Duration::from_secs(config.session_grant_duration_secs),
enabled: AtomicBool::new(config.enabled),
mode: RwLock::new(mode),
}
}
pub fn merge(&self, other: &PermissionConfig) -> Self {
let merged = Self::new();
for rule in self.get_rules() {
let key = format!("{:?}:{}", rule.tool_type, rule.resource_pattern);
merged.whitelist.insert(key, rule);
}
for rule in other.get_rules() {
let key = format!("{:?}:{}", rule.tool_type, rule.resource_pattern);
merged.whitelist.insert(key, rule);
}
for entry in other.session_grants.iter() {
let perm_type = entry.key();
let grants = entry.value();
merged.session_grants.insert(*perm_type, grants.clone());
}
merged.set_mode(other.mode());
merged.set_enabled(other.is_enabled());
merged
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializablePermissionConfig {
pub whitelist: Vec<PermissionRule>,
pub enabled: bool,
pub session_grant_duration_secs: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<PermissionMode>,
}
impl Default for SerializablePermissionConfig {
fn default() -> Self {
Self {
whitelist: Vec::new(),
enabled: true,
session_grant_duration_secs: 30 * 60, mode: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_permission_type_description() {
assert!(PermissionType::WriteFile
.description()
.contains("Write files"));
assert!(PermissionType::ExecuteCommand
.description()
.contains("Execute"));
}
#[test]
fn test_risk_level() {
assert_eq!(PermissionType::WriteFile.risk_level(), RiskLevel::Medium);
assert_eq!(PermissionType::ExecuteCommand.risk_level(), RiskLevel::High);
}
#[test]
fn test_session_grant_with_real_paths() {
let grant = SessionGrant::new("/tmp/*", Duration::from_secs(3600));
assert!(grant.matches(PermissionType::WriteFile, "/tmp/test.txt"));
assert!(!grant.matches(PermissionType::WriteFile, "/var/test.txt"));
}
#[test]
fn test_permission_rule_matches() {
let rule = PermissionRule::new(PermissionType::WriteFile, "*.rs", true);
assert!(rule.matches(PermissionType::WriteFile, "/tmp/test.rs"));
assert!(!rule.matches(PermissionType::WriteFile, "/tmp/test.txt"));
assert!(!rule.matches(PermissionType::ExecuteCommand, "/tmp/test.rs"));
}
#[test]
fn test_permission_rule_directory_pattern() {
let rule = PermissionRule::new(PermissionType::WriteFile, "/tmp/*", true);
assert!(rule.matches(PermissionType::WriteFile, "/tmp/test.txt"));
assert!(!rule.matches(PermissionType::WriteFile, "/var/test.txt"));
}
#[test]
fn test_session_grant_matches() {
let grant = SessionGrant::new("/tmp/*", Duration::from_secs(3600));
assert!(grant.matches(PermissionType::WriteFile, "/tmp/test.txt"));
assert!(!grant.matches(PermissionType::WriteFile, "/var/test.txt"));
}
#[test]
fn test_permission_rule_rejects_traversal() {
let rule = PermissionRule::new(PermissionType::WriteFile, "/safe/**", true);
assert!(!rule.matches(PermissionType::WriteFile, "/safe/../etc/passwd"));
}
#[test]
fn test_session_grant_rejects_traversal() {
let grant = SessionGrant::new("/safe/**", Duration::from_secs(3600));
assert!(!grant.matches(PermissionType::WriteFile, "/safe/../etc/passwd"));
}
#[test]
fn test_permission_rule_normalizes_slashes() {
let rule = PermissionRule::new(PermissionType::WriteFile, "/tmp/*", true);
assert!(rule.matches(PermissionType::WriteFile, "/tmp//file.txt"));
}
#[test]
fn test_permission_rule_rejects_relative_resource() {
let rule = PermissionRule::new(PermissionType::WriteFile, "*.rs", true);
assert!(!rule.matches(PermissionType::WriteFile, "test.rs"));
}
#[test]
fn test_config_needs_confirmation() {
let config = PermissionConfig::new();
assert!(config.needs_confirmation(PermissionType::WriteFile, "/tmp/test.txt"));
config.grant_session_permission(PermissionType::WriteFile, "/tmp/*");
assert!(!config.needs_confirmation(PermissionType::WriteFile, "/tmp/test.txt"));
assert!(config.needs_confirmation(PermissionType::WriteFile, "/var/test.txt"));
}
#[test]
fn test_whitelist_allowed() {
let config = PermissionConfig::new();
config.add_rule(PermissionRule::new(PermissionType::WriteFile, "*.rs", true));
assert_eq!(
config.is_whitelist_allowed(PermissionType::WriteFile, "/tmp/test.rs"),
Some(true)
);
assert_eq!(
config.is_whitelist_allowed(PermissionType::WriteFile, "/tmp/test.txt"),
None
);
}
#[test]
fn test_whitelist_denial() {
let config = PermissionConfig::new();
config.add_rule(PermissionRule::new(
PermissionType::WriteFile,
"*.txt",
false,
));
assert_eq!(
config.is_whitelist_allowed(PermissionType::WriteFile, "/tmp/test.txt"),
Some(false)
);
}
#[test]
fn test_glob_pattern_exact_match() {
assert!(match_glob_pattern("/tmp/test.txt", "/tmp/test.txt"));
assert!(!match_glob_pattern("/tmp/test.txt", "/tmp/other.txt"));
}
#[test]
fn test_glob_pattern_wildcard() {
assert!(match_glob_pattern("*", "/any/path"));
assert!(match_glob_pattern("**/*", "/any/path"));
}
#[test]
fn test_glob_pattern_extension() {
assert!(match_glob_pattern("*.rs", "test.rs"));
assert!(match_glob_pattern("*.rs", "/path/to/test.rs"));
assert!(!match_glob_pattern("*.rs", "test.txt"));
assert!(!match_glob_pattern("*.rs", "/path/to/test.rs.txt"));
}
#[test]
fn test_glob_pattern_directory_children() {
let rule = PermissionRule::new(PermissionType::WriteFile, "/tmp/*", true);
assert!(rule.matches(PermissionType::WriteFile, "/tmp/test.txt"));
assert!(rule.matches(PermissionType::WriteFile, "/tmp/file.rs"));
assert!(!rule.matches(PermissionType::WriteFile, "/tmp/subdir/file.txt"));
assert!(!rule.matches(PermissionType::WriteFile, "/tmpx/file.txt"));
}
#[test]
fn test_glob_pattern_recursive() {
assert!(match_glob_pattern("/tmp/**", "/tmp/file.txt"));
assert!(match_glob_pattern("/tmp/**", "/tmp/subdir/file.txt"));
assert!(match_glob_pattern("/tmp/**", "/tmp/a/b/c/d.txt"));
assert!(!match_glob_pattern("/tmp/**", "/tmpx/file.txt"));
}
#[test]
fn test_glob_pattern_edge_cases() {
assert!(!match_glob_pattern("/tmp/*", "/tmpx/file.txt"));
assert!(match_glob_pattern("/home/user/*", "/home/user/file.txt"));
assert!(!match_glob_pattern("/home/user/*", "/home/user2/file.txt"));
}
#[test]
fn test_non_path_resources_http_domains() {
let rule = PermissionRule::new(PermissionType::HttpRequest, "api.example.com", true);
assert!(rule.matches(PermissionType::HttpRequest, "api.example.com"));
assert!(!rule.matches(PermissionType::HttpRequest, "other.example.com"));
}
#[test]
fn test_non_path_resources_commands() {
let rule = PermissionRule::new(PermissionType::ExecuteCommand, "npm", true);
assert!(rule.matches(PermissionType::ExecuteCommand, "npm"));
assert!(!rule.matches(PermissionType::ExecuteCommand, "yarn"));
}
#[test]
fn test_non_path_resources_session_ids() {
let grant = SessionGrant::new("session_abc123", Duration::from_secs(3600));
assert!(grant.matches(PermissionType::TerminalSession, "session_abc123"));
assert!(!grant.matches(PermissionType::TerminalSession, "session_xyz789"));
}
#[test]
fn test_permission_rule_expiration() {
let rule = PermissionRule::new(PermissionType::WriteFile, "/tmp/*", true)
.with_expiration(chrono::Utc::now() - chrono::Duration::seconds(1));
assert!(!rule.matches(PermissionType::WriteFile, "/tmp/test.txt"));
}
#[test]
fn test_session_grant_expiration() {
let grant = SessionGrant::new("/tmp/*", Duration::from_secs(0));
std::thread::sleep(std::time::Duration::from_millis(10));
assert!(!grant.matches(PermissionType::WriteFile, "/tmp/test.txt"));
}
#[test]
fn test_empty_strings() {
let rule = PermissionRule::new(PermissionType::WriteFile, "", true);
assert!(!rule.matches(PermissionType::WriteFile, "/tmp/test.txt"));
assert!(!rule.matches(PermissionType::WriteFile, ""));
}
#[test]
fn test_special_characters_in_paths() {
let rule = PermissionRule::new(PermissionType::WriteFile, "/tmp/*", true);
assert!(rule.matches(PermissionType::WriteFile, "/tmp/file-with-dash.txt"));
assert!(rule.matches(PermissionType::WriteFile, "/tmp/file_with_underscore.txt"));
assert!(rule.matches(PermissionType::WriteFile, "/tmp/file.with.dots.txt"));
}
#[test]
fn test_traversal_variants() {
let rule = PermissionRule::new(PermissionType::WriteFile, "/safe/*", true);
assert!(!rule.matches(PermissionType::WriteFile, "/safe/../etc/passwd"));
assert!(!rule.matches(PermissionType::WriteFile, "/safe/./etc/passwd"));
assert!(!rule.matches(PermissionType::WriteFile, "/safe/subdir/../../etc/passwd"));
assert!(!rule.matches(PermissionType::WriteFile, "/safe//etc/passwd")); }
#[test]
fn test_has_path_traversal() {
assert!(has_path_traversal("../etc/passwd"));
assert!(has_path_traversal("/safe/../etc/passwd"));
assert!(!has_path_traversal("/safe/./etc/passwd"));
assert!(!has_path_traversal("/safe/etc/passwd"));
}
#[test]
fn test_wildcard_matches_anything() {
assert!(match_glob_pattern("*", "anything"));
assert!(match_glob_pattern("*", "/any/path"));
assert!(match_glob_pattern("**/*", "/any/deep/path"));
assert!(match_glob_pattern("*", "api.example.com"));
assert!(match_glob_pattern("*", "C:/Windows/file.txt"));
}
#[test]
fn test_windows_paths() {
let normalized = normalize_path_basic("C:/Users/file.txt");
assert_eq!(normalized, "C:/Users/file.txt");
let normalized = normalize_path_basic("C:\\Users\\file.txt");
assert_eq!(normalized, "C:/Users/file.txt");
assert!(normalized.contains(':'));
assert!(normalized.starts_with("C:/"));
}
#[test]
fn test_permission_type_mismatch() {
let rule = PermissionRule::new(PermissionType::WriteFile, "/tmp/*", true);
assert!(!rule.matches(PermissionType::ExecuteCommand, "/tmp/test.txt"));
assert!(!rule.matches(PermissionType::HttpRequest, "/tmp/test.txt"));
}
#[test]
fn test_config_enabled_disabled() {
let config = PermissionConfig::new();
assert!(config.is_enabled());
assert!(config.needs_confirmation(PermissionType::WriteFile, "/tmp/test.txt"));
config.set_enabled(false);
assert!(!config.is_enabled());
assert!(!config.needs_confirmation(PermissionType::WriteFile, "/tmp/test.txt"));
}
#[test]
fn test_path_symlink_switch_blocked() {
use std::io::Write;
let temp_dir = std::env::temp_dir().join(format!("toctou_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let allowed_dir = temp_dir.join("allowed");
std::fs::create_dir_all(&allowed_dir).unwrap();
let test_file = allowed_dir.join("test.txt");
{
let mut file = std::fs::File::create(&test_file).unwrap();
file.write_all(b"original content").unwrap();
}
assert!(open_file_no_follow(&test_file).is_ok());
let symlink_file = allowed_dir.join("symlink.txt");
let outside_file = temp_dir.join("outside.txt");
{
let mut file = std::fs::File::create(&outside_file).unwrap();
file.write_all(b"sensitive content").unwrap();
}
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
symlink(&outside_file, &symlink_file).unwrap();
let result = open_file_no_follow(&symlink_file);
assert!(result.is_err(), "Should block opening symlink");
if let Err(e) = result {
let is_blocked = e.kind() == std::io::ErrorKind::PermissionDenied
|| e.kind() == std::io::ErrorKind::InvalidInput
|| e.kind() == std::io::ErrorKind::Other
|| e.raw_os_error() == Some(62) || e.raw_os_error() == Some(40); assert!(is_blocked, "Expected symlink to be blocked, got: {:?}", e);
}
}
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_path_traversal_blocked() {
let test_cases = vec![
"/safe/../etc/passwd",
"/safe/subdir/../../etc/passwd",
"/safe/./../etc/passwd",
];
for path in test_cases {
let config = PermissionConfig::new();
config.add_rule(PermissionRule::new(
PermissionType::WriteFile,
"/safe/*",
true,
));
assert!(
config.needs_confirmation(PermissionType::WriteFile, path),
"Path traversal should require confirmation (be blocked by default): {}",
path
);
}
}
#[test]
fn test_path_within_allowed_directory() {
let config = PermissionConfig::new();
config.add_rule(PermissionRule::new(
PermissionType::WriteFile,
"/tmp/allowed/**",
true,
));
assert!(!config.needs_confirmation(PermissionType::WriteFile, "/tmp/allowed/file.txt"));
assert!(
!config.needs_confirmation(PermissionType::WriteFile, "/tmp/allowed/subdir/file.txt")
);
assert!(config.needs_confirmation(PermissionType::WriteFile, "/tmp/other/file.txt"));
assert!(config.needs_confirmation(PermissionType::WriteFile, "/etc/passwd"));
}
#[test]
fn test_secure_file_create_parent_validation() {
use std::io::Write;
let temp_dir =
std::env::temp_dir().join(format!("secure_create_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let allowed_dir = temp_dir.join("allowed");
std::fs::create_dir_all(&allowed_dir).unwrap();
let new_file = allowed_dir.join("new_file.txt");
let result = open_file_for_write_secure(&new_file);
assert!(
result.is_ok(),
"Should be able to create file in allowed directory"
);
if let Ok(mut file) = result {
file.write_all(b"test content").unwrap();
drop(file);
assert!(new_file.exists());
let content = std::fs::read_to_string(&new_file).unwrap();
assert_eq!(content, "test content");
}
let bad_path = temp_dir.join("nonexistent_dir").join("file.txt");
let result = open_file_for_write_secure(&bad_path);
assert!(result.is_err(), "Should fail when parent doesn't exist");
let _ = std::fs::remove_dir_all(&temp_dir);
}
}
#[cfg(test)]
mod integration_tests {
use super::*;
#[test]
fn test_whitelist_with_session_grants() {
let config = PermissionConfig::new();
config.add_rule(PermissionRule::new(
PermissionType::WriteFile,
"/tmp/*",
true,
));
assert!(!config.needs_confirmation(PermissionType::WriteFile, "/tmp/test.txt"));
assert!(config.needs_confirmation(PermissionType::WriteFile, "/home/test.txt"));
config.grant_session_permission(PermissionType::WriteFile, "/home/*");
}
#[test]
fn test_multiple_session_grants() {
let config = PermissionConfig::new();
config.grant_session_permission(PermissionType::WriteFile, "/tmp/*");
config.grant_session_permission(PermissionType::WriteFile, "/home/*");
assert!(!config.needs_confirmation(PermissionType::WriteFile, "/tmp/test.txt"));
}
#[test]
fn test_deny_overrides_allow() {
let config = PermissionConfig::new();
config.add_rule(PermissionRule::new(
PermissionType::WriteFile,
"/tmp/*",
true,
));
config.add_rule(PermissionRule::new(
PermissionType::WriteFile,
"/tmp/sensitive.txt",
false,
));
assert_eq!(
config.is_whitelist_allowed(PermissionType::WriteFile, "/tmp/test.txt"),
Some(true)
);
assert_eq!(
config.is_whitelist_allowed(PermissionType::WriteFile, "/tmp/sensitive.txt"),
Some(false)
);
}
#[test]
fn test_non_path_permissions_integration() {
let config = PermissionConfig::new();
config.grant_session_permission(PermissionType::HttpRequest, "api.example.com");
assert!(!config.needs_confirmation(PermissionType::HttpRequest, "api.example.com"));
config.grant_session_permission(PermissionType::ExecuteCommand, "npm");
assert!(!config.needs_confirmation(PermissionType::ExecuteCommand, "npm"));
}
#[test]
fn test_permission_mode_default_is_default() {
let config = PermissionConfig::new();
assert_eq!(config.mode(), PermissionMode::Default);
}
#[test]
fn test_permission_mode_set_and_get() {
let config = PermissionConfig::new();
config.set_mode(PermissionMode::Plan);
assert_eq!(config.mode(), PermissionMode::Plan);
config.set_mode(PermissionMode::BypassPermissions);
assert_eq!(config.mode(), PermissionMode::BypassPermissions);
}
#[test]
fn test_permission_mode_serialize_roundtrip() {
let mut serializable = SerializablePermissionConfig::default();
assert!(serializable.mode.is_none());
serializable.mode = Some(PermissionMode::Plan);
let json = serde_json::to_string(&serializable).unwrap();
assert!(json.contains("plan"));
let deserialized: SerializablePermissionConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.mode, Some(PermissionMode::Plan));
}
#[test]
fn test_permission_mode_backward_compat_no_mode() {
let json = r#"{"whitelist":[],"enabled":true,"session_grant_duration_secs":1800}"#;
let deserialized: SerializablePermissionConfig = serde_json::from_str(json).unwrap();
assert!(deserialized.mode.is_none());
let config = PermissionConfig::from_serializable(deserialized);
assert_eq!(config.mode(), PermissionMode::Default);
}
#[test]
fn test_permission_config_merge() {
let user = PermissionConfig::new();
user.add_rule(PermissionRule::new(
PermissionType::WriteFile,
"/tmp/user/*",
true,
));
user.set_mode(PermissionMode::Default);
let project = PermissionConfig::new();
project.add_rule(PermissionRule::new(
PermissionType::WriteFile,
"/tmp/project/*",
true,
));
project.add_rule(PermissionRule::new(
PermissionType::WriteFile,
"/tmp/project/secret",
false,
));
project.set_mode(PermissionMode::AcceptEdits);
let merged = user.merge(&project);
assert_eq!(merged.mode(), PermissionMode::AcceptEdits);
assert!(!merged.needs_confirmation(PermissionType::WriteFile, "/tmp/user/code.rs"));
assert!(!merged.needs_confirmation(PermissionType::WriteFile, "/tmp/project/code.rs"));
assert!(merged.needs_confirmation(PermissionType::WriteFile, "/tmp/project/secret"));
}
#[test]
fn test_permission_mode_description() {
assert!(!PermissionMode::Default.description().is_empty());
assert!(!PermissionMode::Plan.description().is_empty());
assert!(!PermissionMode::AcceptEdits.description().is_empty());
assert!(!PermissionMode::DontAsk.description().is_empty());
assert!(!PermissionMode::BypassPermissions.description().is_empty());
}
}