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