1use 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
43const KERI_VERSION_PLACEHOLDER: &str = "KERI10JSON000000_";
46
47fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub enum Role {
63 Controller,
65 Witness,
67 Watcher,
69 Registrar,
71 Judge,
73 Juror,
75 Peer,
77 Mailbox,
79 Agent,
81 Gateway,
83}
84
85impl Role {
86 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 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#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct Oobi {
139 pub scheme: String,
141 pub authority: String,
143 pub cid: Prefix,
145 pub role: Role,
147 pub eid: Option<Prefix>,
149}
150
151impl Oobi {
152 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct LocSchemeReply {
254 pub v: String,
256 pub d: Said,
258 pub dt: String,
260 pub eid: Prefix,
262 pub scheme: String,
264 pub url: String,
266}
267
268impl LocSchemeReply {
269 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#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct EndRoleReply {
327 pub v: String,
329 pub d: Said,
331 pub dt: String,
333 pub cid: Prefix,
335 pub role: Role,
337 pub eid: Prefix,
339}
340
341impl EndRoleReply {
342 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#[derive(Debug, Clone)]
404pub struct OobiEndpoint {
405 pub oobi: Oobi,
407 pub loc_scheme: LocSchemeReply,
409 pub end_role: EndRoleReply,
411}
412
413impl OobiEndpoint {
414 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 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#[derive(Debug, Clone)]
462pub struct OobiResolution {
463 pub cid: Prefix,
465 pub state: KeyState,
467 pub event_count: usize,
469}
470
471pub 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 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#[derive(Debug, thiserror::Error)]
509pub enum OobiError {
510 #[error("invalid OOBI URL: {0}")]
512 Url(String),
513 #[error("unsupported OOBI scheme: {0:?}")]
515 Scheme(String),
516 #[error("invalid {segment} prefix in OOBI URL: {source}")]
518 Prefix {
519 segment: &'static str,
521 source: crate::types::KeriTypeError,
523 },
524 #[error("unknown OOBI role: {0:?}")]
526 Role(String),
527 #[error("OOBI stream carried no KEL events")]
529 EmptyKel,
530 #[error("OOBI introduced {expected} but delivered a KEL for {actual}")]
532 CidMismatch {
533 expected: String,
535 actual: String,
537 },
538 #[error("KEL replay failed: {0}")]
540 Replay(#[from] ValidationError),
541 #[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 #[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}