use core::fmt;
use crate::hash::to_hex_bytes;
use crate::write_auth::is_hex;
pub const MAX_IDENTITY_LEN: usize = 173;
pub const MAX_NAME_LEN: usize = 100;
const ED25519_PREFIX: &str = "ed25519-";
const ADDRESS_PREFIX: &str = "0x";
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum IdentityError {
#[error("repository identity longer than 173 bytes")]
TooLong,
#[error("namespace outside the SPEC-TRANSPORT-CONNECT §7.4 grammar")]
Namespace,
#[error("repository name outside the SPEC-TRANSPORT-CONNECT §7.4 grammar")]
Name,
#[error("bare repository name where a namespaced identity is required")]
BareName,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Namespace {
Ed25519([u8; 32]),
Address([u8; 20]),
}
impl Namespace {
pub fn parse(s: &str) -> Result<Self, IdentityError> {
if let Some(hex) = s.strip_prefix(ED25519_PREFIX) {
decode_hex(hex).map(Self::Ed25519)
} else if let Some(hex) = s.strip_prefix(ADDRESS_PREFIX) {
decode_hex(hex).map(Self::Address)
} else {
Err(IdentityError::Namespace)
}
}
}
impl fmt::Display for Namespace {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Ed25519(key) => write!(f, "{ED25519_PREFIX}{}", to_hex_bytes(key)),
Self::Address(addr) => write!(f, "{ADDRESS_PREFIX}{}", to_hex_bytes(addr)),
}
}
}
fn decode_hex<const N: usize>(hex: &str) -> Result<[u8; N], IdentityError> {
if !is_hex(hex, N) {
return Err(IdentityError::Namespace);
}
let nibble = |b: u8| {
if b.is_ascii_digit() {
b - b'0'
} else {
b - b'a' + 10
}
};
let mut out = [0u8; N];
for (byte, pair) in out.iter_mut().zip(hex.as_bytes().chunks_exact(2)) {
*byte = (nibble(pair[0]) << 4) | nibble(pair[1]);
}
Ok(out)
}
pub fn validate_name(name: &str) -> Result<(), IdentityError> {
let lead = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit();
let bytes = name.as_bytes();
match bytes.split_first() {
Some((&first, rest))
if bytes.len() <= MAX_NAME_LEN
&& lead(first)
&& rest.iter().all(|&b| lead(b) || b"._-".contains(&b)) =>
{
Ok(())
}
_ => Err(IdentityError::Name),
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct RepositoryIdentity {
namespace: Option<Namespace>,
name: String,
}
impl RepositoryIdentity {
pub fn new(namespace: Option<Namespace>, name: &str) -> Result<Self, IdentityError> {
validate_name(name)?;
Ok(Self {
namespace,
name: name.to_owned(),
})
}
pub fn parse(s: &str) -> Result<Self, IdentityError> {
let id = Self::parse_bare_allowed(s)?;
if id.namespace.is_none() {
return Err(IdentityError::BareName);
}
Ok(id)
}
pub fn parse_bare_allowed(s: &str) -> Result<Self, IdentityError> {
if s.len() > MAX_IDENTITY_LEN {
return Err(IdentityError::TooLong);
}
match s.split_once('/') {
Some((namespace, name)) => Self::new(Some(Namespace::parse(namespace)?), name),
None => Self::new(None, s),
}
}
#[must_use]
pub fn namespace(&self) -> Option<&Namespace> {
self.namespace.as_ref()
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
}
impl fmt::Display for RepositoryIdentity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.namespace {
Some(namespace) => write!(f, "{namespace}/{}", self.name),
None => f.write_str(&self.name),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: &str = "ed25519-3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29";
const ADDR: &str = "0x8ba1f109551bd432803012645ac136ddd64dba72";
fn roundtrip(s: &str) -> RepositoryIdentity {
let id = RepositoryIdentity::parse(s).unwrap();
assert_eq!(id.to_string(), s);
id
}
#[test]
fn accepts_both_namespace_forms() {
let id = roundtrip(&format!("{KEY}/website"));
assert!(matches!(id.namespace(), Some(Namespace::Ed25519(k)) if k[0] == 0x3b));
assert_eq!(id.name(), "website");
let id = roundtrip(&format!("{ADDR}/a.b_c-9"));
assert!(matches!(id.namespace(), Some(Namespace::Address(a)) if a[19] == 0x72));
assert_eq!(Namespace::parse(ADDR).unwrap().to_string(), ADDR);
}
#[test]
fn accepts_the_longest_identity() {
let s = format!("{KEY}/{}", "a".repeat(MAX_NAME_LEN));
assert_eq!(s.len(), MAX_IDENTITY_LEN);
roundtrip(&s);
let s = format!("{KEY}/{}", "a".repeat(MAX_NAME_LEN + 1));
assert_eq!(RepositoryIdentity::parse(&s), Err(IdentityError::TooLong));
}
#[test]
fn rejects_bad_namespaces() {
let upper = KEY.replace('b', "B");
let cases = [
upper.as_str(),
"0x8BA1F109551BD432803012645AC136DDD64DBA72",
"0X8ba1f109551bd432803012645ac136ddd64dba72",
"0x8ba1f109551bd432803012645ac136ddd64dba7",
"0x8ba1f109551bd432803012645ac136ddd64dba722",
&KEY[..KEY.len() - 1],
"ed25519-",
"0x",
"",
"root",
"ed25519:3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29",
];
for ns in cases {
assert_eq!(Namespace::parse(ns), Err(IdentityError::Namespace), "{ns}");
assert_eq!(
RepositoryIdentity::parse(&format!("{ns}/x")),
Err(IdentityError::Namespace),
"{ns}"
);
}
}
#[test]
fn rejects_bad_names() {
let long = "a".repeat(MAX_NAME_LEN + 1);
for name in [
"", ".x", "_x", "-x", "Web", "weB", "a/b", "a b", "a*", "é", &long,
] {
assert_eq!(
RepositoryIdentity::parse(&format!("{ADDR}/{name}")),
Err(IdentityError::Name),
"{name:?}"
);
assert!(
RepositoryIdentity::parse_bare_allowed(name).is_err(),
"{name:?}"
);
}
assert_eq!(
RepositoryIdentity::parse(&format!("{ADDR}/a/b")),
Err(IdentityError::Name)
);
assert_eq!(
RepositoryIdentity::parse(&format!("/{ADDR}/a")),
Err(IdentityError::Namespace)
);
}
#[test]
fn bare_names_only_when_allowed() {
assert_eq!(
RepositoryIdentity::parse("default"),
Err(IdentityError::BareName)
);
let id = RepositoryIdentity::parse_bare_allowed("default").unwrap();
assert_eq!(id.namespace(), None);
assert_eq!(id.to_string(), "default");
let id = RepositoryIdentity::parse_bare_allowed(ADDR).unwrap();
assert_eq!(id.namespace(), None);
assert_eq!(
RepositoryIdentity::parse_bare_allowed(&"a".repeat(MAX_NAME_LEN))
.unwrap()
.name()
.len(),
MAX_NAME_LEN
);
}
#[test]
fn new_validates_the_name() {
let ns = Namespace::parse(ADDR).unwrap();
assert!(RepositoryIdentity::new(Some(ns), "ok").is_ok());
assert_eq!(
RepositoryIdentity::new(Some(ns), "Bad"),
Err(IdentityError::Name)
);
}
}