use super::error::{AgentNameError, AppError, ValidationKind};
const RESERVED_NAMES: &[&str] = &[
".well-known",
"api",
"auth",
"dids",
"stats",
"acl",
"health",
];
fn path_err(msg: impl Into<String>) -> AppError {
AppError::validation(ValidationKind::InvalidPath, msg)
}
fn validate_segment(segment: &str) -> Result<(), AppError> {
if segment.len() < 2 || segment.len() > 63 {
return Err(path_err(
"each path segment must be between 2 and 63 characters",
));
}
if !segment
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(path_err(
"path segments must contain only lowercase letters, digits, and hyphens",
));
}
let first = segment.as_bytes()[0];
let last = segment.as_bytes()[segment.len() - 1];
if !first.is_ascii_alphanumeric() || !last.is_ascii_alphanumeric() {
return Err(path_err(
"each path segment must start and end with an alphanumeric character",
));
}
Ok(())
}
pub fn validate_custom_path(path: &str) -> Result<(), AppError> {
if path.is_empty() {
return Err(path_err("path must not be empty"));
}
if path.len() > 255 {
return Err(path_err("path must be at most 255 characters"));
}
if path.starts_with('/') || path.ends_with('/') {
return Err(path_err("path must not start or end with '/'"));
}
for (i, segment) in path.split('/').enumerate() {
if segment.is_empty() {
return Err(path_err(
"path must not contain empty segments (double slashes)",
));
}
validate_segment(segment)?;
if i == 0 && RESERVED_NAMES.contains(&segment) {
return Err(path_err(format!(
"'{segment}' is a reserved name and cannot be used as the first path segment",
)));
}
}
Ok(())
}
pub fn validate_mnemonic(mnemonic: &str) -> Result<(), AppError> {
if mnemonic == ".well-known" {
return Ok(());
}
validate_custom_path(mnemonic)
}
pub fn validate_webs_mnemonic(mnemonic: &str) -> Result<(), AppError> {
if mnemonic.is_empty() {
return Err(path_err("path must not be empty"));
}
if mnemonic.len() > 255 {
return Err(path_err("path must be at most 255 characters"));
}
if mnemonic.starts_with('/') || mnemonic.ends_with('/') {
return Err(path_err("path must not start or end with '/'"));
}
if mnemonic == ROOT_DID_MNEMONIC {
return Err(path_err(
"did:webs has no root slot — the AID is always the final path segment",
));
}
let segments: Vec<&str> = mnemonic.split('/').collect();
let (aid, leading) = segments
.split_last()
.expect("split on a non-empty string yields at least one segment");
for (i, segment) in leading.iter().enumerate() {
if segment.is_empty() {
return Err(path_err(
"path must not contain empty segments (double slashes)",
));
}
validate_segment(segment)?;
if i == 0 && RESERVED_NAMES.contains(segment) {
return Err(path_err(format!(
"'{segment}' is a reserved name and cannot be used as the first path segment",
)));
}
}
validate_aid_segment(aid)?;
if leading.is_empty() && RESERVED_NAMES.contains(aid) {
return Err(path_err(format!(
"'{aid}' is a reserved name and cannot be used as the first path segment",
)));
}
Ok(())
}
fn validate_aid_segment(aid: &str) -> Result<(), AppError> {
if aid.len() < 4 || aid.len() > 63 {
return Err(path_err(
"the final path segment of a did:webs path is its AID, and must be \
between 4 and 63 characters",
));
}
if !aid
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err(path_err(
"the final path segment of a did:webs path is its AID, and must contain \
only characters from the base64url alphabet (A-Z, a-z, 0-9, '-', '_')",
));
}
Ok(())
}
const RESERVED_AGENT_NAMES: &[&str] = &[
"abuse",
"admin",
"administrator",
"api",
"help",
"hostmaster",
"info",
"postmaster",
"root",
"security",
"support",
"sysadmin",
"webmaster",
"well-known",
];
pub const ROOT_DID_MNEMONIC: &str = ".well-known";
pub fn validate_agent_name(name: &str) -> Result<(), AppError> {
let name = name.strip_prefix('@').unwrap_or(name);
if name.is_empty() {
return Ok(());
}
validate_segment(name)?;
if RESERVED_AGENT_NAMES.contains(&name) {
return Err(AppError::AgentName(AgentNameError::Reserved));
}
Ok(())
}
pub fn validate_agent_name_binding(name: &str, mnemonic: &str) -> Result<(), AppError> {
validate_agent_name(name)?;
let bare = name.strip_prefix('@').unwrap_or(name);
if bare.is_empty() && mnemonic != ROOT_DID_MNEMONIC {
return Err(AppError::AgentName(AgentNameError::Reserved));
}
Ok(())
}
#[cfg(test)]
mod webs_mnemonic_tests {
use super::*;
const AID: &str = "ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe";
#[test]
fn the_shared_validator_rejects_every_aid() {
assert!(
validate_custom_path(AID).is_err(),
"an AID is mixed-case, so the lowercase-only segment rule rejects it",
);
}
#[test]
fn accepts_a_bare_aid() {
validate_webs_mnemonic(AID).expect("an AID alone is the common did:webs slot");
}
#[test]
fn accepts_path_segments_before_the_aid() {
validate_webs_mnemonic(&format!("tenants/acme/{AID}")).expect("leading path is allowed");
}
#[test]
fn rejects_uppercase_in_leading_segments() {
assert!(validate_webs_mnemonic(&format!("Tenants/{AID}")).is_err());
}
#[test]
fn rejects_a_reserved_first_segment() {
assert!(validate_webs_mnemonic(&format!("api/{AID}")).is_err());
assert!(validate_webs_mnemonic("api").is_err());
}
#[test]
fn rejects_an_aid_outside_base64url() {
assert!(validate_webs_mnemonic("not.an.aid").is_err());
assert!(validate_webs_mnemonic("has spaces").is_err());
}
#[test]
fn rejects_a_too_short_aid() {
assert!(validate_webs_mnemonic("abc").is_err());
}
#[test]
fn rejects_the_root_slot() {
assert!(validate_webs_mnemonic(ROOT_DID_MNEMONIC).is_err());
}
#[test]
fn rejects_empty_and_slash_edges() {
assert!(validate_webs_mnemonic("").is_err());
assert!(validate_webs_mnemonic(&format!("/{AID}")).is_err());
assert!(validate_webs_mnemonic(&format!("{AID}/")).is_err());
assert!(validate_webs_mnemonic(&format!("tenants//{AID}")).is_err());
}
#[test]
fn accepts_the_base64url_specials() {
validate_webs_mnemonic("EAbc-def_ghi").expect("`-` and `_` are legal in an AID");
}
}