use std::collections::HashMap;
#[cfg(feature = "agent-names")]
pub mod agent_name;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NameSource {
AgentName { verified: bool },
AclLabel,
DeviceName,
LocalAlias,
ServerLabel,
ContextName,
}
impl NameSource {
#[must_use]
pub fn rank(self) -> u8 {
match self {
Self::AgentName { verified: true } => 100,
Self::AclLabel => 60,
Self::LocalAlias => 50,
Self::DeviceName => 45,
Self::ServerLabel => 40,
Self::ContextName => 30,
Self::AgentName { verified: false } => 10,
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::AgentName { verified: true } => "agent-name",
Self::AgentName { verified: false } => "agent-name-unverified",
Self::AclLabel => "acl-label",
Self::DeviceName => "device-name",
Self::LocalAlias => "local-alias",
Self::ServerLabel => "server-label",
Self::ContextName => "context-name",
}
}
#[must_use]
pub fn is_trusted(self) -> bool {
!matches!(self, Self::AgentName { verified: false })
}
}
impl serde::Serialize for NameSource {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DisplayName {
pub name: String,
pub source: NameSource,
}
impl DisplayName {
pub fn new(name: impl Into<String>, source: NameSource) -> Self {
Self {
name: name.into(),
source,
}
}
#[must_use]
pub fn is_trusted(&self) -> bool {
self.source.is_trusted()
}
}
pub const UNVERIFIED_SUFFIX: &str = " [unverified]";
#[derive(Debug, Clone, Default)]
pub struct NameBook {
entries: HashMap<String, DisplayName>,
}
impl NameBook {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, did: impl Into<String>, name: DisplayName) {
if name.name.trim().is_empty() {
return;
}
let did = did.into();
match self.entries.get(&did) {
Some(existing) if existing.source.rank() >= name.source.rank() => {}
_ => {
self.entries.insert(did, name);
}
}
}
pub fn insert_opt(&mut self, did: impl Into<String>, name: Option<&str>, source: NameSource) {
if let Some(n) = name {
self.insert(did, DisplayName::new(n, source));
}
}
#[must_use]
pub fn get(&self, did: &str) -> Option<&DisplayName> {
self.entries.get(did)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn names_any<'a>(&self, dids: impl IntoIterator<Item = &'a str>) -> bool {
dids.into_iter().any(|d| self.entries.contains_key(d))
}
#[must_use]
pub fn name_of(&self, did: &str) -> Option<String> {
self.entries.get(did).map(|n| {
if n.is_trusted() {
n.name.clone()
} else {
format!("{}{UNVERIFIED_SUFFIX}", n.name)
}
})
}
#[must_use]
pub fn render_inline(&self, did: &str) -> String {
match self.name_of(did) {
Some(name) => format!("{name} ({})", shorten_did(did)),
None => shorten_did(did),
}
}
}
#[must_use]
pub fn shorten_did(did: &str) -> String {
shorten_did_keep(did, DEFAULT_KEEP)
}
pub const DEFAULT_KEEP: usize = 10;
const TAIL: usize = 6;
#[must_use]
pub fn shorten_did_keep(did: &str, keep: usize) -> String {
if !did.starts_with("did:") {
return did.to_string();
}
let parts: Vec<&str> = did.split(':').collect();
let method = parts.get(1).copied().unwrap_or_default();
if (method == "webvh" || method == "web") && parts.len() > 3 {
let scid = parts[2];
if char_len(scid) <= keep + 1 {
return did.to_string();
}
let mut out = parts.clone();
let abbreviated = format!("{}…", take_chars(scid, keep));
out[2] = &abbreviated;
return out.join(":");
}
let id = parts[2..].join(":");
if char_len(&id) <= keep + TAIL + 1 {
return did.to_string();
}
format!(
"{}:{}:{}…{}",
parts[0],
method,
take_chars(&id, keep),
take_last_chars(&id, TAIL)
)
}
fn char_len(s: &str) -> usize {
s.chars().count()
}
fn take_chars(s: &str, n: usize) -> String {
s.chars().take(n).collect()
}
fn take_last_chars(s: &str, n: usize) -> String {
let len = char_len(s);
s.chars().skip(len.saturating_sub(n)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
const VECTORS: &[(&str, &str)] = &[
("alice", "alice"),
("https://example.com/@alice", "https://example.com/@alice"),
(
"did:webvh:QmXkAbCdEfGhIjKlMnOp:webvh.storm.ws:glenn-vta",
"did:webvh:QmXkAbCdEf…:webvh.storm.ws:glenn-vta",
),
(
"did:web:QmXkAbCdEfGhIjKlMnOp:example.com",
"did:web:QmXkAbCdEf…:example.com",
),
("did:webvh:Qm123:example.com", "did:webvh:Qm123:example.com"),
(
"did:key:z6MkfrQjWzPQrTuVwXyZaBcDeFgHiJkLmNoPqRsTuVwXyZ4rT",
"did:key:z6MkfrQjWz…XyZ4rT",
),
("did:key:z6MkfrQjWz", "did:key:z6MkfrQjWz"),
(
"did:webvh:QmXkAbCdEfGhIjKlMnOpQrSt",
"did:webvh:QmXkAbCdEf…OpQrSt",
),
];
#[test]
fn shorten_did_matches_shared_vectors() {
for (input, expected) in VECTORS {
assert_eq!(&shorten_did(input), expected, "input: {input}");
}
}
#[test]
fn shorten_did_is_char_safe() {
let did = "did:key:zÄÖÜäöüßÅÆØåæøΩμλ0123456789";
let out = shorten_did(did);
assert!(out.starts_with("did:key:"));
assert!(out.contains('…'));
}
#[test]
fn verified_agent_name_outranks_local_label() {
assert!(
NameSource::AgentName { verified: true }.rank() > NameSource::AclLabel.rank(),
"a cryptographically bound global name beats one operator's private note"
);
}
#[test]
fn unverified_agent_name_ranks_below_every_local_source() {
let unverified = NameSource::AgentName { verified: false }.rank();
for local in [
NameSource::AclLabel,
NameSource::DeviceName,
NameSource::LocalAlias,
NameSource::ServerLabel,
NameSource::ContextName,
] {
assert!(
local.rank() > unverified,
"{local:?} must not be displaced by an unchecked claim"
);
}
}
#[test]
fn only_unverified_agent_names_are_untrusted() {
assert!(!NameSource::AgentName { verified: false }.is_trusted());
assert!(NameSource::AgentName { verified: true }.is_trusted());
assert!(NameSource::AclLabel.is_trusted());
}
const DID_A: &str = "did:key:z6MkfrQjWzPQrTuVwXyZaBcDeFgHiJkLmNoPqRsTuVwXyZ4rT";
#[test]
fn insert_is_order_independent() {
let strong = DisplayName::new(
"agent.example/@ops",
NameSource::AgentName { verified: true },
);
let weak = DisplayName::new("my-note", NameSource::AclLabel);
let mut a = NameBook::new();
a.insert(DID_A, weak.clone());
a.insert(DID_A, strong.clone());
let mut b = NameBook::new();
b.insert(DID_A, strong.clone());
b.insert(DID_A, weak);
assert_eq!(a.get(DID_A), b.get(DID_A));
assert_eq!(a.get(DID_A), Some(&strong));
}
#[test]
fn unverified_claim_never_displaces_an_operator_label() {
let mut book = NameBook::new();
book.insert(DID_A, DisplayName::new("payroll-bot", NameSource::AclLabel));
book.insert(
DID_A,
DisplayName::new(
"mybank.com/@treasury",
NameSource::AgentName { verified: false },
),
);
assert_eq!(
book.get(DID_A).map(|n| n.name.as_str()),
Some("payroll-bot")
);
}
#[test]
fn unverified_names_are_tagged_when_rendered() {
let mut book = NameBook::new();
book.insert(
DID_A,
DisplayName::new(
"mybank.com/@treasury",
NameSource::AgentName { verified: false },
),
);
let rendered = book.name_of(DID_A).unwrap();
assert!(
rendered.contains("unverified"),
"a self-asserted name must never render bare: {rendered}"
);
assert!(book.render_inline(DID_A).contains("unverified"));
}
#[test]
fn blank_labels_are_not_stored() {
let mut book = NameBook::new();
book.insert(DID_A, DisplayName::new("", NameSource::AclLabel));
book.insert(DID_A, DisplayName::new(" ", NameSource::AclLabel));
assert!(book.is_empty(), "a blank label must not shadow the DID");
}
#[test]
fn insert_opt_skips_none() {
let mut book = NameBook::new();
book.insert_opt(DID_A, None, NameSource::AclLabel);
assert!(book.is_empty());
book.insert_opt(DID_A, Some("ops"), NameSource::AclLabel);
assert_eq!(book.len(), 1);
}
#[test]
fn render_inline_always_shows_the_did() {
let mut book = NameBook::new();
book.insert(DID_A, DisplayName::new("ops", NameSource::AclLabel));
let rendered = book.render_inline(DID_A);
assert!(rendered.starts_with("ops ("));
assert!(
rendered.contains("did:key:"),
"the operator must be able to audit the name against an identifier"
);
}
#[test]
fn render_inline_falls_back_to_the_shortened_did() {
let book = NameBook::new();
assert_eq!(book.render_inline(DID_A), shorten_did(DID_A));
}
#[test]
fn names_any_drives_the_optional_name_column() {
let mut book = NameBook::new();
assert!(!book.names_any([DID_A, "did:key:zOther"]));
book.insert(DID_A, DisplayName::new("ops", NameSource::AclLabel));
assert!(book.names_any([DID_A, "did:key:zOther"]));
assert!(!book.names_any(["did:key:zOther"]));
}
#[test]
fn name_source_json_tags_are_stable() {
assert_eq!(
serde_json::to_string(&NameSource::AgentName { verified: false }).unwrap(),
"\"agent-name-unverified\""
);
assert_eq!(
serde_json::to_string(&NameSource::AclLabel).unwrap(),
"\"acl-label\""
);
}
}