Skip to main content

auths_keri/
oobi.rs

1//! Out-Of-Band Introduction (OOBI) — KERI discovery.
2//!
3//! An OOBI is how one KERI controller tells another *"here is my AID, and here
4//! is a URL at which you can fetch its key event log and service endpoints."* It
5//! is the bootstrap of every live exchange: before a peer can request a receipt,
6//! present a credential, or resolve a key-state, it must first discover *where*
7//! the controlling AID's KEL and endpoints live. OOBIs carry that location
8//! out-of-band (hence the name); the KEL fetched through one is still verified
9//! cryptographically, so the URL is only a hint, never a root of trust.
10//!
11//! Two halves, mirroring the two directions of discovery:
12//!
13//! * **Resolve** (peer → us): parse a peer's OOBI URL into a typed [`Oobi`],
14//!   fetch the bytes it points at, and [`ingest_oobi_stream`] them — replaying
15//!   the embedded KEL into a verified [`KeyState`] and collecting the endpoint
16//!   reply records the peer published alongside it.
17//! * **Serve** (us → peer): from one of our own KELs and the URL we host it at,
18//!   [`OobiEndpoint::for_controller`] derives the OOBI URL to publish and the
19//!   `rpy` reply stream (`/loc/scheme` + `/end/role/add`) a peer fetches when it
20//!   resolves us.
21//!
22//! The wire records are byte-exact with keripy 1.3.4: a `/loc/scheme` reply is
23//! `{v, t:"rpy", d, dt, r:"/loc/scheme", a:{eid, scheme, url}}` and an
24//! `/end/role/add` reply is `{v, t:"rpy", d, dt, r:"/end/role/add",
25//! a:{cid, role, eid}}`, each SAID-ified and version-sized exactly as
26//! `keri.app.habbing.Hab.reply`. The URL grammar is keripy's `OOBI_RE`
27//! (`/oobi/{cid}/{role}[/{eid}]`).
28//!
29//! This module is I/O-free: it parses URLs, serializes/parses wire records, and
30//! replays KELs. The HTTP fetch lives behind a port in the caller (the CLI's
31//! OOBI adapter), so the discovery logic never imports a transport.
32
33use serde::ser::SerializeMap;
34use serde::{Serialize, Serializer};
35
36use crate::error::KeriTranslationError;
37use crate::events::KERI_VERSION_PREFIX;
38use crate::said::{Protocol, compute_said_with_protocol};
39use crate::state::KeyState;
40use crate::types::{Prefix, Said};
41use crate::validate::{TrustedKel, ValidationError, parse_kel_json};
42
43/// Placeholder version string filled in during saidify (17 chars, like every
44/// KERI record's `v`).
45const KERI_VERSION_PLACEHOLDER: &str = "KERI10JSON000000_";
46
47/// Sizes the version string `KERI10JSON{size:06x}_` to a serialized record — the
48/// same single-pass machinery the TEL records use (the field width is constant,
49/// so re-serializing with the placeholder gives the final byte length).
50fn recompute_version_string<T: Serialize>(event: &T) -> Result<String, OobiError> {
51    let bytes = serde_json::to_vec(event).map_err(KeriTranslationError::SerializationFailed)?;
52    Ok(format!("{KERI_VERSION_PREFIX}{:06x}_", bytes.len()))
53}
54
55/// An authorized endpoint role in a KERI introduction.
56///
57/// Mirrors keripy's `kering.Roles` — the fixed vocabulary of what an endpoint
58/// identifier (`eid`) is authorized to *do* for a controller (`cid`). Parsing is
59/// total: an unknown role is rejected at the boundary, so an `Role` value is
60/// always one keripy would accept in a `/end/role` reply.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub enum Role {
63    /// The controller itself (its own endpoint).
64    Controller,
65    /// A witness that receipts the controller's KEL.
66    Witness,
67    /// A watcher that observes the controller's KEL for duplicity.
68    Watcher,
69    /// A registrar of the controller's credential registries.
70    Registrar,
71    /// A judge in a multi-sig group.
72    Judge,
73    /// A juror in a multi-sig group.
74    Juror,
75    /// A peer in a direct-mode exchange.
76    Peer,
77    /// A mailbox that buffers messages for the controller.
78    Mailbox,
79    /// An agent acting on behalf of the controller (e.g. a KERIA agent).
80    Agent,
81    /// A gateway endpoint.
82    Gateway,
83}
84
85impl Role {
86    /// The keripy `kering.Roles` wire token for this role.
87    pub fn as_str(self) -> &'static str {
88        match self {
89            Role::Controller => "controller",
90            Role::Witness => "witness",
91            Role::Watcher => "watcher",
92            Role::Registrar => "registrar",
93            Role::Judge => "judge",
94            Role::Juror => "juror",
95            Role::Peer => "peer",
96            Role::Mailbox => "mailbox",
97            Role::Agent => "agent",
98            Role::Gateway => "gateway",
99        }
100    }
101
102    /// Parses a keripy role token into a typed [`Role`].
103    ///
104    /// Total at the boundary: an unrecognized token is an [`OobiError::Role`],
105    /// never a silently-accepted string.
106    pub fn parse(s: &str) -> Result<Self, OobiError> {
107        Ok(match s {
108            "controller" => Role::Controller,
109            "witness" => Role::Witness,
110            "watcher" => Role::Watcher,
111            "registrar" => Role::Registrar,
112            "judge" => Role::Judge,
113            "juror" => Role::Juror,
114            "peer" => Role::Peer,
115            "mailbox" => Role::Mailbox,
116            "agent" => Role::Agent,
117            "gateway" => Role::Gateway,
118            other => return Err(OobiError::Role(other.to_string())),
119        })
120    }
121}
122
123impl std::fmt::Display for Role {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        f.write_str(self.as_str())
126    }
127}
128
129/// A parsed Out-Of-Band Introduction URL.
130///
131/// The canonical keripy OOBI form is
132/// `<scheme>://<authority>/oobi/<cid>/<role>[/<eid>]` (keripy's `OOBI_RE`). A
133/// parsed `Oobi` guarantees: a recognized scheme, a present authority, a
134/// CESR-valid controller prefix (`cid`), a known [`Role`], and — when present —
135/// a CESR-valid endpoint prefix (`eid`). Invalid URLs never become an `Oobi`;
136/// they are rejected by [`Oobi::parse`] at the boundary.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct Oobi {
139    /// URL scheme — `http`, `https`, or `tcp` (keripy `kering.Schemes`).
140    pub scheme: String,
141    /// Network authority (`host[:port]`) hosting the introduction endpoint.
142    pub authority: String,
143    /// Controller AID being introduced (the `cid` path segment).
144    pub cid: Prefix,
145    /// Authorized role of the endpoint for that controller.
146    pub role: Role,
147    /// Optional endpoint provider AID (`eid`) when the OOBI scopes one endpoint.
148    pub eid: Option<Prefix>,
149}
150
151impl Oobi {
152    /// Parses a peer's OOBI URL into a typed [`Oobi`].
153    ///
154    /// Accepts the keripy `OOBI_RE` shape
155    /// `<scheme>://<authority>/oobi/<cid>/<role>[/<eid>]`. Every component is
156    /// validated at the boundary: the scheme must be one keripy speaks, the
157    /// `cid`/`eid` must be CESR-valid prefixes, and the role must be a known
158    /// [`Role`].
159    pub fn parse(url: &str) -> Result<Self, OobiError> {
160        let (scheme, rest) = url
161            .split_once("://")
162            .ok_or_else(|| OobiError::Url(format!("missing scheme separator in {url:?}")))?;
163        let scheme = scheme.to_ascii_lowercase();
164        if !matches!(scheme.as_str(), "http" | "https" | "tcp") {
165            return Err(OobiError::Scheme(scheme));
166        }
167
168        // Split authority from the path; an absent path is not a valid OOBI.
169        let (authority, path) = match rest.split_once('/') {
170            Some((authority, path)) => (authority, path),
171            None => return Err(OobiError::Url(format!("missing /oobi path in {url:?}"))),
172        };
173        if authority.is_empty() {
174            return Err(OobiError::Url(format!("empty authority in {url:?}")));
175        }
176
177        // Drop any query string / fragment (keripy treats them as alias hints
178        // only) and split the path into its segments.
179        let path = path.split(['?', '#']).next().unwrap_or(path);
180        let mut segs = path.split('/').filter(|s| !s.is_empty());
181        match segs.next() {
182            Some("oobi") => {}
183            _ => return Err(OobiError::Url(format!("path is not /oobi/... in {url:?}"))),
184        }
185
186        let cid_str = segs
187            .next()
188            .ok_or_else(|| OobiError::Url(format!("missing cid segment in {url:?}")))?;
189        let cid = Prefix::new(cid_str.to_string()).map_err(|e| OobiError::Prefix {
190            segment: "cid",
191            source: e,
192        })?;
193
194        let role_str = segs
195            .next()
196            .ok_or_else(|| OobiError::Url(format!("missing role segment in {url:?}")))?;
197        let role = Role::parse(role_str)?;
198
199        let eid = match segs.next() {
200            Some(eid_str) => {
201                Some(
202                    Prefix::new(eid_str.to_string()).map_err(|e| OobiError::Prefix {
203                        segment: "eid",
204                        source: e,
205                    })?,
206                )
207            }
208            None => None,
209        };
210
211        // A trailing segment past the eid is not a keripy OOBI.
212        if segs.next().is_some() {
213            return Err(OobiError::Url(format!("trailing path segment in {url:?}")));
214        }
215
216        Ok(Oobi {
217            scheme,
218            authority: authority.to_string(),
219            cid,
220            role,
221            eid,
222        })
223    }
224
225    /// The canonical OOBI URL for this introduction.
226    ///
227    /// Round-trips [`Oobi::parse`]: `Oobi::parse(&o.url()) == Ok(o)`.
228    pub fn url(&self) -> String {
229        let base = format!(
230            "{}://{}/oobi/{}/{}",
231            self.scheme, self.authority, self.cid, self.role
232        );
233        match &self.eid {
234            Some(eid) => format!("{base}/{eid}"),
235            None => base,
236        }
237    }
238}
239
240impl std::fmt::Display for Oobi {
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        f.write_str(&self.url())
243    }
244}
245
246/// A `/loc/scheme` reply — *"endpoint `eid` is reachable via `scheme` at `url`."*
247///
248/// Byte-exact with keripy's `Hab.makeLocScheme`: serializes as
249/// `{v, t:"rpy", d, dt, r:"/loc/scheme", a:{eid, scheme, url}}`, SAID-ified over
250/// the whole record and version-sized to the serialized bytes. Build via
251/// [`LocSchemeReply::new`] (which saidifies), then serialize for the wire.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct LocSchemeReply {
254    /// Version string `KERI10JSON{size:06x}_`.
255    pub v: String,
256    /// SAID of this reply (Blake3-256 over the saidified record).
257    pub d: Said,
258    /// ISO-8601 datetime stamp (RFC-3339 profile, microsecond precision).
259    pub dt: String,
260    /// Endpoint provider AID this location describes.
261    pub eid: Prefix,
262    /// URL scheme of the endpoint (`http`/`https`/`tcp`).
263    pub scheme: String,
264    /// Endpoint URL.
265    pub url: String,
266}
267
268impl LocSchemeReply {
269    /// Builds a saidified `/loc/scheme` reply for an endpoint location.
270    pub fn new(
271        eid: Prefix,
272        scheme: impl Into<String>,
273        url: impl Into<String>,
274        dt: impl Into<String>,
275    ) -> Result<Self, OobiError> {
276        let mut reply = Self {
277            v: KERI_VERSION_PLACEHOLDER.to_string(),
278            d: Said::default(),
279            dt: dt.into(),
280            eid,
281            scheme: scheme.into(),
282            url: url.into(),
283        };
284        reply.saidify()?;
285        Ok(reply)
286    }
287
288    fn saidify(&mut self) -> Result<(), OobiError> {
289        let body =
290            serde_json::to_value(&*self).map_err(KeriTranslationError::SerializationFailed)?;
291        self.d = compute_said_with_protocol(&body, Protocol::Keri)?;
292        self.v = recompute_version_string(&*self)?;
293        Ok(())
294    }
295}
296
297impl Serialize for LocSchemeReply {
298    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
299        let mut map = serializer.serialize_map(Some(6))?;
300        map.serialize_entry("v", &self.v)?;
301        map.serialize_entry("t", "rpy")?;
302        map.serialize_entry("d", &self.d)?;
303        map.serialize_entry("dt", &self.dt)?;
304        map.serialize_entry("r", "/loc/scheme")?;
305        let mut a = serde_json::Map::new();
306        a.insert(
307            "eid".into(),
308            serde_json::Value::String(self.eid.to_string()),
309        );
310        a.insert(
311            "scheme".into(),
312            serde_json::Value::String(self.scheme.clone()),
313        );
314        a.insert("url".into(), serde_json::Value::String(self.url.clone()));
315        map.serialize_entry("a", &serde_json::Value::Object(a))?;
316        map.end()
317    }
318}
319
320/// An `/end/role/add` reply — *"controller `cid` authorizes `eid` in `role`."*
321///
322/// Byte-exact with keripy's `Hab.makeEndRole`: serializes as
323/// `{v, t:"rpy", d, dt, r:"/end/role/add", a:{cid, role, eid}}`, SAID-ified and
324/// version-sized exactly as `Hab.reply`. Build via [`EndRoleReply::new`].
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct EndRoleReply {
327    /// Version string `KERI10JSON{size:06x}_`.
328    pub v: String,
329    /// SAID of this reply.
330    pub d: Said,
331    /// ISO-8601 datetime stamp.
332    pub dt: String,
333    /// Controller AID authorizing the endpoint.
334    pub cid: Prefix,
335    /// Role the endpoint is authorized for.
336    pub role: Role,
337    /// Endpoint provider AID being authorized.
338    pub eid: Prefix,
339}
340
341impl EndRoleReply {
342    /// Builds a saidified `/end/role/add` reply authorizing an endpoint.
343    pub fn new(
344        cid: Prefix,
345        role: Role,
346        eid: Prefix,
347        dt: impl Into<String>,
348    ) -> Result<Self, OobiError> {
349        let mut reply = Self {
350            v: KERI_VERSION_PLACEHOLDER.to_string(),
351            d: Said::default(),
352            dt: dt.into(),
353            cid,
354            role,
355            eid,
356        };
357        reply.saidify()?;
358        Ok(reply)
359    }
360
361    fn saidify(&mut self) -> Result<(), OobiError> {
362        let body =
363            serde_json::to_value(&*self).map_err(KeriTranslationError::SerializationFailed)?;
364        self.d = compute_said_with_protocol(&body, Protocol::Keri)?;
365        self.v = recompute_version_string(&*self)?;
366        Ok(())
367    }
368}
369
370impl Serialize for EndRoleReply {
371    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
372        let mut map = serializer.serialize_map(Some(6))?;
373        map.serialize_entry("v", &self.v)?;
374        map.serialize_entry("t", "rpy")?;
375        map.serialize_entry("d", &self.d)?;
376        map.serialize_entry("dt", &self.dt)?;
377        map.serialize_entry("r", "/end/role/add")?;
378        let mut a = serde_json::Map::new();
379        a.insert(
380            "cid".into(),
381            serde_json::Value::String(self.cid.to_string()),
382        );
383        a.insert(
384            "role".into(),
385            serde_json::Value::String(self.role.to_string()),
386        );
387        a.insert(
388            "eid".into(),
389            serde_json::Value::String(self.eid.to_string()),
390        );
391        map.serialize_entry("a", &serde_json::Value::Object(a))?;
392        map.end()
393    }
394}
395
396/// The serve side: an AID's discoverable introduction.
397///
398/// From a controller's KEL and the URL its endpoint is hosted at, this derives
399/// what a resolving peer needs: the OOBI URL to publish, and the `rpy` reply
400/// stream (`/loc/scheme` + `/end/role/add`) the peer fetches. The endpoint
401/// provider (`eid`) defaults to the controller itself (`cid`) — the
402/// "controller" role — exactly as keripy's self-introduction does.
403#[derive(Debug, Clone)]
404pub struct OobiEndpoint {
405    /// The OOBI URL a peer resolves to discover this controller.
406    pub oobi: Oobi,
407    /// The endpoint-location reply (`/loc/scheme`).
408    pub loc_scheme: LocSchemeReply,
409    /// The role-authorization reply (`/end/role/add`).
410    pub end_role: EndRoleReply,
411}
412
413impl OobiEndpoint {
414    /// Derives a controller's self-introduction from its replayed key-state.
415    ///
416    /// `scheme` + `authority` describe where the controller hosts its endpoint;
417    /// `url` is the absolute endpoint URL embedded in the `/loc/scheme` reply
418    /// (keripy includes the full URL, not just the authority). The introduction
419    /// is for the `controller` role with the controller as its own endpoint.
420    pub fn for_controller(
421        state: &KeyState,
422        scheme: impl Into<String>,
423        authority: impl Into<String>,
424        url: impl Into<String>,
425        dt: impl Into<String>,
426    ) -> Result<Self, OobiError> {
427        let scheme = scheme.into();
428        let authority = authority.into();
429        let dt = dt.into();
430        let cid = state.prefix.clone();
431        let oobi = Oobi {
432            scheme: scheme.clone(),
433            authority,
434            cid: cid.clone(),
435            role: Role::Controller,
436            eid: None,
437        };
438        let loc_scheme = LocSchemeReply::new(cid.clone(), scheme, url, dt.clone())?;
439        let end_role = EndRoleReply::new(cid.clone(), Role::Controller, cid, dt)?;
440        Ok(OobiEndpoint {
441            oobi,
442            loc_scheme,
443            end_role,
444        })
445    }
446
447    /// Serializes the `rpy` reply stream a resolving peer fetches (newline-joined
448    /// JSON, the keripy `replyEndRole` wire shape minus the leading KEL replay,
449    /// which the caller prepends from the KEL it serves).
450    pub fn reply_stream(&self) -> Result<String, OobiError> {
451        let loc = serde_json::to_string(&self.loc_scheme)
452            .map_err(KeriTranslationError::SerializationFailed)?;
453        let end = serde_json::to_string(&self.end_role)
454            .map_err(KeriTranslationError::SerializationFailed)?;
455        Ok(format!("{loc}\n{end}"))
456    }
457}
458
459/// The result of resolving a peer's OOBI: a verified key-state plus the endpoint
460/// reply records the peer published alongside its KEL.
461#[derive(Debug, Clone)]
462pub struct OobiResolution {
463    /// The controller AID the OOBI introduced.
464    pub cid: Prefix,
465    /// The replayed, verified key-state of that controller's KEL.
466    pub state: KeyState,
467    /// Number of KEL events ingested.
468    pub event_count: usize,
469}
470
471/// Ingests the bytes fetched from an OOBI URL: replays the embedded KEL into a
472/// verified [`KeyState`].
473///
474/// The fetched body is a KERI message stream. We extract its key events (the
475/// `icp`/`rot`/`ixn`/`dip`/`drt` records the peer replayed) as a JSON array and
476/// replay them — so the KEL is verified cryptographically, not trusted because
477/// it arrived over a particular URL. The OOBI URL only told us *where* to look;
478/// trust comes from the replay.
479///
480/// `expected_cid` is the controller the OOBI claimed to introduce; ingest fails
481/// if the replayed KEL's prefix does not match it (an OOBI that delivers a
482/// *different* AID's KEL is a discovery failure, not a silent substitution).
483pub fn ingest_oobi_stream(
484    expected_cid: &Prefix,
485    kel_json: &str,
486) -> Result<OobiResolution, OobiError> {
487    let events = parse_kel_json(kel_json)?;
488    if events.is_empty() {
489        return Err(OobiError::EmptyKel);
490    }
491    let event_count = events.len();
492    // A KEL fetched through an OOBI is replayed (verified) before it is trusted.
493    let state = TrustedKel::from_trusted_source(&events).replay()?;
494    if state.prefix != *expected_cid {
495        return Err(OobiError::CidMismatch {
496            expected: expected_cid.to_string(),
497            actual: state.prefix.to_string(),
498        });
499    }
500    Ok(OobiResolution {
501        cid: state.prefix.clone(),
502        state,
503        event_count,
504    })
505}
506
507/// Errors raised while parsing, serving, or resolving an OOBI.
508#[derive(Debug, thiserror::Error)]
509pub enum OobiError {
510    /// The OOBI URL did not match the `<scheme>://<authority>/oobi/...` grammar.
511    #[error("invalid OOBI URL: {0}")]
512    Url(String),
513    /// The URL scheme is not one KERI speaks (`http`/`https`/`tcp`).
514    #[error("unsupported OOBI scheme: {0:?}")]
515    Scheme(String),
516    /// A path segment was not a CESR-valid prefix.
517    #[error("invalid {segment} prefix in OOBI URL: {source}")]
518    Prefix {
519        /// Which segment failed (`cid` or `eid`).
520        segment: &'static str,
521        /// The underlying CESR/derivation-code error.
522        source: crate::types::KeriTypeError,
523    },
524    /// The role segment was not a known KERI role.
525    #[error("unknown OOBI role: {0:?}")]
526    Role(String),
527    /// The OOBI stream carried no KEL events.
528    #[error("OOBI stream carried no KEL events")]
529    EmptyKel,
530    /// The replayed KEL belonged to a different AID than the OOBI introduced.
531    #[error("OOBI introduced {expected} but delivered a KEL for {actual}")]
532    CidMismatch {
533        /// The AID the OOBI URL claimed.
534        expected: String,
535        /// The AID the delivered KEL actually replayed to.
536        actual: String,
537    },
538    /// The fetched KEL failed to parse or replay (cryptographic verification).
539    #[error("KEL replay failed: {0}")]
540    Replay(#[from] ValidationError),
541    /// A wire record failed to saidify/serialize.
542    #[error("KERI record build failed: {0}")]
543    Record(#[from] KeriTranslationError),
544}
545
546#[cfg(test)]
547#[allow(clippy::unwrap_used, clippy::expect_used)]
548mod tests {
549    use super::*;
550
551    const CID: &str = "EOoC9Auw5kgKLi0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM";
552    const EID: &str = "BADQWh0eolE5bVV6-9RYizxtmdvrly_tEKMlYuom3Nz6";
553
554    #[test]
555    fn parses_controller_oobi() {
556        let url = format!("http://127.0.0.1:5642/oobi/{CID}/controller");
557        let oobi = Oobi::parse(&url).unwrap();
558        assert_eq!(oobi.scheme, "http");
559        assert_eq!(oobi.authority, "127.0.0.1:5642");
560        assert_eq!(oobi.cid.as_str(), CID);
561        assert_eq!(oobi.role, Role::Controller);
562        assert_eq!(oobi.eid, None);
563    }
564
565    #[test]
566    fn parses_witness_oobi_with_eid() {
567        let url = format!("https://witness.example:5631/oobi/{CID}/witness/{EID}");
568        let oobi = Oobi::parse(&url).unwrap();
569        assert_eq!(oobi.scheme, "https");
570        assert_eq!(oobi.role, Role::Witness);
571        assert_eq!(oobi.eid.as_ref().unwrap().as_str(), EID);
572    }
573
574    #[test]
575    fn url_round_trips() {
576        for url in [
577            format!("http://127.0.0.1:5642/oobi/{CID}/controller"),
578            format!("https://w.example:5631/oobi/{CID}/witness/{EID}"),
579            format!("tcp://10.0.0.1:5621/oobi/{CID}/mailbox"),
580        ] {
581            let oobi = Oobi::parse(&url).unwrap();
582            assert_eq!(oobi.url(), url);
583            assert_eq!(Oobi::parse(&oobi.url()).unwrap(), oobi);
584        }
585    }
586
587    #[test]
588    fn drops_query_alias_hint() {
589        let url = format!("http://127.0.0.1:5642/oobi/{CID}/controller?name=alice");
590        let oobi = Oobi::parse(&url).unwrap();
591        assert_eq!(oobi.cid.as_str(), CID);
592        assert_eq!(oobi.role, Role::Controller);
593    }
594
595    #[test]
596    fn rejects_bad_scheme() {
597        let err = Oobi::parse(&format!("ftp://h/oobi/{CID}/controller")).unwrap_err();
598        assert!(matches!(err, OobiError::Scheme(_)));
599    }
600
601    #[test]
602    fn rejects_unknown_role() {
603        let err = Oobi::parse(&format!("http://h:1/oobi/{CID}/overlord")).unwrap_err();
604        assert!(matches!(err, OobiError::Role(_)));
605    }
606
607    #[test]
608    fn rejects_missing_path() {
609        assert!(matches!(
610            Oobi::parse(&format!("http://h:1/oobi/{CID}")).unwrap_err(),
611            OobiError::Url(_)
612        ));
613        assert!(matches!(
614            Oobi::parse("http://h:1").unwrap_err(),
615            OobiError::Url(_)
616        ));
617    }
618
619    #[test]
620    fn rejects_invalid_cid_prefix() {
621        let err = Oobi::parse("http://h:1/oobi/not-a-prefix/controller").unwrap_err();
622        assert!(matches!(err, OobiError::Prefix { segment: "cid", .. }));
623    }
624
625    #[test]
626    fn role_parse_total() {
627        for r in [
628            Role::Controller,
629            Role::Witness,
630            Role::Watcher,
631            Role::Registrar,
632            Role::Judge,
633            Role::Juror,
634            Role::Peer,
635            Role::Mailbox,
636            Role::Agent,
637            Role::Gateway,
638        ] {
639            assert_eq!(Role::parse(r.as_str()).unwrap(), r);
640        }
641        assert!(Role::parse("nope").is_err());
642    }
643
644    // The wire records must be byte-exact with keripy 1.3.4's `Hab.reply`. These
645    // SAIDs/version strings were generated from keripy itself (the oracle):
646    //   serdering.SerderKERI(sad={v, t:"rpy", d:"", dt, r, a}, makify=True)
647    #[test]
648    fn loc_scheme_reply_byte_exact_keripy() {
649        let reply = LocSchemeReply::new(
650            Prefix::new(EID.to_string()).unwrap(),
651            "http",
652            "http://127.0.0.1:5642/",
653            "2024-01-01T00:00:00.000000+00:00",
654        )
655        .unwrap();
656        let json = serde_json::to_string(&reply).unwrap();
657        let expected = r#"{"v":"KERI10JSON0000fa_","t":"rpy","d":"EHrMc5EKCqJHrpCAAlgG6UPaupi-tmlDw8SvspQobfC1","dt":"2024-01-01T00:00:00.000000+00:00","r":"/loc/scheme","a":{"eid":"BADQWh0eolE5bVV6-9RYizxtmdvrly_tEKMlYuom3Nz6","scheme":"http","url":"http://127.0.0.1:5642/"}}"#;
658        assert_eq!(json, expected);
659    }
660
661    #[test]
662    fn end_role_add_reply_byte_exact_keripy() {
663        let reply = EndRoleReply::new(
664            Prefix::new(CID.to_string()).unwrap(),
665            Role::Controller,
666            Prefix::new(EID.to_string()).unwrap(),
667            "2024-01-01T00:00:00.000000+00:00",
668        )
669        .unwrap();
670        let json = serde_json::to_string(&reply).unwrap();
671        let expected = r#"{"v":"KERI10JSON000116_","t":"rpy","d":"EBHnCvYya3Udo4SEGo82HeOPt7WkVDEC0KWfKYnZpupF","dt":"2024-01-01T00:00:00.000000+00:00","r":"/end/role/add","a":{"cid":"EOoC9Auw5kgKLi0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM","role":"controller","eid":"BADQWh0eolE5bVV6-9RYizxtmdvrly_tEKMlYuom3Nz6"}}"#;
672        assert_eq!(json, expected);
673    }
674}