Skip to main content

vta_sdk/display_name/
mod.rs

1//! DID → human-readable display name.
2//!
3//! Operators read DIDs constantly and can't. Every operator-facing surface in
4//! the workspace — the PNM/CNM CLIs, the VTC operator CLI, the VTC admin UI —
5//! prints raw DIDs, each truncating them its own way. This module is the one
6//! seam they all render through.
7//!
8//! # The model: a book the caller fills, not a resolver that fetches
9//!
10//! [`NameBook`] is a plain `DID → name` map. Commands populate it from data
11//! they *already hold* and then query it while rendering. That shape is what
12//! keeps naming free: `acl list` already receives every entry with its
13//! `label`, so filling the book from that one response also names the
14//! `created_by` column — a DID that has no label of its own but is very often
15//! another entry's subject. No extra request, no N+1 lookup.
16//!
17//! Nothing here performs I/O. The one source that needs the network — a
18//! verified agent name — lives in [`agent_name`] behind the `agent-names`
19//! feature and hands its result back as a [`DisplayName`] like any other.
20//!
21//! # Where names come from today
22//!
23//! No DID document in this workspace currently publishes an `alsoKnownAs`
24//! entry, so there are no agent names to show yet. The names that exist are
25//! local: `AclEntry.label`, `ContextRecord.name`, `WebvhServerRecord.label`,
26//! the PNM/CNM per-VTA config `name`. [`NameSource`] keeps them distinguished
27//! rather than flattening them to a string, because they are not equally
28//! trustworthy — see below.
29//!
30//! # Trust
31//!
32//! A name is a claim, and the claims differ in who is making them.
33//!
34//! A local label was typed by the operator into their own store. A *verified*
35//! agent name round-tripped: the DID's document claimed it and resolving the
36//! name led back to that same DID.
37//!
38//! An **unverified** agent name is neither. `alsoKnownAs` is self-asserted —
39//! the two-sided binding in the agent-name specification protects the
40//! name→DID direction, not the reverse — so a hostile DID can claim
41//! `alsoKnownAs: ["mybank.com/@treasury"]` and anything that renders that
42//! bare has just told the operator a lie in an authoritative voice. Such
43//! names sort *below* every local source in [`NameSource::rank`] and report
44//! [`DisplayName::is_trusted`] `== false`, and [`NameBook::render_inline`]
45//! tags them. Surfaces must not strip that tag.
46
47use std::collections::HashMap;
48
49#[cfg(feature = "agent-names")]
50pub mod agent_name;
51
52/// Where a display name came from.
53///
54/// Kept as a distinct type rather than folding everything into a `String`
55/// because the sources are not equally trustworthy, and the difference has to
56/// survive all the way to the pixel — see the module-level note on trust.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum NameSource {
59    /// An `alsoKnownAs` entry on the DID's own document.
60    ///
61    /// `verified` means the name was resolved forward and led back to this
62    /// same DID. `verified == false` is a bare self-assertion and is the
63    /// least trustworthy source there is.
64    AgentName { verified: bool },
65    /// `AclEntry.label` / `VtcAclEntry.label` — operator-typed, our store.
66    AclLabel,
67    /// A `DeviceBinding.display_name` from an agent device registration.
68    DeviceName,
69    /// The `name` of a locally-configured VTA / community (`~/.config/{pnm,cnm}`).
70    LocalAlias,
71    /// `WebvhServerRecord.label` — names a DID-hosting server.
72    ServerLabel,
73    /// `ContextRecord.name` — names a context, used for the context's own DID.
74    ContextName,
75}
76
77impl NameSource {
78    /// Precedence when two sources name the same DID. Higher wins.
79    ///
80    /// A verified agent name outranks everything: it is globally meaningful
81    /// and cryptographically bound to the DID, where a local label is one
82    /// operator's private note. An *unverified* agent name ranks below every
83    /// local source for the reason in the module docs — the operator's own
84    /// data must never be displaced by a stranger's unchecked claim.
85    #[must_use]
86    pub fn rank(self) -> u8 {
87        match self {
88            Self::AgentName { verified: true } => 100,
89            Self::AclLabel => 60,
90            Self::LocalAlias => 50,
91            Self::DeviceName => 45,
92            Self::ServerLabel => 40,
93            Self::ContextName => 30,
94            Self::AgentName { verified: false } => 10,
95        }
96    }
97
98    /// Stable machine-readable tag, used for the `nameSource` field in
99    /// `--json` output. Kebab-case so it reads well through `jq`.
100    #[must_use]
101    pub fn as_str(self) -> &'static str {
102        match self {
103            Self::AgentName { verified: true } => "agent-name",
104            Self::AgentName { verified: false } => "agent-name-unverified",
105            Self::AclLabel => "acl-label",
106            Self::DeviceName => "device-name",
107            Self::LocalAlias => "local-alias",
108            Self::ServerLabel => "server-label",
109            Self::ContextName => "context-name",
110        }
111    }
112
113    /// Whether a name from this source may be shown without qualification.
114    ///
115    /// False only for an unverified agent name.
116    #[must_use]
117    pub fn is_trusted(self) -> bool {
118        !matches!(self, Self::AgentName { verified: false })
119    }
120}
121
122impl serde::Serialize for NameSource {
123    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
124        s.serialize_str(self.as_str())
125    }
126}
127
128/// A name for a DID, and the provenance of that name.
129#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
130#[serde(rename_all = "camelCase")]
131pub struct DisplayName {
132    pub name: String,
133    pub source: NameSource,
134}
135
136impl DisplayName {
137    pub fn new(name: impl Into<String>, source: NameSource) -> Self {
138        Self {
139            name: name.into(),
140            source,
141        }
142    }
143
144    /// See [`NameSource::is_trusted`].
145    #[must_use]
146    pub fn is_trusted(&self) -> bool {
147        self.source.is_trusted()
148    }
149}
150
151/// Marker appended to a name that has not been verified. Surfaces may
152/// restyle it (the CLI colours it yellow) but must not drop it.
153pub const UNVERIFIED_SUFFIX: &str = " [unverified]";
154
155/// A `DID → name` map, populated by the caller from data it already holds.
156///
157/// Insertion is idempotent and order-independent: a lower-ranked source never
158/// displaces a higher-ranked one, so a command can fill the book from several
159/// responses in whatever order they arrive without the result depending on
160/// that order.
161#[derive(Debug, Clone, Default)]
162pub struct NameBook {
163    entries: HashMap<String, DisplayName>,
164}
165
166impl NameBook {
167    #[must_use]
168    pub fn new() -> Self {
169        Self::default()
170    }
171
172    /// Record a name for `did`, keeping whichever source ranks higher.
173    ///
174    /// Empty and whitespace-only names are dropped rather than stored: an
175    /// unset label deserialises as `Some("")` often enough that storing it
176    /// would put a blank string where the DID should be.
177    pub fn insert(&mut self, did: impl Into<String>, name: DisplayName) {
178        if name.name.trim().is_empty() {
179            return;
180        }
181        let did = did.into();
182        match self.entries.get(&did) {
183            Some(existing) if existing.source.rank() >= name.source.rank() => {}
184            _ => {
185                self.entries.insert(did, name);
186            }
187        }
188    }
189
190    /// Convenience for the common case: an `Option<String>` label straight
191    /// off a response struct.
192    pub fn insert_opt(&mut self, did: impl Into<String>, name: Option<&str>, source: NameSource) {
193        if let Some(n) = name {
194            self.insert(did, DisplayName::new(n, source));
195        }
196    }
197
198    #[must_use]
199    pub fn get(&self, did: &str) -> Option<&DisplayName> {
200        self.entries.get(did)
201    }
202
203    #[must_use]
204    pub fn is_empty(&self) -> bool {
205        self.entries.is_empty()
206    }
207
208    #[must_use]
209    pub fn len(&self) -> usize {
210        self.entries.len()
211    }
212
213    /// Whether any DID in `dids` has a name.
214    ///
215    /// Table renderers call this to decide whether to emit a NAME column at
216    /// all — on a VTA where nothing has been labelled, a column of dashes is
217    /// worse than no column.
218    pub fn names_any<'a>(&self, dids: impl IntoIterator<Item = &'a str>) -> bool {
219        dids.into_iter().any(|d| self.entries.contains_key(d))
220    }
221
222    /// The name for `did` as a bare string, tagged when unverified.
223    ///
224    /// Returns `None` when the DID has no name, so callers can decide between
225    /// a placeholder and the shortened DID.
226    #[must_use]
227    pub fn name_of(&self, did: &str) -> Option<String> {
228        self.entries.get(did).map(|n| {
229            if n.is_trusted() {
230                n.name.clone()
231            } else {
232                format!("{}{UNVERIFIED_SUFFIX}", n.name)
233            }
234        })
235    }
236
237    /// One-line rendering for prose and confirmations:
238    /// `mediator-prod (did:webvh:QmXk…9f2:example.com)`.
239    ///
240    /// Falls back to the shortened DID alone when there is no name. The DID is
241    /// always present — a name the operator cannot cross-check against an
242    /// identifier is a name they cannot audit.
243    #[must_use]
244    pub fn render_inline(&self, did: &str) -> String {
245        match self.name_of(did) {
246            Some(name) => format!("{name} ({})", shorten_did(did)),
247            None => shorten_did(did),
248        }
249    }
250}
251
252/// Shorten a DID for a fixed-width cell while keeping the part that
253/// identifies it.
254///
255/// A `did:webvh` / `did:web` carries its meaning in the *tail* — the domain
256/// and path, e.g. `…:webvh.storm.ws:glenn-vta` — and its noise in the middle:
257/// the SCID, a content hash. So the SCID is abbreviated and everything after
258/// it kept verbatim. That is the opposite of a CSS-style trailing ellipsis,
259/// which clips off precisely the informative half.
260///
261/// Other methods (`did:key` and friends) have no human tail, so the opaque id
262/// is middle-truncated instead, keeping a head and a tail so an operator can
263/// still tell two of them apart at a glance.
264///
265/// Inputs that aren't DIDs, and DIDs already short enough, are returned
266/// unchanged.
267///
268/// This is a port of `shortenDid` in
269/// `vtc-service/admin-ui/src/lib/format.ts`. The two must agree — an operator
270/// moves between a terminal and the admin console looking at the same
271/// community, and a DID abbreviated two ways is one they re-identify on every
272/// switch. The vector table in the tests below is the authority (the console
273/// has no test runner); it is reproduced in that file's doc comment.
274#[must_use]
275pub fn shorten_did(did: &str) -> String {
276    shorten_did_keep(did, DEFAULT_KEEP)
277}
278
279/// Number of leading characters kept from an opaque DID segment.
280pub const DEFAULT_KEEP: usize = 10;
281
282/// Trailing characters kept when middle-truncating a non-`webvh` DID.
283const TAIL: usize = 6;
284
285#[must_use]
286pub fn shorten_did_keep(did: &str, keep: usize) -> String {
287    if !did.starts_with("did:") {
288        return did.to_string();
289    }
290    let parts: Vec<&str> = did.split(':').collect();
291    let method = parts.get(1).copied().unwrap_or_default();
292
293    if (method == "webvh" || method == "web") && parts.len() > 3 {
294        let scid = parts[2];
295        if char_len(scid) <= keep + 1 {
296            return did.to_string();
297        }
298        let mut out = parts.clone();
299        let abbreviated = format!("{}…", take_chars(scid, keep));
300        out[2] = &abbreviated;
301        return out.join(":");
302    }
303
304    // `did:<method>:<id>`, where `<id>` may itself contain colons.
305    let id = parts[2..].join(":");
306    if char_len(&id) <= keep + TAIL + 1 {
307        return did.to_string();
308    }
309    format!(
310        "{}:{}:{}…{}",
311        parts[0],
312        method,
313        take_chars(&id, keep),
314        take_last_chars(&id, TAIL)
315    )
316}
317
318fn char_len(s: &str) -> usize {
319    s.chars().count()
320}
321
322fn take_chars(s: &str, n: usize) -> String {
323    s.chars().take(n).collect()
324}
325
326fn take_last_chars(s: &str, n: usize) -> String {
327    let len = char_len(s);
328    s.chars().skip(len.saturating_sub(n)).collect()
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    // ── shorten_did ─────────────────────────────────────────────────
336    //
337    // These vectors are reproduced in the doc comment on `shortenDid` in
338    // `vtc-service/admin-ui/src/lib/format.ts`, which has no test runner of
339    // its own — this test is the authority for both. The Rust CLIs and the
340    // React admin UI show the same operator the same DIDs; if they abbreviate
341    // differently, the operator cannot tell whether two screens are showing
342    // one identity or two. Change one side and you must change the other.
343    const VECTORS: &[(&str, &str)] = &[
344        // Not a DID — untouched.
345        ("alice", "alice"),
346        ("https://example.com/@alice", "https://example.com/@alice"),
347        // webvh: SCID abbreviated, domain + path tail kept in full.
348        (
349            "did:webvh:QmXkAbCdEfGhIjKlMnOp:webvh.storm.ws:glenn-vta",
350            "did:webvh:QmXkAbCdEf…:webvh.storm.ws:glenn-vta",
351        ),
352        // web: same rule.
353        (
354            "did:web:QmXkAbCdEfGhIjKlMnOp:example.com",
355            "did:web:QmXkAbCdEf…:example.com",
356        ),
357        // webvh with a short SCID — nothing to gain, left alone.
358        ("did:webvh:Qm123:example.com", "did:webvh:Qm123:example.com"),
359        // did:key — no human tail, so middle-truncate head + tail.
360        (
361            "did:key:z6MkfrQjWzPQrTuVwXyZaBcDeFgHiJkLmNoPqRsTuVwXyZ4rT",
362            "did:key:z6MkfrQjWz…XyZ4rT",
363        ),
364        // Short did:key — under the threshold, untouched.
365        ("did:key:z6MkfrQjWz", "did:key:z6MkfrQjWz"),
366        // A webvh DID with only 3 segments has no domain tail to protect, so
367        // it falls through to the generic middle-truncating arm.
368        (
369            "did:webvh:QmXkAbCdEfGhIjKlMnOpQrSt",
370            "did:webvh:QmXkAbCdEf…OpQrSt",
371        ),
372    ];
373
374    #[test]
375    fn shorten_did_matches_shared_vectors() {
376        for (input, expected) in VECTORS {
377            assert_eq!(&shorten_did(input), expected, "input: {input}");
378        }
379    }
380
381    #[test]
382    fn shorten_did_is_char_safe() {
383        // Non-ASCII in the opaque segment must not panic on a byte boundary.
384        let did = "did:key:zÄÖÜäöüßÅÆØåæøΩμλ0123456789";
385        let out = shorten_did(did);
386        assert!(out.starts_with("did:key:"));
387        assert!(out.contains('…'));
388    }
389
390    // ── NameSource precedence ───────────────────────────────────────
391
392    #[test]
393    fn verified_agent_name_outranks_local_label() {
394        assert!(
395            NameSource::AgentName { verified: true }.rank() > NameSource::AclLabel.rank(),
396            "a cryptographically bound global name beats one operator's private note"
397        );
398    }
399
400    #[test]
401    fn unverified_agent_name_ranks_below_every_local_source() {
402        let unverified = NameSource::AgentName { verified: false }.rank();
403        for local in [
404            NameSource::AclLabel,
405            NameSource::DeviceName,
406            NameSource::LocalAlias,
407            NameSource::ServerLabel,
408            NameSource::ContextName,
409        ] {
410            assert!(
411                local.rank() > unverified,
412                "{local:?} must not be displaced by an unchecked claim"
413            );
414        }
415    }
416
417    #[test]
418    fn only_unverified_agent_names_are_untrusted() {
419        assert!(!NameSource::AgentName { verified: false }.is_trusted());
420        assert!(NameSource::AgentName { verified: true }.is_trusted());
421        assert!(NameSource::AclLabel.is_trusted());
422    }
423
424    // ── NameBook ────────────────────────────────────────────────────
425
426    const DID_A: &str = "did:key:z6MkfrQjWzPQrTuVwXyZaBcDeFgHiJkLmNoPqRsTuVwXyZ4rT";
427
428    #[test]
429    fn insert_is_order_independent() {
430        let strong = DisplayName::new(
431            "agent.example/@ops",
432            NameSource::AgentName { verified: true },
433        );
434        let weak = DisplayName::new("my-note", NameSource::AclLabel);
435
436        let mut a = NameBook::new();
437        a.insert(DID_A, weak.clone());
438        a.insert(DID_A, strong.clone());
439
440        let mut b = NameBook::new();
441        b.insert(DID_A, strong.clone());
442        b.insert(DID_A, weak);
443
444        assert_eq!(a.get(DID_A), b.get(DID_A));
445        assert_eq!(a.get(DID_A), Some(&strong));
446    }
447
448    #[test]
449    fn unverified_claim_never_displaces_an_operator_label() {
450        let mut book = NameBook::new();
451        book.insert(DID_A, DisplayName::new("payroll-bot", NameSource::AclLabel));
452        book.insert(
453            DID_A,
454            DisplayName::new(
455                "mybank.com/@treasury",
456                NameSource::AgentName { verified: false },
457            ),
458        );
459        assert_eq!(
460            book.get(DID_A).map(|n| n.name.as_str()),
461            Some("payroll-bot")
462        );
463    }
464
465    #[test]
466    fn unverified_names_are_tagged_when_rendered() {
467        let mut book = NameBook::new();
468        book.insert(
469            DID_A,
470            DisplayName::new(
471                "mybank.com/@treasury",
472                NameSource::AgentName { verified: false },
473            ),
474        );
475        let rendered = book.name_of(DID_A).unwrap();
476        assert!(
477            rendered.contains("unverified"),
478            "a self-asserted name must never render bare: {rendered}"
479        );
480        assert!(book.render_inline(DID_A).contains("unverified"));
481    }
482
483    #[test]
484    fn blank_labels_are_not_stored() {
485        let mut book = NameBook::new();
486        book.insert(DID_A, DisplayName::new("", NameSource::AclLabel));
487        book.insert(DID_A, DisplayName::new("   ", NameSource::AclLabel));
488        assert!(book.is_empty(), "a blank label must not shadow the DID");
489    }
490
491    #[test]
492    fn insert_opt_skips_none() {
493        let mut book = NameBook::new();
494        book.insert_opt(DID_A, None, NameSource::AclLabel);
495        assert!(book.is_empty());
496        book.insert_opt(DID_A, Some("ops"), NameSource::AclLabel);
497        assert_eq!(book.len(), 1);
498    }
499
500    #[test]
501    fn render_inline_always_shows_the_did() {
502        let mut book = NameBook::new();
503        book.insert(DID_A, DisplayName::new("ops", NameSource::AclLabel));
504        let rendered = book.render_inline(DID_A);
505        assert!(rendered.starts_with("ops ("));
506        assert!(
507            rendered.contains("did:key:"),
508            "the operator must be able to audit the name against an identifier"
509        );
510    }
511
512    #[test]
513    fn render_inline_falls_back_to_the_shortened_did() {
514        let book = NameBook::new();
515        assert_eq!(book.render_inline(DID_A), shorten_did(DID_A));
516    }
517
518    #[test]
519    fn names_any_drives_the_optional_name_column() {
520        let mut book = NameBook::new();
521        assert!(!book.names_any([DID_A, "did:key:zOther"]));
522        book.insert(DID_A, DisplayName::new("ops", NameSource::AclLabel));
523        assert!(book.names_any([DID_A, "did:key:zOther"]));
524        assert!(!book.names_any(["did:key:zOther"]));
525    }
526
527    #[test]
528    fn name_source_json_tags_are_stable() {
529        // These strings appear in `--json` output; scripts key off them.
530        assert_eq!(
531            serde_json::to_string(&NameSource::AgentName { verified: false }).unwrap(),
532            "\"agent-name-unverified\""
533        );
534        assert_eq!(
535            serde_json::to_string(&NameSource::AclLabel).unwrap(),
536            "\"acl-label\""
537        );
538    }
539}