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(':'))
}
}
impl fmt::Display for Capability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
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 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);
}
}
}