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 the path kept
265/// verbatim. A `did:webvh` host longer than 12 characters with subdomains
266/// keeps only its last two labels (`webvh.storm.ws` → `…storm.ws`): the path
267/// is what tells two DIDs on one host apart, so the host yields first. That
268/// is the opposite of a CSS-style trailing ellipsis, which clips off
269/// precisely the informative half.
270///
271/// Other methods (`did:key` and friends) have no human tail, so the opaque id
272/// is middle-truncated instead, keeping a head and a tail so an operator can
273/// still tell two of them apart at a glance.
274///
275/// Inputs that aren't DIDs, and DIDs already short enough, are returned
276/// unchanged.
277///
278/// This is a port of `shortenDid` in
279/// `vtc-service/admin-ui/src/lib/format.ts`. The two must agree — an operator
280/// moves between a terminal and the admin console looking at the same
281/// community, and a DID abbreviated two ways is one they re-identify on every
282/// switch. The vector table in the tests below is reproduced in that file's
283/// doc comment and asserted by its `format.test.ts`.
284#[must_use]
285pub fn shorten_did(did: &str) -> String {
286    shorten_did_keep(did, DEFAULT_KEEP)
287}
288
289/// Number of leading characters kept from an opaque DID segment.
290pub const DEFAULT_KEEP: usize = 10;
291
292/// Trailing characters kept when middle-truncating a non-`webvh` DID.
293const TAIL: usize = 6;
294
295#[must_use]
296pub fn shorten_did_keep(did: &str, keep: usize) -> String {
297    if !did.starts_with("did:") {
298        return did.to_string();
299    }
300    // `did:scid:vh:1:<scid>[?src=<host>[/<path>]]`: the SCID abbreviated like a
301    // did:webvh's, and the `src` host like its host, the path kept.
302    if let Some(rest) = did.strip_prefix("did:scid:vh:1:") {
303        let (scid, src) = match rest.split_once("?src=") {
304            Some((scid, src)) => (scid, Some(src)),
305            None => (rest, None),
306        };
307        let mut out = String::from("did:scid:vh:1:");
308        if char_len(scid) > keep + 1 {
309            out.push_str(&take_chars(scid, keep));
310            out.push('…');
311        } else {
312            out.push_str(scid);
313        }
314        if let Some(src) = src {
315            let (host, path) = match src.split_once('/') {
316                Some((h, p)) => (h, Some(p)),
317                None => (src, None),
318            };
319            out.push_str("?src=");
320            out.push_str(&shorten_host(host));
321            if let Some(path) = path {
322                out.push('/');
323                out.push_str(path);
324            }
325        }
326        return out;
327    }
328
329    let parts: Vec<&str> = did.split(':').collect();
330    let method = parts.get(1).copied().unwrap_or_default();
331
332    if (method == "webvh" || method == "web") && parts.len() > 3 {
333        let mut out: Vec<String> = parts.iter().map(|p| (*p).to_string()).collect();
334        let scid = parts[2];
335        if char_len(scid) > keep + 1 {
336            out[2] = format!("{}…", take_chars(scid, keep));
337        }
338        // The path after the host is what tells two DIDs on one host apart,
339        // so a long host gives up its subdomains before the path gives up
340        // anything.
341        if method == "webvh" {
342            out[3] = shorten_host(parts[3]);
343        }
344        return out.join(":");
345    }
346
347    // `did:<method>:<id>`, where `<id>` may itself contain colons.
348    let id = parts[2..].join(":");
349    if char_len(&id) <= keep + TAIL + 1 {
350        return did.to_string();
351    }
352    format!(
353        "{}:{}:{}…{}",
354        parts[0],
355        method,
356        take_chars(&id, keep),
357        take_last_chars(&id, TAIL)
358    )
359}
360
361/// Hosts up to this many characters are shown whole.
362const HOST_KEEP: usize = 12;
363
364/// `webvh.storm.ws` → `…storm.ws`: a host longer than [`HOST_KEEP`] with
365/// subdomains keeps its last two labels. A two-label host is left whole.
366fn shorten_host(host: &str) -> String {
367    let labels: Vec<&str> = host.split('.').collect();
368    if labels.len() > 2 && char_len(host) > HOST_KEEP {
369        format!("…{}", labels[labels.len() - 2..].join("."))
370    } else {
371        host.to_string()
372    }
373}
374
375fn char_len(s: &str) -> usize {
376    s.chars().count()
377}
378
379fn take_chars(s: &str, n: usize) -> String {
380    s.chars().take(n).collect()
381}
382
383fn take_last_chars(s: &str, n: usize) -> String {
384    let len = char_len(s);
385    s.chars().skip(len.saturating_sub(n)).collect()
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    // ── shorten_did ─────────────────────────────────────────────────
393    //
394    // These vectors are reproduced in the doc comment on `shortenDid` in
395    // `vtc-service/admin-ui/src/lib/format.ts` and asserted there too, by
396    // `format.test.ts`. The Rust CLIs and the
397    // React admin UI show the same operator the same DIDs; if they abbreviate
398    // differently, the operator cannot tell whether two screens are showing
399    // one identity or two. Change one side and you must change the other.
400    const VECTORS: &[(&str, &str)] = &[
401        // Not a DID — untouched.
402        ("alice", "alice"),
403        ("https://example.com/@alice", "https://example.com/@alice"),
404        // webvh: SCID abbreviated, a long host's subdomains dropped, the path
405        // kept in full.
406        (
407            "did:webvh:QmXkAbCdEfGhIjKlMnOp:webvh.storm.ws:glenn-vta",
408            "did:webvh:QmXkAbCdEf…:…storm.ws:glenn-vta",
409        ),
410        (
411            "did:webvh:QmXkAbCdEfGhIjKlMnOp:dids.firstperson.dev:stem-wall",
412            "did:webvh:QmXkAbCdEf…:…firstperson.dev:stem-wall",
413        ),
414        // A short host, or one without subdomains, is kept whole.
415        (
416            "did:webvh:QmXkAbCdEfGhIjKlMnOp:dids.ic3.dev:fruit-feel",
417            "did:webvh:QmXkAbCdEf…:dids.ic3.dev:fruit-feel",
418        ),
419        (
420            "did:webvh:QmXkAbCdEfGhIjKlMnOp:firstperson.network:a:b",
421            "did:webvh:QmXkAbCdEf…:firstperson.network:a:b",
422        ),
423        // web: same rule.
424        (
425            "did:web:QmXkAbCdEfGhIjKlMnOp:example.com",
426            "did:web:QmXkAbCdEf…:example.com",
427        ),
428        // webvh with a short SCID — nothing to gain, left alone.
429        ("did:webvh:Qm123:example.com", "did:webvh:Qm123:example.com"),
430        // did:key — no human tail, so middle-truncate head + tail.
431        (
432            "did:key:z6MkfrQjWzPQrTuVwXyZaBcDeFgHiJkLmNoPqRsTuVwXyZ4rT",
433            "did:key:z6MkfrQjWz…XyZ4rT",
434        ),
435        // Short did:key — under the threshold, untouched.
436        ("did:key:z6MkfrQjWz", "did:key:z6MkfrQjWz"),
437        // A webvh DID with only 3 segments has no domain tail to protect, so
438        // it falls through to the generic middle-truncating arm.
439        (
440            "did:webvh:QmXkAbCdEfGhIjKlMnOpQrSt",
441            "did:webvh:QmXkAbCdEf…OpQrSt",
442        ),
443        // did:scid:vh:1 — the SCID like a webvh's, the `src` host like its
444        // host, the path kept; a short one, and a bare one, as they are.
445        (
446            "did:scid:vh:1:QmXkAbCdEfGhIjKlMnOp?src=webvh.storm.ws/glenn-vta",
447            "did:scid:vh:1:QmXkAbCdEf…?src=…storm.ws/glenn-vta",
448        ),
449        (
450            "did:scid:vh:1:QmXkAbCdEfGhIjKlMnOp",
451            "did:scid:vh:1:QmXkAbCdEf…",
452        ),
453        (
454            "did:scid:vh:1:Qm123?src=example.com",
455            "did:scid:vh:1:Qm123?src=example.com",
456        ),
457    ];
458
459    #[test]
460    fn shorten_did_matches_shared_vectors() {
461        for (input, expected) in VECTORS {
462            assert_eq!(&shorten_did(input), expected, "input: {input}");
463        }
464    }
465
466    #[test]
467    fn shorten_did_is_char_safe() {
468        // Non-ASCII in the opaque segment must not panic on a byte boundary.
469        let did = "did:key:zÄÖÜäöüßÅÆØåæøΩμλ0123456789";
470        let out = shorten_did(did);
471        assert!(out.starts_with("did:key:"));
472        assert!(out.contains('…'));
473    }
474
475    // ── NameSource precedence ───────────────────────────────────────
476
477    #[test]
478    fn verified_agent_name_outranks_local_label() {
479        assert!(
480            NameSource::AgentName { verified: true }.rank() > NameSource::AclLabel.rank(),
481            "a cryptographically bound global name beats one operator's private note"
482        );
483    }
484
485    #[test]
486    fn unverified_agent_name_ranks_below_every_local_source() {
487        let unverified = NameSource::AgentName { verified: false }.rank();
488        for local in [
489            NameSource::AclLabel,
490            NameSource::DeviceName,
491            NameSource::LocalAlias,
492            NameSource::ServerLabel,
493            NameSource::ContextName,
494        ] {
495            assert!(
496                local.rank() > unverified,
497                "{local:?} must not be displaced by an unchecked claim"
498            );
499        }
500    }
501
502    #[test]
503    fn only_unverified_agent_names_are_untrusted() {
504        assert!(!NameSource::AgentName { verified: false }.is_trusted());
505        assert!(NameSource::AgentName { verified: true }.is_trusted());
506        assert!(NameSource::AclLabel.is_trusted());
507    }
508
509    // ── NameBook ────────────────────────────────────────────────────
510
511    const DID_A: &str = "did:key:z6MkfrQjWzPQrTuVwXyZaBcDeFgHiJkLmNoPqRsTuVwXyZ4rT";
512
513    #[test]
514    fn insert_is_order_independent() {
515        let strong = DisplayName::new(
516            "agent.example/@ops",
517            NameSource::AgentName { verified: true },
518        );
519        let weak = DisplayName::new("my-note", NameSource::AclLabel);
520
521        let mut a = NameBook::new();
522        a.insert(DID_A, weak.clone());
523        a.insert(DID_A, strong.clone());
524
525        let mut b = NameBook::new();
526        b.insert(DID_A, strong.clone());
527        b.insert(DID_A, weak);
528
529        assert_eq!(a.get(DID_A), b.get(DID_A));
530        assert_eq!(a.get(DID_A), Some(&strong));
531    }
532
533    #[test]
534    fn unverified_claim_never_displaces_an_operator_label() {
535        let mut book = NameBook::new();
536        book.insert(DID_A, DisplayName::new("payroll-bot", NameSource::AclLabel));
537        book.insert(
538            DID_A,
539            DisplayName::new(
540                "mybank.com/@treasury",
541                NameSource::AgentName { verified: false },
542            ),
543        );
544        assert_eq!(
545            book.get(DID_A).map(|n| n.name.as_str()),
546            Some("payroll-bot")
547        );
548    }
549
550    #[test]
551    fn unverified_names_are_tagged_when_rendered() {
552        let mut book = NameBook::new();
553        book.insert(
554            DID_A,
555            DisplayName::new(
556                "mybank.com/@treasury",
557                NameSource::AgentName { verified: false },
558            ),
559        );
560        let rendered = book.name_of(DID_A).unwrap();
561        assert!(
562            rendered.contains("unverified"),
563            "a self-asserted name must never render bare: {rendered}"
564        );
565        assert!(book.render_inline(DID_A).contains("unverified"));
566    }
567
568    #[test]
569    fn blank_labels_are_not_stored() {
570        let mut book = NameBook::new();
571        book.insert(DID_A, DisplayName::new("", NameSource::AclLabel));
572        book.insert(DID_A, DisplayName::new("   ", NameSource::AclLabel));
573        assert!(book.is_empty(), "a blank label must not shadow the DID");
574    }
575
576    #[test]
577    fn insert_opt_skips_none() {
578        let mut book = NameBook::new();
579        book.insert_opt(DID_A, None, NameSource::AclLabel);
580        assert!(book.is_empty());
581        book.insert_opt(DID_A, Some("ops"), NameSource::AclLabel);
582        assert_eq!(book.len(), 1);
583    }
584
585    #[test]
586    fn render_inline_always_shows_the_did() {
587        let mut book = NameBook::new();
588        book.insert(DID_A, DisplayName::new("ops", NameSource::AclLabel));
589        let rendered = book.render_inline(DID_A);
590        assert!(rendered.starts_with("ops ("));
591        assert!(
592            rendered.contains("did:key:"),
593            "the operator must be able to audit the name against an identifier"
594        );
595    }
596
597    #[test]
598    fn render_inline_falls_back_to_the_shortened_did() {
599        let book = NameBook::new();
600        assert_eq!(book.render_inline(DID_A), shorten_did(DID_A));
601    }
602
603    #[test]
604    fn names_any_drives_the_optional_name_column() {
605        let mut book = NameBook::new();
606        assert!(!book.names_any([DID_A, "did:key:zOther"]));
607        book.insert(DID_A, DisplayName::new("ops", NameSource::AclLabel));
608        assert!(book.names_any([DID_A, "did:key:zOther"]));
609        assert!(!book.names_any(["did:key:zOther"]));
610    }
611
612    #[test]
613    fn name_source_json_tags_are_stable() {
614        // These strings appear in `--json` output; scripts key off them.
615        assert_eq!(
616            serde_json::to_string(&NameSource::AgentName { verified: false }).unwrap(),
617            "\"agent-name-unverified\""
618        );
619        assert_eq!(
620            serde_json::to_string(&NameSource::AclLabel).unwrap(),
621            "\"acl-label\""
622        );
623    }
624}