use std::{fmt, str::FromStr};
use crate::error::UrnParseError;
pub(crate) fn is_valid_source_id(s: &str) -> bool {
!s.is_empty()
&& s.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(
c,
'-' | '.' | '_' | '~' | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '=' | '@' | '/' )
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Urn {
pub source_id: String,
pub name: String,
}
impl Urn {
pub fn parse(s: &str) -> Result<Self, UrnParseError> {
s.parse()
}
}
impl FromStr for Urn {
type Err = UrnParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts: Vec<&str> = s.splitn(4, ':').collect();
if parts.len() != 4 {
return Err(UrnParseError::WrongSegmentCount(parts.len()));
}
if !parts[0].eq_ignore_ascii_case("urn") {
return Err(UrnParseError::InvalidScheme(parts[0].to_owned()));
}
if !parts[1].eq_ignore_ascii_case("secrets-rs") {
return Err(UrnParseError::InvalidNid(parts[1].to_owned()));
}
if parts[2].is_empty() {
return Err(UrnParseError::EmptySourceId);
}
if !is_valid_source_id(parts[2]) {
return Err(UrnParseError::InvalidSourceId(parts[2].to_owned()));
}
if parts[3].is_empty() {
return Err(UrnParseError::EmptyName);
}
Ok(Urn {
source_id: parts[2].to_owned(),
name: parts[3].to_owned(),
})
}
}
impl fmt::Display for Urn {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "urn:secrets-rs:{}:{}", self.source_id, self.name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_valid_urn() {
let urn: Urn = "urn:secrets-rs:env:MY_SECRET".parse().unwrap();
assert_eq!(urn.source_id, "env");
assert_eq!(urn.name, "MY_SECRET");
}
#[test]
fn case_insensitive_scheme_and_nid() {
let urn: Urn = "URN:SECRETS-RS:env:KEY".parse().unwrap();
assert_eq!(urn.source_id, "env");
assert_eq!(urn.name, "KEY");
}
#[test]
fn display_uses_lowercase_prefix() {
let urn: Urn = "urn:secrets-rs:env:KEY".parse().unwrap();
assert_eq!(urn.to_string(), "urn:secrets-rs:env:KEY");
}
#[test]
fn name_may_contain_colons() {
let urn: Urn = "urn:secrets-rs:env:a:b:c".parse().unwrap();
assert_eq!(urn.name, "a:b:c");
}
#[test]
fn error_on_wrong_scheme() {
let err = "nrn:secrets-rs:env:KEY".parse::<Urn>().unwrap_err();
assert!(matches!(err, UrnParseError::InvalidScheme(_)));
}
#[test]
fn error_on_wrong_nid() {
let err = "urn:other:env:KEY".parse::<Urn>().unwrap_err();
assert!(matches!(err, UrnParseError::InvalidNid(_)));
}
#[test]
fn error_on_too_few_segments() {
let err = "urn:secrets-rs:env".parse::<Urn>().unwrap_err();
assert!(matches!(err, UrnParseError::WrongSegmentCount(_)));
}
#[test]
fn error_on_empty_source_id() {
let err = "urn:secrets-rs::KEY".parse::<Urn>().unwrap_err();
assert_eq!(err, UrnParseError::EmptySourceId);
}
#[test]
fn error_on_empty_name() {
let err = "urn:secrets-rs:env:".parse::<Urn>().unwrap_err();
assert_eq!(err, UrnParseError::EmptyName);
}
#[test]
fn error_on_source_id_with_space() {
let err = "urn:secrets-rs:bad id:KEY".parse::<Urn>().unwrap_err();
assert!(matches!(err, UrnParseError::InvalidSourceId(_)));
}
#[test]
fn error_on_source_id_with_non_ascii() {
let err = "urn:secrets-rs:café:KEY".parse::<Urn>().unwrap_err();
assert!(matches!(err, UrnParseError::InvalidSourceId(_)));
}
#[test]
fn valid_source_id_with_punctuation() {
let urn = "urn:secrets-rs:aws.sm-v2/prod:MY_SECRET"
.parse::<Urn>()
.unwrap();
assert_eq!(urn.source_id, "aws.sm-v2/prod");
assert_eq!(urn.name, "MY_SECRET");
}
}