use serde::{Deserialize, Serialize};
use std::fmt;
pub const SIGN_COMMIT: &str = "sign_commit";
pub const SIGN_RELEASE: &str = "sign_release";
pub const MANAGE_MEMBERS: &str = "manage_members";
pub const ROTATE_KEYS: &str = "rotate_keys";
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CapabilityError {
#[error("capability is empty")]
Empty,
#[error("capability exceeds 64 chars: {0}")]
TooLong(usize),
#[error("invalid characters in capability '{0}': only alphanumeric, ':', '-', '_' allowed")]
InvalidChars(String),
#[error(
"reserved namespace 'auths:' — use well-known constructors or choose a different prefix"
)]
ReservedNamespace,
#[error("the '{0}' prefix is reserved for infrastructure capabilities")]
ReservedInfraNamespace(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(try_from = "String", into = "String")]
pub struct Capability(String);
impl Capability {
pub const MAX_LEN: usize = 64;
const RESERVED_PREFIX: &'static str = "auths:";
const RESERVED_INFRA_PREFIXES: &'static [&'static str] =
&["compute:", "network:", "storage:", "runtime:", "env:"];
#[inline]
pub fn sign_commit() -> Self {
Self(SIGN_COMMIT.to_string())
}
#[inline]
pub fn sign_release() -> Self {
Self(SIGN_RELEASE.to_string())
}
#[inline]
pub fn manage_members() -> Self {
Self(MANAGE_MEMBERS.to_string())
}
#[inline]
pub fn rotate_keys() -> Self {
Self(ROTATE_KEYS.to_string())
}
pub fn parse(raw: &str) -> Result<Self, CapabilityError> {
let canonical = raw.trim().to_lowercase();
if canonical.is_empty() {
return Err(CapabilityError::Empty);
}
if canonical.len() > Self::MAX_LEN {
return Err(CapabilityError::TooLong(canonical.len()));
}
if !canonical
.chars()
.all(|c| c.is_alphanumeric() || c == ':' || c == '-' || c == '_')
{
return Err(CapabilityError::InvalidChars(canonical));
}
if canonical.starts_with(Self::RESERVED_PREFIX) {
return Err(CapabilityError::ReservedNamespace);
}
for prefix in Self::RESERVED_INFRA_PREFIXES {
if canonical.starts_with(prefix) {
return Err(CapabilityError::ReservedInfraNamespace(prefix.to_string()));
}
}
Ok(Self(canonical))
}
#[inline]
pub fn as_str(&self) -> &str {
&self.0
}
pub fn is_well_known(&self) -> bool {
matches!(
self.0.as_str(),
SIGN_COMMIT | SIGN_RELEASE | MANAGE_MEMBERS | ROTATE_KEYS
)
}
pub fn namespace(&self) -> Option<&str> {
self.0.split(':').next().filter(|_| self.0.contains(':'))
}
const CLAIM_SEPARATOR: char = ',';
pub fn join_claim(capabilities: &[Capability]) -> String {
capabilities
.iter()
.map(Capability::as_str)
.collect::<Vec<_>>()
.join(&Self::CLAIM_SEPARATOR.to_string())
}
pub fn parse_claim(claim: &str) -> Result<Vec<Capability>, CapabilityError> {
if claim.is_empty() {
return Ok(Vec::new());
}
claim
.split(Self::CLAIM_SEPARATOR)
.map(Capability::parse)
.collect()
}
}
impl fmt::Display for Capability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
const USAGE_CAP_RESOURCE: &str = "calls";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct UsageCap {
max_calls: u64,
}
impl UsageCap {
#[inline]
pub const fn calls(max_calls: u64) -> Self {
Self { max_calls }
}
#[inline]
pub const fn max_calls(self) -> u64 {
self.max_calls
}
pub fn from_capability(cap: &Capability) -> Option<Self> {
Self::from_claim_segment(cap.as_str())
}
pub fn from_capabilities(caps: &[Capability]) -> Option<Self> {
caps.iter().find_map(Self::from_capability)
}
fn from_claim_segment(segment: &str) -> Option<Self> {
let bound = segment
.strip_prefix(&format!("{USAGE_CAP_RESOURCE}:"))
.or_else(|| segment.strip_prefix(&format!("{USAGE_CAP_RESOURCE}<=")))?;
bound.parse::<u64>().ok().map(Self::calls)
}
fn segment_targets_usage_resource(segment: &str) -> bool {
segment.starts_with(&format!("{USAGE_CAP_RESOURCE}:"))
|| segment.starts_with(&format!("{USAGE_CAP_RESOURCE}<="))
}
pub fn is_malformed_quant_predicate(cap: &Capability) -> bool {
let segment = cap.as_str();
Self::segment_targets_usage_resource(segment) && Self::from_claim_segment(segment).is_none()
}
}
impl fmt::Display for UsageCap {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{USAGE_CAP_RESOURCE}:{}", self.max_calls)
}
}
impl TryFrom<String> for Capability {
type Error = CapabilityError;
fn try_from(s: String) -> Result<Self, Self::Error> {
let canonical = s.trim().to_lowercase();
if canonical.is_empty() {
return Err(CapabilityError::Empty);
}
if canonical.len() > Self::MAX_LEN {
return Err(CapabilityError::TooLong(canonical.len()));
}
if !canonical
.chars()
.all(|c| c.is_alphanumeric() || c == ':' || c == '-' || c == '_')
{
return Err(CapabilityError::InvalidChars(canonical));
}
Ok(Self(canonical))
}
}
impl std::str::FromStr for Capability {
type Err = CapabilityError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let normalized = s.trim().to_lowercase().replace('-', "_");
match normalized.as_str() {
"sign_commit" | "signcommit" => Ok(Capability::sign_commit()),
"sign_release" | "signrelease" => Ok(Capability::sign_release()),
"manage_members" | "managemembers" => Ok(Capability::manage_members()),
"rotate_keys" | "rotatekeys" => Ok(Capability::rotate_keys()),
_ => Capability::parse(&normalized),
}
}
}
impl From<Capability> for String {
fn from(cap: Capability) -> Self {
cap.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capability_serializes_to_snake_case() {
assert_eq!(
serde_json::to_string(&Capability::sign_commit()).unwrap(),
r#""sign_commit""#
);
assert_eq!(
serde_json::to_string(&Capability::sign_release()).unwrap(),
r#""sign_release""#
);
assert_eq!(
serde_json::to_string(&Capability::manage_members()).unwrap(),
r#""manage_members""#
);
assert_eq!(
serde_json::to_string(&Capability::rotate_keys()).unwrap(),
r#""rotate_keys""#
);
}
#[test]
fn capability_deserializes_from_snake_case() {
assert_eq!(
serde_json::from_str::<Capability>(r#""sign_commit""#).unwrap(),
Capability::sign_commit()
);
assert_eq!(
serde_json::from_str::<Capability>(r#""sign_release""#).unwrap(),
Capability::sign_release()
);
assert_eq!(
serde_json::from_str::<Capability>(r#""manage_members""#).unwrap(),
Capability::manage_members()
);
assert_eq!(
serde_json::from_str::<Capability>(r#""rotate_keys""#).unwrap(),
Capability::rotate_keys()
);
}
#[test]
fn capability_custom_serializes_as_string() {
let cap = Capability::parse("acme:deploy").unwrap();
assert_eq!(serde_json::to_string(&cap).unwrap(), r#""acme:deploy""#);
}
#[test]
fn capability_custom_deserializes_unknown_strings() {
let cap: Capability = serde_json::from_str(r#""custom-capability""#).unwrap();
assert_eq!(cap, Capability::parse("custom-capability").unwrap());
}
#[test]
fn capability_parse_accepts_valid_strings() {
assert!(Capability::parse("deploy").is_ok());
assert!(Capability::parse("acme:deploy").is_ok());
assert!(Capability::parse("my-custom-cap").is_ok());
assert!(Capability::parse("org:team:action").is_ok());
assert!(Capability::parse("with_underscore").is_ok()); }
#[test]
fn capability_parse_rejects_invalid_strings() {
assert!(matches!(Capability::parse(""), Err(CapabilityError::Empty)));
assert!(matches!(
Capability::parse(&"a".repeat(65)),
Err(CapabilityError::TooLong(65))
));
assert!(matches!(
Capability::parse("has spaces"),
Err(CapabilityError::InvalidChars(_))
));
assert!(matches!(
Capability::parse("has.dot"),
Err(CapabilityError::InvalidChars(_))
));
}
#[test]
fn capability_parse_rejects_reserved_namespace() {
assert!(matches!(
Capability::parse("auths:custom"),
Err(CapabilityError::ReservedNamespace)
));
assert!(matches!(
Capability::parse("auths:sign_commit"),
Err(CapabilityError::ReservedNamespace)
));
}
#[test]
fn capability_parse_accepts_role_markers() {
let cap = Capability::parse("role:admin").unwrap();
assert_eq!(cap.as_str(), "role:admin");
let json = serde_json::to_string(&cap).unwrap();
let roundtrip: Capability = serde_json::from_str(&json).unwrap();
assert_eq!(cap, roundtrip);
}
#[test]
fn capability_parse_normalizes_to_lowercase() {
let cap = Capability::parse("DEPLOY").unwrap();
assert_eq!(cap.as_str(), "deploy");
let cap = Capability::parse("ACME:Deploy").unwrap();
assert_eq!(cap.as_str(), "acme:deploy");
}
#[test]
fn capability_parse_trims_whitespace() {
let cap = Capability::parse(" deploy ").unwrap();
assert_eq!(cap.as_str(), "deploy");
}
#[test]
fn capability_is_hashable() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(Capability::sign_commit());
set.insert(Capability::sign_release());
set.insert(Capability::parse("test").unwrap());
assert_eq!(set.len(), 3);
assert!(set.contains(&Capability::sign_commit()));
}
#[test]
fn capability_equality_with_different_construction_paths() {
let from_constructor = Capability::sign_commit();
let from_deser: Capability = serde_json::from_str(r#""sign_commit""#).unwrap();
assert_eq!(from_constructor, from_deser);
let from_parse = Capability::parse("acme:deploy").unwrap();
let from_deser: Capability = serde_json::from_str(r#""acme:deploy""#).unwrap();
assert_eq!(from_parse, from_deser);
}
#[test]
fn capability_display_matches_canonical_form() {
assert_eq!(Capability::sign_commit().to_string(), "sign_commit");
assert_eq!(Capability::sign_release().to_string(), "sign_release");
assert_eq!(Capability::manage_members().to_string(), "manage_members");
assert_eq!(Capability::rotate_keys().to_string(), "rotate_keys");
assert_eq!(
Capability::parse("acme:deploy").unwrap().to_string(),
"acme:deploy"
);
}
#[test]
fn capability_as_str_returns_canonical_form() {
assert_eq!(Capability::sign_commit().as_str(), "sign_commit");
assert_eq!(Capability::sign_release().as_str(), "sign_release");
assert_eq!(Capability::manage_members().as_str(), "manage_members");
assert_eq!(Capability::rotate_keys().as_str(), "rotate_keys");
assert_eq!(
Capability::parse("acme:deploy").unwrap().as_str(),
"acme:deploy"
);
}
#[test]
fn capability_is_well_known() {
assert!(Capability::sign_commit().is_well_known());
assert!(Capability::sign_release().is_well_known());
assert!(Capability::manage_members().is_well_known());
assert!(Capability::rotate_keys().is_well_known());
assert!(!Capability::parse("custom").unwrap().is_well_known());
}
#[test]
fn capability_namespace() {
assert_eq!(
Capability::parse("acme:deploy").unwrap().namespace(),
Some("acme")
);
assert_eq!(
Capability::parse("org:team:action").unwrap().namespace(),
Some("org")
);
assert_eq!(Capability::parse("deploy").unwrap().namespace(), None);
}
#[test]
fn capability_vec_serializes_as_array() {
let caps = vec![Capability::sign_commit(), Capability::sign_release()];
let json = serde_json::to_string(&caps).unwrap();
assert_eq!(json, r#"["sign_commit","sign_release"]"#);
}
#[test]
fn capability_vec_deserializes_from_array() {
let json = r#"["sign_commit","manage_members","custom-cap"]"#;
let caps: Vec<Capability> = serde_json::from_str(json).unwrap();
assert_eq!(caps.len(), 3);
assert_eq!(caps[0], Capability::sign_commit());
assert_eq!(caps[1], Capability::manage_members());
assert_eq!(caps[2], Capability::parse("custom-cap").unwrap());
}
#[test]
fn claim_codec_roundtrips_multi_capability() {
let caps = vec![
Capability::parse("fs:read").unwrap(),
Capability::parse("fs:write").unwrap(),
];
let claim = Capability::join_claim(&caps);
assert_eq!(claim, "fs:read,fs:write");
assert_eq!(Capability::parse_claim(&claim).unwrap(), caps);
}
#[test]
fn claim_codec_roundtrips_single_capability() {
let caps = vec![Capability::sign_commit()];
let claim = Capability::join_claim(&caps);
assert_eq!(claim, "sign_commit");
assert_eq!(Capability::parse_claim(&claim).unwrap(), caps);
}
#[test]
fn parse_claim_empty_is_no_capabilities() {
assert!(Capability::parse_claim("").unwrap().is_empty());
}
#[test]
fn parse_claim_rejects_malformed_segment() {
assert!(matches!(
Capability::parse_claim("fs:read,has space"),
Err(CapabilityError::InvalidChars(_))
));
}
#[test]
fn join_claim_of_empty_is_empty_string() {
assert_eq!(Capability::join_claim(&[]), "");
}
#[test]
fn usage_cap_parses_colon_grammar() {
let cap = Capability::parse("calls:3").unwrap();
assert_eq!(UsageCap::from_capability(&cap), Some(UsageCap::calls(3)));
assert_eq!(UsageCap::from_capability(&cap).unwrap().max_calls(), 3);
}
#[test]
fn usage_cap_parses_comparison_grammar() {
assert_eq!(
UsageCap::from_claim_segment("calls<=5"),
Some(UsageCap::calls(5))
);
}
#[test]
fn usage_cap_zero_is_a_real_bound() {
let cap = Capability::parse("calls:0").unwrap();
assert_eq!(UsageCap::from_capability(&cap), Some(UsageCap::calls(0)));
}
#[test]
fn presence_token_carries_no_usage_cap() {
assert_eq!(UsageCap::from_capability(&Capability::sign_commit()), None);
let deploy = Capability::parse("acme:deploy").unwrap();
assert_eq!(UsageCap::from_capability(&deploy), None);
}
#[test]
fn calls_resource_with_non_numeric_bound_is_not_a_cap() {
let cap = Capability::parse("calls:abc").unwrap();
assert_eq!(UsageCap::from_capability(&cap), None);
}
#[test]
fn usage_cap_found_among_many_capabilities() {
let caps = vec![
Capability::sign_commit(),
Capability::parse("calls:7").unwrap(),
Capability::parse("acme:deploy").unwrap(),
];
assert_eq!(UsageCap::from_capabilities(&caps), Some(UsageCap::calls(7)));
}
#[test]
fn usage_cap_displays_canonical_grammar() {
assert_eq!(UsageCap::calls(3).to_string(), "calls:3");
}
#[test]
fn malformed_quant_predicate_flags_unparseable_calls_bounds() {
for bad in ["calls:", "calls:abc"] {
let cap = Capability::parse(bad).unwrap();
assert_eq!(
UsageCap::from_capability(&cap),
None,
"{bad} carries no cap"
);
assert!(
UsageCap::is_malformed_quant_predicate(&cap),
"{bad} must be flagged malformed"
);
}
let neg: Capability = "calls:-1".parse().unwrap();
assert!(UsageCap::is_malformed_quant_predicate(&neg));
}
#[test]
fn malformed_quant_predicate_passes_wellformed_and_presence_tokens() {
assert!(!UsageCap::is_malformed_quant_predicate(
&Capability::parse("calls:3").unwrap()
));
assert!(!UsageCap::is_malformed_quant_predicate(
&Capability::parse("calls:0").unwrap()
));
assert!(!UsageCap::is_malformed_quant_predicate(
&Capability::sign_commit()
));
assert!(!UsageCap::is_malformed_quant_predicate(
&Capability::parse("acme:deploy").unwrap()
));
assert!(!UsageCap::is_malformed_quant_predicate(
&Capability::parse("recalls:thing").unwrap()
));
}
#[test]
fn capability_serde_roundtrip_well_known() {
let caps = vec![
Capability::sign_commit(),
Capability::sign_release(),
Capability::manage_members(),
Capability::rotate_keys(),
];
for cap in caps {
let json = serde_json::to_string(&cap).unwrap();
let roundtrip: Capability = serde_json::from_str(&json).unwrap();
assert_eq!(cap, roundtrip);
}
}
#[test]
fn capability_serde_roundtrip_custom() {
let caps = vec![
Capability::parse("deploy").unwrap(),
Capability::parse("acme:deploy").unwrap(),
Capability::parse("org:team:action").unwrap(),
];
for cap in caps {
let json = serde_json::to_string(&cap).unwrap();
let roundtrip: Capability = serde_json::from_str(&json).unwrap();
assert_eq!(cap, roundtrip);
}
}
}