use serde::{Deserialize, Serialize};
use std::fmt;
use crate::{IdprovaError, Result};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct Scope {
pub namespace: String,
pub protocol: String,
pub resource: String,
pub action: String,
}
impl Scope {
pub fn parse(s: &str) -> Result<Self> {
let parts: Vec<&str> = s.splitn(5, ':').collect();
if parts.len() != 4 {
return Err(IdprovaError::ScopeNotPermitted(format!(
"scope must have 4 parts (namespace:protocol:resource:action), got: {s}"
)));
}
Ok(Self {
namespace: parts[0].to_string(),
protocol: parts[1].to_string(),
resource: parts[2].to_string(),
action: parts[3].to_string(),
})
}
pub fn covers(&self, requested: &Scope) -> bool {
(self.namespace == "*" || self.namespace == requested.namespace)
&& (self.protocol == "*" || self.protocol == requested.protocol)
&& (self.resource == "*" || self.resource == requested.resource)
&& (self.action == "*" || self.action == requested.action)
}
pub fn to_string_repr(&self) -> String {
format!(
"{}:{}:{}:{}",
self.namespace, self.protocol, self.resource, self.action
)
}
}
impl fmt::Display for Scope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_string_repr())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScopeSet {
scopes: Vec<Scope>,
}
impl ScopeSet {
pub fn new(scopes: Vec<Scope>) -> Self {
Self { scopes }
}
pub fn parse(scope_strings: &[String]) -> Result<Self> {
let scopes: Result<Vec<Scope>> = scope_strings.iter().map(|s| Scope::parse(s)).collect();
Ok(Self { scopes: scopes? })
}
pub fn permits(&self, requested: &Scope) -> bool {
self.scopes.iter().any(|s| s.covers(requested))
}
pub fn is_subset_of(&self, parent: &ScopeSet) -> bool {
self.scopes.iter().all(|s| parent.permits(s))
}
pub fn to_strings(&self) -> Vec<String> {
self.scopes.iter().map(|s| s.to_string_repr()).collect()
}
pub fn iter(&self) -> impl Iterator<Item = &Scope> {
self.scopes.iter()
}
pub fn len(&self) -> usize {
self.scopes.len()
}
pub fn is_empty(&self) -> bool {
self.scopes.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_scope() {
let s = Scope::parse("mcp:tool:filesystem:read").unwrap();
assert_eq!(s.namespace, "mcp");
assert_eq!(s.protocol, "tool");
assert_eq!(s.resource, "filesystem");
assert_eq!(s.action, "read");
}
#[test]
fn test_parse_scope_rejects_3_parts() {
assert!(
Scope::parse("mcp:tool:read").is_err(),
"3-part scopes must be rejected — use 4 parts: mcp:tool:*:read"
);
}
#[test]
fn test_parse_scope_rejects_2_parts() {
assert!(Scope::parse("mcp:tool").is_err());
}
#[test]
fn test_scope_covers_exact() {
let parent = Scope::parse("mcp:tool:filesystem:read").unwrap();
let child = Scope::parse("mcp:tool:filesystem:read").unwrap();
assert!(parent.covers(&child));
}
#[test]
fn test_scope_wildcard_covers() {
let parent = Scope::parse("mcp:*:*:*").unwrap();
let child = Scope::parse("mcp:tool:filesystem:read").unwrap();
assert!(parent.covers(&child));
let partial = Scope::parse("mcp:tool:*:*").unwrap();
assert!(partial.covers(&child));
assert!(!partial.covers(&Scope::parse("a2a:agent:billing:execute").unwrap()));
}
#[test]
fn test_scope_wildcard_action_only() {
let parent = Scope::parse("mcp:tool:filesystem:*").unwrap();
assert!(parent.covers(&Scope::parse("mcp:tool:filesystem:read").unwrap()));
assert!(parent.covers(&Scope::parse("mcp:tool:filesystem:write").unwrap()));
assert!(!parent.covers(&Scope::parse("mcp:tool:search:read").unwrap()));
}
#[test]
fn test_scope_does_not_cover() {
let parent = Scope::parse("mcp:tool:filesystem:read").unwrap();
let child = Scope::parse("mcp:tool:filesystem:write").unwrap();
assert!(!parent.covers(&child));
}
#[test]
fn test_scope_set_permits() {
let set = ScopeSet::parse(&[
"mcp:tool:filesystem:read".to_string(),
"mcp:resource:data:read".to_string(),
])
.unwrap();
assert!(set.permits(&Scope::parse("mcp:tool:filesystem:read").unwrap()));
assert!(set.permits(&Scope::parse("mcp:resource:data:read").unwrap()));
assert!(!set.permits(&Scope::parse("mcp:tool:filesystem:write").unwrap()));
assert!(!set.permits(&Scope::parse("a2a:agent:billing:execute").unwrap()));
}
#[test]
fn test_scope_set_narrowing() {
let parent = ScopeSet::parse(&["mcp:*:*:*".to_string()]).unwrap();
let child = ScopeSet::parse(&["mcp:tool:filesystem:read".to_string()]).unwrap();
assert!(child.is_subset_of(&parent));
assert!(!parent.is_subset_of(&child));
}
#[test]
fn test_scope_set_narrowing_partial_wildcard() {
let parent = ScopeSet::parse(&["mcp:tool:*:read".to_string()]).unwrap();
let child = ScopeSet::parse(&["mcp:tool:filesystem:read".to_string()]).unwrap();
assert!(child.is_subset_of(&parent));
let wider = ScopeSet::parse(&["mcp:tool:*:*".to_string()]).unwrap();
assert!(!wider.is_subset_of(&parent));
}
#[test]
fn test_scope_display() {
let s = Scope::parse("mcp:tool:filesystem:read").unwrap();
assert_eq!(s.to_string(), "mcp:tool:filesystem:read");
}
}