use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GrantTarget {
Path {
path: PathBuf,
recursive: bool,
},
Domain {
pattern: String,
},
Command {
pattern: String,
},
Tool {
tool_name: String,
},
}
impl GrantTarget {
pub fn path(path: impl Into<PathBuf>, recursive: bool) -> Self {
Self::Path {
path: path.into(),
recursive,
}
}
pub fn domain(pattern: impl Into<String>) -> Self {
Self::Domain {
pattern: pattern.into(),
}
}
pub fn command(pattern: impl Into<String>) -> Self {
Self::Command {
pattern: pattern.into(),
}
}
pub fn tool(tool_name: impl Into<String>) -> Self {
Self::Tool {
tool_name: tool_name.into(),
}
}
pub fn covers(&self, request: &GrantTarget) -> bool {
match (self, request) {
(
GrantTarget::Path {
path: grant_path,
recursive,
},
GrantTarget::Path {
path: request_path, ..
},
) => path_covers(grant_path, request_path, *recursive),
(GrantTarget::Domain { pattern: grant }, GrantTarget::Domain { pattern: request }) => {
domain_pattern_matches(grant, request)
}
(
GrantTarget::Command { pattern: grant },
GrantTarget::Command { pattern: request },
) => command_pattern_matches(grant, request),
(
GrantTarget::Tool {
tool_name: grant_name,
},
GrantTarget::Tool {
tool_name: request_name,
},
) => grant_name == request_name,
_ => false,
}
}
pub fn description(&self) -> String {
match self {
GrantTarget::Path { path, recursive } => {
if *recursive {
format!("{} (recursive)", path.display())
} else {
format!("{}", path.display())
}
}
GrantTarget::Domain { pattern } => pattern.clone(),
GrantTarget::Command { pattern } => pattern.clone(),
GrantTarget::Tool { tool_name } => tool_name.clone(),
}
}
pub fn target_type(&self) -> &'static str {
match self {
GrantTarget::Path { .. } => "Path",
GrantTarget::Domain { .. } => "Domain",
GrantTarget::Command { .. } => "Command",
GrantTarget::Tool { .. } => "Tool",
}
}
}
fn path_covers(grant_path: &Path, request_path: &Path, recursive: bool) -> bool {
let normalized_grant = normalize_path(grant_path);
let normalized_request = normalize_path(request_path);
if has_path_traversal(&normalized_request, &normalized_grant) {
return false;
}
if recursive {
normalized_request.starts_with(&normalized_grant)
} else {
if normalized_request == normalized_grant {
return true;
}
if let Some(parent) = normalized_request.parent() {
parent == normalized_grant
} else {
false
}
}
}
fn normalize_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
normalized.pop();
}
std::path::Component::CurDir => {
}
_ => {
normalized.push(component);
}
}
}
normalized
}
fn has_path_traversal(request: &Path, grant: &Path) -> bool {
!request.starts_with(grant) && request.parent() != Some(grant)
}
fn domain_pattern_matches(pattern: &str, domain: &str) -> bool {
if pattern == "*" {
return true;
}
if pattern == domain {
return true;
}
if let Some(suffix) = pattern.strip_prefix("*.") {
if domain == suffix {
return true;
}
if domain.ends_with(&format!(".{}", suffix)) {
return true;
}
}
false
}
fn command_pattern_matches(pattern: &str, command: &str) -> bool {
if pattern == "*" {
return true;
}
if pattern == command {
return true;
}
if let Some(prefix) = pattern.strip_suffix(" *") {
if command == prefix {
return true;
}
if command.starts_with(&format!("{} ", prefix)) {
return true;
}
}
false
}
impl std::fmt::Display for GrantTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.description())
}
}
#[cfg(test)]
mod tests {
use super::*;
mod path_tests {
use super::*;
#[test]
fn test_exact_path_match() {
let grant = GrantTarget::path("/project/src", false);
let request = GrantTarget::path("/project/src", false);
assert!(grant.covers(&request));
}
#[test]
fn test_direct_child_non_recursive() {
let grant = GrantTarget::path("/project/src", false);
let request = GrantTarget::path("/project/src/main.rs", false);
assert!(grant.covers(&request));
}
#[test]
fn test_nested_child_non_recursive_fails() {
let grant = GrantTarget::path("/project/src", false);
let request = GrantTarget::path("/project/src/utils/mod.rs", false);
assert!(!grant.covers(&request));
}
#[test]
fn test_recursive_covers_nested() {
let grant = GrantTarget::path("/project/src", true);
let request = GrantTarget::path("/project/src/utils/mod.rs", false);
assert!(grant.covers(&request));
}
#[test]
fn test_recursive_covers_deep_nested() {
let grant = GrantTarget::path("/project", true);
let request = GrantTarget::path("/project/src/utils/helpers/mod.rs", false);
assert!(grant.covers(&request));
}
#[test]
fn test_sibling_path_not_covered() {
let grant = GrantTarget::path("/project/src", true);
let request = GrantTarget::path("/project/tests/test.rs", false);
assert!(!grant.covers(&request));
}
#[test]
fn test_parent_path_not_covered() {
let grant = GrantTarget::path("/project/src", true);
let request = GrantTarget::path("/project/Cargo.toml", false);
assert!(!grant.covers(&request));
}
#[test]
fn test_path_traversal_blocked() {
let grant = GrantTarget::path("/project/src", true);
let request = GrantTarget::path("/project/src/../secrets/key.pem", false);
assert!(!grant.covers(&request));
}
#[test]
fn test_unrelated_path_not_covered() {
let grant = GrantTarget::path("/project", true);
let request = GrantTarget::path("/etc/passwd", false);
assert!(!grant.covers(&request));
}
#[test]
fn test_path_prefix_collision_not_covered() {
let grant = GrantTarget::path("/project/src", true);
let request1 = GrantTarget::path("/project/src-backup/file.rs", false);
assert!(
!grant.covers(&request1),
"/project/src should not cover /project/src-backup"
);
let request2 = GrantTarget::path("/project/srcrc/file.rs", false);
assert!(
!grant.covers(&request2),
"/project/src should not cover /project/srcrc"
);
let request3 = GrantTarget::path("/project/src_old/file.rs", false);
assert!(
!grant.covers(&request3),
"/project/src should not cover /project/src_old"
);
let request4 = GrantTarget::path("/project/src/backup/file.rs", false);
assert!(
grant.covers(&request4),
"/project/src should cover /project/src/backup"
);
}
}
mod domain_tests {
use super::*;
#[test]
fn test_exact_domain_match() {
let grant = GrantTarget::domain("api.github.com");
let request = GrantTarget::domain("api.github.com");
assert!(grant.covers(&request));
}
#[test]
fn test_wildcard_subdomain() {
let grant = GrantTarget::domain("*.github.com");
let request = GrantTarget::domain("api.github.com");
assert!(grant.covers(&request));
}
#[test]
fn test_wildcard_matches_base_domain() {
let grant = GrantTarget::domain("*.github.com");
let request = GrantTarget::domain("github.com");
assert!(grant.covers(&request));
}
#[test]
fn test_wildcard_all() {
let grant = GrantTarget::domain("*");
let request = GrantTarget::domain("any.domain.com");
assert!(grant.covers(&request));
}
#[test]
fn test_different_domain_not_covered() {
let grant = GrantTarget::domain("api.github.com");
let request = GrantTarget::domain("api.gitlab.com");
assert!(!grant.covers(&request));
}
#[test]
fn test_wildcard_only_matches_direct_subdomains() {
let grant = GrantTarget::domain("*.github.com");
let request = GrantTarget::domain("evil.com");
assert!(!grant.covers(&request));
}
}
mod command_tests {
use super::*;
#[test]
fn test_exact_command_match() {
let grant = GrantTarget::command("git status");
let request = GrantTarget::command("git status");
assert!(grant.covers(&request));
}
#[test]
fn test_wildcard_command() {
let grant = GrantTarget::command("git *");
let request = GrantTarget::command("git status");
assert!(grant.covers(&request));
}
#[test]
fn test_wildcard_command_with_args() {
let grant = GrantTarget::command("git *");
let request = GrantTarget::command("git commit -m 'message'");
assert!(grant.covers(&request));
}
#[test]
fn test_wildcard_all_commands() {
let grant = GrantTarget::command("*");
let request = GrantTarget::command("rm -rf /");
assert!(grant.covers(&request));
}
#[test]
fn test_different_command_not_covered() {
let grant = GrantTarget::command("git *");
let request = GrantTarget::command("docker run nginx");
assert!(!grant.covers(&request));
}
#[test]
fn test_partial_command_not_covered() {
let grant = GrantTarget::command("git");
let request = GrantTarget::command("git status");
assert!(!grant.covers(&request));
}
#[test]
fn test_wildcard_command_matches_bare_command() {
let grant = GrantTarget::command("git *");
let request = GrantTarget::command("git");
assert!(grant.covers(&request));
}
}
mod tool_tests {
use super::*;
#[test]
fn test_exact_tool_match() {
let grant = GrantTarget::tool("switch_aws_account");
let request = GrantTarget::tool("switch_aws_account");
assert!(grant.covers(&request));
}
#[test]
fn test_different_tool_not_covered() {
let grant = GrantTarget::tool("switch_aws_account");
let request = GrantTarget::tool("delete_resource");
assert!(!grant.covers(&request));
}
#[test]
fn test_tool_description() {
let target = GrantTarget::tool("switch_aws_account");
assert_eq!(target.description(), "switch_aws_account");
}
#[test]
fn test_tool_target_type() {
let target = GrantTarget::tool("my_tool");
assert_eq!(target.target_type(), "Tool");
}
}
mod cross_target_tests {
use super::*;
#[test]
fn test_different_target_types_dont_match() {
let path_grant = GrantTarget::path("/project", true);
let domain_request = GrantTarget::domain("github.com");
assert!(!path_grant.covers(&domain_request));
let command_grant = GrantTarget::command("git *");
let path_request = GrantTarget::path("/project/src", false);
assert!(!command_grant.covers(&path_request));
let tool_grant = GrantTarget::tool("my_tool");
let command_request = GrantTarget::command("my_tool");
assert!(!tool_grant.covers(&command_request));
}
}
mod serialization_tests {
use super::*;
#[test]
fn test_path_serialization() {
let target = GrantTarget::path("/project/src", true);
let json = serde_json::to_string(&target).unwrap();
assert!(json.contains("\"type\":\"path\""));
assert!(json.contains("\"recursive\":true"));
let deserialized: GrantTarget = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, target);
}
#[test]
fn test_domain_serialization() {
let target = GrantTarget::domain("*.github.com");
let json = serde_json::to_string(&target).unwrap();
assert!(json.contains("\"type\":\"domain\""));
let deserialized: GrantTarget = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, target);
}
#[test]
fn test_command_serialization() {
let target = GrantTarget::command("git *");
let json = serde_json::to_string(&target).unwrap();
assert!(json.contains("\"type\":\"command\""));
let deserialized: GrantTarget = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, target);
}
#[test]
fn test_tool_serialization() {
let target = GrantTarget::tool("switch_aws_account");
let json = serde_json::to_string(&target).unwrap();
assert!(json.contains("\"type\":\"tool\""));
assert!(json.contains("\"tool_name\":\"switch_aws_account\""));
let deserialized: GrantTarget = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, target);
}
}
}