use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::{ActError, ActResult};
pub const WILDCARD: &str = "*";
pub const SCOPE_SEGMENTS: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Scope {
raw: String,
first_colon: usize,
second_colon: usize,
}
impl Scope {
pub fn parse(value: impl Into<String>) -> ActResult<Self> {
let raw = value.into();
let mut colons = raw.match_indices(':').map(|(i, _)| i);
let (Some(first_colon), Some(second_colon)) = (colons.next(), colons.next()) else {
return Err(ActError::MalformedScope {
value: raw,
reason: "expected three segments in service:resource:action form",
});
};
if colons.next().is_some() {
return Err(ActError::MalformedScope {
value: raw,
reason: "expected exactly three segments, found more",
});
}
let candidate = Self {
first_colon,
second_colon,
raw,
};
if candidate.service().is_empty()
|| candidate.resource().is_empty()
|| candidate.action().is_empty()
{
return Err(ActError::MalformedScope {
value: candidate.raw,
reason: "no segment may be empty",
});
}
Ok(candidate)
}
#[must_use]
pub fn service(&self) -> &str {
&self.raw[..self.first_colon]
}
#[must_use]
pub fn resource(&self) -> &str {
&self.raw[self.first_colon + 1..self.second_colon]
}
#[must_use]
pub fn action(&self) -> &str {
&self.raw[self.second_colon + 1..]
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.raw
}
#[must_use]
pub fn covers(&self, requested: &Self) -> bool {
fn segment_covers(granted: &str, requested: &str) -> bool {
granted == WILDCARD || granted == requested
}
segment_covers(self.service(), requested.service())
&& segment_covers(self.resource(), requested.resource())
&& segment_covers(self.action(), requested.action())
}
}
impl std::str::FromStr for Scope {
type Err = ActError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl std::fmt::Display for Scope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.raw)
}
}
impl Serialize for Scope {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.raw)
}
}
impl<'de> Deserialize<'de> for Scope {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
Self::parse(raw).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn parses_three_segments() -> ActResult<()> {
let scope = Scope::parse("tools:calendar:invoke")?;
assert_eq!(scope.service(), "tools");
assert_eq!(scope.resource(), "calendar");
assert_eq!(scope.action(), "invoke");
assert_eq!(scope.as_str(), "tools:calendar:invoke");
Ok(())
}
#[test]
fn rejects_two_segment_scopes() {
let error = Scope::parse("tools:invoke").expect_err("two segments must be rejected");
assert!(matches!(error, ActError::MalformedScope { .. }));
}
#[test]
fn rejects_four_segments_and_empty_segments() {
assert!(Scope::parse("a:b:c:d").is_err());
assert!(Scope::parse("a::c").is_err());
assert!(Scope::parse(":b:c").is_err());
assert!(Scope::parse("a:b:").is_err());
}
#[test]
fn wildcard_grant_covers_specific_request() -> ActResult<()> {
let granted = Scope::parse("runtime:platform:*")?;
assert!(granted.covers(&Scope::parse("runtime:platform:spawn")?));
assert!(!granted.covers(&Scope::parse("runtime:network:spawn")?));
Ok(())
}
#[test]
fn requested_wildcard_does_not_widen_authority() -> ActResult<()> {
let granted = Scope::parse("tools:calendar:invoke")?;
assert!(!granted.covers(&Scope::parse("tools:calendar:*")?));
assert!(!granted.covers(&Scope::parse("tools:*:invoke")?));
Ok(())
}
#[test]
fn serializes_as_a_plain_string() -> ActResult<()> {
let scope = Scope::parse("tools:calendar:invoke")?;
let mut buf = Vec::new();
ciborium::into_writer(&scope, &mut buf).map_err(|e| ActError::Encode(e.to_string()))?;
let value: ciborium::Value = ciborium::from_reader(buf.as_slice())
.map_err(|e| ActError::ClaimsDecode(e.to_string()))?;
assert!(value.is_text(), "scope must encode as text, got {value:?}");
Ok(())
}
}