use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use vti_common::error::AppError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum VtcRole {
Admin,
Moderator,
Issuer,
Member,
Custom(String),
}
const CUSTOM_PREFIX: &str = "custom:";
const CUSTOM_NAME_MAX: usize = 64;
impl VtcRole {
fn as_wire(&self) -> String {
match self {
VtcRole::Admin => "admin".into(),
VtcRole::Moderator => "moderator".into(),
VtcRole::Issuer => "issuer".into(),
VtcRole::Member => "member".into(),
VtcRole::Custom(name) => format!("{CUSTOM_PREFIX}{name}"),
}
}
pub fn custom(name: impl Into<String>) -> Result<Self, AppError> {
let name = name.into();
validate_custom_name(&name)?;
Ok(VtcRole::Custom(name))
}
pub fn is_standard(&self) -> bool {
!matches!(self, VtcRole::Custom(_))
}
}
impl fmt::Display for VtcRole {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.as_wire())
}
}
impl FromStr for VtcRole {
type Err = AppError;
fn from_str(s: &str) -> Result<Self, AppError> {
match s {
"admin" => Ok(VtcRole::Admin),
"moderator" => Ok(VtcRole::Moderator),
"issuer" => Ok(VtcRole::Issuer),
"member" => Ok(VtcRole::Member),
other => {
if let Some(name) = other.strip_prefix(CUSTOM_PREFIX) {
validate_custom_name(name)?;
Ok(VtcRole::Custom(name.to_string()))
} else {
Err(AppError::Validation(format!(
"unknown VTC role '{other}'. Expected one of admin, moderator, issuer, \
member, or custom:<name>."
)))
}
}
}
}
}
impl Serialize for VtcRole {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.as_wire())
}
}
impl<'de> Deserialize<'de> for VtcRole {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
VtcRole::from_str(&s).map_err(serde::de::Error::custom)
}
}
fn validate_custom_name(name: &str) -> Result<(), AppError> {
if name.is_empty() {
return Err(AppError::Validation(
"custom role name cannot be empty".into(),
));
}
if name.len() > CUSTOM_NAME_MAX {
return Err(AppError::Validation(format!(
"custom role name too long (max {CUSTOM_NAME_MAX} chars)"
)));
}
if !name.chars().all(is_valid_name_char) {
return Err(AppError::Validation(format!(
"custom role name '{name}' contains disallowed characters \
(allowed: lowercase a–z, digits, `-`, `_`)"
)));
}
Ok(())
}
fn is_valid_name_char(c: char) -> bool {
matches!(c, 'a'..='z' | '0'..='9' | '-' | '_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn standard_variants_round_trip_through_wire() {
for (variant, wire) in [
(VtcRole::Admin, "admin"),
(VtcRole::Moderator, "moderator"),
(VtcRole::Issuer, "issuer"),
(VtcRole::Member, "member"),
] {
let serialised = serde_json::to_string(&variant).unwrap();
assert_eq!(serialised, format!("\"{wire}\""));
let deserialised: VtcRole = serde_json::from_str(&serialised).unwrap();
assert_eq!(deserialised, variant);
assert_eq!(variant.to_string(), wire);
}
}
#[test]
fn custom_variant_round_trips() {
let r = VtcRole::custom("editor").unwrap();
let serialised = serde_json::to_string(&r).unwrap();
assert_eq!(serialised, "\"custom:editor\"");
let deserialised: VtcRole = serde_json::from_str(&serialised).unwrap();
assert_eq!(deserialised, r);
}
#[test]
fn custom_constructor_validates_charset() {
assert!(VtcRole::custom("editor").is_ok());
assert!(VtcRole::custom("trust-anchor").is_ok());
assert!(VtcRole::custom("badge_holder_42").is_ok());
for bad in ["", "EDITOR", "with spaces", "colon:bad", "../../etc"] {
let err = VtcRole::custom(bad).expect_err(&format!("expected reject: {bad}"));
assert!(matches!(err, AppError::Validation(_)));
}
}
#[test]
fn deserialise_rejects_unknown_string() {
let err = serde_json::from_str::<VtcRole>("\"unknown\"").unwrap_err();
assert!(err.to_string().contains("unknown VTC role"));
}
#[test]
fn deserialise_rejects_custom_with_bad_charset() {
let err = serde_json::from_str::<VtcRole>("\"custom:UPPER\"").unwrap_err();
assert!(err.to_string().contains("disallowed characters"));
}
#[test]
fn deserialise_rejects_custom_with_empty_name() {
let err = serde_json::from_str::<VtcRole>("\"custom:\"").unwrap_err();
assert!(err.to_string().contains("cannot be empty"));
}
#[test]
fn deserialise_rejects_custom_with_name_too_long() {
let name = "a".repeat(CUSTOM_NAME_MAX + 1);
let err = serde_json::from_str::<VtcRole>(&format!("\"custom:{name}\"")).unwrap_err();
assert!(err.to_string().contains("too long"));
}
#[test]
fn custom_name_with_colon_does_not_smuggle_admin() {
let r: VtcRole = serde_json::from_str("\"custom:admin\"").unwrap();
assert_eq!(r, VtcRole::Custom("admin".into()));
assert_ne!(r, VtcRole::Admin);
}
#[test]
fn is_standard_returns_true_only_for_named_variants() {
assert!(VtcRole::Admin.is_standard());
assert!(VtcRole::Moderator.is_standard());
assert!(VtcRole::Issuer.is_standard());
assert!(VtcRole::Member.is_standard());
assert!(!VtcRole::Custom("x".into()).is_standard());
}
#[test]
fn from_str_handles_vti_common_admin_wire_shape() {
let role = VtcRole::from_str("admin").unwrap();
assert_eq!(role, VtcRole::Admin);
}
}