use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
const MAX_LEN: usize = 64;
const MAX_SEGMENT_LEN: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CredentialRefError {
#[error("credential reference is empty")]
Empty,
#[error(
"credential reference is too long ({len} bytes; max {MAX_LEN}, max {MAX_SEGMENT_LEN} per segment)"
)]
TooLong {
len: usize,
},
#[error("credential reference must be `provider` or `provider/qualifier`")]
Shape,
#[error(
"credential reference segments must be lowercase kebab-case `[a-z0-9-]`, \
not starting or ending with `-` (DOC-45 C-2.4)"
)]
Charset,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct CredentialRef {
provider: String,
qualifier: Option<String>,
}
impl CredentialRef {
pub fn parse(text: &str) -> Result<Self, CredentialRefError> {
if text.is_empty() {
return Err(CredentialRefError::Empty);
}
if text.len() > MAX_LEN {
return Err(CredentialRefError::TooLong { len: text.len() });
}
let mut parts = text.split('/');
let provider = parts.next().unwrap_or_default();
let qualifier = parts.next();
if parts.next().is_some() {
return Err(CredentialRefError::Shape);
}
validate_segment(provider)?;
if let Some(q) = qualifier {
validate_segment(q)?;
}
Ok(Self {
provider: provider.to_string(),
qualifier: qualifier.map(str::to_string),
})
}
pub fn provider(&self) -> &str {
&self.provider
}
pub fn qualifier(&self) -> Option<&str> {
self.qualifier.as_deref()
}
}
fn validate_segment(segment: &str) -> Result<(), CredentialRefError> {
if segment.is_empty() {
return Err(CredentialRefError::Shape);
}
if segment.len() > MAX_SEGMENT_LEN {
return Err(CredentialRefError::TooLong { len: segment.len() });
}
if segment.starts_with('-') || segment.ends_with('-') {
return Err(CredentialRefError::Charset);
}
if !segment
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
return Err(CredentialRefError::Charset);
}
Ok(())
}
impl fmt::Display for CredentialRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.provider)?;
if let Some(q) = &self.qualifier {
write!(f, "/{q}")?;
}
Ok(())
}
}
impl FromStr for CredentialRef {
type Err = CredentialRefError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl Serialize for CredentialRef {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for CredentialRef {
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)]
mod tests {
use super::*;
#[test]
fn round_trips_through_serde() {
for text in ["slack", "slack/bot", "github/work", "google-oauth"] {
let parsed = CredentialRef::parse(text).unwrap();
let json = serde_json::to_string(&parsed).unwrap();
assert_eq!(json, format!("\"{text}\""));
let back: CredentialRef = serde_json::from_str(&json).unwrap();
assert_eq!(back, parsed);
assert_eq!(text.parse::<CredentialRef>().unwrap(), parsed);
}
}
#[test]
fn display_is_verbatim() {
assert_eq!(CredentialRef::parse("slack").unwrap().to_string(), "slack");
assert_eq!(
CredentialRef::parse("github/work").unwrap().to_string(),
"github/work"
);
}
#[test]
fn realistic_credentials_are_rejected_by_the_grammar() {
let specimens: &[(&str, &str)] = &[
("GitHub PAT", "ghp_16C7e42F292c6912E7710c838347Ae178B4a"),
(
"GitHub fine-grained",
"github_pat_11ABCDE0Y_aBcDeFgHiJkLmNoP",
),
("OpenAI", "sk-proj-Ab12Cd34Ef56Gh78Ij90KlMnOpQrSt"),
(
"Slack bot",
concat!("xo", "xb", "-2314151234-2321313111-QwErTyUiOpAsDf"),
),
(
"Slack app",
concat!("xa", "pp", "-1-A012BCDEF-1234567890-abcdefABCDEF0123"),
),
(
"Telegram",
"1234567890:AAF-abcDEF1234ghIkl-zyx57W2v1u123ew11",
),
("Brave", "BSA_aBcDeFgHiJkLmNoPqRsTuVwXyZ012345"),
("JWT", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.dBjftJeZ4CVP"),
("PEM body", "-----BEGIN RSA PRIVATE KEY-----\nMIIEow=="),
("Google OAuth secret", "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"),
];
for (name, specimen) in specimens {
assert!(
CredentialRef::parse(specimen).is_err(),
"{name} specimen parsed as a CredentialRef — the C-2.4 grammar is too permissive"
);
}
}
#[test]
fn qualifier_is_optional_and_preserved() {
let bare = CredentialRef::parse("github").unwrap();
assert_eq!(bare.provider(), "github");
assert_eq!(bare.qualifier(), None);
let qualified = CredentialRef::parse("github/work").unwrap();
assert_eq!(qualified.provider(), "github");
assert_eq!(qualified.qualifier(), Some("work"));
assert_ne!(bare, qualified);
}
#[test]
fn rejects_out_of_grammar_text() {
use CredentialRefError::*;
let cases: &[(&str, CredentialRefError)] = &[
("", Empty),
("a/b/c", Shape),
("slack/", Shape),
("/slack", Shape),
("Slack", Charset),
("slack_bot", Charset),
("slack.bot", Charset),
("slack bot", Charset),
("-slack", Charset),
("slack-", Charset),
("slack\n", Charset),
];
for (input, expected) in cases {
assert_eq!(
CredentialRef::parse(input).unwrap_err(),
*expected,
"input {input:?}"
);
}
let long = "a".repeat(MAX_LEN + 1);
assert_eq!(
CredentialRef::parse(&long).unwrap_err(),
TooLong { len: MAX_LEN + 1 }
);
let long_segment = format!("{}/b", "a".repeat(MAX_SEGMENT_LEN + 1));
assert_eq!(
CredentialRef::parse(&long_segment).unwrap_err(),
TooLong {
len: MAX_SEGMENT_LEN + 1
}
);
}
#[test]
fn parse_error_never_echoes_the_input() {
let secret = "ghp_16C7e42F292c6912E7710c838347Ae178B4a";
let rendered = CredentialRef::parse(secret).unwrap_err().to_string();
assert!(!rendered.contains(secret), "leaked: {rendered}");
assert!(!rendered.contains("ghp_"), "leaked prefix: {rendered}");
}
#[test]
fn deserialize_rejects_out_of_grammar_text() {
let json = concat!(
"\"",
"xo",
"xb",
"-2314151234-2321313111-QwErTyUiOpAsDf",
"\""
);
assert!(serde_json::from_str::<CredentialRef>(json).is_err());
}
}